From 872987de0b346fb5a95df6fb658a3aaf4527430e Mon Sep 17 00:00:00 2001 From: Ammar Ahmed Date: Tue, 25 Aug 2026 22:30:19 +0500 Subject: [PATCH] editor: restore scroll position by block instead of pixel offset --- apps/web/src/components/editor/index.tsx | 23 ++- apps/web/src/components/editor/tiptap.tsx | 10 +- apps/web/src/components/editor/types.ts | 3 + .../__tests__/scroll-anchor.test.ts | 163 ++++++++++++++++++ .../src/extensions/virtualization/anchor.ts | 135 +++++++++++++++ .../src/extensions/virtualization/index.ts | 5 + .../virtualization/viewport-plugin.ts | 2 +- packages/editor/src/index.ts | 5 + 8 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 packages/editor/src/extensions/virtualization/__tests__/scroll-anchor.test.ts create mode 100644 packages/editor/src/extensions/virtualization/anchor.ts diff --git a/apps/web/src/components/editor/index.tsx b/apps/web/src/components/editor/index.tsx index 9bbfac0fc..f37c3aa12 100644 --- a/apps/web/src/components/editor/index.tsx +++ b/apps/web/src/components/editor/index.tsx @@ -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) { 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)} > ( + `${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 }); }); } diff --git a/apps/web/src/components/editor/tiptap.tsx b/apps/web/src/components/editor/tiptap.tsx index 5cf361e16..e9d71a3c8 100644 --- a/apps/web/src/components/editor/tiptap.tsx +++ b/apps/web/src/components/editor/tiptap.tsx @@ -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) }; } diff --git a/apps/web/src/components/editor/types.ts b/apps/web/src/components/editor/types.ts index 5d39b21be..542335f58 100644 --- a/apps/web/src/components/editor/types.ts +++ b/apps/web/src/components/editor/types.ts @@ -18,6 +18,7 @@ along with this program. If not, see . */ 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; } diff --git a/packages/editor/src/extensions/virtualization/__tests__/scroll-anchor.test.ts b/packages/editor/src/extensions/virtualization/__tests__/scroll-anchor.test.ts new file mode 100644 index 000000000..95ee5eb3f --- /dev/null +++ b/packages/editor/src/extensions/virtualization/__tests__/scroll-anchor.test.ts @@ -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 . +*/ + +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 += `

Paragraph number ${i}.

`; + 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(); + }); +}); diff --git a/packages/editor/src/extensions/virtualization/anchor.ts b/packages/editor/src/extensions/virtualization/anchor.ts new file mode 100644 index 000000000..99d350449 --- /dev/null +++ b/packages/editor/src/extensions/virtualization/anchor.ts @@ -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 . +*/ + +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("[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( + `[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; +} diff --git a/packages/editor/src/extensions/virtualization/index.ts b/packages/editor/src/extensions/virtualization/index.ts index 15bee60ce..b4e50d291 100644 --- a/packages/editor/src/extensions/virtualization/index.ts +++ b/packages/editor/src/extensions/virtualization/index.ts @@ -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"; diff --git a/packages/editor/src/extensions/virtualization/viewport-plugin.ts b/packages/editor/src/extensions/virtualization/viewport-plugin.ts index 7f4514693..542ea0c6e 100644 --- a/packages/editor/src/extensions/virtualization/viewport-plugin.ts +++ b/packages/editor/src/extensions/virtualization/viewport-plugin.ts @@ -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; diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 256475449..ec8d1faa2 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -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,