mirror of
https://github.com/kotaindah55/extended-markdown-syntax.git
synced 2026-07-22 05:38:06 +00:00
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:
parent
4f5e62bc0a
commit
18b9e0f8f3
197 changed files with 5820 additions and 5751 deletions
|
|
@ -9,47 +9,47 @@ import { FlatCompat } from "@eslint/eslintrc";
|
|||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all
|
||||
});
|
||||
|
||||
export default [{
|
||||
ignores: [
|
||||
"**/node_modules/",
|
||||
"**/main.js",
|
||||
"**/.deprecated/",
|
||||
"**/esbuild.config.mjs",
|
||||
"**/eslint.config.mjs"
|
||||
],
|
||||
ignores: [
|
||||
"**/node_modules/",
|
||||
"**/main.js",
|
||||
"**/.deprecated/",
|
||||
"**/esbuild.config.mjs",
|
||||
"**/eslint.config.mjs"
|
||||
],
|
||||
}, ...compat.extends(
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
), {
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
parser: tsParser,
|
||||
ecmaVersion: 6,
|
||||
sourceType: "module",
|
||||
},
|
||||
parser: tsParser,
|
||||
ecmaVersion: 6,
|
||||
sourceType: "module",
|
||||
},
|
||||
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
args: "none",
|
||||
}],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"prefer-const": "off",
|
||||
"no-cond-assign": "off",
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
args: "none",
|
||||
}],
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"prefer-const": "off",
|
||||
"no-cond-assign": "off",
|
||||
},
|
||||
}];
|
||||
214
main.ts
214
main.ts
|
|
@ -1,134 +1,88 @@
|
|||
import { MarkdownView, Plugin, Command } from "obsidian";
|
||||
// import { drawSelection } from "@codemirror/view";
|
||||
import { PreviewExtendedSyntax } from "src/preview-mode/post-processor";
|
||||
import { ColorConfig, PluginSettings, TagConfig } from "src/types";
|
||||
import { DEFAULT_SETTINGS } from "src/settings";
|
||||
import { ExtendedSettingTab } from "src/settings/interface";
|
||||
import { pluginFacet, settingsFacet } from "src/editor-mode/facets";
|
||||
import { deepCopy } from "src/utils";
|
||||
import { configureDelimLookup, reconfigureDelimLookup, supportTag } from "src/format-configs/utils"
|
||||
import { appFacet } from "src/editor-mode/facets";
|
||||
import { editorExtendedSyntax } from "src/editor-mode/extensions";
|
||||
import { refresherAnnot } from "src/editor-mode/annotations";
|
||||
import { StyleSheetHandler } from "src/stylesheet-handler";
|
||||
import { createColorConfig, convertColorConfigToCSSRule, getDefaultColorConfigs } from "src/color-management";
|
||||
import { Format, Theme } from "src/enums";
|
||||
import { editorCommands } from "src/editor-mode/commands";
|
||||
import { extendEditorCtxMenu } from "src/editor-mode/ui-components/utils";
|
||||
import { getTagConfigs } from "src/settings/configs";
|
||||
import { PluginSettings } from "src/types";
|
||||
import { DEFAULT_SETTINGS } from "src/settings/configs";
|
||||
import { ExtendedSettingTab } from "src/settings/ui/setting-tab";
|
||||
import { configureDelimLookup } from "src/format-configs/format-utils"
|
||||
import { StyleSheetHandler } from "src/stylesheet";
|
||||
import { Theme } from "src/enums";
|
||||
import { editorCommands } from "src/editor-mode/formatting/commands";
|
||||
import { extendEditorCtxMenu } from "src/editor-mode/ui-components";
|
||||
import { TagManager } from "src/tag-manager";
|
||||
import { ReadingModeSyntaxExtender } from "src/preview-mode/post-processor/core";
|
||||
import { editorSyntaxExtender, refreshCall } from "src/editor-mode/cm-extension";
|
||||
|
||||
export default class ExtendedMarkdownSyntax extends Plugin {
|
||||
settings: PluginSettings;
|
||||
colorsHandler: StyleSheetHandler;
|
||||
opacityHandler: StyleSheetHandler;
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new ExtendedSettingTab(this.app, this));
|
||||
configureDelimLookup(this.settings);
|
||||
this.registerEditorExtension([
|
||||
appFacet.of(this.app),
|
||||
pluginFacet.of(this),
|
||||
settingsFacet.of(this.settings),
|
||||
editorExtendedSyntax
|
||||
]);
|
||||
this.registerMarkdownPostProcessor(new PreviewExtendedSyntax(this.settings).postProcess);
|
||||
this.colorsHandler = new StyleSheetHandler(this);
|
||||
this.opacityHandler = new StyleSheetHandler(this);
|
||||
this.buildColorsStyleSheet();
|
||||
this.opacityHandler.insert(`body.theme-light{--hl-opacity:${this.settings.lightModeHlOpacity}}`);
|
||||
this.opacityHandler.insert(`body.theme-dark{--hl-opacity:${this.settings.darkModeHlOpacity}}`);
|
||||
this.registerCommands(editorCommands);
|
||||
this.extendEditorCtxMenu();
|
||||
console.log("Load Extended Markdown Syntax");
|
||||
}
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, deepCopy(DEFAULT_SETTINGS), await this.loadData());
|
||||
}
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
onunload(): void {
|
||||
this.colorsHandler.destroy();
|
||||
this.opacityHandler.destroy();
|
||||
console.log("Unload Extended Markdown Syntax");
|
||||
}
|
||||
reconfigureDelimLookup() {
|
||||
reconfigureDelimLookup(this.settings);
|
||||
}
|
||||
/** Get the settings change effect without reload the whole app. */
|
||||
refreshMarkdownView() {
|
||||
this.app.workspace.iterateAllLeaves(leaf => {
|
||||
let view = leaf.view;
|
||||
if (view instanceof MarkdownView) {
|
||||
let cmView = view.editor.cm;
|
||||
cmView.dispatch({
|
||||
annotations: refresherAnnot.of(true),
|
||||
});
|
||||
view.previewMode.rerender(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
buildColorsStyleSheet(callback?: (colorConfig: ColorConfig, colorConfigs: ColorConfig[]) => unknown) {
|
||||
let colorConfigs = this.settings.colorConfigs;
|
||||
for (let i = 0; i < colorConfigs.length; i++) {
|
||||
let ruleStr = convertColorConfigToCSSRule(colorConfigs[i]);
|
||||
this.colorsHandler.insert(ruleStr);
|
||||
if (callback) { callback(colorConfigs[i], colorConfigs) }
|
||||
}
|
||||
}
|
||||
setHlOpacity(value: number, theme: Theme) {
|
||||
let selector = theme == Theme.LIGHT
|
||||
? "body.theme-light"
|
||||
: "body.theme-dark",
|
||||
property = "--hl-opacity",
|
||||
// The index of light mode opacity is 0, and dark mode opacity is 1.
|
||||
ruleIndex = theme;
|
||||
this.opacityHandler.replace(`${selector}{${property}:${value}}`, ruleIndex);
|
||||
}
|
||||
rebuildColorsStyleSheet(callback?: (colorConfig: ColorConfig, colorConfigs: ColorConfig[]) => unknown) {
|
||||
this.colorsHandler.removeAll();
|
||||
this.buildColorsStyleSheet(callback);
|
||||
}
|
||||
addNewColor() {
|
||||
let index = this.settings.colorConfigs.length,
|
||||
newConfig = createColorConfig("color-" + index, "#ffd000", "Color " + index),
|
||||
ruleStr = convertColorConfigToCSSRule(newConfig);
|
||||
this.settings.colorConfigs.push(newConfig);
|
||||
this.colorsHandler.insert(ruleStr, index);
|
||||
}
|
||||
addTagConfig(type: Format) {
|
||||
if (!supportTag(type)) { return }
|
||||
if (type == Format.HIGHLIGHT) { this.addNewColor() }
|
||||
else {
|
||||
let configs = getTagConfigs(this.settings, type),
|
||||
index = configs.length;
|
||||
configs.push({
|
||||
name: "Tag " + index,
|
||||
tag: "tag-" + index,
|
||||
showInMenu: true
|
||||
});
|
||||
}
|
||||
}
|
||||
revertColorConfigs(callback?: (colorConfig: ColorConfig, colorConfigs: ColorConfig[]) => unknown) {
|
||||
let configs = this.settings.colorConfigs;
|
||||
configs.splice(0, configs.length, ...getDefaultColorConfigs());
|
||||
this.rebuildColorsStyleSheet(callback);
|
||||
}
|
||||
revertTagConfigs(type: Format, callback?: (config: TagConfig, configs: TagConfig[]) => unknown) {
|
||||
if (!supportTag(type)) { return }
|
||||
if (type == Format.HIGHLIGHT) {
|
||||
this.revertColorConfigs(callback);
|
||||
} else {
|
||||
let configs = getTagConfigs(this.settings, type);
|
||||
configs.splice(0);
|
||||
}
|
||||
}
|
||||
registerCommands(commands: Command[]) {
|
||||
commands.forEach(cmd => this.addCommand(cmd));
|
||||
}
|
||||
extendEditorCtxMenu() {
|
||||
this.registerEvent(this.app.workspace.on("editor-menu", (menu, editor, ctx) => {
|
||||
extendEditorCtxMenu(menu, editor, ctx);
|
||||
}));
|
||||
}
|
||||
settings: PluginSettings;
|
||||
opacityHandler: StyleSheetHandler;
|
||||
tagManager: TagManager;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new ExtendedSettingTab(this.app, this));
|
||||
|
||||
configureDelimLookup(this.settings);
|
||||
this.registerEditorExtension(editorSyntaxExtender(this));
|
||||
this.registerMarkdownPostProcessor(new ReadingModeSyntaxExtender(this.settings).postProcess);
|
||||
|
||||
this.tagManager = new TagManager(this);
|
||||
this.opacityHandler = new StyleSheetHandler(this);
|
||||
this.opacityHandler.insert(`body.theme-light{--hl-opacity:${this.settings.lightModeHlOpacity}}`);
|
||||
this.opacityHandler.insert(`body.theme-dark{--hl-opacity:${this.settings.darkModeHlOpacity}}`);
|
||||
|
||||
this.registerCommands(editorCommands);
|
||||
this.extendEditorCtxMenu();
|
||||
|
||||
console.log("Load Extended Markdown Syntax");
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, structuredClone(DEFAULT_SETTINGS), await this.loadData());
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
onunload(): void {
|
||||
this.tagManager.colorsHandler.destroy();
|
||||
this.opacityHandler.destroy();
|
||||
console.log("Unload Extended Markdown Syntax");
|
||||
}
|
||||
|
||||
reconfigureDelimLookup() {
|
||||
configureDelimLookup(this.settings);
|
||||
}
|
||||
|
||||
/** Get the settings change effect without reload the whole app. */
|
||||
refreshMarkdownView(deep = true) {
|
||||
this.app.workspace.getLeavesOfType("markdown").forEach(leaf => {
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
let cmView = leaf.view.editor.cm;
|
||||
cmView.dispatch({
|
||||
annotations: refreshCall.of({ deep }),
|
||||
});
|
||||
leaf.view.previewMode.rerender(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setHlOpacity(value: number, theme: Theme) {
|
||||
let selector = theme == Theme.LIGHT
|
||||
? "body.theme-light"
|
||||
: "body.theme-dark",
|
||||
property = "--hl-opacity",
|
||||
// The index of light mode opacity is 0, and dark mode opacity is 1.
|
||||
ruleIndex = theme;
|
||||
this.opacityHandler.replace(`${selector}{${property}:${value}}`, ruleIndex);
|
||||
}
|
||||
|
||||
registerCommands(commands: Command[]) {
|
||||
commands.forEach(cmd => this.addCommand(cmd));
|
||||
}
|
||||
|
||||
extendEditorCtxMenu() {
|
||||
this.registerEvent(this.app.workspace.on("editor-menu", (menu, editor, ctx) => {
|
||||
extendEditorCtxMenu(menu, editor, ctx);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export const PREDEFINED_COLOR_TAGS = ["red", "orange", "yellow", "green", "cyan", "blue", "purple", "pink"] as const;
|
||||
|
|
@ -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));}`
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export * from "./convertColorConfigToCSSRule";
|
||||
export * from "./takeBuiltinColor";
|
||||
export * from "./getDefaultColorConfigs";
|
||||
export * from "./createColorConfig";
|
||||
export * from "./PREDEFINED_COLOR_TAGS";
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export function takeBuiltinColor(color: string) {
|
||||
return document.body.computedStyleMap().get(`--color-${color}`)?.toString();
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "./refresherAnnot";
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { Annotation } from "@codemirror/state";
|
||||
|
||||
export let refresherAnnot = Annotation.define<true>();
|
||||
237
src/editor-mode/cm-extension.ts
Normal file
237
src/editor-mode/cm-extension.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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
|
||||
];
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
433
src/editor-mode/decorator/builder.ts
Normal file
433
src/editor-mode/decorator/builder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export * from "./DecorationBuilder";
|
||||
export * from "./DelimOmitter";
|
||||
export * from "./TokensBuffer";
|
||||
export * from "./LineBreakReplacer";
|
||||
export * from "./DecorationHolder";
|
||||
32
src/editor-mode/decorator/decorations.ts
Normal file
32
src/editor-mode/decorator/decorations.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Decoration } from "@codemirror/view";
|
||||
|
||||
export const REVEALED_SPOILER_DECO = Decoration.mark({
|
||||
class: "cm-spoiler-revealed",
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./createHlDeco";
|
||||
export * from "./createInlineDecoRange";
|
||||
export * from "./createLineDecoRange";
|
||||
95
src/editor-mode/decorator/widgets.ts
Normal file
95
src/editor-mode/decorator/widgets.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./HiddenWidget";
|
||||
export * from "./ColorButton";
|
||||
export * from "./LineBreak";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Line } from "@codemirror/state";
|
||||
|
||||
export function isBlankLine(line: Line): boolean {
|
||||
return !line.text.trimEnd();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export * from "./checkRefreshed";
|
||||
export * from "./getActiveCanvasNodeCoords";
|
||||
export * from "./getActiveCanvasEditor";
|
||||
export * from "./isCanvas";
|
||||
export * from "./isEditorModeChanged";
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { App } from "obsidian";
|
||||
|
||||
export function isCanvas(app: App) {
|
||||
return app.workspace.getMostRecentLeaf()?.view.getViewType() == "canvas";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
];
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./settingsFacet";
|
||||
export * from "./appFacet";
|
||||
export * from "./pluginFacet";
|
||||
export * from "./activityFacet";
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
166
src/editor-mode/formatting/commands.ts
Normal file
166
src/editor-mode/formatting/commands.ts
Normal 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);
|
||||
435
src/editor-mode/formatting/formatter.ts
Normal file
435
src/editor-mode/formatting/formatter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from "./isSurroundedByDelimiter";
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export function isSurroundedByDelimiter(str: string, delimStr: string) {
|
||||
return str.startsWith(delimStr) && str.endsWith(delimStr);
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./Formatter";
|
||||
export * from "./FormatterState";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./SelectionObserver";
|
||||
export * from "./ActivityRecorder";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { InlineFormat } from "src/types";
|
||||
|
||||
export const EditorDelimLookup: Record<string, InlineFormat> = {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export * from "./ShifterNodeConfigs";
|
||||
export * from "./EditorDelimLookup";
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from "./Parser";
|
||||
export * from "./ParserState";
|
||||
export * from "./Tokenizer";
|
||||
export * from "./TokenQueue";
|
||||
|
|
@ -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!
|
||||
};
|
||||
}
|
||||
|
|
@ -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)");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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)");
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
})();
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
export * from "./iterTokenGroup";
|
||||
export * from "./moveTokenIndexCache";
|
||||
export * from "./provideTokenRanges";
|
||||
export * from "./isToken";
|
||||
export * from "./getTagRange";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from "./colorTag";
|
||||
export * from "./customSpanTag";
|
||||
export * from "./fencedDivTag";
|
||||
456
src/editor-mode/preprocessor/observer.ts
Normal file
456
src/editor-mode/preprocessor/observer.ts
Normal 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
Loading…
Reference in a new issue