From 63b5457b66d1f84b54279b39703d68d6b920a2b1 Mon Sep 17 00:00:00 2001 From: TheTom Date: Tue, 17 Feb 2026 14:46:55 -0600 Subject: [PATCH] Fix viewport jumping to top of embedded link range on every keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live preview embed replacer was triggering a full DOM rebuild on every focusChanged event (which fires on each keystroke). replaceEmbed() would destroy and recreate all child nodes unconditionally, causing the browser to re-layout and reset scroll position — making it impossible to type below an embedded range like ![[Isaiah 59#1..10]]. Fixes: - Remove focusChanged from update trigger (only rebuild on docChanged/viewportChanged) - Add 300ms debounce so rapid typing doesn't hammer the renderer - Add content-aware cache key (file path + line range + djb2 content hash) so replaceEmbed() skips re-rendering when nothing actually changed - Add async race guard (generation counter) to prevent stale renders - Add eager initial render in constructor for mode switches - Set cache key on outer element so it works for both live preview and markdown post-processor paths --- main.ts | 2 +- src/embeds.ts | 53 +++++++++++++++++++++++++++++---- src/livePreviewEmbedReplacer.ts | 36 +++++++++++++++++++--- 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/main.ts b/main.ts index b4f0821..09efcf1 100644 --- a/main.ts +++ b/main.ts @@ -27,7 +27,7 @@ export default class LinkRange extends Plugin { // wait for layout to be ready this.app.workspace.onLayoutReady(() => { this.registerEditorExtension(ViewPlugin.define((v) => { - return new LifePreviewEmbedReplacer(this.settings, this.app) + return new LifePreviewEmbedReplacer(v, this.settings, this.app) })); const ext = Prec.lowest(buildCMViewPlugin(this.app, this.settings)); diff --git a/src/embeds.ts b/src/embeds.ts index f36cbc7..1735adb 100644 --- a/src/embeds.ts +++ b/src/embeds.ts @@ -2,8 +2,19 @@ import { App, MarkdownRenderer, setIcon, TFile } from "obsidian"; import { LinkRangeSettings } from "./settings"; import { checkLink } from "./utils"; +// Simple string hash for cache key generation (djb2) +function hashString(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return hash.toString(36); +} + export async function replaceEmbed(app: App, embed: Node, settings: LinkRangeSettings, isMarkdownPost = false) { let embedHtml = embed as HTMLElement + // Keep a reference to the original outer element for cache key storage + const outerElement = embedHtml; const res = checkLink(app, embedHtml, settings, true, "src"); @@ -11,6 +22,30 @@ export async function replaceEmbed(app: App, embed: Node, settings: LinkRangeSet const file = res?.file if (isLinkRange && file !== undefined) { const { vault } = app; + + // Read file content first so we can include a content hash in the + // cache key. This ensures we re-render when the source text changes + // even if the heading line numbers stay the same. + const fileContent = await vault.cachedRead(file); + let lines = fileContent.split("\n"); + lines = lines.slice(res.h1Line, res.h2Line); + const contentText = lines.join("\n"); + + const cacheKey = `${file.path}:${res.h1Line}:${res.h2Line}:${hashString(contentText)}`; + // Check the cache key on the outer element (works for both live preview + // and markdown post-processor paths) + const prevKey = outerElement.getAttribute("data-link-range-key"); + if (prevKey === cacheKey) { + // Already rendered with the same content — bail out + return; + } + + // Guard against async race: another call may have started rendering + // while we were awaiting cachedRead. If so, let the newer one win. + // We use a generation counter stored on the element. + const generation = (parseInt(outerElement.getAttribute("data-link-range-gen") || "0") || 0) + 1; + outerElement.setAttribute("data-link-range-gen", generation.toString()); + embedHtml.childNodes.forEach(x => { x.remove() }) @@ -51,15 +86,21 @@ export async function replaceEmbed(app: App, embed: Node, settings: LinkRangeSet }); }) - const fileContent = await vault.cachedRead(file); - - let lines = fileContent.split("\n"); - lines = lines.slice(res.h1Line, res.h2Line); + // Post-await race guard: if a newer render kicked off while we were + // waiting, abort this one so the newer render wins. + const currentGen = parseInt(outerElement.getAttribute("data-link-range-gen") || "0") || 0; + if (currentGen !== generation) { + return; + } const contentDiv = embedHtml.createDiv({ cls: ["markdown-embed-content"] }) - MarkdownRenderer.renderMarkdown(lines.join("\n"), contentDiv, "", null!) - } + await MarkdownRenderer.renderMarkdown(contentText, contentDiv, "", null!) + + // Tag the outer element with the cache key so subsequent calls can + // skip re-rendering (works for both live preview and post-processor) + outerElement.setAttribute("data-link-range-key", cacheKey); + } } diff --git a/src/livePreviewEmbedReplacer.ts b/src/livePreviewEmbedReplacer.ts index ab159e5..beccaab 100644 --- a/src/livePreviewEmbedReplacer.ts +++ b/src/livePreviewEmbedReplacer.ts @@ -8,10 +8,17 @@ export class LifePreviewEmbedReplacer implements PluginValue { decorations: DecorationSet = Decoration.none; settings: LinkRangeSettings; app: App; + // Debounce timer to avoid hammering replaceEmbed on rapid typing + private debounceTimer: ReturnType | null = null; + private pendingView: EditorView | null = null; - constructor(settings: LinkRangeSettings, app: App) { + constructor(view: EditorView, settings: LinkRangeSettings, app: App) { this.settings = settings; this.app = app; + // Eagerly render once on construction so embeds appear immediately + // when opening a note or switching to live preview mode, without + // requiring a doc change or scroll. + this.decorations = this.buildDecorations(view); } buildDecorations(view: EditorView): DecorationSet { @@ -32,8 +39,29 @@ export class LifePreviewEmbedReplacer implements PluginValue { return; } - if ( update.docChanged || update.viewportChanged || update.focusChanged ) { - this.decorations = this.buildDecorations(update.view); + // Only rebuild on doc changes or viewport changes — NOT focusChanged. + // focusChanged fires on every keystroke and caused the viewport to + // jump because replaceEmbed() destroys and recreates the entire DOM. + if (update.docChanged || update.viewportChanged) { + // Debounce to avoid re-rendering embeds on every single keystroke. + // This lets the user type freely without the viewport jumping around. + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.pendingView = update.view; + this.debounceTimer = setTimeout(() => { + if (this.pendingView) { + this.decorations = this.buildDecorations(this.pendingView); + this.pendingView = null; + } + this.debounceTimer = null; + }, 300); } - } + } + + destroy() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + } }