diff --git a/package-lock.json b/package-lock.json index c4468ec..efa509f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@types/node": "^14.14.2", "dotenv": "^10.0.0", "esbuild": "^0.14.2", - "obsidian": "^0.16.0", + "obsidian": "^0.16.3", "rollup": "^2.32.1", "rollup-plugin-css-only": "^3.1.0", "standard-version": "^9.3.2", @@ -2075,9 +2075,9 @@ } }, "node_modules/obsidian": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-0.16.0.tgz", - "integrity": "sha512-KnQu1CntLz/EqA50W0zwlCqMgLbvMMfW2nmNQV4aMPW/aSYyjmnRMEwO0rAThQGhJPabDm2okVUSeXLctC/aMA==", + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-0.16.3.tgz", + "integrity": "sha512-hal9qk1A0GMhHSeLr2/+o3OpLmImiP+Y+sx2ewP13ds76KXsziG96n+IPFT0mSkup1zSwhEu+DeRhmbcyCCXWw==", "dev": true, "dependencies": { "@types/codemirror": "0.0.108", @@ -4413,9 +4413,9 @@ } }, "obsidian": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-0.16.0.tgz", - "integrity": "sha512-KnQu1CntLz/EqA50W0zwlCqMgLbvMMfW2nmNQV4aMPW/aSYyjmnRMEwO0rAThQGhJPabDm2okVUSeXLctC/aMA==", + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-0.16.3.tgz", + "integrity": "sha512-hal9qk1A0GMhHSeLr2/+o3OpLmImiP+Y+sx2ewP13ds76KXsziG96n+IPFT0mSkup1zSwhEu+DeRhmbcyCCXWw==", "dev": true, "requires": { "@types/codemirror": "0.0.108", diff --git a/package.json b/package.json index 209110d..910079b 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@types/node": "^14.14.2", "dotenv": "^10.0.0", "esbuild": "^0.14.2", - "obsidian": "^0.16.0", + "obsidian": "^0.16.3", "rollup": "^2.32.1", "rollup-plugin-css-only": "^3.1.0", "standard-version": "^9.3.2", diff --git a/src/live-preview.ts b/src/live-preview.ts new file mode 100644 index 0000000..0567576 --- /dev/null +++ b/src/live-preview.ts @@ -0,0 +1,226 @@ +/* + * inspired and adapted from https://github.com/blacksmithgu/obsidian-dataview/blob/master/src/main.ts + * + * The original work is MIT-licensed. + * + * MIT License + * + * Copyright (c) 2022 artisticat1 + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * */ + +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, + WidgetType +} from "@codemirror/view"; +import { EditorSelection, Range } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { + Component, + editorEditorField, + editorLivePreviewField, + editorViewField +} from "obsidian"; +import Processor from "./processor"; + +function selectionAndRangeOverlap( + selection: EditorSelection, + rangeFrom: number, + rangeTo: number +) { + for (const range of selection.ranges) { + if (range.from <= rangeTo && range.to >= rangeFrom) { + return true; + } + } + + return false; +} + +/* class InlineWidget extends WidgetType { + constructor( + readonly cssClasses: string[], + readonly rawQuery: string, + private el: HTMLElement, + private view: EditorView + ) { + super(); + } + + // Widgets only get updated when the raw query changes/the element gets focus and loses it + // to prevent redraws when the editor updates. + eq(other: InlineWidget): boolean { + if (other.rawQuery === this.rawQuery) { + // change CSS classes without redrawing the element + for (let value of other.cssClasses) { + if (!this.cssClasses.includes(value)) { + this.el.removeClass(value); + } else { + this.el.addClass(value); + } + } + return true; + } + return false; + } + + // Add CSS classes and return HTML element. + // In "complex" cases it will get filled with the correct text/child elements later. + toDOM(view: EditorView): HTMLElement { + this.el.addClasses(this.cssClasses); + return this.el; + } + + /* Make queries only editable when shift is pressed (or navigated inside with the keyboard + * or the mouse is placed at the end, but that is always possible regardless of this method). + * Mostly useful for links, and makes results selectable. + * If the widgets should always be expandable, make this always return false. + */ +/* ignoreEvent(event: MouseEvent | Event): boolean { + // instanceof check does not work in pop-out windows, so check it like this + if (event.type === "mousedown") { + const currentPos = this.view.posAtCoords({ + x: (event as MouseEvent).x, + y: (event as MouseEvent).y + }); + if ((event as MouseEvent).shiftKey) { + // Set the cursor after the element so that it doesn't select starting from the last cursor position. + if (currentPos) { + //@ts-ignore + const { editor } = this.view.state + .field(editorEditorField) + .state.field(editorViewField); + editor.setCursor(editor.offsetToPos(currentPos)); + } + return false; + } + } + return true; + } +} */ + +function inlineRender(view: EditorView) { + // still doesn't work as expected for tables and callouts + + const currentFile = app.workspace.getActiveFile(); + if (!currentFile) return; + + const widgets: Range[] = []; + const selection = view.state.selection; + /* before: + * em for italics + * highlight for highlight + * after: + * strong for bold + * strikethrough for strikethrough + */ + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ node }) => { + const type = node.type; + // markdown formatting symbols + if (type.name.includes("formatting")) return; + + // contains the position of node + const start = node.from; + const end = node.to; + // don't continue if current cursor position and inline code node (including formatting + // symbols) overlap + if (selectionAndRangeOverlap(selection, start - 1, end + 1)) + return; + + const original = view.state.doc.sliceString(start, end).trim(); + if (!Processor.END_RE.test(original)) return; + + /* If the query result is predefined text (e.g. in the case of errors), set innerText to it. + * Otherwise, pass on an empty element and fill it in later. + * This is necessary because {@link InlineWidget.toDOM} is synchronous but some rendering + * asynchronous. + */ + const parsed = Processor.parse(original) ?? []; + + for (const item of parsed) { + const { attributes, text } = item; + const firstBracket = original + .slice(0, original.indexOf(text)) + .lastIndexOf("{"); + + const lastBracket = original.indexOf( + "}", + original.indexOf(text) + ); + + /* const classes = getCssClasses(type.name); */ + /* return; */ + widgets.push( + Decoration.replace({ + /* widget: new InlineWidget(classes, code, el, view), */ + inclusive: false, + block: false + }).range(start + firstBracket, start + lastBracket + 1), + Decoration.mark({ + inclusive: true, + attributes: Object.fromEntries(attributes) + }).range(start, end) + ); + } + } + }); + } + + return Decoration.set(widgets, true); +} + +export function inlinePlugin() { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = inlineRender(view) ?? Decoration.none; + } + + update(update: ViewUpdate) { + // only activate in LP and not source mode + //@ts-ignore + if (!update.state.field(editorLivePreviewField)) { + this.decorations = Decoration.none; + return; + } + if ( + update.docChanged || + update.viewportChanged || + update.selectionSet + ) { + this.decorations = + inlineRender(update.view) ?? Decoration.none; + } + } + }, + { decorations: (v) => v.decorations } + ); +} diff --git a/src/main.ts b/src/main.ts index eb1c466..86beda9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,42 +1,6 @@ -import { - editorLivePreviewField, - editorViewField, - MarkdownPostProcessorContext, - Plugin, - requireApiVersion, - TFile -} from "obsidian"; -import { - EditorView, - Decoration, - ViewPlugin, - DecorationSet, - ViewUpdate -} from "@codemirror/view"; -import { syntaxTree } from "@codemirror/language"; -import { tokenClassNodeProp } from "@codemirror/language"; -import { - SelectionRange, - StateEffect, - StateField, - EditorState, - Range -} from "@codemirror/state"; - +import { MarkdownPostProcessorContext, Plugin, TFile } from "obsidian"; import Processor from "./processor"; - -export const isLivePreview = (state: EditorState) => { - if (requireApiVersion && requireApiVersion("0.13.23")) { - return state.field(editorLivePreviewField); - } else { - const md = state.field(editorViewField); - const { state: viewState } = md.leaf.getViewState() ?? {}; - - return ( - viewState && viewState.mode == "source" && viewState.source == false - ); - } -}; +import { inlinePlugin } from "./live-preview"; export default class MarkdownAttributes extends Plugin { parsing: Map = new Map(); @@ -44,7 +8,7 @@ export default class MarkdownAttributes extends Plugin { console.log(`Markdown Attributes v${this.manifest.version} loaded.`); this.registerMarkdownPostProcessor(this.postprocessor.bind(this)); - this.registerEditorExtension(this.state()); + this.registerEditorExtension(inlinePlugin()); } async postprocessor( @@ -139,211 +103,8 @@ export default class MarkdownAttributes extends Plugin { if (!(child instanceof HTMLElement)) return; Processor.parse(child); } - state() { - //https://gist.github.com/nothingislost/faa89aa723254883d37f45fd16162337 - type TokenSpec = { - from: number; - to: number; - loc: { from: number; to: number }; - attributes: [string, string][]; - value: string; - index: number; - }; - - class StatefulDecorationSet { - editor: EditorView; - replacers: { [cls: string]: Decoration } = Object.create(null); - markers: { [cls: string]: Decoration } = Object.create(null); - - constructor(editor: EditorView) { - this.editor = editor; - } - async compute(tokens: TokenSpec[]) { - const replace: Range[] = []; - for (let token of tokens) { - //need to add in additional locations to the caches so that the reveal transaction will properly surface them - - const deco = Decoration.replace({ - inclusive: true, - loc: token.loc - }); - - const marker = Decoration.mark({ - inclusive: true, - attributes: Object.fromEntries(token.attributes), - loc: token.loc - }); - - replace.push( - deco.range(token.from, token.to), - marker.range(token.loc.from, token.loc.to) - ); - } - return Decoration.set(replace, true); - } - - async updateDecos(tokens: TokenSpec[]): Promise { - const replacers = await this.compute(tokens); - // if our compute function returned nothing and the state field still has decorations, clear them out - if (replace || this.editor.state.field(field).size) { - this.editor.dispatch({ - effects: [replace.of(replacers ?? Decoration.none)] - }); - } - } - } - - const plugin = ViewPlugin.fromClass( - class { - manager: StatefulDecorationSet; - source = false; - - constructor(view: EditorView) { - this.manager = new StatefulDecorationSet(view); - this.build(view); - } - - update(update: ViewUpdate) { - if (!isLivePreview(update.view.state)) { - if (this.source == false) { - this.source = true; - this.manager.updateDecos([]); - } - return; - } - if ( - update.docChanged || - update.viewportChanged || - update.selectionSet || - this.source == true - ) { - this.source = false; - this.build(update.view); - } - } - - destroy() {} - - build(view: EditorView) { - if (!isLivePreview(view.state)) return; - const targetElements: TokenSpec[] = []; - for (let { from, to } of view.visibleRanges) { - const tree = syntaxTree(view.state); - tree.iterate({ - from, - to, - enter: ({ node }) => { - const type = node.type; - const tokenProps = - type.prop(tokenClassNodeProp); - - const props = new Set(tokenProps?.split(" ")); - if ( - props.has("hmd-codeblock") && - !props.has("formatting-code-block") - ) - return; - const original = view.state.doc.sliceString( - from, - to - ); - - //TODO: You will probably need to identify block types to determine from and to values to apply mark. - if (!Processor.END_RE.test(original)) return; - const parsed = Processor.parse(original) ?? []; - - for (const item of parsed) { - const { attributes, text } = item; - const end = - original.indexOf(text) + text.length; - const match = original - .trim() - .match( - new RegExp( - `\\{\\s?${text}\s?\\}$`, - "m" - ) - ); - targetElements.push({ - from: from + match.index - 1, - to: - from + - match.index + - match[0].length, - loc: { from, to: from + end }, - value: match[0], - attributes, - index: match.index - }); - } - } - }); - } - this.manager.updateDecos(targetElements); - } - } - ); - - //////////////// - // Utility Code - //////////////// - - const replace = StateEffect.define(); - const field = StateField.define({ - create(): DecorationSet { - return Decoration.none; - }, - update(deco, tr): DecorationSet { - return tr.effects.reduce((deco, effect) => { - if (effect.is(replace)) - return effect.value.update({ - filter: (_, __, decoration) => { - return !rangesInclude( - tr.newSelection.ranges, - decoration.spec.loc.from, - decoration.spec.loc.to - ); - } - }); - return deco; - }, deco.map(tr.changes)); - }, - provide: (field) => EditorView.decorations.from(field) - }); - - return [field, plugin]; - } - - isLivePreview(state: EditorState) { - if (requireApiVersion && requireApiVersion("0.13.23")) { - return state.field(editorLivePreviewField); - } else { - const md = state.field(editorViewField); - const { state: viewState } = md.leaf.getViewState() ?? {}; - - return ( - viewState && - viewState.mode == "source" && - viewState.source == false - ); - } - } async onunload() { console.log("Markdown Attributes unloaded"); } } - -function rangesInclude( - ranges: readonly SelectionRange[], - from: number, - to: number -) { - for (const range of ranges) { - const { from: rFrom, to: rTo } = range; - if (rFrom >= from && rFrom <= to) return true; - if (rTo >= from && rTo <= to) return true; - if (rFrom < from && rTo > to) return true; - } - return false; -}