Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions src/features/editor/documentOutline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -161,18 +161,20 @@ 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
}

function createFakeEditor({ toc = [], headingsHtml = '' }: FakeEditorOptions = {}) {
const domNode = document.createElement('div')
domNode.innerHTML = `<div class="mu-container">${headingsHtml}</div>`
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
}
}

Expand All @@ -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 = '<h1>Alpha</h1><p>text</p><h2>Beta</h2>'
Expand Down Expand Up @@ -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: '<h1>Alpha</h1>',
})
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,
Expand Down
14 changes: 12 additions & 2 deletions src/features/editor/documentOutline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down
99 changes: 93 additions & 6 deletions src/features/editor/resumePosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -158,17 +159,29 @@ function buildEditorDom() {
interface ControllerHarnessOptions {
createRecord: (options: CreateResumePositionRecordOptions) => Promise<ResumePositionRecord | null>
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<string, ResumePositionRecord>()
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',
Expand All @@ -178,7 +191,7 @@ function createControllerHarness({ createRecord, readPosition }: ControllerHarne
createRecord,
})

return { controller, shell, store }
return { controller, shell, editorRoot, store, ensureMountedThrough }
}

afterEach(() => {
Expand Down Expand Up @@ -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()
})
})
56 changes: 37 additions & 19 deletions src/features/editor/resumePosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
17 changes: 17 additions & 0 deletions src/types/muya-core.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>)
init(): void
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading