refactor: reorganize source codes

Most of the codes were organized, with the addition of replacing spaces with tabs, removing unused functions and snippets, etc.
This commit is contained in:
kotaindah55 2025-04-06 23:00:24 +02:00
parent 4f5e62bc0a
commit 18b9e0f8f3
197 changed files with 5820 additions and 5751 deletions

View file

@ -9,47 +9,47 @@ import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const compat = new FlatCompat({ const compat = new FlatCompat({
baseDirectory: __dirname, baseDirectory: __dirname,
recommendedConfig: js.configs.recommended, recommendedConfig: js.configs.recommended,
allConfig: js.configs.all allConfig: js.configs.all
}); });
export default [{ export default [{
ignores: [ ignores: [
"**/node_modules/", "**/node_modules/",
"**/main.js", "**/main.js",
"**/.deprecated/", "**/.deprecated/",
"**/esbuild.config.mjs", "**/esbuild.config.mjs",
"**/eslint.config.mjs" "**/eslint.config.mjs"
], ],
}, ...compat.extends( }, ...compat.extends(
"eslint:recommended", "eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended",
), { ), {
plugins: { plugins: {
"@typescript-eslint": typescriptEslint, "@typescript-eslint": typescriptEslint,
}, },
languageOptions: { languageOptions: {
globals: { globals: {
...globals.node, ...globals.node,
}, },
parser: tsParser, parser: tsParser,
ecmaVersion: 6, ecmaVersion: 6,
sourceType: "module", sourceType: "module",
}, },
rules: { rules: {
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "@typescript-eslint/no-unused-vars": ["error", {
args: "none", args: "none",
}], }],
"@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-empty-function": "off",
"prefer-const": "off", "prefer-const": "off",
"no-cond-assign": "off", "no-cond-assign": "off",
}, },
}]; }];

214
main.ts
View file

@ -1,134 +1,88 @@
import { MarkdownView, Plugin, Command } from "obsidian"; import { MarkdownView, Plugin, Command } from "obsidian";
// import { drawSelection } from "@codemirror/view"; import { PluginSettings } from "src/types";
import { PreviewExtendedSyntax } from "src/preview-mode/post-processor"; import { DEFAULT_SETTINGS } from "src/settings/configs";
import { ColorConfig, PluginSettings, TagConfig } from "src/types"; import { ExtendedSettingTab } from "src/settings/ui/setting-tab";
import { DEFAULT_SETTINGS } from "src/settings"; import { configureDelimLookup } from "src/format-configs/format-utils"
import { ExtendedSettingTab } from "src/settings/interface"; import { StyleSheetHandler } from "src/stylesheet";
import { pluginFacet, settingsFacet } from "src/editor-mode/facets"; import { Theme } from "src/enums";
import { deepCopy } from "src/utils"; import { editorCommands } from "src/editor-mode/formatting/commands";
import { configureDelimLookup, reconfigureDelimLookup, supportTag } from "src/format-configs/utils" import { extendEditorCtxMenu } from "src/editor-mode/ui-components";
import { appFacet } from "src/editor-mode/facets"; import { TagManager } from "src/tag-manager";
import { editorExtendedSyntax } from "src/editor-mode/extensions"; import { ReadingModeSyntaxExtender } from "src/preview-mode/post-processor/core";
import { refresherAnnot } from "src/editor-mode/annotations"; import { editorSyntaxExtender, refreshCall } from "src/editor-mode/cm-extension";
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";
export default class ExtendedMarkdownSyntax extends Plugin { export default class ExtendedMarkdownSyntax extends Plugin {
settings: PluginSettings; settings: PluginSettings;
colorsHandler: StyleSheetHandler; opacityHandler: StyleSheetHandler;
opacityHandler: StyleSheetHandler; tagManager: TagManager;
async onload() {
await this.loadSettings(); async onload() {
this.addSettingTab(new ExtendedSettingTab(this.app, this)); await this.loadSettings();
configureDelimLookup(this.settings); this.addSettingTab(new ExtendedSettingTab(this.app, this));
this.registerEditorExtension([
appFacet.of(this.app), configureDelimLookup(this.settings);
pluginFacet.of(this), this.registerEditorExtension(editorSyntaxExtender(this));
settingsFacet.of(this.settings), this.registerMarkdownPostProcessor(new ReadingModeSyntaxExtender(this.settings).postProcess);
editorExtendedSyntax
]); this.tagManager = new TagManager(this);
this.registerMarkdownPostProcessor(new PreviewExtendedSyntax(this.settings).postProcess); this.opacityHandler = new StyleSheetHandler(this);
this.colorsHandler = new StyleSheetHandler(this); this.opacityHandler.insert(`body.theme-light{--hl-opacity:${this.settings.lightModeHlOpacity}}`);
this.opacityHandler = new StyleSheetHandler(this); this.opacityHandler.insert(`body.theme-dark{--hl-opacity:${this.settings.darkModeHlOpacity}}`);
this.buildColorsStyleSheet();
this.opacityHandler.insert(`body.theme-light{--hl-opacity:${this.settings.lightModeHlOpacity}}`); this.registerCommands(editorCommands);
this.opacityHandler.insert(`body.theme-dark{--hl-opacity:${this.settings.darkModeHlOpacity}}`); this.extendEditorCtxMenu();
this.registerCommands(editorCommands);
this.extendEditorCtxMenu(); console.log("Load Extended Markdown Syntax");
console.log("Load Extended Markdown Syntax"); }
}
async loadSettings() { async loadSettings() {
this.settings = Object.assign({}, deepCopy(DEFAULT_SETTINGS), await this.loadData()); this.settings = Object.assign({}, structuredClone(DEFAULT_SETTINGS), await this.loadData());
} }
async saveSettings() {
await this.saveData(this.settings); async saveSettings() {
} await this.saveData(this.settings);
onunload(): void { }
this.colorsHandler.destroy();
this.opacityHandler.destroy(); onunload(): void {
console.log("Unload Extended Markdown Syntax"); this.tagManager.colorsHandler.destroy();
} this.opacityHandler.destroy();
reconfigureDelimLookup() { console.log("Unload Extended Markdown Syntax");
reconfigureDelimLookup(this.settings); }
}
/** Get the settings change effect without reload the whole app. */ reconfigureDelimLookup() {
refreshMarkdownView() { configureDelimLookup(this.settings);
this.app.workspace.iterateAllLeaves(leaf => { }
let view = leaf.view;
if (view instanceof MarkdownView) { /** Get the settings change effect without reload the whole app. */
let cmView = view.editor.cm; refreshMarkdownView(deep = true) {
cmView.dispatch({ this.app.workspace.getLeavesOfType("markdown").forEach(leaf => {
annotations: refresherAnnot.of(true), if (leaf.view instanceof MarkdownView) {
}); let cmView = leaf.view.editor.cm;
view.previewMode.rerender(true); cmView.dispatch({
} annotations: refreshCall.of({ deep }),
}); });
} leaf.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); setHlOpacity(value: number, theme: Theme) {
if (callback) { callback(colorConfigs[i], colorConfigs) } let selector = theme == Theme.LIGHT
} ? "body.theme-light"
} : "body.theme-dark",
setHlOpacity(value: number, theme: Theme) { property = "--hl-opacity",
let selector = theme == Theme.LIGHT // The index of light mode opacity is 0, and dark mode opacity is 1.
? "body.theme-light" ruleIndex = theme;
: "body.theme-dark", this.opacityHandler.replace(`${selector}{${property}:${value}}`, ruleIndex);
property = "--hl-opacity", }
// The index of light mode opacity is 0, and dark mode opacity is 1.
ruleIndex = theme; registerCommands(commands: Command[]) {
this.opacityHandler.replace(`${selector}{${property}:${value}}`, ruleIndex); commands.forEach(cmd => this.addCommand(cmd));
} }
rebuildColorsStyleSheet(callback?: (colorConfig: ColorConfig, colorConfigs: ColorConfig[]) => unknown) {
this.colorsHandler.removeAll(); extendEditorCtxMenu() {
this.buildColorsStyleSheet(callback); this.registerEvent(this.app.workspace.on("editor-menu", (menu, editor, ctx) => {
} extendEditorCtxMenu(menu, editor, ctx);
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);
}));
}
} }

View file

@ -1 +0,0 @@
export const PREDEFINED_COLOR_TAGS = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink"] as const;

View file

@ -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));}`
}

View file

@ -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 }
}

View file

@ -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;
}

View file

@ -1,5 +0,0 @@
export * from "./convertColorConfigToCSSRule";
export * from "./takeBuiltinColor";
export * from "./getDefaultColorConfigs";
export * from "./createColorConfig";
export * from "./PREDEFINED_COLOR_TAGS";

View file

@ -1,3 +0,0 @@
export function takeBuiltinColor(color: string) {
return document.body.computedStyleMap().get(`--color-${color}`)?.toString();
}

View file

@ -1 +0,0 @@
export * from "./refresherAnnot";

View file

@ -1,3 +0,0 @@
import { Annotation } from "@codemirror/state";
export let refresherAnnot = Annotation.define<true>();

View file

@ -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<unknown>): effect is StateEffect<BuiltinParserState> {
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<unknown>) => {
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<RefreshDesc>();
export const instancesStore = Facet.define<ExtendedMarkdownSyntax, InternalInstances>({
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<TagMenuOptionCaches, TagMenuOptionCaches>({
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];
}

View file

@ -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
];

View file

@ -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();
}
}
}

View file

@ -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<T extends RangeValue> {
add?: readonly Range<T>[];
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<Decoration>[] = [],
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<Decoration>[] = [];
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<Decoration> = 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<Decoration> = {
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<Decoration>[] {
let tokens = this._getReparsedBlockTokens(),
ranges: Range<Decoration>[] = [];
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<TokenDecoration>[] = [],
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<TokenDecoration>[] = [],
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<Decoration>[] = [];
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<Decoration>[] = [];
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;
}
}

View file

@ -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<TokenDecoration>[] = [],
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<TokenDecoration>[] = [];
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<TokenDecoration>[] = [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<Decoration>[] = [];
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<Decoration>[] = [];
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;
}
}

View file

@ -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() {}
}

View file

@ -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<Decoration>[] = [],
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<Decoration>[] = [];
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);
}
}

View file

@ -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<Decoration> = 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<Decoration> = {
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<Decoration>[] = [];
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;
}
}

View file

@ -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 = [];
}
}

View file

@ -1,5 +0,0 @@
export * from "./DecorationBuilder";
export * from "./DelimOmitter";
export * from "./TokensBuffer";
export * from "./LineBreakReplacer";
export * from "./DecorationHolder";

View file

@ -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<string, unknown>): Decoration {
let spec: Parameters<typeof Decoration.mark>[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<TokenDecoration> {
return (Decoration
.mark({ class: cls, token }) as TokenDecoration)
.range(token.from, token.to);
}
export function createLineDecoRange(token: Token, cls: string, line: Line): Range<TokenDecoration> {
return (Decoration
.line({ class: cls, token }) as TokenDecoration)
.range(line.from);
}

View file

@ -1,5 +0,0 @@
import { Decoration } from "@codemirror/view";
export const REVEALED_SPOILER_DECO = Decoration.mark({
class: "cm-spoiler-revealed",
});

View file

@ -1,15 +0,0 @@
import { Decoration } from "@codemirror/view";
import { Format } from "src/enums";
export function createHlDeco(color: string, data?: Record<string, unknown>) {
let spec: Parameters<typeof Decoration.mark>[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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -1,3 +0,0 @@
export * from "./createHlDeco";
export * from "./createInlineDecoRange";
export * from "./createLineDecoRange";

View file

@ -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<Decoration> {
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<Decoration> {
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<Decoration> {
return Decoration.replace({
widget: new LineBreak(offset),
}).range(offset - 1, offset);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -1,3 +0,0 @@
export * from "./HiddenWidget";
export * from "./ColorButton";
export * from "./LineBreak";

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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";

View file

@ -1,5 +0,0 @@
import { Line } from "@codemirror/state";
export function isBlankLine(line: Line): boolean {
return !line.text.trimEnd();
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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 }
}
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -1,5 +0,0 @@
export * from "./checkRefreshed";
export * from "./getActiveCanvasNodeCoords";
export * from "./getActiveCanvasEditor";
export * from "./isCanvas";
export * from "./isEditorModeChanged";

View file

@ -1,5 +0,0 @@
import { App } from "obsidian";
export function isCanvas(app: App) {
return app.workspace.getMostRecentLeaf()?.view.getViewType() == "canvas";
}

View file

@ -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;
}

View file

@ -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)
];

View file

@ -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
});

View file

@ -1,9 +0,0 @@
import { Facet } from "@codemirror/state";
import { App } from "obsidian";
export const appFacet = Facet.define<App, App>({
combine(value) {
return value[0];
},
static: true
});

View file

@ -1,4 +0,0 @@
export * from "./settingsFacet";
export * from "./appFacet";
export * from "./pluginFacet";
export * from "./activityFacet";

View file

@ -1,9 +0,0 @@
import { Facet } from "@codemirror/state";
import ExtendedMarkdownSyntax from "main";
export let pluginFacet = Facet.define<ExtendedMarkdownSyntax, ExtendedMarkdownSyntax>({
combine(value) {
return value[0];
},
static: true
});

View file

@ -1,9 +0,0 @@
import { Facet } from "@codemirror/state";
import { PluginSettings } from "src/types";
export const settingsFacet = Facet.define<PluginSettings, PluginSettings>({
combine(value) {
return value[0];
},
static: true
});

View file

@ -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();
}
}

View file

@ -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<Record<number, { shift: number }>> = {};
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 };
}
}

View file

@ -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);

View file

@ -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<Record<number, { shift: number }>> = {};
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();
}
}

View file

@ -1 +0,0 @@
export * from "./isSurroundedByDelimiter";

View file

@ -1,3 +0,0 @@
export function isSurroundedByDelimiter(str: string, delimStr: string) {
return str.startsWith(delimStr) && str.endsWith(delimStr);
}

View file

@ -1,2 +0,0 @@
export * from "./Formatter";
export * from "./FormatterState";

View file

@ -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;
}
}
}

View file

@ -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, Region> = {
[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<Record<TokenLevel, number[]>>[] = [];
/**
* All selection-based decorations, e.g. omitted delimiters, that are
* associated with these tokens should be redrawn.
*/
changedRegions: Record<TokenLevel, Region> = {
[TokenLevel.INLINE]: [],
[TokenLevel.BLOCK]: []
};
/**
* Collection of offset ranges, to be used as filter ranges for the
* previously drawn selection-based decorations.
*/
filterRegions: Record<TokenLevel, Region> = {
[TokenLevel.INLINE]: [],
[TokenLevel.BLOCK]: []
};
indexCaches: Record<TokenLevel | "selection", IndexCache> = {
[TokenLevel.INLINE]: { number: 0 },
[TokenLevel.BLOCK]: { number: 0 },
selection: { number: 0 }
};
handlers: Record<TokenLevel, ((token: Token, index: number, tokens: TokenGroup, inSelection: boolean) => 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<Decoration>.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;
}
}
}

View file

@ -1,2 +0,0 @@
export * from "./SelectionObserver";
export * from "./ActivityRecorder";

View file

@ -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<TokenLevel, { from: number, initTo: number, changedTo: number }>;
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;
}
}
}

View file

@ -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() }
}
}

View file

@ -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<Record<Format, Token>> = {};
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 = {};
}
}

View file

@ -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;
}
}

View file

@ -1,3 +0,0 @@
import { InlineFormat } from "src/types";
export const EditorDelimLookup: Record<string, InlineFormat> = {}

View file

@ -1,80 +0,0 @@
import { SyntaxNode } from "@lezer/common";
export const ShifterNodeConfigs: Record<string, { query: string, getOpen: (node: SyntaxNode) => 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;
}
}
}

View file

@ -1,2 +0,0 @@
export * from "./ShifterNodeConfigs";
export * from "./EditorDelimLookup";

View file

@ -1,4 +0,0 @@
export * from "./Parser";
export * from "./ParserState";
export * from "./Tokenizer";
export * from "./TokenQueue";

View file

@ -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!
};
}

View file

@ -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)");
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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";

View file

@ -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
);
}

View file

@ -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 }
}

View file

@ -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)");
}

View file

@ -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 }
}

View file

@ -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 };
}

View file

@ -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);
})();

View file

@ -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 };
}

View file

@ -1,5 +0,0 @@
export * from "./iterTokenGroup";
export * from "./moveTokenIndexCache";
export * from "./provideTokenRanges";
export * from "./isToken";
export * from "./getTagRange";

View file

@ -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;
}

View file

@ -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++;
}
}

View file

@ -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;
}

View file

@ -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 }
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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) }
}

View file

@ -1,3 +0,0 @@
export * from "./colorTag";
export * from "./customSpanTag";
export * from "./fencedDivTag";

View file

@ -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, Region> = {
[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, Region> = {
[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, Region> = {
[TokenLevel.INLINE]: [],
[TokenLevel.BLOCK]: []
}
private _indexCaches: Record<TokenLevel | "selection", IndexCache> = {
[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<Record<TokenLevel, number[]>>[] = [];
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<Decoration>.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));
}
}

Some files were not shown because too many files have changed in this diff Show more