diff --git a/apps/web/src/components/editor/index.tsx b/apps/web/src/components/editor/index.tsx index 5ae7a6d7a..bad71d576 100644 --- a/apps/web/src/components/editor/index.tsx +++ b/apps/web/src/components/editor/index.tsx @@ -547,6 +547,10 @@ export function Editor(props: EditorProps) { const editor = useEditorManager.getState().getEditor(id)?.editor; const selection = editor?.getSelection(); if (selection) Config.set(`${id}:selection`, selection); + // the scroll handler is debounced, so a reload right after scrolling + // would otherwise lose the position + const anchor = editor?.getScrollAnchor(); + if (anchor) Config.set(`${id}:scroll-anchor`, anchor); }; }, [id]); diff --git a/packages/editor/src/extensions/virtualization/__tests__/scroll-anchor.test.ts b/packages/editor/src/extensions/virtualization/__tests__/scroll-anchor.test.ts index 95ee5eb3f..f06dd8909 100644 --- a/packages/editor/src/extensions/virtualization/__tests__/scroll-anchor.test.ts +++ b/packages/editor/src/extensions/virtualization/__tests__/scroll-anchor.test.ts @@ -59,22 +59,28 @@ function rect(top: number, height: number) { const original = HTMLElement.prototype.getBoundingClientRect; /** happy-dom lays nothing out, so blocks and pages get a synthetic geometry. */ -function stubLayout(editor: Editor) { +function stubLayout(editor: Editor, scrollTop = 0) { const dom = editor.view.dom as HTMLElement; HTMLElement.prototype.getBoundingClientRect = function () { - if (this === dom) return rect(0, BLOCKS * BLOCK_HEIGHT); + if (this === dom) return rect(-scrollTop, 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); + return rect( + index * PAGE_SIZE * BLOCK_HEIGHT - scrollTop, + PAGE_SIZE * BLOCK_HEIGHT + ); const pageIndex = Array.prototype.indexOf.call( dom.children, this.parentElement ); - return rect((pageIndex * PAGE_SIZE + index) * BLOCK_HEIGHT, BLOCK_HEIGHT); + return rect( + (pageIndex * PAGE_SIZE + index) * BLOCK_HEIGHT - scrollTop, + BLOCK_HEIGHT + ); }; } @@ -151,6 +157,21 @@ describe("scroll anchor", () => { editor.destroy(); }); + test("anchors on a placeholder page using the document", () => { + const editor = createEditor(createContainer()); + // scrolled so the fold sits on page 6, which has never rendered + stubLayout(editor, 6 * PAGE_SIZE * BLOCK_HEIGHT); + expect( + (editor.view.dom.children[6] as HTMLElement).hasAttribute( + "data-virtual-placeholder" + ) + ).toBe(true); + + const anchor = getScrollAnchor(editor.view); + expect(anchor?.blockId).toBe("blk300"); + editor.destroy(); + }); + test("reports failure for a block that is no longer there", () => { const editor = createEditor(createContainer()); stubLayout(editor); diff --git a/packages/editor/src/extensions/virtualization/anchor.ts b/packages/editor/src/extensions/virtualization/anchor.ts index 99d350449..9d86367f9 100644 --- a/packages/editor/src/extensions/virtualization/anchor.ts +++ b/packages/editor/src/extensions/virtualization/anchor.ts @@ -20,6 +20,7 @@ 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 { isPage } from "../paging/split.js"; import { findScrollParent, virtualizationKey } from "./viewport-plugin.js"; export type ScrollAnchor = { @@ -43,21 +44,35 @@ function containerOf(view: EditorView) { */ export function getScrollAnchor(view: EditorView): ScrollAnchor | undefined { const { top } = containerOf(view); - const blocks = view.dom.querySelectorAll("[data-block-id]"); + const children = view.dom.children; - 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; + for (let i = 0; i < children.length; i++) { + const element = children[i] as HTMLElement; 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) }; + + const node = view.state.doc.child(i); + if (!isPage(node)) { + const blockId = node.attrs.blockId as string | undefined; + return blockId + ? { blockId, offset: Math.round(top - rect.top) } + : undefined; + } + + // Inside a rendered page, anchor on the exact block at the fold. + for (const child of Array.from(element.children)) { + const childRect = child.getBoundingClientRect(); + if (childRect.bottom <= top) continue; + const blockId = child.getAttribute("data-block-id"); + if (blockId) return { blockId, offset: Math.round(top - childRect.top) }; + } + + // A page that has not rendered has no blocks to inspect, but the document + // still knows which block it starts with. + const blockId = node.firstChild?.attrs.blockId as string | undefined; + return blockId + ? { blockId, offset: Math.round(top - rect.top) } + : undefined; } return undefined; }