Skip to content

Commit 7ecf8d4

Browse files
authored
Merge pull request #1 from schemen/feat_performanceboost
Improve rendering pipeline to be much more selective when to render
2 parents 7b6c81e + 2020d35 commit 7ecf8d4

8 files changed

Lines changed: 361 additions & 49 deletions

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "embed-metadata",
33
"name": "Embed Metadata",
4-
"version": "0.2.1",
4+
"version": "0.3.0",
55
"minAppVersion": "0.15.0",
66
"description": "Render frontmatter metadata (Properties) inside your notes with a lightweight inline syntax.",
77
"author": "Schemen",

src/editor-metadata.ts

Lines changed: 273 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,70 @@
11
// Live Preview renderer using CodeMirror decorations
22
import {Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType} from "@codemirror/view";
3-
import {RangeSetBuilder} from "@codemirror/state";
3+
import {RangeSetBuilder, Text} from "@codemirror/state";
44
import {editorInfoField, editorLivePreviewField, TFile} from "obsidian";
5-
import {getSyntaxRegex, resolveFrontmatterString} from "./metadata-utils";
5+
import {createFrontmatterResolver, getSyntaxOpen, getSyntaxRegex} from "./metadata-utils";
66
import {renderInlineMarkdown} from "./markdown-render";
7-
import {applyValueStyles} from "./metadata-style";
7+
import {applyValueStyles, getStyleKey} from "./metadata-style";
88
import {EmbedMetadataPlugin} from "./settings";
99

10+
type LineMarker = {
11+
from: number;
12+
to: number;
13+
key: string;
14+
};
15+
16+
type LineMarkers = {
17+
text: string;
18+
markers: LineMarker[];
19+
};
20+
1021
// Build the Live Preview view plugin that renders syntax markers in the editor.
1122
export function createEditorExtension(plugin: EmbedMetadataPlugin) {
1223
return ViewPlugin.fromClass(
1324
class {
1425
decorations: DecorationSet;
26+
private cursorMarkerKey: string;
27+
private lineCache: Map<number, LineMarkers>;
28+
private syntaxStyle: string;
1529

1630
constructor(view: EditorView) {
17-
this.decorations = buildDecorations(view, plugin);
31+
this.lineCache = new Map();
32+
this.syntaxStyle = plugin.settings.syntaxStyle;
33+
this.decorations = buildDecorations(view, plugin, this.lineCache);
34+
this.cursorMarkerKey = getCursorMarkerKey(view, plugin);
1835
}
1936

2037
update(update: ViewUpdate) {
21-
if (update.docChanged || update.viewportChanged || update.selectionSet) {
22-
this.decorations = buildDecorations(update.view, plugin);
38+
let needsRebuild = false;
39+
40+
if (this.syntaxStyle !== plugin.settings.syntaxStyle) {
41+
this.syntaxStyle = plugin.settings.syntaxStyle;
42+
this.lineCache.clear();
43+
needsRebuild = true;
44+
}
45+
46+
if (update.docChanged) {
47+
this.decorations = this.decorations.map(update.changes);
48+
pruneLineCache(this.lineCache, update.state.doc.lines);
49+
if (shouldRebuildForChanges(update, plugin)) {
50+
needsRebuild = true;
51+
}
52+
}
53+
54+
if (update.viewportChanged) {
55+
needsRebuild = true;
56+
}
57+
58+
if (update.docChanged || update.selectionSet) {
59+
const nextCursorMarkerKey = getCursorMarkerKey(update.view, plugin);
60+
if (nextCursorMarkerKey !== this.cursorMarkerKey) {
61+
this.cursorMarkerKey = nextCursorMarkerKey;
62+
needsRebuild = true;
63+
}
64+
}
65+
66+
if (needsRebuild) {
67+
this.decorations = buildDecorations(update.view, plugin, this.lineCache);
2368
}
2469
}
2570
},
@@ -30,7 +75,11 @@ export function createEditorExtension(plugin: EmbedMetadataPlugin) {
3075
}
3176

3277
// Scan visible ranges and replace syntax markers with widgets (skipping active edits).
33-
function buildDecorations(view: EditorView, plugin: EmbedMetadataPlugin): DecorationSet {
78+
function buildDecorations(
79+
view: EditorView,
80+
plugin: EmbedMetadataPlugin,
81+
lineCache: Map<number, LineMarkers>
82+
): DecorationSet {
3483
if (!view.state.field(editorLivePreviewField)) {
3584
return Decoration.none;
3685
}
@@ -48,73 +97,261 @@ function buildDecorations(view: EditorView, plugin: EmbedMetadataPlugin): Decora
4897

4998
const builder = new RangeSetBuilder<Decoration>();
5099
const selectionRanges = view.state.selection.ranges;
100+
const styleKey = getStyleKey(plugin.settings);
101+
const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle);
51102
const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle);
103+
const seenLines = new Set<number>();
104+
const resolveValue = createFrontmatterResolver(frontmatter, plugin.settings.caseInsensitiveKeys);
52105

53106
for (const range of view.visibleRanges) {
54-
const text = view.state.doc.sliceString(range.from, range.to);
55-
syntaxRegex.lastIndex = 0;
56-
let match: RegExpExecArray | null;
57-
58-
while ((match = syntaxRegex.exec(text)) !== null) {
59-
const start = range.from + match.index;
60-
const end = start + match[0].length;
107+
const startLine = view.state.doc.lineAt(range.from).number;
108+
const endLine = view.state.doc.lineAt(Math.max(range.to - 1, range.from)).number;
61109

62-
if (selectionRanges.some((sel) => sel.from <= end && sel.to >= start)) {
110+
for (let lineNumber = startLine; lineNumber <= endLine; lineNumber += 1) {
111+
if (seenLines.has(lineNumber)) {
63112
continue;
64113
}
114+
seenLines.add(lineNumber);
65115

66-
if (selectionRanges.some((sel) => {
67-
const head = sel.head ?? sel.from;
68-
return head >= start && head <= end;
69-
})) {
116+
const line = view.state.doc.line(lineNumber);
117+
const markers = getLineMarkers(lineNumber, line, lineCache, syntaxRegex, syntaxOpen);
118+
if (markers.length === 0) {
70119
continue;
71120
}
72121

122+
for (const marker of markers) {
123+
const start = line.from + marker.from;
124+
const end = line.from + marker.to;
125+
126+
if (selectionRanges.some((sel) => sel.from === sel.to && sel.from >= start && sel.to <= end)) {
127+
continue;
128+
}
129+
130+
const value = resolveValue(marker.key);
131+
if (value === null) {
132+
continue;
133+
}
134+
135+
builder.add(
136+
start,
137+
end,
138+
Decoration.replace({
139+
widget: new MetadataWidget(value, file.path, plugin, styleKey),
140+
inclusive: false,
141+
})
142+
);
143+
}
144+
}
145+
}
146+
147+
return builder.finish();
148+
}
149+
150+
function getLineMarkers(
151+
lineNumber: number,
152+
line: {from: number; text: string},
153+
lineCache: Map<number, LineMarkers>,
154+
syntaxRegex: RegExp,
155+
syntaxOpen: string
156+
): LineMarker[] {
157+
const cached = lineCache.get(lineNumber);
158+
if (cached && cached.text === line.text) {
159+
return cached.markers;
160+
}
161+
162+
const markers: LineMarker[] = [];
163+
if (line.text.includes(syntaxOpen)) {
164+
syntaxRegex.lastIndex = 0;
165+
let match: RegExpExecArray | null;
166+
while ((match = syntaxRegex.exec(line.text)) !== null) {
73167
const key = (match[1] ?? "").trim();
74168
if (!key) {
75169
continue;
76170
}
77171

78-
const value = resolveFrontmatterString(
79-
frontmatter,
80-
key,
81-
plugin.settings.caseInsensitiveKeys
82-
);
83-
if (value === null) {
84-
continue;
172+
const start = match.index;
173+
const end = start + match[0].length;
174+
markers.push({from: start, to: end, key});
175+
}
176+
}
177+
178+
lineCache.set(lineNumber, {text: line.text, markers});
179+
return markers;
180+
}
181+
182+
function pruneLineCache(lineCache: Map<number, LineMarkers>, maxLine: number): void {
183+
for (const lineNumber of lineCache.keys()) {
184+
if (lineNumber > maxLine) {
185+
lineCache.delete(lineNumber);
186+
}
187+
}
188+
}
189+
190+
function shouldRebuildForChanges(update: ViewUpdate, plugin: EmbedMetadataPlugin): boolean {
191+
const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle);
192+
const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle);
193+
const nextDoc = update.state.doc;
194+
const prevDoc = update.startState.doc;
195+
const prevFrontmatter = getFrontmatterRange(prevDoc);
196+
const nextFrontmatter = getFrontmatterRange(nextDoc);
197+
let needsRebuild = false;
198+
199+
update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
200+
if (needsRebuild) {
201+
return;
202+
}
203+
204+
if (prevFrontmatter && rangesOverlap(fromA, toA, prevFrontmatter.from, prevFrontmatter.to)) {
205+
needsRebuild = true;
206+
return;
207+
}
208+
209+
if (nextFrontmatter && rangesOverlap(fromB, toB, nextFrontmatter.from, nextFrontmatter.to)) {
210+
needsRebuild = true;
211+
return;
212+
}
213+
214+
if (changeTouchesMarker(prevDoc, fromA, toA, syntaxRegex, syntaxOpen)) {
215+
needsRebuild = true;
216+
return;
217+
}
218+
219+
if (changeTouchesMarker(nextDoc, fromB, toB, syntaxRegex, syntaxOpen)) {
220+
needsRebuild = true;
221+
}
222+
});
223+
224+
return needsRebuild;
225+
}
226+
227+
function changeTouchesMarker(
228+
doc: Text,
229+
from: number,
230+
to: number,
231+
syntaxRegex: RegExp,
232+
syntaxOpen: string
233+
): boolean {
234+
const safeTo = Math.max(to - 1, from);
235+
const startLine = doc.lineAt(from).number;
236+
const endLine = doc.lineAt(safeTo).number;
237+
238+
for (let lineNumber = startLine; lineNumber <= endLine; lineNumber += 1) {
239+
const line = doc.line(lineNumber);
240+
if (!line.text.includes(syntaxOpen)) {
241+
continue;
242+
}
243+
244+
syntaxRegex.lastIndex = 0;
245+
let match: RegExpExecArray | null;
246+
while ((match = syntaxRegex.exec(line.text)) !== null) {
247+
const start = line.from + match.index;
248+
const end = start + match[0].length;
249+
if (rangesOverlap(from, to, start, end)) {
250+
return true;
85251
}
252+
}
253+
}
254+
255+
return false;
256+
}
257+
258+
function rangesOverlap(from: number, to: number, start: number, end: number): boolean {
259+
if (from === to) {
260+
return from >= start && from <= end;
261+
}
262+
return start < to && end > from;
263+
}
264+
265+
function getFrontmatterRange(doc: Text): {from: number; to: number} | null {
266+
if (doc.lines === 0) {
267+
return null;
268+
}
269+
270+
const firstLine = doc.line(1);
271+
if (!/^---\s*$/.test(firstLine.text)) {
272+
return null;
273+
}
86274

87-
builder.add(
88-
start,
89-
end,
90-
Decoration.replace({
91-
widget: new MetadataWidget(value, file.path, plugin),
92-
inclusive: false,
93-
})
94-
);
275+
for (let lineNumber = 2; lineNumber <= doc.lines; lineNumber += 1) {
276+
const line = doc.line(lineNumber);
277+
if (/^(---|\.\.\.)\s*$/.test(line.text)) {
278+
return {from: firstLine.from, to: line.to};
95279
}
96280
}
97281

98-
return builder.finish();
282+
return null;
283+
}
284+
285+
function getCursorMarkerKey(view: EditorView, plugin: EmbedMetadataPlugin): string {
286+
const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle);
287+
const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle);
288+
const markerKeys: string[] = [];
289+
290+
for (const range of view.state.selection.ranges) {
291+
if (range.from !== range.to) {
292+
continue;
293+
}
294+
295+
const pos = range.from;
296+
const line = view.state.doc.lineAt(pos);
297+
if (!line.text.includes(syntaxOpen)) {
298+
continue;
299+
}
300+
301+
syntaxRegex.lastIndex = 0;
302+
let match: RegExpExecArray | null;
303+
while ((match = syntaxRegex.exec(line.text)) !== null) {
304+
const start = line.from + match.index;
305+
const end = start + match[0].length;
306+
if (pos >= start && pos <= end) {
307+
markerKeys.push(`${start}:${end}`);
308+
break;
309+
}
310+
}
311+
}
312+
313+
if (markerKeys.length === 0) {
314+
return "";
315+
}
316+
317+
markerKeys.sort();
318+
return markerKeys.join("|");
99319
}
100320

101321
class MetadataWidget extends WidgetType {
102322
private readonly value: string;
103323
private readonly sourcePath: string;
104324
private readonly plugin: EmbedMetadataPlugin;
325+
private readonly styleKey: string;
326+
private readonly isEmpty: boolean;
105327

106-
constructor(value: string, sourcePath: string, plugin: EmbedMetadataPlugin) {
328+
constructor(value: string, sourcePath: string, plugin: EmbedMetadataPlugin, styleKey: string) {
107329
super();
108330
this.value = value;
109331
this.sourcePath = sourcePath;
110332
this.plugin = plugin;
333+
this.styleKey = styleKey;
334+
this.isEmpty = value.length === 0;
335+
}
336+
337+
eq(other: MetadataWidget): boolean {
338+
return this.value === other.value
339+
&& this.sourcePath === other.sourcePath
340+
&& this.styleKey === other.styleKey;
341+
}
342+
343+
ignoreEvent(): boolean {
344+
return !this.isEmpty;
111345
}
112346

113347
// Render the replacement widget node for a single syntax marker.
114348
toDOM(): HTMLElement {
115349
const span = document.createElement("span");
116350
renderInlineMarkdown(this.plugin.app, this.sourcePath, span, this.value, this.plugin);
117351
applyValueStyles(span, this.plugin.settings);
352+
if (this.isEmpty) {
353+
span.classList.add("embed-metadata-empty");
354+
}
118355
return span;
119356
}
120357
}

0 commit comments

Comments
 (0)