mirror of
https://github.com/alcatrazyu/better-footnote.git
synced 2026-07-22 18:00:25 +00:00
fix: improve sidebar refresh performance
This commit is contained in:
parent
27c7dbbe48
commit
cbbfd9fdfd
6 changed files with 326 additions and 23 deletions
|
|
@ -1,12 +1,13 @@
|
|||
# Better Footnote 1.4.2
|
||||
# Better Footnote 1.4.3
|
||||
|
||||
Small sidebar search usability update.
|
||||
Performance and workflow stability update for large notes.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Preserve search box focus and cursor position when the sidebar re-renders, so the first click into the search field remains usable.
|
||||
- Make the search placeholder more discoverable by hinting that `^number` or `^name` jumps directly to a footnote.
|
||||
- Keep the existing exact id search, ordinary content search, navigation, and footnote editing behavior unchanged.
|
||||
- Reduce typing lag in large notes by deferring sidebar refreshes only during real text input instead of every editor change.
|
||||
- Keep structural footnote operations responsive, including new footnotes, Tidy Footnotes renumbering, sidebar deletion, and command-driven changes.
|
||||
- Return focus to the Markdown editor after deleting a footnote from the sidebar, so Tidy Footnotes shortcuts and `Ctrl`/`Command + Z` undo work without an extra click.
|
||||
- Keep the existing footnote editing, search, exact id jump, multi-reference navigation, and deletion semantics unchanged.
|
||||
|
||||
## Release assets
|
||||
|
||||
|
|
|
|||
223
main.js
223
main.js
|
|
@ -2,6 +2,8 @@
|
|||
const VIEW_TYPE = "better-footnote-view";
|
||||
const SAVE_DELAY_MS = 450;
|
||||
const RENDER_DELAY_MS = 90;
|
||||
const EDITOR_CHANGE_REFRESH_DELAY_MS = 900;
|
||||
const CURSOR_SYNC_DELAY_MS = 80;
|
||||
const FOOTNOTE_CONTINUATION_INDENT = " ";
|
||||
const FLASH_SELECTION_MS = 1400;
|
||||
const AUTO_TIDY_DELAY_MS = 250;
|
||||
|
|
@ -82,7 +84,7 @@
|
|||
deleteConfirm: "Delete footnote",
|
||||
deleteNeedsEditor: "Open the source note before deleting a footnote.",
|
||||
deleteFailed: "Failed to delete footnote: {message}",
|
||||
deletedFootnote: "Deleted footnote [^{id}]. Click the note editor, then press {shortcut} to undo.",
|
||||
deletedFootnote: "Deleted footnote [^{id}]. Press {shortcut} to undo.",
|
||||
},
|
||||
zh: {
|
||||
title: "Better Footnote",
|
||||
|
|
@ -145,7 +147,7 @@
|
|||
deleteConfirm: "删除脚注",
|
||||
deleteNeedsEditor: "请先打开对应笔记,再删除脚注。",
|
||||
deleteFailed: "删除脚注失败:{message}",
|
||||
deletedFootnote: "已删除脚注 [^{id}]。点击笔记编辑区后按 {shortcut} 撤销。",
|
||||
deletedFootnote: "已删除脚注 [^{id}]。按 {shortcut} 可撤销。",
|
||||
},
|
||||
ja: {
|
||||
title: "Better Footnote",
|
||||
|
|
@ -208,7 +210,7 @@
|
|||
deleteConfirm: "脚注を削除",
|
||||
deleteNeedsEditor: "脚注を削除する前に、元のノートを開いてください。",
|
||||
deleteFailed: "脚注の削除に失敗しました: {message}",
|
||||
deletedFootnote: "脚注 [^{id}] を削除しました。ノート編集欄をクリックしてから {shortcut} で取り消せます。",
|
||||
deletedFootnote: "脚注 [^{id}] を削除しました。{shortcut} で取り消せます。",
|
||||
},
|
||||
ko: {
|
||||
title: "Better Footnote",
|
||||
|
|
@ -271,7 +273,7 @@
|
|||
deleteConfirm: "각주 삭제",
|
||||
deleteNeedsEditor: "각주를 삭제하기 전에 원본 노트를 여세요.",
|
||||
deleteFailed: "각주 삭제 실패: {message}",
|
||||
deletedFootnote: "각주 [^{id}]를 삭제했습니다. 노트 편집 영역을 클릭한 뒤 {shortcut}로 되돌릴 수 있습니다.",
|
||||
deletedFootnote: "각주 [^{id}]를 삭제했습니다. {shortcut}로 되돌릴 수 있습니다.",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -930,6 +932,98 @@
|
|||
return nearest && nearest.distance <= 2 ? nearest.reference : null;
|
||||
}
|
||||
|
||||
function closestElement(target, selector) {
|
||||
return target && typeof target.closest === "function" ? target.closest(selector) : null;
|
||||
}
|
||||
|
||||
function isBetterFootnoteTarget(target) {
|
||||
return Boolean(closestElement(target, ".better-footnote"));
|
||||
}
|
||||
|
||||
function isMarkdownEditorTarget(target) {
|
||||
return Boolean(closestElement(target, ".cm-editor, .markdown-source-view"));
|
||||
}
|
||||
|
||||
function isCursorNavigationKey(event) {
|
||||
return [
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
].includes(event?.key);
|
||||
}
|
||||
|
||||
function isTextEditingKey(event) {
|
||||
const key = event?.key;
|
||||
if (!key) return false;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||||
if (event.isComposing || key === "Process") return true;
|
||||
if (key.length === 1) return true;
|
||||
return ["Backspace", "Delete", "Enter", "NumpadEnter", "Tab"].includes(key);
|
||||
}
|
||||
|
||||
function isEditorTextInputEvent(event) {
|
||||
if (!event || isBetterFootnoteTarget(event.target) || !isMarkdownEditorTarget(event.target)) {
|
||||
return false;
|
||||
}
|
||||
if (event.type === "keydown") {
|
||||
return isTextEditingKey(event);
|
||||
}
|
||||
const inputType = String(event.inputType || "");
|
||||
if (!inputType) return true;
|
||||
if (inputType.startsWith("history")) return false;
|
||||
return inputType.startsWith("insert") || inputType.startsWith("delete");
|
||||
}
|
||||
|
||||
function isCommandLikeEditorKeydown(event) {
|
||||
if (!event || event.type !== "keydown") return false;
|
||||
if (isBetterFootnoteTarget(event.target) || !isMarkdownEditorTarget(event.target)) return false;
|
||||
if (isTextEditingKey(event)) return false;
|
||||
return Boolean(event.metaKey || event.ctrlKey || event.altKey || isCursorNavigationKey(event));
|
||||
}
|
||||
|
||||
function createDeferredFileScheduler({ delayMs, setTimeoutFn, clearTimeoutFn, onFlush }) {
|
||||
const timers = new Map();
|
||||
return {
|
||||
schedule(key, payload) {
|
||||
const normalizedKey = String(key || "");
|
||||
if (!normalizedKey) return;
|
||||
const existing = timers.get(normalizedKey);
|
||||
if (existing) {
|
||||
clearTimeoutFn(existing);
|
||||
}
|
||||
const timer = setTimeoutFn(() => {
|
||||
timers.delete(normalizedKey);
|
||||
onFlush(payload, normalizedKey);
|
||||
}, delayMs);
|
||||
timers.set(normalizedKey, timer);
|
||||
},
|
||||
cancel(key) {
|
||||
const normalizedKey = String(key || "");
|
||||
const existing = timers.get(normalizedKey);
|
||||
if (!existing) return;
|
||||
clearTimeoutFn(existing);
|
||||
timers.delete(normalizedKey);
|
||||
},
|
||||
clear() {
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeoutFn(timer);
|
||||
}
|
||||
timers.clear();
|
||||
},
|
||||
has(key) {
|
||||
return timers.has(String(key || ""));
|
||||
},
|
||||
size() {
|
||||
return timers.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof require !== "function") {
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = {
|
||||
|
|
@ -952,6 +1046,10 @@
|
|||
referenceIndexForFootnoteReference,
|
||||
findReferenceAtOffset,
|
||||
findDefinitionAtOffset,
|
||||
isTextEditingKey,
|
||||
isEditorTextInputEvent,
|
||||
isCommandLikeEditorKeydown,
|
||||
createDeferredFileScheduler,
|
||||
};
|
||||
}
|
||||
return;
|
||||
|
|
@ -985,6 +1083,10 @@
|
|||
deletedFootnoteRecordMatchesFootnote,
|
||||
normalizeReferenceIndex,
|
||||
referenceIndexForFootnoteReference,
|
||||
isTextEditingKey,
|
||||
isEditorTextInputEvent,
|
||||
isCommandLikeEditorKeydown,
|
||||
createDeferredFileScheduler,
|
||||
};
|
||||
}
|
||||
return;
|
||||
|
|
@ -1118,6 +1220,13 @@
|
|||
this.pendingTidyKeys = new Set();
|
||||
this.tidyMissingNoticeShown = false;
|
||||
this.suppressCursorSyncUntil = 0;
|
||||
this.typingCursorSyncSuppressUntil = 0;
|
||||
this.deferredEditorRefresh = createDeferredFileScheduler({
|
||||
delayMs: EDITOR_CHANGE_REFRESH_DELAY_MS,
|
||||
setTimeoutFn: (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimeoutFn: (timer) => window.clearTimeout(timer),
|
||||
onFlush: ({ file }) => this.flushDeferredEditorRefresh(file),
|
||||
});
|
||||
this.activeDeleteNotice = null;
|
||||
this.recentlyDeletedFootnotesByFile = new Map();
|
||||
this.restoredDeletedFootnoteCursorSuppressionsByFile = new Map();
|
||||
|
|
@ -1143,11 +1252,16 @@
|
|||
this.registerEvent(this.app.workspace.on("file-open", () => this.onWorkspaceContextChanged()));
|
||||
this.registerEvent(this.app.workspace.on("editor-change", () => this.onEditorChanged()));
|
||||
this.registerEvent(this.app.vault.on("modify", (file) => {
|
||||
if (isMarkdownFile(file)) this.refreshViews(file);
|
||||
if (isMarkdownFile(file)) this.onVaultFileModified(file);
|
||||
}));
|
||||
this.registerDomEvent(document, "beforeinput", (event) => this.onEditorTextInputEvent(event));
|
||||
this.registerDomEvent(document, "keydown", (event) => this.onEditorKeydown(event));
|
||||
this.registerDomEvent(document, "pointerdown", (event) => this.onDocumentPointerDown(event));
|
||||
this.registerDomEvent(document, "selectionchange", () => this.scheduleCursorSync());
|
||||
this.registerDomEvent(document, "keyup", () => this.scheduleCursorSync());
|
||||
this.registerDomEvent(document, "mouseup", () => this.scheduleCursorSync());
|
||||
this.registerDomEvent(document, "keyup", (event) => {
|
||||
this.scheduleCursorSync({ force: isCursorNavigationKey(event) });
|
||||
});
|
||||
this.registerDomEvent(document, "mouseup", () => this.scheduleCursorSync({ force: true }));
|
||||
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
this.trackCurrentMarkdownFile();
|
||||
|
|
@ -1162,6 +1276,7 @@
|
|||
if (this.flashSelectionTimer !== null) {
|
||||
window.clearTimeout(this.flashSelectionTimer);
|
||||
}
|
||||
this.deferredEditorRefresh?.clear();
|
||||
this.hideActiveDeleteNotice();
|
||||
this.pendingTidyKeys.clear();
|
||||
this.recentlyDeletedFootnotesByFile.clear();
|
||||
|
|
@ -1193,16 +1308,77 @@
|
|||
onWorkspaceContextChanged() {
|
||||
this.trackCurrentMarkdownFile();
|
||||
this.refreshViews();
|
||||
this.scheduleCursorSync();
|
||||
this.scheduleCursorSync({ force: true });
|
||||
}
|
||||
|
||||
onEditorChanged() {
|
||||
this.trackCurrentMarkdownFile();
|
||||
this.refreshViews(this.lastMarkdownFile);
|
||||
this.scheduleCursorSync();
|
||||
const file = this.trackCurrentMarkdownFile();
|
||||
if (!file) return;
|
||||
if (this.isTypingActive()) {
|
||||
this.scheduleDeferredEditorRefresh(file);
|
||||
return;
|
||||
}
|
||||
this.refreshViews(file);
|
||||
this.scheduleCursorSync({ force: true, delay: RENDER_DELAY_MS + CURSOR_SYNC_DELAY_MS });
|
||||
}
|
||||
|
||||
onVaultFileModified(file) {
|
||||
if (this.isTypingActive() && this.lastMarkdownFile?.path === file?.path) {
|
||||
this.scheduleDeferredEditorRefresh(file);
|
||||
return;
|
||||
}
|
||||
this.refreshViews(file);
|
||||
}
|
||||
|
||||
onEditorTextInputEvent(event) {
|
||||
if (isEditorTextInputEvent(event)) {
|
||||
this.markTypingActive();
|
||||
}
|
||||
}
|
||||
|
||||
onEditorKeydown(event) {
|
||||
if (isEditorTextInputEvent(event)) {
|
||||
this.markTypingActive();
|
||||
} else if (isCommandLikeEditorKeydown(event)) {
|
||||
this.clearTypingActive();
|
||||
}
|
||||
}
|
||||
|
||||
onDocumentPointerDown(event) {
|
||||
if (!isBetterFootnoteTarget(event?.target)) {
|
||||
this.clearTypingActive();
|
||||
}
|
||||
}
|
||||
|
||||
markTypingActive() {
|
||||
this.typingCursorSyncSuppressUntil = Date.now() + EDITOR_CHANGE_REFRESH_DELAY_MS;
|
||||
}
|
||||
|
||||
clearTypingActive() {
|
||||
this.typingCursorSyncSuppressUntil = 0;
|
||||
}
|
||||
|
||||
isTypingActive() {
|
||||
return Date.now() < this.typingCursorSyncSuppressUntil;
|
||||
}
|
||||
|
||||
scheduleDeferredEditorRefresh(file) {
|
||||
if (!file?.path) return;
|
||||
this.deferredEditorRefresh.schedule(file.path, { file });
|
||||
}
|
||||
|
||||
flushDeferredEditorRefresh(file) {
|
||||
if (!file?.path) return;
|
||||
this.refreshViews(file);
|
||||
this.scheduleCursorSync({ force: true, delay: RENDER_DELAY_MS + CURSOR_SYNC_DELAY_MS });
|
||||
}
|
||||
|
||||
refreshViews(file = null) {
|
||||
if (file?.path) {
|
||||
this.deferredEditorRefresh?.cancel(file.path);
|
||||
} else {
|
||||
this.deferredEditorRefresh?.clear();
|
||||
}
|
||||
for (const view of this.views) {
|
||||
if (!file || !view.file || view.file.path === file.path) {
|
||||
view.scheduleRender();
|
||||
|
|
@ -1210,17 +1386,24 @@
|
|||
}
|
||||
}
|
||||
|
||||
scheduleCursorSync() {
|
||||
scheduleCursorSync(options = {}) {
|
||||
const force = Boolean(options.force);
|
||||
if (!force && this.isTypingActive()) {
|
||||
return;
|
||||
}
|
||||
if (this.cursorSyncTimer !== null) {
|
||||
window.clearTimeout(this.cursorSyncTimer);
|
||||
}
|
||||
this.cursorSyncTimer = window.setTimeout(() => {
|
||||
this.cursorSyncTimer = null;
|
||||
this.syncCursorToViews();
|
||||
}, 80);
|
||||
this.syncCursorToViews({ force });
|
||||
}, typeof options.delay === "number" ? options.delay : CURSOR_SYNC_DELAY_MS);
|
||||
}
|
||||
|
||||
syncCursorToViews() {
|
||||
syncCursorToViews(options = {}) {
|
||||
if (!options.force && this.isTypingActive()) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() < this.suppressCursorSyncUntil) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1574,6 +1757,7 @@
|
|||
}
|
||||
this.rememberDeletedFootnote(file, result.footnote);
|
||||
this.suppressCursorSyncFromSidebarJump();
|
||||
this.focusMarkdownEditor(markdownView);
|
||||
this.refreshViews(file);
|
||||
this.showFootnoteDeletedNotice(footnoteId);
|
||||
return true;
|
||||
|
|
@ -1610,6 +1794,15 @@
|
|||
return false;
|
||||
}
|
||||
|
||||
focusMarkdownEditor(markdownView) {
|
||||
const editor = markdownView?.editor;
|
||||
if (editor && typeof editor.focus === "function") {
|
||||
editor.focus();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
showFootnoteDeletedNotice(footnoteId) {
|
||||
const strings = getStrings();
|
||||
this.hideActiveDeleteNotice();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "better-footnote",
|
||||
"name": "Better Footnote",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "A writing-focused footnote sidebar for editing, navigating, and preserving context in academic Markdown notes.",
|
||||
"author": "AlcatrazYU",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "better-footnote",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"private": true,
|
||||
"description": "A writing-focused Obsidian sidebar for academic Markdown footnotes.",
|
||||
"author": "AlcatrazYU",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,70 @@ assert.deepEqual(
|
|||
["17", "citation", "citation"],
|
||||
);
|
||||
|
||||
const markdownEditorTarget = {
|
||||
closest(selector) {
|
||||
return selector.includes(".cm-editor") || selector.includes(".markdown-source-view") ? {} : null;
|
||||
},
|
||||
};
|
||||
const betterFootnoteTarget = {
|
||||
closest(selector) {
|
||||
if (selector.includes(".better-footnote")) return {};
|
||||
return selector.includes(".cm-editor") || selector.includes(".markdown-source-view") ? {} : null;
|
||||
},
|
||||
};
|
||||
const nonEditorTarget = {
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(parser.isTextEditingKey({ key: "a" }), true);
|
||||
assert.equal(parser.isTextEditingKey({ key: "Backspace" }), true);
|
||||
assert.equal(parser.isTextEditingKey({ key: "a", metaKey: true }), false);
|
||||
assert.equal(parser.isTextEditingKey({ key: "ArrowDown" }), false);
|
||||
assert.equal(parser.isEditorTextInputEvent({
|
||||
type: "beforeinput",
|
||||
inputType: "insertText",
|
||||
target: markdownEditorTarget,
|
||||
}), true);
|
||||
assert.equal(parser.isEditorTextInputEvent({
|
||||
type: "beforeinput",
|
||||
inputType: "historyUndo",
|
||||
target: markdownEditorTarget,
|
||||
}), false);
|
||||
assert.equal(parser.isEditorTextInputEvent({
|
||||
type: "keydown",
|
||||
key: "x",
|
||||
target: markdownEditorTarget,
|
||||
}), true);
|
||||
assert.equal(parser.isEditorTextInputEvent({
|
||||
type: "beforeinput",
|
||||
inputType: "insertText",
|
||||
target: betterFootnoteTarget,
|
||||
}), false);
|
||||
assert.equal(parser.isEditorTextInputEvent({
|
||||
type: "beforeinput",
|
||||
inputType: "insertText",
|
||||
target: nonEditorTarget,
|
||||
}), false);
|
||||
assert.equal(parser.isCommandLikeEditorKeydown({
|
||||
type: "keydown",
|
||||
key: "t",
|
||||
metaKey: true,
|
||||
target: markdownEditorTarget,
|
||||
}), true);
|
||||
assert.equal(parser.isCommandLikeEditorKeydown({
|
||||
type: "keydown",
|
||||
key: "ArrowDown",
|
||||
target: markdownEditorTarget,
|
||||
}), true);
|
||||
assert.equal(parser.isCommandLikeEditorKeydown({
|
||||
type: "keydown",
|
||||
key: "t",
|
||||
metaKey: true,
|
||||
target: betterFootnoteTarget,
|
||||
}), false);
|
||||
|
||||
const outOfOrder = parser.parseFootnotes([
|
||||
"先引用新增脚注[^52],再引用旧脚注[^49]。",
|
||||
"",
|
||||
|
|
@ -242,4 +306,48 @@ assert.equal(
|
|||
"49",
|
||||
);
|
||||
|
||||
{
|
||||
const scheduled = new Map();
|
||||
const cleared = new Set();
|
||||
const flushed = [];
|
||||
let nextTimerId = 1;
|
||||
const scheduler = parser.createDeferredFileScheduler({
|
||||
delayMs: 900,
|
||||
setTimeoutFn(callback, delay) {
|
||||
const id = nextTimerId++;
|
||||
scheduled.set(id, { callback, delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeoutFn(id) {
|
||||
cleared.add(id);
|
||||
scheduled.delete(id);
|
||||
},
|
||||
onFlush(payload, key) {
|
||||
flushed.push({ payload, key });
|
||||
},
|
||||
});
|
||||
|
||||
for (let index = 0; index < 56; index += 1) {
|
||||
scheduler.schedule("large.md", { file: { path: "large.md" }, index });
|
||||
}
|
||||
|
||||
assert.equal(scheduler.size(), 1);
|
||||
assert.equal(cleared.size, 55);
|
||||
assert.equal(flushed.length, 0);
|
||||
const [timer] = scheduled.values();
|
||||
assert.equal(timer.delay, 900);
|
||||
timer.callback();
|
||||
assert.equal(scheduler.size(), 0);
|
||||
assert.deepEqual(flushed, [
|
||||
{ payload: { file: { path: "large.md" }, index: 55 }, key: "large.md" },
|
||||
]);
|
||||
|
||||
scheduler.schedule("a.md", { file: { path: "a.md" } });
|
||||
scheduler.schedule("b.md", { file: { path: "b.md" } });
|
||||
assert.equal(scheduler.has("a.md"), true);
|
||||
assert.equal(scheduler.has("b.md"), true);
|
||||
scheduler.clear();
|
||||
assert.equal(scheduler.size(), 0);
|
||||
}
|
||||
|
||||
console.log("parser tests passed");
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@
|
|||
"1.3.0": "1.5.0",
|
||||
"1.4.0": "1.5.0",
|
||||
"1.4.1": "1.5.0",
|
||||
"1.4.2": "1.5.0"
|
||||
"1.4.2": "1.5.0",
|
||||
"1.4.3": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue