Compare commits

..

1 Commits

Author SHA1 Message Date
claude_code 768d135a19 fix(editor): smooth scroll-position restore, no yank on early scroll (#266)
The reading-position restore fired only after collab sync (`!showStatic`),
so the page painted at the top and then visibly jumped — and readers who had
already started scrolling were rudely yanked back to the saved position.

- Abort restore permanently on genuine user scroll intent: `wheel`/`touchstart`
  unconditionally, and `keydown` only for real scroll keys (Arrow/Page/Home/End/
  Space) so shortcuts and typing do not disable it. Our own `window.scrollTo`
  never emits these, so restore cannot self-abort.
- Restore earlier via `useLayoutEffect` (before paint) while the static/cached
  content is laid out, and re-assert once after the static->live editor swap.
- Make `restoreScrollPosition` idempotent with a redundancy guard so the two
  triggers never double-scroll; share one bounded timeout budget across them.
- Add tests for interaction-abort, scroll-key filtering, idempotence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:37:49 +03:00
10 changed files with 204 additions and 248 deletions
+2 -70
View File
@@ -14,10 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. The row is
centered horizontally by default in modern browsers (CSS `:has()`), falling
back to start-aligned rows in browsers without support. Unlike the float
modes, text does not wrap around inline images. The mode round-trips
images as a row that wraps onto the next line on narrow screens. Unlike the
float modes, text does not wrap around inline images. The mode round-trips
losslessly through markdown as `data-align`, like the other alignment
values.
@@ -86,53 +84,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
The mark is preserved losslessly through Markdown export/import (as a raw
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
- **Dock the AI chat window into the side menu.** The floating chat window can
be pinned to the sidebar — drag it onto the navbar (a drop-zone highlight
shows where it lands) or use the new "Dock to sidebar" header button; while
docked it fills the sidebar area and follows its live size. "Undock" (or
dragging it back out) restores the floating window, a collapsed/absent
sidebar falls back to floating, and the docked state survives a reload.
(#276, #282)
- **Hovering commented text shows the comment thread in a tooltip.** Pointing
at a highlighted comment mark pops a small card with the author and plain
text of the root comment and its replies, so a thread can be skimmed without
opening the side panel. The card appears after a short delay (no flicker on a
passing glance), skips resolved and text-less threads, and dismisses on
scroll or click — clicking a mark still opens the comments panel. (#268,
#271)
- **"Move to trash" button in the temporary-note banner.** Besides "Make
permanent", the banner on an open temporary note now also offers to trash the
note immediately instead of waiting out its lifetime. It reuses the regular
soft-delete path, so the "Page moved to trash" undo toast is the safety net —
no confirmation dialog. (#273, #277)
- **Code-block controls float as an overlay instead of taking a row above the
code.** The language selector and copy button now sit in the block's top-right
corner, and the selector stays invisible until the block is hovered or the
selector is focused, so reading code is chrome-free. In read-only views only
the copy button renders. (#275, #278)
- **The AI agent is told about your page edits between turns.** The server
snapshots the open page's Markdown at the end of every agent turn and, on the
next turn, injects a unified diff of what changed in between, so the agent
knows its earlier copy of the page is stale and builds on the user's edits
instead of reverting or overwriting them. The diff is whitespace-normalized
(pure formatting churn injects nothing) and size-capped, with a hint to
re-read the full page via `getPage` when truncated. (#274, #281)
- **Stress-accent button (U+0301) in the bubble menu.** Select a vowel and
toggle a combining acute accent over it — a Russian-style stress mark. The
accent is stored as plain text (no custom mark), so it survives Markdown/HTML
export, full-text search and public shares unchanged; the toggle is a single
undo step and re-clicking removes the accent. (#270, #280)
- **Reading position survives a reload.** The editor remembers how far you
scrolled in each page (per tab, in `sessionStorage`) and restores that
position after an F5 or reopening the document, waiting for the collaborative
content to finish laying out first. A URL `#hash` anchor still wins — restore
is a no-op then. (#266, #267)
- **The slash menu finds commands typed in the wrong keyboard layout.** A query
typed with the wrong layout active (e.g. `/сщву` for `/code`, or `/cyjcrf`
for the Cyrillic «сноска» → Footnote) is additionally remapped ЙЦУКЕН↔QWERTY
by physical key position and matched against the commands; genuine Cyrillic
search terms keep priority over remapped candidates, and short wrong-layout
prefixes match by command title. (#283, #285, #287)
### Changed
@@ -198,25 +149,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
emits a single-use "intentional clear" signal that lets exactly that one empty
write through the guard, so genuinely emptying a page is persisted while
accidental empties are blocked. (#248, #251)
- **Ctrl+Z works again right after using a table menu.** Closing a table
row/column menu (grip or chevron) left focus on the menu's portaled target
outside the editor, so undo keystrokes went nowhere until you clicked back
into a cell. The editor is now refocused after the menu closes — unless you
deliberately moved focus to another input or editable (e.g. the page title).
(#269, #279)
- **The AI reindex progress counter no longer freezes at 0.** Right after
"Reindex now" the client could read the stale pre-reindex snapshot of an
already-indexed workspace (`reindexing=false`, all pages counted) as
"finished" and stop polling on the very first tick, leaving the counter
frozen until a manual reload. Polling now keeps going until it has actually
observed the active run. (#262, #264)
- **An MCP edit can no longer be silently lost to a duplicate collab document.**
When the agent addressed a page by its short slugId, the MCP opened a
collaboration document named after that slugId while the web editor always
uses the page's canonical UUID — two independent live documents for one page,
whose debounced stores clobbered each other. The MCP now resolves every page
id to the canonical UUID before opening the collab doc (a UUID input
short-circuits locally; a slugId is resolved once and cached). (#260, #265)
### Security
+3 -6
View File
@@ -104,7 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
-**Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
-**Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
-**Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
-**Temporary notes**create a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview.
-**Temporary notes**mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
### In progress
@@ -187,17 +187,14 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
- Spaces
- Permissions management
- Groups
- Comments (with resolve / re-open and hover tooltips showing the comment text)
- Comments (with resolve / re-open)
- Page history
- Search
- File attachments
- Embeds (Airtable, Loom, Miro and more)
- Translations (10+ languages)
- Embedded MCP server (`/mcp`)
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access); the chat window docks into the side menu, and the agent is told about your in-page edits between turns
- Code-block buttons as an overlay, with the language selector revealed on hover
- Stress-accent button (U+0301) in the bubble menu
- Reading scroll position restored on reload
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
### Screenshots
+3 -7
View File
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
-**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
-**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
-**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
-**Временные заметки**создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
-**Временные заметки**пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
### В процессе
@@ -174,18 +174,14 @@ dump/restore, существующий каталог данных переис
- Пространства (Spaces)
- Управление правами доступа
- Группы
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
- Комментарии (с резолвом / переоткрытием)
- История страниц
- Поиск
- Вложения файлов
- Встраивания (Airtable, Loom, Miro и другие)
- Переводы (10+ языков)
- Встроенный MCP-сервер (`/mcp`)
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
- Кнопка «Ударение» (U+0301) в bubble-меню
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
### Скриншоты
@@ -1,8 +1,5 @@
import { describe, it, expect } from "vitest";
import {
normalizeTableColumnWidths,
classifyClipboardSelection,
} from "./markdown-clipboard";
import { normalizeTableColumnWidths } from "./markdown-clipboard";
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
function root(html: string): HTMLElement {
@@ -127,47 +124,3 @@ describe("normalizeTableColumnWidths", () => {
).toEqual([null, null]);
});
});
describe("classifyClipboardSelection", () => {
it("serializes a list of 2+ items as markdown", () => {
expect(
classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]),
).toEqual({ asMarkdown: true, wrapBareRows: false });
});
it("leaves a single-item list as plain text", () => {
expect(
classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]),
).toEqual({ asMarkdown: false, wrapBareRows: false });
});
it("serializes a whole table without wrapping bare rows", () => {
expect(
classifyClipboardSelection([{ name: "table", childCount: 3 }]),
).toEqual({ asMarkdown: true, wrapBareRows: false });
});
it("serializes a partial cell selection (bare rows) and flags wrapping", () => {
expect(
classifyClipboardSelection([
{ name: "tableRow", childCount: 2 },
{ name: "tableRow", childCount: 2 },
]),
).toEqual({ asMarkdown: true, wrapBareRows: true });
});
it("leaves plain paragraphs as plain text", () => {
expect(
classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]),
).toEqual({ asMarkdown: false, wrapBareRows: false });
});
it("does not wrap when rows are mixed with other block types", () => {
expect(
classifyClipboardSelection([
{ name: "tableRow", childCount: 2 },
{ name: "paragraph", childCount: 1 },
]),
).toEqual({ asMarkdown: false, wrapBareRows: false });
});
});
@@ -27,36 +27,24 @@ export const MarkdownClipboard = Extension.create({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const topLevelNodes: { name: string; childCount: number }[] = [];
const listTypes = ["bulletList", "orderedList", "taskList"];
let topLevelCount = 0;
let hasList = false;
slice.content.forEach((node) => {
topLevelNodes.push({
name: node.type.name,
childCount: node.childCount,
});
if (listTypes.includes(node.type.name)) {
hasList = true;
topLevelCount += node.childCount;
} else {
topLevelCount++;
}
});
const { asMarkdown, wrapBareRows } =
classifyClipboardSelection(topLevelNodes);
if (!asMarkdown) return null;
if (!hasList || topLevelCount < 2) return null;
const div = document.createElement("div");
const serializer = DOMSerializer.fromSchema(this.editor.schema);
const fragment = serializer.serializeFragment(slice.content);
if (wrapBareRows) {
// A partial table cell-selection serializes to bare <tr> nodes
// (prosemirror-tables returns the whole `table` node only when the
// entire table is selected). Bare <tr> would be foster-parented
// away by the HTML parser inside htmlToMarkdown, so wrap them in
// <table><tbody> first for the GFM turndown rule to detect them.
const table = document.createElement("table");
const tbody = document.createElement("tbody");
tbody.appendChild(fragment);
table.appendChild(tbody);
div.appendChild(table);
} else {
div.appendChild(fragment);
}
div.appendChild(fragment);
return htmlToMarkdown(div.innerHTML);
},
handlePaste: (view, event, slice) => {
@@ -165,55 +153,6 @@ export const MarkdownClipboard = Extension.create({
},
});
/**
* Decide whether a copied slice's plain-text clipboard payload should be
* serialized as Markdown (instead of ProseMirror's default text serializer,
* which joins block leaves with newlines — the "one value per line" bug for
* tables).
*
* Serialize as Markdown for structured content:
* - lists with 2+ total items (a single copied bullet stays literal text);
* - a whole table (top-level `table` node);
* - a partial table cell-selection, which prosemirror-tables copies as bare
* `tableRow` nodes (only a full-table selection yields a `table` node).
*
* `wrapBareRows` flags the bare-rows case so the caller wraps the serialized
* <tr> nodes in <table><tbody> before the HTML->Markdown step. Plain paragraphs
* return asMarkdown=false so a simple text copy stays literal, and internal
* copy/paste keeps using the richer text/html clipboard payload.
*/
export function classifyClipboardSelection(
nodes: { name: string; childCount: number }[],
): { asMarkdown: boolean; wrapBareRows: boolean } {
const listTypes = ["bulletList", "orderedList", "taskList"];
let topLevelCount = 0;
let hasList = false;
let hasTable = false;
let tableRowCount = 0;
let nonRowCount = 0;
for (const node of nodes) {
if (listTypes.includes(node.name)) {
hasList = true;
topLevelCount += node.childCount;
nonRowCount++;
} else {
if (node.name === "table") hasTable = true;
if (node.name === "tableRow") tableRowCount++;
else nonRowCount++;
topLevelCount++;
}
}
// Bare tableRow nodes at the top level only occur for a partial cell
// selection; a slice never mixes bare rows with other block types, so
// "every top-level node is a row" is a safe signal to wrap-and-serialize.
const wrapBareRows = tableRowCount > 0 && nonRowCount === 0;
const asMarkdown =
(hasList && topLevelCount >= 2) || hasTable || wrapBareRows;
return { asMarkdown, wrapBareRows };
}
/**
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
@@ -100,7 +100,7 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(a3) restores at most once per mount even if called again", () => {
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
@@ -111,8 +111,12 @@ describe("useScrollPosition", () => {
});
expect(window.scrollTo).toHaveBeenCalledTimes(1);
// Simulate the browser now being at the restored position.
setScrollY(500);
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
// restoreScrollPosition]) must NOT scroll again and yank the reader.
// restoreScrollPosition]) must NOT scroll again: the redundancy guard sees
// the window is already at the target and does nothing.
act(() => {
result.current.restoreScrollPosition();
});
@@ -162,6 +166,84 @@ describe("useScrollPosition", () => {
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("g1"));
// The reader shows scroll intent before restore is triggered.
act(() => {
window.dispatchEvent(new Event("wheel"));
});
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(h) aborts an in-flight restore poll when the reader scrolls", () => {
vi.useFakeTimers();
window.sessionStorage.setItem(`${KEY_PREFIX}h1`, "500");
setInnerHeight(800);
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
const { result } = renderHook(() => useScrollPosition("h1"));
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
// The reader takes over mid-poll: this cancels the in-flight poll.
act(() => {
window.dispatchEvent(new Event("wheel"));
});
// Content of the page grows tall enough and time passes: the cancelled poll
// must NOT resurrect and yank the reader.
setScrollHeight(2000);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(i) a non-scroll keydown does NOT abort restore", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("i1"));
// A non-scroll key (e.g. typing, a shortcut) must NOT count as scroll intent.
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
});
act(() => {
result.current.restoreScrollPosition();
});
// Restore still happens: the innocuous keypress did not disable it.
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
});
it("(j) a scroll keydown (Space) DOES abort restore", () => {
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
const { result } = renderHook(() => useScrollPosition("j1"));
// Space scrolls the page: this is real scroll intent and must abort restore.
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
});
act(() => {
result.current.restoreScrollPosition();
});
expect(window.scrollTo).not.toHaveBeenCalled();
});
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
// Nothing saved.
const a = renderHook(() => useScrollPosition("nope"));
@@ -13,6 +13,18 @@ const RESTORE_POLL_MS = 100;
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
const STORAGE_PREFIX = "gitmost:scroll-position:";
// Keys that scroll the window. Only these count as scroll intent for keydown;
// other keys (shortcuts, modifiers, typing) must NOT disable scroll restore.
const SCROLL_KEYS = new Set([
"ArrowUp",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
" ", // Space (and Shift+Space) scroll the page
]);
function storageKey(pageId: string): string {
return `${STORAGE_PREFIX}${pageId}`;
}
@@ -48,32 +60,41 @@ function writeStorage(pageId: string, scrollY: number): void {
* Persists and restores the window scroll position per page so a reader keeps
* their place across a reload (F5) or reopening the document.
*
* Returns `restoreScrollPosition`, which the page editor calls once the live
* (non-static) content is laid out. The two scroll mechanisms are mutually
* exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
* wins and restore is a no-op.
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
* (early, while the static/cached content is laid out, and again after the
* static->live editor swap); it is idempotent, so re-asserting the same target is
* a no-op. The two scroll mechanisms are mutually exclusive: if the URL has a
* `#hash` anchor, the existing anchor-scroll logic wins and restore is a no-op.
*/
export function useScrollPosition(pageId: string): {
restoreScrollPosition: () => void;
} {
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
// hook instance with fresh refs. These refs latch per-mount and are NOT reset
// when `pageId` changes in place (only the effect re-runs on [pageId]). If that
// `key={page.id}` is ever removed, restore would silently break on the 2nd page
// (refs would hold the first page's target / already-restored flag) — in that
// case the refs must be reset on a pageId change.
// hook instance with fresh refs. Restore is idempotent and interaction-gated
// (not single-shot): it may be called from several triggers and re-asserts the
// SAME captured target, which is a no-op once the window is already positioned.
// The per-mount refs that latch are `initialTargetRef` (the captured target)
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT
// reset when `pageId` changes in place (only the effect re-runs on [pageId]).
// If that `key={page.id}` is ever removed, restore would silently break on the
// 2nd page (refs would hold the first page's target / interaction flag) — in
// that case the refs must be reset on a pageId change.
//
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
// handler can overwrite the stored value with a fresh 0 (the page starts
// scrolled to top on load). `null` means "not yet captured".
const initialTargetRef = useRef<number | null>(null);
// Guards so restore runs at most once per page mount.
const hasRestoredRef = useRef(false);
// Set once the reader shows unambiguous scroll intent; restore must never yank
// a reader who has already started scrolling.
const userInteractedRef = useRef(false);
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
const pollTimerRef = useRef<number | null>(null);
// Timestamp of the FIRST restore attempt so re-triggers (e.g. the static→live
// editor swap) share ONE bounded timeout budget instead of restarting it.
const restoreStartRef = useRef<number | null>(null);
// Capture the previously-saved value synchronously during render, before the
// effect below registers handlers that would persist the current (0) scrollY.
@@ -114,14 +135,43 @@ export function useScrollPosition(pageId: string): {
}
};
// User scroll-intent signals. wheel and touch are unconditional scroll
// intent; keydown is filtered to actual scroll keys only (SCROLL_KEYS) so
// shortcuts, lone modifiers, and typing do not abort restore. Our own
// window.scrollTo does NOT emit these, so restore can never self-abort via
// them. Once the reader shows intent we mark it and cancel any in-flight
// restore poll so restore can never yank them back. (Scrollbar-drag via
// pointer is an accepted small gap — it is not covered here.)
const onUserIntent = (event: Event) => {
// wheel/touchstart are unambiguous scroll intent; for keydown, only real
// scroll keys count — a shortcut or typing must not abort restore.
if (
event.type === "keydown" &&
!SCROLL_KEYS.has((event as KeyboardEvent).key)
) {
return;
}
userInteractedRef.current = true;
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("pagehide", onPageHide);
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("wheel", onUserIntent, { passive: true });
window.addEventListener("touchstart", onUserIntent, { passive: true });
window.addEventListener("keydown", onUserIntent);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("pagehide", onPageHide);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("wheel", onUserIntent);
window.removeEventListener("touchstart", onUserIntent);
window.removeEventListener("keydown", onUserIntent);
if (throttleTimer !== null) {
window.clearTimeout(throttleTimer);
throttleTimer = null;
@@ -137,9 +187,8 @@ export function useScrollPosition(pageId: string): {
}, [pageId]);
const restoreScrollPosition = useCallback(() => {
// Run at most once per page mount.
if (hasRestoredRef.current) return;
hasRestoredRef.current = true;
// The reader took over — never yank them back.
if (userInteractedRef.current) return;
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
if (window.location.hash) return;
@@ -148,9 +197,26 @@ export function useScrollPosition(pageId: string): {
// Nothing meaningful to restore to.
if (targetY <= 0) return;
const start = Date.now();
// Cancel any in-flight poll before (re)starting, so overlapping triggers can
// never run two concurrent polls against the same target.
if (pollTimerRef.current !== null) {
window.clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
// Share one timeout budget across re-triggers instead of restarting it.
if (restoreStartRef.current === null) {
restoreStartRef.current = Date.now();
}
const start = restoreStartRef.current;
const tryRestore = () => {
// Bail mid-poll if the reader started scrolling while we were waiting.
if (userInteractedRef.current) {
pollTimerRef.current = null;
return;
}
const maxScroll =
document.documentElement.scrollHeight - window.innerHeight;
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
@@ -158,10 +224,12 @@ export function useScrollPosition(pageId: string): {
// Restore once the content is tall enough to reach the target, or bail out
// after the timeout and scroll as far as currently possible.
if (maxScroll >= targetY || timedOut) {
window.scrollTo({
top: Math.min(targetY, Math.max(maxScroll, 0)),
behavior: "auto",
});
const top = Math.min(targetY, Math.max(maxScroll, 0));
// Redundancy guard: re-asserting the SAME target when already positioned
// is a no-op, so this hook can be called from multiple triggers safely.
if (Math.abs(window.scrollY - top) > 1) {
window.scrollTo({ top, behavior: "auto" });
}
pollTimerRef.current = null;
return;
}
@@ -2,6 +2,7 @@ import "@/features/editor/styles/index.css";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -482,8 +483,17 @@ export default function PageEditor({
}
}, [yjsConnectionStatus, isSynced]);
// Restore the saved reading position once the live content is laid out.
useEffect(() => {
// Restore as early as the static (cached) content is laid out, before paint,
// so the reader's position is applied without a visible jump. Aborts itself if
// the reader has already started scrolling (handled inside the hook).
useLayoutEffect(() => {
restoreScrollPosition();
}, [restoreScrollPosition]);
// Re-assert once after the static -> live editor swap in case the swap reset
// the window scroll. Idempotent: a no-op when the position is already correct,
// and a no-op after the reader has interacted.
useLayoutEffect(() => {
if (!showStatic && editor) restoreScrollPosition();
}, [showStatic, editor, restoreScrollPosition]);
@@ -71,22 +71,3 @@
}
}
/* Inline image rows (#284): center the anonymous line boxes formed by
consecutive [data-image-align="inline"] node-view containers. A row has no
DOM wrapper of its own, so its horizontal placement is controlled by the
text-align of the nearest block ancestor (the editor root or a nested
block container: blockquote, callout, list item, table cell, details).
Centering is enabled only in containers that actually hold an inline
image (:has), and every other child of such a container gets its default
alignment back so ordinary text is unaffected. Explicit per-block
alignment from the toolbar is an inline style and still wins. Browsers
without :has() degrade to left-pinned rows. */
.ProseMirror:has(> [data-image-align="inline"]),
.ProseMirror :has(> [data-image-align="inline"]) {
text-align: center;
}
.ProseMirror:has(> [data-image-align="inline"]) > :not([data-image-align="inline"]),
.ProseMirror :has(> [data-image-align="inline"]) > :not([data-image-align="inline"]) {
text-align: start;
}
+1 -3
View File
@@ -449,9 +449,7 @@ export function applyAlignment(container: HTMLElement, align: string) {
// the next line when the viewport is narrow. The right/bottom padding
// provides the gap between images in a row and between wrapped rows;
// vertical-align: top keeps rows of different-height images aligned by
// their top edge. Horizontal centering of the whole row is handled by the
// client stylesheet (media.css) via a :has() rule on the parent block
// container, since the row has no wrapper element of its own.
// their top edge.
container.style.display = "inline-block";
container.style.verticalAlign = "top";
container.style.padding = "0 10px 10px 0";