mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-02 04:01:43 +02:00
editor: restore scroll position by block instead of pixel offset
This commit is contained in:
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
135
packages/editor/src/extensions/virtualization/anchor.ts
Normal file
135
packages/editor/src/extensions/virtualization/anchor.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user