mirror of
https://github.com/rmellmer/obsidian-link-range.git
synced 2026-07-22 07:40:31 +00:00
Merge pull request #22 from TheTom/fix/viewport-jump-on-keystroke
Fix viewport jumping to top of embed on every keystroke
This commit is contained in:
commit
fef65b367d
3 changed files with 80 additions and 11 deletions
2
main.ts
2
main.ts
|
|
@ -27,7 +27,7 @@ export default class LinkRange extends Plugin {
|
||||||
// wait for layout to be ready
|
// wait for layout to be ready
|
||||||
this.app.workspace.onLayoutReady(() => {
|
this.app.workspace.onLayoutReady(() => {
|
||||||
this.registerEditorExtension(ViewPlugin.define((v) => {
|
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));
|
const ext = Prec.lowest(buildCMViewPlugin(this.app, this.settings));
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,19 @@ import { App, MarkdownRenderer, setIcon, TFile } from "obsidian";
|
||||||
import { LinkRangeSettings } from "./settings";
|
import { LinkRangeSettings } from "./settings";
|
||||||
import { checkLink } from "./utils";
|
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) {
|
export async function replaceEmbed(app: App, embed: Node, settings: LinkRangeSettings, isMarkdownPost = false) {
|
||||||
let embedHtml = embed as HTMLElement
|
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 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
|
const file = res?.file
|
||||||
if (isLinkRange && file !== undefined) {
|
if (isLinkRange && file !== undefined) {
|
||||||
const { vault } = app;
|
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 => {
|
embedHtml.childNodes.forEach(x => {
|
||||||
x.remove()
|
x.remove()
|
||||||
})
|
})
|
||||||
|
|
@ -51,15 +86,21 @@ export async function replaceEmbed(app: App, embed: Node, settings: LinkRangeSet
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
const fileContent = await vault.cachedRead(file);
|
// Post-await race guard: if a newer render kicked off while we were
|
||||||
|
// waiting, abort this one so the newer render wins.
|
||||||
let lines = fileContent.split("\n");
|
const currentGen = parseInt(outerElement.getAttribute("data-link-range-gen") || "0") || 0;
|
||||||
lines = lines.slice(res.h1Line, res.h2Line);
|
if (currentGen !== generation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const contentDiv = embedHtml.createDiv({
|
const contentDiv = embedHtml.createDiv({
|
||||||
cls: ["markdown-embed-content"]
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,17 @@ export class LifePreviewEmbedReplacer implements PluginValue {
|
||||||
decorations: DecorationSet = Decoration.none;
|
decorations: DecorationSet = Decoration.none;
|
||||||
settings: LinkRangeSettings;
|
settings: LinkRangeSettings;
|
||||||
app: App;
|
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.settings = settings;
|
||||||
this.app = app;
|
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 {
|
buildDecorations(view: EditorView): DecorationSet {
|
||||||
|
|
@ -32,8 +39,29 @@ export class LifePreviewEmbedReplacer implements PluginValue {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( update.docChanged || update.viewportChanged || update.focusChanged ) {
|
// Only rebuild on doc changes or viewport changes — NOT focusChanged.
|
||||||
this.decorations = this.buildDecorations(update.view);
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue