// Top-level block reuse (Phase 16.2 D-03): after an edit, every top-level // block the edit did not touch is emitted as its original source bytes, // with the white space and comments between blocks, whatever the edit was // (one changed block, a deletion, an insertion). import { afterEach, describe, expect, it } from 'vitest' import { getSchema } from '@tiptap/core' import { preservationExtensions } from '../../assets/src/blogeditor/extensions' import { captureBlocks } from '../../assets/src/blogeditor/serializer' import { insertAtBlock, mount } from './helpers' afterEach(() => { document.body.replaceChildren() }) // Source formatting the schema serializer would not reproduce: upper-case // tags, single quotes, unquoted and reordered attributes, entities, // self-closing void tags and odd white space. const blocks = [ "

Pierwszy akapit & więcej

", '

Wstęp

', '

Drugi link

', '', '

Trzeci
wiersz

', '', ] const separators = ['\n\n', '\n\n', '\r\n', '\n \n', '\n'] const source = blocks.map((block, i) => block + (separators[i] ?? '')).join('') + '\n\n' /** topLevel returns the editor document's top-level node types. */ function topLevel(el: ReturnType['editor']): string[] { const out: string[] = [] el.state.doc.forEach((node) => out.push(node.type.name)) return out } describe('block reuse', () => { it('returns the source unchanged before any edit', () => { const { el, editor } = mount(source) expect(topLevel(editor)).toEqual(['paragraph', 'heading', 'paragraph', 'bulletList', 'paragraph', 'rawHtml']) expect(el.value).toBe(source) }) it('keeps every other block, separator and comment byte for byte after one edit', () => { const { el, editor } = mount(source) insertAtBlock(editor, 2, 'X') const value = el.value expect(value).not.toBe(source) for (const [i, block] of blocks.entries()) { if (i === 2) continue expect(value).toContain(block) } for (const separator of separators) expect(value).toContain(separator) expect(value).toContain('

XDrugi') expect(value.endsWith('\n\n')).toBe(true) // Only the edited block differs from the source. expect(value.replace(/

XDrugi[^]*?<\/p>/, blocks[2]!)).toBe(source) }) it('keeps the remaining blocks after a deletion', () => { const { el, editor } = mount(source) let from = 0 let to = 0 editor.state.doc.forEach((node, offset, index) => { if (index === 1) { from = offset to = offset + node.nodeSize } }) editor.view.dispatch(editor.state.tr.delete(from, to)) const value = el.value expect(value).not.toContain('Wstęp') for (const [i, block] of blocks.entries()) { if (i !== 1) expect(value).toContain(block) } expect(value.startsWith(blocks[0]!)).toBe(true) }) it('keeps every block around an insertion and serializes only the new one', () => { const { el, editor } = mount(source) let at = 0 editor.state.doc.forEach((_node, offset, index) => { if (index === 3) at = offset }) const paragraph = editor.schema.nodes.paragraph!.create(null, editor.schema.text('Nowy')) editor.view.dispatch(editor.state.tr.insert(at, paragraph)) const value = el.value for (const block of blocks) expect(value).toContain(block) const inserted = value.indexOf('

Nowy

') expect(inserted).toBeGreaterThan(value.indexOf(blocks[2]!)) expect(inserted).toBeLessThan(value.indexOf(blocks[3]!)) }) it('keeps reused blocks after several edits and an undo returns the exact source', () => { const { el, editor } = mount(source) insertAtBlock(editor, 0, 'A') insertAtBlock(editor, 4, 'B') const value = el.value for (const i of [1, 2, 3, 5]) expect(value).toContain(blocks[i]!) editor.commands.undo() editor.commands.undo() expect(el.value).toBe(source) }) it('serializes every block when the document is replaced entirely', () => { const { el, editor } = mount(source) editor.commands.setContent('

Inny

') expect(el.value).toBe('

Inny

') }) }) describe('captureBlocks', () => { const schema = getSchema(preservationExtensions()) it('records each block with the text before it and the trailing text', () => { const captured = captureBlocks('\n

a

\n\n

b

', schema)! expect(captured.blocks.map((b) => [b.before, b.html])).toEqual([ ['\n', '

a

'], ['\n\n', '

b

'], ]) expect(captured.trailing).toBe(' ') expect(captured.blocks.map((b) => b.nodes.map((n) => n.type.name))).toEqual([['paragraph'], ['heading']]) }) it('splits raw text elements and nested elements by source position', () => { const html = '
a

b

c

' const captured = captureBlocks(html, schema)! expect(captured.blocks.map((b) => b.html)).toEqual(['', '
a

b

', '

c

']) }) it.each([ ['an unclosed comment', '

a