diff --git a/manifest.json b/manifest.json index b63fe0f..af04f77 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "embed-metadata", "name": "Embed Metadata", - "version": "0.2.1", + "version": "0.3.0", "minAppVersion": "0.15.0", "description": "Render frontmatter metadata (Properties) inside your notes with a lightweight inline syntax.", "author": "Schemen", diff --git a/src/editor-metadata.ts b/src/editor-metadata.ts index 61d22ff..87688c6 100644 --- a/src/editor-metadata.ts +++ b/src/editor-metadata.ts @@ -1,25 +1,70 @@ // Live Preview renderer using CodeMirror decorations import {Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetType} from "@codemirror/view"; -import {RangeSetBuilder} from "@codemirror/state"; +import {RangeSetBuilder, Text} from "@codemirror/state"; import {editorInfoField, editorLivePreviewField, TFile} from "obsidian"; -import {getSyntaxRegex, resolveFrontmatterString} from "./metadata-utils"; +import {createFrontmatterResolver, getSyntaxOpen, getSyntaxRegex} from "./metadata-utils"; import {renderInlineMarkdown} from "./markdown-render"; -import {applyValueStyles} from "./metadata-style"; +import {applyValueStyles, getStyleKey} from "./metadata-style"; import {EmbedMetadataPlugin} from "./settings"; +type LineMarker = { + from: number; + to: number; + key: string; +}; + +type LineMarkers = { + text: string; + markers: LineMarker[]; +}; + // Build the Live Preview view plugin that renders syntax markers in the editor. export function createEditorExtension(plugin: EmbedMetadataPlugin) { return ViewPlugin.fromClass( class { decorations: DecorationSet; + private cursorMarkerKey: string; + private lineCache: Map; + private syntaxStyle: string; constructor(view: EditorView) { - this.decorations = buildDecorations(view, plugin); + this.lineCache = new Map(); + this.syntaxStyle = plugin.settings.syntaxStyle; + this.decorations = buildDecorations(view, plugin, this.lineCache); + this.cursorMarkerKey = getCursorMarkerKey(view, plugin); } update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.decorations = buildDecorations(update.view, plugin); + let needsRebuild = false; + + if (this.syntaxStyle !== plugin.settings.syntaxStyle) { + this.syntaxStyle = plugin.settings.syntaxStyle; + this.lineCache.clear(); + needsRebuild = true; + } + + if (update.docChanged) { + this.decorations = this.decorations.map(update.changes); + pruneLineCache(this.lineCache, update.state.doc.lines); + if (shouldRebuildForChanges(update, plugin)) { + needsRebuild = true; + } + } + + if (update.viewportChanged) { + needsRebuild = true; + } + + if (update.docChanged || update.selectionSet) { + const nextCursorMarkerKey = getCursorMarkerKey(update.view, plugin); + if (nextCursorMarkerKey !== this.cursorMarkerKey) { + this.cursorMarkerKey = nextCursorMarkerKey; + needsRebuild = true; + } + } + + if (needsRebuild) { + this.decorations = buildDecorations(update.view, plugin, this.lineCache); } } }, @@ -30,7 +75,11 @@ export function createEditorExtension(plugin: EmbedMetadataPlugin) { } // Scan visible ranges and replace syntax markers with widgets (skipping active edits). -function buildDecorations(view: EditorView, plugin: EmbedMetadataPlugin): DecorationSet { +function buildDecorations( + view: EditorView, + plugin: EmbedMetadataPlugin, + lineCache: Map +): DecorationSet { if (!view.state.field(editorLivePreviewField)) { return Decoration.none; } @@ -48,66 +97,251 @@ function buildDecorations(view: EditorView, plugin: EmbedMetadataPlugin): Decora const builder = new RangeSetBuilder(); const selectionRanges = view.state.selection.ranges; + const styleKey = getStyleKey(plugin.settings); + const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); + const seenLines = new Set(); + const resolveValue = createFrontmatterResolver(frontmatter, plugin.settings.caseInsensitiveKeys); for (const range of view.visibleRanges) { - const text = view.state.doc.sliceString(range.from, range.to); - syntaxRegex.lastIndex = 0; - let match: RegExpExecArray | null; - - while ((match = syntaxRegex.exec(text)) !== null) { - const start = range.from + match.index; - const end = start + match[0].length; + const startLine = view.state.doc.lineAt(range.from).number; + const endLine = view.state.doc.lineAt(Math.max(range.to - 1, range.from)).number; - if (selectionRanges.some((sel) => sel.from <= end && sel.to >= start)) { + for (let lineNumber = startLine; lineNumber <= endLine; lineNumber += 1) { + if (seenLines.has(lineNumber)) { continue; } + seenLines.add(lineNumber); - if (selectionRanges.some((sel) => { - const head = sel.head ?? sel.from; - return head >= start && head <= end; - })) { + const line = view.state.doc.line(lineNumber); + const markers = getLineMarkers(lineNumber, line, lineCache, syntaxRegex, syntaxOpen); + if (markers.length === 0) { continue; } + for (const marker of markers) { + const start = line.from + marker.from; + const end = line.from + marker.to; + + if (selectionRanges.some((sel) => sel.from === sel.to && sel.from >= start && sel.to <= end)) { + continue; + } + + const value = resolveValue(marker.key); + if (value === null) { + continue; + } + + builder.add( + start, + end, + Decoration.replace({ + widget: new MetadataWidget(value, file.path, plugin, styleKey), + inclusive: false, + }) + ); + } + } + } + + return builder.finish(); +} + +function getLineMarkers( + lineNumber: number, + line: {from: number; text: string}, + lineCache: Map, + syntaxRegex: RegExp, + syntaxOpen: string +): LineMarker[] { + const cached = lineCache.get(lineNumber); + if (cached && cached.text === line.text) { + return cached.markers; + } + + const markers: LineMarker[] = []; + if (line.text.includes(syntaxOpen)) { + syntaxRegex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = syntaxRegex.exec(line.text)) !== null) { const key = (match[1] ?? "").trim(); if (!key) { continue; } - const value = resolveFrontmatterString( - frontmatter, - key, - plugin.settings.caseInsensitiveKeys - ); - if (value === null) { - continue; + const start = match.index; + const end = start + match[0].length; + markers.push({from: start, to: end, key}); + } + } + + lineCache.set(lineNumber, {text: line.text, markers}); + return markers; +} + +function pruneLineCache(lineCache: Map, maxLine: number): void { + for (const lineNumber of lineCache.keys()) { + if (lineNumber > maxLine) { + lineCache.delete(lineNumber); + } + } +} + +function shouldRebuildForChanges(update: ViewUpdate, plugin: EmbedMetadataPlugin): boolean { + const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); + const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); + const nextDoc = update.state.doc; + const prevDoc = update.startState.doc; + const prevFrontmatter = getFrontmatterRange(prevDoc); + const nextFrontmatter = getFrontmatterRange(nextDoc); + let needsRebuild = false; + + update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { + if (needsRebuild) { + return; + } + + if (prevFrontmatter && rangesOverlap(fromA, toA, prevFrontmatter.from, prevFrontmatter.to)) { + needsRebuild = true; + return; + } + + if (nextFrontmatter && rangesOverlap(fromB, toB, nextFrontmatter.from, nextFrontmatter.to)) { + needsRebuild = true; + return; + } + + if (changeTouchesMarker(prevDoc, fromA, toA, syntaxRegex, syntaxOpen)) { + needsRebuild = true; + return; + } + + if (changeTouchesMarker(nextDoc, fromB, toB, syntaxRegex, syntaxOpen)) { + needsRebuild = true; + } + }); + + return needsRebuild; +} + +function changeTouchesMarker( + doc: Text, + from: number, + to: number, + syntaxRegex: RegExp, + syntaxOpen: string +): boolean { + const safeTo = Math.max(to - 1, from); + const startLine = doc.lineAt(from).number; + const endLine = doc.lineAt(safeTo).number; + + for (let lineNumber = startLine; lineNumber <= endLine; lineNumber += 1) { + const line = doc.line(lineNumber); + if (!line.text.includes(syntaxOpen)) { + continue; + } + + syntaxRegex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = syntaxRegex.exec(line.text)) !== null) { + const start = line.from + match.index; + const end = start + match[0].length; + if (rangesOverlap(from, to, start, end)) { + return true; } + } + } + + return false; +} + +function rangesOverlap(from: number, to: number, start: number, end: number): boolean { + if (from === to) { + return from >= start && from <= end; + } + return start < to && end > from; +} + +function getFrontmatterRange(doc: Text): {from: number; to: number} | null { + if (doc.lines === 0) { + return null; + } + + const firstLine = doc.line(1); + if (!/^---\s*$/.test(firstLine.text)) { + return null; + } - builder.add( - start, - end, - Decoration.replace({ - widget: new MetadataWidget(value, file.path, plugin), - inclusive: false, - }) - ); + for (let lineNumber = 2; lineNumber <= doc.lines; lineNumber += 1) { + const line = doc.line(lineNumber); + if (/^(---|\.\.\.)\s*$/.test(line.text)) { + return {from: firstLine.from, to: line.to}; } } - return builder.finish(); + return null; +} + +function getCursorMarkerKey(view: EditorView, plugin: EmbedMetadataPlugin): string { + const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); + const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); + const markerKeys: string[] = []; + + for (const range of view.state.selection.ranges) { + if (range.from !== range.to) { + continue; + } + + const pos = range.from; + const line = view.state.doc.lineAt(pos); + if (!line.text.includes(syntaxOpen)) { + continue; + } + + syntaxRegex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = syntaxRegex.exec(line.text)) !== null) { + const start = line.from + match.index; + const end = start + match[0].length; + if (pos >= start && pos <= end) { + markerKeys.push(`${start}:${end}`); + break; + } + } + } + + if (markerKeys.length === 0) { + return ""; + } + + markerKeys.sort(); + return markerKeys.join("|"); } class MetadataWidget extends WidgetType { private readonly value: string; private readonly sourcePath: string; private readonly plugin: EmbedMetadataPlugin; + private readonly styleKey: string; + private readonly isEmpty: boolean; - constructor(value: string, sourcePath: string, plugin: EmbedMetadataPlugin) { + constructor(value: string, sourcePath: string, plugin: EmbedMetadataPlugin, styleKey: string) { super(); this.value = value; this.sourcePath = sourcePath; this.plugin = plugin; + this.styleKey = styleKey; + this.isEmpty = value.length === 0; + } + + eq(other: MetadataWidget): boolean { + return this.value === other.value + && this.sourcePath === other.sourcePath + && this.styleKey === other.styleKey; + } + + ignoreEvent(): boolean { + return !this.isEmpty; } // Render the replacement widget node for a single syntax marker. @@ -115,6 +349,9 @@ class MetadataWidget extends WidgetType { const span = document.createElement("span"); renderInlineMarkdown(this.plugin.app, this.sourcePath, span, this.value, this.plugin); applyValueStyles(span, this.plugin.settings); + if (this.isEmpty) { + span.classList.add("embed-metadata-empty"); + } return span; } } diff --git a/src/markdown-render.ts b/src/markdown-render.ts index 1655ea6..2c39572 100644 --- a/src/markdown-render.ts +++ b/src/markdown-render.ts @@ -1,6 +1,8 @@ // Inline markdown renderer for metadata values (links, embeds, etc.) import {App, Component, MarkdownRenderer} from "obsidian"; +const markdownHintRegex = /(\[\[|!\[\[|`|\*|_|~|\[[^\]]+\]\([^)]+\)|#|\n)/; + // Render a value as inline markdown export function renderInlineMarkdown( app: App, @@ -9,6 +11,11 @@ export function renderInlineMarkdown( value: string, component: Component ): void { + if (!value || !markdownHintRegex.test(value)) { + el.textContent = value; + return; + } + el.textContent = ""; const temp = document.createElement("span"); diff --git a/src/metadata-renderer.ts b/src/metadata-renderer.ts index d866042..845e273 100644 --- a/src/metadata-renderer.ts +++ b/src/metadata-renderer.ts @@ -1,6 +1,6 @@ // Reading view renderer that replaces syntax markers in the preview DOM. import {TFile} from "obsidian"; -import {getSyntaxOpen, getSyntaxRegex, resolveFrontmatterString} from "./metadata-utils"; +import {createFrontmatterResolver, getSyntaxOpen, getSyntaxRegex} from "./metadata-utils"; import {renderInlineMarkdown} from "./markdown-render"; import {EmbedMetadataPlugin} from "./settings"; @@ -24,6 +24,7 @@ export function registerMetadataRenderer(plugin: EmbedMetadataPlugin) { return; } + const resolveValue = createFrontmatterResolver(frontmatter, plugin.settings.caseInsensitiveKeys); const doc = el.ownerDocument; const syntaxOpen = getSyntaxOpen(plugin.settings.syntaxStyle); const syntaxRegex = getSyntaxRegex(plugin.settings.syntaxStyle); @@ -52,11 +53,7 @@ export function registerMetadataRenderer(plugin: EmbedMetadataPlugin) { for (const textNode of textNodes) { replaceSyntaxInTextNode( textNode, - (key) => resolveFrontmatterString( - frontmatter, - key, - plugin.settings.caseInsensitiveKeys - ), + (key) => resolveValue(key), doc, syntaxRegex, syntaxOpen, diff --git a/src/metadata-style.ts b/src/metadata-style.ts index 6a60618..4ebbd9f 100644 --- a/src/metadata-style.ts +++ b/src/metadata-style.ts @@ -1,6 +1,21 @@ // Shared styling helper for rendered metadata values. import {EmbedMetadataSettings} from "./settings"; +// Build a stable key for style settings so widgets can be reused safely. +export function getStyleKey(settings: EmbedMetadataSettings): string { + return [ + settings.bold ? "1" : "0", + settings.italic ? "1" : "0", + settings.underline ? "1" : "0", + settings.underlineColorEnabled ? "1" : "0", + settings.underlineColor, + settings.highlight ? "1" : "0", + settings.highlightColorEnabled ? "1" : "0", + settings.highlightColor, + settings.hoverEmphasis ? "1" : "0", + ].join("|"); +} + // Apply visual styling classes to a rendered value element. export function applyValueStyles(el: HTMLElement, settings: EmbedMetadataSettings): void { el.classList.add("embed-metadata-value"); diff --git a/src/metadata-utils.ts b/src/metadata-utils.ts index 0433eac..78dbb27 100644 --- a/src/metadata-utils.ts +++ b/src/metadata-utils.ts @@ -2,6 +2,7 @@ // Syntax parsing and frontmatter resolution utilities. export type SyntaxStyle = "brackets" | "doubleBraces"; +export type FrontmatterResolver = (keyPath: string) => string | null; // Get the syntax opener for the selected style. export function getSyntaxOpen(style: SyntaxStyle): string { @@ -33,9 +34,10 @@ export function getSyntaxTriggerRegex(style: SyntaxStyle): RegExp { export function resolveFrontmatterString( frontmatter: Record, keyPath: string, - caseInsensitive = false + caseInsensitive = false, + keyMapCache?: WeakMap> ): string | null { - const value = resolveFrontmatterValue(frontmatter, keyPath, caseInsensitive); + const value = resolveFrontmatterValue(frontmatter, keyPath, caseInsensitive, keyMapCache); if (value === undefined) { return null; } @@ -45,6 +47,32 @@ export function resolveFrontmatterString( return formatFrontmatterValue(value); } +// Create a cached resolver for frontmatter lookups within a render pass. +export function createFrontmatterResolver( + frontmatter: Record, + caseInsensitive: boolean +): FrontmatterResolver { + let valueCache: Map | null = null; + let keyMapCache: WeakMap> | undefined; + + return (keyPath: string) => { + if (valueCache && valueCache.has(keyPath)) { + return valueCache.get(keyPath) ?? null; + } + + if (!valueCache) { + valueCache = new Map(); + } + if (caseInsensitive && !keyMapCache) { + keyMapCache = new WeakMap(); + } + + const value = resolveFrontmatterString(frontmatter, keyPath, caseInsensitive, keyMapCache); + valueCache.set(keyPath, value); + return value; + }; +} + // Collect flat and nested frontmatter keys for suggestions. export function collectFrontmatterKeys(frontmatter: Record): string[] { const keys: string[] = []; @@ -69,7 +97,8 @@ export function collectFrontmatterKeys(frontmatter: Record): st function resolveFrontmatterValue( frontmatter: Record, keyPath: string, - caseInsensitive: boolean + caseInsensitive: boolean, + keyMapCache?: WeakMap> ): unknown { const parts = keyPath .split(".") @@ -89,8 +118,7 @@ function resolveFrontmatterValue( } if (caseInsensitive) { - const lowered = part.toLowerCase(); - const matched = Object.keys(record).find((key) => key.toLowerCase() === lowered); + const matched = getCaseInsensitiveKey(record, part, keyMapCache); if (matched) { current = record[matched]; continue; @@ -103,6 +131,28 @@ function resolveFrontmatterValue( return current; } +function getCaseInsensitiveKey( + record: Record, + part: string, + keyMapCache?: WeakMap> +): string | null { + const lowered = part.toLowerCase(); + if (!keyMapCache) { + return Object.keys(record).find((key) => key.toLowerCase() === lowered) ?? null; + } + + let keyMap = keyMapCache.get(record); + if (!keyMap) { + keyMap = new Map(); + for (const key of Object.keys(record)) { + keyMap.set(key.toLowerCase(), key); + } + keyMapCache.set(record, keyMap); + } + + return keyMap.get(lowered) ?? null; +} + // Convert frontmatter values into a readable inline string. function formatFrontmatterValue(value: unknown): string { if (Array.isArray(value)) { diff --git a/styles.css b/styles.css index 70c6ba7..abd0344 100644 --- a/styles.css +++ b/styles.css @@ -43,3 +43,8 @@ .embed-metadata-hoverable:not(.embed-metadata-bold):hover { font-weight: 600; } + +.embed-metadata-value.embed-metadata-empty { + display: inline-block; + min-width: 0.25ch; +} diff --git a/versions.json b/versions.json index 1931e11..5fc932f 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,6 @@ { "0.1.0": "0.15.0", "0.2.0": "0.15.0", - "0.2.1": "0.15.0" + "0.2.1": "0.15.0", + "0.3.0": "0.15.0" }