diff --git a/src/features/editor/documentOutline.test.ts b/src/features/editor/documentOutline.test.ts index a77884b..f59a28b 100644 --- a/src/features/editor/documentOutline.test.ts +++ b/src/features/editor/documentOutline.test.ts @@ -18,9 +18,9 @@ describe('projectOutlineItems', () => { it('normalizes indentation from the lowest heading level present', () => { const items = projectOutlineItems([ - { content: 'Alpha', lvl: 3, slug: 'a' }, - { content: 'Beta', lvl: 4, slug: 'b' }, - { content: 'Gamma', lvl: 3, slug: 'c' }, + { content: 'Alpha', lvl: 3, slug: 'a', index: 0 }, + { content: 'Beta', lvl: 4, slug: 'b', index: 1 }, + { content: 'Gamma', lvl: 3, slug: 'c', index: 2 }, ]) expect(items.map(item => item.indent)).toEqual([0, 1, 0]) @@ -29,9 +29,9 @@ describe('projectOutlineItems', () => { it('caps deep hierarchies at MAX_OUTLINE_INDENT steps', () => { const items = projectOutlineItems([ - { content: 'H1', lvl: 1, slug: 'a' }, - { content: 'H5', lvl: 5, slug: 'b' }, - { content: 'H6', lvl: 6, slug: 'c' }, + { content: 'H1', lvl: 1, slug: 'a', index: 0 }, + { content: 'H5', lvl: 5, slug: 'b', index: 1 }, + { content: 'H6', lvl: 6, slug: 'c', index: 2 }, ]) expect(items[1].indent).toBe(MAX_OUTLINE_INDENT) @@ -40,8 +40,8 @@ describe('projectOutlineItems', () => { it('keeps repeated headings in document order with distinct slugs', () => { const items = projectOutlineItems([ - { content: 'Foo', lvl: 2, slug: 'first' }, - { content: 'Foo', lvl: 2, slug: 'second' }, + { content: 'Foo', lvl: 2, slug: 'first', index: 0 }, + { content: 'Foo', lvl: 2, slug: 'second', index: 1 }, ]) expect(items).toHaveLength(2) @@ -161,7 +161,7 @@ describe('waitForViewportSettle', () => { }) interface FakeEditorOptions { - toc?: { content: string; lvl: number; slug: string; githubSlug: string }[] + toc?: { content: string; lvl: number; slug: string; githubSlug: string; index: number }[] headingsHtml?: string } @@ -169,10 +169,12 @@ function createFakeEditor({ toc = [], headingsHtml = '' }: FakeEditorOptions = { const domNode = document.createElement('div') domNode.innerHTML = `
${headingsHtml}
` const getTOC = vi.fn(() => toc) + const ensureMountedThrough = vi.fn() - return { domNode, getTOC } as unknown as MuyaEditor & { + return { domNode, getTOC, ensureMountedThrough } as unknown as MuyaEditor & { domNode: HTMLElement getTOC: typeof getTOC + ensureMountedThrough: typeof ensureMountedThrough } } @@ -195,8 +197,8 @@ function createOutlineHarness(options: FakeEditorOptions & { editorMissing?: boo } const TOC_FIXTURE = [ - { content: 'Alpha', lvl: 1, slug: 'alpha', githubSlug: 'alpha' }, - { content: 'Beta', lvl: 2, slug: 'beta', githubSlug: 'beta' }, + { content: 'Alpha', lvl: 1, slug: 'alpha', githubSlug: 'alpha', index: 0 }, + { content: 'Beta', lvl: 2, slug: 'beta', githubSlug: 'beta', index: 2 }, ] const HEADINGS_FIXTURE = '

Alpha

text

Beta

' @@ -246,6 +248,28 @@ describe('createDocumentOutline', () => { expect(documentOutline.outlineItems.value).toEqual([]) }) + it('materializes the heading through a pending progressive mount before resolving', async () => { + // Only the first block is mounted; the TOC (collected from the JSON + // state) still lists the tail heading. selectHeading must mount through + // the target's state index before querying the DOM. + const { scrollToHeading, documentOutline, editor } = createOutlineHarness({ + toc: TOC_FIXTURE, + headingsHtml: '

Alpha

', + }) + editor.ensureMountedThrough.mockImplementation((index: number) => { + if (index >= 2) { + editor.domNode.querySelector('.mu-container')!.innerHTML = HEADINGS_FIXTURE + } + }) + + await documentOutline.openOutline() + documentOutline.selectHeading('beta') + + expect(editor.ensureMountedThrough).toHaveBeenCalledWith(2) + expect(scrollToHeading).toHaveBeenCalledTimes(1) + expect((scrollToHeading.mock.calls[0][0] as Element).tagName).toBe('H2') + }) + it('fails safe when the target heading is stale: closes without scrolling', async () => { const { scrollToHeading, documentOutline, editor } = createOutlineHarness({ toc: TOC_FIXTURE, diff --git a/src/features/editor/documentOutline.ts b/src/features/editor/documentOutline.ts index 0e6a5fd..5bcdb5c 100644 --- a/src/features/editor/documentOutline.ts +++ b/src/features/editor/documentOutline.ts @@ -26,6 +26,8 @@ interface MuyaTocItem { content: string lvl: number slug: string + /** Top-level state index — pre-mount key during a progressive mount. */ + index: number } /** @@ -50,9 +52,10 @@ export function projectOutlineItems(toc: readonly MuyaTocItem[]): OutlineItem[] /** * Muya's TOC slug is NOT stamped onto the heading DOM. getTOC() walks only - * the top-level scrollPage children, and those blocks render as the DIRECT + * the top-level JSON-state nodes, and those blocks render as the DIRECT * children of the scrollPage root (`.mu-container`), so the flat TOC index - * maps 1:1 onto this ordered element set. Headings inside blockquotes, + * maps 1:1 onto this ordered element set (once the target is mounted — + * selectHeading materializes it first). Headings inside blockquotes, * lists, or raw HTML are NOT direct `.mu-container` children and must stay * out of the query, or every later index would shift. */ @@ -233,6 +236,13 @@ export function createDocumentOutline({ } const editor = getEditor() + // With a progressive mount in flight the heading's DOM may not exist yet + // even though the TOC (collected from the JSON state) lists it — + // materialize blocks through the heading before resolving its element. + const target = tocSnapshot.find(item => item.slug === slug) + if (editor && target && typeof target.index === 'number') { + editor.ensureMountedThrough(target.index) + } const heading = editor ? resolveOutlineHeadingElement(editor.domNode, tocSnapshot, slug) : null diff --git a/src/features/editor/resumePosition.test.ts b/src/features/editor/resumePosition.test.ts index 456ac8f..caf01d5 100644 --- a/src/features/editor/resumePosition.test.ts +++ b/src/features/editor/resumePosition.test.ts @@ -7,9 +7,10 @@ import { computeResumeScrollTop, createResumePosition, } from './resumePosition' -import type { - CreateResumePositionRecordOptions, - ResumePositionRecord, +import { + createResumePositionRecord, + type CreateResumePositionRecordOptions, + type ResumePositionRecord, } from '../../lib/resumePositions' import type { MuyaEditor } from './editorRuntime' @@ -158,17 +159,29 @@ function buildEditorDom() { interface ControllerHarnessOptions { createRecord: (options: CreateResumePositionRecordOptions) => Promise readPosition?: (docKey: string) => ResumePositionRecord | null + /** Logical top-level block count (jsonState), independent of mounted DOM. */ + logicalBlockCount?: number } -function createControllerHarness({ createRecord, readPosition }: ControllerHarnessOptions) { +function createControllerHarness({ + createRecord, + readPosition, + logicalBlockCount = 1, +}: ControllerHarnessOptions) { // No ResizeObserver: the open-settle phase and stabilization resolve // immediately, keeping these tests synchronous and deterministic. vi.stubGlobal('ResizeObserver', undefined) const { shell, editorRoot } = buildEditorDom() const store = new Map() + const ensureMountedThrough = vi.fn() const controller = createResumePosition({ - getEditor: () => ({ domNode: editorRoot }) as unknown as MuyaEditor, + getEditor: () => + ({ + domNode: editorRoot, + ensureMountedThrough, + editor: { jsonState: { rawState: new Array(logicalBlockCount) } }, + }) as unknown as MuyaEditor, isEditorReady: () => true, getDocumentKey: () => 'doc', getMarkdown: () => 'markdown', @@ -178,7 +191,7 @@ function createControllerHarness({ createRecord, readPosition }: ControllerHarne createRecord, }) - return { controller, shell, store } + return { controller, shell, editorRoot, store, ensureMountedThrough } } afterEach(() => { @@ -329,3 +342,77 @@ describe('persistNow live capture', () => { expect(captured[0].displayText).toBe('Block zero') }) }) + +describe('restore offer during a progressive mount', () => { + // A real record whose hash matches the harness markdown, pointing past the + // single mounted block — i.e. into the pending tail of a larger document. + async function makeMatchingRecord(topBlockIndex: number) { + const record = await createResumePositionRecord({ + markdown: 'markdown', + topBlockIndex, + topBlockRatio: 0, + displayText: 'Resume deep in the document', + }) + expect(record).not.toBeNull() + return record! + } + + it('offers a pending-tail target without materializing anything', async () => { + const record = await makeMatchingRecord(5) + const { controller, ensureMountedThrough } = createControllerHarness({ + createRecord: () => Promise.resolve(null), + readPosition: () => record, + logicalBlockCount: 10, + }) + + await controller.startForOpenedDocument() + + // The probe runs on every open without user action: it must never + // deep-mount (a near-end index would synchronously rebuild the whole + // document). The offer stands on the hash match plus logical bounds. + expect(controller.resumeCardVisible.value).toBe(true) + expect(ensureMountedThrough).not.toHaveBeenCalled() + controller.resetForNewDocument() + }) + + it('discards an index beyond the logical document without mounting', async () => { + const record = await makeMatchingRecord(25) + const { controller, ensureMountedThrough } = createControllerHarness({ + createRecord: () => Promise.resolve(null), + readPosition: () => record, + logicalBlockCount: 10, + }) + + await controller.startForOpenedDocument() + + expect(controller.resumeCardVisible.value).toBe(false) + expect(ensureMountedThrough).not.toHaveBeenCalled() + controller.resetForNewDocument() + }) + + it('materializes through the target only when the user activates the card', async () => { + const record = await makeMatchingRecord(5) + const { controller, editorRoot, ensureMountedThrough } = createControllerHarness({ + createRecord: () => Promise.resolve(null), + readPosition: () => record, + logicalBlockCount: 10, + }) + ensureMountedThrough.mockImplementation((index: number) => { + const container = editorRoot.querySelector('.mu-container')! + while (container.children.length <= index) { + const paragraph = document.createElement('p') + paragraph.textContent = `Block ${container.children.length}` + container.append(paragraph) + } + }) + + await controller.startForOpenedDocument() + expect(controller.resumeCardVisible.value).toBe(true) + + controller.activateResume() + + expect(ensureMountedThrough).toHaveBeenCalledWith(5) + expect(controller.resumeCardVisible.value).toBe(false) + controller.resetForNewDocument() + }) +}) diff --git a/src/features/editor/resumePosition.ts b/src/features/editor/resumePosition.ts index cba0207..403fd17 100644 --- a/src/features/editor/resumePosition.ts +++ b/src/features/editor/resumePosition.ts @@ -408,27 +408,41 @@ export function createResumePosition({ return } - const block = blockContainer.children[record.topBlockIndex] + // This probe runs on every open without user action, so it must NEVER + // materialize blocks: with a progressive mount in flight, a stored + // near-end index would otherwise synchronously deep-mount the document + // and reintroduce the very freeze progressive mounting removes. The + // stored block may therefore have no DOM yet — the hash match above + // proved the document identical, so validate the index against the + // LOGICAL top-level count instead and defer materialization to the + // user's explicit tap (activateResume). + const block = blockContainer.children[record.topBlockIndex] ?? null if (!block) { - logger?.debug('resume position discarded: block index out of range', { - docKey, - topBlockIndex: record.topBlockIndex, + const logicalCount = getEditor()?.editor.jsonState.rawState.length ?? 0 + if (record.topBlockIndex >= logicalCount) { + logger?.debug('resume position discarded: block index out of range', { + docKey, + topBlockIndex: record.topBlockIndex, + }) + return + } + // In the pending tail: the target sits past the synchronous mount + // prefix — hundreds of blocks, far beyond the min-distance threshold — + // so eligibility holds by construction and the rect check is skipped. + } else { + const containerRect = scrollContainer.getBoundingClientRect() + const blockRect = block.getBoundingClientRect() + const target = computeResumeScrollTop({ + scrollTop: scrollContainer.scrollTop, + containerTop: containerRect.top, + blockTop: blockRect.top, + blockHeight: blockRect.height, + ratio: record.topBlockRatio, }) - return - } - - const containerRect = scrollContainer.getBoundingClientRect() - const blockRect = block.getBoundingClientRect() - const target = computeResumeScrollTop({ - scrollTop: scrollContainer.scrollTop, - containerTop: containerRect.top, - blockTop: blockRect.top, - blockHeight: blockRect.height, - ratio: record.topBlockRatio, - }) - if (target < scrollContainer.clientHeight * RESUME_MIN_DISTANCE_VIEWPORTS) { - logger?.debug('resume position skipped: too close to the top', { docKey }) - return + if (target < scrollContainer.clientHeight * RESUME_MIN_DISTANCE_VIEWPORTS) { + logger?.debug('resume position skipped: too close to the top', { docKey }) + return + } } // The user started scrolling or editing while validation ran: they are @@ -610,6 +624,10 @@ export function createResumePosition({ const scrollContainer = getScrollContainer() const blockContainer = getBlockContainer() + // Materialization happens HERE, on the user's explicit tap — the offer + // deliberately never mounts (a stored near-end index would deep-mount + // the whole document during the automatic probe). No-op once mounted. + getEditor()?.ensureMountedThrough(target.topBlockIndex) const block = blockContainer?.children[target.topBlockIndex] ?? null if (!scrollContainer || !block) { logger?.debug('resume activation aborted: target unresolvable') diff --git a/src/types/muya-core.d.ts b/src/types/muya-core.d.ts index aefa5b2..f647d6b 100644 --- a/src/types/muya-core.d.ts +++ b/src/types/muya-core.d.ts @@ -20,6 +20,12 @@ declare module '@muyajs/core' { lvl: number slug: string githubSlug: string + /** + * Top-level state index of the heading. During a progressive mount the + * heading's DOM may not exist yet; pass this to + * `muya.ensureMountedThrough(index)` before resolving the element. + */ + index: number } export interface IMuyaClipboard { @@ -71,6 +77,12 @@ declare module '@muyajs/core' { activeContentBlock: unknown clipboard: IMuyaClipboard selection: IMuyaSelection + /** + * The complete logical document. `rawState` is the live state root + * (no clone) — read-only consumers only, e.g. bounds checks against + * the top-level block count during a progressive mount. + */ + jsonState: { readonly rawState: readonly unknown[] } } constructor(element: HTMLElement, options?: Record) init(): void @@ -99,6 +111,11 @@ declare module '@muyajs/core' { selectAll(): void search(value: string, opts?: IMuyaSearchOptions): IMuyaSearchState find(action: 'previous' | 'next'): IMuyaSearchState + /** + * Materialize blocks through the given top-level state index (progressive + * mount, marktext#4887). No-op when the index is already mounted. + */ + ensureMountedThrough(index: number): void destroy(): void } diff --git a/third_party/muya/src/__tests__/setCursorByOffset.spec.ts b/third_party/muya/src/__tests__/setCursorByOffset.spec.ts index 3aefbde..76431ce 100644 --- a/third_party/muya/src/__tests__/setCursorByOffset.spec.ts +++ b/third_party/muya/src/__tests__/setCursorByOffset.spec.ts @@ -135,4 +135,24 @@ describe('muya.setCursorByOffset() (PG2)', () => { const after = muya.getHistory(); expect(after.stack.undo.length).toBe(before.stack.undo.length); }); + + it('PG2: restores a caret beyond the progressive-mount prefix (review repro 2)', () => { + // 600 one-line paragraphs; markdown line 1100 is paragraph 550 — + // beyond the 512-weight synchronous prefix, so its mount chunk has + // not run at init time (#4887). + const markdown = `${Array.from({ length: 600 }, (_, i) => `line ${i}`).join('\n\n')}\n`; + const muya = bootMuya(markdown); + expect(muya.domNode.querySelectorAll('p.mu-paragraph').length).toBeLessThan(600); + + const restored = muya.setCursorByOffset({ + anchor: { line: 1100, ch: 3 }, + focus: { line: 1100, ch: 3 }, + }); + + expect(restored).toBe(true); + // The restore mounted only up to its target, not the whole remainder. + const mounted = muya.domNode.querySelectorAll('p.mu-paragraph').length; + expect(mounted).toBeGreaterThanOrEqual(551); + expect(mounted).toBeLessThan(600); + }); }); diff --git a/third_party/muya/src/block/base/content.ts b/third_party/muya/src/block/base/content.ts index d242fac..d6b8a68 100644 --- a/third_party/muya/src/block/base/content.ts +++ b/third_party/muya/src/block/base/content.ts @@ -522,8 +522,10 @@ class Content extends TreeNode { ) { event.preventDefault(); event.stopPropagation(); - if (nextContentBlock) { - cursorBlock = nextContentBlock; + const targetContentBlock + = nextContentBlock ?? this.resolveNextContentInContext(); + if (targetContentBlock) { + cursorBlock = targetContentBlock; } // Only append a trailing paragraph when the last block has content. // Otherwise ArrowDown in an already-empty last paragraph would keep diff --git a/third_party/muya/src/block/base/format.ts b/third_party/muya/src/block/base/format.ts index 1f6cbe7..eba31af 100644 --- a/third_party/muya/src/block/base/format.ts +++ b/third_party/muya/src/block/base/format.ts @@ -1531,7 +1531,10 @@ class Format extends Content { this.muya.editor.history.markInputBoundary('deleteContentForward', null); - const nextBlock = this.nextContentInContext(); + // Pending-mount aware: at the frontier the paragraph to merge with + // may not be materialized yet (#4887) — a plain context walk would + // silently swallow the keystroke. + const nextBlock = this.resolveNextContentInContext(); if (!nextBlock || nextBlock.blockName !== 'paragraph.content') { // If the next block is code content or table cell, nothing need to do. event.preventDefault(); @@ -1847,6 +1850,18 @@ class Format extends Content { if (!scrollPage) return; + // Hosts without a footnote tool never consume the event — skip the + // whole-document preparation below instead of freezing a large file + // for nothing (#4887). + if (!this.muya.eventCenter.hasListeners('muya-footnote-tool')) + return; + + // The identifier -> definition map must cover the WHOLE document: a + // definition still in the unmounted tail (#4887) resolving as + // "missing" would make the tool offer Create and duplicate it. Same + // documented whole-document trade as search. + scrollPage.flushPendingMount(); + // Collect the first definition for each identifier — duplicates in // the document share the same `#fn-{N}` target on the HTML side. const footnotes = new Map(); diff --git a/third_party/muya/src/block/base/treeNode.ts b/third_party/muya/src/block/base/treeNode.ts index 1f0829c..8855ca7 100644 --- a/third_party/muya/src/block/base/treeNode.ts +++ b/third_party/muya/src/block/base/treeNode.ts @@ -168,6 +168,49 @@ class TreeNode implements ILinkedNode { return parent.nextContentInContext(); } + /** + * Like `nextContentInContext`, but pending-mount aware (#4887): the + * traversal only sees mounted blocks, so at the progressive-mount + * frontier a still-pending successor reads as "no successor" and + * end-of-document fallbacks (append a trailing paragraph) would insert + * it between the mounted prefix and the logical tail. Keep + * materializing until a content appears or the mount stops making + * progress — a single step is not enough, because the next top-level + * block can be an empty container with no content descendant. At the + * genuine document end this mounts nothing and returns null. + * + * Call it on a CONTENT receiver (as `nextContentInContext` itself): the + * sibling walk starts at the receiver's parent, so a top-level Parent + * receiver would skip its own successors. + */ + resolveNextContentInContext(): Nullable { + let next = this.nextContentInContext(); + if (next) + return next; + + const { scrollPage } = this; + if (!scrollPage) + return null; + + // `nextContentInContext` has already scanned every MOUNTED + // successor — including mounted empty containers — so the search + // can only advance by extending the mount frontier itself. Ask for + // the first UNMOUNTED index (`children.length`) each round: any + // request below the frontier is a no-op (`ensureMountedThrough` + // returns early for mounted indices), which would misread the + // pending tail as the document end. + while (!next) { + const mounted = scrollPage.children.length; + scrollPage.ensureMountedThrough(mounted); + if (scrollPage.children.length === mounted) + break; + + next = this.nextContentInContext(); + } + + return next; + } + /** * Weather `this` is the only child of its parent. */ diff --git a/third_party/muya/src/block/commonMark/headingCopyLink/index.ts b/third_party/muya/src/block/commonMark/headingCopyLink/index.ts index 519949a..e38a442 100644 --- a/third_party/muya/src/block/commonMark/headingCopyLink/index.ts +++ b/third_party/muya/src/block/commonMark/headingCopyLink/index.ts @@ -94,14 +94,29 @@ class HeadingCopyLink extends TreeNode { } // Emit `heading-copy-link` with the heading's stable slug. At activation - // time the attachment's parent is the heading block. + // time the attachment's parent is the heading block. Slugs are keyed on + // the heading's STATE node (see state/getTOC.ts), so resolve the block to + // its top-level state entry — headings are always top-level, and the + // block's path starts with its top-level index. private _activate() { const heading = this.parent; if (!heading) return; + // The block path reflects same-frame tree mutations immediately, + // while `rawState` only updates on the rAF flush — drain the batch + // so the index resolves against the same document revision. + this.muya.editor.jsonState.flush(); + + const index = heading.path[0]; + const node = typeof index === 'number' + ? this.muya.editor.jsonState.rawState[index] + : undefined; + if (!node) + return; + this.muya.eventCenter.emit('heading-copy-link', { - key: stableSlug(heading), + key: stableSlug(node), }); } diff --git a/third_party/muya/src/block/content/codeBlockContent/index.ts b/third_party/muya/src/block/content/codeBlockContent/index.ts index e3643b2..9440191 100644 --- a/third_party/muya/src/block/content/codeBlockContent/index.ts +++ b/third_party/muya/src/block/content/codeBlockContent/index.ts @@ -259,7 +259,7 @@ class CodeBlockContent extends Content { // Shift + Enter to jump out of code block. if (event.shiftKey) { let cursorBlock; - const nextContentBlock = this.nextContentInContext(); + const nextContentBlock = this.resolveNextContentInContext(); if (nextContentBlock) { cursorBlock = nextContentBlock; } diff --git a/third_party/muya/src/block/content/tableCell/__tests__/tabHandler.spec.ts b/third_party/muya/src/block/content/tableCell/__tests__/tabHandler.spec.ts index 04a722a..1279052 100644 --- a/third_party/muya/src/block/content/tableCell/__tests__/tabHandler.spec.ts +++ b/third_party/muya/src/block/content/tableCell/__tests__/tabHandler.spec.ts @@ -10,7 +10,9 @@ import TableCellContent from '../index'; // We drive the handler directly off the prototype with a structurally // typed `this` so we don't need the full Muya bootstrap (which needs a // real DOM). The handler only reads `event.shiftKey` and calls -// `previousContentInContext` / `nextContentInContext` on `this`. +// `previousContentInContext` / `resolveNextContentInContext` on `this` +// (the latter is the pending-mount-aware wrapper, #4887; with no +// scrollPage on the stub it degrades to the plain context walk). // Structural neighbour shape — the table-cell tab handler only calls // `setCursor` on whatever `prev`/`next` resolves to. @@ -22,6 +24,8 @@ function makeFakeCell(prev: IFakeNeighbour | null, next: IFakeNeighbour | null) return { nextContentInContext: vi.fn(() => next), previousContentInContext: vi.fn(() => prev), + resolveNextContentInContext: + TableCellContent.prototype.resolveNextContentInContext, }; } diff --git a/third_party/muya/src/block/content/tableCell/index.ts b/third_party/muya/src/block/content/tableCell/index.ts index 14730db..ed75082 100644 --- a/third_party/muya/src/block/content/tableCell/index.ts +++ b/third_party/muya/src/block/content/tableCell/index.ts @@ -97,7 +97,7 @@ class TableCellContent extends Format { } else { const lastCellContent = row.lastContentInDescendant(); - const nextContent = lastCellContent?.nextContentInContext(); + const nextContent = lastCellContent?.resolveNextContentInContext(); if (nextContent) { cursorBlock = nextContent; @@ -176,8 +176,10 @@ class TableCellContent extends Format { } else { let cursorBlock = null; - if (tableNextContent) { - cursorBlock = tableNextContent; + const nextContent + = tableNextContent ?? this.resolveNextContentInContext(); + if (nextContent) { + cursorBlock = nextContent; } else { const state = { @@ -243,9 +245,13 @@ class TableCellContent extends Format { // always pass a KeyboardEvent in practice. The structural check just // keeps unit tests that pass a partial event object passing. const isShiftTab = 'shiftKey' in event && event.shiftKey === true; + // Forward Tab is pending-mount aware (#4887): from the last cell it + // must reach the table's successor even when that block is still in + // the unmounted tail. Shift+Tab walks backwards through the always- + // mounted prefix and needs nothing. const cursorBlock = isShiftTab ? this.previousContentInContext() - : this.nextContentInContext(); + : this.resolveNextContentInContext(); if (cursorBlock) cursorBlock.setCursor(0, 0, true); diff --git a/third_party/muya/src/block/scrollPage/__tests__/chunkedMount.spec.ts b/third_party/muya/src/block/scrollPage/__tests__/chunkedMount.spec.ts new file mode 100644 index 0000000..b039803 --- /dev/null +++ b/third_party/muya/src/block/scrollPage/__tests__/chunkedMount.spec.ts @@ -0,0 +1,803 @@ +// @vitest-environment happy-dom + +import * as json1 from 'ot-json1'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Muya } from '../../../muya'; + +// Chunked initial mount (#4887): documents beyond the synchronous prefix +// finish mounting in scheduled chunks; whole-tree consumers force the rest. + +const SECTIONS = 600; + +function buildDoc(): string { + return `${Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`).join('\n\n')}\n`; +} + +function mountedParagraphs(muya: Muya): number { + return muya.domNode.querySelectorAll('p.mu-paragraph').length; +} + +// Exact sequence equality between the rendered paragraphs and the state — +// catches duplication, loss, and reordering that substring checks miss. +function expectDomMatchesState(muya: Muya) { + const dom = Array.from(muya.domNode.querySelectorAll('p.mu-paragraph')).map( + node => node.textContent?.trim(), + ); + const state = muya + .getState() + .filter(entry => entry.name === 'paragraph') + .map(entry => (entry as { text: string }).text); + expect(dom).toEqual(state); +} + +const bootedHosts: HTMLElement[] = []; + +beforeEach(() => { + window.MUYA_VERSION = 'test'; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + while (bootedHosts.length) + bootedHosts.pop()!.remove(); + document.getSelection()?.removeAllRanges(); +}); + +function bootMuya(): Muya { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown: buildDoc() } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + return muya; +} + +describe('scrollPage chunked mount', () => { + it('mounts a prefix synchronously and the rest in scheduled chunks', () => { + const muya = bootMuya(); + + expect(mountedParagraphs(muya)).toBe(512); + + vi.runAllTimers(); + + expect(mountedParagraphs(muya)).toBe(SECTIONS); + muya.destroy(); + }); + + it('flushes the pending tail for end-of-document consumers', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + const last = muya.editor.scrollPage!.lastContentInDescendant(); + + expect(mountedParagraphs(muya)).toBe(SECTIONS); + expect(last?.text).toBe(`Paragraph ${SECTIONS - 1}`); + muya.destroy(); + }); + + it('serializes the full document while a mount is pending', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + // Markdown comes from jsonState, not the DOM — never truncated. + expect(muya.getMarkdown()).toContain(`Paragraph ${SECTIONS - 1}`); + muya.destroy(); + }); + + it('updateState cancels a pending mount instead of appending stale blocks', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + muya.setContent('only one\n'); + vi.runAllTimers(); + + expect(mountedParagraphs(muya)).toBe(1); + expect(muya.domNode.textContent).not.toContain('Paragraph 599'); + muya.destroy(); + }); + + it('reports the complete TOC while a mount is pending (review repro 1)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const markdown = `${Array.from({ length: 599 }, (_, i) => `Paragraph ${i}`).join('\n\n')}\n\n# Tail Heading\n`; + const muya = new Muya(host, { markdown } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + expect(mountedParagraphs(muya)).toBeLessThan(599); + const toc = muya.getTOC(); + expect(toc).toHaveLength(1); + expect(toc[0].content).toBe('Tail Heading'); + muya.destroy(); + }); + + it('mounts a detached host to completion (review repro 4)', () => { + const host = document.createElement('div'); + // Never appended to the document. + const muya = new Muya(host, { markdown: buildDoc() } as ConstructorParameters[1]); + muya.init(); + + expect(mountedParagraphs(muya)).toBe(512); + vi.runAllTimers(); + expect(mountedParagraphs(muya)).toBe(SECTIONS); + muya.destroy(); + }); + + it('ensureMountedThrough mounts only up to the requested index', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + muya.editor.scrollPage!.ensureMountedThrough(549); + + const mounted = mountedParagraphs(muya); + expect(mounted).toBeGreaterThanOrEqual(550); + expect(mounted).toBeLessThan(SECTIONS); + vi.runAllTimers(); + expect(mountedParagraphs(muya)).toBe(SECTIONS); + muya.destroy(); + }); + + it('an insertion during the mount window keeps mounting the correct tail', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + // Simulate an edit inserting a top-level block (Enter/paste path): + // live tree and state change at the same index, so the pending tail + // keeps mounting without any flush and without duplication. + const scrollPage = muya.editor.scrollPage!; + const newBlock = (scrollPage.constructor as typeof import('../index').ScrollPage) + .loadBlock('paragraph') + .create(muya, { name: 'paragraph', text: 'inserted' } as never); + scrollPage.append(newBlock as never, 'user'); + + vi.runAllTimers(); + expect(mountedParagraphs(muya)).toBe(SECTIONS + 1); + expect(muya.domNode.textContent).toContain(`Paragraph ${SECTIONS - 1}`); + muya.destroy(); + }); + + it('removing a mounted block during the window stays consistent (review repro 3)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + // Deletion goes through the block's own remove(), which bypasses any + // ScrollPage override — the live-cursor design must not care. + const first = muya.editor.scrollPage!.firstChild as { remove: (source?: string) => void }; + first.remove('user'); + expect(mountedParagraphs(muya)).toBe(511); + + muya.editor.scrollPage!.ensureMountedThrough(511); + expect(mountedParagraphs(muya)).toBe(512); + + vi.runAllTimers(); + expect(mountedParagraphs(muya)).toBe(SECTIONS - 1); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('an op targeting the unmounted tail is applied exactly once (review blocker 1)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + // Insert at index 550 — beyond the mounted prefix. updateContents + // must mount the target from the PRE-dispatch state, otherwise the + // DOM drop re-applies the op to a block built from the updated state. + muya.editor.updateContents( + json1.insertOp([550], { name: 'paragraph', text: 'remote inserted' } as never)!, + null, + 'api', + ); + + vi.runAllTimers(); + expect(mountedParagraphs(muya)).toBe(SECTIONS + 1); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('a text edit into the unmounted tail is applied exactly once (review blocker 1)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + muya.editor.updateContents( + json1.editOp([560, 'text'], 'text-unicode', ['Paragraph 560'.length, 'x'])!, + null, + 'api', + ); + + vi.runAllTimers(); + expectDomMatchesState(muya); + expect(muya.domNode.textContent).toContain('Paragraph 560x'); + expect(muya.domNode.textContent).not.toContain('Paragraph 560xx'); + muya.destroy(); + }); + + it('tail blocks never share meta objects with the state (review: shared meta)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const parts = Array.from({ length: 560 }, (_, i) => `Paragraph ${i}`); + parts[550] = '```js\ncode\n```'; + const muya = new Muya(host, { + markdown: `${parts.join('\n\n')}\n`, + } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + muya.editor.scrollPage!.ensureMountedThrough(550); + const block = muya.editor.scrollPage!.find(550) as unknown as { meta?: object }; + const stateNode = muya.editor.jsonState.rawState[550] as { meta?: object }; + + expect(block.meta).toBeDefined(); + expect(stateNode.meta).toBeDefined(); + // A shared object would let block-level edits rewrite the JSON state + // ahead of their op — the op then applies on top of itself. + expect(block.meta).not.toBe(stateNode.meta); + muya.destroy(); + }); + + it('a replace at the mount boundary applies exactly once (review: boundary replace)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + muya.editor.updateContents( + json1.replaceOp( + [550], + { name: 'paragraph', text: 'Paragraph 550' } as never, + { name: 'paragraph', text: 'replacement' } as never, + )!, + null, + 'api', + ); + + vi.runAllTimers(); + expectDomMatchesState(muya); + const texts = Array.from(muya.domNode.querySelectorAll('p.mu-paragraph')).map( + node => node.textContent?.trim(), + ); + expect(texts.filter(text => text === 'replacement')).toHaveLength(1); + expect(texts).toContain('Paragraph 551'); + muya.destroy(); + }); + + it('undo flushes a same-frame edit into the history first (review: swallowed edit)', () => { + const muya = bootMuya(); + + // A recorded edit deep in the tail — undoing it triggers the + // on-demand mount path. + muya.editor.updateContents( + json1.editOp([550, 'text'], 'text-unicode', ['Paragraph 550'.length, '!'])!, + null, + 'user', + ); + // Step past the history coalescing window so the next edit is its + // own entry. + vi.advanceTimersByTime(5000); + + // A same-frame edit that has NOT flushed through the rAF batch yet. + const first = muya.editor.scrollPage!.firstContentInDescendant()!; + muya.editor.activeContentBlock = first as never; + first.setCursor(0, 0, true); + (first as unknown as { text: string }).text = 'Paragraph 0 edited'; + + muya.undo(); + + // The pending edit must have entered the stack and been popped — + // not applied while its history entry was silently dropped. + expect((first as unknown as { text: string }).text).toBe('Paragraph 0'); + expect(muya.getHistory().stack.undo.length).toBeGreaterThan(0); + muya.destroy(); + }); + + it('redo with a pending same-frame edit invalidates quietly (review: redo crash)', () => { + const muya = bootMuya(); + + // A recorded edit, undone — leaves exactly one redo entry. + muya.editor.updateContents( + json1.editOp([550, 'text'], 'text-unicode', ['Paragraph 550'.length, '!'])!, + null, + 'user', + ); + vi.advanceTimersByTime(5000); + muya.undo(); + expect(muya.getHistory().stack.redo.length).toBe(1); + + // A new edit still sitting in the rAF batch. Flushing it inside + // redo() records it — which clears the redo stack; redo must then + // no-op instead of popping the emptied stack. + const first = muya.editor.scrollPage!.firstContentInDescendant()!; + muya.editor.activeContentBlock = first as never; + first.setCursor(0, 0, true); + (first as unknown as { text: string }).text = 'Paragraph 0 edited'; + + expect(() => muya.redo()).not.toThrow(); + expect((first as unknown as { text: string }).text).toBe('Paragraph 0 edited'); + expect(muya.getHistory().stack.redo.length).toBe(0); + muya.destroy(); + }); + + it('a pending first edit is undoable immediately (review: flush before stack check)', () => { + const muya = bootMuya(); + expect(muya.getHistory().stack.undo.length).toBe(0); + + const first = muya.editor.scrollPage!.firstContentInDescendant()!; + muya.editor.activeContentBlock = first as never; + first.setCursor(0, 0, true); + (first as unknown as { text: string }).text = 'Paragraph 0 edited'; + + // The edit has not flushed through the rAF batch, so the undo stack + // is still empty — undo() must flush first, then pop that entry. + muya.undo(); + + expect((first as unknown as { text: string }).text).toBe('Paragraph 0'); + muya.destroy(); + }); + + it('setContent drops the old selection so focus() cannot deep-mount (review blocker 2)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + // Park the caret deep in the document (mounts through it). + muya.setCursorByOffset({ + anchor: { line: 1100, ch: 3 }, + focus: { line: 1100, ch: 3 }, + }); + expect(mountedParagraphs(muya)).toBeGreaterThanOrEqual(551); + + // Replace the document; the stale anchorPath must not survive into + // the new tree, or focus() would force-mount up to the old caret. + muya.setContent(buildDoc()); + muya.editor.focus(); + expect(mountedParagraphs(muya)).toBe(512); + + vi.runAllTimers(); + expect(mountedParagraphs(muya)).toBe(SECTIONS); + muya.destroy(); + }); + + it('clicking below the frontier is inert while pending, appends after completion (review: blank-click)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + // muya's isMouseEvent guard checks the `x` alias, which happy-dom's + // MouseEvent does not implement. + const makeClick = () => { + const click = new MouseEvent('click', { + clientY: 10_000, + bubbles: true, + cancelable: true, + }); + if (!('x' in click)) + Object.defineProperty(click, 'x', { value: 0 }); + return click; + }; + + // While the tail is pending, the area below the frontier is NOT + // blank — the document continues there. No insert (the paragraph + // would land at index 512, mid-document) and no forced mount (a + // container tap must not freeze a large file). + muya.editor.scrollPage!.domNode!.dispatchEvent(makeClick()); + muya.editor.jsonState.flush(); + expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS); + expect(mountedParagraphs(muya)).toBe(512); + + // Once the background mount completes, the same click is a real + // end-of-document gesture again. + vi.runAllTimers(); + muya.editor.scrollPage!.domNode!.dispatchEvent(makeClick()); + muya.editor.jsonState.flush(); + const states = muya.editor.jsonState.rawState; + expect(states).toHaveLength(SECTIONS + 1); + expect((states[SECTIONS] as { text: string }).text).toBe(''); + muya.destroy(); + }); + + it('arrow-down across an empty container at the frontier keeps mounting to a content (review: empty container)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown: 'seed\n' } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + // 512 paragraphs, an EMPTY container (no content descendant), then a + // trailing paragraph — one mount step past the frontier lands on the + // empty container, which is NOT the document end. + const states = [ + ...Array.from({ length: 512 }, (_, i) => ({ + name: 'paragraph', + text: `Paragraph ${i}`, + })), + { name: 'block-quote', children: [] }, + { name: 'paragraph', text: 'Paragraph after empty container' }, + ]; + muya.setContent(states as never); + expect(mountedParagraphs(muya)).toBe(512); + + const frontier = (muya.editor.scrollPage!.find(511) as unknown as { + lastContentInDescendant: () => { + text: string; + setCursor: (start: number, end: number, focus?: boolean) => void; + arrowHandler: (event: Event) => void; + }; + }).lastContentInDescendant(); + expect(frontier.text).toBe('Paragraph 511'); + + muya.editor.activeContentBlock = frontier as never; + frontier.setCursor(0, 0, true); + frontier.arrowHandler( + new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }), + ); + muya.editor.jsonState.flush(); + + // No paragraph inserted between the prefix and the logical tail. + expect(muya.editor.jsonState.rawState).toHaveLength(514); + expect(muya.domNode.textContent).toContain('Paragraph after empty container'); + vi.runAllTimers(); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('the footnote tool sees definitions in the unmounted tail (review: footnote create)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const parts = Array.from({ length: 599 }, (_, i) => `Paragraph ${i}`); + parts[0] = 'Intro with a footnote[^tail] reference.'; + const markdown = `${parts.join('\n\n')}\n\n[^tail]: The definition lives in the tail.\n`; + const muya = new Muya(host, { + markdown, + footnote: true, + } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + expect(mountedParagraphs(muya)).toBeLessThan(599); + + const payloads: { footnotes: Map }[] = []; + muya.on('muya-footnote-tool', ((payload: { footnotes: Map }) => + void payloads.push(payload)) as never); + + // The identifier→definition map must cover the WHOLE document: a + // definition in the unmounted tail resolving as "missing" would make + // the tool offer Create and duplicate it. + const reference = document.createElement('sup'); + reference.id = 'noteref-tail'; + const first = muya.editor.scrollPage!.firstContentInDescendant()!; + (first as unknown as { + _emitFootnoteToolEvent: (el: HTMLElement) => void; + })._emitFootnoteToolEvent(reference); + + expect(payloads).toHaveLength(1); + expect(payloads[0].footnotes.has('tail')).toBe(true); + muya.destroy(); + }); + + it('heading copy-link resolves against the flushed state (review: index skew)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { + markdown: 'Paragraph 0\n\n# Section\n\nParagraph 2\n', + } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + const slug = muya.getTOC()[0].slug; + const handler = vi.fn(); + muya.on('heading-copy-link', handler); + + // A structural edit whose op still sits in the rAF batch: the live + // tree moves the heading to index 0 while rawState keeps it at 1 — + // resolving the block path against the unflushed state would slug + // the wrong node. + (muya.editor.scrollPage!.firstChild as unknown as { + remove: (source?: string) => void; + }).remove('user'); + + muya.domNode + .querySelector('[class*="copy-header-link"]')! + .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0]).toEqual({ key: slug }); + muya.destroy(); + }); + + it('arrowDown on the mount frontier navigates into the pending tail (review: frontier arrow)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + // Block 511 is the last MOUNTED block, not the last block: ArrowDown + // must reach Paragraph 512, not append an empty paragraph between + // the prefix and the pending tail. + const frontier = (muya.editor.scrollPage!.find(511) as unknown as { + lastContentInDescendant: () => { + text: string; + setCursor: (start: number, end: number, focus?: boolean) => void; + arrowHandler: (event: Event) => void; + }; + }).lastContentInDescendant(); + expect(frontier.text).toBe('Paragraph 511'); + + muya.editor.activeContentBlock = frontier as never; + frontier.setCursor(frontier.text.length, frontier.text.length, true); + frontier.arrowHandler( + new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }), + ); + muya.editor.jsonState.flush(); + + // No paragraph was inserted at the frontier... + expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS); + // ...and the successor is now mounted with the caret available in it. + expect(mountedParagraphs(muya)).toBeGreaterThanOrEqual(513); + expect(muya.domNode.textContent).toContain('Paragraph 512'); + vi.runAllTimers(); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('a click between blocks or in the gutter never flushes the pending tail (review: eager flush)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + // clientY does not clear the mounted tail's bottom: not an + // end-of-document gesture — nothing mounts, nothing is inserted. + const click = new MouseEvent('click', { clientY: 0, bubbles: true, cancelable: true }); + if (!('x' in click)) + Object.defineProperty(click, 'x', { value: 0 }); + muya.editor.scrollPage!.domNode!.dispatchEvent(click); + muya.editor.jsonState.flush(); + + expect(mountedParagraphs(muya)).toBe(512); + expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS); + muya.destroy(); + }); + + it('enter in the last table row at the frontier navigates into the pending tail (review: table exit)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const parts = Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`); + parts[550] = '| a | b |\n| - | - |\n| c | d |'; + const muya = new Muya(host, { + markdown: `${parts.join('\n\n')}\n`, + } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + // Put the mount frontier exactly at the table. + muya.editor.scrollPage!.ensureMountedThrough(550); + const before = mountedParagraphs(muya); + const cell = (muya.editor.scrollPage!.find(550) as unknown as { + lastContentInDescendant: () => { + text: string; + setCursor: (start: number, end: number, focus?: boolean) => void; + enterHandler: (event: Event) => void; + }; + }).lastContentInDescendant(); + expect(cell.text).toBe('d'); + + muya.editor.activeContentBlock = cell as never; + cell.setCursor(1, 1, true); + cell.enterHandler(new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })); + muya.editor.jsonState.flush(); + + // No paragraph inserted at the frontier; the successor was mounted. + expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS); + expect(mountedParagraphs(muya)).toBeGreaterThan(before); + vi.runAllTimers(); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('arrow-down in the last table row at the frontier navigates into the pending tail (review: table exit)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const parts = Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`); + parts[550] = '| a | b |\n| - | - |\n| c | d |'; + const muya = new Muya(host, { + markdown: `${parts.join('\n\n')}\n`, + } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + muya.editor.scrollPage!.ensureMountedThrough(550); + const before = mountedParagraphs(muya); + const cell = (muya.editor.scrollPage!.find(550) as unknown as { + lastContentInDescendant: () => { + text: string; + setCursor: (start: number, end: number, focus?: boolean) => void; + arrowHandler: (event: Event) => void; + }; + }).lastContentInDescendant(); + + muya.editor.activeContentBlock = cell as never; + cell.setCursor(1, 1, true); + cell.arrowHandler(new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true })); + muya.editor.jsonState.flush(); + + expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS); + expect(mountedParagraphs(muya)).toBeGreaterThan(before); + vi.runAllTimers(); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('shift-enter out of a code block at the frontier navigates into the pending tail (review: code exit)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const parts = Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`); + parts[550] = '```js\ncode\n```'; + const muya = new Muya(host, { + markdown: `${parts.join('\n\n')}\n`, + } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + muya.editor.scrollPage!.ensureMountedThrough(550); + const before = mountedParagraphs(muya); + const content = (muya.editor.scrollPage!.find(550) as unknown as { + lastContentInDescendant: () => { + text: string; + setCursor: (start: number, end: number, focus?: boolean) => void; + enterHandler: (event: KeyboardEvent) => void; + }; + }).lastContentInDescendant(); + expect(content.text).toBe('code'); + + muya.editor.activeContentBlock = content as never; + content.setCursor(4, 4, true); + content.enterHandler( + new KeyboardEvent('keydown', { key: 'Enter', shiftKey: true, cancelable: true }), + ); + muya.editor.jsonState.flush(); + + expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS); + expect(mountedParagraphs(muya)).toBeGreaterThan(before); + vi.runAllTimers(); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('a footnote tap without a tool listener never flushes the pending tail (review: consumer gate)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const parts = Array.from({ length: 599 }, (_, i) => `Paragraph ${i}`); + parts[0] = 'Intro with a footnote[^tail] reference.'; + const markdown = `${parts.join('\n\n')}\n\n[^tail]: The definition lives in the tail.\n`; + const muya = new Muya(host, { + markdown, + footnote: true, + } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + const before = mountedParagraphs(muya); + expect(before).toBeLessThan(599); + + // No `muya-footnote-tool` subscriber: preparing the whole-document + // definition map would freeze a large file for nothing. + const reference = document.createElement('sup'); + reference.id = 'noteref-tail'; + const first = muya.editor.scrollPage!.firstContentInDescendant()!; + (first as unknown as { + _emitFootnoteToolEvent: (el: HTMLElement) => void; + })._emitFootnoteToolEvent(reference); + + expect(mountedParagraphs(muya)).toBe(before); + muya.destroy(); + }); + + it('arrow-down across an ALREADY-MOUNTED empty container still reaches the tail (review: frontier start)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const muya = new Muya(host, { markdown: 'seed\n' } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + // 511 paragraphs + an empty container fill the synchronous prefix + // EXACTLY (512 weight), so the empty container sits at the frontier + // already mounted — a resolver that starts mounting at the + // receiver's index + 1 finds that index mounted, makes no progress, + // and misreads the pending tail as the document end. + const states = [ + ...Array.from({ length: 511 }, (_, i) => ({ + name: 'paragraph', + text: `Paragraph ${i}`, + })), + { name: 'block-quote', children: [] }, + { name: 'paragraph', text: 'Paragraph after empty container' }, + ]; + muya.setContent(states as never); + expect(mountedParagraphs(muya)).toBe(511); + expect( + (muya.editor.scrollPage!.find(511) as unknown as { blockName: string }).blockName, + ).toBe('block-quote'); + + const frontier = (muya.editor.scrollPage!.find(510) as unknown as { + lastContentInDescendant: () => { + text: string; + setCursor: (start: number, end: number, focus?: boolean) => void; + arrowHandler: (event: Event) => void; + }; + }).lastContentInDescendant(); + expect(frontier.text).toBe('Paragraph 510'); + + muya.editor.activeContentBlock = frontier as never; + frontier.setCursor(0, 0, true); + frontier.arrowHandler( + new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true }), + ); + muya.editor.jsonState.flush(); + + expect(muya.editor.jsonState.rawState).toHaveLength(513); + expect(muya.domNode.textContent).toContain('Paragraph after empty container'); + vi.runAllTimers(); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('delete at the end of the frontier paragraph merges with the pending successor (review: delete merge)', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + const frontier = (muya.editor.scrollPage!.find(511) as unknown as { + lastContentInDescendant: () => { + text: string; + setCursor: (start: number, end: number, focus?: boolean) => void; + deleteHandler: (event: KeyboardEvent) => void; + }; + }).lastContentInDescendant(); + expect(frontier.text).toBe('Paragraph 511'); + + muya.editor.activeContentBlock = frontier as never; + frontier.setCursor(frontier.text.length, frontier.text.length, true); + frontier.deleteHandler( + new KeyboardEvent('keydown', { key: 'Delete', cancelable: true }), + ); + muya.editor.jsonState.flush(); + + // Same as at full mount: the two paragraphs merge — the keystroke is + // not swallowed by the mount frontier. + expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS - 1); + expect(frontier.text).toBe('Paragraph 511Paragraph 512'); + vi.runAllTimers(); + expectDomMatchesState(muya); + muya.destroy(); + }); + + it('tab out of the last table cell at the frontier reaches the pending successor (review: table tab)', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const parts = Array.from({ length: SECTIONS }, (_, i) => `Paragraph ${i}`); + parts[550] = '| a | b |\n| - | - |\n| c | d |'; + const muya = new Muya(host, { + markdown: `${parts.join('\n\n')}\n`, + } as ConstructorParameters[1]); + muya.init(); + bootedHosts.push(muya.domNode); + + muya.editor.scrollPage!.ensureMountedThrough(550); + const before = mountedParagraphs(muya); + const cell = (muya.editor.scrollPage!.find(550) as unknown as { + lastContentInDescendant: () => { + text: string; + setCursor: (start: number, end: number, focus?: boolean) => void; + tabHandler: (event: Event) => void; + }; + }).lastContentInDescendant(); + expect(cell.text).toBe('d'); + + muya.editor.activeContentBlock = cell as never; + cell.setCursor(1, 1, true); + cell.tabHandler(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })); + muya.editor.jsonState.flush(); + + expect(muya.editor.jsonState.rawState).toHaveLength(SECTIONS); + expect(mountedParagraphs(muya)).toBeGreaterThan(before); + expect(muya.domNode.textContent).toContain('Paragraph 551'); + muya.destroy(); + }); + + it('destroy cancels a pending mount so timers never fire afterwards', () => { + const muya = bootMuya(); + expect(mountedParagraphs(muya)).toBe(512); + + muya.destroy(); + expect(() => vi.runAllTimers()).not.toThrow(); + }); +}); diff --git a/third_party/muya/src/block/scrollPage/index.ts b/third_party/muya/src/block/scrollPage/index.ts index 82d0b5a..dc05569 100644 --- a/third_party/muya/src/block/scrollPage/index.ts +++ b/third_party/muya/src/block/scrollPage/index.ts @@ -1,3 +1,4 @@ +import type { JSONOpList } from 'ot-json1'; import type { Muya } from '../../muya'; import type { TState } from '../../state/types'; import type { Nullable } from '../../types'; @@ -5,6 +6,7 @@ import type Content from '../base/content'; import type TreeNode from '../base/treeNode'; import type { IConstructor, TBlockPath } from '../types'; import { BLOCK_DOM_PROPERTY } from '../../config'; +import { cloneStateTree } from '../../state/cloneState'; import { isHTMLElement, isMouseEvent } from '../../utils'; import logger from '../../utils/logger'; import Parent from '../base/parent'; @@ -16,6 +18,31 @@ interface IBlurFocus { focus: Nullable; } +// Recursive node count of a state subtree — the unit of mount budgeting. +function stateWeight(state: TState): number { + let weight = 1; + if ('children' in state && Array.isArray(state.children)) { + for (const child of state.children as TState[]) + weight += stateWeight(child); + } + return weight; +} + +// End index (exclusive) after taking blocks from `from` until `budget` +// weight is consumed. Always takes at least one block so progress is made +// even when a single block exceeds the whole budget. +function takeByWeight(state: TState[], from: number, budget: number): number { + let end = from; + let used = 0; + while (end < state.length) { + used += stateWeight(state[end]); + end += 1; + if (used >= budget) + break; + } + return end; +} + export class ScrollPage extends Parent { private _blurFocus: IBlurFocus = { blur: null, focus: null }; @@ -51,17 +78,35 @@ export class ScrollPage extends Parent { static create(muya: Muya, state: TState[]) { const scrollPage = new ScrollPage(muya); - scrollPage.append( - ...state.map((block) => { - return this.loadBlock(block.name).create(muya, block); - }), - ); + scrollPage._mountBlocks(state); scrollPage.parent!.domNode!.appendChild(scrollPage.domNode!); return scrollPage; } + // Chunked-mount tuning. The synchronous prefix is budgeted in STATE + // WEIGHT (the recursive node count of a top-level block, not block + // count — one list with thousands of items would blow a count budget) + // and comfortably overfills any viewport. Background chunks are budgeted + // by ELAPSED TIME so a scheduled task stays under half a frame on the + // machine actually running it. A single top-level block larger than + // either budget still mounts as one unit — blocks are atomic — which is + // the documented bound of this scheme. + // The chunk budget bounds the JS append work only — the browser still + // styles/lays out each appended chunk after the task, so the observed + // event-loop gap is larger than the budget. 4 ms keeps that total gap + // small on typical content; the perf smoke guards it end to end. + private static readonly _initialMountWeight = 512; + private static readonly _chunkBudgetMs = 4; + + private _pendingMount: { + handle: ReturnType | null; + } | null = null; + + // See suspendOnDemandMount(). + private _onDemandMountSuspended = false; + override get path() { return []; } @@ -94,16 +139,226 @@ export class ScrollPage extends Parent { } updateState(state: TState[]) { - const { muya } = this; + this.cancelPendingMount(); // Empty scrollPage dom this.empty(); + this._mountBlocks(state); + } + + // Building every block's DOM subtree up front is the dominant open cost on + // large documents (#4887): parse and state are linear, but a 300k-word file + // still means ~100k block nodes constructed in one synchronous task. Mount + // an over-viewport prefix synchronously and append the rest in scheduled + // time-budgeted chunks, so the document is visible and editable at once. + // + // The pending tail is NOT a snapshot. Every chunk re-reads + // `jsonState.rawState` and resumes at the live child count; the invariant + // is that the mounted prefix always equals `state[0 .. children.length)`. + // Structural edits (Enter splits, paste, block removal) mutate the live + // tree and the state at the SAME index, so the invariant survives them + // with no flushing. Whole-tree consumers (`queryBlock` into the tail, + // `lastContentInDescendant`, search) mount what they need through + // `ensureMountedThrough` / `flushPendingMount`. + private _mountBlocks(state: TState[]) { + const initial = takeByWeight(state, 0, ScrollPage._initialMountWeight); + this._appendFromState(state, 0, initial); + + if (state.length > initial) { + this._pendingMount = { handle: null }; + this._scheduleNextChunk(); + } + } + + private _appendFromState(state: readonly TState[], from: number, to: number) { + // Blocks keep references into the state object they are built from + // (`this.meta = state.meta` and the like). The slice may come straight + // from `jsonState.rawState`, so clone it — a block mutating shared + // metadata would silently rewrite the JSON state ahead of its op. this.append( - ...state.map((block) => { - return ScrollPage.loadBlock(block.name).create(muya, block); + ...cloneStateTree(state.slice(from, to)).map((block) => { + return ScrollPage.loadBlock(block.name).create(this.muya, block); }), ); } + private _scheduleNextChunk() { + if (!this._pendingMount) + return; + + // setTimeout keeps mounting while the tab is backgrounded (rAF stalls). + this._pendingMount.handle = setTimeout(() => { + this._mountNextChunk(); + }, 0); + } + + private _mountNextChunk() { + const pending = this._pendingMount; + if (!pending) + return; + + // Edits mutate the live tree immediately but their ops reach the + // state on the next frame; drain them first so the live child count + // is a valid cursor into `rawState`. + this.muya.editor.jsonState.flush(); + + // Mount block by block until the task budget is spent — actual + // elapsed time, not a fixed weight, so one chunk never grows into a + // frame-blowing task on heavy content. + const states = this.muya.editor.jsonState.rawState; + const started = performance.now(); + let next = this.children.length; + while ( + next < states.length + && performance.now() - started < ScrollPage._chunkBudgetMs + ) { + this._appendFromState(states, next, next + 1); + next += 1; + } + + if (next >= states.length) { + this._pendingMount = null; + this.muya.eventCenter.emit('muya-mount-complete', { total: states.length }); + } + else { + this.muya.eventCenter.emit('muya-mount-progress', { + mounted: next, + total: states.length, + }); + this._scheduleNextChunk(); + } + } + + /** + * Mount every top-level block an ot-json1 operation touches. MUST run + * against the pre-dispatch state: applying the op to the state first and + * mounting afterwards would materialize the target from the updated + * state and then apply the op to it a second time. + */ + ensureMountedForOperation(op: JSONOpList) { + if (!this._pendingMount) + return; + + // Collect the leading numeric descent of every path in the op — + // the top-level block indices. Deeper numbers (child indices, text + // offsets) are never the FIRST element of a component, and branch + // lists nest their sub-paths as arrays. + let max = -1; + const scan = (component: unknown) => { + if (!Array.isArray(component) || component.length === 0) + return; + const first: unknown = component[0]; + if (typeof first === 'number') { + if (first > max) + max = first; + } + else if (Array.isArray(first)) { + for (const child of component) + scan(child); + } + }; + scan(op); + + // Mount one PAST the deepest touched index: a replace picks the + // target out of the live tree and then drops the replacement before + // the next sibling — that reference sibling must already be mounted, + // or the drop would materialize it from the post-dispatch state. + if (max >= 0) + this.ensureMountedThrough(max + 1); + } + + /** + * While the incremental pick/drop walker applies an operation, on-demand + * materialization must stay OFF: the state is already post-dispatch, so + * mounting during the walk would materialize nodes the walker itself is + * about to insert (duplicating them). Targets are pre-mounted from the + * pre-dispatch state instead (`ensureMountedForOperation`); if the + * walker still reaches an unmounted path it fails loudly and the + * caller's fallback rebuilds the whole tree. + */ + suspendOnDemandMount() { + this._onDemandMountSuspended = true; + } + + resumeOnDemandMount() { + this._onDemandMountSuspended = false; + } + + /** + * Synchronously mount blocks until the top-level index is available. + * Bounded consumers (offset-based cursor restore, path queries into the + * tail, TOC click-to-scroll) use this instead of a full flush. + */ + ensureMountedThrough(index: number) { + const pending = this._pendingMount; + if (!pending || index < this.children.length) + return; + + // See _mountNextChunk: pending edit ops must land in the state + // before the live child count is used as a cursor into it. + this.muya.editor.jsonState.flush(); + + const states = this.muya.editor.jsonState.rawState; + if (index >= states.length - 1) { + this.flushPendingMount(); + return; + } + + if (pending.handle !== null) { + clearTimeout(pending.handle); + pending.handle = null; + } + this._appendFromState(states, this.children.length, index + 1); + this.muya.eventCenter.emit('muya-mount-progress', { + mounted: this.children.length, + total: states.length, + }); + this._scheduleNextChunk(); + } + + override lastContentInDescendant() { + // End-of-document consumers (Ctrl+End, select-all, cursor restore) + // must see the real last block, not the last mounted chunk. + this.flushPendingMount(); + + return super.lastContentInDescendant(); + } + + /** Synchronously finish a chunked mount that is still in flight. */ + flushPendingMount() { + const pending = this._pendingMount; + if (!pending) + return; + + if (pending.handle !== null) + clearTimeout(pending.handle); + this._pendingMount = null; + + // See _mountNextChunk: pending edit ops must land in the state + // before the live child count is used as a cursor into it. + this.muya.editor.jsonState.flush(); + + const states = this.muya.editor.jsonState.rawState; + this._appendFromState(states, this.children.length, states.length); + this.muya.eventCenter.emit('muya-mount-complete', { + total: states.length, + }); + } + + /** + * Drop a mount that is still in flight without materializing it. Called + * by `updateState` before a rebuild and by `Muya.destroy` — the pending + * timer must never run against a torn-down instance. An explicit call is + * the lifecycle signal; DOM connectedness is deliberately not consulted, + * so detached hosts keep mounting (`new Muya(detachedElement)` works). + */ + cancelPendingMount() { + if (!this._pendingMount) + return; + if (this._pendingMount.handle !== null) + clearTimeout(this._pendingMount.handle); + this._pendingMount = null; + } + /** * Find the content block by the path * @param {Array} path @@ -112,6 +367,19 @@ export class ScrollPage extends Parent { if (path.length === 0) return this; + // An op or lookup may target a block whose chunk has not mounted yet — + // mount up to the target only, never the whole remainder (a full + // flush here would turn the source-mode cursor handoff back into a + // whole-document mount). Suspended while the pick/drop walker runs: + // see suspendOnDemandMount(). + if ( + this._pendingMount + && !this._onDemandMountSuspended + && (path[0] as number) >= this.children.length + ) { + this.ensureMountedThrough(path[0] as number); + } + const p = path.shift() as number; const block = this.find(p) as Parent & { queryBlock: (p: TBlockPath) => Parent | Content | undefined }; return block && path.length ? block.queryBlock(path) : block; @@ -187,6 +455,17 @@ export class ScrollPage extends Parent { const target = event.target; if (target[BLOCK_DOM_PROPERTY] === this) { + // While a tail is pending, the area below the mount frontier is + // NOT blank — the document logically continues there (#4887). + // Appending would land mid-document; materializing the tail on a + // container click would freeze a large file for taps in the + // gutter or between blocks, and the forced reflow then moves the + // real bottom below the click point anyway. Do nothing — + // background chunks finish shortly and end-of-document clicks + // behave normally again. + if (this._pendingMount) + return; + const lastChild = this.lastChild as Parent; const lastContentBlock = lastChild.lastContentInDescendant()!; const { clientY } = event; diff --git a/third_party/muya/src/editor/index.ts b/third_party/muya/src/editor/index.ts index 60e046b..99c6960 100644 --- a/third_party/muya/src/editor/index.ts +++ b/third_party/muya/src/editor/index.ts @@ -398,6 +398,13 @@ export class Editor { updateContents(operations: JSONOp, selection: Nullable, source: string) { const muya = this._muya; + // Mount every top-level block the operation touches BEFORE it reaches + // the state: the DOM pick/drop below resolves blocks via queryBlock, + // which would otherwise materialize an unmounted target from the + // ALREADY-updated state and then drop the same operation onto it a + // second time — a permanent state/DOM fork (#4887 review). + if (operations !== null) + this.scrollPage?.ensureMountedForOperation(operations); // ot-json1 no-op (`null`) is forwarded to dispatch — JSONState // short-circuits internally so listeners still see a json-change // event for the no-op. @@ -408,13 +415,20 @@ export class Editor { return; try { + // On-demand mounting must not run inside the walk: the state is + // post-dispatch here, so a mount would materialize nodes the + // walker itself is about to drop (duplicates). Targets were + // pre-mounted above; a miss below fails into the rebuild path. + this.scrollPage?.suspendOnDemandMount(); const snapshot = pick(this.scrollPage as BlockNode, operations); drop(snapshot, operations, muya); + this.scrollPage?.resumeOnDemandMount(); this._restoreSelection(selection); } catch (error) { + this.scrollPage?.resumeOnDemandMount(); // The incremental walk left the live tree half-applied (pick removed // blocks drop never re-inserted). The json state is authoritative and // already up to date — rebuild from it instead of leaving an empty doc. @@ -498,6 +512,13 @@ export class Editor { const state = this.jsonState.getState(); this.scrollPage!.updateState(state); + // The old document's selection must not leak into the new one: + // focus() re-resolves the stale anchorPath against the fresh tree, + // and during a progressive mount that path query would force-mount + // up to (or past) the old caret (#4887 review). Callers restore + // carets through their own flows afterwards. + this.selection.clear(); + this.activeContentBlock = null; this.history.clear(); this.searchModule.reset(); diff --git a/third_party/muya/src/event/index.ts b/third_party/muya/src/event/index.ts index 6f26560..38261d4 100644 --- a/third_party/muya/src/event/index.ts +++ b/third_party/muya/src/event/index.ts @@ -85,6 +85,16 @@ class EventCenter { this.listeners[event] = [handler]; } + /** + * Whether any listener is subscribed to `event`. Lets producers skip + * expensive preparation (e.g. completing a progressive mount, #4887) + * when no consumer is installed. + */ + hasListeners(event: string): boolean { + const listeners = this.listeners[event]; + return Array.isArray(listeners) && listeners.length > 0; + } + /** * [on] on custom event */ diff --git a/third_party/muya/src/history/index.ts b/third_party/muya/src/history/index.ts index cbb8bf9..778ec8b 100644 --- a/third_party/muya/src/history/index.ts +++ b/third_party/muya/src/history/index.ts @@ -149,6 +149,18 @@ class History { } private _change(source: HistoryAction, dest: HistoryAction) { + // A same-frame edit may still sit in the rAF op batch. Drain it into + // the history FIRST — before the stack check AND before + // `_ignoreChange` silences json-change: + // - recording the pending edit clears the redo stack, so checking + // length before flushing would let redo() pop an emptied stack; + // - anything mid-undo that flushes the batch (e.g. an on-demand + // mount during a progressive mount, #4887) would otherwise apply + // the edit while dropping its history entry; + // - and an edit that has not flushed yet becomes undoable + // immediately, even when the stack was empty before it. + this._muya.editor.jsonState.flush(); + if (this._stack[source].length === 0) return; diff --git a/third_party/muya/src/muya.ts b/third_party/muya/src/muya.ts index bb72cf9..aebe1cc 100644 --- a/third_party/muya/src/muya.ts +++ b/third_party/muya/src/muya.ts @@ -221,6 +221,15 @@ export class Muya { return this.editor.jsonState.getTOC(); } + /** + * Synchronously mount top-level blocks up to `index` if a progressive + * mount is still in flight (#4887). Hosts call this before scrolling to + * a block whose DOM may not exist yet — e.g. a TOC entry's `index`. + */ + ensureMountedThrough(index: number) { + this.editor.scrollPage?.ensureMountedThrough(index); + } + undo() { this.editor.history.undo(); } @@ -1637,6 +1646,9 @@ export class Muya { } destroy() { + // A chunked mount still in flight must never fire against a + // torn-down instance (scrollPage/index.ts). + this.editor.scrollPage?.cancelPendingMount(); this.eventCenter.detachAllDomEvents(); this.eventCenter.unsubscribeAll(); // this.domNode[BLOCK_DOM_PROPERTY] = null; diff --git a/third_party/muya/src/search/index.ts b/third_party/muya/src/search/index.ts index 80282d3..dcbfe71 100644 --- a/third_party/muya/src/search/index.ts +++ b/third_party/muya/src/search/index.ts @@ -210,6 +210,11 @@ export class Search { // Highlight current search. if (value) { + // Search walks the LIVE tree; a progressive mount still in flight + // would silently drop tail matches (#4887). Searching is an + // explicit user action, so completing the mount here is the + // documented trade. + this._scrollPage?.flushPendingMount(); this._scrollPage?.depthFirstTraverse((block: TreeNode) => { if (block.isContent()) { const { text } = block; diff --git a/third_party/muya/src/selection/offsetCursor.ts b/third_party/muya/src/selection/offsetCursor.ts index b75e819..f85a58b 100644 --- a/third_party/muya/src/selection/offsetCursor.ts +++ b/third_party/muya/src/selection/offsetCursor.ts @@ -127,6 +127,18 @@ function _findSentinel(scrollPage: ScrollPage, sentinel: string): ISentinelHit | * the anchor sentinel precedes it in the same block. */ export function resolveSentinelCursor(scrollPage: ScrollPage): IPathCursor | null { + // The sentinel may sit in a block whose mount chunk has not run yet + // (#4887 progressive mount). Locate its top-level index in the COMPLETE + // state and mount just far enough — never the whole remainder. + const states = scrollPage.muya.editor.jsonState.rawState; + for (let i = states.length - 1; i >= 0; i--) { + const raw = JSON.stringify(states[i]); + if (raw.includes(ANCHOR_SENTINEL) || raw.includes(FOCUS_SENTINEL)) { + scrollPage.ensureMountedThrough(i); + break; + } + } + const anchorHit = _findSentinel(scrollPage, ANCHOR_SENTINEL); const focusHit = _findSentinel(scrollPage, FOCUS_SENTINEL); diff --git a/third_party/muya/src/state/getTOC.ts b/third_party/muya/src/state/getTOC.ts index a8cf391..41e8961 100644 --- a/third_party/muya/src/state/getTOC.ts +++ b/third_party/muya/src/state/getTOC.ts @@ -1,49 +1,50 @@ -import type Content from '../block/base/content'; -import type Parent from '../block/base/parent'; import type { Muya } from '../muya'; +import type { TState } from './types'; import { tokenizer, tokensToPlainText } from '../inlineRenderer/lexer'; import { getUniqueId } from '../utils'; import { generateGithubSlug } from '../utils/slug'; +import { isAtxHeadingState, isSetextHeadingState } from './types'; export interface ITocItem { content: string; lvl: number; slug: string; githubSlug: string; + // Top-level state index of the heading. During a progressive mount the + // heading's DOM may not exist yet; hosts pass this to + // `muya.ensureMountedThrough(index)` before scrolling to it. + index: number; } -interface IHeadingBlock extends Parent { - meta: { level: number }; -} - -const slugCache = new WeakMap(); +// Slugs are keyed on the heading's STATE node, not its block: the TOC is +// collected from `jsonState` (the complete logical document) so it stays +// correct even while block mounting is in progress, and block-side consumers +// (headingCopyLink) resolve their heading to the same state node. ot-json1 +// apply is copy-on-write, so an unedited heading keeps its node — and its +// slug — across other edits; editing the heading replaces the node, which +// only re-keys that entry (hosts rebuild their TOC on `json-change` anyway). +const slugCache = new WeakMap(); -export function stableSlug(block: Parent): string { - let slug = slugCache.get(block); +export function stableSlug(node: object): string { + let slug = slugCache.get(node); if (slug == null) { slug = getUniqueId(); - slugCache.set(block, slug); + slugCache.set(node, slug); } return slug; } export function getTOC(muya: Muya): ITocItem[] { - const { scrollPage } = muya.editor; - if (!scrollPage) - return []; - + const states: readonly TState[] = muya.editor.jsonState.rawState; const items: ITocItem[] = []; - for (const node of scrollPage.children.iterator()) { - const { blockName } = node; - if (blockName !== 'atx-heading' && blockName !== 'setext-heading') + for (let index = 0; index < states.length; index++) { + const state = states[index]; + if (!isAtxHeadingState(state) && !isSetextHeadingState(state)) continue; - const block = node as IHeadingBlock; - const head = block.children.head as Content | null; - const text = head?.text ?? ''; - - const source = blockName === 'setext-heading' + const text = state.text ?? ''; + const source = isSetextHeadingState(state) ? text.trim() : text.replace(/^\s*#{1,6}\s+/, '').trim(); @@ -57,9 +58,10 @@ export function getTOC(muya: Muya): ITocItem[] { items.push({ content, - lvl: block.meta.level, - slug: stableSlug(block), + lvl: state.meta.level, + slug: stableSlug(state), githubSlug: generateGithubSlug(content), + index, }); } diff --git a/third_party/muya/src/state/index.ts b/third_party/muya/src/state/index.ts index 3576df6..83f98ae 100644 --- a/third_party/muya/src/state/index.ts +++ b/third_party/muya/src/state/index.ts @@ -225,6 +225,15 @@ class JSONState { }); } + // The live state root WITHOUT a defensive clone. For read-only, + // same-tick consumers (TOC collection, slug identity) that must see the + // complete logical document regardless of how much of it is mounted, and + // for whom the per-call whole-document clone of `getState()` is the cost + // being avoided. Callers must never mutate the returned tree. + get rawState(): readonly TState[] { + return this._state; + } + getState(): TState[] { return cloneStateTree(this._state); }