editor: anchor the scroll position even on pages that have not rendered

This commit is contained in:
Ammar Ahmed
2026-08-25 22:51:19 +05:00
parent 78cdc390b3
commit 5959364de5
3 changed files with 56 additions and 16 deletions

View File

@@ -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]);

View File

@@ -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);

View File

@@ -20,6 +20,7 @@ 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 { 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<HTMLElement>("[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;
}