🐛 fix: #121 Rewrite live preview renderer

This commit is contained in:
Nathan Smith 2024-07-07 22:44:56 -04:00
parent 9b59e6abc6
commit f2eee1f178
3 changed files with 141 additions and 73 deletions

39
package-lock.json generated
View file

@ -18,6 +18,7 @@
"queue": "^7.0.0"
},
"devDependencies": {
"@codemirror/language": "^6.10.2",
"@codemirror/view": "^6.23.0",
"@eslint/js": "^9.1.1",
"@jest/globals": "^29.7.0",
@ -657,6 +658,20 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@codemirror/language": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz",
"integrity": "sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==",
"dev": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.0.tgz",
@ -1609,6 +1624,30 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz",
"integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==",
"dev": true
},
"node_modules/@lezer/highlight": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz",
"integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==",
"dev": true,
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.1.tgz",
"integrity": "sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==",
"dev": true,
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",

View file

@ -17,6 +17,7 @@
},
"license": "MIT",
"devDependencies": {
"@codemirror/language": "^6.10.2",
"@codemirror/view": "^6.23.0",
"@eslint/js": "^9.1.1",
"@jest/globals": "^29.7.0",

View file

@ -1,6 +1,6 @@
import { editorLivePreviewField } from "obsidian";
import type { DecorationSet, PluginValue, EditorView, ViewUpdate } from "@codemirror/view";
import { Decoration, MatchDecorator, ViewPlugin, WidgetType } from "@codemirror/view";
import type { DecorationSet, EditorView, PluginSpec, PluginValue, ViewUpdate } from "@codemirror/view";
import type { GithubLinkPlugin } from "../plugin";
import { createTag } from "./inline";
@ -9,82 +9,122 @@ interface DecoSpec {
widget?: InlineTagWidget;
}
/**
* CodeMirror widget that replaces the text via Decoration
* This should be kept as simple as possible; logic on whether
* or not to render the widget is part of the view plugin
*/
class InlineTagWidget extends WidgetType {
public error = false;
private container: HTMLElement = createSpan();
constructor(
public readonly href: string,
dispatch: () => void,
) {
public readonly href: string;
constructor(public readonly match: RegExpExecArray) {
super();
const tag = createTag(href);
this.container.appendChild(tag);
this.href = match[0];
}
eq(widget: WidgetType): boolean {
return (widget as InlineTagWidget).href === this.href;
eq(other: WidgetType): boolean {
return other instanceof InlineTagWidget && other.href === this.href;
}
toDOM(): HTMLElement {
return this.container;
const container = createSpan();
const tag = createTag(this.href);
container.appendChild(tag);
return container;
}
}
export function createInlineViewPlugin(_plugin: GithubLinkPlugin) {
class InlineViewPluginValue implements PluginValue {
public readonly view: EditorView;
private readonly match = new MatchDecorator({
/**
* State for parsing content of a file for code blocks
*/
public lastMatchEnd: number = 0;
public inCodeblock = false;
public inlineTags: DecorationSet = Decoration.none;
private readonly matcher = new MatchDecorator({
regexp: /(https:\/\/)?github\.com\S+/g,
decorate: (add, from, to, match, view) => {
const shouldRender = this.shouldRender(view, from, to, match);
if (shouldRender) {
add(
from,
to,
Decoration.replace({
widget: new InlineTagWidget(match[0], view.dispatch),
}),
);
}
decorate: (add, from, to, match, _view) => {
add(
from,
to,
Decoration.replace({
widget: new InlineTagWidget(match),
}),
);
},
});
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.view = view;
this.updateDecorations(view);
constructor(private readonly view: EditorView) {
this.inlineTags = this.matcher.createDeco(view);
}
update(update: ViewUpdate): void {
this.updateDecorations(update.view, update);
this.inlineTags = this.matcher.updateDeco(update, this.inlineTags);
}
destroy(): void {
this.decorations = Decoration.none;
this.inlineTags = Decoration.none;
}
updateDecorations(view: EditorView, update?: ViewUpdate) {
if (!update || this.decorations.size === 0) {
this.decorations = this.match.createDeco(view);
} else {
this.decorations = this.match.updateDeco(update, this.decorations);
isLivePreview(): boolean {
return this.view.state.field(editorLivePreviewField);
}
/**
* Check if the decoration at the given position, with the given match, should render
*/
shouldRender(decorationFrom: number, decorationTo: number, match: RegExpMatchArray): boolean {
const view = this.view;
// Bail if it's not live preview mode
if (!this.isLivePreview()) {
return false;
}
}
isLivePreview(state: EditorView["state"]): boolean {
return state.field(editorLivePreviewField);
}
// Check if we're in a codeblock
// Note, codeblock check is more expensive than some others,
// But we do it first to ensure this.inCodeblock remains accurate
const lastLine = view.state.doc.lineAt(this.lastMatchEnd).number;
let currentLine = view.state.doc.lineAt(decorationFrom).number;
while (currentLine >= lastLine) {
const line = view.state.doc.line(currentLine);
/**
* This is somewhat naive; a four tick codeblock ````
* that contains a three tick codeblock ``` will be treated
* as ```` open, ``` close, which is not correct, and can
* lead to rendering tags where we shouldn't. This could be
* fixed with a more complex notion of "inCodeblock", keeping
* track of not just the last fence but also the fence content
* so we can treat any three tick fences as not changing the
* state if they follow a four tick fence, for example.
*
* However, there may also be a better way to keep track of
* open / close fences. See this example, but it didn't work
* for me:
* https://github.com/OlegWock/obsidian-emera/blob/master/src/processors/block-jsx-processor.ts#L26
*/
if (line.text.trim().startsWith("```")) {
this.inCodeblock = !this.inCodeblock;
}
currentLine = line.number - 1;
}
if (this.inCodeblock) {
return false;
}
shouldRender(view: EditorView, decorationFrom: number, decorationTo: number, match: RegExpMatchArray) {
// Ignore matches inside a markdown link
// TODO: This could probably be a regex.
const input = match.input ?? "";
const index = match.index ?? 0;
const matchValue = match[0];
// eslint-disable-next-line unused-imports/no-unused-vars
const endIndex = index + matchValue.length;
if (input[index - 1] === "(" && matchValue.endsWith(")")) {
return false;
}
// Check for cursors / selections inside the widget
const overlap = view.state.selection.ranges.some((r) => {
if (r.from <= decorationFrom) {
return r.to >= decorationFrom;
@ -92,41 +132,29 @@ export function createInlineViewPlugin(_plugin: GithubLinkPlugin) {
return r.from <= decorationTo;
}
});
return !overlap && this.isLivePreview(view.state);
if (overlap) {
return false;
}
return true;
}
}
const InlineViewPluginSpec: PluginSpec<InlineViewPluginValue> = {
decorations: (plugin) => {
// Update and return decorations for the CodeMirror view
return ViewPlugin.fromClass(InlineViewPluginValue, {
decorations: (instance) => {
// Set initial state for code block search
instance.lastMatchEnd = 0;
instance.inCodeblock = false;
return plugin.decorations.update({
filter: (rangeFrom, rangeTo, deco) => {
const widget = (deco.spec as DecoSpec).widget;
if (widget && widget.error) {
return false;
}
// Check if the range is collapsed (cursor position)
return (
rangeFrom === rangeTo ||
// Check if there are no overlapping selection ranges
!plugin.view.state.selection.ranges.filter((selectionRange) => {
// Determine the start and end positions of the selection range
const selectionStart = selectionRange.from;
const selectionEnd = selectionRange.to;
// Check if the selection range overlaps with the specified range
if (selectionStart <= rangeFrom) {
return selectionEnd >= rangeFrom; // Overlapping condition
} else {
return selectionStart <= rangeTo; // Overlapping condition
}
}).length
);
// Filter all potential decorations
return instance.inlineTags.update({
filter: (from, to, deco) => {
const spec = deco.spec as DecoSpec;
const shouldRender = !spec.widget || instance.shouldRender(from, to, spec.widget.match);
instance.lastMatchEnd = to;
return shouldRender;
},
});
},
};
return ViewPlugin.fromClass(InlineViewPluginValue, InlineViewPluginSpec);
});
}