mirror of
https://github.com/nathonius/obsidian-github-link.git
synced 2026-07-22 09:20:25 +00:00
🐛 fix: #121 Rewrite live preview renderer
This commit is contained in:
parent
9b59e6abc6
commit
f2eee1f178
3 changed files with 141 additions and 73 deletions
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue