Compare commits

..

4 commits

Author SHA1 Message Date
Ryan Mellmer
fef65b367d
Merge pull request #22 from TheTom/fix/viewport-jump-on-keystroke
Fix viewport jumping to top of embed on every keystroke
2026-02-17 15:19:59 -08:00
TheTom
63b5457b66 Fix viewport jumping to top of embedded link range on every keystroke
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
2026-02-17 14:48:23 -06:00
Ryan Mellmer
61754fff07
Merge pull request #18 from GladeDiviney/pathnames
support link paths
2025-06-05 20:39:15 -07:00
Glade Diviney
2a6566ec4b support link paths 2025-06-02 07:37:01 -07:00
4 changed files with 193 additions and 126 deletions

View file

@ -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));

View file

@ -2,69 +2,105 @@ 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");
const isLinkRange = res !== null && res.h2 !== undefined;
if (isLinkRange) {
const file = res?.file
if (isLinkRange && file !== undefined) {
const { vault } = app;
const foundNote : TFile | undefined = app.vault.getMarkdownFiles().filter(
x => x.basename == res.note
).first()
if (foundNote) {
embedHtml.childNodes.forEach(x => {
x.remove()
})
// 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 linkRange = embedHtml.querySelectorAll("div.link-range-embed")
linkRange.forEach(x => {
x.remove()
})
if (isMarkdownPost) {
// prevent default embed functionality for markdown post processor
embedHtml.removeClasses(["internal-embed"])
// create a child div under embedHtml to place content inside
embedHtml = embedHtml.createDiv({
cls: ["internal-embed", "markdown-embed", "inline-embed", "is-loaded", "link-range-embed"]
})
}
embedHtml.setText("")
embedHtml.createEl("h2", {
text: res.altText
})
const linkDiv = embedHtml.createDiv({
cls: ["markdown-embed-link"],
});
setIcon(linkDiv, 'link')
linkDiv.onClickEvent((ev: MouseEvent) => {
const leaf = app.workspace.getMostRecentLeaf();
leaf?.openFile(foundNote, {
state: {
scroll: res.h1Line
}
});
})
const fileContent = await vault.cachedRead(foundNote);
let lines = fileContent.split("\n");
lines = lines.slice(res.h1Line, res.h2Line);
const contentDiv = embedHtml.createDiv({
cls: ["markdown-embed-content"]
})
MarkdownRenderer.renderMarkdown(lines.join("\n"), contentDiv, "", null!)
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()
})
const linkRange = embedHtml.querySelectorAll("div.link-range-embed")
linkRange.forEach(x => {
x.remove()
})
if (isMarkdownPost) {
// prevent default embed functionality for markdown post processor
embedHtml.removeClasses(["internal-embed"])
// create a child div under embedHtml to place content inside
embedHtml = embedHtml.createDiv({
cls: ["internal-embed", "markdown-embed", "inline-embed", "is-loaded", "link-range-embed"]
})
}
embedHtml.setText("")
embedHtml.createEl("h2", {
text: res.altText
})
const linkDiv = embedHtml.createDiv({
cls: ["markdown-embed-link"],
});
setIcon(linkDiv, 'link')
linkDiv.onClickEvent((ev: MouseEvent) => {
const leaf = app.workspace.getMostRecentLeaf();
leaf?.openFile(file, {
state: {
scroll: res.h1Line
}
});
})
// 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"]
})
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);
}
}

View file

@ -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<typeof setTimeout> | 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);
}
}
}

View file

@ -1,19 +1,17 @@
import { App, TFile } from "obsidian";
import { LinkRangeSettings, Pattern } from "./settings";
import * as path from 'path';
export interface ParsedLink {
note: string;
h1: string;
h2?: string;
altText?: string;
file?: TFile;
h1Line?: number;
h2Line?: number;
}
const NOTE_PLACEHOLDER = "$note"
const H1_PLACEHOLDER = "$h1"
const H2_PLACEHOLDER = "$h2"
export function checkLinkText(href: string, settings: LinkRangeSettings): ParsedLink | null {
const linkRegex = /([^#|]*)#?([^#|]*)?\|?(.*)?/;
@ -36,19 +34,18 @@ export function checkLinkText(href: string, settings: LinkRangeSettings): Parsed
altText = matches[3]
}
else {
let pattern = findPatternForFile(note, settings);
let headingVisual = pattern.headingVisual === '' ? '#' : pattern.headingVisual;
let headingSeparatorVisual = pattern.headingSeparatorVisual === '' ? settings.headingSeparator : pattern.headingSeparatorVisual;
const pattern = findPatternForFile(note, settings);
const baseNote = path.basename(note)
const headingVisual = pattern.headingVisual === '' ? '#' : pattern.headingVisual;
const headingSeparatorVisual = pattern.headingSeparatorVisual === '' ? settings.headingSeparator : pattern.headingSeparatorVisual;
if (h2 !== undefined) {
altText = `${note}${headingVisual}${h1}${headingSeparatorVisual}${h2}`
altText = `${baseNote}${headingVisual}${h1}${headingSeparatorVisual}${h2}`
}
else {
altText = `${note}${headingVisual}${h1}`
altText = `${baseNote}${headingVisual}${h1}`
}
}
return { note, h1, h2, altText }
}
@ -63,63 +60,69 @@ export function checkLink(app :App, linkHTML: HTMLElement, settings: LinkRangeSe
const alt = linkHTML.getAttribute("alt")
if (res && app.metadataCache != null) {
// non-standard alt text, must be user provided via "|"
if (alt != null && !alt.contains(res.note)) {
res.altText = alt
}
if (!isEmbed && !linkHTML.innerText.contains(res.note)) {
res.altText = linkHTML.innerText
}
const foundNote : TFile | undefined = app.vault.getMarkdownFiles().filter(
x => x.basename == res.note
).first()
if (foundNote) {
const meta = app.metadataCache.getFileCache(foundNote);
if (meta == undefined || meta?.headings == undefined) {
return null;
}
const h1Line = meta?.headings?.filter(
h => h.heading == res.h1
).first()?.position.start.line;
let h2Line = null;
if (settings.endInclusive) {
let h2LineIndex = meta?.headings?.findIndex(
h => h.heading == res.h2
)
if (meta?.headings?.length > h2LineIndex) {
h2LineIndex += 1
}
h2Line = meta?.headings?.at(h2LineIndex)?.position.end.line
}
else {
h2Line = meta?.headings?.filter(
h => h.heading == res.h2
).first()?.position.end.line;
}
if (h1Line == undefined) {
return null;
}
res.h1Line = h1Line
res.h2Line = h2Line
return res;
}
if (!res || app.metadataCache == null) {
return null;
}
return null;
// non-standard alt text, must be user provided via "|"
if (alt != null && !alt.contains(res.note)) {
res.altText = alt
}
if (!isEmbed && !linkHTML.innerText.contains(res.note)) {
res.altText = linkHTML.innerText
}
// Locate the referenced file, including partial paths
const partialPath = res.note + ".md"
const basePart = path.basename(res.note)
const file : TFile | undefined = app.vault.getMarkdownFiles().filter(
x => x.basename == basePart && x.path.endsWith(partialPath)
).first()
if (!file) {
return null
}
res.file = file
const meta = app.metadataCache.getFileCache(file);
if (meta == undefined || meta?.headings == undefined) {
return null;
}
const h1Line = meta?.headings?.filter(
h => h.heading == res.h1
).first()?.position.start.line;
let h2Line = null;
if (settings.endInclusive) {
let h2LineIndex = meta?.headings?.findIndex(
h => h.heading == res.h2
)
if (meta?.headings?.length > h2LineIndex) {
h2LineIndex += 1
}
h2Line = meta?.headings?.at(h2LineIndex)?.position.end.line
}
else {
h2Line = meta?.headings?.filter(
h => h.heading == res.h2
).first()?.position.end.line;
}
if (h1Line == undefined) {
return null;
}
res.h1Line = h1Line
res.h2Line = h2Line
return res;
}
export function postProcessorUpdate(app: App) {