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;
}