editor: restore scroll position by block instead of pixel offset

This commit is contained in:
Ammar Ahmed
2026-08-25 22:30:19 +05:00
parent 1af46dff79
commit 872987de0b
8 changed files with 339 additions and 7 deletions

View File

@@ -47,6 +47,7 @@ import { useStore as useUserStore } from "../../stores/user-store";
import { useStore as useSearchStore } from "../../stores/search-store";
import { AppEventManager, AppEvents } from "../../common/app-events";
import { FlexScrollContainer } from "../scroll-container";
import { ScrollAnchor } from "@notesnook/editor";
import Tiptap, { OnChangeHandler } from "./tiptap";
import Header from "./header";
import { Attachment } from "../icons";
@@ -564,7 +565,7 @@ export function Editor(props: EditorProps) {
onLoad={(editor) => {
editor = editor || useEditorManager.getState().getEditor(id)?.editor;
if (editor) restoreSelection(editor, id);
restoreScrollPosition(session);
restoreScrollPosition(session, editor);
}}
onSelectionChange={({ from, to }) => {
Config.set(`${id}:selection`, { from, to });
@@ -760,6 +761,11 @@ function EditorChrome(props: PropsWithChildren<EditorProps>) {
const scrollTop = e.target.scrollTop;
Config.set(`${id}:scroll-position`, scrollTop);
}
const anchor = useEditorManager
.getState()
.getEditor(id)
?.editor?.getScrollAnchor();
Config.set(`${id}:scroll-anchor`, anchor ?? null);
}, 500)}
>
<Flex
@@ -944,9 +950,19 @@ function isFile(e: DragEvent) {
);
}
function restoreScrollPosition(session: EditorSession) {
function restoreScrollPosition(session: EditorSession, editor?: IEditor) {
if (session?.activeBlockId) return scrollIntoViewById(session.activeBlockId);
// Restoring by block avoids the guesswork: a saved pixel offset was measured
// against a fully rendered document, and a paged one only knows estimated
// heights until it renders. The editor reveals the page holding the block
// before scrolling, so the position it lands on is the real one.
const anchor = Config.get<ScrollAnchor | null>(
`${session.id}:scroll-anchor`,
null
);
if (anchor && editor?.restoreScrollAnchor(anchor)) return;
const scrollContainer = document.getElementById(`editorScroll_${session.id}`);
const scrollPosition = Config.get(`${session.id}:scroll-position`, 0);
if (scrollContainer) {
@@ -973,7 +989,8 @@ function restoreScrollPosition(session: EditorSession) {
function restoreSelection(editor: IEditor, id: string) {
setTimeout(() => {
editor.focus({
position: Config.get(`${id}:selection`)
position: Config.get(`${id}:selection`),
scrollIntoView: false
});
});
}

View File

@@ -38,7 +38,9 @@ import {
getChangedNodes,
LinkAttributes,
fromFlatPosition,
getScrollAnchor,
profiler,
restoreScrollAnchor,
serializeDocumentHTML,
toFlatPosition,
type VirtualizationMode,
@@ -302,7 +304,7 @@ function TipTap(props: TipTapProps) {
profiler.event("editor.created");
if (oldNonce.current !== nonce)
editor.commands.focus("start", { scrollIntoView: true });
editor.commands.focus("start", { scrollIntoView: false });
oldNonce.current = nonce;
const instance = toIEditor(editor as Editor);
@@ -822,7 +824,7 @@ function toIEditor(editor: Editor): IEditor {
if (typeof position === "object")
editor
.chain()
.focus()
.focus(null, { scrollIntoView: scrollIntoView ?? true })
.setTextSelection({
from: fromFlatPosition(editor.state.doc, position.from),
to: fromFlatPosition(editor.state.doc, position.to)
@@ -871,7 +873,9 @@ function toIEditor(editor: Editor): IEditor {
from: toFlatPosition(editor.state.doc, from),
to: toFlatPosition(editor.state.doc, to)
};
}
},
getScrollAnchor: () => getScrollAnchor(editor.view),
restoreScrollAnchor: (anchor) => restoreScrollAnchor(editor.view, anchor)
};
}

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Attachment } from "@notesnook/editor";
import { ScrollAnchor } from "@notesnook/editor";
export const MAX_AUTO_SAVEABLE_WORDS = IS_TESTING ? 100 : 100_000;
@@ -53,4 +54,6 @@ export interface IEditor {
startSearch: () => void;
getContent: () => string;
getSelection: () => { from: number; to: number };
getScrollAnchor: () => ScrollAnchor | undefined;
restoreScrollAnchor: (anchor: ScrollAnchor) => boolean;
}

View File

@@ -0,0 +1,163 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { afterEach, describe, expect, test } from "vitest";
import { Editor, Node } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Page, Paging } from "../../paging/index.js";
import { BlockId } from "../../block-id/block-id.js";
import { Virtualization } from "../index.js";
import { getScrollAnchor, restoreScrollAnchor } from "../anchor.js";
const PagedDocument = Node.create({
name: "doc",
topNode: true,
content: "(page | block)+"
});
const BLOCKS = 500;
const PAGE_SIZE = 50;
const BLOCK_HEIGHT = 20;
function savedNoteHTML(n: number) {
let content = "";
for (let i = 0; i < n; i++)
content += `<p data-block-id="blk${i}">Paragraph number ${i}.</p>`;
return content;
}
function rect(top: number, height: number) {
return {
top,
bottom: top + height,
height,
left: 0,
right: 800,
width: 800,
x: 0,
y: top,
toJSON: () => ({})
} as DOMRect;
}
const original = HTMLElement.prototype.getBoundingClientRect;
/** happy-dom lays nothing out, so blocks and pages get a synthetic geometry. */
function stubLayout(editor: Editor) {
const dom = editor.view.dom as HTMLElement;
HTMLElement.prototype.getBoundingClientRect = function () {
if (this === dom) return rect(0, BLOCKS * BLOCK_HEIGHT);
// the scroll container and anything outside the editor sits at the top
if (!dom.contains(this)) return rect(0, 800);
const siblings = this.parentElement?.children;
const index = siblings ? Array.prototype.indexOf.call(siblings, this) : -1;
if (index < 0) return rect(0, 0);
if (this.parentElement === dom)
return rect(index * PAGE_SIZE * BLOCK_HEIGHT, PAGE_SIZE * BLOCK_HEIGHT);
const pageIndex = Array.prototype.indexOf.call(
dom.children,
this.parentElement
);
return rect((pageIndex * PAGE_SIZE + index) * BLOCK_HEIGHT, BLOCK_HEIGHT);
};
}
function createContainer() {
const container = document.createElement("div");
container.style.overflowY = "auto";
Object.defineProperty(container, "scrollHeight", { value: 1000000 });
Object.defineProperty(container, "clientHeight", { value: 800 });
document.body.appendChild(container);
return container;
}
function createEditor(container?: HTMLElement) {
return new Editor({
element: container,
extensions: [
StarterKit.configure({ document: false }),
PagedDocument,
Page,
BlockId,
Paging.configure({
enabled: true,
pageSize: PAGE_SIZE,
thresholdBlocks: 10
}),
Virtualization.configure({ enabled: true, unit: "pages" })
],
content: savedNoteHTML(BLOCKS)
});
}
afterEach(() => {
HTMLElement.prototype.getBoundingClientRect = original;
});
describe("scroll anchor", () => {
test("anchors on the first block in view, not a pixel offset", () => {
const editor = createEditor(createContainer());
stubLayout(editor);
const anchor = getScrollAnchor(editor.view);
expect(anchor?.blockId).toBe("blk0");
expect(anchor?.offset).toBe(0);
editor.destroy();
});
test("reveals the page holding the block before scrolling to it", () => {
const editor = createEditor(createContainer());
stubLayout(editor);
// block 300 lives in page 6, which starts out as a placeholder
const page = editor.state.doc.child(6);
expect(
(editor.view.dom.children[6] as HTMLElement).hasAttribute(
"data-virtual-placeholder"
)
).toBe(true);
const restored = restoreScrollAnchor(editor.view, {
blockId: "blk300",
offset: 0
});
expect(restored).toBe(true);
expect(
(editor.view.dom.children[6] as HTMLElement).hasAttribute(
"data-virtual-placeholder"
)
).toBe(false);
expect(
editor.view.dom.querySelector('[data-block-id="blk300"]')
).not.toBeNull();
expect(page.attrs.blockId).toBeTruthy();
editor.destroy();
});
test("reports failure for a block that is no longer there", () => {
const editor = createEditor(createContainer());
stubLayout(editor);
expect(
restoreScrollAnchor(editor.view, { blockId: "gone", offset: 0 })
).toBe(false);
editor.destroy();
});
});

View File

@@ -0,0 +1,135 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { profiler } from "../../utils/profiler.js";
import { findScrollParent, virtualizationKey } from "./viewport-plugin.js";
export type ScrollAnchor = {
/** The block that was at the top of the viewport. */
blockId: string;
/** How far above the viewport top that block started, in pixels. */
offset: number;
};
function containerOf(view: EditorView) {
const container = findScrollParent(view.dom);
const top = container ? container.getBoundingClientRect().top : 0;
return { container, top };
}
/**
* Records the position as "this block, this far up" rather than a pixel offset.
* Placeholder heights are estimates, so a document's total height changes as it
* renders — a saved pixel offset points somewhere else entirely next time the
* note is opened, while a block is always the same block.
*/
export function getScrollAnchor(view: EditorView): ScrollAnchor | undefined {
const { top } = containerOf(view);
const blocks = view.dom.querySelectorAll<HTMLElement>("[data-block-id]");
for (const element of blocks) {
// Pages carry a block id too, but theirs is regenerated every time a note
// is opened and split, so only real blocks make a durable anchor.
if (
element.hasAttribute("data-page") ||
element.hasAttribute("data-virtual-placeholder")
)
continue;
const rect = element.getBoundingClientRect();
if (rect.bottom <= top) continue;
const blockId = element.getAttribute("data-block-id");
if (!blockId) continue;
return { blockId, offset: Math.round(top - rect.top) };
}
return undefined;
}
function findBlock(
doc: ProsemirrorNode,
blockId: string
): { pageId?: string; found: boolean } {
let result: { pageId?: string; found: boolean } = { found: false };
doc.forEach((node) => {
if (result.found) return;
if (node.attrs.blockId === blockId) {
result = { found: true };
return;
}
node.forEach((child) => {
if (result.found) return;
if (child.attrs.blockId === blockId)
result = { found: true, pageId: node.attrs.blockId as string };
});
});
return result;
}
/**
* Brings the anchored block back to where it was. The page holding it is
* revealed first: a block inside a placeholder has no element to scroll to.
*/
export function restoreScrollAnchor(
view: EditorView,
anchor: ScrollAnchor
): boolean {
const end = profiler.start("virtualization.restoreAnchor");
const { container, top } = containerOf(view);
if (!container) {
end();
return false;
}
const target = findBlock(view.state.doc, anchor.blockId);
if (!target.found) {
end();
profiler.count("virtualization.restoreAnchorMissed");
return false;
}
if (target.pageId) {
const visible = virtualizationKey.getState(view.state)?.visible;
if (!visible?.has(target.pageId)) {
const next = new Set(visible ?? []);
next.add(target.pageId);
view.dispatch(
view.state.tr
.setMeta(virtualizationKey, { visible: next })
.setMeta("preventUpdate", true)
.setMeta("addToHistory", false)
);
}
}
const element = view.dom.querySelector<HTMLElement>(
`[data-block-id="${anchor.blockId}"]`
);
if (!element) {
end();
profiler.count("virtualization.restoreAnchorMissed");
return false;
}
container.scrollTop +=
element.getBoundingClientRect().top - top - anchor.offset;
end();
profiler.count("virtualization.restoreAnchors");
return true;
}

View File

@@ -124,3 +124,8 @@ export function installVirtualization(editor: Editor): void {
export { HeightMap } from "./height-map.js";
export { virtualizationKey } from "./viewport-plugin.js";
export {
getScrollAnchor,
restoreScrollAnchor,
type ScrollAnchor
} from "./anchor.js";

View File

@@ -44,7 +44,7 @@ const MATERIALIZE_SPEC = { materialize: true };
type IsPageable = (typeName: string) => boolean;
function findScrollParent(node: HTMLElement): HTMLElement | null {
export function findScrollParent(node: HTMLElement): HTMLElement | null {
let current: HTMLElement | null = node.parentElement;
while (current) {
const overflowY = getComputedStyle(current).overflowY;

View File

@@ -499,6 +499,11 @@ export {
serializeDocumentHTML,
toFlatPosition
} from "./extensions/paging/index.js";
export {
getScrollAnchor,
restoreScrollAnchor,
type ScrollAnchor
} from "./extensions/virtualization/index.js";
export * from "./utils/downloader.js";
export {
useTiptap,