diff --git a/eslint.config.mjs b/eslint.config.mjs index 5638cb1..46f4c9a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,47 +9,47 @@ import { FlatCompat } from "@eslint/eslintrc"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all }); export default [{ - ignores: [ - "**/node_modules/", - "**/main.js", - "**/.deprecated/", - "**/esbuild.config.mjs", - "**/eslint.config.mjs" - ], + ignores: [ + "**/node_modules/", + "**/main.js", + "**/.deprecated/", + "**/esbuild.config.mjs", + "**/eslint.config.mjs" + ], }, ...compat.extends( - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended", + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended", ), { - plugins: { - "@typescript-eslint": typescriptEslint, - }, + plugins: { + "@typescript-eslint": typescriptEslint, + }, - languageOptions: { - globals: { - ...globals.node, - }, + languageOptions: { + globals: { + ...globals.node, + }, - parser: tsParser, - ecmaVersion: 6, - sourceType: "module", - }, + parser: tsParser, + ecmaVersion: 6, + sourceType: "module", + }, - rules: { - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { - args: "none", - }], - "@typescript-eslint/ban-ts-comment": "off", - "no-prototype-builtins": "off", - "@typescript-eslint/no-empty-function": "off", - "prefer-const": "off", - "no-cond-assign": "off", - }, + rules: { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { + args: "none", + }], + "@typescript-eslint/ban-ts-comment": "off", + "no-prototype-builtins": "off", + "@typescript-eslint/no-empty-function": "off", + "prefer-const": "off", + "no-cond-assign": "off", + }, }]; \ No newline at end of file diff --git a/main.ts b/main.ts index 0b6f4bf..455fa8b 100644 --- a/main.ts +++ b/main.ts @@ -1,134 +1,88 @@ import { MarkdownView, Plugin, Command } from "obsidian"; -// import { drawSelection } from "@codemirror/view"; -import { PreviewExtendedSyntax } from "src/preview-mode/post-processor"; -import { ColorConfig, PluginSettings, TagConfig } from "src/types"; -import { DEFAULT_SETTINGS } from "src/settings"; -import { ExtendedSettingTab } from "src/settings/interface"; -import { pluginFacet, settingsFacet } from "src/editor-mode/facets"; -import { deepCopy } from "src/utils"; -import { configureDelimLookup, reconfigureDelimLookup, supportTag } from "src/format-configs/utils" -import { appFacet } from "src/editor-mode/facets"; -import { editorExtendedSyntax } from "src/editor-mode/extensions"; -import { refresherAnnot } from "src/editor-mode/annotations"; -import { StyleSheetHandler } from "src/stylesheet-handler"; -import { createColorConfig, convertColorConfigToCSSRule, getDefaultColorConfigs } from "src/color-management"; -import { Format, Theme } from "src/enums"; -import { editorCommands } from "src/editor-mode/commands"; -import { extendEditorCtxMenu } from "src/editor-mode/ui-components/utils"; -import { getTagConfigs } from "src/settings/configs"; +import { PluginSettings } from "src/types"; +import { DEFAULT_SETTINGS } from "src/settings/configs"; +import { ExtendedSettingTab } from "src/settings/ui/setting-tab"; +import { configureDelimLookup } from "src/format-configs/format-utils" +import { StyleSheetHandler } from "src/stylesheet"; +import { Theme } from "src/enums"; +import { editorCommands } from "src/editor-mode/formatting/commands"; +import { extendEditorCtxMenu } from "src/editor-mode/ui-components"; +import { TagManager } from "src/tag-manager"; +import { ReadingModeSyntaxExtender } from "src/preview-mode/post-processor/core"; +import { editorSyntaxExtender, refreshCall } from "src/editor-mode/cm-extension"; export default class ExtendedMarkdownSyntax extends Plugin { - settings: PluginSettings; - colorsHandler: StyleSheetHandler; - opacityHandler: StyleSheetHandler; - async onload() { - await this.loadSettings(); - this.addSettingTab(new ExtendedSettingTab(this.app, this)); - configureDelimLookup(this.settings); - this.registerEditorExtension([ - appFacet.of(this.app), - pluginFacet.of(this), - settingsFacet.of(this.settings), - editorExtendedSyntax - ]); - this.registerMarkdownPostProcessor(new PreviewExtendedSyntax(this.settings).postProcess); - this.colorsHandler = new StyleSheetHandler(this); - this.opacityHandler = new StyleSheetHandler(this); - this.buildColorsStyleSheet(); - this.opacityHandler.insert(`body.theme-light{--hl-opacity:${this.settings.lightModeHlOpacity}}`); - this.opacityHandler.insert(`body.theme-dark{--hl-opacity:${this.settings.darkModeHlOpacity}}`); - this.registerCommands(editorCommands); - this.extendEditorCtxMenu(); - console.log("Load Extended Markdown Syntax"); - } - async loadSettings() { - this.settings = Object.assign({}, deepCopy(DEFAULT_SETTINGS), await this.loadData()); - } - async saveSettings() { - await this.saveData(this.settings); - } - onunload(): void { - this.colorsHandler.destroy(); - this.opacityHandler.destroy(); - console.log("Unload Extended Markdown Syntax"); - } - reconfigureDelimLookup() { - reconfigureDelimLookup(this.settings); - } - /** Get the settings change effect without reload the whole app. */ - refreshMarkdownView() { - this.app.workspace.iterateAllLeaves(leaf => { - let view = leaf.view; - if (view instanceof MarkdownView) { - let cmView = view.editor.cm; - cmView.dispatch({ - annotations: refresherAnnot.of(true), - }); - view.previewMode.rerender(true); - } - }); - } - buildColorsStyleSheet(callback?: (colorConfig: ColorConfig, colorConfigs: ColorConfig[]) => unknown) { - let colorConfigs = this.settings.colorConfigs; - for (let i = 0; i < colorConfigs.length; i++) { - let ruleStr = convertColorConfigToCSSRule(colorConfigs[i]); - this.colorsHandler.insert(ruleStr); - if (callback) { callback(colorConfigs[i], colorConfigs) } - } - } - setHlOpacity(value: number, theme: Theme) { - let selector = theme == Theme.LIGHT - ? "body.theme-light" - : "body.theme-dark", - property = "--hl-opacity", - // The index of light mode opacity is 0, and dark mode opacity is 1. - ruleIndex = theme; - this.opacityHandler.replace(`${selector}{${property}:${value}}`, ruleIndex); - } - rebuildColorsStyleSheet(callback?: (colorConfig: ColorConfig, colorConfigs: ColorConfig[]) => unknown) { - this.colorsHandler.removeAll(); - this.buildColorsStyleSheet(callback); - } - addNewColor() { - let index = this.settings.colorConfigs.length, - newConfig = createColorConfig("color-" + index, "#ffd000", "Color " + index), - ruleStr = convertColorConfigToCSSRule(newConfig); - this.settings.colorConfigs.push(newConfig); - this.colorsHandler.insert(ruleStr, index); - } - addTagConfig(type: Format) { - if (!supportTag(type)) { return } - if (type == Format.HIGHLIGHT) { this.addNewColor() } - else { - let configs = getTagConfigs(this.settings, type), - index = configs.length; - configs.push({ - name: "Tag " + index, - tag: "tag-" + index, - showInMenu: true - }); - } - } - revertColorConfigs(callback?: (colorConfig: ColorConfig, colorConfigs: ColorConfig[]) => unknown) { - let configs = this.settings.colorConfigs; - configs.splice(0, configs.length, ...getDefaultColorConfigs()); - this.rebuildColorsStyleSheet(callback); - } - revertTagConfigs(type: Format, callback?: (config: TagConfig, configs: TagConfig[]) => unknown) { - if (!supportTag(type)) { return } - if (type == Format.HIGHLIGHT) { - this.revertColorConfigs(callback); - } else { - let configs = getTagConfigs(this.settings, type); - configs.splice(0); - } - } - registerCommands(commands: Command[]) { - commands.forEach(cmd => this.addCommand(cmd)); - } - extendEditorCtxMenu() { - this.registerEvent(this.app.workspace.on("editor-menu", (menu, editor, ctx) => { - extendEditorCtxMenu(menu, editor, ctx); - })); - } + settings: PluginSettings; + opacityHandler: StyleSheetHandler; + tagManager: TagManager; + + async onload() { + await this.loadSettings(); + this.addSettingTab(new ExtendedSettingTab(this.app, this)); + + configureDelimLookup(this.settings); + this.registerEditorExtension(editorSyntaxExtender(this)); + this.registerMarkdownPostProcessor(new ReadingModeSyntaxExtender(this.settings).postProcess); + + this.tagManager = new TagManager(this); + this.opacityHandler = new StyleSheetHandler(this); + this.opacityHandler.insert(`body.theme-light{--hl-opacity:${this.settings.lightModeHlOpacity}}`); + this.opacityHandler.insert(`body.theme-dark{--hl-opacity:${this.settings.darkModeHlOpacity}}`); + + this.registerCommands(editorCommands); + this.extendEditorCtxMenu(); + + console.log("Load Extended Markdown Syntax"); + } + + async loadSettings() { + this.settings = Object.assign({}, structuredClone(DEFAULT_SETTINGS), await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + onunload(): void { + this.tagManager.colorsHandler.destroy(); + this.opacityHandler.destroy(); + console.log("Unload Extended Markdown Syntax"); + } + + reconfigureDelimLookup() { + configureDelimLookup(this.settings); + } + + /** Get the settings change effect without reload the whole app. */ + refreshMarkdownView(deep = true) { + this.app.workspace.getLeavesOfType("markdown").forEach(leaf => { + if (leaf.view instanceof MarkdownView) { + let cmView = leaf.view.editor.cm; + cmView.dispatch({ + annotations: refreshCall.of({ deep }), + }); + leaf.view.previewMode.rerender(true); + } + }); + } + + setHlOpacity(value: number, theme: Theme) { + let selector = theme == Theme.LIGHT + ? "body.theme-light" + : "body.theme-dark", + property = "--hl-opacity", + // The index of light mode opacity is 0, and dark mode opacity is 1. + ruleIndex = theme; + this.opacityHandler.replace(`${selector}{${property}:${value}}`, ruleIndex); + } + + registerCommands(commands: Command[]) { + commands.forEach(cmd => this.addCommand(cmd)); + } + + extendEditorCtxMenu() { + this.registerEvent(this.app.workspace.on("editor-menu", (menu, editor, ctx) => { + extendEditorCtxMenu(menu, editor, ctx); + })); + } } \ No newline at end of file diff --git a/src/color-management/PREDEFINED_COLOR_TAGS.ts b/src/color-management/PREDEFINED_COLOR_TAGS.ts deleted file mode 100644 index f0bf29f..0000000 --- a/src/color-management/PREDEFINED_COLOR_TAGS.ts +++ /dev/null @@ -1 +0,0 @@ -export const PREDEFINED_COLOR_TAGS = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink"] as const; \ No newline at end of file diff --git a/src/color-management/convertColorConfigToCSSRule.ts b/src/color-management/convertColorConfigToCSSRule.ts deleted file mode 100644 index f26adde..0000000 --- a/src/color-management/convertColorConfigToCSSRule.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ColorConfig } from "src/types"; - -export function convertColorConfigToCSSRule(config: ColorConfig) { - let selector = `.cm-custom-highlight-${config.tag}, .markdown-rendered mark.custom-highlight-${config.tag}, .ems-menu-item.ems-highlight-${config.tag}>.menu-item-title`, - prop = "background-color", - color = config.color; - return `${selector}{${prop}:color(from ${color} srgb r g b/var(--hl-opacity));}` -} \ No newline at end of file diff --git a/src/color-management/createColorConfig.ts b/src/color-management/createColorConfig.ts deleted file mode 100644 index d6ff834..0000000 --- a/src/color-management/createColorConfig.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ColorConfig } from "src/types"; - -export function createColorConfig(tag: string, color: string, name: string, showInMenu = true): ColorConfig { - return { tag, color, name, showInMenu } -} \ No newline at end of file diff --git a/src/color-management/getDefaultColorConfigs.ts b/src/color-management/getDefaultColorConfigs.ts deleted file mode 100644 index c735fb0..0000000 --- a/src/color-management/getDefaultColorConfigs.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ColorConfig } from "src/types"; -import { takeBuiltinColor, PREDEFINED_COLOR_TAGS } from "src/color-management"; - -export function getDefaultColorConfigs() { - let configs: ColorConfig[] = []; - for (let i = 0; i < PREDEFINED_COLOR_TAGS.length; i++) { - let tag = PREDEFINED_COLOR_TAGS[i], - name = tag[0].toUpperCase() + tag.slice(1), - color = takeBuiltinColor(tag) ?? "#ffffff"; - configs.push({ tag, name, color, showInMenu: true }); - } - return configs; -} \ No newline at end of file diff --git a/src/color-management/index.ts b/src/color-management/index.ts deleted file mode 100644 index 44f82a2..0000000 --- a/src/color-management/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./convertColorConfigToCSSRule"; -export * from "./takeBuiltinColor"; -export * from "./getDefaultColorConfigs"; -export * from "./createColorConfig"; -export * from "./PREDEFINED_COLOR_TAGS"; \ No newline at end of file diff --git a/src/color-management/takeBuiltinColor.ts b/src/color-management/takeBuiltinColor.ts deleted file mode 100644 index 7d740d3..0000000 --- a/src/color-management/takeBuiltinColor.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function takeBuiltinColor(color: string) { - return document.body.computedStyleMap().get(`--color-${color}`)?.toString(); -} \ No newline at end of file diff --git a/src/editor-mode/annotations/index.ts b/src/editor-mode/annotations/index.ts deleted file mode 100644 index 886eef4..0000000 --- a/src/editor-mode/annotations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./refresherAnnot"; \ No newline at end of file diff --git a/src/editor-mode/annotations/refresherAnnot.ts b/src/editor-mode/annotations/refresherAnnot.ts deleted file mode 100644 index cdee880..0000000 --- a/src/editor-mode/annotations/refresherAnnot.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Annotation } from "@codemirror/state"; - -export let refresherAnnot = Annotation.define(); \ No newline at end of file diff --git a/src/editor-mode/cm-extension.ts b/src/editor-mode/cm-extension.ts new file mode 100644 index 0000000..45f7b45 --- /dev/null +++ b/src/editor-mode/cm-extension.ts @@ -0,0 +1,237 @@ +import ExtendedMarkdownSyntax from "main"; +import { editorLivePreviewField } from "obsidian"; +import { Annotation, EditorState, Extension, Facet, Prec, StateEffect, StateField, Transaction } from "@codemirror/state"; +import { EditorView, PluginValue, ViewPlugin, ViewUpdate } from "@codemirror/view"; +import { ParseContext, syntaxTree } from "@codemirror/language"; +import { Tree } from "@lezer/common"; +import { IndexCache } from "src/types"; +import { EditorParser } from "src/editor-mode/preprocessor/parser"; +import { SelectionObserver } from "src/editor-mode/preprocessor/observer"; +import { DecorationBuilder } from "src/editor-mode/decorator/builder"; +import { Formatter } from "src/editor-mode/formatting/formatter"; + +type TagMenuOptionCaches = Record<"colorMenuItem" | "spanTagMenuItem" | "divTagMenuItem", IndexCache>; + +interface BuiltinParserState { + context: ParseContext, + tree: Tree +} + +interface RefreshDesc { + deep?: boolean; +} + +interface InternalInstances { + mainPlugin: ExtendedMarkdownSyntax; + parser: EditorParser; + observer: SelectionObserver; + builder: DecorationBuilder; + formatter: Formatter; + activities: ActivityRecord; +} + +export interface ActivityRecord { + isParsing?: boolean; + isObserving?: boolean; + isSelectionMoved?: boolean; + isRefreshed?: boolean; + isDeepRefreshed?: boolean; + isModeChanged?: boolean; +} + +function _isBuiltinParserStateEffect(effect: StateEffect): effect is StateEffect { + let effectVal = effect; + return ( + "context" in effectVal && effectVal.context instanceof ParseContext && + "tree" in effectVal && effectVal.tree instanceof Tree + ); +} + +function _isSelectionMoved(transaction: Transaction): boolean { + return !( + transaction.selection && + transaction.startState.selection.eq(transaction.selection) + ); +} + +function _ensureMostUpdatedTree(transaction: Transaction): Tree { + let currentTree = syntaxTree(transaction.state), + mostUpdatedTree = transaction.effects.reduce( + (tree: Tree, effect: StateEffect) => { + if (_isBuiltinParserStateEffect(effect)) { + tree = effect.value.tree; + } + return tree; + }, + currentTree + ); + return mostUpdatedTree; +} + +function _isEditorModeChanged(transaction: Transaction): boolean { + let isLivePreviewCurrently = transaction.state.field(editorLivePreviewField), + isLivePreviewPreviously = transaction.startState.field(editorLivePreviewField); + return isLivePreviewCurrently != isLivePreviewPreviously; +} + +class _EditorPlugin implements PluginValue { + public readonly builder: DecorationBuilder; + public readonly mainPlugin: ExtendedMarkdownSyntax; + + public root: DocumentOrShadowRoot; + public activities: ActivityRecord; + + constructor( + plugin: ExtendedMarkdownSyntax, + view: EditorView, + builder: DecorationBuilder, + activities: ActivityRecord + ) { + this.mainPlugin = plugin; + this.root = view.root; + this.builder = builder; + this.activities = activities; + this.builder.onViewInit(view); + } + + public update(update: ViewUpdate) { + if (this.root !== update.view.root) { + this._onRootChanged(update.view.root); + } + this.builder.onViewUpdate(update, this.activities); + } + + public destroy(): void { + this.mainPlugin.tagManager.colorsHandler.eject(this.root); + this.mainPlugin.opacityHandler.eject(this.root); + } + + private _onRootChanged(newRoot: DocumentOrShadowRoot): void { + this.mainPlugin.tagManager.colorsHandler.eject(this.root); + this.mainPlugin.opacityHandler.eject(this.root); + this.mainPlugin.tagManager.colorsHandler.inject(newRoot); + this.mainPlugin.opacityHandler.inject(newRoot); + this.root = newRoot; + } +} + +export const refreshCall = Annotation.define(); + +export const instancesStore = Facet.define({ + combine(value) { + if (!value.length) return undefined as unknown as InternalInstances; + let mainPlugin = value[0], + parser = new EditorParser(mainPlugin.settings), + observer = new SelectionObserver(parser), + builder = new DecorationBuilder(parser, observer), + formatter = new Formatter(parser, observer); + return { mainPlugin, parser, observer, builder, formatter, activities: {} } + }, + get static() { + return true; + } +}); + +export const tagMenuOptionCaches = Facet.define({ + combine(value) { + return value[0]; + }, + get static() { + return true; + } +}); + +export const editorSyntaxExtender = (plugin: ExtendedMarkdownSyntax): Extension => { + let preprocessField = StateField.define({ + create(state: EditorState) { + let { parser, observer, builder, activities } = state.facet(instancesStore); + parser.initParse(state.doc, syntaxTree(state)); + observer.observe(state.selection, true); + builder.onStateInit(state); + return { + parser, observer, builder, activities, + lineBreaksSet: builder.holder.lineBreaksSet, + blockOmittedSet: builder.holder.blockOmittedSet + } + }, + + update(prevField, transaction: Transaction) { + let newTree = _ensureMostUpdatedTree(transaction), + oldTree = syntaxTree(transaction.startState), + refreshDesc = transaction.annotation(refreshCall), + { parser, observer, builder, activities } = prevField; + + let isParsing = false, + isObserving = false, + isRefreshed = !!refreshDesc, + isDeepRefreshed = !!refreshDesc?.deep, + isModeChanged = _isEditorModeChanged(transaction); + + if (isDeepRefreshed) { + parser.initParse(transaction.newDoc, newTree); + isParsing = true; + } else if (transaction.docChanged || oldTree.length != newTree.length) { + parser.applyChange(transaction.newDoc, newTree, oldTree, transaction.changes); + isParsing = true; + } + + if (isDeepRefreshed || isModeChanged) { + observer.restartObserver(transaction.newSelection, transaction.docChanged); + isObserving = true; + } else if (isParsing || _isSelectionMoved(transaction)) { + observer.observe(transaction.newSelection, activities.isParsing ?? false); + isObserving = true; + } + + builder.onStateUpdate(transaction, { isParsing, isObserving, isDeepRefreshed, isModeChanged }); + + activities.isParsing ||= isParsing; + activities.isObserving ||= isObserving; + activities.isRefreshed ||= isRefreshed; + activities.isModeChanged ||= isModeChanged; + + if ( + prevField.lineBreaksSet === builder.holder.lineBreaksSet && + prevField.blockOmittedSet === builder.holder.blockOmittedSet + ) return prevField; + + return { + parser, observer, builder, activities, + lineBreaksSet: builder.holder.lineBreaksSet, + blockOmittedSet: builder.holder.blockOmittedSet + }; + }, + + provide(field): Extension { + return [ + EditorView.decorations.from(field, sets => sets.blockOmittedSet), + Prec.lowest(EditorView.decorations.from(field, sets => sets.lineBreaksSet)) + ] + } + }); + + let viewPlugin = ViewPlugin.define( + view => { + let { builder, activities } = view.state.facet(instancesStore); + return new _EditorPlugin(plugin, view, builder, activities); + }, + { + provide: () => { + return [ + tagMenuOptionCaches.of({ + colorMenuItem: { number: 0 }, + spanTagMenuItem: { number: 0 }, + divTagMenuItem: { number: 0 }, + }), + EditorView.decorations.of(view => view.state.facet(instancesStore).builder.holder.blockSet), + EditorView.decorations.of(view => view.state.facet(instancesStore).builder.holder.inlineOmittedSet), + EditorView.decorations.of(view => view.state.facet(instancesStore).builder.holder.colorBtnSet), + EditorView.decorations.of(view => view.state.facet(instancesStore).builder.holder.revealedSpoilerSet), + EditorView.outerDecorations.of(view => view.state.facet(instancesStore).builder.holder.inlineSet), + ]; + } + } + ); + + return [instancesStore.of(plugin), preprocessField, viewPlugin]; +} \ No newline at end of file diff --git a/src/editor-mode/commands/index.ts b/src/editor-mode/commands/index.ts deleted file mode 100644 index 86f9510..0000000 --- a/src/editor-mode/commands/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { colorMenuCmd, customHighlightCmd, customSpanCmd, divTagMenuCmd, fencedDivCmd, insertionCmd, spanTagMenuCmd, spoilerCmd, subscriptCmd, superscriptCmd } from "src/editor-mode/commands/palettes"; - -export const editorCommands = [ - insertionCmd, - spoilerCmd, - superscriptCmd, - subscriptCmd, - customHighlightCmd, - customSpanCmd, - fencedDivCmd, - colorMenuCmd, - spanTagMenuCmd, - divTagMenuCmd -]; - -export const ctxMenuCommands = [ - insertionCmd, - spoilerCmd, - superscriptCmd, - subscriptCmd, - customHighlightCmd, - customSpanCmd -]; \ No newline at end of file diff --git a/src/editor-mode/commands/palettes/index.ts b/src/editor-mode/commands/palettes/index.ts deleted file mode 100644 index 8fc2318..0000000 --- a/src/editor-mode/commands/palettes/index.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { EditorView } from "codemirror"; -import { Format } from "src/enums"; -import { CtxMenuCommand } from "src/types"; -import { Command } from "obsidian"; -import { editorPlugin } from "src/editor-mode/extensions"; -import { TagMenu } from "src/editor-mode/ui-components"; - -export const insertionCmd: CtxMenuCommand = { - id: "toggle-insertion", - name: "Toggle insertion", - icon: "underline", - ctxMenuTitle: "Insertion", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - let formatter = editorView.plugin(editorPlugin)!.formatter; - formatter.startFormat(Format.INSERTION); - } - }, - type: Format.INSERTION -} - -export const spoilerCmd: CtxMenuCommand = { - id: "toggle-spoiler", - name: "Toggle spoiler", - icon: "rectangle-horizontal", - ctxMenuTitle: "Spoiler", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - let formatter = editorView.plugin(editorPlugin)!.formatter; - formatter.startFormat(Format.SPOILER); - } - }, - type: Format.SPOILER -} - -export const superscriptCmd: CtxMenuCommand = { - id: "toggle-superscript", - name: "Toggle superscript", - icon: "superscript", - ctxMenuTitle: "Superscript", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - let formatter = editorView.plugin(editorPlugin)!.formatter; - formatter.startFormat(Format.SUPERSCRIPT); - } - }, - type: Format.SUPERSCRIPT -} - -export const subscriptCmd: CtxMenuCommand = { - id: "toggle-subscript", - name: "Toggle subscript", - icon: "subscript", - ctxMenuTitle: "Subscript", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - let formatter = editorView.plugin(editorPlugin)!.formatter; - formatter.startFormat(Format.SUBSCRIPT); - } - }, - type: Format.SUBSCRIPT -} - -export const customHighlightCmd: CtxMenuCommand = { - id: "toggle-custom-highlight", - name: "Toggle custom highlight", - icon: "highlighter", - ctxMenuTitle: "Custom highlight", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - let formatter = editorView.plugin(editorPlugin)!.formatter; - formatter.startFormat(Format.HIGHLIGHT, undefined, false, true); - } - }, - type: Format.HIGHLIGHT -} - -export const customSpanCmd: CtxMenuCommand = { - id: "toggle-custom-span", - name: "Toggle custom span", - icon: "brush", - ctxMenuTitle: "Custom span", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - let formatter = editorView.plugin(editorPlugin)!.formatter; - formatter.startFormat(Format.CUSTOM_SPAN, undefined, false, true); - } - }, - type: Format.CUSTOM_SPAN -} - -export const fencedDivCmd: Command = { - id: "toggle-fenced-div", - name: "Toggle fenced div", - icon: "list-plus", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - let formatter = editorView.plugin(editorPlugin)!.formatter; - formatter.startFormat(Format.FENCED_DIV, undefined, false, true); - } - }, -} - -export const colorMenuCmd: Command = { - id: "show-color-menu", - name: "Show highlight color menu", - icon: "palette", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - TagMenu.create(editorView, Format.HIGHLIGHT).showMenu(); - } - } -} - -export const spanTagMenuCmd: Command = { - id: "show-custom-span-tag-menu", - name: "Show custom span tag menu", - icon: "shapes", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - TagMenu.create(editorView, Format.CUSTOM_SPAN).showMenu(); - } - } -} - -export const divTagMenuCmd: Command = { - id: "show-fenced-div-tag-menu", - name: "Show fenced div tag menu", - icon: "shapes", - editorCallback: (editor, ctx) => { - let editorView = (editor.activeCm || editor.cm); - if (editorView instanceof EditorView) { - TagMenu.create(editorView, Format.FENCED_DIV).showMenu(); - } - } -} \ No newline at end of file diff --git a/src/editor-mode/decorator/builder.ts b/src/editor-mode/decorator/builder.ts new file mode 100644 index 0000000..0da43cd --- /dev/null +++ b/src/editor-mode/decorator/builder.ts @@ -0,0 +1,433 @@ +import { ChangeDesc, ChangeSet, EditorState, Range, RangeSet, RangeValue, Text, Transaction } from "@codemirror/state"; +import { Decoration, DecorationSet, EditorView, ViewUpdate } from "@codemirror/view"; +import { editorLivePreviewField } from "obsidian"; +import { DisplayBehaviour, Format, MarkdownViewMode, TokenLevel, TokenStatus } from "src/enums"; +import { BlockFormat, PlainRange, InlineFormat, TokenGroup, TokenDecoration, PluginSettings, Token } from "src/types"; +import { BlockRules, InlineRules } from "src/format-configs/rules"; +import { trimTag } from "src/format-configs/format-utils"; +import { EditorParser } from "src/editor-mode/preprocessor/parser"; +import { SelectionObserver } from "src/editor-mode/preprocessor/observer"; +import { ActivityRecord } from "src/editor-mode/cm-extension"; +import { LineBreak, HiddenWidget, ColorButton } from "src/editor-mode/decorator/widgets"; +import { REVEALED_SPOILER_DECO } from "src/editor-mode/decorator/decorations"; +import { getTagRange, iterTokenGroup, provideTokenPartsRanges } from "src/editor-mode/utils/token-utils" +import { TextCursor } from "../utils/doc-utils"; + +interface RangeSetUpdate { + add?: readonly Range[]; + sort?: boolean; + filter?: (from: number, to: number, value: T) => boolean; + filterFrom?: number; + filterTo?: number; +} + +function _createInlineDecoRange(token: Token, cls: string) { + return (Decoration + .mark({ class: cls, token }) as TokenDecoration) + .range(token.from, token.to); +} + +class _DecorationHolder { + public inlineSet: DecorationSet = RangeSet.empty; + public blockSet: DecorationSet = RangeSet.empty; + public inlineOmittedSet: DecorationSet = RangeSet.empty; + public blockOmittedSet: DecorationSet = RangeSet.empty; + public colorBtnSet: DecorationSet = RangeSet.empty; + public revealedSpoilerSet: DecorationSet = RangeSet.empty; + public lineBreaksSet: DecorationSet = RangeSet.empty; +} + +class _DelimOmitter { + private readonly _selectionObserver: SelectionObserver; + private readonly _settings: PluginSettings; + + constructor(settings: PluginSettings, selectionObserver: SelectionObserver) { + this._settings = settings; + this._selectionObserver = selectionObserver; + } + + public omitBlock(omittedSet?: DecorationSet, changes?: ChangeDesc): DecorationSet { + let omittedRanges: Range[] = [], + filterRegion = this._selectionObserver.filterRegions[TokenLevel.BLOCK]; + + this._selectionObserver.iterateChangedRegion(TokenLevel.BLOCK, (token, _, __, inSelection) => { + if (inSelection || token.status != TokenStatus.ACTIVE || !token.validTag) return; + let openFrom = token.from, + openTo = openFrom + token.openLen + token.tagLen; + if (token.to > openTo) openTo++; + omittedRanges.push(HiddenWidget.of(openFrom, openTo, token, true)); + }); + + if (!omittedSet?.size) { + omittedSet = Decoration.set(omittedRanges); + } else { + if (changes) + omittedSet = omittedSet.map(changes); + + for (let i = 0; i < filterRegion.length; i++) { + let filterRange = filterRegion[i]; + omittedSet = omittedSet.update({ + filterFrom: filterRange.from, + filterTo: filterRange.to, + filter: () => false, + }); + } + omittedSet = omittedSet.update({ add: omittedRanges }); + } + + return omittedSet; + } + + public omitInline(activeTokens: TokenGroup): DecorationSet { + let alwaysShowHlTag = this._settings.hlTagDisplayBehaviour & DisplayBehaviour.ALWAYS, + alwaysShowSpanTag = this._settings.spanTagDisplayBehaviour & DisplayBehaviour.ALWAYS, + showHlTagIfTouched = this._settings.hlTagDisplayBehaviour & DisplayBehaviour.TAG_TOUCHED, + showSpanTagIfTouched = this._settings.spanTagDisplayBehaviour & DisplayBehaviour.TAG_TOUCHED; + + let omittedRanges: Range[] = []; + + for (let i = 0; i < activeTokens.length; i++) { + let token = activeTokens[i], + openFrom = token.from, + openTo = openFrom + token.openLen, + tagTo = openTo + token.tagLen; + if (this._selectionObserver.touchSelection(token.from, token.to)) { + if ( + token.validTag && !this._selectionObserver.touchSelection(openTo, tagTo) && + (token.type == Format.HIGHLIGHT && showHlTagIfTouched || token.type == Format.CUSTOM_SPAN && showSpanTagIfTouched) + ) { + omittedRanges.push(HiddenWidget.of(openTo, tagTo, token)); + } + } else { + if (token.type == Format.HIGHLIGHT && !alwaysShowHlTag || token.type == Format.CUSTOM_SPAN && !alwaysShowSpanTag) { + openTo = tagTo; + } + omittedRanges.push(HiddenWidget.of(openFrom, openTo, token)); + if (token.closeLen) { + omittedRanges.push(HiddenWidget.of(token.to - token.closeLen, token.to, token)); + } + } + } + + return Decoration.set(omittedRanges, true); + } +} + +class _LineBreakReplacer { + public readonly parser: EditorParser; + public lineBreakSet: RangeSet = RangeSet.empty; + + constructor(parser: EditorParser) { + this.parser = parser; + } + + public replace(doc: Text, changes?: ChangeSet): DecorationSet { + let reparsedRange = this.parser.reparsedRanges[TokenLevel.BLOCK], + blockTokens = this.parser.blockTokens, + updateSpec: RangeSetUpdate = { + add: this._produceWidgetRanges(doc) + }; + + if (!blockTokens.length) + return this.lineBreakSet = RangeSet.empty; + + if (reparsedRange.from != reparsedRange.initTo || reparsedRange.from != reparsedRange.changedTo) { + updateSpec.filterFrom = Math.min(blockTokens[reparsedRange.from]?.from ?? this.parser.lastStreamPoint.from, this.parser.lastStreamPoint.from); + updateSpec.filterTo = this.parser.lastStreamPoint.to; + updateSpec.filter = () => false; + } + + if (changes) this.lineBreakSet = this.lineBreakSet.map(changes); + return this.lineBreakSet = this.lineBreakSet.update(updateSpec); + } + + private _getReparsedBlockTokens(): TokenGroup { + let range = this.parser.reparsedRanges[TokenLevel.BLOCK]; + return this.parser.blockTokens.slice(range.from, range.changedTo); + } + + private _produceWidgetRanges(doc: Text): Range[] { + let tokens = this._getReparsedBlockTokens(), + ranges: Range[] = []; + if (!tokens.length) { return ranges } + + let tokenIndex = 0, + textCursor = TextCursor.atOffset(doc, tokens[0].from); + + do { + let curToken = tokens[tokenIndex], + { curLine } = textCursor; + if (!curToken) break; + if (curToken.status != TokenStatus.ACTIVE) { tokenIndex++; continue } + if ( + curLine.from == curToken.from || + curLine.from - 1 == curToken.from + curToken.openLen + curToken.tagLen + ) continue; + if ( + curLine.from >= curToken.to || + curLine.to < curToken.from || + curLine.to == curToken.to && curToken.closedByBlankLine + ) { tokenIndex++; continue } + ranges.push(LineBreak.of(curLine.from)); + } while (textCursor.next()); + + return ranges; + } +} + +class _TokensBuffer { + public activeTokens: TokenGroup = []; + public hlTokens: TokenGroup = []; + public spoilerTokens: TokenGroup = []; + + public catch(activeTokens: TokenGroup, hlTokens: TokenGroup, spoilerTokens: TokenGroup): void { + this.activeTokens = activeTokens; + this.hlTokens = hlTokens; + this.spoilerTokens = spoilerTokens; + } + + public empty(): void { + this.activeTokens = []; + this.hlTokens = []; + this.spoilerTokens = []; + } +} + +export class DecorationBuilder { + private readonly _parser: EditorParser; + private readonly _omitter: _DelimOmitter; + private readonly _catcher: _TokensBuffer; + private readonly _lineBreakReplacer: _LineBreakReplacer; + private readonly _selectionObserver: SelectionObserver; + private readonly _settings: PluginSettings; + private readonly _indexCaches = { + inlineToken: { number: 0 }, // 0-based + blockToken: { number: 0 } // 0-based + } + + public readonly holder: _DecorationHolder; + + constructor(parser: EditorParser, selectionObserver: SelectionObserver) { + this._parser = parser; + this._selectionObserver = selectionObserver; + this._settings = parser.settings; + this._omitter = new _DelimOmitter(this._settings, selectionObserver); + this._catcher = new _TokensBuffer(); + this._lineBreakReplacer = new _LineBreakReplacer(parser); + this.holder = new _DecorationHolder(); + } + + /** + * Main decorations hold basic formatting style of the tokens. + * + * Intended to build non-height-altering decorations. So, it doesn't + * include line breaks and fenced div opening omitter. (It runs only in + * the view update) + */ + public buildMain(view: EditorView, state: EditorState, noStyledFencedDiv: boolean): void { + let { visibleRanges } = view, + visibleText = state.sliceDoc( + visibleRanges[0]?.from ?? 0, + visibleRanges[visibleRanges.length - 1]?.to ?? 0 + ); + if (!visibleRanges.length) visibleRanges = [{ from: 0, to: 0 }]; + this._buildInline(visibleRanges, visibleText); + this._buildBlock(visibleRanges, visibleText, noStyledFencedDiv); + } + + /** + * Supplementary decorations consist omitted delimiter of inline tokens, + * color buttons for the highlight, and revealed spoiler when touched the + * cursor or selection. + * + * Intended to build non-height-altering decorations. So, it doesn't + * include line breaks and fenced div opening omitter. (It runs only in + * the view update) + */ + public buildSupplementary(isLivePreview: boolean): void { + if (isLivePreview) { + this._omitInlineDelim(this._catcher.activeTokens); + this._createColorBtnWidgets(this._catcher.hlTokens); + this._revealSpoiler(this._catcher.spoilerTokens); + } else { + this.holder.colorBtnSet = this.holder.revealedSpoilerSet = RangeSet.empty; + } + } + + /** + * Runs once on editor intialization, should be inside the view update + * (i.e. the ViewPlugin update). + */ + public onViewInit(view: EditorView): void { + let state = view.state, + isLivePreview = state.field(editorLivePreviewField), + noStyledFencedDiv = !isLivePreview && this._settings.noStyledDivInSourceMode; + this.buildMain(view, state, noStyledFencedDiv); + this.buildSupplementary(isLivePreview); + } + + public onViewUpdate(update: ViewUpdate, activities: ActivityRecord): void { + let state = update.state, + view = update.view, + isLivePreview = state.field(editorLivePreviewField), + noStyledFencedDiv = !isLivePreview && this._settings.noStyledDivInSourceMode; + + if (activities.isParsing || activities.isRefreshed || (activities.isModeChanged && this._settings.noStyledDivInSourceMode) || update.viewportMoved) { + this.buildMain(view, state, noStyledFencedDiv); + } + + if (activities.isObserving || activities.isRefreshed || update.viewportMoved) { + this.buildSupplementary(isLivePreview); + } + + activities.isParsing = + activities.isObserving = + activities.isRefreshed = + activities.isModeChanged = false; + } + + /** + * Runs once on editor intialization, should be inside the state update + * (i.e. the StateField update). + */ + public onStateInit(state: EditorState): void { + let isLivePreview = state.field(editorLivePreviewField); + if (isLivePreview) { + this._omitFencedDivOpening(); + } + this._replaceLineBreaks(state.doc); + } + + public onStateUpdate(transaction: Transaction, activities: ActivityRecord): void { + if (activities.isParsing) + this._replaceLineBreaks(transaction.newDoc, transaction.changes); + + if (activities.isModeChanged || activities.isDeepRefreshed) + this.holder.blockOmittedSet = RangeSet.empty; + + if (!transaction.state.field(editorLivePreviewField)) { + this._removeOmitter(); + } else if (activities.isObserving || activities.isModeChanged) { + this._omitFencedDivOpening(transaction.changes); + } + } + + private _buildInline(visibleRanges: readonly PlainRange[], visibleText: string): DecorationSet { + let inlineDecoRanges: Range[] = [], + activeTokens: TokenGroup = [], + hlTokens: TokenGroup = [], + spoilerTokens: TokenGroup = [], + viewportStart = visibleRanges[0].from; + + iterTokenGroup({ + tokens: this._parser.inlineTokens, + ranges: visibleRanges, + indexCache: this._indexCaches.inlineToken, + callback: token => { + if (token.status != TokenStatus.ACTIVE) return; + if (token.type == Format.HIGHLIGHT) hlTokens.push(token); + if (token.type == Format.SPOILER) spoilerTokens.push(token); + activeTokens.push(token); + let cls = "cm-" + InlineRules[token.type as InlineFormat].class; + if (token.tagLen) { + let tagRange = getTagRange(token), + tagStr = visibleText.slice(tagRange.from - viewportStart + 1, tagRange.to - viewportStart - 1); + if (token.type == Format.CUSTOM_SPAN) { + tagStr = trimTag(tagStr); + cls += " " + tagStr; + } else { + cls += " " + cls + "-" + tagStr; + } + } + inlineDecoRanges.push(_createInlineDecoRange(token, cls)); + }, + }); + + this._catcher.catch(activeTokens, hlTokens, spoilerTokens); + return this.holder.inlineSet = Decoration.set(inlineDecoRanges); + } + + private _buildBlock(visibleRanges: readonly PlainRange[], visibleText: string, noStyle: boolean): DecorationSet { + let lineDecoRanges: Range[] = [], + viewportStart = visibleRanges[0].from; + + iterTokenGroup({ + tokens: this._parser.blockTokens, + ranges: visibleRanges, + indexCache: this._indexCaches.blockToken, + callback: token => { + if (token.status != TokenStatus.ACTIVE) return; + + let baseCls = "cm-" + BlockRules[token.type as BlockFormat].class, + { contentRange, tagRange } = provideTokenPartsRanges(token), + tagStr = trimTag(visibleText.slice(tagRange.from - viewportStart, tagRange.to - viewportStart)), + openDelimCls = baseCls + " cm-fenced-div-start", + contentCls = baseCls + (noStyle ? "" : " " + tagStr), + hasContent = !!visibleText + .slice(contentRange.from - viewportStart, contentRange.to - viewportStart) + .trimEnd(); + + lineDecoRanges.push( + (Decoration.line({ class: openDelimCls, token }) as TokenDecoration) + .range(token.from) + ); + + if (hasContent) { + lineDecoRanges.push( + (Decoration.line({ class: contentCls, token }) as TokenDecoration) + .range(contentRange.from + 1) + ); + } + } + }); + + return this.holder.blockSet = Decoration.set(lineDecoRanges); + } + + private _createColorBtnWidgets(hlTokens: TokenGroup): DecorationSet { + if (!this._settings.colorButton) + return this.holder.colorBtnSet = RangeSet.empty; + + let btnWidgets: Range[] = []; + for (let i = 0; i < hlTokens.length; i++) { + let token = hlTokens[i]; + if (this._selectionObserver.touchSelection(token.from, token.to)) { + btnWidgets.push(ColorButton.of(token)); + } + } + return this.holder.colorBtnSet = Decoration.set(btnWidgets); + } + + private _revealSpoiler(spoilerTokens: TokenGroup): DecorationSet { + let revealedRanges: Range[] = []; + for (let i = 0; i < spoilerTokens.length; i++) { + let token = spoilerTokens[i]; + if (this._selectionObserver.touchSelection(token.from, token.to)) { + revealedRanges.push(REVEALED_SPOILER_DECO.range(token.from, token.to)); + } + } + return this.holder.revealedSpoilerSet = Decoration.set(revealedRanges); + } + + private _omitInlineDelim(activeTokens: TokenGroup): DecorationSet { + return this.holder.inlineOmittedSet = this._omitter.omitInline(activeTokens); + } + + /** Executed only in the state update. */ + private _replaceLineBreaks(doc: Text, changes?: ChangeSet): DecorationSet { + return this.holder.lineBreaksSet = this._lineBreakReplacer.replace(doc, changes); + } + + /** Executed only in the state update. */ + private _omitFencedDivOpening(changes?: ChangeDesc): DecorationSet { + let isOmitted = !(this._settings.alwaysShowFencedDivTag & MarkdownViewMode.EDITOR_MODE); + if (!isOmitted) { + return this.holder.blockOmittedSet = RangeSet.empty; + } + return this.holder.blockOmittedSet = this._omitter.omitBlock(this.holder.blockOmittedSet, changes); + } + + private _removeOmitter(): void { + this.holder.inlineOmittedSet = this.holder.blockOmittedSet = RangeSet.empty; + } +} \ No newline at end of file diff --git a/src/editor-mode/decorator/builder/DecorationBuilder.ts b/src/editor-mode/decorator/builder/DecorationBuilder.ts deleted file mode 100644 index b344aa5..0000000 --- a/src/editor-mode/decorator/builder/DecorationBuilder.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { ChangeDesc, ChangeSet, EditorState, Range, RangeSet, Text, Transaction } from "@codemirror/state"; -import { Decoration, EditorView, ViewUpdate } from "@codemirror/view"; -import { Parser } from "src/editor-mode/parser"; -import { Format, MarkdownViewMode, TokenStatus } from "src/enums"; -import { REVEALED_SPOILER_DECO } from "src/editor-mode/decorator/decorations"; -import { BlockFormat, PlainRange, InlineFormat, Token, TokenGroup, TokenDecoration, PluginSettings } from "src/types"; -import { ColorButton } from "src/editor-mode/decorator/widgets"; -import { BlockRules, InlineRules } from "src/format-configs"; -import { DecorationHolder, DelimOmitter, LineBreakReplacer, TokensBuffer } from "src/editor-mode/decorator/builder"; -import { createInlineDecoRange, createLineDecoRange } from "src/editor-mode/decorator/decorator-utils"; -import { isEditorModeChanged } from "src/editor-mode/editor-utils"; -import { getLineAt, iterLine, sliceStrFromLine } from "src/editor-mode/doc-utils"; -import { getTagRange, iterTokenGroup } from "src/editor-mode/parser/token-utils" -import { trimTag } from "src/utils"; -import { SelectionObserver } from "src/editor-mode/observer"; -import { editorLivePreviewField } from "obsidian"; -import { refresherAnnot } from "src/editor-mode/annotations"; -import { activityFacet } from "src/editor-mode/facets"; - -export class DecorationBuilder { - readonly parser: Parser; - readonly omitter: DelimOmitter; - readonly catcher: TokensBuffer; - readonly lineBreakReplacer: LineBreakReplacer; - readonly selectionObserver: SelectionObserver; - readonly holder: DecorationHolder; - readonly settings: PluginSettings; - readonly indexCaches = { - linePos: { number: 1 }, // 1-based - inlineToken: { number: 0 }, // 0-based - blockToken: { number: 0 } // 0-based - } - constructor(parser: Parser, selectionObserver: SelectionObserver) { - this.parser = parser; - this.selectionObserver = selectionObserver; - this.settings = parser.settings; - this.omitter = new DelimOmitter(this.settings, selectionObserver); - this.catcher = new TokensBuffer(); - this.lineBreakReplacer = new LineBreakReplacer(parser); - this.holder = new DecorationHolder(); - } - /** - * Main decorations hold basic formatting style of the tokens. - * - * Intended to build non-height-altering decorations. So, it doesn't - * include line breaks and fenced div opening omitter. (It runs only in - * the view update) - */ - buildMain(view: EditorView, state: EditorState) { - this.buildInline(state, view.visibleRanges); - this.buildBlock(state, view.visibleRanges); - } - /** - * Supplementary decorations consist omitted delimiter of inline tokens, - * color buttons for the highlight, and revealed spoiler when touched the - * cursor or selection. - * - * Intended to build non-height-altering decorations. So, it doesn't - * include line breaks and fenced div opening omitter. (It runs only in - * the view update) - */ - buildSupplementary(isLivePreview: boolean) { - if (isLivePreview) { - this.omitInlineDelim(this.catcher.activeTokens); - this.createColorBtnWidgets(this.catcher.hlTokens); - this.revealSpoiler(this.catcher.spoilerTokens); - } else { - this.holder.colorBtnSet = this.holder.revealedSpoilerSet = RangeSet.empty; - } - } - /** - * Runs once on editor intialization, should be inside the view update - * (i.e. the ViewPlugin update). - */ - onViewInit(view: EditorView) { - let state = view.state, - isLivePreview = state.field(editorLivePreviewField); - this.buildMain(view, state); - this.buildSupplementary(isLivePreview); - } - onViewUpdate(update: ViewUpdate) { - let state = update.state, - view = update.view, - isLivePreview = state.field(editorLivePreviewField), - activityRecorder = state.facet(activityFacet); - if (activityRecorder.verify("builder-view-update", "parse", true) || update.viewportMoved) { - this.buildMain(view, state); - } - if (activityRecorder.verify("builder-view-update", "observe", true) || update.viewportMoved) { - this.buildSupplementary(isLivePreview); - } - } - /** - * Runs once on editor intialization, should be inside the state update - * (i.e. the StateField update). - */ - onStateInit(state: EditorState) { - let isLivePreview = state.field(editorLivePreviewField); - if (isLivePreview) { - this.omitFencedDivOpening(); - } - this.replaceLineBreaks(state.doc); - } - onStateUpdate(transaction: Transaction) { - let state = transaction.state, - isLivePreview = state.field(editorLivePreviewField), - isRefreshed = transaction.annotation(refresherAnnot), - isModeChanged = isEditorModeChanged(state, transaction.startState), - activityRecorder = state.facet(activityFacet); - if (activityRecorder.verify("builder-state-update", "parse")) { - this.replaceLineBreaks(transaction.newDoc, transaction.changes); - } - if (isModeChanged || isRefreshed) { - this.selectionObserver.restartObserver(transaction.newSelection, transaction.docChanged); - this.holder.blockOmittedSet = RangeSet.empty; - } - if (!isLivePreview) { - this.removeOmitter(); - } else if (activityRecorder.verify("builder-state-update", "observe") || isModeChanged) { - this.omitFencedDivOpening(transaction.changes); - } - } - buildInline(state: EditorState, visibleRanges: readonly PlainRange[]) { - let inlineDecoRanges: Range[] = [], - activeTokens: TokenGroup = [], - hlTokens: TokenGroup = [], - spoilerTokens: TokenGroup = []; - iterTokenGroup({ - tokens: this.parser.inlineTokens, - ranges: visibleRanges, - indexCache: this.indexCaches.inlineToken, - callback: (token) => { - if (token.status != TokenStatus.ACTIVE) { return } - if (token.type == Format.HIGHLIGHT) { hlTokens.push(token) } - if (token.type == Format.SPOILER) { spoilerTokens.push(token) } - inlineDecoRanges.push(this.transformInlineToken(token, state.doc)); - activeTokens.push(token); - }, - }); - this.catcher.catch(activeTokens, hlTokens, spoilerTokens); - return this.holder.inlineSet = Decoration.set(inlineDecoRanges); - } - buildBlock(state: EditorState, visibleRanges: readonly PlainRange[]) { - let lineDecoRanges: Range[] = []; - iterTokenGroup({ - tokens: this.parser.blockTokens, - ranges: visibleRanges, - indexCache: this.indexCaches.blockToken, - callback: (token) => { - if (token.status != TokenStatus.ACTIVE) { return } - lineDecoRanges.push(...this.transformBlockToken(token, state.doc)); - }, - }); - return this.holder.blockSet = Decoration.set(lineDecoRanges); - } - transformInlineToken(token: Token, doc: Text) { - let cls = "cm-" + InlineRules[token.type as InlineFormat].class; - if (token.tagLen) { - let line = getLineAt(doc, token.from, this.indexCaches.linePos), - tagRange = getTagRange(token), - tagStr = sliceStrFromLine(line, tagRange.from + 1, tagRange.to - 1); - if (token.type == Format.CUSTOM_SPAN) { - tagStr = trimTag(tagStr); - cls += " " + tagStr; - } else { - cls += " " + cls + "-" + tagStr; - } - } - return createInlineDecoRange(token, cls); - } - transformBlockToken(token: Token, doc: Text) { - let baseCls = "cm-" + BlockRules[token.type as BlockFormat].class, - openLine = getLineAt(doc, token.from, this.indexCaches.linePos), - tagRange = getTagRange(token), - tagStr = trimTag(sliceStrFromLine(openLine, tagRange.from, tagRange.to)), - openDelimCls = baseCls + " cm-fenced-div-start", - contentCls = baseCls + " " + tagStr, - ranges: Range[] = [createLineDecoRange(token, openDelimCls, openLine)]; - iterLine({ - doc, fromLn: openLine.number + 1, - callback(line) { - if (line.to > token.to) { return false } - if (line.to == token.to && !line.text.trimEnd()) { return false } - let decoRange = createLineDecoRange(token, contentCls, line); - ranges.push(decoRange); - }, - }); - return ranges; - } - createColorBtnWidgets(hlTokens: TokenGroup) { - if (!this.settings.colorButton) { - return this.holder.colorBtnSet = RangeSet.empty; - } - let btnWidgets: Range[] = []; - for (let i = 0; i < hlTokens.length; i++) { - let token = hlTokens[i]; - if (this.selectionObserver.touchSelection(token.from, token.to)) { - btnWidgets.push(ColorButton.of(token)); - } - } - return this.holder.colorBtnSet = Decoration.set(btnWidgets); - } - revealSpoiler(spoilerTokens: TokenGroup) { - let revealedRanges: Range[] = []; - for (let i = 0; i < spoilerTokens.length; i++) { - let token = spoilerTokens[i]; - if (this.selectionObserver.touchSelection(token.from, token.to)) { - revealedRanges.push(REVEALED_SPOILER_DECO.range(token.from, token.to)); - } - } - return this.holder.revealedSpoilerSet = Decoration.set(revealedRanges); - } - omitInlineDelim(activeTokens: TokenGroup) { - return this.holder.inlineOmittedSet = this.omitter.omitInline(activeTokens); - } - /** Executed only in the state update. */ - replaceLineBreaks(doc: Text, changes?: ChangeSet) { - return this.holder.lineBreaksSet = this.lineBreakReplacer.replace(doc, changes); - } - /** Executed only in the state update. */ - omitFencedDivOpening(changes?: ChangeDesc) { - let isOmitted = !(this.settings.alwaysShowFencedDivTag & MarkdownViewMode.EDITOR_MODE); - if (!isOmitted) { - return this.holder.blockOmittedSet = RangeSet.empty; - } - return this.holder.blockOmittedSet = this.omitter.omitBlock(this.holder.blockOmittedSet, changes); - } - removeOmitter() { - this.holder.inlineOmittedSet = this.holder.blockOmittedSet = RangeSet.empty; - } -} \ No newline at end of file diff --git a/src/editor-mode/decorator/builder/DecorationHolder.ts b/src/editor-mode/decorator/builder/DecorationHolder.ts deleted file mode 100644 index bb831cf..0000000 --- a/src/editor-mode/decorator/builder/DecorationHolder.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { RangeSet } from "@codemirror/state"; -import { DecorationSet } from "@codemirror/view"; - -export class DecorationHolder { - inlineSet: DecorationSet = RangeSet.empty; - blockSet: DecorationSet = RangeSet.empty; - inlineOmittedSet: DecorationSet = RangeSet.empty; - blockOmittedSet: DecorationSet = RangeSet.empty; - colorBtnSet: DecorationSet = RangeSet.empty; - revealedSpoilerSet: DecorationSet = RangeSet.empty; - lineBreaksSet: DecorationSet = RangeSet.empty; - constructor() {} -} \ No newline at end of file diff --git a/src/editor-mode/decorator/builder/DelimOmitter.ts b/src/editor-mode/decorator/builder/DelimOmitter.ts deleted file mode 100644 index 9b834e9..0000000 --- a/src/editor-mode/decorator/builder/DelimOmitter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { ChangeDesc, Range } from "@codemirror/state"; -import { Decoration, DecorationSet } from "@codemirror/view"; -import { DisplayBehaviour, Format, TokenLevel, TokenStatus } from "src/enums"; -import { PluginSettings, TokenGroup } from "src/types"; -import { HiddenWidget } from "src/editor-mode/decorator/widgets"; -import { SelectionObserver } from "src/editor-mode/observer"; - -export class DelimOmitter { - selectionObserver: SelectionObserver; - settings: PluginSettings; - constructor(settings: PluginSettings, selectionObserver: SelectionObserver) { - this.settings = settings; - this.selectionObserver = selectionObserver; - } - omitBlock(omittedSet?: DecorationSet, changes?: ChangeDesc) { - let omittedRanges: Range[] = [], - filterRegion = this.selectionObserver.filterRegions[TokenLevel.BLOCK]; - this.selectionObserver.iterateChangedRegion(TokenLevel.BLOCK, (token, index, tokens, inSelection) => { - if (inSelection || token.status != TokenStatus.ACTIVE || !token.validTag) { return } - let openFrom = token.from, - openTo = openFrom + token.openLen + token.tagLen; - if (token.to > openTo) { openTo++ } - omittedRanges.push(HiddenWidget.of(openFrom, openTo, token, true)); - }); - if (!omittedSet?.size) { - omittedSet = Decoration.set(omittedRanges); - } else { - if (changes) { - omittedSet = omittedSet.map(changes); - } - for (let i = 0; i < filterRegion.length; i++) { - let filterRange = filterRegion[i]; - omittedSet = omittedSet.update({ - filterFrom: filterRange.from, - filterTo: filterRange.to, - filter: () => false, - }); - } - omittedSet = omittedSet.update({ add: omittedRanges }); - } - return omittedSet; - } - omitInline(activeTokens: TokenGroup) { - let alwaysShowHlTag = this.settings.hlTagDisplayBehaviour & DisplayBehaviour.ALWAYS, - alwaysShowSpanTag = this.settings.spanTagDisplayBehaviour & DisplayBehaviour.ALWAYS, - showHlTagIfTouched = this.settings.hlTagDisplayBehaviour & DisplayBehaviour.TAG_TOUCHED, - showSpanTagIfTouched = this.settings.spanTagDisplayBehaviour & DisplayBehaviour.TAG_TOUCHED; - let omittedRanges: Range[] = []; - for (let i = 0; i < activeTokens.length; i++) { - let token = activeTokens[i], - openFrom = token.from, - openTo = openFrom + token.openLen, - tagTo = openTo + token.tagLen; - if (this.selectionObserver.touchSelection(token.from, token.to)) { - if ( - token.validTag && !this.selectionObserver.touchSelection(openTo, tagTo) && - (token.type == Format.HIGHLIGHT && showHlTagIfTouched || token.type == Format.CUSTOM_SPAN && showSpanTagIfTouched) - ) { - omittedRanges.push(HiddenWidget.of(openTo, tagTo, token)); - } - } else { - if (token.type == Format.HIGHLIGHT && !alwaysShowHlTag || token.type == Format.CUSTOM_SPAN && !alwaysShowSpanTag) { - openTo = tagTo; - } - omittedRanges.push(HiddenWidget.of(openFrom, openTo, token)); - if (token.closeLen) { - omittedRanges.push(HiddenWidget.of(token.to - token.closeLen, token.to, token)); - } - } - } - return Decoration.set(omittedRanges, true); - } -} \ No newline at end of file diff --git a/src/editor-mode/decorator/builder/LineBreakReplacer.ts b/src/editor-mode/decorator/builder/LineBreakReplacer.ts deleted file mode 100644 index 5ca2617..0000000 --- a/src/editor-mode/decorator/builder/LineBreakReplacer.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { RangeSet, Range, Text, ChangeSet } from "@codemirror/state"; -import { Decoration } from "@codemirror/view"; -import { Parser } from "src/editor-mode/parser"; -import { IndexCache, RangeSetUpdate, TokenGroup } from "src/types"; -import { getLineAt, iterLine } from "src/editor-mode/doc-utils"; -import { LineBreak } from "src/editor-mode/decorator/widgets"; -import { TokenLevel, TokenStatus } from "src/enums"; - -export class LineBreakReplacer { - private linePosCache: IndexCache = { number: 1 }; - parser: Parser; - lineBreakSet: RangeSet = RangeSet.empty; - constructor(parser: Parser) { - this.parser = parser; - } - replace(doc: Text, changes?: ChangeSet) { - let reparsedRange = this.parser.reparsedRanges[TokenLevel.BLOCK], - blockTokens = this.parser.blockTokens, - updateSpec: RangeSetUpdate = { - add: this.produceWidgetRanges(doc) - }; - if (!blockTokens.length) { - return this.lineBreakSet = RangeSet.empty; - } - if (reparsedRange.from != reparsedRange.initTo || reparsedRange.from != reparsedRange.changedTo) { - updateSpec.filterFrom = Math.min(blockTokens[reparsedRange.from]?.from ?? this.parser.lastStreamPoint.from, this.parser.lastStreamPoint.from); - updateSpec.filterTo = this.parser.lastStreamPoint.to; - updateSpec.filter = () => false; - } - if (changes) { - this.lineBreakSet = this.lineBreakSet.map(changes); - } - return this.lineBreakSet = this.lineBreakSet.update(updateSpec); - } - private getReparsedBlockTokens(): TokenGroup { - let range = this.parser.reparsedRanges[TokenLevel.BLOCK]; - return this.parser.blockTokens.slice(range.from, range.changedTo); - } - private produceWidgetRanges(doc: Text) { - let tokens = this.getReparsedBlockTokens(), - ranges: Range[] = []; - if (!tokens.length) { return ranges } - let startLine = getLineAt(doc, tokens[0].from, this.linePosCache), - tokenIndex = 0; - iterLine({ - doc, - fromLn: startLine.number, - callback: (line) => { - let curToken = tokens[tokenIndex]; - if (!curToken) { return false } - if (curToken.status != TokenStatus.ACTIVE) { tokenIndex++; return } - if ( - line.from == curToken.from || - line.from - 1 == curToken.from + curToken.openLen + curToken.tagLen - ) { return } - if ( - line.from >= curToken.to || - line.to < curToken.from || - line.to == curToken.to && curToken.closedByBlankLine - ) { tokenIndex++; return } - ranges.push(LineBreak.of(line.from)); - }, - }); - return ranges; - } -} \ No newline at end of file diff --git a/src/editor-mode/decorator/builder/TokensBuffer.ts b/src/editor-mode/decorator/builder/TokensBuffer.ts deleted file mode 100644 index 4464a9c..0000000 --- a/src/editor-mode/decorator/builder/TokensBuffer.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { TokenGroup } from "src/types"; - -export class TokensBuffer { - activeTokens: TokenGroup = []; - hlTokens: TokenGroup = []; - spoilerTokens: TokenGroup = []; - constructor () {} - catch(activeTokens: TokenGroup, hlTokens: TokenGroup, spoilerTokens: TokenGroup) { - this.activeTokens = activeTokens; - this.hlTokens = hlTokens; - this.spoilerTokens = spoilerTokens; - } - empty() { - this.activeTokens = []; - this.hlTokens = []; - this.spoilerTokens = []; - } -} \ No newline at end of file diff --git a/src/editor-mode/decorator/builder/index.ts b/src/editor-mode/decorator/builder/index.ts deleted file mode 100644 index 23ee52e..0000000 --- a/src/editor-mode/decorator/builder/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./DecorationBuilder"; -export * from "./DelimOmitter"; -export * from "./TokensBuffer"; -export * from "./LineBreakReplacer"; -export * from "./DecorationHolder"; \ No newline at end of file diff --git a/src/editor-mode/decorator/decorations.ts b/src/editor-mode/decorator/decorations.ts new file mode 100644 index 0000000..a0c497d --- /dev/null +++ b/src/editor-mode/decorator/decorations.ts @@ -0,0 +1,32 @@ +import { Line, Range } from "@codemirror/state"; +import { Decoration } from "@codemirror/view"; +import { Token, TokenDecoration } from "src/types"; +import { Format } from "src/enums"; + +export const REVEALED_SPOILER_DECO: Decoration = Decoration.mark({ + class: "cm-spoiler-revealed", +}) + +export function createHlDeco(color: string, data?: Record): Decoration { + let spec: Parameters[0] = { + class: "cm-custom-highlight cm-custom-highlight-" + (color || "default"), + type: Format.HIGHLIGHT, + color, + inclusive: false + }; + if (data) + for (let prop in data) spec[prop] = data[prop]; + return Decoration.mark(spec); +} + +export function createInlineDecoRange(token: Token, cls: string): Range { + return (Decoration + .mark({ class: cls, token }) as TokenDecoration) + .range(token.from, token.to); +} + +export function createLineDecoRange(token: Token, cls: string, line: Line): Range { + return (Decoration + .line({ class: cls, token }) as TokenDecoration) + .range(line.from); +} \ No newline at end of file diff --git a/src/editor-mode/decorator/decorations/index.ts b/src/editor-mode/decorator/decorations/index.ts deleted file mode 100644 index de87092..0000000 --- a/src/editor-mode/decorator/decorations/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Decoration } from "@codemirror/view"; - -export const REVEALED_SPOILER_DECO = Decoration.mark({ - class: "cm-spoiler-revealed", -}); \ No newline at end of file diff --git a/src/editor-mode/decorator/decorator-utils/createHlDeco.ts b/src/editor-mode/decorator/decorator-utils/createHlDeco.ts deleted file mode 100644 index e2a1407..0000000 --- a/src/editor-mode/decorator/decorator-utils/createHlDeco.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Decoration } from "@codemirror/view"; -import { Format } from "src/enums"; - -export function createHlDeco(color: string, data?: Record) { - let spec: Parameters[0] = { - class: "cm-custom-highlight cm-custom-highlight-" + (color || "default"), - type: Format.HIGHLIGHT, - color, - inclusive: false - }; - if (data) { - for (let prop in data) { spec[prop] = data[prop] } - } - return Decoration.mark(spec); -} \ No newline at end of file diff --git a/src/editor-mode/decorator/decorator-utils/createInlineDecoRange.ts b/src/editor-mode/decorator/decorator-utils/createInlineDecoRange.ts deleted file mode 100644 index 844fed2..0000000 --- a/src/editor-mode/decorator/decorator-utils/createInlineDecoRange.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Decoration } from "@codemirror/view"; -import { Token, TokenDecoration } from "src/types"; - -export function createInlineDecoRange(token: Token, cls: string) { - return (Decoration - .mark({ class: cls, token }) as TokenDecoration) - .range(token.from, token.to); -} \ No newline at end of file diff --git a/src/editor-mode/decorator/decorator-utils/createLineDecoRange.ts b/src/editor-mode/decorator/decorator-utils/createLineDecoRange.ts deleted file mode 100644 index 82c027c..0000000 --- a/src/editor-mode/decorator/decorator-utils/createLineDecoRange.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Line } from "@codemirror/state"; -import { Decoration } from "@codemirror/view"; -import { Token, TokenDecoration } from "src/types"; - -export function createLineDecoRange(token: Token, cls: string, line: Line) { - return (Decoration - .line({ class: cls, token }) as TokenDecoration) - .range(line.from); -} \ No newline at end of file diff --git a/src/editor-mode/decorator/decorator-utils/index.ts b/src/editor-mode/decorator/decorator-utils/index.ts deleted file mode 100644 index d3f9aab..0000000 --- a/src/editor-mode/decorator/decorator-utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./createHlDeco"; -export * from "./createInlineDecoRange"; -export * from "./createLineDecoRange"; \ No newline at end of file diff --git a/src/editor-mode/decorator/widgets.ts b/src/editor-mode/decorator/widgets.ts new file mode 100644 index 0000000..a14ac03 --- /dev/null +++ b/src/editor-mode/decorator/widgets.ts @@ -0,0 +1,95 @@ +import { Range } from "@codemirror/state"; +import { WidgetType, EditorView, Decoration } from "@codemirror/view" +import { Format } from "src/enums"; +import { Token } from "src/types"; +import { TagMenu } from "src/editor-mode/ui-components"; + +/** + * These code snippets are taken from + * https://github.com/Superschnizel/obisdian-fast-text-color/blob/master/src/widgets/ColorWidget.ts + * with some modifications. + */ +export class ColorButton extends WidgetType { + private _btnPos: number; + private _colorMenu: TagMenu; + + private constructor(token: Token) { + super(); + this._btnPos = token.from + token.openLen; + } + + public eq(other: ColorButton): boolean { + return this._btnPos == other._btnPos; + } + + public toDOM(view: EditorView): HTMLElement { + let btn = document.createElement("span"); + btn.setAttribute("aria-hidden", "true"); + btn.className = "cm-highlight-color-btn"; + btn.onclick = () => { + view.dispatch({ + selection: { anchor: this._btnPos } + }); + this._colorMenu ||= TagMenu.create(view, Format.HIGHLIGHT); + this._colorMenu.showMenu(); + } + return btn; + } + + public ignoreEvent() { + return false; + } + + public static of(hlToken: Token): Range { + let btnOffset = hlToken.from + hlToken.openLen; + return Decoration + .widget({ widget: new ColorButton(hlToken), side: 1 }) + .range(btnOffset); + } +} + +export class HiddenWidget extends WidgetType { + private _token: Token; + private constructor(replaced: Token) { + super(); + this._token = replaced; + } + + public eq(other: HiddenWidget): boolean { + return other._token == this._token; + } + + public toDOM(): HTMLElement { + return document.createElement("span"); + } + + public static of(from: number, to: number, token: Token, isBlock = false): Range { + return Decoration.replace({ + widget: new HiddenWidget(token), + block: isBlock, + inclusiveEnd: false + }).range(from, to); + } +} + +export class LineBreak extends WidgetType { + private _offset: number; + private constructor(offset: number) { + super(); + this._offset = offset; + } + + public eq(other: LineBreak): boolean { + return other._offset === this._offset; + } + + public toDOM(): HTMLElement { + return document.createElement("br"); + } + + public static of(offset: number): Range { + return Decoration.replace({ + widget: new LineBreak(offset), + }).range(offset - 1, offset); + } +} \ No newline at end of file diff --git a/src/editor-mode/decorator/widgets/ColorButton.ts b/src/editor-mode/decorator/widgets/ColorButton.ts deleted file mode 100644 index 3ecc425..0000000 --- a/src/editor-mode/decorator/widgets/ColorButton.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { WidgetType, EditorView, Decoration } from "@codemirror/view" -import { Menu } from "obsidian"; -import { TagMenu } from "src/editor-mode/ui-components"; -import { Format } from "src/enums"; -import { Token } from "src/types"; - -/** - * These code snippets are taken from - * https://github.com/Superschnizel/obisdian-fast-text-color/blob/master/src/widgets/ColorWidget.ts - * with some modifications. - */ -export class ColorButton extends WidgetType { - menu: Menu; - btnPos: number; - colorMenu: TagMenu; - constructor(token: Token) { - super(); - this.btnPos = token.from + token.openLen; - } - eq(other: ColorButton) { - return this.btnPos == other.btnPos; - } - toDOM(view: EditorView): HTMLElement { - let btn = document.createElement("span"); - btn.setAttribute("aria-hidden", "true"); - btn.className = "cm-highlight-color-btn"; - btn.onclick = () => { - view.dispatch({ - selection: { anchor: this.btnPos } - }); - this.colorMenu ||= TagMenu.create(view, Format.HIGHLIGHT); - this.colorMenu.showMenu(); - } - return btn; - } - ignoreEvent() { - return false; - } - static of(hlToken: Token) { - let btnOffset = hlToken.from + hlToken.openLen; - return Decoration - .widget({ widget: new ColorButton(hlToken), side: 1 }) - .range(btnOffset); - } -} \ No newline at end of file diff --git a/src/editor-mode/decorator/widgets/HiddenWidget.ts b/src/editor-mode/decorator/widgets/HiddenWidget.ts deleted file mode 100644 index 692cdbf..0000000 --- a/src/editor-mode/decorator/widgets/HiddenWidget.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Decoration, WidgetType } from "@codemirror/view"; -import { Token } from "src/types"; - -export class HiddenWidget extends WidgetType { - token: Token; - constructor(replaced: Token) { - super(); - this.token = replaced; - } - eq(other: HiddenWidget) { - return other.token == this.token; - } - toDOM(): HTMLElement { - return document.createElement("span"); - } - static of(from: number, to: number, token: Token, isBlock = false) { - return Decoration.replace({ - widget: new HiddenWidget(token), - block: isBlock, - inclusiveEnd: false - }).range(from, to); - } -} \ No newline at end of file diff --git a/src/editor-mode/decorator/widgets/LineBreak.ts b/src/editor-mode/decorator/widgets/LineBreak.ts deleted file mode 100644 index ff7f75d..0000000 --- a/src/editor-mode/decorator/widgets/LineBreak.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Decoration, WidgetType } from "@codemirror/view"; - -export class LineBreak extends WidgetType { - offset: number; - constructor(offset: number) { - super(); - this.offset = offset; - } - eq(other: LineBreak) { - return other.offset === this.offset; - } - toDOM(): HTMLElement { - return document.createElement("br"); - } - static of(offset: number) { - return Decoration.replace({ - widget: new LineBreak(offset), - }).range(offset - 1, offset); - } -} \ No newline at end of file diff --git a/src/editor-mode/decorator/widgets/index.ts b/src/editor-mode/decorator/widgets/index.ts deleted file mode 100644 index accfed6..0000000 --- a/src/editor-mode/decorator/widgets/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./HiddenWidget"; -export * from "./ColorButton"; -export * from "./LineBreak"; \ No newline at end of file diff --git a/src/editor-mode/doc-utils/getBlockEndAt.ts b/src/editor-mode/doc-utils/getBlockEndAt.ts deleted file mode 100644 index e5655b2..0000000 --- a/src/editor-mode/doc-utils/getBlockEndAt.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Line, Text } from "@codemirror/state" -import { isBlankLine } from "src/editor-mode/doc-utils"; - -export function getBlockEndAt(doc: Text, offsetOrLine: number | Line, blankLineAsEnd = true) { - let line = offsetOrLine instanceof Line ? offsetOrLine : doc.lineAt(offsetOrLine); - while (line.number < doc.lines) { - line = doc.line(line.number + 1); - if (isBlankLine(line)) { - if (!blankLineAsEnd) { line = doc.line(line.number - 1) } - break; - } - } - return line; -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/getBlockStartAt.ts b/src/editor-mode/doc-utils/getBlockStartAt.ts deleted file mode 100644 index 4aea630..0000000 --- a/src/editor-mode/doc-utils/getBlockStartAt.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Line, Text } from "@codemirror/state"; -import { isBlankLine } from "src/editor-mode/doc-utils"; - -export function getBlockStartAt(doc: Text, offsetOrLine: number | Line) { - let curLine = offsetOrLine instanceof Line ? offsetOrLine : doc.lineAt(offsetOrLine), - prevLine = curLine.number > 1 ? doc.line(curLine.number - 1) : null; - if (isBlankLine(curLine)) { return curLine } - while (prevLine) { - if (isBlankLine(prevLine)) { break } - curLine = prevLine; - prevLine = curLine.number > 1 ? doc.line(curLine.number - 1) : null; - } - return curLine; -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/getBlockStarts.ts b/src/editor-mode/doc-utils/getBlockStarts.ts deleted file mode 100644 index edb4bb0..0000000 --- a/src/editor-mode/doc-utils/getBlockStarts.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Line, Text } from "@codemirror/state"; -import { PlainRange } from "src/types"; -import { getBlockStartAt, isBlankLine } from "src/editor-mode/doc-utils"; - -export function getBlockStarts(doc: Text, range: PlainRange, lookBehind = true) { - let curLine = doc.lineAt(range.from), - nextLine = curLine.number <= doc.lines ? doc.line(curLine.number + 1) : null, - curLineIsBlank = isBlankLine(curLine), - blockStarts: Line[] = []; - if (lookBehind) { - blockStarts.push(getBlockStartAt(doc, curLine)); - } else { - blockStarts.push(curLine); - } - while (nextLine) { - if (isBlankLine(nextLine)) { - curLineIsBlank = true; - } else { - if (curLineIsBlank) { - blockStarts.push(nextLine); - } - curLineIsBlank = false; - } - curLine = nextLine; - nextLine = curLine.number <= doc.lines ? doc.line(curLine.number + 1) : null; - } - if (blockStarts.length > 1 && isBlankLine(blockStarts[0])) { - blockStarts.shift(); - } - return blockStarts; -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/getBlocks.ts b/src/editor-mode/doc-utils/getBlocks.ts deleted file mode 100644 index 9ec6a05..0000000 --- a/src/editor-mode/doc-utils/getBlocks.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { PlainRange } from "src/types"; -import { Text } from "@codemirror/state"; -import { getBlockStartAt } from "./getBlockStartAt"; -import { isBlankLine } from "./isBlankLine"; -import { getBlockEndAt } from "./getBlockEndAt"; - -export function getBlocks(doc: Text, range: PlainRange, lookBehind = false, lookAhead = false) { - let blocks: { start: number, end: number }[] = [], - line = doc.lineAt(range.from), - initLine = line, - curBlock: { start: number, end: number } | undefined; - if (!isBlankLine(line) && lookBehind) { - let blockStart = getBlockStartAt(doc, line); - curBlock = { start: blockStart.number, end: line.number + 1 }; - blocks.push(curBlock); - } - for (; range.to >= line.from; line = doc.line(line.number + 1)) { - if (isBlankLine(line)) { curBlock = undefined } - else if (curBlock) { curBlock.end++ } - else { - curBlock = { start: line.number, end: line.number + 1 }; - blocks.push(curBlock); - } - if (line.number >= doc.lines) { break } - } - if (curBlock && lookAhead) { - curBlock.end = getBlockEndAt(doc, line, false).number + 1; - } - if (!blocks.length) { - blocks.push({ start: initLine.number, end: initLine.number + 1 }); - } - return blocks; -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/getLineAt.ts b/src/editor-mode/doc-utils/getLineAt.ts deleted file mode 100644 index b882d6c..0000000 --- a/src/editor-mode/doc-utils/getLineAt.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { IndexCache } from "src/types"; -import { Text } from "@codemirror/state" - -export function getLineAt(doc: Text, offset: number, linePosCache?: IndexCache) { - if (!linePosCache) { return doc.lineAt(offset) } - if (linePosCache.number > doc.lines) { linePosCache.number = doc.lines } - let curLine = doc.line(linePosCache.number); - if (offset < curLine.from) { - do { - curLine = doc.line(curLine.number - 1); - } while (offset < curLine.from) - } else if (offset > curLine.to) { - do { - curLine = doc.line(curLine.number + 1); - } while (offset > curLine.to) - } - linePosCache.number = curLine.number; - return curLine; -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/getNextLine.ts b/src/editor-mode/doc-utils/getNextLine.ts deleted file mode 100644 index 682625e..0000000 --- a/src/editor-mode/doc-utils/getNextLine.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Line, Text } from "@codemirror/state"; - -export function getNextLine(doc: Text, offsetOrLine: number | Line) { - let line = offsetOrLine instanceof Line ? offsetOrLine : doc.lineAt(offsetOrLine); - if (line.number >= doc.lines) { return null } - return doc.line(line.number + 1); -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/getPrevLine.ts b/src/editor-mode/doc-utils/getPrevLine.ts deleted file mode 100644 index 9d480b5..0000000 --- a/src/editor-mode/doc-utils/getPrevLine.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Line, Text } from "@codemirror/state"; - -export function getPrevLine(doc: Text, offsetOrLineNum: number | Line) { - let lineNum: number; - if (offsetOrLineNum instanceof Line) { - lineNum = offsetOrLineNum.number; - } else { - lineNum = doc.lineAt(offsetOrLineNum).number; - } - if (lineNum <= 1) { return null } - return doc.line(lineNum - 1); -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/index.ts b/src/editor-mode/doc-utils/index.ts deleted file mode 100644 index e484a3e..0000000 --- a/src/editor-mode/doc-utils/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from "./getBlockStartAt"; -export * from "./getBlockStarts"; -export * from "./getPrevLine"; -export * from "./getBlockEndAt"; -export * from "./isBlankLine"; -export * from "./sliceStrFromLine"; -export * from "./getLineAt"; -export * from "./iterLine"; -export * from "./getBlocks"; -export * from "./getNextLine"; -export * from "./isBlockStart"; -export * from "./isBlockEnd"; \ No newline at end of file diff --git a/src/editor-mode/doc-utils/isBlankLine.ts b/src/editor-mode/doc-utils/isBlankLine.ts deleted file mode 100644 index 97173c2..0000000 --- a/src/editor-mode/doc-utils/isBlankLine.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Line } from "@codemirror/state"; - -export function isBlankLine(line: Line): boolean { - return !line.text.trimEnd(); -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/isBlockEnd.ts b/src/editor-mode/doc-utils/isBlockEnd.ts deleted file mode 100644 index 8218fa1..0000000 --- a/src/editor-mode/doc-utils/isBlockEnd.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Line, Text } from "@codemirror/state"; -import { getNextLine } from "./getNextLine"; -import { isBlankLine } from "./isBlankLine"; - -export function isBlockEnd(doc: Text, line: Line) { - let nextLine = getNextLine(doc, line); - return !nextLine || isBlankLine(nextLine); -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/isBlockStart.ts b/src/editor-mode/doc-utils/isBlockStart.ts deleted file mode 100644 index 29a1802..0000000 --- a/src/editor-mode/doc-utils/isBlockStart.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Line, Text } from "@codemirror/state"; -import { getPrevLine } from "./getPrevLine"; -import { isBlankLine } from "./isBlankLine"; - -export function isBlockStart(doc: Text, line: Line) { - let prevLine = getPrevLine(doc, line); - return !prevLine || isBlankLine(prevLine); -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/iterLine.ts b/src/editor-mode/doc-utils/iterLine.ts deleted file mode 100644 index 514b702..0000000 --- a/src/editor-mode/doc-utils/iterLine.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IterLineSpec } from "src/types"; - -export function iterLine(spec: IterLineSpec) { - for (let i = spec.fromLn; i <= (spec.toLn ?? spec.doc.lines); i++) { - let curLine = spec.doc.line(i); - if (spec.callback(curLine, spec.doc) === false) { break } - } -} \ No newline at end of file diff --git a/src/editor-mode/doc-utils/sliceStrFromLine.ts b/src/editor-mode/doc-utils/sliceStrFromLine.ts deleted file mode 100644 index 5ffd438..0000000 --- a/src/editor-mode/doc-utils/sliceStrFromLine.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Line } from "@codemirror/state"; - -export function sliceStrFromLine(line: Line, from: number, to: number) { - from -= line.from; - to -= line.from; - return line.text.slice(from, to); -} \ No newline at end of file diff --git a/src/editor-mode/editor-utils/checkRefreshed.ts b/src/editor-mode/editor-utils/checkRefreshed.ts deleted file mode 100644 index 9a69712..0000000 --- a/src/editor-mode/editor-utils/checkRefreshed.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Transaction } from "@codemirror/state"; -import { refresherAnnot } from "src/editor-mode/annotations"; - -export function checkRefreshed(transactions: Transaction[] | readonly Transaction[] | Transaction) { - if (transactions instanceof Array) { - for (let i = 0; i < transactions.length; i++) { - if (transactions[i].annotation(refresherAnnot)) { return true } - } - } else { - if (transactions.annotation(refresherAnnot)) { return true } - } - return false; -} \ No newline at end of file diff --git a/src/editor-mode/editor-utils/getActiveCanvasEditor.ts b/src/editor-mode/editor-utils/getActiveCanvasEditor.ts deleted file mode 100644 index 2ed7bcd..0000000 --- a/src/editor-mode/editor-utils/getActiveCanvasEditor.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { App } from "obsidian"; -import { isCanvas } from "src/editor-mode/editor-utils"; - -export function getActiveCanvasEditor(app: App) { - if (isCanvas(app)) { - return app.workspace.activeEditor; - } - return null; -} \ No newline at end of file diff --git a/src/editor-mode/editor-utils/getActiveCanvasNodeCoords.ts b/src/editor-mode/editor-utils/getActiveCanvasNodeCoords.ts deleted file mode 100644 index 6b47744..0000000 --- a/src/editor-mode/editor-utils/getActiveCanvasNodeCoords.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { App, MarkdownView } from "obsidian"; -import { getActiveCanvasEditor } from "./getActiveCanvasEditor"; - -export function getActiveCanvasNodeCoords(app: App) { - let canvasEditor = getActiveCanvasEditor(app), - containerEl = (canvasEditor as MarkdownView)?.containerEl; - if (containerEl) { - return containerEl.getBoundingClientRect(); - } - return null; -} \ No newline at end of file diff --git a/src/editor-mode/editor-utils/index.ts b/src/editor-mode/editor-utils/index.ts deleted file mode 100644 index cdff943..0000000 --- a/src/editor-mode/editor-utils/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./checkRefreshed"; -export * from "./getActiveCanvasNodeCoords"; -export * from "./getActiveCanvasEditor"; -export * from "./isCanvas"; -export * from "./isEditorModeChanged"; \ No newline at end of file diff --git a/src/editor-mode/editor-utils/isCanvas.ts b/src/editor-mode/editor-utils/isCanvas.ts deleted file mode 100644 index 2021cd3..0000000 --- a/src/editor-mode/editor-utils/isCanvas.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { App } from "obsidian"; - -export function isCanvas(app: App) { - return app.workspace.getMostRecentLeaf()?.view.getViewType() == "canvas"; -} \ No newline at end of file diff --git a/src/editor-mode/editor-utils/isEditorModeChanged.ts b/src/editor-mode/editor-utils/isEditorModeChanged.ts deleted file mode 100644 index d7b2c67..0000000 --- a/src/editor-mode/editor-utils/isEditorModeChanged.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { EditorState } from "@codemirror/state"; -import { editorLivePreviewField } from "obsidian"; - -export function isEditorModeChanged(curState: EditorState, prevState: EditorState) { - let isLivePreviewCurrently = curState.field(editorLivePreviewField), - isLivePreviewPreviously = prevState.field(editorLivePreviewField); - return isLivePreviewCurrently != isLivePreviewPreviously; -} \ No newline at end of file diff --git a/src/editor-mode/extensions/index.ts b/src/editor-mode/extensions/index.ts deleted file mode 100644 index b369a4c..0000000 --- a/src/editor-mode/extensions/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Extension, RangeSet } from "@codemirror/state"; -import { EditorView, ViewPlugin } from "@codemirror/view"; -import { builderField, decoSetField, parserField, selectionObserverField } from "src/editor-mode/state-fields"; -import { EditorPlugin } from "src/editor-mode/view-plugin"; -import { activityFacet } from "src/editor-mode/facets"; - -export const editorPlugin = ViewPlugin.fromClass(EditorPlugin); - -export const editorExtendedSyntax: Extension = [ - activityFacet.of(null), - parserField, - selectionObserverField, - builderField, - decoSetField, - editorPlugin, - EditorView.decorations.of(view => view.plugin(editorPlugin)?.builder.holder.blockSet ?? RangeSet.empty), - EditorView.decorations.of(view => view.plugin(editorPlugin)?.builder.holder.inlineOmittedSet ?? RangeSet.empty), - EditorView.decorations.of(view => view.plugin(editorPlugin)?.builder.holder.colorBtnSet ?? RangeSet.empty), - EditorView.decorations.of(view => view.plugin(editorPlugin)?.builder.holder.revealedSpoilerSet ?? RangeSet.empty), - EditorView.outerDecorations.of(view => view.plugin(editorPlugin)?.builder.holder.inlineSet ?? RangeSet.empty) -]; \ No newline at end of file diff --git a/src/editor-mode/facets/activityFacet.ts b/src/editor-mode/facets/activityFacet.ts deleted file mode 100644 index 2218d9f..0000000 --- a/src/editor-mode/facets/activityFacet.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Facet } from "@codemirror/state"; -import { ActivityRecorder } from "src/editor-mode/observer"; - -export const activityFacet = Facet.define({ - combine() { - return new ActivityRecorder(); - }, - static: true -}); \ No newline at end of file diff --git a/src/editor-mode/facets/appFacet.ts b/src/editor-mode/facets/appFacet.ts deleted file mode 100644 index c812179..0000000 --- a/src/editor-mode/facets/appFacet.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Facet } from "@codemirror/state"; -import { App } from "obsidian"; - -export const appFacet = Facet.define({ - combine(value) { - return value[0]; - }, - static: true -}); \ No newline at end of file diff --git a/src/editor-mode/facets/index.ts b/src/editor-mode/facets/index.ts deleted file mode 100644 index b80bf29..0000000 --- a/src/editor-mode/facets/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./settingsFacet"; -export * from "./appFacet"; -export * from "./pluginFacet"; -export * from "./activityFacet"; \ No newline at end of file diff --git a/src/editor-mode/facets/pluginFacet.ts b/src/editor-mode/facets/pluginFacet.ts deleted file mode 100644 index 1953a35..0000000 --- a/src/editor-mode/facets/pluginFacet.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Facet } from "@codemirror/state"; -import ExtendedMarkdownSyntax from "main"; - -export let pluginFacet = Facet.define({ - combine(value) { - return value[0]; - }, - static: true -}); \ No newline at end of file diff --git a/src/editor-mode/facets/settingsFacet.ts b/src/editor-mode/facets/settingsFacet.ts deleted file mode 100644 index 7955078..0000000 --- a/src/editor-mode/facets/settingsFacet.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Facet } from "@codemirror/state"; -import { PluginSettings } from "src/types"; - -export const settingsFacet = Facet.define({ - combine(value) { - return value[0]; - }, - static: true -}); \ No newline at end of file diff --git a/src/editor-mode/formatting/Formatter.ts b/src/editor-mode/formatting/Formatter.ts deleted file mode 100644 index 6ab595f..0000000 --- a/src/editor-mode/formatting/Formatter.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { ChangeSet, EditorSelection } from "@codemirror/state"; -import { SelectionObserver } from "src/editor-mode/observer"; -import { selectionObserverField } from "src/editor-mode/state-fields"; -import { EditorView } from "codemirror"; -import { Parser } from "src/editor-mode/parser"; -import { Format, TokenLevel, TokenStatus } from "src/enums"; -import { PlainRange, PluginSettings, Token } from "src/types"; -import { getTagRange, provideTokenRanges } from "src/editor-mode/parser/token-utils"; -import { getBlocks, isBlockStart, isBlockEnd } from "src/editor-mode/doc-utils"; -import { FormatterState } from "src/editor-mode/formatting"; -import { supportTag } from "src/format-configs/utils"; -import { isTouched } from "src/editor-mode/range-utils"; -import { TagMenu } from "src/editor-mode/ui-components"; -import { isSurroundedByDelimiter } from "./formatting-utils"; - -export class Formatter { - view: EditorView; - state: FormatterState; - parser: Parser; - selectionObserver: SelectionObserver; - settings: PluginSettings; - constructor(view: EditorView) { - this.view = view; - this.selectionObserver = view.state.field(selectionObserverField); - this.parser = this.selectionObserver.parser; - this.settings = this.parser.settings; - } - get doc() { - return this.view.state.doc; - } - defineState(type: Format, tagStr?: string) { - this.state = new FormatterState(type, this.doc, this.selectionObserver, this.settings, tagStr); - } - clearState() { - (this.state as unknown as undefined) = undefined; - } - startFormat(type: Format, tagStr?: string, forceRemove?: boolean, showMenu?: boolean) { - if (forceRemove) { - tagStr = undefined; - } - if (!this.settings.openTagMenuAfterFormat) { - showMenu = false; - } - let tokenMaps = this.selectionObserver.pickMaps(type); - if (tokenMaps.length == 1 && !tokenMaps[0]?.length && showMenu) { - TagMenu.create(this.view, type).showMenu(); - return; - } - this.defineState(type, tagStr); - if (forceRemove) { - this.removeAll() - } else if (this.state.level == TokenLevel.INLINE) { - this.formatInline(); - } else { - this.formatBlock(); - } - this.composeChanges(); - this.remapSelection(); - this.dispatchToView(); - } - formatInline() { - let state = this.state, - tokens = this.state.tokens; - do { - let { curTokenMap, curRange, tagStr, precise } = state, - firstToken = curTokenMap ? tokens[curTokenMap[0]] : null; - if (!precise) { - this.toggleInlineDelim(); - } else if (!firstToken) { - this.wrap(); - } else if (firstToken.from > curRange.from || firstToken.to < curRange.to) { - this.extend(); - } else if (firstToken.status != TokenStatus.ACTIVE) { - this.close(firstToken); - } else if (tagStr !== undefined) { - this.changeInlineTag(firstToken); - } else if (curRange.empty) { - this.remove(firstToken); - } else { - this.breakApart(firstToken); - } - } while (state.advance()) - } - formatBlock() { - do { - this.toggleBlockTag(); - } while (this.state.advance()) - } - /** - * Use wrap only when the current range didn't meet any token. - * - * **Exclusive to inline formatting use.** - */ - wrap(detectWord = true) { - let { curRange, delimStr, tagStr } = this.state; - // If the current selection is actually an empty cursor, attempt to use - // word range if any. - if (curRange.empty) { - let cursorOffset = curRange.from; - if (detectWord) { - curRange = this.view.state.wordAt(curRange.from) ?? curRange; - } - if (cursorOffset == curRange.to) { - let shiftAmount = delimStr.length; - if (cursorOffset == curRange.from) { - shiftAmount += tagStr?.length ?? 0; - } - this.state.pushSelectionShift(this.state.curSelectionIndex, shiftAmount); - } - } - this.state.pushChange([ - { from: curRange.from, insert: delimStr + (tagStr ?? "") }, - { from: curRange.to, insert: delimStr } - ]); - } - /** - * Add corresponding closing delimiter to the token that's currently - * inactive. Should be run when the cursor is within or the same range as - * the token. - * - * **Exclusive to inline formatting use.** - * - * @param token should be in `INACTIVE` status. - */ - close(token: Token) { - let { delimStr, tagStr } = this.state; - if (supportTag(token.type) && tagStr) { - this.state.pushChange({ from: token.from + token.openLen, insert: tagStr }); - } - this.state.pushChange({ from: token.to, insert: delimStr }); - } - /** - * Break the current token into two new tokens if the current range - * was within the content range of the token and didn't touch both - * delimiters, or narrow it if one of both delimiters was touched, or - * behave like `remove()`. Should be run when the current selection - * range within the token. - * - * **Exclusive to inline formatting use.** - */ - breakApart(token: Token) { - let { openRange, tagRange, closeRange } = provideTokenRanges(token), - { curRange, delimStr } = this.state, - tagStr = this.doc.sliceString(tagRange.from, tagRange.to); - // Remove opening delimiter when the current range touched it. Otherwise, - // insert corresponding delimiter at the start offset. - if (isTouched(curRange.from, openRange)) { - this.state.pushChange(openRange); - } else { - this.state.pushChange({ from: curRange.from, insert: delimStr }); - } - // Remove closing delimiter when the current range touched it. Otherwise, - // insert corresponding delimiter at the end offset. - if (isTouched(curRange.to, closeRange)) { - this.state.pushChange(closeRange); - } else { - // This delimiter should be opening. To have the same tag as the original - // we need to copy the original tag to the newly created one. - this.state.pushChange({ from: curRange.to, insert: delimStr + tagStr }); - } - } - /** - * Replace the tag of targetted token, or insert as a new if the current - * tag was invalid or didn't exist. - * - * **Exclusive to inline formatting use.** - */ - changeInlineTag(token: Token) { - let { tagRange } = provideTokenRanges(token), - { tagStr, curRange, curSelectionIndex } = this.state; - if (tagStr === undefined) { return } - if (!token.validTag) { tagRange.to = tagRange.from } - this.state.pushChange({ from: tagRange.from, to: tagRange.to, insert: tagStr }); - if (curRange.empty && curRange.from == tagRange.from) { - this.state.pushSelectionShift(curSelectionIndex, tagStr.length); - } - } - /** - * Extend formatting range to cover across the tokens that are in the - * current token map, and across the current selection. Should be run - * with condition the selection touched at least two tokens, or a token - * with the selection range exceeds the token range, at least one of its - * side. - * - * **Exclusive to inline formatting use.** - */ - extend() { - let { curRange, delimStr, tagStr, curTokenMap, mappedTokens } = this.state, - tokens = this.parser.getTokens(this.state.level), - firstTokenIndex = curTokenMap?.[0], - lastTokenIndex = curTokenMap?.at(-1), - firstToken = mappedTokens[0], - lastToken = mappedTokens[mappedTokens.length - 1], - isLastTokenAtEdge = false, - fusedRange: PlainRange = { from: firstTokenIndex ?? 0, to: (lastTokenIndex ?? 0) + 1 }; - // If the start offset of the current range touches a token, then - // eliminate only its closing delimiter. - if (firstToken && firstToken.from <= curRange.from) { - let { tagRange, closeRange } = provideTokenRanges(firstToken); - fusedRange.from++; - if (tagStr !== undefined) { - this.state.pushChange( - firstToken.validTag - ? { from: tagRange.from, to: tagRange.to, insert: tagStr } - : { from: tagRange.from, insert: tagStr } - ); - } - this.state.pushChange(closeRange); - } else { - this.state.pushChange({ from: curRange.from, insert: delimStr }); - } - if (lastToken && lastToken.to >= curRange.to) { - isLastTokenAtEdge = true; - fusedRange.to--; - } - // Tokens that don't touch one of the current range side have to be fused - // (i.e. eliminate all of their delimiter). - for (let i = fusedRange.from; i < fusedRange.to; i++) { - let tokenIndex = curTokenMap?.[i]; - if (tokenIndex !== undefined) { - this.remove(tokens[tokenIndex]); - } - } - // If the end offset of the current range touches a token, then eliminate - // only its opening delimiter. - if (isLastTokenAtEdge) { - let { openRange, tagRange } = provideTokenRanges(lastToken!); - this.state.pushChange(openRange); - if (lastToken!.validTag) { - this.state.pushChange(tagRange); - } - if (lastToken!.status != TokenStatus.ACTIVE) { - this.state.pushChange({ from: curRange.to, insert: delimStr }); - } - } else { - this.state.pushChange({ from: curRange.to, insert: delimStr }); - } - } - /** Run only when tidier formatting is switched off. */ - toggleInlineDelim() { - let { curRange, delimStr } = this.state, - delimLen = delimStr.length, - selectedStrWithOverlappedEdge = this.doc.sliceString(curRange.from - delimLen, curRange.to + delimLen), - selectedStr = selectedStrWithOverlappedEdge.slice(delimLen, -delimLen); - if (isSurroundedByDelimiter(selectedStr, delimStr)) { - this.state.pushChange([ - { from: curRange.from, to: curRange.from + delimLen }, - { from: curRange.to - delimLen, to: curRange.to } - ]); - } else if (isSurroundedByDelimiter(selectedStrWithOverlappedEdge, delimStr)) { - this.state.pushChange([ - { from: curRange.from - delimLen, to: curRange.from }, - { from: curRange.to, to: curRange.to + delimLen } - ]); - } else { - let detectWord = false; - this.wrap(detectWord); - } - } - addBlockTag(block: { start: number, end: number }) { - let { doc } = this, - { delimStr } = this.state, - blockStart = doc.line(block.start), - blockEnd = doc.line(block.end - 1), - tagStr = this.state.tagStr ?? ""; - if (!isBlockStart(doc, blockStart)) { delimStr = "\n" + delimStr } - this.state.pushChange({ from: blockStart.from, insert: delimStr + tagStr }); - if (!isBlockEnd(doc, blockEnd)) { - this.state.pushChange({ from: blockEnd.to, insert: "\n" }); - } - } - changeBlockTag(token: Token) { - let { tagRange } = provideTokenRanges(token), - { tagStr } = this.state; - this.state.pushChange({ from: tagRange.from, to: tagRange.to, insert: tagStr }); - } - toggleBlockTag() { - let doc = this.doc, - blocks = getBlocks(doc, this.state.curRange), - { mappedTokens, tagStr } = this.state; - for (let i = 0, j = 0; i < blocks.length; i++) { - let block = blocks[i], - token: Token | undefined = mappedTokens[j], - blockStart = doc.line(block.start), - tagRange = token ? getTagRange(token) : undefined; - if (!token || blockStart.from < token.from) { - this.addBlockTag(block); - } else if (token.status != TokenStatus.ACTIVE || blockStart.from > tagRange!.to + 1) { - this.addBlockTag(block); j++; - } else if (tagStr === undefined) { - this.remove(token); j++; - } else { - this.changeBlockTag(token); j++; - } - } - } - /** - * Remove formatting based on the token by erasing its delimiter. Should - * be run on fused token in `extend()`, or when the cursor is empty and - * within the token. - */ - remove(token: Token) { - let { openRange, tagRange, closeRange } = provideTokenRanges(token), - removedRanges = [openRange, tagRange, closeRange]; - if (!token.validTag) { removedRanges.remove(tagRange) } - if (token.level == TokenLevel.BLOCK && token.to > tagRange.to) { tagRange.to++ } - this.state.pushChange(removedRanges); - } - removeAll() { - do { - let { mappedTokens } = this.state; - mappedTokens.forEach(token => { - this.remove(token); - }); - } while (this.state.advance()) - } - composeChanges() { - return this.state.changeSet = ChangeSet.of(this.state.changes, this.state.docLen); - } - remapSelection() { - let { selectionRanges, changeSet, selectionShift: selectionChanges } = this.state; - for (let i = 0; i < selectionRanges.length; i++) { - let range = selectionRanges[i].map(changeSet), - shift = selectionChanges[i]?.shift; - if (shift) { - range = EditorSelection.range(range.to + shift, range.from + shift); - } - selectionRanges[i] = range; - } - return this.state.remappedSelection = EditorSelection.create(selectionRanges); - } - dispatchToView() { - let { changeSet, remappedSelection } = this.state; - this.view.dispatch( - { changes: changeSet }, - { selection: remappedSelection, sequential: true } - ); - this.clearState(); - } -} \ No newline at end of file diff --git a/src/editor-mode/formatting/FormatterState.ts b/src/editor-mode/formatting/FormatterState.ts deleted file mode 100644 index a80d240..0000000 --- a/src/editor-mode/formatting/FormatterState.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ChangeSet, ChangeSpec, EditorSelection, SelectionRange, Text } from "@codemirror/state"; -import { Format, TokenLevel } from "src/enums"; -import { BlockFormat, InlineFormat, PluginSettings, TokenGroup } from "src/types"; -import { SelectionObserver } from "src/editor-mode/observer"; -import { isInlineFormat } from "src/format-configs/utils"; -import { trimSelection } from "src/editor-mode/selection"; -import { BlockRules, InlineRules } from "src/format-configs"; - -export class FormatterState { - docLen: number; - tokens: TokenGroup; - selectionRanges: SelectionRange[]; - curSelectionIndex: number = 0; - tokenMaps: (number[] | undefined)[]; - curTokenMap: number[] | undefined; - level: TokenLevel; - type: Format; - delimStr: string; - tagStr: string | undefined; - precise: boolean; - changes: ChangeSpec[] = []; - selectionShift: Partial> = {}; - changeSet: ChangeSet; - remappedSelection: EditorSelection; - constructor(type: Format, doc: Text, selectionObserver: SelectionObserver, settings: PluginSettings, tagStr?: string) { - this.type = type; - this.docLen = doc.length; - this.level = isInlineFormat(type) ? TokenLevel.INLINE : TokenLevel.BLOCK; - this.tagStr = tagStr; - this.precise = settings.tidyFormatting; - this.tokens = selectionObserver.parser.getTokens(this.level); - this.tokenMaps = selectionObserver.pickMaps(type); - this.curTokenMap = this.tokenMaps[this.curSelectionIndex]; - if (this.precise) { - let trimmedSelection = trimSelection(selectionObserver.selection, doc); - this.selectionRanges = trimmedSelection.ranges.map(range => range); - } else { - this.selectionRanges = selectionObserver.selection.ranges.map(range => range); - } - if (tagStr && this.level == TokenLevel.INLINE) { - this.tagStr = "{" + tagStr + "}"; - } else if (this.level == TokenLevel.BLOCK) { - this.tagStr += "\n"; - } - let { char, length: delimLen } = this.level == TokenLevel.INLINE - ? InlineRules[type as InlineFormat] - : BlockRules[type as BlockFormat]; - this.delimStr = char.padEnd(delimLen, char); - } - get curRange() { - return this.selectionRanges[this.curSelectionIndex]; - } - get mappedTokens(): TokenGroup { - if (!this.curTokenMap) { return [] } - return this.curTokenMap.map(index => this.tokens[index]); - } - advance() { - this.curSelectionIndex++ - if (this.curSelectionIndex >= this.selectionRanges.length) { - return false; - } - this.curTokenMap = this.tokenMaps[this.curSelectionIndex]; - return true; - } - pushChange(spec: ChangeSpec) { - this.changes.push(spec); - } - pushSelectionShift(index: number, shift: number) { - this.selectionShift[index] = { shift }; - } -} \ No newline at end of file diff --git a/src/editor-mode/formatting/commands.ts b/src/editor-mode/formatting/commands.ts new file mode 100644 index 0000000..7b4c51d --- /dev/null +++ b/src/editor-mode/formatting/commands.ts @@ -0,0 +1,166 @@ +import { Command } from "obsidian"; +import { EditorView } from "@codemirror/view"; +import { Format } from "src/enums"; +import { CtxMenuCommand } from "src/types"; +import { instancesStore } from "src/editor-mode/cm-extension"; +import { TagMenu } from "src/editor-mode/ui-components"; +import { Formatter } from "src/editor-mode/formatting/formatter"; + +function _getFormatter(view: EditorView): Formatter { + return view.state.facet(instancesStore).formatter; +} + +export const insertionCmd: CtxMenuCommand = { + id: "toggle-insertion", + name: "Toggle insertion", + icon: "underline", + ctxMenuTitle: "Insertion", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + let formatter = _getFormatter(editorView); + formatter.startFormat(editorView, Format.INSERTION); + } + }, + type: Format.INSERTION +} + +export const spoilerCmd: CtxMenuCommand = { + id: "toggle-spoiler", + name: "Toggle spoiler", + icon: "rectangle-horizontal", + ctxMenuTitle: "Spoiler", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + let formatter = _getFormatter(editorView); + formatter.startFormat(editorView, Format.SPOILER); + } + }, + type: Format.SPOILER +} + +export const superscriptCmd: CtxMenuCommand = { + id: "toggle-superscript", + name: "Toggle superscript", + icon: "superscript", + ctxMenuTitle: "Superscript", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + let formatter = _getFormatter(editorView); + formatter.startFormat(editorView, Format.SUPERSCRIPT); + } + }, + type: Format.SUPERSCRIPT +} + +export const subscriptCmd: CtxMenuCommand = { + id: "toggle-subscript", + name: "Toggle subscript", + icon: "subscript", + ctxMenuTitle: "Subscript", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + let formatter = _getFormatter(editorView); + formatter.startFormat(editorView, Format.SUBSCRIPT); + } + }, + type: Format.SUBSCRIPT +} + +export const customHighlightCmd: CtxMenuCommand = { + id: "toggle-custom-highlight", + name: "Toggle custom highlight", + icon: "highlighter", + ctxMenuTitle: "Custom highlight", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + let formatter = _getFormatter(editorView); + formatter.startFormat(editorView, Format.HIGHLIGHT, undefined, false, true); + } + }, + type: Format.HIGHLIGHT +} + +export const customSpanCmd: CtxMenuCommand = { + id: "toggle-custom-span", + name: "Toggle custom span", + icon: "brush", + ctxMenuTitle: "Custom span", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + let formatter = _getFormatter(editorView); + formatter.startFormat(editorView, Format.CUSTOM_SPAN, undefined, false, true); + } + }, + type: Format.CUSTOM_SPAN +} + +export const fencedDivCmd: Command = { + id: "toggle-fenced-div", + name: "Toggle fenced div", + icon: "list-plus", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + let formatter = _getFormatter(editorView); + formatter.startFormat(editorView, Format.FENCED_DIV, undefined, false, true); + } + }, +} + +export const colorMenuCmd: Command = { + id: "show-color-menu", + name: "Show highlight color menu", + icon: "palette", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + TagMenu.create(editorView, Format.HIGHLIGHT).showMenu(); + } + } +} + +export const spanTagMenuCmd: Command = { + id: "show-custom-span-tag-menu", + name: "Show custom span tag menu", + icon: "shapes", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + TagMenu.create(editorView, Format.CUSTOM_SPAN).showMenu(); + } + } +} + +export const divTagMenuCmd: Command = { + id: "show-fenced-div-tag-menu", + name: "Show fenced div tag menu", + icon: "shapes", + editorCallback: (editor, ctx) => { + let editorView = (editor.activeCm || editor.cm); + if (editorView instanceof EditorView) { + TagMenu.create(editorView, Format.FENCED_DIV).showMenu(); + } + } +} + +export const ctxMenuCommands = [ + insertionCmd, + spoilerCmd, + superscriptCmd, + subscriptCmd, + customHighlightCmd, + customSpanCmd +]; + +export const editorCommands = [ + fencedDivCmd, + colorMenuCmd, + spanTagMenuCmd, + divTagMenuCmd +].concat(ctxMenuCommands); \ No newline at end of file diff --git a/src/editor-mode/formatting/formatter.ts b/src/editor-mode/formatting/formatter.ts new file mode 100644 index 0000000..d70ecb7 --- /dev/null +++ b/src/editor-mode/formatting/formatter.ts @@ -0,0 +1,435 @@ +import { ChangeSet, ChangeSpec, EditorSelection, EditorState, SelectionRange, Text } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { Format, TokenLevel, TokenStatus } from "src/enums"; +import { BlockFormat, InlineFormat, PluginSettings, TokenGroup, PlainRange, Token } from "src/types"; +import { SelectionObserver } from "src/editor-mode/preprocessor/observer"; +import { EditorParser } from "src/editor-mode/preprocessor/parser"; +import { getTagRange, provideTokenPartsRanges } from "src/editor-mode/utils/token-utils"; +import { getBlocks, isBlockStart, isBlockEnd } from "src/editor-mode/utils/doc-utils"; +import { trimSelection } from "src/editor-mode/utils/selection-utils"; +import { isTouched } from "src/editor-mode/utils/range-utils"; +import { TagMenu } from "src/editor-mode/ui-components"; +import { BlockRules, InlineRules } from "src/format-configs/rules"; +import { isInlineFormat, supportTag } from "src/format-configs/format-utils"; + +function _isSurroundedByDelimiter(str: string, delimStr: string): boolean { + return str.startsWith(delimStr) && str.endsWith(delimStr); +} + +class _FormatterState { + public editorState: EditorState; + public doc: Text; + public tokens: TokenGroup; + public selectionRanges: SelectionRange[]; + public curSelectionIndex: number = 0; + public tokenMaps: (number[] | undefined)[]; + public curTokenMap: number[] | undefined; + public level: TokenLevel; + public type: Format; + public delimStr: string; + public tagStr: string | undefined; + public precise: boolean; + public changes: ChangeSpec[] = []; + public selectionShift: Partial> = {}; + public changeSet: ChangeSet; + public remappedSelection: EditorSelection; + + constructor(type: Format, editorState: EditorState, selectionObserver: SelectionObserver, settings: PluginSettings, tagStr?: string) { + this.type = type; + this.editorState = editorState; + this.doc = editorState.doc; + this.level = isInlineFormat(type) ? TokenLevel.INLINE : TokenLevel.BLOCK; + this.tagStr = tagStr; + this.precise = settings.tidyFormatting; + this.tokens = selectionObserver.parser.getTokens(this.level); + this.tokenMaps = selectionObserver.pickMaps(type); + this.curTokenMap = this.tokenMaps[this.curSelectionIndex]; + if (this.precise) { + let trimmedSelection = trimSelection(selectionObserver.selection, this.doc); + this.selectionRanges = trimmedSelection.ranges.map(range => range); + } else { + this.selectionRanges = selectionObserver.selection.ranges.map(range => range); + } + if (tagStr && this.level == TokenLevel.INLINE) { + this.tagStr = "{" + tagStr + "}"; + } else if (this.level == TokenLevel.BLOCK) { + this.tagStr += "\n"; + } + let { char, length: delimLen } = this.level == TokenLevel.INLINE + ? InlineRules[type as InlineFormat] + : BlockRules[type as BlockFormat]; + this.delimStr = char.padEnd(delimLen, char); + } + + public get curRange(): SelectionRange { + return this.selectionRanges[this.curSelectionIndex]; + } + + public get mappedTokens(): TokenGroup { + if (!this.curTokenMap) { return [] } + return this.curTokenMap.map(index => this.tokens[index]); + } + + public advance(): boolean { + this.curSelectionIndex++ + if (this.curSelectionIndex >= this.selectionRanges.length) { + return false; + } + this.curTokenMap = this.tokenMaps[this.curSelectionIndex]; + return true; + } + + public pushChange(spec: ChangeSpec): void { + this.changes.push(spec); + } + + public pushSelectionShift(index: number, shift: number): void { + this.selectionShift[index] = { shift }; + } +} + +export class Formatter { + private readonly _parser: EditorParser; + private readonly _observer: SelectionObserver; + private readonly _settings: PluginSettings; + public state: _FormatterState; + + constructor(parser: EditorParser, observer: SelectionObserver) { + this._parser = parser; + this._observer = observer; + this._settings = this._parser.settings; + } + + public startFormat(view: EditorView, type: Format, tagStr?: string, forceRemove?: boolean, showMenu?: boolean): void { + if (forceRemove) { + tagStr = undefined; + } + if (!this._settings.openTagMenuAfterFormat) { + showMenu = false; + } + + let tokenMaps = this._observer.pickMaps(type), + editorState = view.state; + if (tokenMaps.length == 1 && !tokenMaps[0]?.length && showMenu) { + TagMenu.create(view, type).showMenu(); + return; + } + this._defineState(editorState, type, tagStr); + + if (forceRemove) { + this._removeAll() + } else if (this.state.level == TokenLevel.INLINE) { + this._formatInline(); + } else { + this._formatBlock(); + } + + this._composeChanges(); + this._remapSelection(); + this._dispatchToView(view); + } + + private _defineState(editorState: EditorState, type: Format, tagStr?: string): void { + this.state = new _FormatterState(type, editorState, this._observer, this._settings, tagStr); + } + + private _clearState(): void { + (this.state as unknown as undefined) = undefined; + } + + private _formatInline(): void { + let state = this.state, + tokens = this.state.tokens; + + do { + let { curTokenMap, curRange, tagStr, precise } = state, + firstToken = curTokenMap ? tokens[curTokenMap[0]] : null; + if (!precise) { + this._toggleInlineDelim(); + } else if (!firstToken) { + this._wrap(); + } else if (firstToken.from > curRange.from || firstToken.to < curRange.to) { + this._extend(); + } else if (firstToken.status != TokenStatus.ACTIVE) { + this._close(firstToken); + } else if (tagStr !== undefined) { + this._changeInlineTag(firstToken); + } else if (curRange.empty) { + this._remove(firstToken); + } else { + this._breakApart(firstToken); + } + } while (state.advance()); + } + + private _formatBlock(): void { + do { + this._toggleBlockTag(); + } while (this.state.advance()); + } + + /** + * Use wrap only when the current range didn't meet any token. + * + * **Exclusive to inline formatting use.** + */ + private _wrap(detectWord = true): void { + let { curRange, delimStr, tagStr } = this.state; + // If the current selection is actually an empty cursor, attempt to use + // word range if any. + if (curRange.empty) { + let cursorOffset = curRange.from; + if (detectWord) { + curRange = this.state.editorState.wordAt(curRange.from) ?? curRange; + } + if (cursorOffset == curRange.to) { + let shiftAmount = delimStr.length; + if (cursorOffset == curRange.from) { + shiftAmount += tagStr?.length ?? 0; + } + this.state.pushSelectionShift(this.state.curSelectionIndex, shiftAmount); + } + } + this.state.pushChange([ + { from: curRange.from, insert: delimStr + (tagStr ?? "") }, + { from: curRange.to, insert: delimStr } + ]); + } + + /** + * Add corresponding closing delimiter to the token that's currently + * inactive. Should be run when the cursor is within or the same range as + * the token. + * + * **Exclusive to inline formatting use.** + * + * @param token should be in `INACTIVE` status. + */ + private _close(token: Token): void { + let { delimStr, tagStr } = this.state; + if (supportTag(token.type) && tagStr) { + this.state.pushChange({ from: token.from + token.openLen, insert: tagStr }); + } + this.state.pushChange({ from: token.to, insert: delimStr }); + } + + /** + * Break the current token into two new tokens if the current range + * was within the content range of the token and didn't touch both + * delimiters, or narrow it if one of both delimiters was touched, or + * behave like `remove()`. Should be run when the current selection + * range within the token. + * + * **Exclusive to inline formatting use.** + */ + private _breakApart(token: Token): void { + let { openRange, tagRange, closeRange } = provideTokenPartsRanges(token), + { curRange, delimStr } = this.state, + tagStr = this.state.doc.sliceString(tagRange.from, tagRange.to); + // Remove opening delimiter when the current range touched it. Otherwise, + // insert corresponding delimiter at the start offset. + if (isTouched(curRange.from, openRange)) { + this.state.pushChange(openRange); + } else { + this.state.pushChange({ from: curRange.from, insert: delimStr }); + } + // Remove closing delimiter when the current range touched it. Otherwise, + // insert corresponding delimiter at the end offset. + if (isTouched(curRange.to, closeRange)) { + this.state.pushChange(closeRange); + } else { + // This delimiter should be opening. To have the same tag as the original + // we need to copy the original tag to the newly created one. + this.state.pushChange({ from: curRange.to, insert: delimStr + tagStr }); + } + } + + /** + * Extend formatting range to cover across the tokens that are in the + * current token map, and across the current selection. Should be run + * with condition the selection touched at least two tokens, or a token + * with the selection range exceeds the token range, at least one of its + * side. + * + * **Exclusive to inline formatting use.** + */ + private _extend(): void { + let { curRange, delimStr, tagStr, curTokenMap, mappedTokens } = this.state, + tokens = this._parser.getTokens(this.state.level), + firstTokenIndex = curTokenMap?.[0], + lastTokenIndex = curTokenMap?.at(-1), + firstToken = mappedTokens[0], + lastToken = mappedTokens[mappedTokens.length - 1], + isLastTokenAtEdge = false, + fusedRange: PlainRange = { from: firstTokenIndex ?? 0, to: (lastTokenIndex ?? 0) + 1 }; + // If the start offset of the current range touches a token, then + // eliminate only its closing delimiter. + if (firstToken && firstToken.from <= curRange.from) { + let { tagRange, closeRange } = provideTokenPartsRanges(firstToken); + fusedRange.from++; + if (tagStr !== undefined) { + this.state.pushChange( + firstToken.validTag + ? { from: tagRange.from, to: tagRange.to, insert: tagStr } + : { from: tagRange.from, insert: tagStr } + ); + } + this.state.pushChange(closeRange); + } else { + this.state.pushChange({ from: curRange.from, insert: delimStr }); + } + if (lastToken && lastToken.to >= curRange.to) { + isLastTokenAtEdge = true; + fusedRange.to--; + } + // Tokens that don't touch one of the current range side have to be fused + // (i.e. eliminate all of their delimiter). + for (let i = fusedRange.from; i < fusedRange.to; i++) { + let tokenIndex = curTokenMap?.[i]; + if (tokenIndex !== undefined) { + this._remove(tokens[tokenIndex]); + } + } + // If the end offset of the current range touches a token, then eliminate + // only its opening delimiter. + if (isLastTokenAtEdge) { + let { openRange, tagRange } = provideTokenPartsRanges(lastToken!); + this.state.pushChange(openRange); + if (lastToken!.validTag) { + this.state.pushChange(tagRange); + } + if (lastToken!.status != TokenStatus.ACTIVE) { + this.state.pushChange({ from: curRange.to, insert: delimStr }); + } + } else { + this.state.pushChange({ from: curRange.to, insert: delimStr }); + } + } + + /** + * Replace the tag of targetted token, or insert as a new if the current + * tag was invalid or didn't exist. + * + * **Exclusive to inline formatting use.** + */ + private _changeInlineTag(token: Token): void { + let { tagRange } = provideTokenPartsRanges(token), + { tagStr, curRange, curSelectionIndex } = this.state; + if (tagStr === undefined) { return } + if (!token.validTag) { tagRange.to = tagRange.from } + this.state.pushChange({ from: tagRange.from, to: tagRange.to, insert: tagStr }); + if (curRange.empty && curRange.from == tagRange.from) { + this.state.pushSelectionShift(curSelectionIndex, tagStr.length); + } + } + + /** Run only when tidier formatting is switched off. */ + private _toggleInlineDelim(): void { + let { curRange, delimStr } = this.state, + delimLen = delimStr.length, + selectedStrWithOverlappedEdge = this.state.doc.sliceString(curRange.from - delimLen, curRange.to + delimLen), + selectedStr = selectedStrWithOverlappedEdge.slice(delimLen, -delimLen); + if (_isSurroundedByDelimiter(selectedStr, delimStr)) { + this.state.pushChange([ + { from: curRange.from, to: curRange.from + delimLen }, + { from: curRange.to - delimLen, to: curRange.to } + ]); + } else if (_isSurroundedByDelimiter(selectedStrWithOverlappedEdge, delimStr)) { + this.state.pushChange([ + { from: curRange.from - delimLen, to: curRange.from }, + { from: curRange.to, to: curRange.to + delimLen } + ]); + } else { + let detectWord = false; + this._wrap(detectWord); + } + } + + private _addBlockTag(block: { start: number, end: number }): void { + let { doc } = this.state, + { delimStr } = this.state, + blockStart = doc.line(block.start), + blockEnd = doc.line(block.end - 1), + tagStr = this.state.tagStr ?? ""; + if (!isBlockStart(doc, blockStart)) { delimStr = "\n" + delimStr } + this.state.pushChange({ from: blockStart.from, insert: delimStr + tagStr }); + if (!isBlockEnd(doc, blockEnd)) { + this.state.pushChange({ from: blockEnd.to, insert: "\n" }); + } + } + + private _changeBlockTag(token: Token): void { + let { tagRange } = provideTokenPartsRanges(token), + { tagStr } = this.state; + this.state.pushChange({ from: tagRange.from, to: tagRange.to, insert: tagStr }); + } + + private _toggleBlockTag(): void { + let { doc } = this.state, + blocks = getBlocks(doc, this.state.curRange), + { mappedTokens, tagStr } = this.state; + for (let i = 0, j = 0; i < blocks.length; i++) { + let block = blocks[i], + token: Token | undefined = mappedTokens[j], + blockStart = doc.line(block.start), + tagRange = token ? getTagRange(token) : undefined; + if (!token || blockStart.from < token.from) { + this._addBlockTag(block); + } else if (token.status != TokenStatus.ACTIVE || blockStart.from > tagRange!.to + 1) { + this._addBlockTag(block); j++; + } else if (tagStr === undefined) { + this._remove(token); j++; + } else { + this._changeBlockTag(token); j++; + } + } + } + + /** + * Remove formatting based on the token by erasing its delimiter. Should + * be run on fused token in `extend()`, or when the cursor is empty and + * within the token. + */ + private _remove(token: Token): void { + let { openRange, tagRange, closeRange } = provideTokenPartsRanges(token), + removedRanges = [openRange, tagRange, closeRange]; + if (!token.validTag) { removedRanges.remove(tagRange) } + if (token.level == TokenLevel.BLOCK && token.to > tagRange.to) { tagRange.to++ } + this.state.pushChange(removedRanges); + } + + private _removeAll(): void { + do { + let { mappedTokens } = this.state; + mappedTokens.forEach(token => { + this._remove(token); + }); + } while (this.state.advance()); + } + + private _composeChanges(): ChangeSet { + return this.state.changeSet = ChangeSet.of(this.state.changes, this.state.doc.length); + } + + private _remapSelection(): EditorSelection { + let { selectionRanges, changeSet, selectionShift: selectionChanges } = this.state; + for (let i = 0; i < selectionRanges.length; i++) { + let range = selectionRanges[i].map(changeSet), + shift = selectionChanges[i]?.shift; + if (shift) { + range = EditorSelection.range(range.to + shift, range.from + shift); + } + selectionRanges[i] = range; + } + return this.state.remappedSelection = EditorSelection.create(selectionRanges); + } + + private _dispatchToView(view: EditorView): void { + let { changeSet, remappedSelection } = this.state; + view.dispatch( + { changes: changeSet }, + { selection: remappedSelection, sequential: true } + ); + this._clearState(); + } +} \ No newline at end of file diff --git a/src/editor-mode/formatting/formatting-utils/index.ts b/src/editor-mode/formatting/formatting-utils/index.ts deleted file mode 100644 index 14f90b5..0000000 --- a/src/editor-mode/formatting/formatting-utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./isSurroundedByDelimiter"; \ No newline at end of file diff --git a/src/editor-mode/formatting/formatting-utils/isSurroundedByDelimiter.ts b/src/editor-mode/formatting/formatting-utils/isSurroundedByDelimiter.ts deleted file mode 100644 index d0b6ab7..0000000 --- a/src/editor-mode/formatting/formatting-utils/isSurroundedByDelimiter.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isSurroundedByDelimiter(str: string, delimStr: string) { - return str.startsWith(delimStr) && str.endsWith(delimStr); -} \ No newline at end of file diff --git a/src/editor-mode/formatting/index.ts b/src/editor-mode/formatting/index.ts deleted file mode 100644 index c72476e..0000000 --- a/src/editor-mode/formatting/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./Formatter"; -export * from "./FormatterState"; \ No newline at end of file diff --git a/src/editor-mode/observer/ActivityRecorder.ts b/src/editor-mode/observer/ActivityRecorder.ts deleted file mode 100644 index ce4c2f1..0000000 --- a/src/editor-mode/observer/ActivityRecorder.ts +++ /dev/null @@ -1,24 +0,0 @@ -export class ActivityRecorder { - private isParsing: boolean = false; - private isObserving: boolean = false; - private verifier = { parse: new Set(), observe: new Set() }; - enter(entry: { isParsing?: boolean, isObserving?: boolean }) { - if (entry.isParsing) { this.isParsing = entry.isParsing } - if (entry.isObserving) { this.isObserving = entry.isObserving } - } - verify(key: unknown, activity: "parse" | "observe", reset = false) { - if (this.verifier[activity].has(key)) { return null } - let acted = activity == "parse" ? this.isParsing : this.isObserving; - if (reset) { this.reset(activity) } - else { this.verifier[activity].add(key) } - return acted; - } - private reset(activity: "parse" | "observe") { - this.verifier[activity].clear(); - if (activity == "parse") { - this.isParsing = false; - } else { - this.isObserving = false; - } - } -} \ No newline at end of file diff --git a/src/editor-mode/observer/SelectionObserver.ts b/src/editor-mode/observer/SelectionObserver.ts deleted file mode 100644 index 5f225df..0000000 --- a/src/editor-mode/observer/SelectionObserver.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { Format, TokenLevel } from "src/enums"; -import { IndexCache, PlainRange, Region, Token, TokenGroup } from "src/types"; -import { Parser } from "src/editor-mode/parser"; -import { EditorSelection } from "@codemirror/state"; -import { joinRegions } from "src/editor-mode/range-utils"; -import { isInlineFormat } from "src/format-configs/utils"; - -/** - * Appliance for managing selection-based decorations that are tied to - * the tokens effectively, avoiding, in some cases, over-iteration and - * redrawing decorations which indeed aren't selected and can be reused - * again. Only applied to the line breaks and fenced div opening - * decorations, which we can't bound decorating them with the viewport, - * not even visibleRanges. Although, it's still useful for formatting - * commands in both block and inline tokens. - */ -export class SelectionObserver { - // Selected, changed, and filter regions were configured in the state - // update, while the visible regions were configured in the view update. - /** Tokens that are touched or intersected by selection. */ - selectedRegions: Record = { - [TokenLevel.INLINE]: [], - [TokenLevel.BLOCK]: [] - }; - /** - * Mapped indexes of tokens to each index of corresponding selection that - * is touched by them. For example, `maps[0]` contains all indexes of - * tokens that touched the first selection. May be empty if corresponding - * selection wasn't touched by any token. - */ - maps: Partial>[] = []; - /** - * All selection-based decorations, e.g. omitted delimiters, that are - * associated with these tokens should be redrawn. - */ - changedRegions: Record = { - [TokenLevel.INLINE]: [], - [TokenLevel.BLOCK]: [] - }; - /** - * Collection of offset ranges, to be used as filter ranges for the - * previously drawn selection-based decorations. - */ - filterRegions: Record = { - [TokenLevel.INLINE]: [], - [TokenLevel.BLOCK]: [] - }; - indexCaches: Record = { - [TokenLevel.INLINE]: { number: 0 }, - [TokenLevel.BLOCK]: { number: 0 }, - selection: { number: 0 } - }; - handlers: Record unknown)[]> = { - [TokenLevel.INLINE]: [], - [TokenLevel.BLOCK]: [] - }; - /** Indicates that the observer was run in current EditorState. */ - isObserving: boolean; - selection: EditorSelection; - parser: Parser; - constructor(parser: Parser) { - this.parser = parser; - } - /** - * Observe given selection to catch tokens that touched it, then map the - * old selected region with the new one, producing latest changed region - * that can be used to produce RangeSet filter. - */ - observe(selection: EditorSelection, isParsing: boolean, restart = false): void { - this.selection = selection; - this.checkIndexCache(); - this.isObserving = true; - for (let level = TokenLevel.BLOCK as TokenLevel; level <= TokenLevel.INLINE; level++) { - let oldSelectedRegion = this.selectedRegions[level]; - this.locateSelectedTokens(level); - // At the moment, changed and filter region are only applied to block- - // level tokens. - if (level == TokenLevel.INLINE) { continue } - this.mapChangedRegion(level, oldSelectedRegion, isParsing, restart); - this.createFilter(level); - } - } - /** - * Restart the observer to its initial state, i.e. clear old selected - * region and reobserve given selection. - */ - restartObserver(selection: EditorSelection, isParsing: boolean): void { - this.selectedRegions = { - [TokenLevel.BLOCK]: [], - [TokenLevel.INLINE]: [] - }; - this.observe(selection, isParsing, true); - } - /** - * Generate region of the tokens that touch or intersect the cursor or - * selection. - */ - locateSelectedTokens(level: TokenLevel): Region { - let newSelectedRegion: Region = [], - curRange: PlainRange | undefined; - this.clearMaps(); - // Moving the cached index to the fore edge of the selection and then - // starting to look ahead involved tokens is not as efficient as way that - // look involved tokens behind without altering the cached index. Note - // that the efficiency of this process isn't so noticable in case of few - // tokens exist. - this.lookBehind(level, (token, index, selectionIndex) => { - if (!this.maps[selectionIndex]?.[level]) { - this.maps[selectionIndex] = { - [level]: [index] - }; - } else { - this.maps[selectionIndex][level].unshift(index); - } - if (!curRange || curRange.from != index + 1) { - curRange = { from: index, to: index + 1 }; - newSelectedRegion.unshift(curRange); - } else { - curRange.from--; - } - }); - curRange = newSelectedRegion.at(-1); - this.lookAhead(level, (token, index, selectionIndex) => { - if (!this.maps[selectionIndex]?.[level]) { - this.maps[selectionIndex] = { - [level]: [index] - }; - } else { - this.maps[selectionIndex][level].push(index); - } - if (!curRange || curRange.to != index) { - curRange = { from: index, to: index + 1 }; - newSelectedRegion.push(curRange); - } else { - curRange.to++; - } - }); - this.indexCaches[level].number = newSelectedRegion[0]?.from ?? this.indexCaches[level].number; - return this.selectedRegions[level] = newSelectedRegion; - } - /** - * Will move the cached index to the last token if it's went out of the - * range. - */ - checkIndexCache(): void { - if (this.indexCaches[TokenLevel.INLINE].number >= this.parser.inlineTokens.length) { - this.indexCaches[TokenLevel.INLINE].number = Math.max(this.parser.inlineTokens.length - 1, 0); - } - if (this.indexCaches[TokenLevel.BLOCK].number >= this.parser.blockTokens.length) { - this.indexCaches[TokenLevel.BLOCK].number = Math.max(this.parser.blockTokens.length - 1, 0); - } - } - /** - * Look the tokens behind untill the start offset of the first selection. - * Runs the callback when the current token touches the selection. - */ - lookBehind(level: TokenLevel, callback: (token: Token, index: number, selectionIndex: number) => unknown): void { - let selectionRanges = this.selection.ranges, - selectionIndex = selectionRanges.length - 1, - tokens = this.parser.getTokens(level), - goalOffset = selectionRanges[0].from; - // Using index taken from the cache as an anchor. - for (let i = this.indexCaches[level].number - 1; i >= 0 && goalOffset <= tokens[i].to; i--) { - while (selectionRanges[selectionIndex].from > tokens[i].to) { selectionIndex-- } - if (!selectionRanges[selectionIndex]) { break } - if (selectionRanges[selectionIndex].to < tokens[i].from) { continue } - callback(tokens[i], i, selectionIndex); - } - } - /** - * Look the tokens ahead untill the end offset of the last selection. - * Runs the callback when the current token touches the selection. - */ - lookAhead(level: TokenLevel, callback: (token: Token, index: number, selectionIndex: number) => boolean | void): void { - let selectionRanges = this.selection.ranges, - selectionIndex = 0, - tokens = this.parser.getTokens(level), - goalOffset = selectionRanges.at(-1)!.to; - // Using index taken from the cache as an anchor. - for (let i = this.indexCaches[level].number; i < tokens.length && goalOffset >= tokens[i].from; i++) { - while (selectionRanges[selectionIndex].to < tokens[i].from) { selectionIndex++ } - if (!selectionRanges[selectionIndex]) { break } - if (selectionRanges[selectionIndex].from > tokens[i].to) { continue } - callback(tokens[i], i, selectionIndex); - } - } - /** - * Create filter region that will be used as filtering boundary in - * `RangeSet.filter()`. - */ - createFilter(level: TokenLevel): Region { - let tokens = level == TokenLevel.INLINE - ? this.parser.inlineTokens - : this.parser.blockTokens; - if (!tokens.length) { - return this.filterRegions[level] = [Object.assign({}, this.parser.lastStreamPoint)]; - } - let filterRegion: Region = [], - changedRegion = this.changedRegions[level], - // Inline tokens can be touched each other. To avoid filtering uninvolved - // decoration, filtering offset should be more inwardly indented. Doing - // so in block-level decorations makes filtering miss them due to - // frontmost position and using line decoration which the actually length - // is 0. - side = level == TokenLevel.INLINE ? 1 : 0; - for (let i = 0; i < changedRegion.length; i++) { - let range = changedRegion[i], - filterFrom: number, - filterTo: number; - // Sometimes, the range can exceed the length of the tokens. - if (range.to > tokens.length) { - if (range.from >= tokens.length) { - filterFrom = this.parser.lastStreamPoint.from; - } else { - filterFrom = tokens[range.from].from + side; - } - filterTo = this.parser.lastStreamPoint.to; - } else { - filterFrom = tokens[range.from].from + side; - filterTo = tokens[range.to - 1].to - side; - } - filterRegion.push({ from: filterFrom, to: filterTo }); - } - return this.filterRegions[level] = filterRegion; - } - /** - * When the cursor or selection was moved or changed, `DelimOmitter` must - * redraw the `HiddenWidget` in order to decide which delimiter should be - * hidden due to its syntax is touching the cursor/selection. To make - * redrawing process more efficient, obviously because of possibility - * that there is delimiter(s) that's still be hidden, we should only - * redraw what have been changed by the move of the cursor/selection, or - * by a document change. Hence, `mapChangedRegion` comes to map the - * token indexes region that should be redrawn. - */ - mapChangedRegion(level: TokenLevel, oldSelectedRegion: Region, isParsing: boolean, restart?: boolean): Region { - let reparsedRange = this.parser.reparsedRanges[level], - reparsedLength = reparsedRange.changedTo - reparsedRange.initTo, - mappedRegion: Region = [], - tokensAdded = reparsedRange.from != reparsedRange.changedTo, - tokensLen = this.parser.getTokens(level).length; - if (restart) { - return this.changedRegions[level] = tokensLen - ? [{ from: 0, to: tokensLen }] - : []; - } - // Don't map the previous selected region when there is actually no - // parsing activity. - if (!isParsing || reparsedRange.from == reparsedRange.initTo && !reparsedLength) { - return this.changedRegions[level] = joinRegions(oldSelectedRegion, this.selectedRegions[level]); - } - // Use either reparsed range or selected region directly when there is no - // token selected before. - if (!oldSelectedRegion.length) { - if (tokensAdded) { - return this.changedRegions[level] = joinRegions([{ from: reparsedRange.from, to: reparsedRange.changedTo }], this.selectedRegions[level]); - } - return this.changedRegions[level] = this.selectedRegions[level]; - } - // We should map the old selected region with the reparsed range before - // joinning it with the new one. - for (let i = 0, isTouched = false; i < oldSelectedRegion.length;) { - if (oldSelectedRegion[i].to < reparsedRange.from) { - mappedRegion.push(Object.assign({}, oldSelectedRegion[i])); - if (++i >= oldSelectedRegion.length) { - if (tokensAdded) { - mappedRegion.push({ from: reparsedRange.from, to: reparsedRange.changedTo }); - } - break; - } - } else if (oldSelectedRegion[i].from < reparsedRange.initTo) { - isTouched = true; - let from = Math.min(reparsedRange.from, oldSelectedRegion[i].from), - to = Math.max(reparsedRange.changedTo, oldSelectedRegion[i].to + reparsedLength); - if (from != to) { - mappedRegion.push({ from, to }); - } - do { i++ } while (i < oldSelectedRegion.length && oldSelectedRegion[i].from < reparsedRange.initTo) - } else { - if (!isTouched && tokensAdded) { - mappedRegion.push({ from: reparsedRange.from, to: reparsedRange.changedTo }); - } - do { - mappedRegion.push({ - from: oldSelectedRegion[i].from + reparsedLength, - to: oldSelectedRegion[i].to + reparsedLength - }); - i++; - } while (i < oldSelectedRegion.length) - } - } - // Finalize the mapping of the changed region. - return this.changedRegions[level] = joinRegions(mappedRegion, this.selectedRegions[level]); - } - /** - * Iterate over tokens involved in the changed region. `to` in each range - * isn't included in the iteration. - */ - iterateChangedRegion(level: TokenLevel, callback?: (token: Token, index: number, tokens: TokenGroup, inSelection: boolean) => unknown): void { - let tokens = this.parser.getTokens(level), - changedRegion = this.changedRegions[level], - selectedRegion = this.selectedRegions[level]; - for (let i = 0, j = 0; i < changedRegion.length; i++) { - let changedRange = changedRegion[i]; - for (let index = changedRange.from; index < changedRange.to; index++) { - // Checking whether the current token was in selection or not. We don't - // use looping directly to check inSelection state, due to the fact that - // changed region was a union of the selected region and the others. - let inSelection = false; - if (selectedRegion[j] && selectedRegion[j].to <= index) { j++ } - if (selectedRegion[j] && selectedRegion[j].from <= index) { - inSelection = true; - } - // Possibly to be undefined, espically when the last token was removed. - if (!tokens[index]) { continue } - callback?.(tokens[index], index, tokens, inSelection); - } - } - } - /** - * Iterate over tokens that are inside, intersected by, or touched by - * selection. `to` in each range isn't included in the iteration. Return - * it `false` to cut off the iteration. - * - * @param watchPos - If true, the last argument of the callback will be - * passed, either `"covered"` (selection covers all across the range - * of the token), `"intersect"` (selection covers a part of the token), - * `"adjacent"` (selection just touches one of its edges), or - * `"covering"` (range of the selection being covered by the token). - * Otherwise, will be passed as `undefined`. - */ - iterateSelectedRegion(level: TokenLevel, watchPos: boolean, callback?: (token: Token, index: number, tokens: TokenGroup, pos: undefined | "covered" | "intersect" | "covering" | "adjacent") => void | boolean) { - let tokens = this.parser.getTokens(level), - selectionRanges = this.selection.ranges, - selectedRegion = this.selectedRegions[level]; - // i => selected range index - // j => selection range index - // k => token index - for (let i = 0, j = 0; i < selectedRegion.length; i++) { - let selectedRange = selectedRegion[i]; - for (let k = selectedRange.from; k < selectedRange.to; k++) { - let token = tokens[k], - pos: "covered" | "intersect" | "adjacent" | "covering" | undefined; - // Withdraw selection range index and reassign it by the first selection - // index that touched current token, if the current token shared the same - // selection with the previous one. - for (let prevIndex = j - 1; prevIndex >= 0; prevIndex--) { - if (selectionRanges[prevIndex].to < token.from) { break } - if (selectionRanges[prevIndex].from <= token.to) { j = prevIndex } - } - // There is no token that isn't touched by any selection at least. - while (token.to < selectionRanges[j].from) { j++ } - // A single token could be touched by more than one selection. - while (selectionRanges[j] && token.to >= selectionRanges[j].from) { - if (watchPos) { - // Relative position precedence order: - // covered -> covering -> intersect -> adjacent - if (pos != "covered" && token.from >= selectionRanges[j].from && token.to <= selectionRanges[j].to) { - pos = "covered"; - } else if (pos != "covering" && token.from < selectionRanges[j].from && token.to > selectionRanges[j].to) { - pos = "covering"; - } else if (pos === undefined && (token.from == selectionRanges[j].to || token.to == selectionRanges[j].from)) { - pos = "adjacent"; - } else if (pos === undefined || pos === "adjacent") { - pos = "intersect"; - } - } - j++; - } - // If callback returned false, cut the iteration off. - if (callback?.(token, k, tokens, pos) === false) { return }; - } - } - } - /** Check that the given range touches the current selection or not. */ - touchSelection(from: number, to: number): boolean { - this.checkSelectionIndexCache(); - let selectionRanges = this.selection.ranges, - // The index cache seems to have a significant effect in the case of many - // of the selections were applied at the same time. - indexCache = this.indexCaches.selection, - curRange = selectionRanges[indexCache.number] ?? selectionRanges.at(-1)!; - if (to < curRange.from) { - while (indexCache.number >= 0) { - if (from <= curRange.to && to >= curRange.from) { return true } - curRange = selectionRanges[--indexCache.number]; - } - indexCache.number = 0; - } else if (from > curRange.to) { - while (indexCache.number < selectionRanges.length) { - if (from <= curRange.to && to >= curRange.from) { return true } - curRange = selectionRanges[++indexCache.number]; - } - indexCache.number = selectionRanges.length - 1; - } else { - return true; - } - return false; - } - pickMaps(type: Format) { - let level = isInlineFormat(type) ? TokenLevel.INLINE : TokenLevel.BLOCK, - tokens = this.parser.getTokens(level); - return this.maps.map(maps => maps[level]?.filter(tokenIndex => tokens[tokenIndex].type == type)); - } - clearMaps() { - this.maps = new Array(this.selection.ranges.length).map(() => ({})); - } - checkSelectionIndexCache() { - if (this.indexCaches.selection.number >= this.selection.ranges.length) { - this.indexCaches.selection.number = this.selection.ranges.length - 1; - } - } -} \ No newline at end of file diff --git a/src/editor-mode/observer/index.ts b/src/editor-mode/observer/index.ts deleted file mode 100644 index a927935..0000000 --- a/src/editor-mode/observer/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./SelectionObserver"; -export * from "./ActivityRecorder"; \ No newline at end of file diff --git a/src/editor-mode/parser/Parser.ts b/src/editor-mode/parser/Parser.ts deleted file mode 100644 index 0189344..0000000 --- a/src/editor-mode/parser/Parser.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { ChangedRange, PlainRange, PluginSettings, StateConfig, TokenGroup } from "src/types"; -import { ParserState, TokenQueue } from "src/editor-mode/parser"; -import { ChangeSet, Line, Text } from "@codemirror/state"; -import { Format, MarkdownViewMode, TokenLevel } from "src/enums"; -import { Tokenizer } from "src/editor-mode/parser"; -import { Formats } from "src/format-configs"; -import { Tree } from "@lezer/common"; -import { composeChanges, disableEscape, findShifterAt, getShifterStart, hasInterferer, reenableEscape } from "src/editor-mode/parser/parser-utils"; -import { provideTokenRanges } from "src/editor-mode/parser/token-utils" -import { EditorDelimLookup } from "src/editor-mode/parser/configs"; -import { colorTag, customSpanTag, fencedDivTag } from "src/editor-mode/parser/tokenizer-components"; -import { getBlockEndAt } from "src/editor-mode/doc-utils" - -export class Parser { - private state: ParserState; - private queue: TokenQueue = new TokenQueue(); - inlineTokens: TokenGroup = []; - blockTokens: TokenGroup = []; - reparsedRanges: Record; - lastStreamPoint: PlainRange = { from: 0, to: 0 }; - settings: PluginSettings; - constructor(settings: PluginSettings) { - this.settings = settings; - if (!settings.editorEscape) { - disableEscape(); - } - } - getTokens(level: TokenLevel) { - return level == TokenLevel.INLINE - ? this.inlineTokens - : this.blockTokens; - } - initParse(doc: Text, tree: Tree) { - if (this.settings.editorEscape) { - reenableEscape(); - } else { - disableEscape(); - } - this.lastStreamPoint.from = 0; - this.inlineTokens = []; - this.blockTokens = []; - this.defineState({ doc, tree, offset: 0, settings: this.settings }); - this.streamParse(); - this.reparsedRanges = { - [TokenLevel.INLINE]: { from: 0, initTo: 0, changedTo: this.inlineTokens.length }, - [TokenLevel.BLOCK]: { from: 0, initTo: 0, changedTo: this.blockTokens.length } - } - } - applyChange(doc: Text, tree: Tree, oldTree: Tree, changes: ChangeSet) { - let changedRange = composeChanges(changes), - offset = Math.min(oldTree.length, tree.length) + 1; - if (changedRange) { - offset = Math.min(offset, changedRange.from); - } - let config: StateConfig = { doc, tree, offset, settings: this.settings }; - this.defineState(config, oldTree); - let blockEndLine = changedRange && !this.checkInterferer(oldTree, changedRange) - ? getBlockEndAt(doc, changedRange.changedTo) - : getBlockEndAt(doc, tree.length); - let reusedTokens: Record<"inline" | "block", TokenGroup> = { - inline: this.getReusedTokens(this.filterTokens(this.inlineTokens, this.reparsedRanges[TokenLevel.INLINE]), changedRange, blockEndLine), - block: this.getReusedTokens(this.filterTokens(this.blockTokens, this.reparsedRanges[TokenLevel.BLOCK]), changedRange, blockEndLine) - }; - this.shiftOffset(); - this.mapReusedTokens(reusedTokens.inline, changedRange); - this.mapReusedTokens(reusedTokens.block, changedRange); - this.state.endOfStream = blockEndLine.number; - this.streamParse(); - this.reparsedRanges[TokenLevel.INLINE].changedTo = this.inlineTokens.length; - this.reparsedRanges[TokenLevel.BLOCK].changedTo = this.blockTokens.length; - this.inlineTokens = this.inlineTokens.concat(reusedTokens.inline); - this.blockTokens = this.blockTokens.concat(reusedTokens.block); - } - private defineState(config: StateConfig, oldTree?: Tree) { - this.state = new ParserState(config, this.inlineTokens, this.blockTokens); - this.queue.attachState(this.state); - if (oldTree) { this.shiftOffsetByNode(oldTree) } - } - private shiftOffsetByNode(oldTree: Tree) { - let oldOffset = this.state.globalOffset, - newNode = findShifterAt(this.state.tree, oldOffset), - oldNode = findShifterAt(oldTree, oldOffset), - newOffset: number | null = null; - if (newNode) { - newOffset = getShifterStart(newNode); - } - if (oldNode) { - let oldStart = getShifterStart(oldNode); - if (oldStart !== null && (newOffset === null || oldStart < newOffset)) { - newOffset = oldStart; - } - } - if (newOffset !== null) { - this.state.setGlobalOffset(newOffset); - return true; - } - return false; - } - private shiftOffset() { - if (this.state.offset == 0) { return false } - let prevOffset = this.state.offset - 1, - str = this.state.lineStr, - char = str[prevOffset]; - if (char == "+" || char == "|" || char == "^" || char == "~" || char == "=" || char == "!" || char == ":") { - while (str[prevOffset - 1] == char) { prevOffset-- } - this.state.offset = prevOffset; - return true; - } - return false; - } - private removeState() { - this.queue.detachState(); - (this.state as ParserState | undefined) = undefined; - } - private streamParse() { - let prevLine: Line | null, - state = this.state; - this.lastStreamPoint.from = state.globalOffset; - // get previous line context - if (prevLine = this.state.prevLine) { - state.setContext(state.getContext(prevLine)); - } - // try to skip current line if it is a blank line - if (!state.trySkipBlankLine()) { - // if not, resolve the current line context - state.resolveContext(); - } - do { this.parseLine() } while (state.advanceLine()) - this.lastStreamPoint.to = state.globalOffset; - this.queue.clear(); - this.removeState(); - } - private parseLine() { - let state = this.state; - if (this.settings.fencedDiv & MarkdownViewMode.EDITOR_MODE && - this.state.offset == 0 && state.blkStart - ) { - Tokenizer.block(state, Format.FENCED_DIV); - } - while (true) { - if (state.isSpace()) { - state.queue.resolve(Formats.SPACE_RESTRICTED_INLINE, false, false); - } - let nodeType = state.processCursor(), - type = EditorDelimLookup[state.char]; - if (nodeType == "skipped") { - state.skipCursorRange(); - } else if (nodeType == "table_sep") { - state.queue.resolve(Formats.ALL_INLINE, false, false); - state.advance(); - } else if (type && (type != Format.HIGHLIGHT || nodeType == "hl_delim")) { - Tokenizer.inline(state, type); - } else if (!state.advance()) { - break; - } - } - } - private checkInterferer(oldTree: Tree, changedRange: ChangedRange) { - return ( - hasInterferer(this.state.tree, changedRange.from, changedRange.changedTo) || - hasInterferer(oldTree, changedRange.from, changedRange.initTo) - ); - } - private filterTokens(tokens: TokenGroup, reparsedRange: typeof this.reparsedRanges[TokenLevel]) { - let index = 0, - reparsedFrom: number | undefined, - reparsedTo: number | undefined, - offset = this.state.globalOffset; - for (let curToken = tokens[index]; index < tokens.length; curToken = tokens[++index]) { - // Keep find token touched by the current offset - if (curToken.to < offset) { continue } - let { openRange, tagRange } = provideTokenRanges(curToken); - if (openRange.to < offset) { - curToken.to = curToken.from; - curToken.closeLen = 0; - reparsedFrom ??= index; - this.queue.push(curToken); - if (curToken.tagLen && tagRange.to >= offset) { - curToken.tagLen = offset - tagRange.from; - if (curToken.type == Format.HIGHLIGHT) { colorTag(this.state, curToken) } - else if (curToken.type == Format.CUSTOM_SPAN) { customSpanTag(this.state, curToken) } - else if (curToken.type == Format.FENCED_DIV) { fencedDivTag(this.state, curToken) } - } - } else { - reparsedTo = index + 1; - break; - } - } - reparsedFrom ??= index; - reparsedRange.from = reparsedFrom; - reparsedRange.initTo = reparsedTo ?? index; - return tokens.splice(index); - } - private getReusedTokens(filteredOut: TokenGroup, changedRange: ChangedRange | null, blockEndLine: Line) { - let curIndex = 0, - changedLen = changedRange?.length; - if (changedLen === undefined) { return [] } - while ( - curIndex < filteredOut.length && - filteredOut[curIndex].to <= blockEndLine.to - changedLen - ) { curIndex++ } - return filteredOut.slice(curIndex); - } - private mapReusedTokens(reusedTokens: TokenGroup, changedRange: ChangedRange | null) { - if (!reusedTokens || !changedRange) { return } - let offsetDiffer = changedRange.changedTo - changedRange.initTo; - for ( - let i = 0, token = reusedTokens[i]; - i < reusedTokens.length; - token = reusedTokens[++i] - ) { - token.from += offsetDiffer; - token.to += offsetDiffer; - } - } -} \ No newline at end of file diff --git a/src/editor-mode/parser/ParserState.ts b/src/editor-mode/parser/ParserState.ts deleted file mode 100644 index cfaacee..0000000 --- a/src/editor-mode/parser/ParserState.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { Line, Text } from "@codemirror/state"; -import { Tree, TreeCursor } from "@lezer/common"; -import { PluginSettings, StateConfig, TokenGroup } from "src/types"; -import { TokenQueue } from "src/editor-mode/parser"; -import { Format, LineCtx } from "src/enums"; -import { Formats } from "src/format-configs"; -import { SKIPPED_NODE_RE } from "src/editor-mode/parser/regexps"; -import { findNode, getContextFromNode } from "src/editor-mode/parser/parser-utils"; -import { isBlankLine } from "src/editor-mode/doc-utils"; - -export class ParserState { - doc: Text; - tree: Tree; - cursor: TreeCursor | null; - line: Line; - /** block start */ - blkStart: boolean; - endOfStream: number; - offset: number; - inlineTokens: TokenGroup; - blockTokens: TokenGroup; - queue: TokenQueue; - curCtx: LineCtx = LineCtx.NONE; - prevCtx: LineCtx = LineCtx.NONE; - settings: PluginSettings; - constructor(config: StateConfig, inlineTokens: TokenGroup, blockTokens: TokenGroup) { - this.doc = config.doc; - this.tree = config.tree; - this.line = this.doc.lineAt(config.offset); - this.endOfStream = this.doc.lineAt(this.tree.length).number; - this.offset = config.offset - this.line.from; - this.inlineTokens = inlineTokens; - this.blockTokens = blockTokens; - this.cursor = this.tree.cursor(); - this.settings = config.settings; - this.nextCursor(); - // if previous line is a blank line or the - // current line is the first line, then the current one - // should be a block start - let prevLine = this.prevLine; - if (prevLine) { - this.blkStart = isBlankLine(prevLine); - } else { - this.blkStart = true; - } - } - /** global offset */ - get globalOffset() { - return this.offset + this.line.from; - } - get linePos() { - return this.line.number; - } - get lineStr() { - return this.line.text; - } - get char() { - let char = this.line.text[this.offset]; - if (!char && !this.isLastLine()) { - return "\n"; - } - return char ?? ""; - } - get prevLine() { - let linePos = this.linePos; - if (linePos == 1) { return null } - return this.doc.line(linePos - 1); - } - advance(n = 1) { - let restLen = this.lineStr.length - this.offset; - if (!restLen) { - this.queue.resolve(Formats.SPACE_RESTRICTED_INLINE, false, false); - /* this.inlineQueue.resolve(SpaceRestrictedFormats); */ - return false; - } - if (n > restLen) { - this.offset += restLen; - } else { - this.offset += n; - } - return true; - } - setGlobalOffset(globalOffset: number) { - if (globalOffset > this.doc.length) { - this.line = this.doc.line(this.doc.lines); - this.offset = this.line.to; - } else { - this.line = this.doc.lineAt(globalOffset); - this.offset = globalOffset - this.line.from; - } - } - isSpace(side: -1 | 0 | 1 = 0) { - let char = this.lineStr[this.offset + side]; - return char == " " || char == "\t" || !char; - } - seekWhitespace(maxOffset = this.line.length) { - let offset = this.offset; - for (let char = this.line.text[offset]; offset < maxOffset; char = this.line.text[++offset]) { - if (char == " " || char == "\t") { - return offset; - } - } - return null; - } - advanceLine(skipBlankLine = true) { - if (this.linePos >= this.endOfStream) { - this.queue.resolveAll(false); - return null; - } - this.line = this.doc.line(this.linePos + 1); - this.offset = 0; - this.blkStart = false; - this.resolveContext(); - if (skipBlankLine) { - this.trySkipBlankLine(); - } - return this.line; - } - trySkipBlankLine() { - if (this.isBlankLine()) { - // block start can be the line after the blank one. - this.blkStart = true; - // resolve all tokens that remain in the queue. - this.queue.resolveAll(true, this.line.to); - /* this.blockQueue.resolve(this.line.to); - this.inlineQueue.resolveAll(this.line.to); */ - // if there is trailing blank lines, then skip them all - while (this.linePos < this.endOfStream) { - this.line = this.doc.line(this.linePos + 1); - if (!this.isBlankLine()) { break } - } - // Is sufficient to resolve the current context once, - // because a sequence of blank lines should have the - // same context. - this.resolveContext(); - return true; - } - // returning false indicates that the current line isn't a blank line - return false; - } - isLastLine() { - return this.line.number >= this.doc.lines; - } - isBlankLine() { - return isBlankLine(this.line); - } - nextCursor(enter = true) { - if (this.cursor) { - if (this.cursor.next(enter) && this.cursor.name != "Document") { return true } - this.cursor = null; - } - return false; - } - cursorPos(endSide: 0 | -1 = 0): "after" | "before" | "touch" | null { - let globalOffset = this.globalOffset; - if (!this.cursor) { return null } - if (globalOffset < this.cursor.from) { return "after" } - if (globalOffset > this.cursor.to + endSide) { return "before" } - return "touch"; - } - processCursor() { - let cursorPos = this.cursorPos(-1); - while (cursorPos == "before") { - this.nextCursor(); - cursorPos = this.cursorPos(-1); - } - if (cursorPos != "touch") { return null } - let nodeName = this.cursor!.name; - if (nodeName.includes("formatting-highlight")) { return "hl_delim" } - if (nodeName.includes("table-sep")) { return "table_sep" } - if (SKIPPED_NODE_RE.test(nodeName)) { return "skipped" } - return null; - } - skipCursorRange() { - if (!this.cursor) { return false } - let cursorTo = this.cursor.to - this.line.from, - whitespaceOffset = this.seekWhitespace(cursorTo); - if (whitespaceOffset !== null) { - this.offset = whitespaceOffset; - this.queue.resolve(Formats.SPACE_RESTRICTED_INLINE, false, false); - /* this.inlineQueue.resolve(SpaceRestrictedFormats); */ - } - this.offset = cursorTo; - return true; - } - getContext(line = this.line) { - if (line.number != this.linePos) { - let node = findNode( - this.tree, line.from, line.from, - (node) => node.parent?.name == "Document" - ); - if (node) { return getContextFromNode(node) } - } else if (this.cursorPos() == "touch") { - let node = this.cursor!.node; - return getContextFromNode(node); - } - return LineCtx.NONE; - } - setContext(ctx: LineCtx) { - this.prevCtx = this.curCtx; - this.curCtx = ctx; - } - resolveContext() { - while (this.cursorPos() == "before") { this.nextCursor() } - this.setContext(this.getContext()); - let isSkip = false, - toBeResolved = false, - includesHl = false, - offset = this.line.from; - switch (this.curCtx) { - case LineCtx.HR_LINE: - case LineCtx.CODEBLOCK: - case LineCtx.TABLE_DELIM: - isSkip = true; - toBeResolved = true; - break; - case LineCtx.BLOCKQUOTE: - if (this.prevCtx != LineCtx.BLOCKQUOTE) { - toBeResolved = true; - } - break; - case LineCtx.LIST_HEAD: - includesHl = true; - // eslint-disable-next-line no-fallthrough - case LineCtx.HEADING: - case LineCtx.FOOTNOTE_HEAD: - toBeResolved = true; - break; - } - switch (this.prevCtx) { - case LineCtx.HEADING: - case LineCtx.TABLE: - toBeResolved = true; - offset -= 1; - } - if (!this.offset) { - if (toBeResolved) { - this.queue.resolve(Formats.ALL_BLOCK, false, false, offset); - this.queue.resolve(Formats.NON_BUILTIN_INLINE, false, false, offset); - this.blkStart = true; - } - if (includesHl) { this.queue.resolve([Format.HIGHLIGHT], false, false, offset) } - } - if (isSkip) { this.skipCursorRange() } - if (this.curCtx) { this.nextCursor() } - } -} \ No newline at end of file diff --git a/src/editor-mode/parser/TokenQueue.ts b/src/editor-mode/parser/TokenQueue.ts deleted file mode 100644 index 5973799..0000000 --- a/src/editor-mode/parser/TokenQueue.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Format, TokenLevel, TokenStatus } from "src/enums"; -import { InlineFormat, Token } from "src/types"; -import { ParserState } from "src/editor-mode/parser"; -import { Formats, InlineRules } from "src/format-configs"; - -/** - * A place storing token based on its type, to be resolved through - * the `Parser` and `ParserState` when satisfies certain conditions, - * such as the token finally reaches its closing delimiter or faces - * a context boundary. - */ -export class TokenQueue { - /** Contains all queued tokens (if any), each is paired by its format type. */ - private tokenMap: Partial> = {}; - private state: ParserState; - constructor() { - } - /** - * Attach a state to the queue. Often used when - * initializing the parsing. - */ - attachState(state: ParserState) { - this.state = state; - this.state.queue = this; - } - /** - * Detach currently attached state from the queue. Often used - * when the parsing was done. - */ - detachState() { - (this.state.queue as unknown) = undefined; - (this.state as unknown) = undefined; - } - /** Checking whether the token with `type` format is queued or not. */ - isQueued(type: Format) { - return !!this.tokenMap[type]; - } - /** Push a token into the queue, exactly into the token map. */ - push(token: Token) { - // Any token pushed into the queue will instantly be stated as `PENDING`. - token.status = TokenStatus.PENDING; - this.tokenMap[token.type] = token; - } - /** - * Get queued token as specified by `type` parameter. - * Returns `null` if it isn't queued. - */ - getToken(type: Format) { - return this.tokenMap[type] ?? null; - } - /** - * Resolve type-specific token(s) in the queue. Resolving it means - * that the token will no longer be in `PENDING` status. Instead, it - * will be stated as `ACTIVE` or `INACTIVE` depending on presence of - * closing delimiter, if it is required for that. Then, resolved token - * will be ejected from the map. - * - * @param closed If false and the token's type requires to be closed, - * then the token will be resolved as `INACTIVE`. Otherwise, it will - * be stated as `ACTIVE`. - * - * @param closedByBlankLine Resolved token is either closed by a blank - * line or not. It has no effect when `closed` is `true`. - * - * @param to Only needed when `closed` is `false`. Used to specify - * the end offset of the resolved token. - */ - resolve(types: Format[], closed: boolean, closedByBlankLine: boolean, to = this.state.globalOffset) { - for (let type of types) { - let token = this.getToken(type); - // If token with this type doesn't exist, then continue to the next one. - if (!token) { continue } - // When it is an inline token. - if (token.level == TokenLevel.INLINE) { - // There is a type -that is highlight- that doesn't need to be closed. - if (!closed && InlineRules[type as InlineFormat].mustBeClosed) { - token.status = TokenStatus.INACTIVE; - } else { - token.status = TokenStatus.ACTIVE; - } - // When it is a block token. - } else { - // Block token doesn't need to be closed. - // Only the validity of its tag affects its status. - if (token.validTag) { - token.status = TokenStatus.ACTIVE; - } else { - token.status = TokenStatus.INACTIVE; - } - } - // Assign "to" value into token.to when "closed" is false. - if (!closed) { - token.to = to; - // Determine that the resolved token is either located after the blank line or not. - token.closedByBlankLine = closedByBlankLine; - } - // Eject the token from the queue. - delete this.tokenMap[type]; - } - } - /** - * Resolve all existing token in the queue. Often used when facing context boundary, - * blank line, or table separator. Should be executed without any closing delimiter - * has been met. - */ - resolveAll(closedByBlankLine: boolean, to = this.state.globalOffset) { - this.resolve(Formats.ALL, false, closedByBlankLine, to); - } - /** Clear all queued tokens. */ - clear() { - this.tokenMap = {}; - } -} \ No newline at end of file diff --git a/src/editor-mode/parser/Tokenizer.ts b/src/editor-mode/parser/Tokenizer.ts deleted file mode 100644 index f04975c..0000000 --- a/src/editor-mode/parser/Tokenizer.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Format, TokenLevel, Delimiter, TokenStatus } from "src/enums"; -import { ParserState } from "src/editor-mode/parser"; -import { BlockFormat, InlineFormat, Token } from "src/types"; -import { handleClosingDelim, retrieveDelimSpec, validateDelim } from "src/editor-mode/parser/parser-utils"; -import { colorTag, customSpanTag, fencedDivTag } from "src/editor-mode/parser/tokenizer-components"; - -/** - * Tokens will only be created through this. Each method - * returns whether `true` or `false`, indicating the success - * of the tokenization. Parsed token will be automatically - * inserted to the token group. - */ -export const Tokenizer = { - /** - * Used for parsing block token. Should only be executed when the - * current line was a block start. - */ - block(state: ParserState, type: BlockFormat) { - // Block token is only parsed when the current line is a block start. - if (!state.blkStart) { return false } - // Retrieve DelimSpec based on input type. - let spec = retrieveDelimSpec(type, Delimiter.OPEN), - // Verifiy that the delimiter was valid and gets its length. - { valid, length: openLen } = validateDelim(state.lineStr, state.offset, spec); - // Advance along the given delimiter length. - state.advance(openLen); - // If it isn't valid, then abort it. - if (!valid) { return false } - let token: Token = { - type, - level: TokenLevel.BLOCK, - status: TokenStatus.PENDING, - from: state.globalOffset - openLen, - to: state.globalOffset - openLen, - openLen, - closeLen: 0, - tagLen: 0, - // Block tag doesn't overlapped over the content. - tagAsContent: false, - validTag: false, - closedByBlankLine: false - }; - // Queue and push the token to the token group. - state.blockTokens.push(token); - state.queue.push(token); - // Currently, block token only has fenced div type. Therefore, - // the tokenizer parses its tag directly without checking its - // type. - fencedDivTag(state, token); - // Indicate that tokenizing run successfully. - return true; - }, - /** - * Used for parsing inline token. Should be executed twice only when the - * parser state encountered allegedly closing delimiter of the queued token. - */ - inline(state: ParserState, type: InlineFormat) { - // Get the token according to the input type, may be null. - let token = state.queue.getToken(type), - // Which delimiter is encountered by the state. - // Determined by the presence of queued token. - role = state.queue.isQueued(type) ? Delimiter.CLOSE : Delimiter.OPEN, - // Get delimiter specification according to its type. - spec = retrieveDelimSpec(type, role), - // Check whether it's highlight, custom span, or neither. - isHighlight = type == Format.HIGHLIGHT, - isCustomSpan = type == Format.CUSTOM_SPAN, - // Verifiy that the delimiter was valid and gets its length. - { valid, length } = validateDelim(state.lineStr, state.offset, spec); - // If it isn't valid, then abort it. - if (!valid) { - state.advance(length); - return false; - } - // If there is a queued token with this type, then finalize it. - if (token) { - handleClosingDelim(state, token, length); - // Else, create new token and push it into the queue. - } else { - let token: Token = { - type, - level: TokenLevel.INLINE, - status: TokenStatus.PENDING, - from: state.globalOffset, - to: state.globalOffset, - openLen: length, - closeLen: 0, - tagLen: 0, - tagAsContent: isHighlight || isCustomSpan, - validTag: false, - closedByBlankLine: false - }; - state.inlineTokens.push(token); - state.queue.push(token); - state.advance(length); - // If this token can have a tag, then try to parse it. - if (isHighlight) { colorTag(state, token) } - else if (isCustomSpan) { customSpanTag(state, token) } - } - return true; - } -} \ No newline at end of file diff --git a/src/editor-mode/parser/configs/EditorDelimLookup.ts b/src/editor-mode/parser/configs/EditorDelimLookup.ts deleted file mode 100644 index dcdd8e8..0000000 --- a/src/editor-mode/parser/configs/EditorDelimLookup.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { InlineFormat } from "src/types"; - -export const EditorDelimLookup: Record = {} \ No newline at end of file diff --git a/src/editor-mode/parser/configs/ShifterNodeConfigs.ts b/src/editor-mode/parser/configs/ShifterNodeConfigs.ts deleted file mode 100644 index 2b0cd50..0000000 --- a/src/editor-mode/parser/configs/ShifterNodeConfigs.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { SyntaxNode } from "@lezer/common"; - -export const ShifterNodeConfigs: Record SyntaxNode | null }> = { - ["table-row"]: { - query: "table-row", - getOpen(node) { - while (!node.name.includes("table-row-0")) { - node = node.prevSibling!; - } - return node; - } - }, - ["url"]: { - query: "url", - getOpen(node) { - return node; - } - }, - ["math"]: { - query: "math", - getOpen(node) { - while (!node.name.includes("math-begin")) { - let prevSibling = node.prevSibling ?? node.parent?.prevSibling?.lastChild ?? null; - if (!prevSibling) { return null } - node = prevSibling; - } - if (node.to - node.from != 1) { return null } - else { return node } - } - }, - ["link"]: { - query: "link(?<=internal-link)|link(?=-start|end)", - getOpen(node) { - while (!node.name.includes("link-start")) { - node = node.prevSibling!; - } - return node; - } - }, - ["formatting-list"]: { - query: "formatting-list", - getOpen(node) { - return node.parent; - } - }, - ["hr"]: { - query: "hr", - getOpen(node) { - return node; - }, - }, - ["codeblock-begin"]: { - query: "codeblock-begin", - getOpen(node) { - return node; - }, - }, - ["formatting-header"]: { - query: "formatting-header", - getOpen(node) { - node = node.parent!; - if (node.name.includes("header-line")) { - node = node.prevSibling!; - } - return node; - }, - }, - ["formatting-quote"]: { - query: "formatting-quote", - getOpen(node) { - return node.parent; - } - }, - ["hmd-footnote"]: { - query: "hmd-footnote", - getOpen(node) { - return node.parent; - } - } -} \ No newline at end of file diff --git a/src/editor-mode/parser/configs/index.ts b/src/editor-mode/parser/configs/index.ts deleted file mode 100644 index 81f5055..0000000 --- a/src/editor-mode/parser/configs/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./ShifterNodeConfigs"; -export * from "./EditorDelimLookup"; \ No newline at end of file diff --git a/src/editor-mode/parser/index.ts b/src/editor-mode/parser/index.ts deleted file mode 100644 index 1af6c14..0000000 --- a/src/editor-mode/parser/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./Parser"; -export * from "./ParserState"; -export * from "./Tokenizer"; -export * from "./TokenQueue"; \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/composeChanges.ts b/src/editor-mode/parser/parser-utils/composeChanges.ts deleted file mode 100644 index 5685f08..0000000 --- a/src/editor-mode/parser/parser-utils/composeChanges.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ChangeSet } from "@codemirror/state"; -import { ChangedRange } from "src/types"; - -/** - * [id] Mengakumulasi seluruh range yang dimuat oleh - * `ChangeSet` untuk menghasilkan `ChangedRange` bila - * memang terdapat pengubahan teks. `ChangedRange` dapat - * difungsikan sebagai offset permulaan parsing, - * menghindari reparsing seluruh dokumen. - */ -export function composeChanges(changes: ChangeSet): ChangedRange | null { - if (changes.empty) { - // Bila tidak terdapat pengubahan, hasilkan null - return null; - } - let from: number, initTo: number, changedTo: number; - changes.iterChangedRanges((fromA, toA, fromB, toB) => { - // [id] Memilih offset terkecil sebagai offset awal pengubahan - from = from === undefined ? fromA : Math.min(from, fromA); - // [id] Memilih offset terbesar sebagai offset akhir pengubahan - initTo = initTo === undefined ? toA : Math.max(initTo, toA); - changedTo = changedTo === undefined ? toB : Math.max(changedTo, toB); - }, false); - return { - from: from!, - initTo: initTo!, - changedTo: changedTo!, - length: changedTo! - initTo! - }; -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/disableEscape.ts b/src/editor-mode/parser/parser-utils/disableEscape.ts deleted file mode 100644 index d92330a..0000000 --- a/src/editor-mode/parser/parser-utils/disableEscape.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SKIPPED_NODE_RE } from "../regexps"; - -export function disableEscape() { - SKIPPED_NODE_RE.compile("table|code|formatting|html|math|tag|url|barelink|atom|comment|string|meta|frontmatter|hr(?!\\w)"); -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/findNode.ts b/src/editor-mode/parser/parser-utils/findNode.ts deleted file mode 100644 index c4d702d..0000000 --- a/src/editor-mode/parser/parser-utils/findNode.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SyntaxNode, Tree } from "@lezer/common"; - -export function findNode(tree: Tree, from: number, to: number, matcher: (node: SyntaxNode) => boolean): SyntaxNode | null { - let node: SyntaxNode | null = null; - tree.iterate({ - from, to, enter(nodeRef) { - if (matcher(nodeRef.node)) { - node = nodeRef.node; - return false; - } - } - }); - return node; -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/findShifterAt.ts b/src/editor-mode/parser/parser-utils/findShifterAt.ts deleted file mode 100644 index 3eac945..0000000 --- a/src/editor-mode/parser/parser-utils/findShifterAt.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { SyntaxNode, Tree } from "@lezer/common"; -import { NodeSpec } from "src/types"; -import { SHIFTER_RE } from "src/editor-mode/parser/regexps"; - -export function findShifterAt(tree: Tree, offset: number): NodeSpec | null { - let node: SyntaxNode | null = null, - type: string | null = null; - tree.iterate({ - from: offset, to: offset, - enter(nodeRef) { - if (nodeRef.name == "Document") { return } - let match = SHIFTER_RE.exec(nodeRef.name); - if (match) { - node = nodeRef.node; - type = match[0]; - return false; - } - }, - }); - if (node && type) { - return { node, type } - } - return null; -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/getContextFromNode.ts b/src/editor-mode/parser/parser-utils/getContextFromNode.ts deleted file mode 100644 index 87dcc87..0000000 --- a/src/editor-mode/parser/parser-utils/getContextFromNode.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { SyntaxNode } from "@lezer/common"; -import { LineCtx } from "src/enums"; - -export function getContextFromNode(node: SyntaxNode) { - let nodeName = node.name, - context = LineCtx.NONE; - if (node.name == "Document") { return context } - if (!node.name.startsWith("HyperMD")) { - if (node.parent!.name == "Document") { return context } - node = node.parent!; - } - // 8 is the length of "HyperMD-" string - if (nodeName.startsWith("hr", 8)) { - context = LineCtx.HR_LINE; - } else if (nodeName.startsWith("header", 8)) { - context = LineCtx.HEADING; - } else if (nodeName.startsWith("quote", 8)) { - context = LineCtx.BLOCKQUOTE; - } else if (nodeName.startsWith("codeblock", 8)) { - context = LineCtx.CODEBLOCK; - } else if (nodeName.startsWith("list", 8)) { - if (nodeName.includes("nobullet")) { - context = LineCtx.LIST; - } else { - context = LineCtx.LIST_HEAD; - } - } else if (nodeName.startsWith("footnote", 8)) { - if (node.firstChild?.name.includes("footnote")) { - context = LineCtx.FOOTNOTE_HEAD; - } else { - context = LineCtx.FOOTNOTE; - } - } else if (nodeName.startsWith("table", 8)) { - if (nodeName.includes("table-row-1")) { - context = LineCtx.TABLE_DELIM; - } else { - context = LineCtx.TABLE; - } - } - return context; -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/getShifterStart.ts b/src/editor-mode/parser/parser-utils/getShifterStart.ts deleted file mode 100644 index c9c5f53..0000000 --- a/src/editor-mode/parser/parser-utils/getShifterStart.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SyntaxNode } from "@lezer/common"; -import { NodeSpec } from "src/types"; -import { ShifterNodeConfigs } from "src/editor-mode/parser/configs"; - -export function getShifterStart(spec: NodeSpec) { - let node: SyntaxNode | null = spec.node, - type = spec.type; - if (node = ShifterNodeConfigs[type]?.getOpen(node)) { - return node.from; - } - return null; -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/handleClosingDelim.ts b/src/editor-mode/parser/parser-utils/handleClosingDelim.ts deleted file mode 100644 index 3c9cc88..0000000 --- a/src/editor-mode/parser/parser-utils/handleClosingDelim.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Token } from "src/types"; -import { ParserState } from "src/editor-mode/parser"; - -export function handleClosingDelim(state: ParserState, token: Token, closeLen: number) { - token.closeLen = closeLen; - token.to = state.globalOffset + closeLen; - state.advance(closeLen); - state.queue.resolve([token.type], true, false); -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/hasInterferer.ts b/src/editor-mode/parser/parser-utils/hasInterferer.ts deleted file mode 100644 index 5c08967..0000000 --- a/src/editor-mode/parser/parser-utils/hasInterferer.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { SyntaxNode, Tree } from "@lezer/common"; -import { SEMANTIC_INTERFERER_RE } from "src/editor-mode/parser/regexps"; -import { findNode } from "src/editor-mode/parser/parser-utils"; - -export function hasInterferer(tree: Tree, from: number, to: number) { - let matcher = (node: SyntaxNode) => { - let match = SEMANTIC_INTERFERER_RE.exec(node.name); - if (match) { - if ( - (match[0] == "math-begin" || match[0] == "math-end") && - node.to - node.from < 2 - ) { return false } - return true; - } - return false; - }; - return !!findNode(tree, from, to, matcher); -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/index.ts b/src/editor-mode/parser/parser-utils/index.ts deleted file mode 100644 index a477707..0000000 --- a/src/editor-mode/parser/parser-utils/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * from "./composeChanges"; -export * from "./findNode"; -export * from "./findShifterAt"; -export * from "./getContextFromNode"; -export * from "./getShifterStart"; -export * from "./hasInterferer"; -export * from "./retrieveDelimSpec"; -export * from "./validateDelim"; -export * from "./disableEscape"; -export * from "./isAlphanumeric"; -export * from "./measureIndent"; -export * from "./handleClosingDelim"; -export * from "./reenableEscape"; \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/isAlphanumeric.ts b/src/editor-mode/parser/parser-utils/isAlphanumeric.ts deleted file mode 100644 index 33da51f..0000000 --- a/src/editor-mode/parser/parser-utils/isAlphanumeric.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function isAlphanumeric(char: string) { - let charCode = char.charCodeAt(0); - return ( - charCode >= 0x30 && charCode <= 0x39 || - charCode >= 0x41 && charCode <= 0x5a || - charCode >= 0x61 && charCode <= 0x7a - ); -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/measureIndent.ts b/src/editor-mode/parser/parser-utils/measureIndent.ts deleted file mode 100644 index f2d0891..0000000 --- a/src/editor-mode/parser/parser-utils/measureIndent.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function measureIndent(str: string, max?: number) { - let indentSize = 0, - charLen = 0; - max ??= str.length * 4; - for (let char = str[charLen]; charLen < str.length && indentSize < max; char = str[++charLen]) { - if (char == " ") { indentSize++ } - else if (char == "\t") { indentSize += 4 - (indentSize % 4) } - else { break } - } - return { indentSize, charLen } -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/reenableEscape.ts b/src/editor-mode/parser/parser-utils/reenableEscape.ts deleted file mode 100644 index 57ad07d..0000000 --- a/src/editor-mode/parser/parser-utils/reenableEscape.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SKIPPED_NODE_RE } from "../regexps"; - -export function reenableEscape() { - SKIPPED_NODE_RE.compile("table|code|formatting|escape|html|math|tag|url|barelink|atom|comment|string|meta|frontmatter|hr(?!\\w)"); -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/retrieveDelimSpec.ts b/src/editor-mode/parser/parser-utils/retrieveDelimSpec.ts deleted file mode 100644 index 12f6982..0000000 --- a/src/editor-mode/parser/parser-utils/retrieveDelimSpec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Delimiter, Format } from "src/enums"; -import { DelimSpec } from "src/types"; -import { BlockRules, InlineRules } from "src/format-configs"; -import { isInlineFormat } from "src/format-configs/utils"; - -export function retrieveDelimSpec(type: Format, role: Delimiter): DelimSpec { - let char: string, length: number, exactLen: boolean, allowSpaceOnDelim: boolean; - if (isInlineFormat(type)) { - ({ char, length, exactLen } = InlineRules[type]); - allowSpaceOnDelim = false; - } else { - ({ char, length, exactLen } = BlockRules[type]); - allowSpaceOnDelim = true; - } - return { char, length, exactLen, role, allowSpaceOnDelim } -} \ No newline at end of file diff --git a/src/editor-mode/parser/parser-utils/validateDelim.ts b/src/editor-mode/parser/parser-utils/validateDelim.ts deleted file mode 100644 index cb1fc7e..0000000 --- a/src/editor-mode/parser/parser-utils/validateDelim.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Delimiter } from "src/enums"; -import { DelimSpec } from "src/types"; - -export function validateDelim(str: string, offset: number, spec: DelimSpec) { - let length = 0, valid = false; - while (str[offset + length] == spec.char) { length++ } - if (spec.exactLen && length == spec.length || !spec.exactLen && length >= spec.length) { - let char: string; - if (spec.role == Delimiter.OPEN) { - char = str[offset + length]; - } else if (spec.role == Delimiter.CLOSE) { - char = str[offset - 1]; - } else { - throw TypeError(""); - } - if (spec.allowSpaceOnDelim || char && char != " " && char != "\t") { valid = true } - } - return { valid, length }; -} \ No newline at end of file diff --git a/src/editor-mode/parser/regexps/index.ts b/src/editor-mode/parser/regexps/index.ts deleted file mode 100644 index ece37c5..0000000 --- a/src/editor-mode/parser/regexps/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ShifterNodeConfigs } from "src/editor-mode/parser/configs" - -export const SEMANTIC_INTERFERER_RE = /(?:codeblock|html|math)-(?:begin|end)|comment-(?:start|end)|cdata|tag$/; -export const SKIPPED_NODE_RE = /table|code|formatting|escape|html|math|tag|url|barelink|atom|comment|string|meta|frontmatter|internal-link|hr(?!\w)/; -export const COLOR_TAG_RE = /\{[a-z0-9-]+\}/iy; -export const SHIFTER_RE = (() => { - let queries = ""; - for (let el in ShifterNodeConfigs) { - queries += (queries ? "|" : "") + ShifterNodeConfigs[el as keyof typeof ShifterNodeConfigs].query; - } - return new RegExp(queries); -})(); \ No newline at end of file diff --git a/src/editor-mode/parser/token-utils/getTagRange.ts b/src/editor-mode/parser/token-utils/getTagRange.ts deleted file mode 100644 index 7c1acd1..0000000 --- a/src/editor-mode/parser/token-utils/getTagRange.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Token } from "src/types"; - -export function getTagRange(token: Token) { - let from = token.from + token.openLen, - to = from + token.tagLen; - return { from, to }; -} \ No newline at end of file diff --git a/src/editor-mode/parser/token-utils/index.ts b/src/editor-mode/parser/token-utils/index.ts deleted file mode 100644 index 13ad946..0000000 --- a/src/editor-mode/parser/token-utils/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./iterTokenGroup"; -export * from "./moveTokenIndexCache"; -export * from "./provideTokenRanges"; -export * from "./isToken"; -export * from "./getTagRange"; \ No newline at end of file diff --git a/src/editor-mode/parser/token-utils/isToken.ts b/src/editor-mode/parser/token-utils/isToken.ts deleted file mode 100644 index a613197..0000000 --- a/src/editor-mode/parser/token-utils/isToken.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { PlainRange, Token } from "src/types"; - -export function isToken(range: PlainRange): range is Token { - let { type, openLen, tagLen, closeLen } = range as Token; - return type !== undefined && openLen !== undefined && tagLen !== undefined && closeLen !== undefined; -} \ No newline at end of file diff --git a/src/editor-mode/parser/token-utils/iterTokenGroup.ts b/src/editor-mode/parser/token-utils/iterTokenGroup.ts deleted file mode 100644 index c57c649..0000000 --- a/src/editor-mode/parser/token-utils/iterTokenGroup.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IterTokenGroupSpec } from "src/types"; -import { moveTokenIndexCache } from "src/editor-mode/parser/token-utils"; - -export function iterTokenGroup(spec: IterTokenGroupSpec) { - let { tokens, ranges, callback, indexCache } = spec; - moveTokenIndexCache(tokens, ranges[0]?.from ?? 0, indexCache); - for ( - let i = indexCache.number, j = 0; - i < tokens.length && j < ranges.length; - ) { - if (ranges[j].to <= tokens[i].from) { j++; continue } - if (tokens[i].from < ranges[j].to && tokens[i].to > ranges[j].from) { - callback(tokens[i]); - } - i++; - } -} \ No newline at end of file diff --git a/src/editor-mode/parser/token-utils/moveTokenIndexCache.ts b/src/editor-mode/parser/token-utils/moveTokenIndexCache.ts deleted file mode 100644 index 257cd71..0000000 --- a/src/editor-mode/parser/token-utils/moveTokenIndexCache.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IndexCache, TokenGroup } from "src/types"; - -export function moveTokenIndexCache(tokens: TokenGroup, offset: number, indexCache: IndexCache) { - if (tokens.length == 0) { - indexCache.number = 0; - return; - } - if (indexCache.number >= tokens.length) { - indexCache.number = tokens.length - 1; - } - let curIndex = indexCache.number, - curToken = tokens[curIndex]; - if (offset < curToken.from && curIndex != 0) { - do { - curToken = tokens[--curIndex]; - } while (offset < curToken.from && curIndex != 0) - } else if (offset > curToken.to && curIndex != tokens.length - 1) { - do { - curToken = tokens[++curIndex]; - } while (offset > curToken.to && curIndex != tokens.length - 1) - } - indexCache.number = curIndex; -} \ No newline at end of file diff --git a/src/editor-mode/parser/token-utils/provideTokenRanges.ts b/src/editor-mode/parser/token-utils/provideTokenRanges.ts deleted file mode 100644 index 20d7c84..0000000 --- a/src/editor-mode/parser/token-utils/provideTokenRanges.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Token } from "src/types"; - -export function provideTokenRanges(token: Token) { - let openRange = { from: token.from, to: token.from + token.openLen }, - closeRange = { from: token.to - token.closeLen, to: token.to }, - tagRange = { from: openRange.to, to: openRange.to + token.tagLen }, - contentRange = { from: (token.tagAsContent ? tagRange.from : tagRange.to), to: closeRange.from }; - return { openRange, closeRange, tagRange, contentRange } -} \ No newline at end of file diff --git a/src/editor-mode/parser/tokenizer-components/colorTag.ts b/src/editor-mode/parser/tokenizer-components/colorTag.ts deleted file mode 100644 index ce640ff..0000000 --- a/src/editor-mode/parser/tokenizer-components/colorTag.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { isAlphanumeric } from "src/editor-mode/parser/parser-utils"; -import { Token } from "src/types"; -import { ParserState } from "src/editor-mode/parser"; - -export function colorTag(state: ParserState, token: Token) { - let offset = state.offset, - initTagLen = token.tagLen, - str = state.lineStr; - if (token.validTag) { - if (str[offset - 1] == "}") { return } - token.validTag = false; - } - if (token.tagLen == 0) { - if (str[offset] != "{") { return } - token.tagLen++; - offset++; - } - for (let char = str[offset]; offset < str.length; char = str[++offset]) { - if (!isAlphanumeric(char) && char != "-") { break } - token.tagLen++; - } - if (token.tagLen > 1 && str[offset] == "}") { - token.validTag = true; - token.tagLen++; - } - state.advance(token.tagLen - initTagLen); -} \ No newline at end of file diff --git a/src/editor-mode/parser/tokenizer-components/customSpanTag.ts b/src/editor-mode/parser/tokenizer-components/customSpanTag.ts deleted file mode 100644 index b2a42f5..0000000 --- a/src/editor-mode/parser/tokenizer-components/customSpanTag.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Token } from "src/types"; -import { ParserState } from "src/editor-mode/parser"; -import { isAlphanumeric } from "src/editor-mode/parser/parser-utils"; - -export function customSpanTag(state: ParserState, token: Token) { - let offset = state.offset, - initTagLen = token.tagLen, - str = state.lineStr; - if (token.validTag) { - if (str[offset - 1] == "}") { return } - token.validTag = false; - } - if (token.tagLen == 0) { - if (str[offset] != "{") { return } - token.tagLen++; - offset++; - } - for (let char = str[offset]; offset < str.length; char = str[++offset]) { - if (!isAlphanumeric(char) && char != "-" && char != " ") { break } - token.tagLen++; - } - if (token.tagLen > 1 && str[offset] == "}") { - token.validTag = true; - token.tagLen++; - } - state.advance(token.tagLen - initTagLen); -} \ No newline at end of file diff --git a/src/editor-mode/parser/tokenizer-components/fencedDivTag.ts b/src/editor-mode/parser/tokenizer-components/fencedDivTag.ts deleted file mode 100644 index 0b61381..0000000 --- a/src/editor-mode/parser/tokenizer-components/fencedDivTag.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { isAlphanumeric } from "src/editor-mode/parser/parser-utils"; -import { Token } from "src/types"; -import { ParserState } from "src/editor-mode/parser"; - -export function fencedDivTag(state: ParserState, token: Token) { - token.validTag = false; - let offset = token.openLen + token.tagLen, - initTagLen = token.tagLen, - str = state.lineStr; - while (offset < str.length) { - let char = str[offset]; - if (char == " " || char == "-" || isAlphanumeric(char)) { token.tagLen++; offset++ } - else { break } - } - state.advance(token.tagLen - initTagLen); - if (offset >= str.length) { token.validTag = true } - else { state.queue.resolve([token.type], false, false) } -} \ No newline at end of file diff --git a/src/editor-mode/parser/tokenizer-components/index.ts b/src/editor-mode/parser/tokenizer-components/index.ts deleted file mode 100644 index 1ccf92d..0000000 --- a/src/editor-mode/parser/tokenizer-components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./colorTag"; -export * from "./customSpanTag"; -export * from "./fencedDivTag"; \ No newline at end of file diff --git a/src/editor-mode/preprocessor/observer.ts b/src/editor-mode/preprocessor/observer.ts new file mode 100644 index 0000000..b1c0631 --- /dev/null +++ b/src/editor-mode/preprocessor/observer.ts @@ -0,0 +1,456 @@ +import { EditorSelection } from "@codemirror/state"; +import { Format, TokenLevel } from "src/enums"; +import { IndexCache, PlainRange, Token, TokenGroup } from "src/types"; +import { EditorParser } from "src/editor-mode/preprocessor/parser"; +import { Region, joinRegions } from "src/editor-mode/utils/range-utils"; +import { isInlineFormat } from "src/format-configs/format-utils"; + +/** + * Appliance for managing selection-based decorations that are tied to + * the tokens effectively, avoiding, in some cases, over-iteration and + * redrawing decorations which indeed aren't selected and can be reused + * again. Only applied to the line breaks and fenced div opening + * decorations, which we can't bound decorating them with the viewport, + * not even visibleRanges. Although, it's still useful for formatting + * commands for both block and inline tokens. + */ +export class SelectionObserver { + /** Tokens that are touched or intersected by selection. */ + public selectedRegions: Record = { + [TokenLevel.INLINE]: [], + [TokenLevel.BLOCK]: [] + } + + /** + * All selection-based decorations, e.g. omitted delimiters, that are + * associated with these tokens should be redrawn. + */ + public changedRegions: Record = { + [TokenLevel.INLINE]: [], + [TokenLevel.BLOCK]: [] + } + + /** + * Collection of offset ranges, to be used as filter ranges for the + * previously drawn selection-based decorations. + */ + public filterRegions: Record = { + [TokenLevel.INLINE]: [], + [TokenLevel.BLOCK]: [] + } + + private _indexCaches: Record = { + [TokenLevel.INLINE]: { number: 0 }, + [TokenLevel.BLOCK]: { number: 0 }, + selection: { number: 0 } + } + + /** + * Mapped indexes of tokens to each index of corresponding selection that + * is touched by them. For example, `maps[0]` contains all indexes of + * tokens that touched the first selection. May be empty if corresponding + * selection wasn't touched by any token. + */ + private _maps: Partial>[] = []; + + public selection: EditorSelection; + public readonly parser: EditorParser; + + constructor(parser: EditorParser) { + this.parser = parser; + } + + /** + * Observe given selection to catch tokens that touched it, then map the + * old selected region with the new one, producing latest changed region + * that can be used to produce RangeSet filter. + * + * @param isParsing When assigned to true, observer will reach into + * reparsed ranges produced by the parser, and join it with the + * selectedRegion, resulting fresh changedRegion. + * + * @param restart Restart the observer to its initial state. You + * shouldn't pass any value into it. Use restartObserver() instead. + */ + public observe(selection: EditorSelection, isParsing: boolean, restart: boolean = false): void { + this.selection = selection; + this._checkIndexCache(); + + for (let level = TokenLevel.BLOCK as TokenLevel; level <= TokenLevel.INLINE; level++) { + let oldSelectedRegion = this.selectedRegions[level]; + this._locateSelectedTokens(level); + + // At the moment, changed and filter region are only applied to block- + // level tokens. + if (level == TokenLevel.INLINE) continue; + this._mapChangedRegion(level, oldSelectedRegion, isParsing, restart); + this.createFilter(level); + } + } + + /** + * Restart the observer to its initial state, i.e. clear old selected + * region and reobserve given selection. + */ + public restartObserver(selection: EditorSelection, isParsing: boolean): void { + this.selectedRegions = { + [TokenLevel.BLOCK]: [], + [TokenLevel.INLINE]: [] + }; + this.observe(selection, isParsing, true); + } + + /** + * Generate region of the tokens that touch or intersect the cursor or + * selection. + */ + private _locateSelectedTokens(level: TokenLevel): Region { + let newSelectedRegion: Region = [], + curRange: PlainRange | undefined; + this._clearMaps(); + + // Moving the cached index to the fore edge of the selection and then + // starting to look ahead involved tokens is not as efficient as way that + // look involved tokens behind without altering the cached index. Note + // that the efficiency of this process isn't so noticable in case of few + // tokens exist. + this._lookBehind(level, (_, index, selectionIndex) => { + if (!this._maps[selectionIndex]?.[level]) { + this._maps[selectionIndex] = { + [level]: [index] + }; + } else { + this._maps[selectionIndex][level].unshift(index); + } + if (!curRange || curRange.from != index + 1) { + curRange = { from: index, to: index + 1 }; + newSelectedRegion.unshift(curRange); + } else { + curRange.from--; + } + }); + + curRange = newSelectedRegion.at(-1); + + this._lookAhead(level, (_, index, selectionIndex) => { + if (!this._maps[selectionIndex]?.[level]) { + this._maps[selectionIndex] = { + [level]: [index] + }; + } else { + this._maps[selectionIndex][level].push(index); + } + if (!curRange || curRange.to != index) { + curRange = { from: index, to: index + 1 }; + newSelectedRegion.push(curRange); + } else { + curRange.to++; + } + }); + + this._indexCaches[level].number = newSelectedRegion[0]?.from ?? this._indexCaches[level].number; + return this.selectedRegions[level] = newSelectedRegion; + } + + /** + * Will move the cached index to the last token if it's went out of the + * range. + */ + private _checkIndexCache(): void { + if (this._indexCaches[TokenLevel.INLINE].number >= this.parser.inlineTokens.length) + this._indexCaches[TokenLevel.INLINE].number = Math.max(this.parser.inlineTokens.length - 1, 0); + + if (this._indexCaches[TokenLevel.BLOCK].number >= this.parser.blockTokens.length) + this._indexCaches[TokenLevel.BLOCK].number = Math.max(this.parser.blockTokens.length - 1, 0); + } + + /** + * Look the tokens behind untill the start offset of the first selection. + * Runs the callback when the current token touches the selection. + */ + private _lookBehind(level: TokenLevel, callback: (token: Token, index: number, selectionIndex: number) => unknown): void { + let selectionRanges = this.selection.ranges, + selectionIndex = selectionRanges.length - 1, + tokens = this.parser.getTokens(level), + goalOffset = selectionRanges[0].from; + + // Using index taken from the cache as an anchor. + for (let i = this._indexCaches[level].number - 1; i >= 0 && goalOffset <= tokens[i].to; i--) { + while (selectionRanges[selectionIndex].from > tokens[i].to) { selectionIndex-- } + if (!selectionRanges[selectionIndex]) { break } + if (selectionRanges[selectionIndex].to < tokens[i].from) { continue } + callback(tokens[i], i, selectionIndex); + } + } + /** + * Look the tokens ahead untill the end offset of the last selection. + * Runs the callback when the current token touches the selection. + */ + private _lookAhead(level: TokenLevel, callback: (token: Token, index: number, selectionIndex: number) => boolean | void): void { + let selectionRanges = this.selection.ranges, + selectionIndex = 0, + tokens = this.parser.getTokens(level), + goalOffset = selectionRanges.at(-1)!.to; + + // Using index taken from the cache as an anchor. + for (let i = this._indexCaches[level].number; i < tokens.length && goalOffset >= tokens[i].from; i++) { + while (selectionRanges[selectionIndex].to < tokens[i].from) { selectionIndex++ } + if (!selectionRanges[selectionIndex]) { break } + if (selectionRanges[selectionIndex].from > tokens[i].to) { continue } + callback(tokens[i], i, selectionIndex); + } + } + + /** + * When the cursor or selection was moved or changed, `DelimOmitter` must + * redraw the `HiddenWidget` in order to decide which delimiter should be + * hidden due to its syntax is touching the cursor/selection. To make + * redrawing process more efficient, obviously because of possibility + * that there is delimiter(s) that's still be hidden, we should only + * redraw what have been changed by the move of the cursor/selection, or + * by a document change. Hence, `mapChangedRegion` comes to map the + * token indexes region that should be redrawn. + */ + private _mapChangedRegion(level: TokenLevel, oldSelectedRegion: Region, isParsing: boolean, restart?: boolean): Region { + let reparsedRange = this.parser.reparsedRanges[level], + reparsedLength = reparsedRange.changedTo - reparsedRange.initTo, + mappedRegion: Region = [], + tokensAdded = reparsedRange.from != reparsedRange.changedTo, + tokensLen = this.parser.getTokens(level).length; + if (restart) + return this.changedRegions[level] = tokensLen + ? [{ from: 0, to: tokensLen }] + : []; + + // Don't map the previous selected region when there is actually no + // parsing activity. + if (!isParsing || reparsedRange.from == reparsedRange.initTo && !reparsedLength) + return this.changedRegions[level] = joinRegions(oldSelectedRegion, this.selectedRegions[level]); + + // Use either reparsed range or selected region directly when there is no + // token selected before. + if (!oldSelectedRegion.length) { + if (tokensAdded) + return this.changedRegions[level] = joinRegions([{ from: reparsedRange.from, to: reparsedRange.changedTo }], this.selectedRegions[level]); + + return this.changedRegions[level] = this.selectedRegions[level]; + } + + // We should map the old selected region with the reparsed range before + // joinning it with the new one. + for (let i = 0, isTouched = false; i < oldSelectedRegion.length;) { + if (oldSelectedRegion[i].to < reparsedRange.from) { + mappedRegion.push(Object.assign({}, oldSelectedRegion[i])); + if (++i >= oldSelectedRegion.length) { + if (tokensAdded) + mappedRegion.push({ from: reparsedRange.from, to: reparsedRange.changedTo }); + break; + } + } else if (oldSelectedRegion[i].from < reparsedRange.initTo) { + isTouched = true; + let from = Math.min(reparsedRange.from, oldSelectedRegion[i].from), + to = Math.max(reparsedRange.changedTo, oldSelectedRegion[i].to + reparsedLength); + if (from != to) + mappedRegion.push({ from, to }); + do { i++ } while (i < oldSelectedRegion.length && oldSelectedRegion[i].from < reparsedRange.initTo) + } else { + if (!isTouched && tokensAdded) + mappedRegion.push({ from: reparsedRange.from, to: reparsedRange.changedTo }); + do { + mappedRegion.push({ + from: oldSelectedRegion[i].from + reparsedLength, + to: oldSelectedRegion[i].to + reparsedLength + }); + i++; + } while (i < oldSelectedRegion.length) + } + } + + // Finalize the mapping of the changed region. + return this.changedRegions[level] = joinRegions(mappedRegion, this.selectedRegions[level]); + } + + /** Empty any of index maps. */ + private _clearMaps(): void { + this._maps = new Array(this.selection.ranges.length).map(() => ({})); + } + + /** + * Will move the cached index of the selections to the last index if it + * exceeds the amount of the selection ranges. + */ + private _checkSelectionIndexCache(): void { + if (this._indexCaches.selection.number >= this.selection.ranges.length) + this._indexCaches.selection.number = this.selection.ranges.length - 1; + } + + /** + * Create filter region that will be used as filtering boundary in + * `RangeSet.filter()`. + */ + public createFilter(level: TokenLevel): Region { + let tokens = level == TokenLevel.INLINE + ? this.parser.inlineTokens + : this.parser.blockTokens; + if (!tokens.length) + return this.filterRegions[level] = [Object.assign({}, this.parser.lastStreamPoint)]; + + let filterRegion: Region = [], + changedRegion = this.changedRegions[level], + // Inline tokens can be touched each other. To avoid filtering uninvolved + // decoration, filtering offset should be more inwardly indented. Doing + // so in block-level decorations makes filtering miss them due to + // frontmost position and using line decoration which the actually length + // is 0. + side = level == TokenLevel.INLINE ? 1 : 0; + + for (let i = 0; i < changedRegion.length; i++) { + let range = changedRegion[i], + filterFrom: number, + filterTo: number; + // Sometimes, the range can exceed the length of the tokens. + if (range.to > tokens.length) { + if (range.from >= tokens.length) + filterFrom = this.parser.lastStreamPoint.from; + else + filterFrom = tokens[range.from].from + side; + filterTo = this.parser.lastStreamPoint.to; + } else { + filterFrom = tokens[range.from].from + side; + filterTo = tokens[range.to - 1].to - side; + } + filterRegion.push({ from: filterFrom, to: filterTo }); + } + + return this.filterRegions[level] = filterRegion; + } + + /** + * Iterate over tokens involved in the changed region. `to` in each range + * isn't included in the iteration. + */ + public iterateChangedRegion(level: TokenLevel, callback?: (token: Token, index: number, tokens: TokenGroup, inSelection: boolean) => unknown): void { + let tokens = this.parser.getTokens(level), + changedRegion = this.changedRegions[level], + selectedRegion = this.selectedRegions[level]; + for (let i = 0, j = 0; i < changedRegion.length; i++) { + let changedRange = changedRegion[i]; + for (let index = changedRange.from; index < changedRange.to; index++) { + // Checking whether the current token was in selection or not. We don't + // use looping directly to check inSelection state, due to the fact that + // changed region was a union of the selected region and the others. + let inSelection = false; + if (selectedRegion[j] && selectedRegion[j].to <= index) { j++ } + if (selectedRegion[j] && selectedRegion[j].from <= index) { + inSelection = true; + } + // Possibly to be undefined, espically when the last token was removed. + if (!tokens[index]) { continue } + callback?.(tokens[index], index, tokens, inSelection); + } + } + } + + /** + * Iterate over tokens that are inside, intersected by, or touched by + * selection. `to` in each range isn't included in the iteration. Return + * it `false` to cut off the iteration. + * + * @param watchPos - If true, the last argument of the callback will be + * passed, either `"covered"` (selection covers all across the range + * of the token), `"intersect"` (selection covers a part of the token), + * `"adjacent"` (selection just touches one of its edges), or + * `"covering"` (range of the selection being covered by the token). + * Otherwise, will be passed as `undefined`. + */ + public iterateSelectedRegion( + level: TokenLevel, + watchPos: boolean, + callback?: (token: Token, index: number, tokens: TokenGroup, pos: undefined | "covered" | "intersect" | "covering" | "adjacent") => void | boolean + ) { + let tokens = this.parser.getTokens(level), + selectionRanges = this.selection.ranges, + selectedRegion = this.selectedRegions[level]; + + // i => selected range index + // j => selection range index + // k => token index + for (let i = 0, j = 0; i < selectedRegion.length; i++) { + let selectedRange = selectedRegion[i]; + for (let k = selectedRange.from; k < selectedRange.to; k++) { + let token = tokens[k], + pos: "covered" | "intersect" | "adjacent" | "covering" | undefined; + + // Withdraw selection range index and reassign it by the first selection + // index that touched current token, if the current token shared the same + // selection with the previous one. + for (let prevIndex = j - 1; prevIndex >= 0; prevIndex--) { + if (selectionRanges[prevIndex].to < token.from) break; + if (selectionRanges[prevIndex].from <= token.to) j = prevIndex; + } + + // There is no token that isn't touched by any selection at least. + while (token.to < selectionRanges[j].from) j++; + + // A single token could be touched by more than one selection. + while (selectionRanges[j] && token.to >= selectionRanges[j].from) { + if (watchPos) { + // Relative position precedence order: + // covered -> covering -> intersect -> adjacent + if (pos != "covered" && token.from >= selectionRanges[j].from && token.to <= selectionRanges[j].to) { + pos = "covered"; + } else if (pos != "covering" && token.from < selectionRanges[j].from && token.to > selectionRanges[j].to) { + pos = "covering"; + } else if (pos === undefined && (token.from == selectionRanges[j].to || token.to == selectionRanges[j].from)) { + pos = "adjacent"; + } else if (pos === undefined || pos === "adjacent") { + pos = "intersect"; + } + } + j++; + } + + // If callback returned false, cut the iteration off. + if (callback?.(token, k, tokens, pos) === false) return; + } + } + } + + /** Check that the given range touches the current selection or not. */ + public touchSelection(from: number, to: number): boolean { + this._checkSelectionIndexCache(); + let selectionRanges = this.selection.ranges, + // The index cache seems to have a significant effect in the case of many + // of the selections were applied at the same time. + indexCache = this._indexCaches.selection, + curRange = selectionRanges[indexCache.number] ?? selectionRanges.at(-1)!; + + if (to < curRange.from) { + while (indexCache.number >= 0) { + if (from <= curRange.to && to >= curRange.from) { return true } + curRange = selectionRanges[--indexCache.number]; + } + indexCache.number = 0; + } + + else if (from > curRange.to) { + while (indexCache.number < selectionRanges.length) { + if (from <= curRange.to && to >= curRange.from) { return true } + curRange = selectionRanges[++indexCache.number]; + } + indexCache.number = selectionRanges.length - 1; + } + + else + return true; + + return false; + } + + /** Pick index map of selected tokens based on their type. */ + public pickMaps(type: Format): (number[] | undefined)[] { + let level = isInlineFormat(type) ? TokenLevel.INLINE : TokenLevel.BLOCK, + tokens = this.parser.getTokens(level); + return this._maps.map(maps => maps[level]?.filter(tokenIndex => tokens[tokenIndex].type == type)); + } +} \ No newline at end of file diff --git a/src/editor-mode/preprocessor/parser-configs.ts b/src/editor-mode/preprocessor/parser-configs.ts new file mode 100644 index 0000000..e2b53ca --- /dev/null +++ b/src/editor-mode/preprocessor/parser-configs.ts @@ -0,0 +1,94 @@ +import { SyntaxNode } from "@lezer/common"; +import { InlineFormat } from "src/types"; + +export const EditorDelimLookup: Record = {} + +export const ShifterNodeConfigs: Record SyntaxNode | null }> = { + ["table-row"]: { + query: "table-row", + getOpen(node) { + while (!node.name.includes("table-row-0")) { + node = node.prevSibling!; + } + return node; + } + }, + ["url"]: { + query: "url", + getOpen(node) { + return node; + } + }, + ["math"]: { + query: "math", + getOpen(node) { + while (!node.name.includes("math-begin")) { + let prevSibling = node.prevSibling ?? node.parent?.prevSibling?.lastChild ?? null; + if (!prevSibling) { return null } + node = prevSibling; + } + if (node.to - node.from != 1) { return null } + else { return node } + } + }, + ["link"]: { + query: "link(?<=internal-link)|link(?=-start|end)", + getOpen(node) { + while (!node.name.includes("link-start")) { + node = node.prevSibling!; + } + return node; + } + }, + ["formatting-list"]: { + query: "formatting-list", + getOpen(node) { + return node.parent; + } + }, + ["hr"]: { + query: "hr", + getOpen(node) { + return node; + }, + }, + ["codeblock-begin"]: { + query: "codeblock-begin", + getOpen(node) { + return node; + }, + }, + ["formatting-header"]: { + query: "formatting-header", + getOpen(node) { + node = node.parent!; + if (node.name.includes("header-line")) { + node = node.prevSibling!; + } + return node; + }, + }, + ["formatting-quote"]: { + query: "formatting-quote", + getOpen(node) { + return node.parent; + } + }, + ["hmd-footnote"]: { + query: "hmd-footnote", + getOpen(node) { + return node.parent; + } + } +} + +export const SEMANTIC_INTERFERER_RE = /(?:codeblock|html|math)-(?:begin|end)|comment-(?:start|end)|cdata|tag$/; +export const SKIPPED_NODE_RE = /table|code|formatting|escape|html|math|tag|url|barelink|atom|comment|string|meta|frontmatter|internal-link|hr(?!\w)/; +export const COLOR_TAG_RE = /\{[a-z0-9-]+\}/iy; +export const SHIFTER_RE = (() => { + let queries = ""; + for (let el in ShifterNodeConfigs) { + queries += (queries ? "|" : "") + ShifterNodeConfigs[el as keyof typeof ShifterNodeConfigs].query; + } + return new RegExp(queries); +})(); \ No newline at end of file diff --git a/src/editor-mode/preprocessor/parser.ts b/src/editor-mode/preprocessor/parser.ts new file mode 100644 index 0000000..2fe7582 --- /dev/null +++ b/src/editor-mode/preprocessor/parser.ts @@ -0,0 +1,737 @@ +import { ChangeSet, Line, Text } from "@codemirror/state"; +import { Tree, TreeCursor } from "@lezer/common"; +import { PluginSettings, StateConfig, TokenGroup, ChangedRange, PlainRange, InlineFormat, Token } from "src/types"; +import { Format, LineCtx, MarkdownViewMode, TokenLevel, TokenStatus } from "src/enums"; +import { Formats, InlineRules } from "src/format-configs/rules"; +import { handleFencedDivTag, handleInlineTag, Tokenizer } from "src/editor-mode/preprocessor/tokenizer"; +import { findNode, getContextFromNode, disableEscape, findShifterAt, getShifterStart, hasInterferer, reenableEscape } from "src/editor-mode/preprocessor/syntax-node"; +import { EditorDelimLookup, SKIPPED_NODE_RE } from "src/editor-mode/preprocessor/parser-configs"; +import { isBlankLine, getBlockEndAt, TextCursor } from "src/editor-mode/utils/doc-utils"; +import { provideTokenPartsRanges } from "src/editor-mode/utils/token-utils" + +function _isTerminalChar(char: string, settings: PluginSettings): boolean { + for (let definedDelim in EditorDelimLookup) + if (char == definedDelim) return true; + + if (settings.fencedDiv & MarkdownViewMode.EDITOR_MODE && char == ":") + return true; + + return false; +} + +function _composeChanges(changes: ChangeSet): ChangedRange | null { + if (changes.empty) { + // Bila tidak terdapat pengubahan, hasilkan null + return null; + } + let from: number, initTo: number, changedTo: number; + changes.iterChangedRanges((fromA, toA, _, toB) => { + // [id] Memilih offset terkecil sebagai offset awal pengubahan + from = from === undefined ? fromA : Math.min(from, fromA); + // [id] Memilih offset terbesar sebagai offset akhir pengubahan + initTo = initTo === undefined ? toA : Math.max(initTo, toA); + changedTo = changedTo === undefined ? toB : Math.max(changedTo, toB); + }, false); + return { + from: from!, + initTo: initTo!, + changedTo: changedTo!, + length: changedTo! - initTo! + }; +} + +/** + * A place storing token based on its type, to be resolved through the + * `EditorParser` and `EditorParserState` when satisfies certain + * conditions, such as the token finally reaches its closing delimiter or + * faces a context boundary. + */ +class _TokenQueue { + /** Contains all queued tokens (if any), each is paired by its format type. */ + private _tokenMap: Partial> = {}; + private _state: EditorParserState; + + /** + * Attach a state to the queue. Often used when + * initializing the parsing. + */ + public attachState(state: EditorParserState): void { + this._state = state; + this._state.queue = this; + } + + /** + * Detach currently attached state from the queue. Often used + * when the parsing was done. + */ + public detachState(): void { + (this._state.queue as unknown) = undefined; + (this._state as unknown) = undefined; + } + + /** Checking whether the token with `type` format is queued or not. */ + public isQueued(type: Format): boolean { + return !!this._tokenMap[type]; + } + + /** Push a token into the queue, exactly into the token map. */ + public push(token: Token): void { + // Any token pushed into the queue will instantly be stated as `PENDING`. + token.status = TokenStatus.PENDING; + this._tokenMap[token.type] = token; + } + + /** + * Get queued token as specified by `type` parameter. + * Returns `null` if it isn't queued. + */ + public getToken(type: Format): Token | null { + return this._tokenMap[type] ?? null; + } + + /** + * Resolve type-specific token(s) in the queue. Resolving it means + * that the token will no longer be in `PENDING` status. Instead, it + * will be stated as `ACTIVE` or `INACTIVE` depending on presence of + * closing delimiter, if it is required for that. Then, resolved token + * will be ejected from the map. + * + * @param closed If false and the token's type requires to be closed, + * then the token will be resolved as `INACTIVE`. Otherwise, it will + * be stated as `ACTIVE`. + * + * @param closedByBlankLine Resolved token is either closed by a blank + * line or not. It has no effect when `closed` is `true`. + * + * @param to Only needed when `closed` is `false`. Used to specify + * the end offset of the resolved token. + */ + public resolve(types: Format[], closed: boolean, closedByBlankLine: boolean, to = this._state.globalOffset): void { + for (let type of types) { + let token = this.getToken(type); + // If token with this type doesn't exist, then continue to the next one. + if (!token) continue; + + // When it is an inline token. + if (token.level == TokenLevel.INLINE) { + // There is a type -that is highlight- that doesn't need to be closed. + if (!closed && InlineRules[type as InlineFormat].mustBeClosed) { + token.status = TokenStatus.INACTIVE; + } else { + token.status = TokenStatus.ACTIVE; + } + // When it is a block token. + } else { + // Block token doesn't need to be closed. + // Only the validity of its tag affects its status. + if (token.validTag) { + token.status = TokenStatus.ACTIVE; + } else { + token.status = TokenStatus.INACTIVE; + } + } + + // Assign "to" value into token.to when "closed" is false. + if (!closed) { + token.to = to; + // Determine that the resolved token is either located after the blank line or not. + token.closedByBlankLine = closedByBlankLine; + } + + // Eject the token from the queue. + delete this._tokenMap[type]; + } + } + + /** + * Resolve all existing token in the queue. Often used when facing context boundary, + * blank line, or table separator. Should be executed without any closing delimiter + * has been met. + */ + public resolveAll(closedByBlankLine: boolean, to = this._state.globalOffset): void { + this.resolve(Formats.ALL, false, closedByBlankLine, to); + } + + /** Clear all queued tokens. */ + public clear(): void { + this._tokenMap = {}; + } +} + +export class EditorParserState { + public readonly doc: Text; + public readonly tree: Tree; + public readonly settings: PluginSettings; + public readonly textCursor: TextCursor; + public endOfStream: number; + + public cursor: TreeCursor | null; + public line: Line; + public offset: number; + /** block start */ + public isBlockStart: boolean; + + public inlineTokens: TokenGroup; + public blockTokens: TokenGroup; + public queue: _TokenQueue; + + public curCtx: LineCtx = LineCtx.NONE; + public prevCtx: LineCtx = LineCtx.NONE; + + constructor(config: StateConfig, inlineTokens: TokenGroup, blockTokens: TokenGroup) { + this.doc = config.doc; + this.tree = config.tree; + this.textCursor = TextCursor.atOffset(this.doc, config.offset); + this.line = this.textCursor.curLine; + this.endOfStream = this.doc.lineAt(this.tree.length).number; + this.offset = config.offset - this.line.from; + this.inlineTokens = inlineTokens; + this.blockTokens = blockTokens; + this.cursor = this.tree.cursor(); + this.settings = config.settings; + this.nextCursor(); + + // if previous line is a blank line or the + // current line is the first line, then the current one + // should be a block start + let prevLine = this.prevLine; + if (prevLine) + this.isBlockStart = isBlankLine(prevLine); + else + this.isBlockStart = true; + } + + /** global offset */ + public get globalOffset(): number { + return this.offset + this.line.from; + } + + public get linePos(): number { + return this.line.number; + } + + public get lineStr(): string { + return this.line.text; + } + + public get char(): string { + let char = this.line.text[this.offset]; + if (!char && !this.isLastLine()) { + return "\n"; + } + return char ?? ""; + } + + public get prevLine(): Line | null { + return this.textCursor.getPrevLine(); + } + + public advance(n = 1): boolean { + let restLen = this.lineStr.length - this.offset; + if (!restLen) { + this.queue.resolve(Formats.SPACE_RESTRICTED_INLINE, false, false); + /* this.inlineQueue.resolve(SpaceRestrictedFormats); */ + return false; + } + + if (n > restLen) + this.offset += restLen; + else + this.offset += n; + + return true; + } + + public setGlobalOffset(globalOffset: number): void { + if (globalOffset > this.doc.length) { + this.line = this.textCursor.gotoLast().curLine; + this.offset = this.line.length; + } else { + this.line = this.doc.lineAt(globalOffset); + this.offset = globalOffset - this.line.from; + } + } + + public isSpace(side: -1 | 0 | 1 = 0): boolean { + let char = this.lineStr[this.offset + side]; + return char == " " || char == "\t" || !char; + } + + public seekWhitespace(maxOffset = this.line.length): number | null { + let offset = this.offset; + for (let char = this.line.text[offset]; offset < maxOffset; char = this.line.text[++offset]) + if (char == " " || char == "\t") + return offset; + + return null; + } + + public advanceLine(skipBlankLine = true): null | Line { + if (this.linePos >= this.endOfStream) { + this.queue.resolveAll(false); + return null; + } + + this.textCursor.next() + this.line = this.textCursor.curLine; + this.offset = 0; + this.isBlockStart = false; + this.resolveContext(); + if (skipBlankLine) this.trySkipBlankLine(); + return this.line; + } + + public trySkipBlankLine(): boolean { + if (this.isBlankLine()) { + // block start can be the line after the blank one. + this.isBlockStart = true; + // resolve all tokens that remain in the queue. + this.queue.resolveAll(true, this.line.to); + /* this.blockQueue.resolve(this.line.to); + this.inlineQueue.resolveAll(this.line.to); */ + // if there is trailing blank lines, then skip them all + while (this.linePos < this.endOfStream) { + this.line = this.doc.line(this.linePos + 1); + if (!this.isBlankLine()) break; + } + // Is sufficient to resolve the current context once, + // because a sequence of blank lines should have the + // same context. + this.resolveContext(); + return true; + } + // returning false indicates that the current line isn't a blank line + return false; + } + + public isLastLine(): boolean { + return this.line.number >= this.doc.lines; + } + + public isBlankLine(): boolean { + return isBlankLine(this.line); + } + + public nextCursor(enter = true): boolean { + if (this.cursor) { + if (this.cursor.next(enter) && this.cursor.name != "Document") return true; + this.cursor = null; + } + return false; + } + + public cursorPos(endSide: 0 | -1 = 0): "after" | "before" | "touch" | null { + let globalOffset = this.globalOffset; + if (!this.cursor) return null; + if (globalOffset < this.cursor.from) return "after"; + if (globalOffset > this.cursor.to + endSide) return "before"; + return "touch"; + } + + public processCursor(): "hl_delim" | "table_sep" | "skipped" | null { + let cursorPos = this.cursorPos(-1); + while (cursorPos == "before") { + this.nextCursor(); + cursorPos = this.cursorPos(-1); + } + if (cursorPos != "touch") return null; + let nodeName = this.cursor!.name; + if (nodeName.includes("formatting-highlight")) return "hl_delim"; + if (nodeName.includes("table-sep")) return "table_sep"; + if (SKIPPED_NODE_RE.test(nodeName)) return "skipped"; + return null; + } + + public skipCursorRange(): boolean { + if (!this.cursor) return false; + let cursorTo = this.cursor.to - this.line.from, + whitespaceOffset = this.seekWhitespace(cursorTo); + if (whitespaceOffset !== null) { + this.offset = whitespaceOffset; + this.queue.resolve(Formats.SPACE_RESTRICTED_INLINE, false, false); + /* this.inlineQueue.resolve(SpaceRestrictedFormats); */ + } + this.offset = cursorTo; + return true; + } + + public getContext(line = this.line): LineCtx { + if (line.number != this.linePos) { + let node = findNode( + this.tree, line.from, line.from, + (node) => node.parent?.name == "Document" + ); + if (node) return getContextFromNode(node); + } + + else if (this.cursorPos() == "touch") { + let node = this.cursor!.node; + return getContextFromNode(node); + } + + return LineCtx.NONE; + } + + public setContext(ctx: LineCtx): void { + this.prevCtx = this.curCtx; + this.curCtx = ctx; + } + + public resolveContext(): void { + while (this.cursorPos() == "before") this.nextCursor(); + this.setContext(this.getContext()); + let isSkip = false, + toBeResolved = false, + includesHl = false, + offset = this.line.from; + + switch (this.curCtx) { + case LineCtx.HR_LINE: + case LineCtx.CODEBLOCK: + case LineCtx.TABLE_DELIM: + isSkip = true; + toBeResolved = true; + break; + case LineCtx.BLOCKQUOTE: + if (this.prevCtx != LineCtx.BLOCKQUOTE) { + toBeResolved = true; + } + break; + case LineCtx.LIST_HEAD: + includesHl = true; + // eslint-disable-next-line no-fallthrough + case LineCtx.HEADING: + case LineCtx.FOOTNOTE_HEAD: + toBeResolved = true; + break; + } + + switch (this.prevCtx) { + case LineCtx.HEADING: + case LineCtx.TABLE: + toBeResolved = true; + offset -= 1; + } + + if (!this.offset) { + if (toBeResolved) { + this.queue.resolve(Formats.ALL_BLOCK, false, false, offset); + this.queue.resolve(Formats.NON_BUILTIN_INLINE, false, false, offset); + this.isBlockStart = true; + } + if (includesHl) this.queue.resolve([Format.HIGHLIGHT], false, false, offset); + } + + if (isSkip) this.skipCursorRange(); + if (this.curCtx) this.nextCursor(); + } +} + +/** + * The core of editor-mode parser. It only parses the document that in + * the `tree` range, then stores the result. + */ +export class EditorParser { + private _state: EditorParserState; + private _queue: _TokenQueue = new _TokenQueue(); + + public inlineTokens: TokenGroup = []; + public blockTokens: TokenGroup = []; + + public reparsedRanges: Record; + public lastStreamPoint: PlainRange = { from: 0, to: 0 }; + + readonly settings: PluginSettings; + + constructor(settings: PluginSettings) { + this.settings = settings; + if (!settings.editorEscape) + disableEscape(); + } + + /** Get the parsed tokens. */ + public getTokens(level: TokenLevel): TokenGroup { + return level == TokenLevel.INLINE + ? this.inlineTokens + : this.blockTokens; + } + + /** + * Initialize parsing, so the parser would parse from the start of the + * document. Should be use when actual initialization, or if there is a + * setting change that need the parser to parse from the start. + */ + public initParse(doc: Text, tree: Tree): void { + // Toggle escape. + if (this.settings.editorEscape) + reenableEscape(); + else + disableEscape(); + + // Flush all the results. + this.lastStreamPoint.from = 0; + this.inlineTokens = []; + this.blockTokens = []; + + // Start parsing. + this._defineState({ doc, tree, offset: 0, settings: this.settings }); + this._streamParse(); + this.reparsedRanges = { + [TokenLevel.INLINE]: { from: 0, initTo: 0, changedTo: this.inlineTokens.length }, + [TokenLevel.BLOCK]: { from: 0, initTo: 0, changedTo: this.blockTokens.length } + } + } + + /** + * Apply the change comes from the document or the length difference + * between previous parsed tree and the current one. + */ + public applyChange(doc: Text, tree: Tree, oldTree: Tree, changes: ChangeSet): void { + // Start stream offset can be the shortest length of both old and new + // tree, or the start offset of the changed range. + let changedRange = _composeChanges(changes), + startStreamOffset = Math.min(oldTree.length, tree.length) + 1; + + if (changedRange) + startStreamOffset = Math.min(startStreamOffset, changedRange.from); + + let config: StateConfig = { doc, tree, offset: startStreamOffset, settings: this.settings }; + this._defineState(config, oldTree); + + // Nearest next blank line is the end stream, unless there is no blank + // line exist within the new tree range, or the changed range encounters + // such interferer node. + let endStreamLine = changedRange && !this._checkInterferer(oldTree, changedRange) + ? getBlockEndAt(doc, changedRange.changedTo) + : getBlockEndAt(doc, tree.length); + this._state.endOfStream = endStreamLine.number; + + // Tokens that are located after the end of stream will be considered as + // left tokens, and will be shifted by the length of the changed range. + let leftTokens: Record<"inline" | "block", TokenGroup> = { + inline: this._getLeftTokens(this._filterTokens(this.inlineTokens, this.reparsedRanges[TokenLevel.INLINE]), changedRange, endStreamLine), + block: this._getLeftTokens(this._filterTokens(this.blockTokens, this.reparsedRanges[TokenLevel.BLOCK]), changedRange, endStreamLine) + }; + + // If there are any terminal characters -are that being used as + // delimiters- located exactly before the start offset of the stream, + // then we need to shift it backward. + this._shiftOffset(); + + // Shift the left tokens. + this._remapLeftTokens(leftTokens.inline, changedRange); + this._remapLeftTokens(leftTokens.block, changedRange); + + // Start parsing. + this._streamParse(); + + // Post-parsing. + this.reparsedRanges[TokenLevel.INLINE].changedTo = this.inlineTokens.length; + this.reparsedRanges[TokenLevel.BLOCK].changedTo = this.blockTokens.length; + this.inlineTokens = this.inlineTokens.concat(leftTokens.inline); + this.blockTokens = this.blockTokens.concat(leftTokens.block); + } + + /** Define new state as parsing begins. */ + private _defineState(config: StateConfig, oldTree?: Tree): void { + this._state = new EditorParserState(config, this.inlineTokens, this.blockTokens); + this._queue.attachState(this._state); + if (oldTree) this._shiftOffsetByNode(oldTree); + } + + /** + * Shift the start offset backward to the shifter node if encounters it. + * It uses the old tree and the new one, to indetify if there is a + * shifter node touching the start offset. Shift can be done if at least + * we found it in one of the two trees. + * + * Must be done along with defining new state. + */ + private _shiftOffsetByNode(oldTree: Tree) { + let oldOffset = this._state.globalOffset, + newNode = findShifterAt(this._state.tree, oldOffset), + oldNode = findShifterAt(oldTree, oldOffset), + newOffset: number | null = null; + + // Try to find it in the new tree. + if (newNode) + newOffset = getShifterStart(newNode); + + // Try to find it in the old tree, even if shifter be found in the new + // tree. Because, it could be different type. + if (oldNode) { + let oldStart = getShifterStart(oldNode); + if (oldStart !== null && (newOffset === null || oldStart < newOffset)) { + newOffset = oldStart; + } + } + + if (newOffset !== null) { + this._state.setGlobalOffset(newOffset); + return true; + } + + return false; + } + + /** + * Shift the start offset backward if there is terminal character + * located exactly before the offset. + */ + private _shiftOffset(): boolean { + if (this._state.offset == 0) return false; + + let prevOffset = this._state.offset - 1, + str = this._state.lineStr, + char = str[prevOffset]; + + if (_isTerminalChar(char, this.settings)) { + while (str[prevOffset - 1] == char) { prevOffset-- } + this._state.offset = prevOffset; + return true; + } + + return false; + } + + /** Detach the state from the parser. Used after finishing the parse. */ + private _detachState(): void { + this._queue.detachState(); + (this._state as EditorParserState | undefined) = undefined; + } + + /** Parse the given document. */ + private _streamParse(): void { + let prevLine: Line | null, + state = this._state; + this.lastStreamPoint.from = state.globalOffset; + + // Get previous line context. + if (prevLine = this._state.prevLine) { + state.setContext(state.getContext(prevLine)); + } + // Try to skip current line if it is a blank line. If fails, resolve the + // current line context. + if (!state.trySkipBlankLine()) + state.resolveContext(); + + // Parse each line untill reach the end of the stream. + do { this._parseLine() } while (state.advanceLine()) + this.lastStreamPoint.to = state.globalOffset; + + // Flush queue and state. + this._queue.clear(); + this._detachState(); + } + + /** Parse a single line. */ + private _parseLine(): void { + let state = this._state; + + // Try to parse block level token. + if ( + this.settings.fencedDiv & MarkdownViewMode.EDITOR_MODE && + this._state.offset == 0 && state.isBlockStart + ) Tokenizer.block(state, Format.FENCED_DIV); + + while (true) { + // Resolve space-restricted tokens if the state encountered a whitespace. + if (state.isSpace()) + state.queue.resolve(Formats.SPACE_RESTRICTED_INLINE, false, false); + + let nodeType = state.processCursor(), + type = EditorDelimLookup[state.char]; + + // Running parse on each character. + if (nodeType == "skipped") { + state.skipCursorRange(); + } else if (nodeType == "table_sep") { + state.queue.resolve(Formats.ALL_INLINE, false, false); + state.advance(); + } else if (type && (type != Format.HIGHLIGHT || nodeType == "hl_delim")) { + // Highlight already be parsed by builtin parser. + Tokenizer.inline(state, type); + } else if (!state.advance()) { + break; + } + } + } + + /** + * Interferer node is node that, if it was found within the changed range + * the end offset of stream will be moved forward to the end offset of + * the new tree. That's including the delimiters of codeblock, mathblock, + * or comment block. + */ + private _checkInterferer(oldTree: Tree, changedRange: ChangedRange): boolean { + return ( + hasInterferer(this._state.tree, changedRange.from, changedRange.changedTo) || + hasInterferer(oldTree, changedRange.from, changedRange.initTo) + ); + } + + private _filterTokens(tokens: TokenGroup, reparsedRange: typeof this.reparsedRanges[TokenLevel]): TokenGroup { + let index = 0, + reparsedFrom: number | undefined, + reparsedTo: number | undefined, + offset = this._state.globalOffset; + + for (let curToken = tokens[index]; index < tokens.length; curToken = tokens[++index]) { + // Keep find token touched by the current offset + if (curToken.to < offset) continue; + + let { openRange, tagRange } = provideTokenPartsRanges(curToken); + if (openRange.to < offset) { + curToken.to = curToken.from; + curToken.closeLen = 0; + reparsedFrom ??= index; + this._queue.push(curToken); + + if (curToken.tagLen && tagRange.to >= offset) { + curToken.tagLen = offset - tagRange.from; + if (curToken.type == Format.CUSTOM_SPAN || curToken.type == Format.HIGHLIGHT) + handleInlineTag(this._state, curToken); + else if (curToken.type == Format.FENCED_DIV) + handleFencedDivTag(this._state, curToken); + } + } + + else { + reparsedTo = index + 1; + break; + } + } + + reparsedFrom ??= index; + reparsedRange.from = reparsedFrom; + reparsedRange.initTo = reparsedTo ?? index; + return tokens.splice(index); + } + + private _getLeftTokens(filteredOut: TokenGroup, changedRange: ChangedRange | null, blockEndLine: Line): TokenGroup { + let curIndex = 0, + changedLen = changedRange?.length; + + if (changedLen === undefined) return []; + + while ( + curIndex < filteredOut.length && + filteredOut[curIndex].to <= blockEndLine.to - changedLen + ) curIndex++; + return filteredOut.slice(curIndex); + } + + private _remapLeftTokens(reusedTokens: TokenGroup, changedRange: ChangedRange | null): void { + if (!reusedTokens || !changedRange) return; + + let offsetDiffer = changedRange.changedTo - changedRange.initTo; + for ( + let i = 0, token = reusedTokens[i]; + i < reusedTokens.length; + token = reusedTokens[++i] + ) { + token.from += offsetDiffer; + token.to += offsetDiffer; + } + } +} \ No newline at end of file diff --git a/src/editor-mode/preprocessor/syntax-node.ts b/src/editor-mode/preprocessor/syntax-node.ts new file mode 100644 index 0000000..5defd1f --- /dev/null +++ b/src/editor-mode/preprocessor/syntax-node.ts @@ -0,0 +1,111 @@ +import { SyntaxNode, Tree } from "@lezer/common"; +import { SEMANTIC_INTERFERER_RE, SHIFTER_RE, ShifterNodeConfigs, SKIPPED_NODE_RE } from "src/editor-mode/preprocessor/parser-configs"; +import { LineCtx } from "src/enums"; + +interface NodeSpec { + node: SyntaxNode, + type: string +} + +export function disableEscape(): void { + SKIPPED_NODE_RE.compile("table|code|formatting|html|math|tag|url|barelink|atom|comment|string|meta|frontmatter|hr(?!\\w)"); +} + +export function reenableEscape(): void { + SKIPPED_NODE_RE.compile("table|code|formatting|escape|html|math|tag|url|barelink|atom|comment|string|meta|frontmatter|hr(?!\\w)"); +} + +export function findNode(tree: Tree, from: number, to: number, matcher: (node: SyntaxNode) => boolean): SyntaxNode | null { + let node: SyntaxNode | null = null; + tree.iterate({ + from, to, enter(nodeRef) { + if (matcher(nodeRef.node)) { + node = nodeRef.node; + return false; + } + } + }); + return node; +} + +export function findShifterAt(tree: Tree, offset: number): NodeSpec | null { + let node: SyntaxNode | null = null, + type: string | null = null; + tree.iterate({ + from: offset, to: offset, + enter(nodeRef) { + if (nodeRef.name == "Document") { return } + let match = SHIFTER_RE.exec(nodeRef.name); + if (match) { + node = nodeRef.node; + type = match[0]; + return false; + } + }, + }); + if (node && type) return { node, type }; + return null; +} + +export function getContextFromNode(node: SyntaxNode): LineCtx { + let nodeName = node.name, + context = LineCtx.NONE; + if (node.name == "Document") { return context } + if (!node.name.startsWith("HyperMD")) { + if (node.parent!.name == "Document") { return context } + node = node.parent!; + } + // 8 is the length of "HyperMD-" string + if (nodeName.startsWith("hr", 8)) { + context = LineCtx.HR_LINE; + } else if (nodeName.startsWith("header", 8)) { + context = LineCtx.HEADING; + } else if (nodeName.startsWith("quote", 8)) { + context = LineCtx.BLOCKQUOTE; + } else if (nodeName.startsWith("codeblock", 8)) { + context = LineCtx.CODEBLOCK; + } else if (nodeName.startsWith("list", 8)) { + if (nodeName.includes("nobullet")) { + context = LineCtx.LIST; + } else { + context = LineCtx.LIST_HEAD; + } + } else if (nodeName.startsWith("footnote", 8)) { + if (node.firstChild?.name.includes("footnote")) { + context = LineCtx.FOOTNOTE_HEAD; + } else { + context = LineCtx.FOOTNOTE; + } + } else if (nodeName.startsWith("table", 8)) { + if (nodeName.includes("table-row-1")) { + context = LineCtx.TABLE_DELIM; + } else { + context = LineCtx.TABLE; + } + } + return context; +} + +export function getShifterStart(spec: NodeSpec): number | null { + let node: SyntaxNode | null = spec.node, + type = spec.type; + if (node = ShifterNodeConfigs[type]?.getOpen(node)) { + return node.from; + } + return null; +} + +export function hasInterferer(tree: Tree, from: number, to: number): boolean { + let matcher = (node: SyntaxNode) => { + let match = SEMANTIC_INTERFERER_RE.exec(node.name); + if (match) { + if ( + (match[0] == "math-begin" || match[0] == "math-end") && + node.to - node.from < 2 + ) { return false } + return true; + } + return false; + }; + return !!findNode(tree, from, to, matcher); +} \ No newline at end of file diff --git a/src/editor-mode/preprocessor/tokenizer.ts b/src/editor-mode/preprocessor/tokenizer.ts new file mode 100644 index 0000000..c78eae5 --- /dev/null +++ b/src/editor-mode/preprocessor/tokenizer.ts @@ -0,0 +1,209 @@ +import { Format, TokenLevel, Delimiter, TokenStatus } from "src/enums"; +import { BlockFormat, InlineFormat, Token } from "src/types"; +import { BlockRules, InlineRules } from "src/format-configs/rules"; +import { EditorParserState } from "src/editor-mode/preprocessor/parser"; +import { isInlineFormat } from "src/format-configs/format-utils"; + +/** Describe the rule that has to be satisfied by the delimiter */ +interface DelimSpec { + /** Should be single character */ + char: string, + /** Delimiter length */ + length: number, + /** + * If true, then delimiter length must be the same as + * in the predetermined rule. Otherwise, the defined length + * act as minimum length. + */ + exactLen: boolean, + /** Should be `Delimiter.OPEN` or `Delimiter.CLOSE` */ + role: Delimiter, + /** + * When true, any space after the opening delimiter or before the closing + * one doesn't make the delimiter invalid. Default is false. + */ + allowSpaceOnDelim?: boolean +} + +function _isAlphanumeric(char: string): boolean { + let charCode = char.charCodeAt(0); + return ( + charCode >= 0x30 && charCode <= 0x39 || + charCode >= 0x41 && charCode <= 0x5a || + charCode >= 0x61 && charCode <= 0x7a + ); +} + +function _retrieveDelimSpec(type: Format, role: Delimiter): DelimSpec { + let char: string, length: number, exactLen: boolean, allowSpaceOnDelim: boolean; + if (isInlineFormat(type)) { + ({ char, length, exactLen } = InlineRules[type]); + allowSpaceOnDelim = false; + } else { + ({ char, length, exactLen } = BlockRules[type]); + allowSpaceOnDelim = true; + } + return { char, length, exactLen, role, allowSpaceOnDelim } +} + + +function _validateDelim(str: string, offset: number, spec: DelimSpec): { valid: boolean, length: number } { + let length = 0, valid = false; + while (str[offset + length] == spec.char) length++; + if (spec.exactLen && length == spec.length || !spec.exactLen && length >= spec.length) { + let char: string; + if (spec.role == Delimiter.OPEN) { + char = str[offset + length]; + } else if (spec.role == Delimiter.CLOSE) { + char = str[offset - 1]; + } else { + throw TypeError(""); + } + if (spec.allowSpaceOnDelim || char && char != " " && char != "\t") { valid = true } + } + return { valid, length }; +} + +function _handleClosingDelim(state: EditorParserState, token: Token, closeLen: number): void { + token.closeLen = closeLen; + token.to = state.globalOffset + closeLen; + state.advance(closeLen); + state.queue.resolve([token.type], true, false); +} + +export function handleInlineTag(state: EditorParserState, token: Token): void { + let offset = state.offset, + initTagLen = token.tagLen, + str = state.lineStr, + noSpace = token.type == Format.HIGHLIGHT; + if (token.validTag) { + if (str[offset - 1] == "}") return; + token.validTag = false; + } + if (token.tagLen == 0) { + if (str[offset] != "{") return; + token.tagLen++; + offset++; + } + for (let char = str[offset]; offset < str.length; char = str[++offset]) { + if (!_isAlphanumeric(char) && char != "-" && (char != " " || noSpace)) break; + token.tagLen++; + } + if (token.tagLen > 1 && str[offset] == "}") { + token.validTag = true; + token.tagLen++; + } + state.advance(token.tagLen - initTagLen); +} + +export function handleFencedDivTag(state: EditorParserState, token: Token): void { + token.validTag = false; + let offset = token.openLen + token.tagLen, + initTagLen = token.tagLen, + str = state.lineStr; + while (offset < str.length) { + let char = str[offset]; + if (char == " " || char == "-" || _isAlphanumeric(char)) { token.tagLen++; offset++ } + else break; + } + state.advance(token.tagLen - initTagLen); + if (offset >= str.length) token.validTag = true; + else state.queue.resolve([token.type], false, false); +} + +/** + * Tokens will only be created through this. Each method + * returns whether `true` or `false`, indicating the success + * of the tokenization. Parsed token will be automatically + * inserted to the token group. + */ +export const Tokenizer = { + /** + * Used for parsing block token. Should only be executed when the + * current line was a block start. + */ + block(state: EditorParserState, type: BlockFormat): boolean { + // Block token is only parsed when the current line is a block start. + if (!state.isBlockStart) return false; + // Retrieve DelimSpec based on input type. + let spec = _retrieveDelimSpec(type, Delimiter.OPEN), + // Verifiy that the delimiter was valid and gets its length. + { valid, length: openLen } = _validateDelim(state.lineStr, state.offset, spec); + // Advance along the given delimiter length. + state.advance(openLen); + // If it isn't valid, then abort it. + if (!valid) return false; + let token: Token = { + type, + level: TokenLevel.BLOCK, + status: TokenStatus.PENDING, + from: state.globalOffset - openLen, + to: state.globalOffset - openLen, + openLen, + closeLen: 0, + tagLen: 0, + // Block tag doesn't overlapped over the content. + tagAsContent: false, + validTag: false, + closedByBlankLine: false + }; + // Queue and push the token to the token group. + state.blockTokens.push(token); + state.queue.push(token); + // Currently, block token only has fenced div type. Therefore, + // the tokenizer parses its tag directly without checking its + // type. + handleFencedDivTag(state, token); + // Indicate that tokenizing run successfully. + return true; + }, + + /** + * Used for parsing inline token. Should be executed twice only when the + * parser state encountered allegedly closing delimiter of the queued token. + */ + inline(state: EditorParserState, type: InlineFormat): boolean { + // Get the token according to the input type, may be null. + let token = state.queue.getToken(type), + // Which delimiter is encountered by the state. + // Determined by the presence of queued token. + role = state.queue.isQueued(type) ? Delimiter.CLOSE : Delimiter.OPEN, + // Get delimiter specification according to its type. + spec = _retrieveDelimSpec(type, role), + // Check whether it's highlight, custom span, or neither. + isHighlight = type == Format.HIGHLIGHT, + isCustomSpan = type == Format.CUSTOM_SPAN, + // Verifiy that the delimiter was valid and gets its length. + { valid, length } = _validateDelim(state.lineStr, state.offset, spec); + // If it isn't valid, then abort it. + if (!valid) { + state.advance(length); + return false; + } + // If there is a queued token with this type, then finalize it. + if (token) + _handleClosingDelim(state, token, length); + // Else, create new token and push it into the queue. + else { + let token: Token = { + type, + level: TokenLevel.INLINE, + status: TokenStatus.PENDING, + from: state.globalOffset, + to: state.globalOffset, + openLen: length, + closeLen: 0, + tagLen: 0, + tagAsContent: isHighlight || isCustomSpan, + validTag: false, + closedByBlankLine: false + }; + state.inlineTokens.push(token); + state.queue.push(token); + state.advance(length); + // If this token can have a tag, then try to parse it. + if (isHighlight || isCustomSpan) handleInlineTag(state, token); + } + return true; + } +} \ No newline at end of file diff --git a/src/editor-mode/range-utils/index.ts b/src/editor-mode/range-utils/index.ts deleted file mode 100644 index 537e85a..0000000 --- a/src/editor-mode/range-utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./isTouched"; -export * from "./joinRegions"; -export * from "./separateRegions"; \ No newline at end of file diff --git a/src/editor-mode/range-utils/isTouched.ts b/src/editor-mode/range-utils/isTouched.ts deleted file mode 100644 index 68dcb14..0000000 --- a/src/editor-mode/range-utils/isTouched.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PlainRange } from "src/types"; - -export function isTouched(offset: number, range: PlainRange) { - return offset >= range.from && offset <= range.to; -} \ No newline at end of file diff --git a/src/editor-mode/range-utils/joinRegions.ts b/src/editor-mode/range-utils/joinRegions.ts deleted file mode 100644 index 0dc6e52..0000000 --- a/src/editor-mode/range-utils/joinRegions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Region } from "src/types"; - -/** Join two regions into a single region. */ -export function joinRegions(regionA: Region, regionB: Region): Region { - let unionRegion: Region = []; - for (let i = 0, j = 0; i < regionA.length || j < regionB.length;) { - // Joinned range only occurs between two touched/intersected ranges. - if (!regionB[j] || regionA[i] && regionA[i].to < regionB[j].from) { - unionRegion.push(Object.assign({}, regionA[i])); - i++; continue; - } - if (!regionA[i] || regionB[j] && regionB[j].to < regionA[i].from) { - unionRegion.push(Object.assign({}, regionB[j])); - j++; continue; - } - unionRegion.push({ - from: Math.min(regionA[i].from, regionB[j].from), - to: Math.max(regionA[i].to, regionB[j].to) - }); - i++; j++; - } - return unionRegion; -} \ No newline at end of file diff --git a/src/editor-mode/range-utils/separateRegions.ts b/src/editor-mode/range-utils/separateRegions.ts deleted file mode 100644 index 4d91a51..0000000 --- a/src/editor-mode/range-utils/separateRegions.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Region } from "src/types"; - -/** - * Create disjunction between two regions. - * - * @suspended _not in use at the time_. - */ -export function separateRegions(regionA: Region, regionB: Region) { - let disjunctRegion: Region = [], - differRegionA: Region = [], - differRegionB: Region = [], - intersectPoint: undefined | number; - for (let i = 0, j = 0; i < regionA.length || j < regionB.length;) { - if (!regionB[j] || regionA[i] && regionA[i].to <= regionB[j].from) { - let range = { from: intersectPoint ?? regionA[i].from, to: regionA[i].to }; - disjunctRegion.push(range); - differRegionA.push(range); - intersectPoint = undefined; - i++; continue; - } - if (!regionA[i] || regionB[j].to <= regionA[i].from) { - let range = { from: intersectPoint ?? regionB[j].from, to: regionB[j].to }; - disjunctRegion.push(range); - differRegionB.push(range); - intersectPoint = undefined; - j++; continue; - } - if (regionA[i].from < regionB[j].from) { - let range = { from: regionA[i].from, to: regionB[j].from }; - disjunctRegion.push(range); - differRegionA.push(range); - } else if (regionB[j].from < regionA[i].from) { - let range = { from: regionB[j].from, to: regionA[i].from }; - disjunctRegion.push(range); - differRegionB.push(range); - } - if (regionA[i].to < regionB[j].to) { intersectPoint = regionA[i].to; i++ } - else if (regionA[i].to > regionB[j].to) { intersectPoint = regionB[j].to; j++ } - else { i++; j++; intersectPoint = undefined } - } - return { disjunctRegion, differRegionA, differRegionB }; -} \ No newline at end of file diff --git a/src/editor-mode/selection/index.ts b/src/editor-mode/selection/index.ts deleted file mode 100644 index d73d1d8..0000000 --- a/src/editor-mode/selection/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./trimSelection"; -export * from "./trimSelectionRange"; \ No newline at end of file diff --git a/src/editor-mode/selection/trimSelection.ts b/src/editor-mode/selection/trimSelection.ts deleted file mode 100644 index d1da041..0000000 --- a/src/editor-mode/selection/trimSelection.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { EditorSelection, Text } from "@codemirror/state"; -import { trimSelectionRange } from "src/editor-mode/selection"; - -export function trimSelection(selection: EditorSelection, doc: Text) { - for (let i = 0; i < selection.ranges.length; i++) { - let trimmed = trimSelectionRange(selection.ranges[i], doc); - selection = selection.replaceRange(trimmed, i); - } - return selection; -} \ No newline at end of file diff --git a/src/editor-mode/state-fields/builderField.ts b/src/editor-mode/state-fields/builderField.ts deleted file mode 100644 index 894e9c4..0000000 --- a/src/editor-mode/state-fields/builderField.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { StateField } from "@codemirror/state"; -import { DecorationBuilder } from "src/editor-mode/decorator/builder"; -import { parserField, selectionObserverField } from "src/editor-mode/state-fields"; - -export const builderField = StateField.define({ - create(state) { - let parser = state.field(parserField), - selectionObserver = state.field(selectionObserverField), - builder = new DecorationBuilder(parser, selectionObserver); - builder.onStateInit(state); - return builder; - }, - update(builder, transaction) { - builder.onStateUpdate(transaction); - return builder; - } -}); \ No newline at end of file diff --git a/src/editor-mode/state-fields/decoSetField.ts b/src/editor-mode/state-fields/decoSetField.ts deleted file mode 100644 index 4bf68ae..0000000 --- a/src/editor-mode/state-fields/decoSetField.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { StateField } from "@codemirror/state"; -import { builderField } from "src/editor-mode/state-fields"; -import { EditorView } from "@codemirror/view"; - -/** Stores only height-altering decorations. */ -export const decoSetField = StateField.define({ - create(state) { - let holder = state.field(builderField).holder; - return { - lineBreaksSet: holder.lineBreaksSet, - blockOmittedSet: holder.blockOmittedSet - }; - }, - update(decoSet, transaction) { - let holder = transaction.state.field(builderField).holder; - // Avoid `EditorView.decorations` recomputation while the sets haven't - // changed/updated yet. It's done by returning same object rather than - // creating the new one, though the same holder. - if ( - holder.lineBreaksSet === decoSet.lineBreaksSet && - holder.blockOmittedSet === decoSet.blockOmittedSet - ) { return decoSet } - return { - lineBreaksSet: holder.lineBreaksSet, - blockOmittedSet: holder.blockOmittedSet - }; - }, - provide(field) { - // The both sets replace line breaks with "
" or hide it. Hence, we - // need providing it directly, i.e. not in the view update. - return [ - EditorView.decorations.from(field, set => set.blockOmittedSet), - EditorView.decorations.from(field, set => set.lineBreaksSet) - ] - } -}); \ No newline at end of file diff --git a/src/editor-mode/state-fields/index.ts b/src/editor-mode/state-fields/index.ts deleted file mode 100644 index 36c1451..0000000 --- a/src/editor-mode/state-fields/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./parserField"; -export * from "./selectionObserverField"; -export * from "./builderField"; -export * from "./decoSetField"; \ No newline at end of file diff --git a/src/editor-mode/state-fields/parserField.ts b/src/editor-mode/state-fields/parserField.ts deleted file mode 100644 index 9f1495f..0000000 --- a/src/editor-mode/state-fields/parserField.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { StateEffect, StateField } from "@codemirror/state"; -import { Parser } from "src/editor-mode/parser"; -import { ParseContext, syntaxTree } from "@codemirror/language"; -import { Tree } from "@lezer/common"; -import { settingsFacet } from "src/editor-mode/facets"; -import { refresherAnnot } from "src/editor-mode/annotations"; -import { activityFacet } from "../facets/activityFacet"; - -/** - * In the context of extending syntax extension, this field should be the - * first in order. - */ -export const parserField = StateField.define({ - create(state) { - let parser = new Parser(state.facet(settingsFacet.reader)), - activityRecorder = state.facet(activityFacet); - parser.initParse(state.doc, syntaxTree(state)); - activityRecorder.enter({ isParsing: true }); - return parser; - }, - update(parser, transaction) { - let oldTree = syntaxTree(transaction.startState), - newTree = syntaxTree(transaction.state), - isRefreshed = transaction.annotation(refresherAnnot), - activityRecorder = transaction.state.facet(activityFacet); - // Sometimes, there is an effect containing ParseContext and the most - // updated Tree along with the state update. If any, we should use it - // instead of that was taken from the state. - transaction.effects.forEach((effect: StateEffect) => { - let updatedTree = (effect.value as Partial<{ context: ParseContext, tree: Tree }>)?.tree; - if (updatedTree instanceof Tree) { - newTree = updatedTree; - } - }); - if (isRefreshed) { - parser.initParse(transaction.newDoc, newTree); - activityRecorder.enter({ isParsing: true }); - return parser; - } - // Also, do reparse when the Tree got to be reparsed. - if (transaction.docChanged || oldTree.length != newTree.length) { - parser.applyChange(transaction.newDoc, newTree, oldTree, transaction.changes); - activityRecorder.enter({ isParsing: true }); - } - return parser; - } -}); \ No newline at end of file diff --git a/src/editor-mode/state-fields/selectionObserverField.ts b/src/editor-mode/state-fields/selectionObserverField.ts deleted file mode 100644 index d6d7aef..0000000 --- a/src/editor-mode/state-fields/selectionObserverField.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { StateField } from "@codemirror/state"; -import { SelectionObserver } from "src/editor-mode/observer"; -import { parserField } from "src/editor-mode/state-fields"; -import { activityFacet } from "src/editor-mode/facets"; - -export const selectionObserverField = StateField.define({ - create(state) { - let observer = new SelectionObserver(state.field(parserField)), - activityRecorder = state.facet(activityFacet); - observer.observe(state.selection, true); - activityRecorder.enter({ isObserving: true }); - return observer; - }, - update(observer, transaction) { - // Start observer only when the parser has run or the selection has been - // moved. - let selectionMoved = !(transaction.selection && transaction.startState.selection.eq(transaction.selection)), - activityRecorder = transaction.state.facet(activityFacet), - isParsing = activityRecorder.verify(observer, "parse"); - transaction.isUserEvent("select"); - if (isParsing || selectionMoved) { - observer.observe(transaction.newSelection, isParsing ?? false); - activityRecorder.enter({ isObserving: true }); - } - return observer; - } -}); \ No newline at end of file diff --git a/src/editor-mode/ui-components.ts b/src/editor-mode/ui-components.ts new file mode 100644 index 0000000..c895721 --- /dev/null +++ b/src/editor-mode/ui-components.ts @@ -0,0 +1,247 @@ +import ExtendedMarkdownSyntax from "main"; +import { IconName, Menu, Editor, MarkdownFileInfo, MarkdownView, Platform } from "obsidian"; +import { Direction, EditorView } from "@codemirror/view"; +import { IndexCache, TagConfig, InlineFormat } from "src/types"; +import { Format, TokenLevel, TokenStatus } from "src/enums"; +import { getActiveCanvasNodeCoords } from "src/editor-mode/utils/canvas-utils"; +import { Formatter } from "src/editor-mode/formatting/formatter"; +import { supportTag } from "src/format-configs/format-utils"; +import { tagMenuOptionCaches, instancesStore } from "src/editor-mode/cm-extension"; +import { ctxMenuCommands } from "src/editor-mode/formatting/commands"; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface ITagMenu extends TagMenu {}; + +export function extendEditorCtxMenu(menu: Menu, editor: Editor, ctx: MarkdownFileInfo | MarkdownView) { + menu.addItem(item => { + let subMenu = item.setSubmenu(), + cmView = editor.activeCm || editor.cm, + activeFormats: Partial> = {}, + commands = ctxMenuCommands; + subMenu.onload = function () { + if (Platform.isMobile) { return } + subMenu.registerDomEvent(subMenu.dom, "mouseover", () => { + if (subMenu.currentSubmenu && subMenu.currentSubmenu !== subMenu.items[subMenu.selected]?.submenu) { + subMenu.closeSubmenu(); + // @ts-ignore + subMenu.openSubmenuSoon(subMenu.items[subMenu.selected]); + } + }); + }; + if (!(cmView instanceof EditorView)) { + throw ReferenceError("EditorView not found"); + } + let selectionObserver = cmView.state.facet(instancesStore).observer; + selectionObserver.iterateSelectedRegion(TokenLevel.INLINE, true, (token , _, __, pos) => { + let type = token.type as InlineFormat; + if (token.status != TokenStatus.ACTIVE || activeFormats[type]) { return } + if (pos == "covered" || pos == "covering") { + activeFormats[type] = true; + } + }); + subMenu.addSections(["selection-extended-inline", "selection-extended-block"]); + item.setTitle("More format..."); + item.setIcon("paintbrush-vertical"); + item.setSection("selection"); + for (let i = 0; i < commands.length; i++) { + subMenu.addItem(item => { + let type = commands[i].type as InlineFormat; + item.setTitle(commands[i].ctxMenuTitle); + item.setIcon(commands[i].icon); + item.setSection("selection-extended-inline"); + if (activeFormats[type]) { + item.setChecked(true); + } + if (type == Format.HIGHLIGHT || type == Format.CUSTOM_SPAN) { + let tagSubMenu = item.setSubmenu(); + TagMenu.bindMenu(tagSubMenu, cmView, type); + } + item.onClick(() => { + commands[i].editorCallback(editor, ctx); + menu.close(); + }); + }); + } + }); +} + +export class TagMenu extends Menu { + mainPlugin: ExtendedMarkdownSyntax; + itemIndexCache: IndexCache = { number: 0 }; + view: EditorView; + type: Format; + formatter: Formatter; + CLS_PREFIX = "ems-menu-item"; + ICON: string; + constructor(view: EditorView, type: Format) { + super(); + this.constructMenu(view, type); + } + constructMenu(view: EditorView, type: Format) { + let internalInstances = view.state.facet(instancesStore); + this.view = view; + this.type = type; + this.formatter = internalInstances.formatter; + this.mainPlugin = internalInstances.mainPlugin; + this.bindIndexCache(); + this.setClassPrefix(); + this.setIcon(); + this.addConfigs(this.getConfigs()); + this.addDefaultItem(); + this.addRemoveItem(); + this.dom.addClass("ems-menu", "ems-tag-menu"); + if (type == Format.HIGHLIGHT) { + this.dom.addClass("ems-color-menu"); + } + } + setClassPrefix() { + if (this.type == Format.HIGHLIGHT) { + this.CLS_PREFIX += " ems-highlight-"; + } else if (this.type == Format.CUSTOM_SPAN) { + this.CLS_PREFIX += " ems-span-"; + } else if (this.type == Format.FENCED_DIV) { + this.CLS_PREFIX += " ems-div-"; + } + } + setIcon() { + if (this.type == Format.HIGHLIGHT) { + this.ICON = "palette"; + } else { + this.ICON = "shapes"; + } + } + bindIndexCache() { + let lastOption = this.view.state.facet(tagMenuOptionCaches); + switch (this.type) { + case Format.HIGHLIGHT: + this.itemIndexCache = lastOption.colorMenuItem; + break; + case Format.CUSTOM_SPAN: + this.itemIndexCache = lastOption.spanTagMenuItem; + break; + case Format.FENCED_DIV: + this.itemIndexCache = lastOption.divTagMenuItem; + break; + default: + this.itemIndexCache = { number: 0 }; + } + } + getConfigs() { + let configs: TagConfig[] = [], + { settings } = this.mainPlugin; + if (this.type == Format.HIGHLIGHT) { + configs = settings.colorConfigs; + } else if (this.type == Format.CUSTOM_SPAN) { + configs = settings.predefinedSpanTag; + } else if (this.type == Format.FENCED_DIV) { + configs = settings.predefinedDivTag; + } + return configs; + } + addConfigs(configs: TagConfig[]) { + let { settings } = this.mainPlugin; + configs.forEach(({ name, tag, showInMenu }) => { + if (!showInMenu) { return } + this.addConfigItem(name, this.ICON, this.CLS_PREFIX + tag, () => { this.format(tag) }); + }); + if (this.type == Format.HIGHLIGHT && settings.showAccentColor) { + this.addConfigItem("Accent", this.ICON, this.CLS_PREFIX + "accent", () => { this.format("accent") }); + } + } + addConfigItem(name: string, icon: IconName, cls: string, callback: (evt: MouseEvent | KeyboardEvent) => unknown) { + this.addItem(item => { + item.setTitle(name); + item.setIcon(icon); + item.dom.addClass(...cls.split(" ")); + item.onClick(evt => { + let index = this.items.findIndex(i => i == item); + this.itemIndexCache.number = index; + callback(evt); + }); + }); + } + addDefaultItem() { + let { settings } = this.mainPlugin; + if ( + this.type == Format.HIGHLIGHT && settings.showDefaultColor || + this.type == Format.CUSTOM_SPAN && settings.showDefaultSpanTag + ) { + this.addConfigItem("Default", this.ICON, this.CLS_PREFIX + "default", () => { this.format("") }); + } + } + addRemoveItem() { + let { settings } = this.mainPlugin; + if ( + this.type == Format.HIGHLIGHT && settings.showRemoveColor || + this.type == Format.CUSTOM_SPAN && settings.showRemoveSpanTag + ) { + this.addConfigItem("Remove", "eraser", "ems-menu-item ems-remove", () => { this.removeFormatting() }); + } + } + format(tag: string) { + this.formatter.startFormat(this.view, this.type, tag); + } + removeFormatting() { + this.formatter.startFormat(this.view, this.type, undefined, true); + } + showMenu() { + this.checkItemIndexCache(); + this.view.requestMeasure({ + read: (view) => { + let app = this.mainPlugin.app, + canvasNodeCoords = getActiveCanvasNodeCoords(app), + charOffset = view.state.selection.ranges.at(-1)!.to, + charCoords = view.coordsForChar(charOffset); + if (!charCoords) { + let contentDOMCoords = view.contentDOM.getBoundingClientRect(), + firstBlock = view.lineBlockAt(charOffset), + isRTL = view.textDirection == Direction.RTL; + charCoords = { + top: contentDOMCoords.top, + bottom: contentDOMCoords.top + firstBlock.height, + left: isRTL ? contentDOMCoords.right : contentDOMCoords.left, + right: isRTL ? contentDOMCoords.left : contentDOMCoords.right + }; + } + return { charCoords, canvasNodeCoords }; + }, + write: (measure) => { + let { charCoords, canvasNodeCoords } = measure; + if (charCoords) { + let menuCoords = { x: charCoords.left, y: charCoords.bottom }; + if (canvasNodeCoords) { + menuCoords.x += canvasNodeCoords.x; + menuCoords.y += canvasNodeCoords.y; + } + this.showAtPosition(menuCoords); + this.select(this.itemIndexCache.number); + } + } + }); + } + checkItemIndexCache() { + if (this.itemIndexCache.number >= this.items.length) { + this.itemIndexCache.number = this.items.length - 1; + } + } + static bindMenu(other: Menu, ...args: ConstructorParameters) { + let methodNames = ["addConfigItem", "addConfigs", "bindIndexCache", "checkItemIndexCache", "constructMenu", "format", "removeFormatting", "showMenu", "setClassPrefix", "setIcon", "addDefaultItem", "addRemoveItem", "getConfigs"] as const; + for (let methodName of methodNames) { + Object.defineProperty(other, methodName, { + value: this.prototype[methodName].bind(other) + }); + } + Object.defineProperty(other, "CLS_PREFIX", { + value: "ems-menu-item", + configurable: true, + writable: true + }); + (other as ITagMenu).constructMenu(...args); + return other as ITagMenu; + } + static create(...args: ConstructorParameters) { + let [view, type] = args; + if (!supportTag(type)) { throw new TypeError("This format type doesn't support custom tag.") } + return new this(view, type); + } +} \ No newline at end of file diff --git a/src/editor-mode/ui-components/TagMenu.ts b/src/editor-mode/ui-components/TagMenu.ts deleted file mode 100644 index 84bcd80..0000000 --- a/src/editor-mode/ui-components/TagMenu.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { Direction, EditorView } from "@codemirror/view"; -import { IconName, Menu } from "obsidian"; -import { IndexCache, ITagMenu, PluginSettings, TagConfig } from "src/types"; -import { appFacet, settingsFacet } from "src/editor-mode/facets"; -import { getActiveCanvasNodeCoords } from "src/editor-mode/editor-utils"; -import { editorPlugin } from "src/editor-mode/extensions"; -import { Format } from "src/enums"; -import { Formatter } from "src/editor-mode/formatting"; -import { supportTag } from "src/format-configs/utils"; - -export class TagMenu extends Menu { - itemIndexCache: IndexCache = { number: 0 }; - view: EditorView; - type: Format; - formatter: Formatter; - settings: PluginSettings; - CLS_PREFIX = "ems-menu-item"; - ICON: string; - constructor(view: EditorView, type: Format) { - super(); - this.constructMenu(view, type); - } - constructMenu(view: EditorView, type: Format) { - this.view = view; - this.type = type; - this.formatter = view.plugin(editorPlugin)!.formatter; - this.settings = view.state.facet(settingsFacet); - this.bindIndexCache(); - this.setClassPrefix(); - this.setIcon(); - this.addConfigs(this.getConfigs()); - this.addDefaultItem(); - this.addRemoveItem(); - this.dom.addClass("ems-menu", "ems-tag-menu"); - if (type == Format.HIGHLIGHT) { - this.dom.addClass("ems-color-menu"); - } - } - setClassPrefix() { - if (this.type == Format.HIGHLIGHT) { - this.CLS_PREFIX += " ems-highlight-"; - } else if (this.type == Format.CUSTOM_SPAN) { - this.CLS_PREFIX += " ems-span-"; - } else if (this.type == Format.FENCED_DIV) { - this.CLS_PREFIX += " ems-div-"; - } - } - setIcon() { - if (this.type == Format.HIGHLIGHT) { - this.ICON = "palette"; - } else { - this.ICON = "shapes"; - } - } - bindIndexCache() { - let { indexCache } = this.view.plugin(editorPlugin)!; - switch (this.type) { - case Format.HIGHLIGHT: - this.itemIndexCache = indexCache.colorMenuItem; - break; - case Format.CUSTOM_SPAN: - this.itemIndexCache = indexCache.spanTagMenuItem; - break; - case Format.FENCED_DIV: - this.itemIndexCache = indexCache.divTagMenuItem; - break; - default: - this.itemIndexCache = { number: 0 }; - } - } - getConfigs() { - let configs: TagConfig[] = []; - if (this.type == Format.HIGHLIGHT) { - configs = this.settings.colorConfigs; - } else if (this.type == Format.CUSTOM_SPAN) { - configs = this.settings.predefinedSpanTag; - } else if (this.type == Format.FENCED_DIV) { - configs = this.settings.predefinedDivTag; - } - return configs; - } - addConfigs(configs: TagConfig[]) { - configs.forEach(({ name, tag, showInMenu }) => { - if (!showInMenu) { return } - this.addConfigItem(name, this.ICON, this.CLS_PREFIX + tag, () => { this.format(tag) }); - }); - if (this.type == Format.HIGHLIGHT && this.settings.showAccentColor) { - this.addConfigItem("Accent", this.ICON, this.CLS_PREFIX + "accent", () => { this.format("accent") }); - } - } - addConfigItem(name: string, icon: IconName, cls: string, callback: (evt: MouseEvent | KeyboardEvent) => unknown) { - this.addItem(item => { - item.setTitle(name); - item.setIcon(icon); - item.dom.addClass(...cls.split(" ")); - item.onClick(evt => { - let index = this.items.findIndex(i => i == item); - this.itemIndexCache.number = index; - callback(evt); - }); - }); - } - addDefaultItem() { - if ( - this.type == Format.HIGHLIGHT && this.settings.showDefaultColor || - this.type == Format.CUSTOM_SPAN && this.settings.showDefaultSpanTag - ) { - this.addConfigItem("Default", this.ICON, this.CLS_PREFIX + "default", () => { this.format("") }); - } - } - addRemoveItem() { - if ( - this.type == Format.HIGHLIGHT && this.settings.showRemoveColor || - this.type == Format.CUSTOM_SPAN && this.settings.showRemoveSpanTag - ) { - this.addConfigItem("Remove", "eraser", "ems-menu-item ems-remove", () => { this.removeFormatting() }); - } - } - format(tag: string) { - this.formatter.startFormat(this.type, tag); - } - removeFormatting() { - this.formatter.startFormat(this.type, undefined, true); - } - showMenu() { - this.checkItemIndexCache(); - this.view.requestMeasure({ - read: (view) => { - let app = view.state.facet(appFacet.reader), - canvasNodeCoords = getActiveCanvasNodeCoords(app), - charOffset = view.state.selection.ranges.at(-1)!.to, - charCoords = view.coordsForChar(charOffset); - if (!charCoords) { - let contentDOMCoords = view.contentDOM.getBoundingClientRect(), - firstBlock = view.lineBlockAt(charOffset), - isRTL = view.textDirection == Direction.RTL; - charCoords = { - top: contentDOMCoords.top, - bottom: contentDOMCoords.top + firstBlock.height, - left: isRTL ? contentDOMCoords.right : contentDOMCoords.left, - right: isRTL ? contentDOMCoords.left : contentDOMCoords.right - }; - } - return { charCoords, canvasNodeCoords }; - }, - write: (measure) => { - let { charCoords, canvasNodeCoords } = measure; - if (charCoords) { - let menuCoords = { x: charCoords.left, y: charCoords.bottom }; - if (canvasNodeCoords) { - menuCoords.x += canvasNodeCoords.x; - menuCoords.y += canvasNodeCoords.y; - } - this.showAtPosition(menuCoords); - this.select(this.itemIndexCache.number); - } - } - }); - } - checkItemIndexCache() { - if (this.itemIndexCache.number >= this.items.length) { - this.itemIndexCache.number = this.items.length - 1; - } - } - static bindMenu(other: Menu, ...args: ConstructorParameters) { - let methodNames = ["addConfigItem", "addConfigs", "bindIndexCache", "checkItemIndexCache", "constructMenu", "format", "removeFormatting", "showMenu", "setClassPrefix", "setIcon", "addDefaultItem", "addRemoveItem", "getConfigs"] as const; - for (let methodName of methodNames) { - Object.defineProperty(other, methodName, { - value: this.prototype[methodName].bind(other) - }); - } - Object.defineProperty(other, "CLS_PREFIX", { - value: "ems-menu-item", - configurable: true, - writable: true - }); - (other as ITagMenu).constructMenu(...args); - return other as ITagMenu; - } - static create(...args: ConstructorParameters) { - let [view, type] = args; - if (!supportTag(type)) { throw new TypeError("This format type doesn't support custom tag.") } - return new this(view, type); - } -} \ No newline at end of file diff --git a/src/editor-mode/ui-components/index.ts b/src/editor-mode/ui-components/index.ts deleted file mode 100644 index 9b5d5c6..0000000 --- a/src/editor-mode/ui-components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./TagMenu"; \ No newline at end of file diff --git a/src/editor-mode/ui-components/utils/extendEditorCtxMenu.ts b/src/editor-mode/ui-components/utils/extendEditorCtxMenu.ts deleted file mode 100644 index d7bc3ad..0000000 --- a/src/editor-mode/ui-components/utils/extendEditorCtxMenu.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { EditorView } from "@codemirror/view"; -import { Editor, MarkdownFileInfo, MarkdownView, Menu, Platform } from "obsidian"; -import { selectionObserverField } from "src/editor-mode/state-fields"; -import { Format, TokenLevel, TokenStatus } from "src/enums"; -import { InlineFormat } from "src/types"; -import { ctxMenuCommands } from "src/editor-mode/commands"; -import { TagMenu } from "src/editor-mode/ui-components"; - -export function extendEditorCtxMenu(menu: Menu, editor: Editor, ctx: MarkdownFileInfo | MarkdownView) { - menu.addItem(item => { - let subMenu = item.setSubmenu(), - cmView = editor.activeCm || editor.cm, - activeFormats: Partial> = {}, - commands = ctxMenuCommands; - subMenu.onload = function () { - if (Platform.isMobile) { return } - subMenu.registerDomEvent(subMenu.dom, "mouseover", () => { - if (subMenu.currentSubmenu && subMenu.currentSubmenu !== subMenu.items[subMenu.selected]?.submenu) { - subMenu.closeSubmenu(); - // @ts-ignore - subMenu.openSubmenuSoon(subMenu.items[subMenu.selected]); - } - }); - }; - if (!(cmView instanceof EditorView)) { - throw ReferenceError("EditorView not found"); - } - let selectionObserver = cmView.state.field(selectionObserverField); - selectionObserver.iterateSelectedRegion(TokenLevel.INLINE, true, (token , index, tokens, pos) => { - let type = token.type as InlineFormat; - if (token.status != TokenStatus.ACTIVE || activeFormats[type]) { return } - if (pos == "covered" || pos == "covering") { - activeFormats[type] = true; - } - }); - subMenu.addSections(["selection-extended-inline", "selection-extended-block"]); - item.setTitle("More format..."); - item.setIcon("paintbrush-vertical"); - item.setSection("selection"); - for (let i = 0; i < commands.length; i++) { - subMenu.addItem(item => { - let type = commands[i].type as InlineFormat; - item.setTitle(commands[i].ctxMenuTitle); - item.setIcon(commands[i].icon); - item.setSection("selection-extended-inline"); - if (activeFormats[type]) { - item.setChecked(true); - } - if (type == Format.HIGHLIGHT || type == Format.CUSTOM_SPAN) { - let tagSubMenu = item.setSubmenu(); - TagMenu.bindMenu(tagSubMenu, cmView, type); - } - item.onClick(() => { - commands[i].editorCallback(editor, ctx); - menu.close(); - }); - }); - } - }); -} \ No newline at end of file diff --git a/src/editor-mode/ui-components/utils/index.ts b/src/editor-mode/ui-components/utils/index.ts deleted file mode 100644 index 33e4682..0000000 --- a/src/editor-mode/ui-components/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./extendEditorCtxMenu"; \ No newline at end of file diff --git a/src/editor-mode/utils/canvas-utils.ts b/src/editor-mode/utils/canvas-utils.ts new file mode 100644 index 0000000..9dd0889 --- /dev/null +++ b/src/editor-mode/utils/canvas-utils.ts @@ -0,0 +1,21 @@ +import { App, MarkdownView } from "obsidian"; + +function _isCanvas(app: App) { + return app.workspace.getMostRecentLeaf()?.view.getViewType() == "canvas"; +} + +function _getActiveCanvasEditor(app: App) { + if (_isCanvas(app)) { + return app.workspace.activeEditor; + } + return null; +} + +export function getActiveCanvasNodeCoords(app: App) { + let canvasEditor = _getActiveCanvasEditor(app), + containerEl = (canvasEditor as MarkdownView)?.containerEl; + if (containerEl) { + return containerEl.getBoundingClientRect(); + } + return null; +} \ No newline at end of file diff --git a/src/editor-mode/utils/doc-utils.ts b/src/editor-mode/utils/doc-utils.ts new file mode 100644 index 0000000..42905f3 --- /dev/null +++ b/src/editor-mode/utils/doc-utils.ts @@ -0,0 +1,274 @@ +import { Line, Text, TextLeaf, TextNode, ILine } from "@codemirror/state"; +import { PlainRange } from "src/types"; + +// TODO: utilize TextCursor to all functions + +type LineAddress = { parent: TextNode | TextLeaf, index: number }[]; + +function _isTextLeaf(doc: Text): doc is TextLeaf { + return "text" in doc && doc.text instanceof Array; +} + +function _isTextNode(doc: Text): doc is TextNode { + return doc.children !== null; +} + +export class TextCursor { + public address: LineAddress; + public curLine: ILine; + + private constructor() {} + + public get doc(): Text { + return this.address[0].parent; + } + + public next(): boolean { + for (let i = this.address.length - 1; i >= 0; i--) { + let { parent, index } = this.address[i], + branches = _isTextLeaf(parent) ? parent.text : parent.children; + if (index + 1 < branches.length) { + this.address[i].index++; + while (i < this.address.length) { + let { parent, index } = this.address[i++]; + if (_isTextLeaf(parent)) { + let length = parent.text[index].length, + from = this.curLine.to + 1; + this.curLine = { + number: this.curLine.number + 1, + text: parent.text[index], + from, + to: from + length, + length + }; + } else { + this.address[i].parent = parent.children[index] as TextNode | TextLeaf; + this.address[i].index = 0; + } + } + return true; + } + } + return false; + } + + public prev(): boolean { + for (let i = this.address.length - 1; i >= 0; i--) { + if (this.address[i].index > 0) { + this.address[i].index--; + while (i < this.address.length) { + let { parent, index } = this.address[i++]; + if (_isTextLeaf(parent)) { + let length = parent.text[index].length, + to = this.curLine.from - 1; + this.curLine = { + number: this.curLine.number - 1, + text: parent.text[index], + from: to - length, + to, + length + }; + } else { + let prevBranch = parent.children[index] as TextNode | TextLeaf; + this.address[i].parent = prevBranch; + this.address[i].index = (_isTextLeaf(prevBranch) ? prevBranch.text : prevBranch.children).length - 1; + } + } + return true; + } + } + return false; + } + + public goto(offset: number): TextCursor { + if (offset < this.curLine.from) while (offset < this.curLine.from && this.prev()); + else if (offset > this.curLine.to) while (offset > this.curLine.to && this.next()); + return this; + } + + public gotoLine(linePos: number): TextCursor { + if (linePos < this.curLine.number) while (this.curLine.number != linePos && this.prev()); + else if (linePos > this.curLine.number) while (this.curLine.number != linePos && this.next()); + return this; + } + + public gotoLast(): TextCursor { + this.address.forEach((branchAddress, i) => { + let { parent } = branchAddress, + branches = _isTextLeaf(parent) ? parent.text : parent.children; + branchAddress.index = branches.length - 1; + if (i + 1 < this.address.length) { + this.address[i + 1].parent = branches[branchAddress.index] as TextLeaf | TextNode; + } else { + let to = this.doc.length, + lineNum = this.doc.lines, + lineStr = branches[branchAddress.index] as string; + this.curLine = { + from: to - lineStr.length, + to, + number: lineNum, + length: lineStr.length, + text: lineStr + }; + } + }); + return this; + } + + public getPrevLine(): ILine | null { + if (this.curLine.number <= 1 || !this.prev()) return null; + let prevLine = this.curLine; + this.next(); + return prevLine; + } + + public getNextLine(): ILine | null { + if (this.curLine.number >= this.doc.lines || !this.next()) return null; + let nextLine = this.curLine; + this.prev(); + return nextLine; + } + + public static atOffset(doc: Text, offset: number): TextCursor { + let { address, line: curLine } = getLineAddressAt(doc, offset), + cursor = new TextCursor(); + cursor.address = address; + cursor.curLine = curLine; + return cursor; + } +} + +export function getNextLine(doc: Text, offsetOrLine: number | Line): Line | null { + let line = offsetOrLine instanceof Line ? offsetOrLine : doc.lineAt(offsetOrLine); + if (line.number >= doc.lines) { return null } + return doc.line(line.number + 1); +} + +export function getBlockEndAt(doc: Text, offsetOrLine: number | Line, blankLineAsEnd = true): Line { + let line = offsetOrLine instanceof Line ? offsetOrLine : doc.lineAt(offsetOrLine); + while (line.number < doc.lines) { + line = doc.line(line.number + 1); + if (isBlankLine(line)) { + if (!blankLineAsEnd) { line = doc.line(line.number - 1) } + break; + } + } + return line; +} + +export function getBlocks(doc: Text, range: PlainRange, lookBehind = false, lookAhead = false): { start: number, end: number }[] { + let blocks: { start: number, end: number }[] = [], + line = doc.lineAt(range.from), + initLine = line, + curBlock: { start: number, end: number } | undefined; + if (!isBlankLine(line) && lookBehind) { + let blockStart = getBlockStartAt(doc, line); + curBlock = { start: blockStart.number, end: line.number + 1 }; + blocks.push(curBlock); + } + for (; range.to >= line.from; line = doc.line(line.number + 1)) { + if (isBlankLine(line)) { curBlock = undefined } + else if (curBlock) { curBlock.end++ } + else { + curBlock = { start: line.number, end: line.number + 1 }; + blocks.push(curBlock); + } + if (line.number >= doc.lines) { break } + } + if (curBlock && lookAhead) { + curBlock.end = getBlockEndAt(doc, line, false).number + 1; + } + if (!blocks.length) { + blocks.push({ start: initLine.number, end: initLine.number + 1 }); + } + return blocks; +} + +export function getBlockStartAt(doc: Text, offsetOrLine: number | Line): Line { + let curLine = offsetOrLine instanceof Line ? offsetOrLine : doc.lineAt(offsetOrLine), + prevLine = curLine.number > 1 ? doc.line(curLine.number - 1) : null; + if (isBlankLine(curLine)) { return curLine } + while (prevLine) { + if (isBlankLine(prevLine)) { break } + curLine = prevLine; + prevLine = curLine.number > 1 ? doc.line(curLine.number - 1) : null; + } + return curLine; +} + +export function getPrevLine(doc: Text, offsetOrLineNum: number | Line): Line | null { + let lineNum: number; + if (offsetOrLineNum instanceof Line) { + lineNum = offsetOrLineNum.number; + } else { + lineNum = doc.lineAt(offsetOrLineNum).number; + } + if (lineNum <= 1) { return null } + return doc.line(lineNum - 1); +} + +export function isBlankLine(line: Line): boolean { + return !line.text.trimEnd(); +} + +export function isBlockEnd(doc: Text, line: Line): boolean { + let nextLine = getNextLine(doc, line); + return !nextLine || isBlankLine(nextLine); +} + +export function isBlockStart(doc: Text, line: Line): boolean { + let prevLine = getPrevLine(doc, line); + return !prevLine || isBlankLine(prevLine); +} + +export function sliceStrFromLine(line: Line, from: number, to: number): string { + from -= line.from; + to -= line.from; + return line.text.slice(from, to); +} + +export function getLineAddressAt(doc: Text, offset: number): { address: LineAddress, line: ILine } { + let parent = doc, + address: LineAddress = [], + lineNum = 0, + lineStr = "", + passedLen = 0; + + if (offset < 0 || offset > parent.length) throw RangeError(); + + while (_isTextNode(parent)) { + let branches = parent.children; + for (let i = 0; i < branches.length; i++) { + let curBranch = branches[i]; + if (offset >= passedLen && offset <= passedLen + curBranch.length) { + address.push({ parent, index: i }); + parent = curBranch; + break; + } + passedLen += curBranch.length + 1; + lineNum += curBranch.lines; + } + } + + if (!_isTextLeaf(parent)) throw TypeError(); + + for (let i = 0; i < parent.text.length; i++) { + let curLineStr = parent.text[i]; + lineNum++; + if (offset >= passedLen && offset <= passedLen + curLineStr.length) { + address.push({ parent, index: i }); + lineStr = curLineStr; + break; + } + passedLen += curLineStr.length + 1; + } + + let line: ILine = { + number: lineNum, + text: lineStr, + from: passedLen, + to: passedLen + lineStr.length, + length: lineStr.length + } + return { address, line }; +} \ No newline at end of file diff --git a/src/editor-mode/utils/range-utils.ts b/src/editor-mode/utils/range-utils.ts new file mode 100644 index 0000000..c278f92 --- /dev/null +++ b/src/editor-mode/utils/range-utils.ts @@ -0,0 +1,34 @@ +import { PlainRange } from "src/types"; + +/** + * Region defined here as a `PlainRange` collection arranged in an array. + * Each range inside should be sorted in order according to its `from`. + */ +export type Region = PlainRange[]; + +/** Join two regions into a single region. */ +export function joinRegions(regionA: Region, regionB: Region): Region { + let unionRegion: Region = []; + for (let i = 0, j = 0; i < regionA.length || j < regionB.length;) { + // Joinned range only occurs between two touched/intersected ranges. + if (!regionB[j] || regionA[i] && regionA[i].to < regionB[j].from) { + unionRegion.push(Object.assign({}, regionA[i])); + i++; continue; + } + if (!regionA[i] || regionB[j] && regionB[j].to < regionA[i].from) { + unionRegion.push(Object.assign({}, regionB[j])); + j++; continue; + } + unionRegion.push({ + from: Math.min(regionA[i].from, regionB[j].from), + to: Math.max(regionA[i].to, regionB[j].to) + }); + i++; j++; + } + return unionRegion; +} + +/** Check whether the offset touches the range. */ +export function isTouched(offset: number, range: PlainRange): boolean { + return offset >= range.from && offset <= range.to; +} \ No newline at end of file diff --git a/src/editor-mode/selection/trimSelectionRange.ts b/src/editor-mode/utils/selection-utils.ts similarity index 62% rename from src/editor-mode/selection/trimSelectionRange.ts rename to src/editor-mode/utils/selection-utils.ts index 6f2ff4e..6d3621a 100644 --- a/src/editor-mode/selection/trimSelectionRange.ts +++ b/src/editor-mode/utils/selection-utils.ts @@ -8,4 +8,13 @@ export function trimSelectionRange(range: SelectionRange, doc: Text): SelectionR return EditorSelection.cursor(range.from); } return EditorSelection.range(range.from + preSpaceLen, range.to - postSpaceLen); -} \ No newline at end of file +} + +export function trimSelection(selection: EditorSelection, doc: Text):EditorSelection { + for (let i = 0; i < selection.ranges.length; i++) { + let trimmed = trimSelectionRange(selection.ranges[i], doc); + selection = selection.replaceRange(trimmed, i); + } + return selection; +} + diff --git a/src/editor-mode/utils/token-utils.ts b/src/editor-mode/utils/token-utils.ts new file mode 100644 index 0000000..f4083b1 --- /dev/null +++ b/src/editor-mode/utils/token-utils.ts @@ -0,0 +1,67 @@ +import { IndexCache, PlainRange, Token, TokenGroup } from "src/types"; + +type TokenPartsRanges = Record<"openRange" | "closeRange" | "tagRange" | "contentRange", PlainRange>; +interface IterTokenGroupSpec { + tokens: TokenGroup, + ranges: PlainRange[] | readonly PlainRange[], + indexCache: IndexCache + callback: (token: Token) => unknown, +} + +export function getTagRange(token: Token): PlainRange { + let from = token.from + token.openLen, + to = from + token.tagLen; + return { from, to }; +} + +export function provideTokenPartsRanges(token: Token): TokenPartsRanges { + let openRange = { from: token.from, to: token.from + token.openLen }, + closeRange = { from: token.to - token.closeLen, to: token.to }, + tagRange = { from: openRange.to, to: openRange.to + token.tagLen }, + contentRange = { from: (token.tagAsContent ? tagRange.from : tagRange.to), to: closeRange.from }; + return { openRange, closeRange, tagRange, contentRange } +} + +export function isToken(range: PlainRange): range is Token { + let { type, openLen, tagLen, closeLen } = range as Token; + return type !== undefined && openLen !== undefined && tagLen !== undefined && closeLen !== undefined; +} + +export function moveTokenIndexCache(tokens: TokenGroup, offset: number, indexCache: IndexCache): void { + if (tokens.length == 0) { + indexCache.number = 0; + return; + } + + if (indexCache.number >= tokens.length) + indexCache.number = tokens.length - 1; + + let curIndex = indexCache.number, + curToken = tokens[curIndex]; + if (offset < curToken.from && curIndex != 0) { + do { + curToken = tokens[--curIndex]; + } while (offset < curToken.from && curIndex != 0) + } else if (offset > curToken.to && curIndex != tokens.length - 1) { + do { + curToken = tokens[++curIndex]; + } while (offset > curToken.to && curIndex != tokens.length - 1) + } + + indexCache.number = curIndex; +} + +export function iterTokenGroup(spec: IterTokenGroupSpec): void { + let { tokens, ranges, callback, indexCache } = spec; + moveTokenIndexCache(tokens, ranges[0]?.from ?? 0, indexCache); + for ( + let i = indexCache.number, j = 0; + i < tokens.length && j < ranges.length; + ) { + if (ranges[j].to <= tokens[i].from) { j++; continue } + if (tokens[i].from < ranges[j].to && tokens[i].to > ranges[j].from) { + callback(tokens[i]); + } + i++; + } +} \ No newline at end of file diff --git a/src/editor-mode/view-plugin/EditorPlugin.ts b/src/editor-mode/view-plugin/EditorPlugin.ts deleted file mode 100644 index c7505b3..0000000 --- a/src/editor-mode/view-plugin/EditorPlugin.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { EditorView, PluginValue, ViewUpdate } from "@codemirror/view"; -import { DecorationBuilder } from "src/editor-mode/decorator/builder"; -import { builderField } from "src/editor-mode/state-fields"; -import { pluginFacet } from "src/editor-mode/facets"; -import ExtendedMarkdownSyntax from "main"; -import { IndexCache } from "src/types"; -import { Formatter } from "src/editor-mode/formatting"; - -export class EditorPlugin implements PluginValue { - builder: DecorationBuilder; - mainPlugin: ExtendedMarkdownSyntax; - formatter: Formatter; - root: DocumentOrShadowRoot; - indexCache: Record<"colorMenuItem" | "spanTagMenuItem" | "divTagMenuItem", IndexCache> = { - colorMenuItem: { number: 0 }, - spanTagMenuItem: { number: 0 }, - divTagMenuItem: { number: 0 } - } - constructor(view: EditorView) { - let state = view.state; - this.mainPlugin = state.facet(pluginFacet); - this.root = view.root; - this.builder = state.field(builderField); - this.builder.onViewInit(view); - this.formatter = new Formatter(view); - } - update(update: ViewUpdate) { - if (this.root !== update.view.root) { - this.onRootChanged(update.view.root); - } - this.builder.onViewUpdate(update); - } - destroy(): void { - this.mainPlugin.colorsHandler.abandon(this.root); - this.mainPlugin.opacityHandler.abandon(this.root); - } - onRootChanged(newRoot: DocumentOrShadowRoot) { - this.mainPlugin.colorsHandler.abandon(this.root); - this.mainPlugin.opacityHandler.abandon(this.root); - this.mainPlugin.colorsHandler.adopt(newRoot); - this.mainPlugin.opacityHandler.adopt(newRoot); - this.root = newRoot; - } -} \ No newline at end of file diff --git a/src/editor-mode/view-plugin/index.ts b/src/editor-mode/view-plugin/index.ts deleted file mode 100644 index 8565c8e..0000000 --- a/src/editor-mode/view-plugin/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./EditorPlugin"; \ No newline at end of file diff --git a/src/enums/Delimiter.ts b/src/enums/Delimiter.ts index ded78ff..d35239e 100644 --- a/src/enums/Delimiter.ts +++ b/src/enums/Delimiter.ts @@ -1,7 +1,7 @@ /** Delimiter position. */ export enum Delimiter { - /** Opening delimiter */ - OPEN, - /** Closing delimiter */ - CLOSE + /** Opening delimiter */ + OPEN, + /** Closing delimiter */ + CLOSE } \ No newline at end of file diff --git a/src/enums/DisplayBehaviour.ts b/src/enums/DisplayBehaviour.ts index 1a883d1..b20b3e3 100644 --- a/src/enums/DisplayBehaviour.ts +++ b/src/enums/DisplayBehaviour.ts @@ -1,5 +1,5 @@ export enum DisplayBehaviour { - ALWAYS, - TAG_TOUCHED, - SYNTAX_TOUCHED + ALWAYS, + TAG_TOUCHED, + SYNTAX_TOUCHED } \ No newline at end of file diff --git a/src/enums/Field.ts b/src/enums/Field.ts index a9660b1..cbcbd89 100644 --- a/src/enums/Field.ts +++ b/src/enums/Field.ts @@ -1,9 +1,9 @@ export enum Field { - TOGGLE, - MULTI_TOGGLE, - DROPDOWN, - COLOR, - TEXT, - TEXT_AREA, - SLIDER + TOGGLE, + MULTI_TOGGLE, + DROPDOWN, + COLOR, + TEXT, + TEXT_AREA, + SLIDER } \ No newline at end of file diff --git a/src/enums/Format.ts b/src/enums/Format.ts index 537d7d6..04715ee 100644 --- a/src/enums/Format.ts +++ b/src/enums/Format.ts @@ -2,18 +2,18 @@ * Formatting type for each token. */ export enum Format { - /** Insertion (underline) */ - INSERTION = 1, - /** Spoiler */ - SPOILER, - /** Superscript */ - SUPERSCRIPT, - /** Subscript */ - SUBSCRIPT, - /** Highlight */ - HIGHLIGHT, - /** Custom span */ - CUSTOM_SPAN, - /** Fenced div (custom block) */ - FENCED_DIV + /** Insertion (underline) */ + INSERTION = 1, + /** Spoiler */ + SPOILER, + /** Superscript */ + SUPERSCRIPT, + /** Subscript */ + SUBSCRIPT, + /** Highlight */ + HIGHLIGHT, + /** Custom span */ + CUSTOM_SPAN, + /** Fenced div (custom block) */ + FENCED_DIV } \ No newline at end of file diff --git a/src/enums/LineCtx.ts b/src/enums/LineCtx.ts index 1fbbb1b..53c0d00 100644 --- a/src/enums/LineCtx.ts +++ b/src/enums/LineCtx.ts @@ -1,13 +1,13 @@ export enum LineCtx { - NONE, - HR_LINE, - HEADING, - BLOCKQUOTE, - CODEBLOCK, - LIST_HEAD, - LIST, - FOOTNOTE_HEAD, - FOOTNOTE, - TABLE, - TABLE_DELIM + NONE, + HR_LINE, + HEADING, + BLOCKQUOTE, + CODEBLOCK, + LIST_HEAD, + LIST, + FOOTNOTE_HEAD, + FOOTNOTE, + TABLE, + TABLE_DELIM } \ No newline at end of file diff --git a/src/enums/MarkdownViewMode.ts b/src/enums/MarkdownViewMode.ts index d65c590..4952504 100644 --- a/src/enums/MarkdownViewMode.ts +++ b/src/enums/MarkdownViewMode.ts @@ -1,6 +1,6 @@ export enum MarkdownViewMode { - NONE = 0, // 00 - PREVIEW_MODE = 1, // 01 - EDITOR_MODE = 2, // 10 - ALL = 3, // 11 + NONE = 0, // 00 + PREVIEW_MODE = 1, // 01 + EDITOR_MODE = 2, // 10 + ALL = 3, // 11 } \ No newline at end of file diff --git a/src/enums/Theme.ts b/src/enums/Theme.ts index b922093..713466b 100644 --- a/src/enums/Theme.ts +++ b/src/enums/Theme.ts @@ -1,4 +1,4 @@ export enum Theme { - LIGHT, - DARK + LIGHT, + DARK } \ No newline at end of file diff --git a/src/enums/TokenLevel.ts b/src/enums/TokenLevel.ts index dbe8a8b..5782898 100644 --- a/src/enums/TokenLevel.ts +++ b/src/enums/TokenLevel.ts @@ -1,4 +1,4 @@ export enum TokenLevel { - BLOCK, - INLINE + BLOCK, + INLINE } \ No newline at end of file diff --git a/src/enums/TokenStatus.ts b/src/enums/TokenStatus.ts index 34856fc..0c321bf 100644 --- a/src/enums/TokenStatus.ts +++ b/src/enums/TokenStatus.ts @@ -7,17 +7,17 @@ * spoiler, sup, and sub. */ export enum TokenStatus { - /** - * The token is being processed through the parser queue. - * After that, its status switched to either `ACTIVE` or `INACTIVE`. - */ - PENDING, - /** The token is ready for the rendering phase. */ - ACTIVE, - /** - * The token stated as `INACTIVE` by default doesn't - * go through rendering phase. Often used to stating - * token sequence that doesn't have closing delimiter. - */ - INACTIVE + /** + * The token is being processed through the parser queue. + * After that, its status switched to either `ACTIVE` or `INACTIVE`. + */ + PENDING, + /** The token is ready for the rendering phase. */ + ACTIVE, + /** + * The token stated as `INACTIVE` by default doesn't + * go through rendering phase. Often used to stating + * token sequence that doesn't have closing delimiter. + */ + INACTIVE } \ No newline at end of file diff --git a/src/format-configs/BlockRules.ts b/src/format-configs/BlockRules.ts deleted file mode 100644 index f934095..0000000 --- a/src/format-configs/BlockRules.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Format } from "src/enums"; -import { BlockFormat, BlockFormatRule } from "src/types"; - -export const BlockRules: Record = { - [Format.FENCED_DIV]: { - char: ":", - length: 3, - exactLen: false, - class: "fenced-div" - } -} \ No newline at end of file diff --git a/src/format-configs/Formats.ts b/src/format-configs/Formats.ts deleted file mode 100644 index 606a494..0000000 --- a/src/format-configs/Formats.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Format } from "src/enums"; -import { BlockRules, InlineRules } from "src/format-configs"; -import { BlockFormat, InlineFormat } from "src/types"; - -export const Formats = { - ALL: (() => { - let formats: Format[] = []; - for (let format in InlineRules) { formats.push(parseInt(format)) } - for (let format in BlockRules) { formats.push(parseInt(format)) } - return formats; - })(), - ALL_BLOCK: (() => { - let formats: BlockFormat[] = []; - for (let format in BlockRules) { formats.push(parseInt(format)) } - return formats; - })(), - ALL_INLINE: (() => { - let formats: InlineFormat[] = []; - for (let format in InlineRules) { formats.push(parseInt(format)) } - return formats; - })(), - SPACE_RESTRICTED_INLINE: (() => { - let formats: InlineFormat[] = []; - for (let format in InlineRules) { - let type = Number.parseInt(format) as InlineFormat; - if (!InlineRules[type].allowSpace) { formats.push(type) } - } - return formats; - })(), - SPACE_ALLOWED_INLINE: (() => { - let formats: InlineFormat[] = []; - for (let format in InlineRules) { - let type = Number.parseInt(format) as InlineFormat; - if (InlineRules[type].allowSpace) { formats.push(type) } - } - return formats; - })(), - NON_BUILTIN_INLINE: (() => { - let formats: InlineFormat[] = []; - for (let format in InlineRules) { - let type = Number.parseInt(format) as InlineFormat; - if (!InlineRules[type].builtin) { formats.push(type) } - } - return formats; - })() -} \ No newline at end of file diff --git a/src/format-configs/InlineRules.ts b/src/format-configs/InlineRules.ts deleted file mode 100644 index bdc97d1..0000000 --- a/src/format-configs/InlineRules.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Format } from "src/enums"; -import { InlineFormatRule, InlineFormat } from "src/types"; - -export const InlineRules: Record = { - [Format.INSERTION]: { - char: "+", - length: 2, - exactLen: true, - allowSpace: true, - mustBeClosed: true, - class: "ins", - getEl: () => document.createElement("ins"), - builtin: false - }, - [Format.SPOILER]: { - char: "|", - length: 2, - exactLen: true, - allowSpace: true, - mustBeClosed: true, - class: "spoiler", - getEl: () => { - let spoilerEl = document.createElement("span"); - spoilerEl.addClass("spoiler"); - spoilerEl.addEventListener("click", (evt) => { - let spoilerEl = evt.currentTarget as Element, - isHidden = !spoilerEl.hasClass("spoiler-revealed"); - spoilerEl.toggleClass("spoiler-revealed", isHidden); - }); - return spoilerEl; - }, - builtin: false - }, - [Format.SUPERSCRIPT]: { - char: "^", - length: 1, - exactLen: true, - allowSpace: false, - mustBeClosed: true, - class: "sup", - getEl: () => document.createElement("sup"), - builtin: false - }, - [Format.SUBSCRIPT]: { - char: "~", - length: 1, - exactLen: true, - allowSpace: false, - mustBeClosed: true, - class: "sub", - getEl: () => document.createElement("sub"), - builtin: false - }, - [Format.HIGHLIGHT]: { - char: "=", - length: 2, - exactLen: false, - allowSpace: true, - mustBeClosed: false, - class: "custom-highlight", - getEl: () => document.createElement("mark"), - builtin: true - }, - [Format.CUSTOM_SPAN]: { - char: "!", - length: 2, - exactLen: true, - allowSpace: true, - mustBeClosed: true, - class: "custom-span", - getEl() { - let el = document.createElement("span"); - el.classList.add(InlineRules[Format.CUSTOM_SPAN].class); - return el; - }, - builtin: false - } -} \ No newline at end of file diff --git a/src/format-configs/format-utils.ts b/src/format-configs/format-utils.ts new file mode 100644 index 0000000..1b7f10a --- /dev/null +++ b/src/format-configs/format-utils.ts @@ -0,0 +1,64 @@ +import { Format, MarkdownViewMode } from "src/enums"; +import { PluginSettings, InlineFormat } from "src/types"; +import { InlineRules } from "src/format-configs/rules"; +import { EditorDelimLookup } from "src/editor-mode/preprocessor/parser-configs"; +import { PreviewDelimLookup } from "src/preview-mode/post-processor/configs"; + +export function configureDelimLookup(settings: PluginSettings): void { + for (let key in EditorDelimLookup) { + delete EditorDelimLookup[key]; + } + for (let key in PreviewDelimLookup) { + delete PreviewDelimLookup[key]; + } + + // editor + if (settings.insertion & MarkdownViewMode.EDITOR_MODE) { + EditorDelimLookup[InlineRules[Format.INSERTION].char] = Format.INSERTION; + } + if (settings.spoiler & MarkdownViewMode.EDITOR_MODE) { + EditorDelimLookup[InlineRules[Format.SPOILER].char] = Format.SPOILER; + } + if (settings.superscript & MarkdownViewMode.EDITOR_MODE) { + EditorDelimLookup[InlineRules[Format.SUPERSCRIPT].char] = Format.SUPERSCRIPT; + } + if (settings.subscript & MarkdownViewMode.EDITOR_MODE) { + EditorDelimLookup[InlineRules[Format.SUBSCRIPT].char] = Format.SUBSCRIPT; + } + if (settings.customHighlight & MarkdownViewMode.EDITOR_MODE) { + EditorDelimLookup[InlineRules[Format.HIGHLIGHT].char] = Format.HIGHLIGHT; + } + if (settings.customSpan & MarkdownViewMode.EDITOR_MODE) { + EditorDelimLookup[InlineRules[Format.CUSTOM_SPAN].char] = Format.CUSTOM_SPAN; + } + // preview + if (settings.insertion & MarkdownViewMode.PREVIEW_MODE) { + PreviewDelimLookup[InlineRules[Format.INSERTION].char] = Format.INSERTION; + } + if (settings.spoiler & MarkdownViewMode.PREVIEW_MODE) { + PreviewDelimLookup[InlineRules[Format.SPOILER].char] = Format.SPOILER; + } + if (settings.superscript & MarkdownViewMode.PREVIEW_MODE) { + PreviewDelimLookup[InlineRules[Format.SUPERSCRIPT].char] = Format.SUPERSCRIPT; + } + if (settings.subscript & MarkdownViewMode.PREVIEW_MODE) { + PreviewDelimLookup[InlineRules[Format.SUBSCRIPT].char] = Format.SUBSCRIPT; + } + if (settings.customSpan & MarkdownViewMode.PREVIEW_MODE) { + PreviewDelimLookup[InlineRules[Format.CUSTOM_SPAN].char] = Format.CUSTOM_SPAN; + } +} + +export function isInlineFormat(type: Format): type is InlineFormat { + return type >= Format.INSERTION && type <= Format.CUSTOM_SPAN +} + +export function supportTag(type: Format): type is Format.HIGHLIGHT | Format.CUSTOM_SPAN | Format.FENCED_DIV { + return type >= Format.HIGHLIGHT || type == Format.FENCED_DIV; +} + +export function trimTag(tagStr: string): string { + return tagStr + .trim() + .replaceAll(/\s{2,}/g, " "); +} \ No newline at end of file diff --git a/src/format-configs/index.ts b/src/format-configs/index.ts deleted file mode 100644 index 8110930..0000000 --- a/src/format-configs/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./InlineRules"; -export * from "./BlockRules"; -export * from "./Formats"; \ No newline at end of file diff --git a/src/format-configs/rules.ts b/src/format-configs/rules.ts new file mode 100644 index 0000000..eda33ae --- /dev/null +++ b/src/format-configs/rules.ts @@ -0,0 +1,130 @@ +import { Format } from "src/enums"; +import { BlockFormat, InlineFormat, BlockFormatRule, InlineFormatRule } from "src/types"; + +export const BlockRules: Record = { + [Format.FENCED_DIV]: { + char: ":", + length: 3, + exactLen: false, + class: "fenced-div" + } +} + +export const InlineRules: Record = { + [Format.INSERTION]: { + char: "+", + length: 2, + exactLen: true, + allowSpace: true, + mustBeClosed: true, + class: "ins", + getEl: () => document.createElement("ins"), + builtin: false + }, + [Format.SPOILER]: { + char: "|", + length: 2, + exactLen: true, + allowSpace: true, + mustBeClosed: true, + class: "spoiler", + getEl: () => { + let spoilerEl = document.createElement("span"); + spoilerEl.addClass("spoiler"); + spoilerEl.addEventListener("click", (evt) => { + let spoilerEl = evt.currentTarget as Element, + isHidden = !spoilerEl.hasClass("spoiler-revealed"); + spoilerEl.toggleClass("spoiler-revealed", isHidden); + }); + return spoilerEl; + }, + builtin: false + }, + [Format.SUPERSCRIPT]: { + char: "^", + length: 1, + exactLen: true, + allowSpace: false, + mustBeClosed: true, + class: "sup", + getEl: () => document.createElement("sup"), + builtin: false + }, + [Format.SUBSCRIPT]: { + char: "~", + length: 1, + exactLen: true, + allowSpace: false, + mustBeClosed: true, + class: "sub", + getEl: () => document.createElement("sub"), + builtin: false + }, + [Format.HIGHLIGHT]: { + char: "=", + length: 2, + exactLen: false, + allowSpace: true, + mustBeClosed: false, + class: "custom-highlight", + getEl: () => document.createElement("mark"), + builtin: true + }, + [Format.CUSTOM_SPAN]: { + char: "!", + length: 2, + exactLen: true, + allowSpace: true, + mustBeClosed: true, + class: "custom-span", + getEl() { + let el = document.createElement("span"); + el.classList.add(InlineRules[Format.CUSTOM_SPAN].class); + return el; + }, + builtin: false + } +} + +export const Formats = { + ALL: (() => { + let formats: Format[] = []; + for (let format in InlineRules) { formats.push(parseInt(format)) } + for (let format in BlockRules) { formats.push(parseInt(format)) } + return formats; + })(), + ALL_BLOCK: (() => { + let formats: BlockFormat[] = []; + for (let format in BlockRules) { formats.push(parseInt(format)) } + return formats; + })(), + ALL_INLINE: (() => { + let formats: InlineFormat[] = []; + for (let format in InlineRules) { formats.push(parseInt(format)) } + return formats; + })(), + SPACE_RESTRICTED_INLINE: (() => { + let formats: InlineFormat[] = []; + for (let format in InlineRules) { + let type = Number.parseInt(format) as InlineFormat; + if (!InlineRules[type].allowSpace) { formats.push(type) } + } + return formats; + })(), + SPACE_ALLOWED_INLINE: (() => { + let formats: InlineFormat[] = []; + for (let format in InlineRules) { + let type = Number.parseInt(format) as InlineFormat; + if (InlineRules[type].allowSpace) { formats.push(type) } + } + return formats; + })(), + NON_BUILTIN_INLINE: (() => { + let formats: InlineFormat[] = []; + for (let format in InlineRules) { + let type = Number.parseInt(format) as InlineFormat; + if (!InlineRules[type].builtin) { formats.push(type) } + } + return formats; + })() +} \ No newline at end of file diff --git a/src/format-configs/utils/configureDelimLookup.ts b/src/format-configs/utils/configureDelimLookup.ts deleted file mode 100644 index 6039f5a..0000000 --- a/src/format-configs/utils/configureDelimLookup.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Format, MarkdownViewMode } from "src/enums"; -import { InlineRules } from "src/format-configs"; -import { PluginSettings } from "src/types"; -import { EditorDelimLookup } from "src/editor-mode/parser/configs"; -import { PreviewDelimLookup } from "src/preview-mode/configs"; - -export function configureDelimLookup(settings: PluginSettings) { - // editor - if (settings.insertion & MarkdownViewMode.EDITOR_MODE) { - EditorDelimLookup[InlineRules[Format.INSERTION].char] = Format.INSERTION; - } - if (settings.spoiler & MarkdownViewMode.EDITOR_MODE) { - EditorDelimLookup[InlineRules[Format.SPOILER].char] = Format.SPOILER; - } - if (settings.superscript & MarkdownViewMode.EDITOR_MODE) { - EditorDelimLookup[InlineRules[Format.SUPERSCRIPT].char] = Format.SUPERSCRIPT; - } - if (settings.subscript & MarkdownViewMode.EDITOR_MODE) { - EditorDelimLookup[InlineRules[Format.SUBSCRIPT].char] = Format.SUBSCRIPT; - } - if (settings.customHighlight & MarkdownViewMode.EDITOR_MODE) { - EditorDelimLookup[InlineRules[Format.HIGHLIGHT].char] = Format.HIGHLIGHT; - } - if (settings.customSpan & MarkdownViewMode.EDITOR_MODE) { - EditorDelimLookup[InlineRules[Format.CUSTOM_SPAN].char] = Format.CUSTOM_SPAN; - } - // preview - if (settings.insertion & MarkdownViewMode.PREVIEW_MODE) { - PreviewDelimLookup[InlineRules[Format.INSERTION].char] = Format.INSERTION; - } - if (settings.spoiler & MarkdownViewMode.PREVIEW_MODE) { - PreviewDelimLookup[InlineRules[Format.SPOILER].char] = Format.SPOILER; - } - if (settings.superscript & MarkdownViewMode.PREVIEW_MODE) { - PreviewDelimLookup[InlineRules[Format.SUPERSCRIPT].char] = Format.SUPERSCRIPT; - } - if (settings.subscript & MarkdownViewMode.PREVIEW_MODE) { - PreviewDelimLookup[InlineRules[Format.SUBSCRIPT].char] = Format.SUBSCRIPT; - } - if (settings.customSpan & MarkdownViewMode.PREVIEW_MODE) { - PreviewDelimLookup[InlineRules[Format.CUSTOM_SPAN].char] = Format.CUSTOM_SPAN; - } -} \ No newline at end of file diff --git a/src/format-configs/utils/index.ts b/src/format-configs/utils/index.ts deleted file mode 100644 index 9f6b204..0000000 --- a/src/format-configs/utils/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./supportTag"; -export * from "./isInlineFormat"; -export * from "./configureDelimLookup"; -export * from "./reconfigureDelimLookup"; \ No newline at end of file diff --git a/src/format-configs/utils/isInlineFormat.ts b/src/format-configs/utils/isInlineFormat.ts deleted file mode 100644 index e3a01e7..0000000 --- a/src/format-configs/utils/isInlineFormat.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Format } from "src/enums"; -import { InlineFormat } from "src/types"; - -export function isInlineFormat(type: Format): type is InlineFormat { - return type >= Format.INSERTION && type <= Format.CUSTOM_SPAN -} \ No newline at end of file diff --git a/src/format-configs/utils/reconfigureDelimLookup.ts b/src/format-configs/utils/reconfigureDelimLookup.ts deleted file mode 100644 index 4810ab4..0000000 --- a/src/format-configs/utils/reconfigureDelimLookup.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EditorDelimLookup } from "src/editor-mode/parser/configs"; -import { PreviewDelimLookup } from "src/preview-mode/configs"; -import { configureDelimLookup } from "src/format-configs/utils"; -import { PluginSettings } from "src/types"; - -export function reconfigureDelimLookup(settings: PluginSettings) { - for (let key in EditorDelimLookup) { - delete EditorDelimLookup[key]; - } - for (let key in PreviewDelimLookup) { - delete PreviewDelimLookup[key]; - } - configureDelimLookup(settings); -} \ No newline at end of file diff --git a/src/format-configs/utils/supportTag.ts b/src/format-configs/utils/supportTag.ts deleted file mode 100644 index d07f989..0000000 --- a/src/format-configs/utils/supportTag.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Format } from "src/enums"; - -export function supportTag(type: Format): type is Format.HIGHLIGHT | Format.CUSTOM_SPAN | Format.FENCED_DIV { - return type >= Format.HIGHLIGHT || type == Format.FENCED_DIV; -} \ No newline at end of file diff --git a/src/preview-mode/configs/PreviewDelimLookup.ts b/src/preview-mode/configs/PreviewDelimLookup.ts deleted file mode 100644 index aba7565..0000000 --- a/src/preview-mode/configs/PreviewDelimLookup.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { InlineFormat } from "src/types"; - -export const PreviewDelimLookup: Record = {} \ No newline at end of file diff --git a/src/preview-mode/configs/SKIPPED_CLASSES.ts b/src/preview-mode/configs/SKIPPED_CLASSES.ts deleted file mode 100644 index e37e6d1..0000000 --- a/src/preview-mode/configs/SKIPPED_CLASSES.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const SKIPPED_CLASSES = [ - "internal-link", - "external-link", - "math", - "internal-embed", - "list-bullet", - "collapse-indicator" -]; \ No newline at end of file diff --git a/src/preview-mode/configs/index.ts b/src/preview-mode/configs/index.ts deleted file mode 100644 index 804c72f..0000000 --- a/src/preview-mode/configs/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./SKIPPED_CLASSES"; -export * from "./PreviewDelimLookup"; \ No newline at end of file diff --git a/src/preview-mode/parser/PreviewModeParser.ts b/src/preview-mode/parser/PreviewModeParser.ts deleted file mode 100644 index b2744c5..0000000 --- a/src/preview-mode/parser/PreviewModeParser.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Formats, InlineRules } from "src/format-configs"; -import { Format } from "src/enums"; -import { InlineFormat } from "src/types"; -import { SKIPPED_CLASSES, PreviewDelimLookup } from "src/preview-mode/configs"; -import { hasClasses, isWhitespace } from "src/preview-mode/utils"; - -export class PreviewModeParser { - root: Element; - walker: TreeWalker; - offset = 0; - curNode: Node; - nodeChanged = false; - stack: InlineFormat[] = []; - queue: Partial> = {} - parsingQueue: PreviewModeParser[]; - constructor(root: Element, parsingQueue: PreviewModeParser[]) { - this.root = root; - this.walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); - this.walker.nextNode(); - this.curNode = this.walker.currentNode; - this.parsingQueue = parsingQueue; - } - streamParse() { - do { - if (this.curNode instanceof Text) { - this.offset = 0; - this.parseTextNode(); - } else if (this.curNode instanceof Element) { - if (this.isSkipped(this.curNode)) { - this.resolve(Format.SUPERSCRIPT); - this.resolve(Format.SUBSCRIPT); - } else if (this.curNode.textContent) { - this.parsingQueue.push(new PreviewModeParser(this.curNode, this.parsingQueue)); - if (/\s/.test(this.curNode.textContent)) { - this.resolve(Format.SUPERSCRIPT); - this.resolve(Format.SUBSCRIPT); - } - } - } - } while (this.nextNode()) - this.forceResolveAll(); - } - parseTextNode() { - let str = this.curNode.textContent ?? ""; - while (!this.nodeChanged && this.offset < str.length) { - let char = str[this.offset], - type = PreviewDelimLookup[char]; - if (char == " " || char == "\n" || char == "\t") { - this.resolve(Format.SUPERSCRIPT); - this.resolve(Format.SUBSCRIPT); - this.offset++; - continue; - } - if (!type || type == Format.HIGHLIGHT) { - this.offset++; - continue; - } - this.tokenize(type); - } - this.nodeChanged = false; - } - finalize(type: InlineFormat, open: Range, content: Range, close: Range) { - let wrapper = InlineRules[type].getEl(); - close.deleteContents(); - content.surroundContents(wrapper); - open.deleteContents(); - if (wrapper == this.curNode.nextSibling) { - this.nextNode(); - } else { - this.prevNode(); - } - this.nodeChanged = true; - } - resolve(type: InlineFormat, close?: Range) { - if (close && this.queue[type]) { - let content = new Range(), - open = this.queue[type]; - content.setStart(open.endContainer, open.endOffset); - content.setEnd(close.startContainer, close.startOffset); - this.stack.findLast((t, i) => { - delete this.queue[t]; - if (t == type) { - this.stack.splice(i); - return true; - } - }); - this.finalize(type, open, content, close); - } else { - delete this.queue[type]; - this.stack = this.stack.filter(t => t != type); - } - } - forceResolveAll() { - for (let i = 0; i < Formats.ALL_INLINE.length; i++) { - this.resolve(Formats.ALL_INLINE[i]); - } - } - tokenize(type: InlineFormat) { - let { length: reqLength, char } = InlineRules[type], - str = this.curNode.textContent!, - length = 0, - hasOpen = !!this.queue[type], - hasSpaceBefore = isWhitespace(str[this.offset - 1]), - hasSpaceAfter: boolean; - while (str[this.offset] == char) { this.offset++; length++ } - hasSpaceAfter = isWhitespace(str[this.offset]); - if (hasOpen && hasSpaceBefore || !hasOpen && hasSpaceAfter || length != reqLength) { return false } - let range = new Range(); - range.setStart(this.curNode, this.offset - length); - range.setEnd(this.curNode, this.offset); - this.pushDelim(type, range); - return true; - } - pushDelim(type: InlineFormat, delim: Range) { - if (this.queue[type]) { - this.resolve(type, delim); - } else { - this.queue[type] = delim; - this.stack.push(type); - } - } - nextNode() { - if (this.walker.nextSibling()) { - this.curNode = this.walker.currentNode; - return true; - } else { - return false; - } - } - prevNode() { - if (this.walker.previousSibling()) { - this.curNode = this.walker.currentNode; - return true; - } - return false; - } - isSkipped(el: Element) { - return ( - hasClasses(el, SKIPPED_CLASSES) || - el.tagName == "CODE" || el.tagName == "IMG" - ); - } -} \ No newline at end of file diff --git a/src/preview-mode/parser/index.ts b/src/preview-mode/parser/index.ts deleted file mode 100644 index 25264ec..0000000 --- a/src/preview-mode/parser/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./PreviewModeParser"; \ No newline at end of file diff --git a/src/preview-mode/post-processor/PreviewExtendedSyntax.ts b/src/preview-mode/post-processor/PreviewExtendedSyntax.ts deleted file mode 100644 index 965ea2b..0000000 --- a/src/preview-mode/post-processor/PreviewExtendedSyntax.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { MarkdownPostProcessor } from "obsidian"; -import { MarkdownViewMode } from "src/enums"; -import { PreviewModeParser } from "src/preview-mode/parser"; -import { FencedDiv, CustomHighlight, CustomSpan } from "src/preview-mode/post-processor/components"; -import { PluginSettings } from "src/types"; - -export class PreviewExtendedSyntax { - private readonly query = "p, h1, h2, h3, h4, h5, h6, td, th, li:not(:has(p)), .callout-title-inner"; - private customHighlight: CustomHighlight; - private customSpan: CustomSpan; - private fencedDiv: FencedDiv; - settings: PluginSettings; - constructor(settings: PluginSettings) { - this.settings = settings; - this.customHighlight = new CustomHighlight(settings); - this.customSpan = new CustomSpan(settings); - this.fencedDiv = new FencedDiv(settings); - } - private parseInline(targetEl: HTMLElement) { - let targetedEls = targetEl.querySelectorAll(this.query), - parsingQueue: PreviewModeParser[] = []; - if (targetEl.classList.contains("table-cell-wrapper")) { - new PreviewModeParser(targetEl, parsingQueue).streamParse(); - for (let i = 0; i < parsingQueue.length; i++) { - parsingQueue[i].streamParse(); - if (i >= 100) { throw Error(`${parsingQueue}`) } - } - return; - } - for (let i = 0; i < targetedEls.length; i++) { - new PreviewModeParser(targetedEls[i], parsingQueue).streamParse(); - for (let i = 0; i < parsingQueue.length; i++) { - parsingQueue[i].streamParse(); - if (i >= 100) { throw Error(`${parsingQueue}`) } - } - parsingQueue.splice(0); - } - } - private isTargeted(container: HTMLElement) { - let firstChild = container.firstElementChild; - if ( - container.classList.contains("table-cell-wrapper") || - firstChild && ( - firstChild instanceof HTMLParagraphElement || - firstChild instanceof HTMLTableElement || - firstChild instanceof HTMLUListElement || - firstChild instanceof HTMLOListElement || - firstChild.tagName == "BLOCKQUOTE" || - firstChild.classList.contains("callout") - )) { return true } - return false; - } - private decorate(container: HTMLElement) { - if (this.settings.customHighlight & MarkdownViewMode.PREVIEW_MODE) { - this.customHighlight.decorate(container); - } - if ( - this.settings.fencedDiv & MarkdownViewMode.PREVIEW_MODE && - container.firstElementChild instanceof HTMLParagraphElement - ) { - this.fencedDiv.decorate(container.firstElementChild); - } - if (this.isTargeted(container)) { - this.parseInline(container); - } - if (this.settings.customSpan & MarkdownViewMode.PREVIEW_MODE) { - this.customSpan.decorate(container); - } - } - postProcess: MarkdownPostProcessor = (container) => { - let isWholeDoc = container.classList.contains("markdown-preview-view"); - if (isWholeDoc) { - if (!this.settings.decoratePDF) { return } - let sectionEls = container.querySelectorAll("&>div"); - for (let i = 0; i < sectionEls.length; i++) { - this.decorate(sectionEls[i]); - } - } else { - this.decorate(container); - } - } -} \ No newline at end of file diff --git a/src/preview-mode/post-processor/components/CustomHighlight.ts b/src/preview-mode/post-processor/components/CustomHighlight.ts deleted file mode 100644 index c52485c..0000000 --- a/src/preview-mode/post-processor/components/CustomHighlight.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { InlineRules } from "src/format-configs"; -import { COLOR_TAG_RE } from "src/preview-mode/regexp"; -import { Format } from "src/enums"; -import { PluginSettings } from "src/types"; - -export class CustomHighlight { - settings: PluginSettings; - constructor(settings: PluginSettings) { - this.settings = settings; - } - decorate(el: HTMLElement) { - let markElements = el.querySelectorAll("mark"), - baseCls = InlineRules[Format.HIGHLIGHT].class; - markElements.forEach((el) => { - if (!(el.firstChild instanceof Text && el.firstChild.textContent)) { return } - let color = COLOR_TAG_RE.exec(el.firstChild.textContent)?.[1]; - if (color) { - el.classList.add(baseCls, `${baseCls}-${color}`); - if (this.settings.showHlTagInPreviewMode) { return } - let from = 0, to = from + color.length + 2; - el.firstChild.replaceData(from, to - from, ""); - } - }); - } -} \ No newline at end of file diff --git a/src/preview-mode/post-processor/components/CustomSpan.ts b/src/preview-mode/post-processor/components/CustomSpan.ts deleted file mode 100644 index f6ab2af..0000000 --- a/src/preview-mode/post-processor/components/CustomSpan.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Format } from "src/enums"; -import { CUSTOM_SPAN_TAG_RE } from "src/preview-mode/regexp"; -import { InlineRules } from "src/format-configs"; -import { PluginSettings } from "src/types"; -import { trimTag } from "src/utils"; - -export class CustomSpan { - settings: PluginSettings; - constructor(settings: PluginSettings) { - this.settings = settings; - } - decorate(el: HTMLElement) { - let baseCls = InlineRules[Format.CUSTOM_SPAN].class, - customSpanElements = el.querySelectorAll("." + baseCls); - customSpanElements.forEach((el) => { - if (!(el.firstChild instanceof Text && el.firstChild.textContent)) { return } - let tag = CUSTOM_SPAN_TAG_RE.exec(el.firstChild.textContent)?.[1]; - if (tag) { - let clsList = trimTag(tag).split(" "); - el.classList.add(...clsList); - if (this.settings.showSpanTagInPreviewMode) { return } - let from = 0, to = from + tag.length + 2; - el.firstChild.replaceData(from, to - from, ""); - } - }); - } -} \ No newline at end of file diff --git a/src/preview-mode/post-processor/components/FencedDiv.ts b/src/preview-mode/post-processor/components/FencedDiv.ts deleted file mode 100644 index 60f4a4c..0000000 --- a/src/preview-mode/post-processor/components/FencedDiv.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { trimTag } from "src/utils"; -import { FENCED_DIV } from "src/preview-mode/regexp"; -import { BlockRules } from "src/format-configs"; -import { Format, MarkdownViewMode } from "src/enums"; -import { PluginSettings } from "src/types"; - -export class FencedDiv { - settings: PluginSettings; - constructor(settings: PluginSettings) { - this.settings = settings; - } - decorate(el: HTMLElement) { - if (!(el.firstChild instanceof Text && el.firstChild.textContent)) { return } - FENCED_DIV.lastIndex = 0; - let baseCls = BlockRules[Format.FENCED_DIV].class, - textNode = el.firstChild, - lineBreakEl = el.querySelector("br"), - match = FENCED_DIV.exec(textNode.textContent ?? ""); - if (match) { - let tag = match[1]!, - clsList = trimTag(tag).split(" "); - el.addClass(baseCls, ...clsList); - if (this.settings.alwaysShowFencedDivTag & MarkdownViewMode.PREVIEW_MODE) { return } - el.removeChild(textNode); - if (lineBreakEl) { el.removeChild(lineBreakEl) } - } - } -} \ No newline at end of file diff --git a/src/preview-mode/post-processor/components/index.ts b/src/preview-mode/post-processor/components/index.ts deleted file mode 100644 index fee1553..0000000 --- a/src/preview-mode/post-processor/components/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./CustomSpan"; -export * from "./CustomHighlight"; -export * from "./FencedDiv"; \ No newline at end of file diff --git a/src/preview-mode/post-processor/configs.ts b/src/preview-mode/post-processor/configs.ts new file mode 100644 index 0000000..0995825 --- /dev/null +++ b/src/preview-mode/post-processor/configs.ts @@ -0,0 +1,16 @@ +import { InlineFormat } from "src/types"; + +export const COLOR_TAG_RE = /^\{([a-z0-9-]+)\}/i; +export const CUSTOM_SPAN_TAG_RE = /^\{([a-z0-9 -]+)\}/i; +export const FENCED_DIV_RE = /:{3,}(?=([a-z0-9 -]+))\1$/yi; + +export const PreviewDelimLookup: Record = {} + +export const SKIPPED_CLASSES = [ + "internal-link", + "external-link", + "math", + "internal-embed", + "list-bullet", + "collapse-indicator" +]; \ No newline at end of file diff --git a/src/preview-mode/post-processor/core.ts b/src/preview-mode/post-processor/core.ts new file mode 100644 index 0000000..14c3662 --- /dev/null +++ b/src/preview-mode/post-processor/core.ts @@ -0,0 +1,139 @@ +import { MarkdownPostProcessor } from "obsidian"; +import { MarkdownViewMode, Format } from "src/enums"; +import { PluginSettings } from "src/types"; +import { InlineRules, BlockRules } from "src/format-configs/rules"; +import { PreviewModeParser } from "src/preview-mode/post-processor/parser"; +import { CUSTOM_SPAN_TAG_RE, COLOR_TAG_RE, FENCED_DIV_RE } from "src/preview-mode/post-processor/configs"; + +function _trimTag(tagStr: string): string { + return tagStr + .trim() + .replaceAll(/\s{2,}/g, " "); +} + +function _drawCustomSpan(settings: PluginSettings, el: HTMLElement): void { + let baseCls = InlineRules[Format.CUSTOM_SPAN].class, + customSpanElements = el.querySelectorAll("." + baseCls); + + customSpanElements.forEach((el) => { + if (!(el.firstChild instanceof Text && el.firstChild.textContent)) return; + let tag = CUSTOM_SPAN_TAG_RE.exec(el.firstChild.textContent)?.[1]; + if (tag) { + let clsList = _trimTag(tag).split(" "); + el.classList.add(...clsList); + if (settings.showSpanTagInPreviewMode) { return } + let from = 0, to = from + tag.length + 2; + el.firstChild.replaceData(from, to - from, ""); + } + }); +} + +function _drawCustomHighlight(settings: PluginSettings, el: HTMLElement): void { + let markElements = el.querySelectorAll("mark"), + baseCls = InlineRules[Format.HIGHLIGHT].class; + + markElements.forEach((el) => { + if (!(el.firstChild instanceof Text && el.firstChild.textContent)) { return } + let color = COLOR_TAG_RE.exec(el.firstChild.textContent)?.[1]; + if (color) { + el.classList.add(baseCls, `${baseCls}-${color}`); + if (settings.showHlTagInPreviewMode) { return } + let from = 0, to = from + color.length + 2; + el.firstChild.replaceData(from, to - from, ""); + } + }); +} + +function _drawFencedDiv(settings: PluginSettings, el: HTMLElement): void { + if (!(el.firstChild instanceof Text && el.firstChild.textContent)) return; + + FENCED_DIV_RE.lastIndex = 0; + let baseCls = BlockRules[Format.FENCED_DIV].class, + textNode = el.firstChild, + lineBreakEl = el.querySelector("br"), + match = FENCED_DIV_RE.exec(textNode.textContent ?? ""); + + if (match) { + let tag = match[1]!, + clsList = _trimTag(tag).split(" "); + el.addClass(baseCls, ...clsList); + if (settings.alwaysShowFencedDivTag & MarkdownViewMode.PREVIEW_MODE) return; + el.removeChild(textNode); + if (lineBreakEl) { el.removeChild(lineBreakEl) } + } +} + +export class ReadingModeSyntaxExtender { + private readonly _selectorQuery = "p, h1, h2, h3, h4, h5, h6, td, th, li:not(:has(p)), .callout-title-inner"; + private readonly _settings: PluginSettings; + + constructor(settings: PluginSettings) { + this._settings = settings; + } + + private _parseInline(targetEl: HTMLElement): void { + let targetedEls = targetEl.querySelectorAll(this._selectorQuery), + parsingQueue: PreviewModeParser[] = []; + + if (targetEl.classList.contains("table-cell-wrapper")) { + new PreviewModeParser(targetEl, parsingQueue).streamParse(); + for (let i = 0; i < parsingQueue.length; i++) { + parsingQueue[i].streamParse(); + if (i >= 100) { throw Error(`${parsingQueue}`) } + } + return; + } + + for (let i = 0; i < targetedEls.length; i++) { + new PreviewModeParser(targetedEls[i], parsingQueue).streamParse(); + for (let i = 0; i < parsingQueue.length; i++) { + parsingQueue[i].streamParse(); + if (i >= 100) { throw Error(`${parsingQueue}`) } + } + parsingQueue.splice(0); + } + } + + private _isTargeted(container: HTMLElement): boolean { + let firstChild = container.firstElementChild; + if ( + container.classList.contains("table-cell-wrapper") || + firstChild && ( + firstChild instanceof HTMLParagraphElement || + firstChild instanceof HTMLTableElement || + firstChild instanceof HTMLUListElement || + firstChild instanceof HTMLOListElement || + firstChild.tagName == "BLOCKQUOTE" || + firstChild.classList.contains("callout") + )) { return true } + return false; + } + + private _decorate(container: HTMLElement): void { + if (this._settings.customHighlight & MarkdownViewMode.PREVIEW_MODE) + _drawCustomHighlight(this._settings, container); + + if ( + this._settings.fencedDiv & MarkdownViewMode.PREVIEW_MODE && + container.firstElementChild instanceof HTMLParagraphElement + ) _drawFencedDiv(this._settings, container.firstElementChild); + + if (this._isTargeted(container)) this._parseInline(container); + + if (this._settings.customSpan & MarkdownViewMode.PREVIEW_MODE) + _drawCustomSpan(this._settings, container); + } + + public postProcess: MarkdownPostProcessor = (container) => { + let isWholeDoc = container.classList.contains("markdown-preview-view"); + if (isWholeDoc) { + if (!this._settings.decoratePDF) { return } + let sectionEls = container.querySelectorAll("&>div"); + for (let i = 0; i < sectionEls.length; i++) { + this._decorate(sectionEls[i]); + } + } else { + this._decorate(container); + } + } +} \ No newline at end of file diff --git a/src/preview-mode/post-processor/index.ts b/src/preview-mode/post-processor/index.ts deleted file mode 100644 index 3fcbb17..0000000 --- a/src/preview-mode/post-processor/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./PreviewExtendedSyntax"; \ No newline at end of file diff --git a/src/preview-mode/post-processor/parser.ts b/src/preview-mode/post-processor/parser.ts new file mode 100644 index 0000000..07ed01b --- /dev/null +++ b/src/preview-mode/post-processor/parser.ts @@ -0,0 +1,165 @@ +import { Format } from "src/enums"; +import { InlineFormat } from "src/types"; +import { Formats, InlineRules } from "src/format-configs/rules"; +import { SKIPPED_CLASSES, PreviewDelimLookup } from "src/preview-mode/post-processor/configs"; + +function _hasClasses(el: Element, classes: string[]): boolean { + for (let cls of classes) + if (el.classList.contains(cls)) return true; + return false; +} + +function _isWhitespace(char: string): boolean { + return char == " " || char == "\n" || char == "\t"; +} + +export class PreviewModeParser { + public root: Element; + + private _walker: TreeWalker; + private _offset = 0; + private _curNode: Node; + private _nodeChanged = false; + private _stack: InlineFormat[] = []; + private _queue: Partial> = {} + private _parsingQueue: PreviewModeParser[]; + + constructor(root: Element, parsingQueue: PreviewModeParser[]) { + this.root = root; + this._walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT); + this._walker.nextNode(); + this._curNode = this._walker.currentNode; + this._parsingQueue = parsingQueue; + } + + public streamParse(): void { + do { + if (this._curNode instanceof Text) { + this._offset = 0; + this._parseTextNode(); + } else if (this._curNode instanceof Element) { + if (this._isSkipped(this._curNode)) { + this._resolve(Format.SUPERSCRIPT); + this._resolve(Format.SUBSCRIPT); + } else if (this._curNode.textContent) { + this._parsingQueue.push(new PreviewModeParser(this._curNode, this._parsingQueue)); + if (/\s/.test(this._curNode.textContent)) { + this._resolve(Format.SUPERSCRIPT); + this._resolve(Format.SUBSCRIPT); + } + } + } + } while (this._nextNode()); + this._forceResolveAll(); + } + + private _parseTextNode(): void { + let str = this._curNode.textContent ?? ""; + while (!this._nodeChanged && this._offset < str.length) { + let char = str[this._offset], + type = PreviewDelimLookup[char]; + if (char == " " || char == "\n" || char == "\t") { + this._resolve(Format.SUPERSCRIPT); + this._resolve(Format.SUBSCRIPT); + this._offset++; + continue; + } + if (!type || type == Format.HIGHLIGHT) { + this._offset++; + continue; + } + this._tokenize(type); + } + this._nodeChanged = false; + } + + private _finalize(type: InlineFormat, open: Range, content: Range, close: Range): void { + let wrapper = InlineRules[type].getEl(); + close.deleteContents(); + content.surroundContents(wrapper); + open.deleteContents(); + if (wrapper == this._curNode.nextSibling) { + this._nextNode(); + } else { + this._prevNode(); + } + this._nodeChanged = true; + } + + private _resolve(type: InlineFormat, close?: Range): void { + if (close && this._queue[type]) { + let content = new Range(), + open = this._queue[type]; + content.setStart(open.endContainer, open.endOffset); + content.setEnd(close.startContainer, close.startOffset); + this._stack.findLast((t, i) => { + delete this._queue[t]; + if (t == type) { + this._stack.splice(i); + return true; + } + }); + this._finalize(type, open, content, close); + } else { + delete this._queue[type]; + this._stack = this._stack.filter(t => t != type); + } + } + + private _forceResolveAll(): void { + for (let i = 0; i < Formats.ALL_INLINE.length; i++) + this._resolve(Formats.ALL_INLINE[i]); + } + + private _tokenize(type: InlineFormat): boolean { + let { length: reqLength, char } = InlineRules[type], + str = this._curNode.textContent!, + length = 0, + hasOpen = !!this._queue[type], + hasSpaceBefore = _isWhitespace(str[this._offset - 1]), + hasSpaceAfter: boolean; + + while (str[this._offset] == char) { this._offset++; length++ } + hasSpaceAfter = _isWhitespace(str[this._offset]); + if (hasOpen && hasSpaceBefore || !hasOpen && hasSpaceAfter || length != reqLength) return false; + + let range = new Range(); + range.setStart(this._curNode, this._offset - length); + range.setEnd(this._curNode, this._offset); + this._pushDelim(type, range); + return true; + } + + private _pushDelim(type: InlineFormat, delim: Range): void { + if (this._queue[type]) { + this._resolve(type, delim); + } else { + this._queue[type] = delim; + this._stack.push(type); + } + } + + private _nextNode(): boolean { + if (this._walker.nextSibling()) { + this._curNode = this._walker.currentNode; + return true; + } else { + return false; + } + } + + private _prevNode(): boolean { + if (this._walker.previousSibling()) { + this._curNode = this._walker.currentNode; + return true; + } + return false; + } + + private _isSkipped(el: Element): boolean { + return ( + _hasClasses(el, SKIPPED_CLASSES) || + el.tagName == "CODE" || el.tagName == "IMG" + ); + } +} \ No newline at end of file diff --git a/src/preview-mode/regexp/index.ts b/src/preview-mode/regexp/index.ts deleted file mode 100644 index 8a3c9a3..0000000 --- a/src/preview-mode/regexp/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const COLOR_TAG_RE = /^\{([a-z0-9-]+)\}/i; -export const CUSTOM_SPAN_TAG_RE = /^\{([a-z0-9 -]+)\}/i; -export const FENCED_DIV = /:{3,}(?=([a-z0-9 -]+))\1$/yi; \ No newline at end of file diff --git a/src/preview-mode/utils/hasClasses.ts b/src/preview-mode/utils/hasClasses.ts deleted file mode 100644 index 01ee079..0000000 --- a/src/preview-mode/utils/hasClasses.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function hasClasses(el: Element, classes: string[]) { - for (let cls of classes) { - if (el.classList.contains(cls)) { return true } - } - return false; -} \ No newline at end of file diff --git a/src/preview-mode/utils/index.ts b/src/preview-mode/utils/index.ts deleted file mode 100644 index bed6bb4..0000000 --- a/src/preview-mode/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./hasClasses"; -export * from "./isWhitespace"; \ No newline at end of file diff --git a/src/preview-mode/utils/isWhitespace.ts b/src/preview-mode/utils/isWhitespace.ts deleted file mode 100644 index aaa8bb4..0000000 --- a/src/preview-mode/utils/isWhitespace.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isWhitespace(char: string) { - return char == " " || char == "\n" || char == "\t"; -} \ No newline at end of file diff --git a/src/settings/configs.ts b/src/settings/configs.ts new file mode 100644 index 0000000..56b2b23 --- /dev/null +++ b/src/settings/configs.ts @@ -0,0 +1,49 @@ +import { getDefaultColorConfigs } from "src/utils/color-utils"; +import { DisplayBehaviour, MarkdownViewMode } from "src/enums"; +import { PluginSettings } from "src/types"; + +export const DEFAULT_SETTINGS: PluginSettings = { + // General + insertion: MarkdownViewMode.ALL, + spoiler: MarkdownViewMode.ALL, + superscript: MarkdownViewMode.ALL, + subscript: MarkdownViewMode.ALL, + customHighlight: MarkdownViewMode.ALL, + customSpan: MarkdownViewMode.ALL, + fencedDiv: MarkdownViewMode.ALL, + + // Formatting + tidyFormatting: true, + openTagMenuAfterFormat: true, + noStyledDivInSourceMode: true, + + // Tag behavior + hlTagDisplayBehaviour: DisplayBehaviour.SYNTAX_TOUCHED, + spanTagDisplayBehaviour: DisplayBehaviour.SYNTAX_TOUCHED, + showHlTagInPreviewMode: false, + showSpanTagInPreviewMode: false, + alwaysShowFencedDivTag: MarkdownViewMode.NONE, + + // Custom Highlight + colorButton: true, + showAccentColor: true, + showDefaultColor: true, + showRemoveColor: true, + lightModeHlOpacity: 0.4, + darkModeHlOpacity: 0.5, + get colorConfigs() { return getDefaultColorConfigs() }, + + // Custom span + showDefaultSpanTag: true, + showRemoveSpanTag: true, + get predefinedSpanTag() { return [{ name: "", tag: "", showInMenu: true }] }, + + // Fenced div + showDefaultDivTag: true, + showRemoveDivTag: true, + get predefinedDivTag() { return [{ name: "", tag: "", showInMenu: true }] }, + + // Others + editorEscape: false, + decoratePDF: true, +} \ No newline at end of file diff --git a/src/settings/configs/DEFAULT_SETTINGS.ts b/src/settings/configs/DEFAULT_SETTINGS.ts deleted file mode 100644 index 650c44c..0000000 --- a/src/settings/configs/DEFAULT_SETTINGS.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getDefaultColorConfigs } from "src/color-management"; -import { DisplayBehaviour, MarkdownViewMode } from "src/enums"; -import { PluginSettings } from "src/types"; - -export const DEFAULT_SETTINGS: PluginSettings = { - // General - insertion: MarkdownViewMode.ALL, - spoiler: MarkdownViewMode.ALL, - superscript: MarkdownViewMode.ALL, - subscript: MarkdownViewMode.ALL, - customHighlight: MarkdownViewMode.ALL, - customSpan: MarkdownViewMode.ALL, - fencedDiv: MarkdownViewMode.ALL, - - // Formatting - tidyFormatting: true, - openTagMenuAfterFormat: true, - - // Tag behavior - hlTagDisplayBehaviour: DisplayBehaviour.SYNTAX_TOUCHED, - spanTagDisplayBehaviour: DisplayBehaviour.SYNTAX_TOUCHED, - showHlTagInPreviewMode: false, - showSpanTagInPreviewMode: false, - alwaysShowFencedDivTag: MarkdownViewMode.NONE, - - // Custom Highlight - colorButton: true, - showAccentColor: true, - showDefaultColor: true, - showRemoveColor: true, - lightModeHlOpacity: 0.4, - darkModeHlOpacity: 0.5, - colorConfigs: getDefaultColorConfigs(), - - // Custom span - showDefaultSpanTag: true, - showRemoveSpanTag: true, - predefinedSpanTag: [{ name: "", tag: "", showInMenu: true }], - - // Fenced div - showDefaultDivTag: true, - showRemoveDivTag: true, - predefinedDivTag: [{ name: "", tag: "", showInMenu: true }], - - // Others - editorEscape: false, - decoratePDF: true, -}; \ No newline at end of file diff --git a/src/settings/configs/dropdown-options/ALWAYS_SHOW_DROPDOWN.ts b/src/settings/configs/dropdown-options/ALWAYS_SHOW_DROPDOWN.ts deleted file mode 100644 index 172805d..0000000 --- a/src/settings/configs/dropdown-options/ALWAYS_SHOW_DROPDOWN.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { MarkdownViewMode } from "src/enums"; - -export const ALWAYS_SHOW_DROPDOWN: Record = { - [MarkdownViewMode.NONE]: "Default", - [MarkdownViewMode.EDITOR_MODE]: "Editor only", - [MarkdownViewMode.PREVIEW_MODE]: "Preview only", - [MarkdownViewMode.ALL]: "Both editor and preview" -}; \ No newline at end of file diff --git a/src/settings/configs/dropdown-options/DISPLAY_BEHAVIOUR_DROPDOWN.ts b/src/settings/configs/dropdown-options/DISPLAY_BEHAVIOUR_DROPDOWN.ts deleted file mode 100644 index adb7bfa..0000000 --- a/src/settings/configs/dropdown-options/DISPLAY_BEHAVIOUR_DROPDOWN.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { DisplayBehaviour } from "src/enums"; - -export const DISPLAY_BEHAVIOUR_DROPDOWN: Record = { - [DisplayBehaviour.ALWAYS]: "Always visible", - [DisplayBehaviour.TAG_TOUCHED]: "When touching the tag itself", - [DisplayBehaviour.SYNTAX_TOUCHED]: "When touching its syntax" -} \ No newline at end of file diff --git a/src/settings/configs/dropdown-options/MARKDOWN_VIEW_MODE_DROPDOWN.ts b/src/settings/configs/dropdown-options/MARKDOWN_VIEW_MODE_DROPDOWN.ts deleted file mode 100644 index fa482e4..0000000 --- a/src/settings/configs/dropdown-options/MARKDOWN_VIEW_MODE_DROPDOWN.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { MarkdownViewMode } from "src/enums"; - -export const MARKDOWN_VIEW_MODE_DROPDOWN: Record = { - [MarkdownViewMode.NONE]: "Disable all", - [MarkdownViewMode.EDITOR_MODE]: "Editor only", - [MarkdownViewMode.PREVIEW_MODE]: "Preview only", - [MarkdownViewMode.ALL]: "Enable all" -}; \ No newline at end of file diff --git a/src/settings/configs/getTagConfigs.ts b/src/settings/configs/getTagConfigs.ts deleted file mode 100644 index d76942d..0000000 --- a/src/settings/configs/getTagConfigs.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Format } from "src/enums"; -import { PluginSettings } from "src/types"; - -export function getTagConfigs(settings: PluginSettings, type: Format.HIGHLIGHT | Format.CUSTOM_SPAN | Format.FENCED_DIV) { - switch (type) { - case Format.HIGHLIGHT: - return settings.colorConfigs; - case Format.CUSTOM_SPAN: - return settings.predefinedSpanTag; - case Format.FENCED_DIV: - return settings.predefinedDivTag; - } -} \ No newline at end of file diff --git a/src/settings/configs/index.ts b/src/settings/configs/index.ts deleted file mode 100644 index 79ac22f..0000000 --- a/src/settings/configs/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./DEFAULT_SETTINGS"; -export * from "./dropdown-options/MARKDOWN_VIEW_MODE_DROPDOWN"; -export * from "./dropdown-options/DISPLAY_BEHAVIOUR_DROPDOWN"; -export * from "./dropdown-options/ALWAYS_SHOW_DROPDOWN"; -export * from "./tag-settings-specs/COLOR_SETTINGS_SPEC"; -export * from "./tag-settings-specs/SPAN_TAG_SETTINGS_SPEC"; -export * from "./tag-settings-specs/DIV_TAG_SETTINGS_SPEC"; -export * from "./getTagConfigs"; -export * from "./retrieveSettingUIConfigs"; \ No newline at end of file diff --git a/src/settings/configs/retrieveSettingUIConfigs.ts b/src/settings/configs/retrieveSettingUIConfigs.ts deleted file mode 100644 index 14b64f0..0000000 --- a/src/settings/configs/retrieveSettingUIConfigs.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { Field, Format, Theme } from "src/enums"; -import { PluginSettings, SettingRoot } from "src/types"; -import { ALWAYS_SHOW_DROPDOWN, DISPLAY_BEHAVIOUR_DROPDOWN, MARKDOWN_VIEW_MODE_DROPDOWN } from "src/settings/configs"; - -/** - * Main configuration of the setting interface. - */ -export function retrieveSettingUIConfigs(settings: PluginSettings): SettingRoot { - return [{ - id: "syntax-switch", - heading: "Syntax switch", - collapsible: true, - items: [{ - name: "Insertion (underline)", - desc: "Use double plus (\"++\") as a delimiter.", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "insertion", - spec: { - options: MARKDOWN_VIEW_MODE_DROPDOWN - }, - update: { internal: true } - }] - }, { - name: "Discord-flavored spoiler", - desc: "Use double bars (\"||\") as a delimiter.", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "spoiler", - spec: { - options: MARKDOWN_VIEW_MODE_DROPDOWN - }, - update: { internal: true } - }] - }, { - name: "Pandoc-style superscript", - desc: "Use a single caret (\"^\") as a delimiter.", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "superscript", - spec: { - options: MARKDOWN_VIEW_MODE_DROPDOWN - }, - update: { internal: true } - }] - }, { - name: "Pandoc-style subscript", - desc: "Use a single tilde (\"~\") as a delimiter.", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "subscript", - spec: { - options: MARKDOWN_VIEW_MODE_DROPDOWN - }, - update: { internal: true } - }] - }, { - name: "Highlight color tag", - desc: "Use alphanumeric and hyphen characters (\"A-Za-z0-9-\") after the opening delimiter covered by curly brackets (\"{}\").", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "customHighlight", - spec: { - options: MARKDOWN_VIEW_MODE_DROPDOWN - }, - update: { internal: true } - }] - }, { - name: "Custom span", - desc: "Use double exclamation marks (\"!!\") as a delimiter. Can be inserted after the " + - "opening delimiter by a tag with the same way as highlight tag. " + - "Additionally, you can use multiple classes inside it, separated by space(s).", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "customSpan", - spec: { - options: MARKDOWN_VIEW_MODE_DROPDOWN - }, - update: { internal: true } - }] - }, { - name: "Pandoc-style fenced div (custom block)", - desc: "Use three colons (\":::\") or more at the beginning of a paragraph, " + - "followed by alphanumeric and hyphen character (\"A-Za-z0-9\") after them. Use space to separate each class.", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "fencedDiv", - spec: { - options: MARKDOWN_VIEW_MODE_DROPDOWN - }, - update: { internal: true } - }] - }] - }, { - id: "formatting", - heading: "Formatting", - collapsible: true, - items: [{ - name: "Make it tidier", - desc: "If turned on, formatting will run in refined way by detecting context the cursor or selection was placed within, " + - "e.g. you just place a cursor to specific word and run the command to format the whole word or vice-versa. " + - "If turned off, formatting is more like a normal auto-wrap toggle and doesn't care about the context.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "tidyFormatting", - spec: null - }] - }, { - name: "Open tag menu after formatting", - desc: "Will automatically open tag menu after doing formatting via command palattes or context menu. " + - "Only applied to the custom highlight, custom span, and fenced div.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "openTagMenuAfterFormat", - spec: null - }] - }] - }, { - id: "tag-display", - heading: "Tag display behavior", - collapsible: true, - items: [{ - name: "Highlight tag in live-preview mode", - desc: "Tags can either always be visible, or when they meet particular condition.", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "hlTagDisplayBehaviour", - spec: { - options: DISPLAY_BEHAVIOUR_DROPDOWN - }, - update: { internal: true } - }] - }, { - name: "Custom span tag in live-preview mode", - desc: "Tags can either always be visible, or when they meet particular condition.", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "spanTagDisplayBehaviour", - spec: { - options: DISPLAY_BEHAVIOUR_DROPDOWN - }, - update: { internal: true } - }] - }, { - name: "Highlight tag in preview mode", - desc: "Show or hide highlight color tag in the preview mode.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showHlTagInPreviewMode", - spec: null, - update: { internal: true } - }] - }, { - name: "Custom span tag in preview mode", - desc: "Show or hide custom span tag in the preview mode.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showSpanTagInPreviewMode", - spec: null, - update: { internal: true } - }] - }, { - name: "Always show fenced div tag", - desc: "By default, the tag is always hidden in the preview mode, " + - "and only displayed when touching the cursor or selection in the live preview mode.", - fields: [{ - type: Field.DROPDOWN, - record: settings, - key: "alwaysShowFencedDivTag", - spec: { - options: ALWAYS_SHOW_DROPDOWN - }, - update: { internal: true } - }] - }] - }, { - id: "custom-highlight", - heading: "Custom highlight", - collapsible: true, - items: [{ - name: "Color button", - desc: "Display a button after highlight opening delimiter, used to open the color menu.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "colorButton", - spec: null, - update: { internal: true } - }] - }, { - name: "Show accent option", - desc: "Show accent option in the color menu.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showAccentColor", - spec: null - }] - }, { - name: "Show default option", - desc: "Show default option in the color menu. Default means non-tagged highlight.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showDefaultColor", - spec: null - }] - }, { - name: "Show remove option", - desc: "Show remove option in the color menu.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showRemoveColor", - spec: null - }] - }, { - name: "Color opacity in light mode", - desc: "Adjust highlight opacity in light mode. Zero means that will be fully transparent.", - fields: [{ - type: Field.SLIDER, - record: settings, - key: "lightModeHlOpacity", - spec: { - min: 0, - max: 1, - step: 0.01 - }, - callback: (slider, plugin) => { - plugin.setHlOpacity(slider.getValue(), Theme.LIGHT); - }, - update: { colors: true } - }] - }, { - name: "Color opacity in dark mode", - desc: "Adjust highlight opacity in dark mode. Zero means that will be fully transparent.", - fields: [{ - type: Field.SLIDER, - record: settings, - key: "darkModeHlOpacity", - spec: { - min: 0, - max: 1, - step: 0.01 - }, - callback: (slider, plugin) => { - plugin.setHlOpacity(slider.getValue(), Theme.DARK); - }, - update: { colors: true } - }] - }, { - name: "Color palettes", - desc: "The first text field gives the name of the item in the color menu, " + - "and the second one sets the tag string to be used in highlight syntax.", - preservedForTagSettings: Format.HIGHLIGHT - }] - }, { - id: "custom-span", - heading: "Custom span", - collapsible: true, - items: [{ - name: "Show default option", - desc: "Show default option in the tag menu. Default means non-tagged span.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showDefaultSpanTag", - spec: null - }] - }, { - name: "Show remove option", - desc: "Show remove option in the tag menu.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showRemoveSpanTag", - spec: null - }] - }, { - name: "Predefined tags", - desc: "Predefined tags to be displayed in the tag menu. The first field gives the name of the item, " + - "the second one sets the tag string to be used in custom span syntax.", - preservedForTagSettings: Format.CUSTOM_SPAN - }] - }, { - id: "fenced-div", - heading: "Fenced div", - collapsible: true, - items: [{ - name: "Show default option", - desc: "Show default option in the tag menu. Default means non-tagged div.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showDefaultDivTag", - spec: null - }] - }, { - name: "Show remove option", - desc: "Show remove option in the tag menu.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "showRemoveDivTag", - spec: null - }] - }, { - name: "Predefined tags", - desc: "Predefined tags to be displayed in the tag menu. The first field gives the name of the item, " + - "the second one sets the tag string to be used in fenced div syntax.", - preservedForTagSettings: Format.FENCED_DIV - }] - }, { - id: "others", - heading: "Others", - collapsible: true, - items: [{ - name: "Delimiter escaping", - desc: "Use backslash as a delimiter escaper. Works only in the editor mode.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "editorEscape", - spec: null, - update: { internal: true } - }] - }, { - name: "Parse exported PDF", - desc: "Parse syntax on an exported PDF.", - fields: [{ - type: Field.TOGGLE, - record: settings, - key: "decoratePDF", - spec: null - }] - }] - }]; -}; \ No newline at end of file diff --git a/src/settings/configs/tag-settings-specs/COLOR_SETTINGS_SPEC.ts b/src/settings/configs/tag-settings-specs/COLOR_SETTINGS_SPEC.ts deleted file mode 100644 index 18081e4..0000000 --- a/src/settings/configs/tag-settings-specs/COLOR_SETTINGS_SPEC.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { convertColorConfigToCSSRule } from "src/color-management"; -import { Format } from "src/enums"; -import { ColorConfig, TagSettingsSpec } from "src/types"; - -export const COLOR_SETTINGS_SPEC: TagSettingsSpec = { - type: Format.HIGHLIGHT, - addBtnPlaceholder: "Add color", - nameFieldPlaceholder: "Color name", - tagFieldPlaceholder: "Tag string", - tagFilter: /[^a-z0-9-]/gi, - onAdd: (settingTab, settingItem, config: ColorConfig) => { - settingItem.addColorPicker(picker => { - let plugin = settingTab.plugin, - configs = plugin.settings.colorConfigs; - picker.setValue(config.color); - picker.colorPickerEl.addClasses(["ems-field", "ems-field-color-picker"]); - picker.onChange(color => { - config.color = color; - let index = configs.findIndex(target => target == config), - ruleStr = convertColorConfigToCSSRule(config); - plugin.colorsHandler.replace(ruleStr, index); - settingTab.saveSettings({ colors: true }); - }); - }); - }, - onMove: (settingTab, oldIndex, newIndex) => { - settingTab.plugin.colorsHandler.moveRule(oldIndex, newIndex); - }, - onResetted: (settingTab) => { - settingTab.saveSettings({ colors: true }); - }, - onDelete: (settingTab, index) => { - settingTab.plugin.colorsHandler.removeSingle(index); - }, - onTagChange: (settingTab, config: ColorConfig, index) => { - let ruleStr = convertColorConfigToCSSRule(config); - settingTab.plugin.colorsHandler.replace(ruleStr, index); - } -} \ No newline at end of file diff --git a/src/settings/configs/tag-settings-specs/DIV_TAG_SETTINGS_SPEC.ts b/src/settings/configs/tag-settings-specs/DIV_TAG_SETTINGS_SPEC.ts deleted file mode 100644 index 456bee0..0000000 --- a/src/settings/configs/tag-settings-specs/DIV_TAG_SETTINGS_SPEC.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Format } from "src/enums"; -import { TagSettingsSpec } from "src/types"; - -export const DIV_TAG_SETTINGS_SPEC: TagSettingsSpec = { - type: Format.FENCED_DIV, - addBtnPlaceholder: "Add tag", - nameFieldPlaceholder: "Tag name", - tagFieldPlaceholder: "Tag string", - tagFilter: /[^ a-z0-9-]/gi -} \ No newline at end of file diff --git a/src/settings/configs/tag-settings-specs/SPAN_TAG_SETTINGS_SPEC.ts b/src/settings/configs/tag-settings-specs/SPAN_TAG_SETTINGS_SPEC.ts deleted file mode 100644 index 8993e14..0000000 --- a/src/settings/configs/tag-settings-specs/SPAN_TAG_SETTINGS_SPEC.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Format } from "src/enums"; -import { TagSettingsSpec } from "src/types"; - -export const SPAN_TAG_SETTINGS_SPEC: TagSettingsSpec = { - type: Format.CUSTOM_SPAN, - addBtnPlaceholder: "Add tag", - nameFieldPlaceholder: "Tag name", - tagFieldPlaceholder: "Tag string", - tagFilter: /[^ a-z0-9-]/gi -} \ No newline at end of file diff --git a/src/settings/index.ts b/src/settings/index.ts deleted file mode 100644 index c0d30ab..0000000 --- a/src/settings/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./configs/DEFAULT_SETTINGS"; \ No newline at end of file diff --git a/src/settings/interface/ExtendedSettingTab.ts b/src/settings/interface/ExtendedSettingTab.ts deleted file mode 100644 index e26e666..0000000 --- a/src/settings/interface/ExtendedSettingTab.ts +++ /dev/null @@ -1,329 +0,0 @@ -import * as Plugin from 'main' -import Sortable, { SortableEvent } from 'sortablejs'; -import { App, IconName, PluginSettingTab, Setting } from 'obsidian'; -import { Field, MarkdownViewMode, DisplayBehaviour, Format } from 'src/enums'; -import { COLOR_SETTINGS_SPEC, DIV_TAG_SETTINGS_SPEC, SPAN_TAG_SETTINGS_SPEC, getTagConfigs, retrieveSettingUIConfigs } from 'src/settings/configs'; -import { DropdownFieldDesc, MultiToggleFieldDesc, PluginSettings, SettingGroup, SettingItem, SettingRoot, SliderFieldDesc, TagConfig, TagSettingsSpec, ToggleFieldDesc } from 'src/types'; -import { collapseElsBelow, insertButton } from 'src/settings/interface/utils'; - -export class ExtendedSettingTab extends PluginSettingTab { - plugin: Plugin.default; - tagSettingItems: Record = { - [Format.HIGHLIGHT]: [], - [Format.CUSTOM_SPAN]: [], - [Format.FENCED_DIV]: [] - }; - collapsedGroups: Record = { - "syntax-switch": true, - "formatting": true, - "tag-display": true, - "custom-highlight": true, - "custom-span": true, - "fenced-div": true, - "others": true - }; - rebuildColorStyleRules = false; - refreshInternal = false; - isHidden = true; - constructor(app: App, plugin: Plugin.default) { - super(app, plugin); - this.plugin = plugin; - } - saveSettings(spec?: { internal?: boolean, colors?: boolean }) { - this.plugin.saveSettings(); - if (spec?.internal) { - this.refreshInternal = true; - } - if (spec?.colors) { - this.rebuildColorStyleRules = true; - } - } - display(): void { - let { containerEl } = this, - { settings } = this.plugin; - let settingsUIConfig = retrieveSettingUIConfigs(settings); - this.drawFromConfig(settingsUIConfig, containerEl); - this.isHidden = false; - } - hide(): void { - if (this.refreshInternal) { - this.plugin.refreshMarkdownView(); - } - if (this.rebuildColorStyleRules) { - this.plugin.rebuildColorsStyleSheet(); - } - this.refreshInternal = this.rebuildColorStyleRules = false; - this.emptyTagSettingItems(); - this.isHidden = true; - this.containerEl.empty(); - super.hide(); - } - emptyTagSettingItems() { - for (let type of [Format.HIGHLIGHT, Format.CUSTOM_SPAN, Format.FENCED_DIV] as (keyof typeof this.tagSettingItems)[]) { - this.tagSettingItems[type].splice(0); - } - } - removeSetting(setting: Setting) { - setting.clear(); - setting.settingEl.remove(); - } - drawFromConfig(config: SettingRoot, containerEl: HTMLElement) { - for (let group of config) { - let isCollapsing = this.collapsedGroups[group.id]; - if (group.heading) { this.drawHeading(group, containerEl) } - for (let item of group.items) { - this.drawSettingItem(item, containerEl, group.collapsible, isCollapsing); - if (item.preservedForTagSettings == Format.HIGHLIGHT) { - this.drawTagSettings(containerEl, COLOR_SETTINGS_SPEC, group.collapsible, isCollapsing); - } else if (item.preservedForTagSettings == Format.CUSTOM_SPAN) { - this.drawTagSettings(containerEl, SPAN_TAG_SETTINGS_SPEC, group.collapsible, isCollapsing); - } else if (item.preservedForTagSettings == Format.FENCED_DIV) { - this.drawTagSettings(containerEl, DIV_TAG_SETTINGS_SPEC, group.collapsible, isCollapsing); - } - } - } - } - drawHeading(group: SettingGroup, containerEl: HTMLElement) { - if (!group.heading) { return } - let heading = new Setting(containerEl) - .setHeading() - .setName(group.heading); - if (group.desc) { - heading.setDesc(group.desc); - } - if (group.collapsible) { - heading.addExtraButton(btn => { - let btnEl = btn.extraSettingsEl; - btn - .setIcon("chevron-down") - .onClick(() => { - let btnEl = btn.extraSettingsEl; - collapseElsBelow(heading.settingEl); - if (btnEl.hasClass("collapsing")) { - btn.setTooltip("Collapse", { placement: "bottom" }); - btnEl.removeClass("collapsing"); - delete this.collapsedGroups[group.id]; - } else { - btn.setTooltip("Expand", { placement: "bottom" }); - btnEl.addClass("collapsing"); - this.collapsedGroups[group.id] = true; - } - }); - btnEl.addClass("collapse-button", "ems-button"); - if (this.collapsedGroups[group.id]) { - btnEl.addClass("collapsing"); - btn.setTooltip("Expand", { placement: "bottom" }); - } else { - btn.setTooltip("Collapse", { placement: "bottom" }); - } - }); - heading.controlEl.addClass("collapse-control"); - } - } - drawSettingItem(item: SettingItem, containerEl: HTMLElement, collapsible?: boolean, isCollapsing?: boolean) { - let setting = new Setting(containerEl).setName(item.name); - if (item.desc) { - setting.setDesc(item.desc); - } - if (collapsible) { - setting.setClass("is-collapsible"); - if (isCollapsing) { - setting.setClass("collapsed"); - } - } - if (item.fields) { - for (let field of item.fields) { - if (field.type == Field.TOGGLE) { this.setToggleField(field, setting) } - else if (field.type == Field.DROPDOWN) { this.setDropdownField(field, setting) } - else if (field.type == Field.MULTI_TOGGLE) { this.setMultiToggleField(field, setting) } - else if (field.type == Field.SLIDER) { this.setSliderField(field, setting) } - } - } - } - drawTagSettings(containerEl: HTMLElement, spec: TagSettingsSpec, collapsible?: boolean, isCollapsing?: boolean) { - let configs = getTagConfigs(this.plugin.settings, spec.type), - groupEl = containerEl.createDiv({ cls: ["setting-item", "setting-group", "ems-setting-group"] }); - configs.forEach(config => { - this.setTagItem(spec, config, configs, groupEl); - }); - let controlSetting = new Setting(containerEl); - insertButton(controlSetting, spec.addBtnPlaceholder, false, () => { - this.plugin.addTagConfig(spec.type); - let newSetting = this.setTagItem(spec, configs.at(-1)!, configs, groupEl); - newSetting.controlEl.querySelector(".ems-field-tag")?.focus(); - this.saveSettings(); - }); - insertButton(controlSetting, "Reset to default", true, () => { - this.tagSettingItems[spec.type].forEach(setting => { - this.removeSetting(setting); - }); - this.tagSettingItems[spec.type].splice(0); - this.plugin.revertTagConfigs(spec.type, (config, configs) => { - this.setTagItem(spec, config, configs, groupEl); - }); - this.saveSettings(); - spec.onResetted?.(this); - }); - if (collapsible) { - controlSetting.setClass("is-collapsible"); - groupEl.addClass("is-collapsible"); - if (isCollapsing) { - controlSetting.setClass("collapsed"); - groupEl.addClass("collapsed"); - } - } - this.makeDragable(groupEl, evt => { - let oldIndex = evt.oldDraggableIndex, - newIndex = evt.newDraggableIndex; - if (this.isHidden || oldIndex === undefined || newIndex === undefined || oldIndex == newIndex) { return } - configs.splice( - Math.min(oldIndex, newIndex), - 0, - ...configs.splice(Math.max(oldIndex, newIndex), 1) - ); - spec.onMove?.(this, oldIndex, newIndex); - }); - } - setTagItem(spec: TagSettingsSpec, config: TagConfig, configs: TagConfig[], groupEl: HTMLElement) { - config.tag = config.tag.replaceAll(spec.tagFilter, ""); - let tagSetting = new Setting(groupEl) - .setClass("ems-setting-item") - .setClass("ems-tag-config") - .addExtraButton(btn => { - btn.extraSettingsEl.addClasses(["ems-button", "ems-button-drag-handle"]); - btn.setIcon("grip-vertical"); - btn.setTooltip("Hold and drag to move", { placement: "left" }); - }) - .addText(text => { - text.setPlaceholder(spec.nameFieldPlaceholder); - text.setValue(config.name); - text.inputEl.addClasses(["ems-field", "ems-field-name"]); - text.onChange(name => { - config.name = name; - this.saveSettings(); - }); - }) - .addExtraButton(btn => { - let btnEl = btn.extraSettingsEl; - btnEl.addClasses(["ems-button", "ems-button-delete"]); - btn.setIcon("trash"); - btn.setTooltip("Delete"); - btn.onClick(() => { - let index = configs.findIndex(target => target == config); - configs.splice(index, 1); - this.tagSettingItems[spec.type].splice(index, 1); - this.removeSetting(tagSetting); - this.saveSettings(); - spec.onDelete?.(this, index); - }); - }) - .addExtraButton(btn => { - btn.extraSettingsEl.addClasses(["ems-button", "ems-button-toggle-show-hide"]); - btn.setIcon(config.showInMenu ? "eye" : "eye-off"); - btn.setTooltip("Show/hide menu item"); - btn.onClick(() => { - config.showInMenu = !config.showInMenu; - btn.setIcon(config.showInMenu ? "eye" : "eye-off"); - this.saveSettings(); - }); - }) - .addText(text => { - text.setPlaceholder(spec.tagFieldPlaceholder); - text.inputEl.addClasses(["ems-field", "ems-field-tag"]); - text.setValue(config.tag); - text.onChange(tag => { - let index = configs.findIndex(target => target == config); - config.tag = tag.replaceAll(spec.tagFilter, ""); - text.setValue(config.tag); - this.saveSettings(); - spec.onTagChange?.(this, config, index); - }); - }); - spec.onAdd?.(this, tagSetting, config); - return tagSetting; - } - setToggleField(field: ToggleFieldDesc, setting: Setting) { - setting.addToggle(toggle => { - let { record, key } = field; - toggle.setValue(record[key]); - toggle.onChange(val => { - record[key] = val; - this.saveSettings(field.update); - if (field.callback) { - field.callback(toggle, this.plugin); - } - }); - }); - } - setDropdownField(field: DropdownFieldDesc, setting: Setting) { - setting.addDropdown(dropdown => { - let { record, key } = field; - dropdown.addOptions(field.spec.options); - dropdown.setValue(record[key].toString()); - dropdown.onChange(val => { - (record[key] as MarkdownViewMode | DisplayBehaviour) = parseInt(val); - this.saveSettings(field.update); - if (field.callback) { - field.callback(dropdown, this.plugin); - } - }); - }); - } - setMultiToggleField(field: MultiToggleFieldDesc, setting: Setting) { - let { record, key } = field, - value = record[key], - options = field.spec.options as Record, - optionMap: string[] = (() => { - let optionMap = []; - for (let opt in options) { optionMap.push(opt) }; - return optionMap; - })(), - index = optionMap.findIndex(val => val === value.toString()); - setting.addExtraButton(btn => { - let { icon, tooltip } = options[value]; - btn.setIcon(icon); - btn.setTooltip(tooltip ?? ""); - btn.onClick(() => { - if (index + 1 >= optionMap.length) { index = 0 } - else { index++ } - let changedVal = parseInt(optionMap[index]) as typeof value, - { icon, tooltip } = options[changedVal]; - btn.setIcon(icon); - btn.setTooltip(tooltip ?? ""); - (record[key] as MarkdownViewMode | DisplayBehaviour) = changedVal; - this.saveSettings(field.update); - if (field.callback) { - field.callback(btn, this.plugin); - } - }); - }); - } - setSliderField(field: SliderFieldDesc, setting: Setting) { - setting.addSlider(slider => { - let { record, key, spec } = field; - slider.setInstant(false); - slider.setDynamicTooltip(); - slider.setLimits(spec.min, spec.max, spec.step); - slider.setValue(record[key]); - slider.onChange(val => { - (record[key] as number) = val; - this.saveSettings(field.update); - if (field.callback) { - field.callback(slider, this.plugin); - } - }); - }); - } - makeDragable(groupEl: HTMLElement, onEnd: (evt: SortableEvent) => unknown) { - Sortable.create(groupEl, { - handle: ".ems-button-drag-handle", - animation: 100, - easing: "cubic-bezier(0.2, 0, 0, 1)", - forceFallback: true, - fallbackOnBody: true, - fallbackClass: "ems-setting-item-dragged", - fallbackTolerance: 4, - onEnd: onEnd, - }); - } -} \ No newline at end of file diff --git a/src/settings/interface/index.ts b/src/settings/interface/index.ts deleted file mode 100644 index dbfca92..0000000 --- a/src/settings/interface/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./ExtendedSettingTab"; \ No newline at end of file diff --git a/src/settings/interface/utils/collapseElsBelow.ts b/src/settings/interface/utils/collapseElsBelow.ts deleted file mode 100644 index 3ba62fb..0000000 --- a/src/settings/interface/utils/collapseElsBelow.ts +++ /dev/null @@ -1,9 +0,0 @@ -export function collapseElsBelow(rootEl: HTMLElement) { - for (let el = rootEl.nextElementSibling; el?.hasClass("is-collapsible"); el = el.nextElementSibling) { - if (el.hasClass("collapsed")) { - el.removeClass("collapsed"); - } else { - el.addClass("collapsed"); - } - } -} \ No newline at end of file diff --git a/src/settings/interface/utils/index.ts b/src/settings/interface/utils/index.ts deleted file mode 100644 index 6b723ce..0000000 --- a/src/settings/interface/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./insertButton"; -export * from "./collapseElsBelow"; \ No newline at end of file diff --git a/src/settings/interface/utils/insertButton.ts b/src/settings/interface/utils/insertButton.ts deleted file mode 100644 index e80f181..0000000 --- a/src/settings/interface/utils/insertButton.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Setting } from "obsidian"; - -export function insertButton(setting: Setting, text: string, cta: boolean, onClick: (evt: MouseEvent) => unknown) { - setting.addButton(btn => { - btn.setButtonText(text); - btn.onClick(onClick); - if (cta) { btn.setCta() } - }); - return setting; -} \ No newline at end of file diff --git a/src/settings/ui/setting-tab.ts b/src/settings/ui/setting-tab.ts new file mode 100644 index 0000000..6ed2daa --- /dev/null +++ b/src/settings/ui/setting-tab.ts @@ -0,0 +1,364 @@ +import ExtendedMarkdownSyntax from 'main' +import Sortable, { SortableEvent } from 'sortablejs'; +import { App, IconName, PluginSettingTab, Setting } from 'obsidian'; +import { Field, MarkdownViewMode, DisplayBehaviour, Format } from 'src/enums'; +import { COLOR_SETTINGS_SPEC, DIV_TAG_SETTINGS_SPEC, SPAN_TAG_SETTINGS_SPEC, settingTabConfigs } from 'src/settings/ui/ui-configs'; +import { DropdownFieldDesc, MultiToggleFieldDesc, PluginSettings, SettingGroup, SettingItem, SettingRoot, SliderFieldDesc, TagConfig, TagSettingsSpec, ToggleFieldDesc } from 'src/types'; + +function _insertButton(setting: Setting, text: string, cta: boolean, onClick: (evt: MouseEvent) => unknown): Setting { + setting.addButton(btn => { + btn.setButtonText(text); + btn.onClick(onClick); + if (cta) { btn.setCta() } + }); + return setting; +} + +function _collapseElsBelow(rootEl: HTMLElement): void { + for (let el = rootEl.nextElementSibling; el?.hasClass("is-collapsible"); el = el.nextElementSibling) { + if (el.hasClass("collapsed")) { + el.removeClass("collapsed"); + } else { + el.addClass("collapsed"); + } + } +} + +export class ExtendedSettingTab extends PluginSettingTab { + public readonly plugin: ExtendedMarkdownSyntax; + public readonly tagSettingItems: Record = { + [Format.HIGHLIGHT]: [], + [Format.CUSTOM_SPAN]: [], + [Format.FENCED_DIV]: [] + }; + public readonly collapsedGroups: Record = { + "syntax-switch": true, + "formatting": true, + "tag-display": true, + "custom-highlight": true, + "custom-span": true, + "fenced-div": true, + "others": true + }; + + private refreshInternal = false; + private deeplyRefreshInternal = false; + private isHidden = true; + + constructor(app: App, plugin: ExtendedMarkdownSyntax) { + super(app, plugin); + this.plugin = plugin; + } + + public saveSettings(spec?: { internal?: boolean, deep?: boolean }) { + this.plugin.saveSettings(); + if (spec?.internal) { + this.refreshInternal = true; + if (spec?.deep) this.deeplyRefreshInternal = true; + } + } + + public display(): void { + let { containerEl } = this, + { settings } = this.plugin; + let settingsUIConfig = settingTabConfigs(settings); + this.drawFromConfig(settingsUIConfig, containerEl); + this.isHidden = false; + } + + public hide(): void { + if (this.refreshInternal) { + this.plugin.refreshMarkdownView(this.deeplyRefreshInternal); + } + this.refreshInternal = this.deeplyRefreshInternal = false; + this.emptyTagSettingItems(); + this.isHidden = true; + this.containerEl.empty(); + super.hide(); + } + + public emptyTagSettingItems(): void { + for (let type of [Format.HIGHLIGHT, Format.CUSTOM_SPAN, Format.FENCED_DIV] as const) { + this.tagSettingItems[type].splice(0); + } + } + + public removeSetting(setting: Setting): void { + setting.clear(); + setting.settingEl.remove(); + } + + public drawFromConfig(config: SettingRoot, containerEl: HTMLElement): void { + for (let group of config) { + let isCollapsing = this.collapsedGroups[group.id]; + if (group.heading) { this.drawHeading(group, containerEl) } + for (let item of group.items) { + this.drawSettingItem(item, containerEl, group.collapsible, isCollapsing); + if (item.preservedForTagSettings == Format.HIGHLIGHT) { + this.drawTagSettings(containerEl, COLOR_SETTINGS_SPEC, group.collapsible, isCollapsing); + } else if (item.preservedForTagSettings == Format.CUSTOM_SPAN) { + this.drawTagSettings(containerEl, SPAN_TAG_SETTINGS_SPEC, group.collapsible, isCollapsing); + } else if (item.preservedForTagSettings == Format.FENCED_DIV) { + this.drawTagSettings(containerEl, DIV_TAG_SETTINGS_SPEC, group.collapsible, isCollapsing); + } + } + } + } + + public drawHeading(group: SettingGroup, containerEl: HTMLElement): void { + if (!group.heading) { return } + let heading = new Setting(containerEl) + .setHeading() + .setName(group.heading); + if (group.desc) { + heading.setDesc(group.desc); + } + if (group.collapsible) { + heading.addExtraButton(btn => { + let btnEl = btn.extraSettingsEl; + btn + .setIcon("chevron-down") + .onClick(() => { + let btnEl = btn.extraSettingsEl; + _collapseElsBelow(heading.settingEl); + if (btnEl.hasClass("collapsing")) { + btn.setTooltip("Collapse", { placement: "bottom" }); + btnEl.removeClass("collapsing"); + delete this.collapsedGroups[group.id]; + } else { + btn.setTooltip("Expand", { placement: "bottom" }); + btnEl.addClass("collapsing"); + this.collapsedGroups[group.id] = true; + } + }); + btnEl.addClass("collapse-button", "ems-button"); + if (this.collapsedGroups[group.id]) { + btnEl.addClass("collapsing"); + btn.setTooltip("Expand", { placement: "bottom" }); + } else { + btn.setTooltip("Collapse", { placement: "bottom" }); + } + }); + heading.controlEl.addClass("collapse-control"); + } + } + + public drawSettingItem(item: SettingItem, containerEl: HTMLElement, collapsible?: boolean, isCollapsing?: boolean): void { + let setting = new Setting(containerEl).setName(item.name); + if (item.desc) { + setting.setDesc(item.desc); + } + if (collapsible) { + setting.setClass("is-collapsible"); + if (isCollapsing) { + setting.setClass("collapsed"); + } + } + if (item.fields) { + for (let field of item.fields) { + if (field.type == Field.TOGGLE) { this._setToggleField(field, setting) } + else if (field.type == Field.DROPDOWN) { this._setDropdownField(field, setting) } + else if (field.type == Field.MULTI_TOGGLE) { this._setMultiToggleField(field, setting) } + else if (field.type == Field.SLIDER) { this._setSliderField(field, setting) } + } + } + } + + public drawTagSettings(containerEl: HTMLElement, spec: TagSettingsSpec, collapsible?: boolean, isCollapsing?: boolean): void { + let groupEl = containerEl.createDiv({ cls: ["setting-item", "setting-group", "ems-setting-group"] }), + tagManager = this.plugin.tagManager, + configs = tagManager.configsMap[spec.type], + controlSetting = new Setting(containerEl); + + configs.forEach(config => { + this._setTagItem(spec, config, configs, groupEl); + }); + + _insertButton(controlSetting, spec.addBtnPlaceholder, false, () => { + tagManager.add(spec.type); + this._setTagItem(spec, configs.at(-1)!, configs, groupEl) + .controlEl.querySelector(".ems-field-tag") + ?.focus(); + }); + + _insertButton(controlSetting, "Reset to default", true, () => { + this.tagSettingItems[spec.type].forEach(setting => { + this.removeSetting(setting); + }); + this.tagSettingItems[spec.type].splice(0); + tagManager.reset(spec.type); + configs.forEach(config => { + this._setTagItem(spec, config, configs, groupEl); + }); + spec.onResetted?.(this); + }); + + if (collapsible) { + controlSetting.setClass("is-collapsible"); + groupEl.addClass("is-collapsible"); + if (isCollapsing) { + controlSetting.setClass("collapsed"); + groupEl.addClass("collapsed"); + } + } + + this._makeDragable(groupEl, evt => { + let oldIndex = evt.oldDraggableIndex, + newIndex = evt.newDraggableIndex; + if (this.isHidden || oldIndex === undefined || newIndex === undefined || oldIndex == newIndex) return; + tagManager.move(spec.type, oldIndex, newIndex); + spec.onMove?.(this, oldIndex, newIndex); + }); + } + + private _setTagItem(spec: TagSettingsSpec, config: TagConfig, configs: TagConfig[], groupEl: HTMLElement): Setting { + config.tag = config.tag.replaceAll(spec.tagFilter, ""); + + let tagSetting = new Setting(groupEl) + .setClass("ems-setting-item") + .setClass("ems-tag-config") + .addExtraButton(btn => { + btn.extraSettingsEl.addClasses(["ems-button", "ems-button-drag-handle"]); + btn.setIcon("grip-vertical"); + btn.setTooltip("Hold and drag to move", { placement: "left" }); + }) + .addText(text => { + text.setPlaceholder(spec.nameFieldPlaceholder); + text.setValue(config.name); + text.inputEl.addClasses(["ems-field", "ems-field-name"]); + text.onChange(name => { + config.name = name; + this.saveSettings(); + }); + }) + .addExtraButton(btn => { + let btnEl = btn.extraSettingsEl; + btnEl.addClasses(["ems-button", "ems-button-delete"]); + btn.setIcon("trash"); + btn.setTooltip("Delete"); + btn.onClick(() => { + let index = configs.findIndex(target => target == config); + this.plugin.tagManager.remove(spec.type, index); + this.tagSettingItems[spec.type].splice(index, 1); + this.removeSetting(tagSetting); + this.saveSettings(); + spec.onDelete?.(this, index); + }); + }) + .addExtraButton(btn => { + btn.extraSettingsEl.addClasses(["ems-button", "ems-button-toggle-show-hide"]); + btn.setIcon(config.showInMenu ? "eye" : "eye-off"); + btn.setTooltip("Show/hide menu item"); + btn.onClick(() => { + config.showInMenu = !config.showInMenu; + btn.setIcon(config.showInMenu ? "eye" : "eye-off"); + this.saveSettings(); + }); + }) + .addText(text => { + text.setPlaceholder(spec.tagFieldPlaceholder); + text.inputEl.addClasses(["ems-field", "ems-field-tag"]); + text.setValue(config.tag); + text.onChange(tag => { + let index = configs.findIndex(target => target == config); + config.tag = tag.replaceAll(spec.tagFilter, ""); + text.setValue(config.tag); + this.saveSettings(); + spec.onTagChange?.(this, config, index); + }); + }); + + spec.onAdd?.(this, tagSetting, config); + this.tagSettingItems[spec.type].push(tagSetting); + return tagSetting; + } + + private _setToggleField(field: ToggleFieldDesc, setting: Setting): void { + setting.addToggle(toggle => { + let { record, key } = field; + toggle.setValue(record[key]); + toggle.onChange(val => { + record[key] = val; + this.saveSettings(field.update); + if (field.callback) { + field.callback(toggle, this.plugin); + } + }); + }); + } + + private _setDropdownField(field: DropdownFieldDesc, setting: Setting): void { + setting.addDropdown(dropdown => { + let { record, key } = field; + dropdown.addOptions(field.spec.options); + dropdown.setValue(record[key].toString()); + dropdown.onChange(val => { + (record[key] as MarkdownViewMode | DisplayBehaviour) = parseInt(val); + this.saveSettings(field.update); + if (field.callback) { + field.callback(dropdown, this.plugin); + } + }); + }); + } + + private _setMultiToggleField(field: MultiToggleFieldDesc, setting: Setting): void { + let { record, key } = field, + value = record[key], + options = field.spec.options as Record, + optionMap: string[] = (() => { + let optionMap = []; + for (let opt in options) { optionMap.push(opt) }; + return optionMap; + })(), + index = optionMap.findIndex(val => val === value.toString()); + setting.addExtraButton(btn => { + let { icon, tooltip } = options[value]; + btn.setIcon(icon); + btn.setTooltip(tooltip ?? ""); + btn.onClick(() => { + if (index + 1 >= optionMap.length) { index = 0 } + else { index++ } + let changedVal = parseInt(optionMap[index]) as typeof value, + { icon, tooltip } = options[changedVal]; + btn.setIcon(icon); + btn.setTooltip(tooltip ?? ""); + (record[key] as MarkdownViewMode | DisplayBehaviour) = changedVal; + this.saveSettings(field.update); + if (field.callback) { + field.callback(btn, this.plugin); + } + }); + }); + } + + private _setSliderField(field: SliderFieldDesc, setting: Setting) { + setting.addSlider(slider => { + let { record, key, spec } = field; + slider.setInstant(false); + slider.setDynamicTooltip(); + slider.setLimits(spec.min, spec.max, spec.step); + slider.setValue(record[key]); + slider.onChange(val => { + (record[key] as number) = val; + this.saveSettings(field.update); + if (field.callback) { + field.callback(slider, this.plugin); + } + }); + }); + } + + private _makeDragable(groupEl: HTMLElement, onEnd: (evt: SortableEvent) => unknown): void { + Sortable.create(groupEl, { + handle: ".ems-button-drag-handle", + animation: 100, + easing: "cubic-bezier(0.2, 0, 0, 1)", + forceFallback: true, + fallbackOnBody: true, + fallbackClass: "ems-setting-item-dragged", + fallbackTolerance: 4, + onEnd: onEnd, + }); + } +} \ No newline at end of file diff --git a/src/settings/ui/ui-configs.ts b/src/settings/ui/ui-configs.ts new file mode 100644 index 0000000..17168f0 --- /dev/null +++ b/src/settings/ui/ui-configs.ts @@ -0,0 +1,430 @@ +import { DisplayBehaviour, MarkdownViewMode, Field, Format, Theme } from "src/enums"; +import { PluginSettings, SettingRoot, TagSettingsSpec, ColorConfig } from "src/types"; +import { convertColorConfigToCSSRule } from "src/utils/color-utils"; + +const DISPLAY_BEHAVIOUR_DROPDOWN: Record = { + [DisplayBehaviour.ALWAYS]: "Always visible", + [DisplayBehaviour.TAG_TOUCHED]: "When touching the tag itself", + [DisplayBehaviour.SYNTAX_TOUCHED]: "When touching its syntax" +} + +const ALWAYS_SHOW_DROPDOWN: Record = { + [MarkdownViewMode.NONE]: "Default", + [MarkdownViewMode.EDITOR_MODE]: "Editor only", + [MarkdownViewMode.PREVIEW_MODE]: "Preview only", + [MarkdownViewMode.ALL]: "Both editor and preview" +} + +const MARKDOWN_VIEW_MODE_DROPDOWN: Record = { + [MarkdownViewMode.NONE]: "Disable all", + [MarkdownViewMode.EDITOR_MODE]: "Editor only", + [MarkdownViewMode.PREVIEW_MODE]: "Preview only", + [MarkdownViewMode.ALL]: "Enable all" +} + +export const COLOR_SETTINGS_SPEC: TagSettingsSpec = { + type: Format.HIGHLIGHT, + addBtnPlaceholder: "Add color", + nameFieldPlaceholder: "Color name", + tagFieldPlaceholder: "Tag string", + tagFilter: /[^a-z0-9-]/gi, + onAdd: (settingTab, settingItem, config: ColorConfig) => { + settingItem.addColorPicker(picker => { + let plugin = settingTab.plugin, + configs = plugin.settings.colorConfigs, + tagManager = plugin.tagManager; + picker.setValue(config.color); + picker.colorPickerEl.addClasses(["ems-field", "ems-field-color-picker"]); + picker.onChange(color => { + config.color = color; + let ruleStr = convertColorConfigToCSSRule(config), + index = configs.findIndex(target => target == config); + tagManager.colorsHandler.replace(ruleStr, index); + settingTab.saveSettings(); + }); + }); + }, + onTagChange: (settingTab, config: ColorConfig, index) => { + let ruleStr = convertColorConfigToCSSRule(config); + settingTab.plugin.tagManager.colorsHandler.replace(ruleStr, index); + } +} + +export const SPAN_TAG_SETTINGS_SPEC: TagSettingsSpec = { + type: Format.CUSTOM_SPAN, + addBtnPlaceholder: "Add tag", + nameFieldPlaceholder: "Tag name", + tagFieldPlaceholder: "Tag string", + tagFilter: /[^ a-z0-9-]/gi +} + +export const DIV_TAG_SETTINGS_SPEC: TagSettingsSpec = { + type: Format.FENCED_DIV, + addBtnPlaceholder: "Add tag", + nameFieldPlaceholder: "Tag name", + tagFieldPlaceholder: "Tag string", + tagFilter: /[^ a-z0-9-]/gi +} + +/** + * Main configuration of the setting interface. + */ +export const settingTabConfigs = (settings: PluginSettings): SettingRoot => { + return [{ + id: "syntax-switch", + heading: "Syntax switch", + collapsible: true, + items: [{ + name: "Insertion (underline)", + desc: "Use double plus (\"++\") as a delimiter.", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "insertion", + spec: { + options: MARKDOWN_VIEW_MODE_DROPDOWN + }, + update: { internal: true, deep: true }, + callback: (_, plugin) => plugin.reconfigureDelimLookup() + }] + }, { + name: "Discord-flavored spoiler", + desc: "Use double bars (\"||\") as a delimiter.", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "spoiler", + spec: { + options: MARKDOWN_VIEW_MODE_DROPDOWN + }, + update: { internal: true, deep: true }, + callback: (_, plugin) => plugin.reconfigureDelimLookup() + }] + }, { + name: "Pandoc-style superscript", + desc: "Use a single caret (\"^\") as a delimiter.", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "superscript", + spec: { + options: MARKDOWN_VIEW_MODE_DROPDOWN + }, + update: { internal: true, deep: true }, + callback: (_, plugin) => plugin.reconfigureDelimLookup() + }] + }, { + name: "Pandoc-style subscript", + desc: "Use a single tilde (\"~\") as a delimiter.", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "subscript", + spec: { + options: MARKDOWN_VIEW_MODE_DROPDOWN + }, + update: { internal: true, deep: true }, + callback: (_, plugin) => plugin.reconfigureDelimLookup() + }] + }, { + name: "Highlight color tag", + desc: "Use alphanumeric and hyphen characters (\"A-Za-z0-9-\") after the opening delimiter covered by curly brackets (\"{}\").", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "customHighlight", + spec: { + options: MARKDOWN_VIEW_MODE_DROPDOWN + }, + update: { internal: true, deep: true }, + callback: (_, plugin) => plugin.reconfigureDelimLookup() + }] + }, { + name: "Custom span", + desc: "Use double exclamation marks (\"!!\") as a delimiter. Can be inserted after the " + + "opening delimiter by a tag with the same way as highlight tag. " + + "Additionally, you can use multiple classes inside it, separated by space(s).", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "customSpan", + spec: { + options: MARKDOWN_VIEW_MODE_DROPDOWN + }, + update: { internal: true, deep: true }, + callback: (_, plugin) => plugin.reconfigureDelimLookup() + }] + }, { + name: "Pandoc-style fenced div (custom block)", + desc: "Use three colons (\":::\") or more at the beginning of a paragraph, " + + "followed by alphanumeric and hyphen character (\"A-Za-z0-9\") after them. Use space to separate each class.", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "fencedDiv", + spec: { + options: MARKDOWN_VIEW_MODE_DROPDOWN + }, + update: { internal: true, deep: true }, + callback: (_, plugin) => plugin.reconfigureDelimLookup() + }] + }] + }, { + id: "formatting", + heading: "Formatting", + collapsible: true, + items: [{ + name: "Make it tidier", + desc: "If turned on, formatting will run in refined way by detecting context the cursor or selection was placed within, " + + "e.g. you just place a cursor to specific word and run the command to format the whole word or vice-versa. " + + "If turned off, formatting is more like a normal auto-wrap toggle and doesn't care about the context.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "tidyFormatting", + spec: null + }] + }, { + name: "Open tag menu after formatting", + desc: "Will automatically open tag menu after doing formatting via command palattes or context menu. " + + "Only applied to the custom highlight, custom span, and fenced div.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "openTagMenuAfterFormat", + spec: null + }] + }, { + name: "No styled fenced div in source mode", + desc: "Prevent any fenced divs being styled in source mode.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "noStyledDivInSourceMode", + spec: null, + update: { internal: true } + }] + }] + }, { + id: "tag-display", + heading: "Tag display behavior", + collapsible: true, + items: [{ + name: "Highlight tag in live-preview mode", + desc: "Tags can either always be visible, or when they meet particular condition.", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "hlTagDisplayBehaviour", + spec: { + options: DISPLAY_BEHAVIOUR_DROPDOWN + }, + update: { internal: true } + }] + }, { + name: "Custom span tag in live-preview mode", + desc: "Tags can either always be visible, or when they meet particular condition.", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "spanTagDisplayBehaviour", + spec: { + options: DISPLAY_BEHAVIOUR_DROPDOWN + }, + update: { internal: true } + }] + }, { + name: "Highlight tag in preview mode", + desc: "Show or hide highlight color tag in the preview mode.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showHlTagInPreviewMode", + spec: null, + update: { internal: true } + }] + }, { + name: "Custom span tag in preview mode", + desc: "Show or hide custom span tag in the preview mode.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showSpanTagInPreviewMode", + spec: null, + update: { internal: true } + }] + }, { + name: "Always show fenced div tag", + desc: "By default, the tag is always hidden in the preview mode, " + + "and only displayed when touching the cursor or selection in the live preview mode.", + fields: [{ + type: Field.DROPDOWN, + record: settings, + key: "alwaysShowFencedDivTag", + spec: { + options: ALWAYS_SHOW_DROPDOWN + }, + update: { internal: true, deep: true } + }] + }] + }, { + id: "custom-highlight", + heading: "Custom highlight", + collapsible: true, + items: [{ + name: "Color button", + desc: "Display a button after highlight opening delimiter, used to open the color menu.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "colorButton", + spec: null, + update: { internal: true } + }] + }, { + name: "Show accent option", + desc: "Show accent option in the color menu.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showAccentColor", + spec: null + }] + }, { + name: "Show default option", + desc: "Show default option in the color menu. Default means non-tagged highlight.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showDefaultColor", + spec: null + }] + }, { + name: "Show remove option", + desc: "Show remove option in the color menu.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showRemoveColor", + spec: null + }] + }, { + name: "Color opacity in light mode", + desc: "Adjust highlight opacity in light mode. Zero means that will be fully transparent.", + fields: [{ + type: Field.SLIDER, + record: settings, + key: "lightModeHlOpacity", + spec: { + min: 0, + max: 1, + step: 0.01 + }, + callback: (slider, plugin) => { + plugin.setHlOpacity(slider.getValue(), Theme.LIGHT); + } + }] + }, { + name: "Color opacity in dark mode", + desc: "Adjust highlight opacity in dark mode. Zero means that will be fully transparent.", + fields: [{ + type: Field.SLIDER, + record: settings, + key: "darkModeHlOpacity", + spec: { + min: 0, + max: 1, + step: 0.01 + }, + callback: (slider, plugin) => { + plugin.setHlOpacity(slider.getValue(), Theme.DARK); + } + }] + }, { + name: "Color palettes", + desc: "The first text field gives the name of the item in the color menu, " + + "and the second one sets the tag string to be used in highlight syntax.", + preservedForTagSettings: Format.HIGHLIGHT + }] + }, { + id: "custom-span", + heading: "Custom span", + collapsible: true, + items: [{ + name: "Show default option", + desc: "Show default option in the tag menu. Default means non-tagged span.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showDefaultSpanTag", + spec: null + }] + }, { + name: "Show remove option", + desc: "Show remove option in the tag menu.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showRemoveSpanTag", + spec: null + }] + }, { + name: "Predefined tags", + desc: "Predefined tags to be displayed in the tag menu. The first field gives the name of the item, " + + "the second one sets the tag string to be used in custom span syntax.", + preservedForTagSettings: Format.CUSTOM_SPAN + }] + }, { + id: "fenced-div", + heading: "Fenced div", + collapsible: true, + items: [{ + name: "Show default option", + desc: "Show default option in the tag menu. Default means non-tagged div.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showDefaultDivTag", + spec: null + }] + }, { + name: "Show remove option", + desc: "Show remove option in the tag menu.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "showRemoveDivTag", + spec: null + }] + }, { + name: "Predefined tags", + desc: "Predefined tags to be displayed in the tag menu. The first field gives the name of the item, " + + "the second one sets the tag string to be used in fenced div syntax.", + preservedForTagSettings: Format.FENCED_DIV + }] + }, { + id: "others", + heading: "Others", + collapsible: true, + items: [{ + name: "Delimiter escaping", + desc: "Use backslash as a delimiter escaper. Works only in the editor mode.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "editorEscape", + spec: null, + update: { internal: true, deep: true } + }] + }, { + name: "Parse exported PDF", + desc: "Parse syntax on an exported PDF.", + fields: [{ + type: Field.TOGGLE, + record: settings, + key: "decoratePDF", + spec: null + }] + }] + }]; +} \ No newline at end of file diff --git a/src/stylesheet-handler/StyleSheetHandler.ts b/src/stylesheet-handler/StyleSheetHandler.ts deleted file mode 100644 index ed22271..0000000 --- a/src/stylesheet-handler/StyleSheetHandler.ts +++ /dev/null @@ -1,99 +0,0 @@ -import * as Plugin from "main"; - -export class StyleSheetHandler { - stylesheet: CSSStyleSheet; - plugin?: Plugin.default; - root: DocumentOrShadowRoot; - subHandlers: StyleSheetHandler[] = []; - constructor(plugin?: Plugin.default, root: DocumentOrShadowRoot = document, rootStyleSheet?: CSSStyleSheet) { - let curWindow = (root as Document)?.win ?? (root as ShadowRoot).ownerDocument.win; - this.stylesheet = new ((curWindow as unknown as { globalThis: typeof globalThis }).globalThis).CSSStyleSheet(); - this.plugin = plugin; - this.root = root; - this.adoptMain(); - if (rootStyleSheet) { - this.copy(rootStyleSheet); - } - } - get rulesLen() { - return this.stylesheet.cssRules.length; - } - adoptMain() { - this.root.adoptedStyleSheets = [this.stylesheet, ...this.root.adoptedStyleSheets]; - } - abandonMain() { - let initStyleSheets = this.root.adoptedStyleSheets.filter(stylesheet => stylesheet != this.stylesheet); - this.root.adoptedStyleSheets = initStyleSheets; - this.subHandlers.forEach(handler => handler.destroy()); - this.subHandlers = []; - } - adopt(doc: DocumentOrShadowRoot) { - if (doc == this.root || this.subHandlers.find(handler => handler.root == this.root)) { return } - this.subHandlers.push(new StyleSheetHandler(undefined, doc, this.stylesheet)); - } - abandon(doc: DocumentOrShadowRoot) { - if (doc == this.root) { return } - this.subHandlers.find((handler, index) => { - if (handler.root == doc) { - handler.destroy(); - this.subHandlers.splice(index, 1); - return true; - } - }); - } - destroy() { - this.abandonMain(); - (this.root as unknown as undefined) = undefined; - (this.stylesheet as unknown as undefined) = undefined; - if (this.plugin) { - (this.plugin.colorsHandler as unknown as undefined) = undefined; - } - } - insert(ruleStr: string, index = this.rulesLen) { - this.subHandlers.forEach(handler => handler.insert(ruleStr, index)); - return this.stylesheet.insertRule(ruleStr, index); - } - replace(ruleStr: string, index: number) { - this.stylesheet.deleteRule(index); - this.stylesheet.insertRule(ruleStr, index); - this.subHandlers.forEach(handler => handler.replace(ruleStr, index)); - } - moveSingleRule(index: number, to: 1 | -1) { - if (to !== 1 && to !== -1) { throw Error("") } - if (index <= 0 && to < 0 || index >= this.rulesLen - 1 && to > 0) { return false } - let ruleStr = this.stylesheet.cssRules.item(index)!.cssText; - this.stylesheet.deleteRule(index); - this.insert(ruleStr, index + to); - this.subHandlers.forEach(handler => handler.moveSingleRule(index, to)); - } - moveRule(fromIndex: number, toIndex: number) { - if (fromIndex == toIndex) { return } - let greaterIndex = Math.max(fromIndex, toIndex), - smallerIndex = Math.min(fromIndex, toIndex), - ruleStr = this.stylesheet.cssRules.item(greaterIndex)!.cssText; - this.removeSingle(greaterIndex); - this.insert(ruleStr, smallerIndex); - this.subHandlers.forEach(handler => handler.moveRule(fromIndex, toIndex)); - } - removeSingle(index: number) { - this.stylesheet.deleteRule(index); - this.subHandlers.forEach(handler => handler.removeSingle(index)); - } - remove(from: number, to: number) { - let rulesLen = this.rulesLen; - for (let i = Math.min(to, rulesLen) - 1; i >= from; i--) { - this.stylesheet.deleteRule(i); - } - this.subHandlers.forEach(handler => handler.remove(from, to)); - } - removeAll() { - this.stylesheet.replace(""); - this.subHandlers.forEach(handler => handler.removeAll()); - } - copy(stylesheet: CSSStyleSheet) { - for (let i = 0; i < stylesheet.cssRules.length; i++) { - let ruleStr = stylesheet.cssRules.item(i)!.cssText; - this.stylesheet.insertRule(ruleStr, i); - } - } -} \ No newline at end of file diff --git a/src/stylesheet-handler/index.ts b/src/stylesheet-handler/index.ts deleted file mode 100644 index e58ed3f..0000000 --- a/src/stylesheet-handler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./StyleSheetHandler"; \ No newline at end of file diff --git a/src/stylesheet.ts b/src/stylesheet.ts new file mode 100644 index 0000000..a24f453 --- /dev/null +++ b/src/stylesheet.ts @@ -0,0 +1,114 @@ +import ExtendedMarkdownSyntax from "main"; + +export class StyleSheetHandler { + private _stylesheet: CSSStyleSheet; + private _plugin?: ExtendedMarkdownSyntax; + private _root: DocumentOrShadowRoot; + private _subHandlers: StyleSheetHandler[] = []; + + constructor(plugin?: ExtendedMarkdownSyntax, root: DocumentOrShadowRoot = document, rootStyleSheet?: CSSStyleSheet) { + let curWindow = (root as Document)?.win ?? (root as ShadowRoot).ownerDocument.win; + this._stylesheet = new curWindow.globalThis.CSSStyleSheet(); + this._plugin = plugin; + this._root = root; + this.injectMain(); + if (rootStyleSheet) { + this.copy(rootStyleSheet); + } + } + + public get rulesLen(): number { + return this._stylesheet.cssRules.length; + } + + public injectMain(): void { + this._root.adoptedStyleSheets = [this._stylesheet, ...this._root.adoptedStyleSheets]; + } + + public ejectMain(): void { + let initStyleSheets = this._root.adoptedStyleSheets.filter(stylesheet => stylesheet != this._stylesheet); + this._root.adoptedStyleSheets = initStyleSheets; + this._subHandlers.forEach(handler => handler.destroy()); + this._subHandlers = []; + } + + public inject(doc: DocumentOrShadowRoot): void { + if (doc == this._root || this._subHandlers.find(handler => handler._root == this._root)) { return } + this._subHandlers.push(new StyleSheetHandler(undefined, doc, this._stylesheet)); + } + + public eject(doc: DocumentOrShadowRoot): void { + if (doc == this._root) { return } + this._subHandlers.find((handler, index) => { + if (handler._root == doc) { + handler.destroy(); + this._subHandlers.splice(index, 1); + return true; + } + }); + } + + public destroy(): void { + this.ejectMain(); + (this._root as unknown as undefined) = undefined; + (this._stylesheet as unknown as undefined) = undefined; + if (this._plugin) { + (this._plugin.tagManager.colorsHandler as unknown as undefined) = undefined; + } + } + + public insert(ruleStr: string, index = this.rulesLen): number { + this._subHandlers.forEach(handler => handler.insert(ruleStr, index)); + return this._stylesheet.insertRule(ruleStr, index); + } + + public replace(ruleStr: string, index: number): void { + this._stylesheet.deleteRule(index); + this._stylesheet.insertRule(ruleStr, index); + this._subHandlers.forEach(handler => handler.replace(ruleStr, index)); + } + + public moveSingleRule(index: number, to: 1 | -1): false | undefined { + if (to !== 1 && to !== -1) { throw Error("") } + if (index <= 0 && to < 0 || index >= this.rulesLen - 1 && to > 0) { return false } + let ruleStr = this._stylesheet.cssRules.item(index)!.cssText; + this._stylesheet.deleteRule(index); + this.insert(ruleStr, index + to); + this._subHandlers.forEach(handler => handler.moveSingleRule(index, to)); + } + + public moveRule(fromIndex: number, toIndex: number): void { + if (fromIndex == toIndex) { return } + let greaterIndex = Math.max(fromIndex, toIndex), + smallerIndex = Math.min(fromIndex, toIndex), + ruleStr = this._stylesheet.cssRules.item(greaterIndex)!.cssText; + this.removeSingle(greaterIndex); + this.insert(ruleStr, smallerIndex); + this._subHandlers.forEach(handler => handler.moveRule(fromIndex, toIndex)); + } + + public removeSingle(index: number): void { + this._stylesheet.deleteRule(index); + this._subHandlers.forEach(handler => handler.removeSingle(index)); + } + + public remove(from: number, to: number): void { + let rulesLen = this.rulesLen; + for (let i = Math.min(to, rulesLen) - 1; i >= from; i--) { + this._stylesheet.deleteRule(i); + } + this._subHandlers.forEach(handler => handler.remove(from, to)); + } + + public removeAll(): void { + this._stylesheet.replace(""); + this._subHandlers.forEach(handler => handler.removeAll()); + } + + public copy(stylesheet: CSSStyleSheet): void { + for (let i = 0; i < stylesheet.cssRules.length; i++) { + let ruleStr = stylesheet.cssRules.item(i)!.cssText; + this._stylesheet.insertRule(ruleStr, i); + } + } +} \ No newline at end of file diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..de42024 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,368 @@ +import type { SyntaxNode, Tree } from "@lezer/common"; +import type { Format, MarkdownViewMode, TokenLevel, Delimiter, TokenStatus, Field, DisplayBehaviour } from "src/enums"; +import { Line, RangeSet, RangeValue, Text, Range, SelectionRange, EditorState } from "@codemirror/state" +import { Decoration, DecorationSet } from "@codemirror/view"; +import { ColorComponent, Command, DropdownComponent, Editor, ExtraButtonComponent, IconName, MarkdownFileInfo, MarkdownView, Setting, SliderComponent, TextAreaComponent, TextComponent, ToggleComponent } from "obsidian"; +import ExtendedMarkdownSyntax from "main"; +import { TagMenu } from "src/editor-mode/ui-components"; +import { ExtendedSettingTab } from "src/settings/ui/setting-tab"; + +declare module "@codemirror/state" { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + export interface ILine extends Line {} + + interface TextNode extends Text { + readonly children: readonly Text[]; + } + + interface TextLeaf extends Text { + readonly children: null; + text: string[]; + } +} + +declare global { + interface Window { + globalThis: typeof globalThis; + } +} + +/** Token interface */ +export type Token = { + /** Format type */ + type: Format, // 3-bit + /** Either block or inline */ + level: TokenLevel, // 1 bit + /** Token status */ + status: TokenStatus, // 2-bit + /** Start offset */ + from: number, // 16-bit + /** End offset */ + to: number, // 16-bit + /** Length of its opening delimiter. */ + openLen: number, + /** Length of its closing delimiter if any, otherwise it should be zero. */ + closeLen: number, + /** Length of its tag if any, otherwise it should be zero. */ + tagLen: number, + /** Whether the range of tag is covered by the range of content or not. */ + tagAsContent: boolean; + /** The tag was considered as invalid when it does not satisfy its rule. */ + validTag: boolean; + /** + * Closed by blank line instead of closing delimiter. If `closeLen` is 0, the + * token's type isn't space-restricted format, and `closedByBlankLine` is `false`, + * then indicates that the token definitely was located in the end of the document + * (`Token.to == Doc.length`). + */ + closedByBlankLine: boolean; +} + +/** Accumulation of `ChangeSet` generated by the `composeChange` function. */ +export type ChangedRange = { + /** + * Start offset of a changed range, measured from + * the text both before and after the change. + */ + from: number, + /** End offset of a changed range, measured from the doc before the change. */ + initTo: number, + /** End offset of a changed range, measured from the doc after the change. */ + changedTo: number; + /** + * Length difference. Can be negative value, indicating that replaced text + * is longer than the substitute. + */ + length: number; +} + +/** Describe the rule that has to be satisfied by the delimiter */ +export type DelimSpec = { + /** Should be single character */ + char: string, + /** Delimiter length */ + length: number, + /** + * If true, then delimiter length must be the same as + * in the predetermined rule. Otherwise, the defined length + * act as minimum length. + */ + exactLen: boolean, + /** Should be `Delimiter.OPEN` or `Delimiter.CLOSE` */ + role: Delimiter, + /** + * When true, any space after the opening delimiter or before the closing + * one doesn't make the delimiter invalid. Default is false. + */ + allowSpaceOnDelim?: boolean +} + +/** + * Used for (re)configuring the state, especially in + * the case of document or tree change + */ +export type StateConfig = { + doc: Text, + tree: Tree, + offset: number, + settings: PluginSettings, + maxLine?: number +} + +/** + * Region defined here as a `PlainRange` collection arranged in an array. + * Each range inside should be sorted in order according to its `from`. + */ +export type Region = PlainRange[]; + +export type NodeSpec = { + node: SyntaxNode, + type: string +} + +export type InlineFormat = Extract; + +export type BlockFormat = Exclude; + +export type NonBuiltinInlineFormat = Exclude; + +export type TagSupportFormat = Format.HIGHLIGHT | Format.CUSTOM_SPAN | Format.FENCED_DIV; + +export type TokenGroup = Token[]; + +export type ColorConfig = { + tag: string, + name: string, + color: string, + showInMenu: boolean +}; + +export type TagConfig = { + tag: string, + name: string, + showInMenu: boolean +}; + +export type PluginSettings = { + // General + insertion: MarkdownViewMode; + spoiler: MarkdownViewMode; + superscript: MarkdownViewMode; + subscript: MarkdownViewMode; + customHighlight: MarkdownViewMode; + customSpan: MarkdownViewMode; + fencedDiv: MarkdownViewMode; + + // Tag behavior + hlTagDisplayBehaviour: DisplayBehaviour; + spanTagDisplayBehaviour: DisplayBehaviour; + showHlTagInPreviewMode: boolean; + showSpanTagInPreviewMode: boolean; + alwaysShowFencedDivTag: MarkdownViewMode; + + // Formatting + tidyFormatting: boolean; + openTagMenuAfterFormat: boolean; + noStyledDivInSourceMode: boolean; + + // Custom highlight + colorButton: boolean; + showAccentColor: boolean; + showDefaultColor: boolean; + showRemoveColor: boolean; + lightModeHlOpacity: number; + darkModeHlOpacity: number; + colorConfigs: ColorConfig[]; + + // Custom span + showDefaultSpanTag: boolean; + showRemoveSpanTag: boolean; + predefinedSpanTag: TagConfig[]; + + // Fenced div + showDefaultDivTag: boolean; + showRemoveDivTag: boolean; + predefinedDivTag: TagConfig[]; + + // Others + editorEscape: boolean; + decoratePDF: boolean; +} + +export type PlainRange = { from: number, to: number }; + +export type InlineFormatRule = { + char: string, + length: number, + exactLen: boolean, + allowSpace: boolean, + mustBeClosed: boolean, + class: string, + getEl: (cls?: string) => Element, + builtin: boolean +} + +export type BlockFormatRule = { + char: string, + length: number, + exactLen: boolean, + class: string, +} + +export type FormatRule = InlineFormatRule & BlockFormatRule; + +export interface TokenDecoration extends Decoration { + spec: { + class: string, + token: Token + }; +} + +export type TokenDecorationSet = RangeSet; + +export type IndexCache = { number: number } + +export type DecoSetCollection = Record<"inlineSet" | "blockSet" | "omittedSet" | "colorBtnSet" | "revealedSpoilerSet", DecorationSet>; + +export type IterLineSpec = { + doc: Text, + fromLn: number, + toLn?: number, + callback: (line: Line, doc: Text) => boolean | void +} + +export type IterTokenGroupSpec = { + tokens: TokenGroup, + ranges: PlainRange[] | readonly PlainRange[], + indexCache: IndexCache + callback: (token: Token) => unknown, +} + +export type RangeSetUpdate = { + add?: readonly Range[]; + sort?: boolean; + filter?: (from: number, to: number, value: T) => boolean; + filterFrom?: number; + filterTo?: number; +} + +export type OptionType = number; + +export type Options = T extends OptionType ? Record : never; + +export type FieldComponentRecord = { + [Field.TOGGLE]: ToggleComponent, + [Field.MULTI_TOGGLE]: ExtraButtonComponent, + [Field.DROPDOWN]: DropdownComponent, + [Field.COLOR]: ColorComponent, + [Field.TEXT]: TextComponent, + [Field.TEXT_AREA]: TextAreaComponent, + [Field.SLIDER]: SliderComponent +}; + +export type FieldValueRecord = { + [Field.TOGGLE]: boolean, + [Field.MULTI_TOGGLE]: OptionType, + [Field.DROPDOWN]: OptionType, + [Field.COLOR]: string, + [Field.TEXT]: string, + [Field.TEXT_AREA]: string, + [Field.SLIDER]: number +}; + +export type FieldSpecRecord = { + [Field.TOGGLE]: null, + [Field.MULTI_TOGGLE]: { options: Options }, + [Field.DROPDOWN]: { options: Options }, + [Field.COLOR]: null, + [Field.TEXT]: { placeholder?: string, filter?: RegExp }, + [Field.TEXT_AREA]: { placeholder?: string, filter?: RegExp, resizable?: boolean } + [Field.SLIDER]: { min: number, max: number, step: number } +}; + +export type FieldValue = FieldValueRecord[TField]; + +export type FieldComponent = FieldComponentRecord[TField]; + +export type FieldSpec = FieldSpecRecord[TField]; + +export type AbstractFieldDesc = { + type: TField, + record: TRecord extends Record ? TRecord : never, + key: TKey extends keyof TRecord ? TRecord[TKey] extends infer TValue ? TValue extends FieldValue ? TKey : never : never : never, + callback?: (component: FieldComponent, plugin: ExtendedMarkdownSyntax) => unknown, + spec: FieldSpec, + update?: { + deep?: boolean, + internal?: boolean + } +}; + +export type ToggleFieldDesc = AbstractFieldDesc; +export type MultiToggleFieldDesc = AbstractFieldDesc; +export type DropdownFieldDesc = AbstractFieldDesc; +export type ColorFieldDesc = AbstractFieldDesc; +export type TextFieldDesc = AbstractFieldDesc; +export type TextAreaFieldDesc = AbstractFieldDesc; +export type SliderFieldDesc = AbstractFieldDesc; + +export type FieldGroup = ( + ToggleFieldDesc | + MultiToggleFieldDesc | + DropdownFieldDesc | + ColorFieldDesc | + TextFieldDesc | + TextAreaFieldDesc | + SliderFieldDesc +)[]; + +export type SettingItem = { + name: string, + desc?: string, + fields?: FieldGroup, + preservedForTagSettings?: Format.HIGHLIGHT | Format.CUSTOM_SPAN | Format.FENCED_DIV +} + +export type SettingGroup = { + heading?: string, + desc?: string, + collapsible?: boolean, + id: string, + items: SettingItem[] +} + +export type SettingRoot = SettingGroup[]; + +export type TagSettingsSpec = { + type: TagSupportFormat; + addBtnPlaceholder: string; + nameFieldPlaceholder: string; + tagFieldPlaceholder: string; + tagFilter: RegExp; + onAdd?: (settingTab: ExtendedSettingTab, tagSettingItem: Setting, newConfig: TagConfig) => unknown; + onMove?: (settingTab: ExtendedSettingTab, oldIndex: number, newIndex: number) => unknown; + onResetted?: (settingTab: ExtendedSettingTab) => unknown; + onDelete?: (settingTab: ExtendedSettingTab, deletedIndex: number) => unknown; + onTagChange?: (settingTab: ExtendedSettingTab, changedConfig: TagConfig, index: number) => unknown; +} + +export type FormattingSpec = { + range: SelectionRange, + type: Format, + state: EditorState, + tokenIndexMap: number[], + tokens: TokenGroup, + remadeTokenIndexes: Partial>, + preventMenu?: boolean, + precise?: boolean +} + +export interface CtxMenuCommand extends Command { + icon: string; + ctxMenuTitle: string; + editorCallback: (editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => unknown; + [data: string]: unknown; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ITagMenu extends TagMenu {} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index afabd97..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,345 +0,0 @@ -import type { SyntaxNode, Tree } from "@lezer/common"; -import type { Format, MarkdownViewMode, TokenLevel, Delimiter, TokenStatus, Field, DisplayBehaviour } from "src/enums"; -import { Line, RangeSet, RangeValue, Text, Range, SelectionRange, EditorState } from "@codemirror/state" -import { Decoration, DecorationSet } from "@codemirror/view"; -import { ColorComponent, Command, DropdownComponent, Editor, ExtraButtonComponent, IconName, MarkdownFileInfo, MarkdownView, Setting, SliderComponent, TextAreaComponent, TextComponent, ToggleComponent } from "obsidian"; -import ExtendedMarkdownSyntax from "main"; -import { TagMenu } from "src/editor-mode/ui-components"; -import { ExtendedSettingTab } from "src/settings/interface"; - -/** Token interface */ -export type Token = { - /** Format type */ - type: Format, // 3-bit - /** Either block or inline */ - level: TokenLevel, // 1 bit - /** Token status */ - status: TokenStatus, // 2-bit - /** Start offset */ - from: number, // 16-bit - /** End offset */ - to: number, // 16-bit - /** Length of its opening delimiter. */ - openLen: number, - /** Length of its closing delimiter if any, otherwise it should be zero. */ - closeLen: number, - /** Length of its tag if any, otherwise it should be zero. */ - tagLen: number, - /** Whether the range of tag is covered by the range of content or not. */ - tagAsContent: boolean; - /** The tag was considered as invalid when it does not satisfy its rule. */ - validTag: boolean; - /** - * Closed by blank line instead of closing delimiter. If `closeLen` is 0, the - * token's type isn't space-restricted format, and `closedByBlankLine` is `false`, - * then indicates that the token definitely was located in the end of the document - * (`Token.to == Doc.length`). - */ - closedByBlankLine: boolean; -} - -/** Accumulation of `ChangeSet` generated by the `composeChange` function. */ -export type ChangedRange = { - /** - * Start offset of a changed range, measured from - * the text both before and after the change. - */ - from: number, - /** End offset of a changed range, measured from the doc before the change. */ - initTo: number, - /** End offset of a changed range, measured from the doc after the change. */ - changedTo: number; - /** - * Length difference. Can be negative value, indicating that replaced text - * is longer than the substitute. - */ - length: number; -} - -/** Describe the rule that has to be satisfied by the delimiter */ -export type DelimSpec = { - /** Should be single character */ - char: string, - /** Delimiter length */ - length: number, - /** - * If true, then delimiter length must be the same as - * in the predetermined rule. Otherwise, the defined length - * act as minimum length. - */ - exactLen: boolean, - /** Should be `Delimiter.OPEN` or `Delimiter.CLOSE` */ - role: Delimiter, - /** - * When true, any space after the opening delimiter or before the closing - * one doesn't make the delimiter invalid. Default is false. - */ - allowSpaceOnDelim?: boolean -} - -/** - * Used for (re)configuring the state, especially in - * the case of document or tree change - */ -export type StateConfig = { - doc: Text, - tree: Tree, - offset: number, - settings: PluginSettings, - maxLine?: number -} - -/** - * Region defined here as a `PlainRange` collection arranged in an array. - * Each range inside should be sorted in order according to its `from`. - */ -export type Region = PlainRange[]; - -export type NodeSpec = { - node: SyntaxNode, - type: string -} - -export type InlineFormat = Extract; - -export type BlockFormat = Exclude; - -export type NonBuiltinInlineFormat = Exclude; - -export type TokenGroup = Token[]; - -export type ColorConfig = { - tag: string, - name: string, - color: string, - showInMenu: boolean -}; - -export type TagConfig = { - tag: string, - name: string, - showInMenu: boolean -}; - -export type PluginSettings = { - // General - insertion: MarkdownViewMode; - spoiler: MarkdownViewMode; - superscript: MarkdownViewMode; - subscript: MarkdownViewMode; - customHighlight: MarkdownViewMode; - customSpan: MarkdownViewMode; - fencedDiv: MarkdownViewMode; - - // Tag behavior - hlTagDisplayBehaviour: DisplayBehaviour; - spanTagDisplayBehaviour: DisplayBehaviour; - showHlTagInPreviewMode: boolean; - showSpanTagInPreviewMode: boolean; - alwaysShowFencedDivTag: MarkdownViewMode; - - // Formatting - tidyFormatting: boolean; - openTagMenuAfterFormat: boolean; - - // Custom highlight - colorButton: boolean; - showAccentColor: boolean; - showDefaultColor: boolean; - showRemoveColor: boolean; - lightModeHlOpacity: number; - darkModeHlOpacity: number; - colorConfigs: ColorConfig[]; - - // Custom span - showDefaultSpanTag: boolean; - showRemoveSpanTag: boolean; - predefinedSpanTag: TagConfig[]; - - // Fenced div - showDefaultDivTag: boolean; - showRemoveDivTag: boolean; - predefinedDivTag: TagConfig[]; - - // Others - editorEscape: boolean; - decoratePDF: boolean; -} - -export type PlainRange = { from: number, to: number }; - -export type InlineFormatRule = { - char: string, - length: number, - exactLen: boolean, - allowSpace: boolean, - mustBeClosed: boolean, - class: string, - getEl: (cls?: string) => Element, - builtin: boolean -} - -export type BlockFormatRule = { - char: string, - length: number, - exactLen: boolean, - class: string, -} - -export type FormatRule = InlineFormatRule & BlockFormatRule; - -export interface TokenDecoration extends Decoration { - spec: { - class: string, - token: Token - }; -} - -export type TokenDecorationSet = RangeSet; - -export type IndexCache = { number: number } - -export type DecoSetCollection = Record<"inlineSet" | "blockSet" | "omittedSet" | "colorBtnSet" | "revealedSpoilerSet", DecorationSet>; - -export type IterLineSpec = { - doc: Text, - fromLn: number, - toLn?: number, - callback: (line: Line, doc: Text) => boolean | void -} - -export type IterTokenGroupSpec = { - tokens: TokenGroup, - ranges: PlainRange[] | readonly PlainRange[], - indexCache: IndexCache - callback: (token: Token) => unknown, -} - -export type RangeSetUpdate = { - add?: readonly Range[]; - sort?: boolean; - filter?: (from: number, to: number, value: T) => boolean; - filterFrom?: number; - filterTo?: number; -} - -export type OptionType = number; - -export type Options = T extends OptionType ? Record : never; - -export type FieldComponentRecord = { - [Field.TOGGLE]: ToggleComponent, - [Field.MULTI_TOGGLE]: ExtraButtonComponent, - [Field.DROPDOWN]: DropdownComponent, - [Field.COLOR]: ColorComponent, - [Field.TEXT]: TextComponent, - [Field.TEXT_AREA]: TextAreaComponent, - [Field.SLIDER]: SliderComponent -}; - -export type FieldValueRecord = { - [Field.TOGGLE]: boolean, - [Field.MULTI_TOGGLE]: OptionType, - [Field.DROPDOWN]: OptionType, - [Field.COLOR]: string, - [Field.TEXT]: string, - [Field.TEXT_AREA]: string, - [Field.SLIDER]: number -}; - -export type FieldSpecRecord = { - [Field.TOGGLE]: null, - [Field.MULTI_TOGGLE]: { options: Options }, - [Field.DROPDOWN]: { options: Options }, - [Field.COLOR]: null, - [Field.TEXT]: { placeholder?: string, filter?: RegExp }, - [Field.TEXT_AREA]: { placeholder?: string, filter?: RegExp, resizable?: boolean } - [Field.SLIDER]: { min: number, max: number, step: number } -}; - -export type FieldValue = FieldValueRecord[TField]; - -export type FieldComponent = FieldComponentRecord[TField]; - -export type FieldSpec = FieldSpecRecord[TField]; - -export type AbstractFieldDesc = { - type: TField, - record: TRecord extends Record ? TRecord : never, - key: TKey extends keyof TRecord ? TRecord[TKey] extends infer TValue ? TValue extends FieldValue ? TKey : never : never : never, - callback?: (component: FieldComponent, plugin: ExtendedMarkdownSyntax) => unknown, - spec: FieldSpec, - update?: { - colors?: boolean, - internal?: boolean - } -}; - -export type ToggleFieldDesc = AbstractFieldDesc; -export type MultiToggleFieldDesc = AbstractFieldDesc; -export type DropdownFieldDesc = AbstractFieldDesc; -export type ColorFieldDesc = AbstractFieldDesc; -export type TextFieldDesc = AbstractFieldDesc; -export type TextAreaFieldDesc = AbstractFieldDesc; -export type SliderFieldDesc = AbstractFieldDesc; - -export type FieldGroup = ( - ToggleFieldDesc | - MultiToggleFieldDesc | - DropdownFieldDesc | - ColorFieldDesc | - TextFieldDesc | - TextAreaFieldDesc | - SliderFieldDesc -)[]; - -export type SettingItem = { - name: string, - desc?: string, - fields?: FieldGroup, - preservedForTagSettings?: Format.HIGHLIGHT | Format.CUSTOM_SPAN | Format.FENCED_DIV -}; - -export type SettingGroup = { - heading?: string, - desc?: string, - collapsible?: boolean, - id: string, - items: SettingItem[] -}; - -export type SettingRoot = SettingGroup[]; - -export type TagSettingsSpec = { - type: Format.HIGHLIGHT | Format.CUSTOM_SPAN | Format.FENCED_DIV; - addBtnPlaceholder: string; - nameFieldPlaceholder: string; - tagFieldPlaceholder: string; - tagFilter: RegExp; - onAdd?: (settingTab: ExtendedSettingTab, tagSettingItem: Setting, newConfig: TagConfig) => unknown; - onMove?: (settingTab: ExtendedSettingTab, oldIndex: number, newIndex: number) => unknown; - onResetted?: (settingTab: ExtendedSettingTab) => unknown; - onDelete?: (settingTab: ExtendedSettingTab, deletedIndex: number) => unknown; - onTagChange?: (settingTab: ExtendedSettingTab, changedConfig: TagConfig, index: number) => unknown; -} - -export type FormattingSpec = { - range: SelectionRange, - type: Format, - state: EditorState, - tokenIndexMap: number[], - tokens: TokenGroup, - remadeTokenIndexes: Partial>, - preventMenu?: boolean, - precise?: boolean -}; - -export interface CtxMenuCommand extends Command { - icon: string; - ctxMenuTitle: string; - editorCallback: (editor: Editor, ctx: MarkdownView | MarkdownFileInfo) => unknown; - [data: string]: unknown; -} - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ITagMenu extends TagMenu {}; \ No newline at end of file diff --git a/src/utils/color-utils.ts b/src/utils/color-utils.ts new file mode 100644 index 0000000..d8b782b --- /dev/null +++ b/src/utils/color-utils.ts @@ -0,0 +1,42 @@ +import ExtendedMarkdownSyntax from "main"; +import { StyleSheetHandler } from "src/stylesheet"; +import { ColorConfig } from "src/types"; + +export const PREDEFINED_COLOR_TAGS = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink"] as const; + +export function getDefaultColorConfigs() { + let configs: ColorConfig[] = []; + for (let i = 0; i < PREDEFINED_COLOR_TAGS.length; i++) { + let tag = PREDEFINED_COLOR_TAGS[i], + name = tag[0].toUpperCase() + tag.slice(1), + color = takeBuiltinColor(tag) ?? "#ffffff"; + configs.push({ tag, name, color, showInMenu: true }); + } + return configs; +} + +export function convertColorConfigToCSSRule(config: ColorConfig) { + let selector = `.cm-custom-highlight-${config.tag}, .markdown-rendered mark.custom-highlight-${config.tag}, .ems-menu-item.ems-highlight-${config.tag}>.menu-item-title`, + prop = "background-color", + color = config.color; + return `${selector}{${prop}:color(from ${color} srgb r g b/var(--hl-opacity));}` +} + +export function createColorConfig(tag: string, color: string, name: string, showInMenu = true): ColorConfig { + return { tag, color, name, showInMenu } +} + + +export function takeBuiltinColor(color: string) { + return document.body.computedStyleMap().get(`--color-${color}`)?.toString(); +} + +export function buildColorsStyleSheet(plugin: ExtendedMarkdownSyntax): StyleSheetHandler { + let colorsHandler = new StyleSheetHandler(plugin), + configs = plugin.settings.colorConfigs; + for (let i = 0; i < configs.length; i++) { + let ruleStr = convertColorConfigToCSSRule(configs[i]); + colorsHandler.insert(ruleStr); + } + return colorsHandler; +} \ No newline at end of file diff --git a/src/utils/decToHex.ts b/src/utils/decToHex.ts deleted file mode 100644 index e102c04..0000000 --- a/src/utils/decToHex.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function decToHex(value: number, minDigit = 2) { - return value.toString(16).padStart(minDigit, "0"); -} \ No newline at end of file diff --git a/src/utils/deepCopy.ts b/src/utils/deepCopy.ts deleted file mode 100644 index a59cdc1..0000000 --- a/src/utils/deepCopy.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function deepCopy(obj: T) { - return JSON.parse(JSON.stringify(obj)) as T; -} \ No newline at end of file diff --git a/src/utils/getTheme.ts b/src/utils/getTheme.ts deleted file mode 100644 index 98eafcb..0000000 --- a/src/utils/getTheme.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function getTheme() { - if (document.body.hasClass("theme-dark")) { return "dark" } - if (document.body.hasClass("theme-light")) { return "light" } - return null; -} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index 824c2d8..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./trimTag"; -export * from "./deepCopy"; -export * from "./moveElement"; -export * from "./getTheme"; -export * from "./decToHex"; \ No newline at end of file diff --git a/src/utils/moveElement.ts b/src/utils/moveElement.ts deleted file mode 100644 index 393f67c..0000000 --- a/src/utils/moveElement.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function moveElement(el: Element, direction: 1 | -1) { - let parentEl = el.parentElement; - if (!parentEl) { return false } - if (direction > 0) { - let nextEl = el.nextElementSibling; - parentEl.insertAfter(el, nextEl); - } else if (direction < 0) { - let prevEl = el.previousElementSibling; - parentEl.insertBefore(el, prevEl); - } -} \ No newline at end of file diff --git a/src/utils/trimTag.ts b/src/utils/trimTag.ts deleted file mode 100644 index 0325262..0000000 --- a/src/utils/trimTag.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function trimTag(tagStr: string) { - return tagStr - .trim() - .replaceAll(/\s{2,}/g, " "); -} \ No newline at end of file diff --git a/styles.css b/styles.css index 9213d00..f9620f9 100644 --- a/styles.css +++ b/styles.css @@ -1,164 +1,164 @@ .cm-ins { - text-decoration: underline; + text-decoration: underline; } .cm-sup { - vertical-align: top; - font-size: var(--font-smallest); + vertical-align: top; + font-size: var(--font-smallest); } .cm-sub { - vertical-align: bottom; - font-size: var(--font-smallest); + vertical-align: bottom; + font-size: var(--font-smallest); } .cm-custom-highlight { - padding-inline: 2px; - border-radius: 2px; - background-color: rgba(var(--text-highlight-bg-rgb), var(--hl-opacity)); - & span.cm-highlight { - background-color: transparent; - } + padding-inline: 2px; + border-radius: 2px; + background-color: rgba(var(--text-highlight-bg-rgb), var(--hl-opacity)); + & span.cm-highlight { + background-color: transparent; + } } .cm-custom-highlight-accent, .markdown-rendered mark.custom-highlight-accent, .ems-menu-item.ems-highlight-accent>.menu-item-title { - background-color: hsl(var(--color-accent-hsl), var(--hl-opacity)); + background-color: hsl(var(--color-accent-hsl), var(--hl-opacity)); } .cm-custom-highlight-default, .markdown-rendered mark.custom-highlight-default, .ems-menu-item.ems-highlight-default>.menu-item-title { - background-color: rgba(var(--text-highlight-bg-rgb), var(--hl-opacity)); + background-color: rgba(var(--text-highlight-bg-rgb), var(--hl-opacity)); } mark { - padding-inline: 2px; - border-radius: 2px; + padding-inline: 2px; + border-radius: 2px; } .cm-highlight-color-btn { - margin-inline: 2px; - width: 12px; - display: inline-block; - height: 12px; - border: solid 1px; - border-color: #ffffff44; - vertical-align: middle; - border-radius: 2px; - background-color: inherit; + margin-inline: 2px; + width: 12px; + display: inline-block; + height: 12px; + border: solid 1px; + border-color: #ffffff44; + vertical-align: middle; + border-radius: 2px; + background-color: inherit; } .ems-menu.ems-tag-menu { - &.ems-color-menu .menu-item { - padding-top: 3px; - padding-bottom: 3px; - & > .menu-item-title { - /* flex: 0 1 auto; */ - padding-inline: 2px; - padding-block: 1px; - border-radius: 2px; - } - } - .ems-menu-item.ems-remove { - color: rgb(var(--color-red-rgb)); - } + &.ems-color-menu .menu-item { + padding-top: 3px; + padding-bottom: 3px; + & > .menu-item-title { + /* flex: 0 1 auto; */ + padding-inline: 2px; + padding-block: 1px; + border-radius: 2px; + } + } + .ems-menu-item.ems-remove { + color: rgb(var(--color-red-rgb)); + } } .menu.custom-span-tags-modal .menu-item.menu-item-custom-span-remove { - color: rgb(var(--color-red-rgb)); + color: rgb(var(--color-red-rgb)); } .cm-spoiler { - border-radius: 2px; - transition-duration: 0.1s; - transition-property: color; - .theme-dark & { - background-color: var(--color-base-25); - } - .theme-light & { - background-color: var(--color-base-40); - } - .is-live-preview &:not(:is(:hover), :has(.cm-spoiler-revealed), .cm-delim) { - color: transparent; - } + border-radius: 2px; + transition-duration: 0.1s; + transition-property: color; + .theme-dark & { + background-color: var(--color-base-25); + } + .theme-light & { + background-color: var(--color-base-40); + } + .is-live-preview &:not(:is(:hover), :has(.cm-spoiler-revealed), .cm-delim) { + color: transparent; + } } span.spoiler { - border-radius: 2px; - transition-duration: 0.1s; - transition-property: color; - .theme-dark & { - background-color: var(--color-base-25); - } - .theme-light & { - background-color: var(--color-base-40); - } - &:not(.spoiler-revealed) { - color: transparent; - } + border-radius: 2px; + transition-duration: 0.1s; + transition-property: color; + .theme-dark & { + background-color: var(--color-base-25); + } + .theme-light & { + background-color: var(--color-base-40); + } + &:not(.spoiler-revealed) { + color: transparent; + } } .cm-line.cm-fenced-div.cm-fenced-div-start { - color: var(--text-faint); + color: var(--text-faint); } .setting-item.setting-group.ems-setting-group { - display: block; - padding-bottom: 0; - padding-top: 0; - &>.setting-item:first-child { - padding-top: 0.75em; - } + display: block; + padding-bottom: 0; + padding-top: 0; + &>.setting-item:first-child { + padding-top: 0.75em; + } } .setting-item.collapsed { - display: none !important; + display: none !important; } .setting-item.ems-setting-item.ems-tag-config { - margin-inline-end: 0; - transition: background-color 150ms cubic-bezier(0.2, 0, 0, 1); - .clickable-icon.extra-setting-button.ems-button.ems-button-drag-handle { - cursor: grab; - } - .setting-item-info { - display: none; - } - .setting-item-control { - .ems-field.ems-field-name, .ems-field.ems-field-tag { - flex: auto; - } - } - &.sortable-chosen { - background-color: hsla(var(--color-accent-hsl), 0.1); - cursor: grabbing; - .clickable-icon.extra-setting-button.ems-button.ems-button-drag-handle { - cursor: grabbing; - background-color: transparent; - } - } + margin-inline-end: 0; + transition: background-color 150ms cubic-bezier(0.2, 0, 0, 1); + .clickable-icon.extra-setting-button.ems-button.ems-button-drag-handle { + cursor: grab; + } + .setting-item-info { + display: none; + } + .setting-item-control { + .ems-field.ems-field-name, .ems-field.ems-field-tag { + flex: auto; + } + } + &.sortable-chosen { + background-color: hsla(var(--color-accent-hsl), 0.1); + cursor: grabbing; + .clickable-icon.extra-setting-button.ems-button.ems-button-drag-handle { + cursor: grabbing; + background-color: transparent; + } + } } .clickable-icon.extra-setting-button.collapse-button>svg { - transition-property: transform; - transition-timing-function: cubic-bezier(0.05, 0.7, 0.1, 1); - transition-duration: 0.2s; + transition-property: transform; + transition-timing-function: cubic-bezier(0.05, 0.7, 0.1, 1); + transition-duration: 0.2s; } .clickable-icon.extra-setting-button.collapse-button.collapsing>svg { - transform: rotate(-90deg); + transform: rotate(-90deg); } .clickable-icon.extra-setting-button.ems-button.ems-button-delete { - color: var(--color-red); + color: var(--color-red); } .clickable-icon.extra-setting-button.ems-button.ems-button-drag-handle { - cursor: grab; - &:active { - cursor: grabbing; - } + cursor: grab; + &:active { + cursor: grabbing; + } } input.ems-field.ems-field-tag { - font-family: var(--font-monospace-override); + font-family: var(--font-monospace-override); } .ems-setting-item-dragged { - visibility: hidden; + visibility: hidden; } \ No newline at end of file