Reflow and edit hard-wrapped prose within single paragraph blocks (#7407)
* Reflow and edit hard-wrapped prose within single paragraph blocks Instead of splitting consecutive markdown source lines into multiple visual paragraph nodes during document initialization, preserve them as a single paragraph containing literal newlines. - Use `white-space: normal` CSS to reflow soft breaks naturally. - Introduce `deleteAdjacentEmptyParagraph` to handle Backspace/Delete without converting soft newlines to hard break elements. - Update the cut handler to delete only a visual line on Cmd+X within hard-wrapped paragraphs. - Avoid split-pane/sync phantom dirty states caused by structural block splitting. * Document why normalizeEmptyListItems is used for paragraph reflow Add comments to clarify that normalizeEmptyListItems preserves hard-wrapped paragraphs as single paragraphs, allowing them to reflow via CSS instead of being split on load or external sync.
This commit is contained in:
parent
17d5eff5d6
commit
136cb50bcb
|
|
@ -566,6 +566,13 @@
|
|||
margin: 0.75em 0;
|
||||
}
|
||||
|
||||
/* Why: document markdown files treat source newlines inside a paragraph as
|
||||
soft breaks, so prose must collapse them instead of inheriting Tiptap's
|
||||
editable-root `break-spaces` behavior. */
|
||||
.rich-markdown-editor-shell .rich-markdown-editor p {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.rich-markdown-editor .tiptap-mathematics-render {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,12 +114,12 @@ export default function RichMarkdownEditor({
|
|||
const editorRef = useRef<Editor | null>(null)
|
||||
const cancelAutoFocusRef = useRef<(() => void) | null>(null)
|
||||
const serializeTimerRef = useRef<number | null>(null)
|
||||
// Why: normalizeSoftBreaks dispatches a ProseMirror transaction inside onCreate
|
||||
// Why: empty-list repair dispatches a ProseMirror transaction inside onCreate
|
||||
// which triggers onUpdate. Without this guard the editor immediately marks the
|
||||
// file dirty before the user has typed anything.
|
||||
const isInitializingRef = useRef(true)
|
||||
// Why: internal maintenance paths can dispatch transactions after mount
|
||||
// (external reloads, soft-break normalization, image-path refresh). Those
|
||||
// (external reloads, empty-list repair, image-path refresh). Those
|
||||
// are not user edits, so onUpdate must ignore them or split panes can flip a
|
||||
// shared file dirty without any real content change.
|
||||
const isApplyingProgrammaticUpdateRef = useRef(false)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ type State = {
|
|||
}
|
||||
|
||||
// Why: a thrown exception inside the TipTap/ProseMirror render or in the
|
||||
// effect that runs `setContent` + `normalizeSoftBreaks` on external-reload
|
||||
// effect that runs `setContent` + empty-list repair on external-reload
|
||||
// would escape to the React root and — without this boundary — cause React
|
||||
// 18 to unmount the entire renderer subtree, blacking out the whole Orca
|
||||
// window (see issue #826). Scoping the boundary to the rich-markdown editor
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { TableCell } from '@tiptap/extension-table-cell'
|
|||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
import { normalizeEmptyListItems } from './rich-markdown-normalize'
|
||||
|
||||
const testExtensions = [
|
||||
StarterKit,
|
||||
|
|
@ -49,12 +49,12 @@ function shouldSyncPropIntoEditor(
|
|||
}
|
||||
|
||||
/**
|
||||
* Simulates the onCreate flow: normalizeSoftBreaks then getMarkdown().
|
||||
* Simulates the onCreate flow: empty-list repair then getMarkdown().
|
||||
*/
|
||||
function simulateOnCreate(diskContent: string): string {
|
||||
const editor = createEditor(diskContent)
|
||||
try {
|
||||
normalizeSoftBreaks(editor)
|
||||
normalizeEmptyListItems(editor)
|
||||
return editor.getMarkdown()
|
||||
} finally {
|
||||
editor.destroy()
|
||||
|
|
@ -65,7 +65,7 @@ function simulateOnCreate(diskContent: string): string {
|
|||
// 1. trimEnd normalization prevents phantom dirty from trailing newlines
|
||||
//
|
||||
// getMarkdown() always appends a trailing \n. For content that round-trips
|
||||
// cleanly (no soft-break normalization), the ONLY difference is that
|
||||
// cleanly (no structural repair changes), the ONLY difference is that
|
||||
// trailing newline. trimEnd() must eliminate that false positive.
|
||||
// -----------------------------------------------------------------------
|
||||
describe('trailing newline does not cause false dirty state', () => {
|
||||
|
|
@ -94,45 +94,37 @@ describe('trailing newline does not cause false dirty state', () => {
|
|||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. normalizeSoftBreaks produces structural differences that getMarkdown()
|
||||
// serializes differently. These cannot be hidden by trimEnd — they are
|
||||
// handled at runtime by the isInitializingRef guard in onUpdate.
|
||||
//
|
||||
// The tests below document the known divergence so that future changes
|
||||
// to the serializer or normalizer don't silently shift which category
|
||||
// a given input falls into.
|
||||
// 2. Hard-wrapped prose must stay structurally clean. The rich editor renders
|
||||
// soft breaks through CSS reflow, not by splitting the document model.
|
||||
// -----------------------------------------------------------------------
|
||||
describe('normalizeSoftBreaks: known structural changes', () => {
|
||||
it('splits consecutive lines into separate paragraphs', () => {
|
||||
describe('document soft-break round-trip', () => {
|
||||
it('keeps consecutive source lines in one paragraph', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three')
|
||||
try {
|
||||
const before = countParagraphs(editor)
|
||||
normalizeSoftBreaks(editor)
|
||||
normalizeEmptyListItems(editor)
|
||||
const after = countParagraphs(editor)
|
||||
|
||||
expect(after).toBeGreaterThan(before)
|
||||
expect(after).toBe(3)
|
||||
expect(before).toBe(1)
|
||||
expect(after).toBe(1)
|
||||
expect(editor.state.doc.firstChild?.textContent).toBe('Line one\nLine two\nLine three')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('serialized soft-break content differs from disk content', () => {
|
||||
it('round-trips a hard-wrapped paragraph without blank-line expansion', () => {
|
||||
const disk = 'Line one\nLine two'
|
||||
const serialized = simulateOnCreate(disk)
|
||||
|
||||
// After normalization each line is its own paragraph, serialized with
|
||||
// blank-line separators. This difference is NOT a bug — the
|
||||
// isInitializingRef guard prevents it from marking the file dirty.
|
||||
expect(trimEnd(serialized)).not.toBe(trimEnd(disk))
|
||||
expect(trimEnd(serialized)).toBe('Line one\n\nLine two')
|
||||
expect(trimEnd(serialized)).toBe(trimEnd(disk))
|
||||
})
|
||||
|
||||
it('does not modify content without soft breaks', () => {
|
||||
const editor = createEditor('# Title\n\nBody text')
|
||||
try {
|
||||
const docBefore = editor.state.doc.toJSON()
|
||||
normalizeSoftBreaks(editor)
|
||||
normalizeEmptyListItems(editor)
|
||||
const docAfter = editor.state.doc.toJSON()
|
||||
|
||||
expect(docAfter).toEqual(docBefore)
|
||||
|
|
@ -171,7 +163,7 @@ describe('real edits are detected as dirty', () => {
|
|||
const diskContent = '# README\n\nOriginal text'
|
||||
const editor = createEditor(diskContent)
|
||||
try {
|
||||
normalizeSoftBreaks(editor)
|
||||
normalizeEmptyListItems(editor)
|
||||
|
||||
// Insert text via a ProseMirror transaction (no DOM required)
|
||||
const { tr } = editor.state
|
||||
|
|
@ -189,7 +181,7 @@ describe('real edits are detected as dirty', () => {
|
|||
const diskContent = '# Title\n\nParagraph to keep\n\nParagraph to delete'
|
||||
const editor = createEditor(diskContent)
|
||||
try {
|
||||
normalizeSoftBreaks(editor)
|
||||
normalizeEmptyListItems(editor)
|
||||
|
||||
// Delete the last paragraph node
|
||||
const doc = editor.state.doc
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { TextSelection } from '@tiptap/pm/state'
|
||||
import type { EditorView } from '@tiptap/pm/view'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
|
|
@ -8,7 +11,8 @@ import { TableCell } from '@tiptap/extension-table-cell'
|
|||
import { TableHeader } from '@tiptap/extension-table-header'
|
||||
import { TableRow } from '@tiptap/extension-table-row'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
|
||||
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
|
||||
/**
|
||||
* Minimal extensions matching the rich editor schema without UI dependencies.
|
||||
|
|
@ -33,6 +37,10 @@ function createEditor(markdown: string): Editor {
|
|||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/**
|
||||
* Simulates the cut handler's depth walk to determine what node would be cut.
|
||||
* This mirrors the logic in RichMarkdownEditor.tsx handleDOMEvents.cut,
|
||||
|
|
@ -101,6 +109,25 @@ function countParagraphs(editor: Editor): number {
|
|||
return count
|
||||
}
|
||||
|
||||
function createClipboardEventMock(): {
|
||||
data: Map<string, string>
|
||||
event: ClipboardEvent
|
||||
preventDefault: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const data = new Map<string, string>()
|
||||
const preventDefault = vi.fn()
|
||||
const event = {
|
||||
clipboardData: {
|
||||
setData: vi.fn((type: string, value: string) => {
|
||||
data.set(type, value)
|
||||
})
|
||||
},
|
||||
preventDefault
|
||||
} as unknown as ClipboardEvent
|
||||
|
||||
return { data, event, preventDefault }
|
||||
}
|
||||
|
||||
describe('rich markdown cut handler behavior', () => {
|
||||
it('heading + paragraph: cuts only the paragraph', () => {
|
||||
const editor = createEditor('# Title\n\nBody text here.\n')
|
||||
|
|
@ -121,20 +148,25 @@ describe('rich markdown cut handler behavior', () => {
|
|||
editor.destroy()
|
||||
})
|
||||
|
||||
it('multi-line markdown (no blank separator): after normalization, each line is a separate paragraph', () => {
|
||||
it('hard-wrapped document prose stays one paragraph after normalization', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three\n')
|
||||
|
||||
// Before normalization: single paragraph with \n chars
|
||||
expect(countParagraphs(editor)).toBe(1)
|
||||
expect(editor.state.doc.firstChild!.textContent).toContain('\n')
|
||||
|
||||
// Normalize: splits the single paragraph into three
|
||||
normalizeEmptyListItems(editor)
|
||||
|
||||
expect(countParagraphs(editor)).toBe(1)
|
||||
expect(editor.state.doc.firstChild!.textContent).toBe('Line one\nLine two\nLine three')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('soft-break normalization still creates visible paragraph breaks', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three\n')
|
||||
normalizeSoftBreaks(editor)
|
||||
|
||||
// After normalization: three separate paragraph nodes
|
||||
expect(countParagraphs(editor)).toBe(3)
|
||||
|
||||
// Each paragraph is its own line — no \n inside any of them
|
||||
const paragraphs: string[] = []
|
||||
editor.state.doc.forEach((node) => {
|
||||
if (node.type.name === 'paragraph') {
|
||||
|
|
@ -144,37 +176,71 @@ describe('rich markdown cut handler behavior', () => {
|
|||
})
|
||||
expect(paragraphs).toEqual(['Line one', 'Line two', 'Line three'])
|
||||
|
||||
// Cmd+X on the first paragraph only cuts "Line one", not all three lines
|
||||
const result = simulateCut(editor, 1)
|
||||
expect(result.cutNodeType).toBe('paragraph')
|
||||
expect(result.cutText).toBe('Line one')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
|
||||
it('multi-line: Cmd+X on second line only cuts that line after normalization', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three\n')
|
||||
normalizeSoftBreaks(editor)
|
||||
it('Cmd+X cuts only a visual line inside a hard-wrapped paragraph', () => {
|
||||
const editor = createEditor('Alpha segment stays\nMiddle segment is cut\nOmega segment stays')
|
||||
try {
|
||||
normalizeEmptyListItems(editor)
|
||||
expect(countParagraphs(editor)).toBe(1)
|
||||
|
||||
// Find the second paragraph ("Line two")
|
||||
const doc = editor.state.doc
|
||||
let secondParaPos = -1
|
||||
let count = 0
|
||||
doc.forEach((node, offset) => {
|
||||
if (node.type.name === 'paragraph') {
|
||||
count++
|
||||
if (count === 2) {
|
||||
secondParaPos = offset + 1
|
||||
const text = editor.state.doc.firstChild!.textContent
|
||||
const paraStart = 1
|
||||
const paraEnd = paraStart + text.length
|
||||
const lineFrom = paraStart + text.indexOf('Middle')
|
||||
const nextLineFrom = paraStart + text.indexOf('Omega')
|
||||
const cursorPos = lineFrom + 'Middle'.length
|
||||
|
||||
let viewState = editor.state.apply(
|
||||
editor.state.tr.setSelection(TextSelection.create(editor.state.doc, cursorPos))
|
||||
)
|
||||
|
||||
const paragraphElement = document.createElement('p')
|
||||
vi.spyOn(paragraphElement, 'getBoundingClientRect').mockReturnValue(
|
||||
DOMRect.fromRect({ x: 20, y: 0, width: 600, height: 60 })
|
||||
)
|
||||
const view = {
|
||||
get state() {
|
||||
return viewState
|
||||
},
|
||||
dispatch: vi.fn((tr) => {
|
||||
viewState = viewState.apply(tr)
|
||||
}),
|
||||
domAtPos: vi.fn(() => ({ node: paragraphElement, offset: 0 })),
|
||||
coordsAtPos: vi.fn((pos: number) => {
|
||||
if (pos === paraStart) {
|
||||
return { top: 0, bottom: 20, left: 20, right: 20 }
|
||||
}
|
||||
if (pos === paraEnd) {
|
||||
return { top: 40, bottom: 60, left: 280, right: 280 }
|
||||
}
|
||||
return { top: 20, bottom: 40, left: 120, right: 120 }
|
||||
}),
|
||||
posAtCoords: vi.fn((coords: { top: number }) => {
|
||||
return { pos: coords.top < 40 ? lineFrom : nextLineFrom, inside: -1 }
|
||||
})
|
||||
} as unknown as EditorView
|
||||
|
||||
const clipboard = createClipboardEventMock()
|
||||
const handled = handleRichMarkdownCut(view, clipboard.event)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(clipboard.preventDefault).toHaveBeenCalled()
|
||||
expect(clipboard.data.get('text/plain')).toBe('Middle segment is cut\n')
|
||||
expect(view.state.doc.firstChild!.textContent).toBe(
|
||||
'Alpha segment stays\nOmega segment stays'
|
||||
)
|
||||
let paragraphCount = 0
|
||||
view.state.doc.forEach((node) => {
|
||||
if (node.type.name === 'paragraph') {
|
||||
paragraphCount++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(secondParaPos).toBeGreaterThan(0)
|
||||
const result = simulateCut(editor, secondParaPos)
|
||||
expect(result.cutNodeType).toBe('paragraph')
|
||||
expect(result.cutText).toBe('Line two')
|
||||
|
||||
editor.destroy()
|
||||
})
|
||||
expect(paragraphCount).toBe(1)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('paragraphs separated by blank lines are separate blocks', () => {
|
||||
|
|
@ -348,11 +414,11 @@ describe('rich markdown cut handler behavior', () => {
|
|||
editor.destroy()
|
||||
})
|
||||
|
||||
it('normalizeSoftBreaks is idempotent on already-clean documents', () => {
|
||||
it('normalizeEmptyListItems is idempotent on already-clean documents', () => {
|
||||
const editor = createEditor('First.\n\nSecond.\n\nThird.\n')
|
||||
|
||||
const docBefore = editor.state.doc.toJSON()
|
||||
normalizeSoftBreaks(editor)
|
||||
normalizeEmptyListItems(editor)
|
||||
const docAfter = editor.state.doc.toJSON()
|
||||
|
||||
// Already separated paragraphs should not be modified
|
||||
|
|
@ -361,11 +427,11 @@ describe('rich markdown cut handler behavior', () => {
|
|||
editor.destroy()
|
||||
})
|
||||
|
||||
it('normalizeSoftBreaks does not modify list items or blockquotes', () => {
|
||||
it('normalizeEmptyListItems does not modify populated list items or blockquotes', () => {
|
||||
const editor = createEditor('- Item 1\n- Item 2\n')
|
||||
|
||||
const docBefore = editor.state.doc.toJSON()
|
||||
normalizeSoftBreaks(editor)
|
||||
normalizeEmptyListItems(editor)
|
||||
const docAfter = editor.state.doc.toJSON()
|
||||
|
||||
// List structure should be unchanged (no top-level paragraphs to split)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Editor, UseEditorOptions } from '@tiptap/react'
|
|||
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
|
||||
import { handleRichMarkdownPaste } from './rich-markdown-paste-handler'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
import { normalizeEmptyListItems } from './rich-markdown-normalize'
|
||||
import { autoFocusRichEditor } from './rich-markdown-auto-focus'
|
||||
import {
|
||||
syncSlashMenu,
|
||||
|
|
@ -208,7 +208,10 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
|
|||
clearAnnotationTarget()
|
||||
},
|
||||
onCreate: ({ editor: nextEditor }) => {
|
||||
normalizeSoftBreaks(nextEditor)
|
||||
// Why: normalizeEmptyListItems (not normalizeSoftBreaks) so hard-wrapped
|
||||
// source paragraphs stay one paragraph and reflow via CSS instead of being
|
||||
// split on load.
|
||||
normalizeEmptyListItems(nextEditor)
|
||||
lastCommittedMarkdownRef.current = content
|
||||
isInitializingRef.current = false
|
||||
cancelAutoFocusRef.current?.()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
|
||||
const hardWrappedMarkdown =
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.\n\n' +
|
||||
'## Next section'
|
||||
|
||||
const hardWrappedTwoParagraphs =
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.\n\n' +
|
||||
'Second paragraph starts here.'
|
||||
|
||||
const headingBeforeHardWrappedParagraph =
|
||||
'## Existing section\n\n' +
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.'
|
||||
|
||||
function createEditor(content = hardWrappedMarkdown): Editor {
|
||||
return new Editor({
|
||||
element: null,
|
||||
extensions,
|
||||
content,
|
||||
contentType: 'markdown'
|
||||
})
|
||||
}
|
||||
|
||||
function findNodeTextPosition(
|
||||
editor: Editor,
|
||||
nodeName: string,
|
||||
textNeedle: string
|
||||
): { from: number; to: number } {
|
||||
let result: { from: number; to: number } | null = null
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === nodeName && node.textContent.includes(textNeedle)) {
|
||||
result = { from: pos + 1, to: pos + node.nodeSize - 1 }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Could not find ${nodeName} containing ${textNeedle}`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function countHardBreaks(editor: Editor): number {
|
||||
let count = 0
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === 'hardBreak') {
|
||||
count += 1
|
||||
}
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
function expectHardWrappedParagraphPreserved(editor: Editor): void {
|
||||
expect(countHardBreaks(editor)).toBe(0)
|
||||
expect(editor.state.doc.firstChild?.type.name).toBe('paragraph')
|
||||
expect(editor.state.doc.firstChild?.textContent).toBe(
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.'
|
||||
)
|
||||
expect(editor.getMarkdown().trimEnd()).toBe(hardWrappedMarkdown)
|
||||
}
|
||||
|
||||
function expectNoHardBreaks(editor: Editor): void {
|
||||
expect(countHardBreaks(editor)).toBe(0)
|
||||
}
|
||||
|
||||
describe('rich markdown empty paragraph deletion', () => {
|
||||
it('Backspace in an inserted empty paragraph preserves soft-wrapped prose', () => {
|
||||
const editor = createEditor()
|
||||
try {
|
||||
const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
|
||||
editor.commands.setTextSelection(paragraph.to)
|
||||
expect(editor.commands.splitBlock()).toBe(true)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
|
||||
|
||||
expectHardWrappedParagraphPreserved(editor)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Backspace at the next block start removes a previous empty paragraph without joining', () => {
|
||||
const editor = createEditor()
|
||||
try {
|
||||
const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
|
||||
editor.commands.setTextSelection(paragraph.to)
|
||||
expect(editor.commands.splitBlock()).toBe(true)
|
||||
|
||||
const heading = findNodeTextPosition(editor, 'heading', 'Next section')
|
||||
editor.commands.setTextSelection(heading.from)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
|
||||
|
||||
expectHardWrappedParagraphPreserved(editor)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Delete at a soft-wrapped paragraph end removes the following empty paragraph', () => {
|
||||
const editor = createEditor()
|
||||
try {
|
||||
const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
|
||||
editor.commands.setTextSelection(paragraph.to)
|
||||
expect(editor.commands.splitBlock()).toBe(true)
|
||||
editor.commands.setTextSelection(paragraph.to)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
|
||||
|
||||
expectHardWrappedParagraphPreserved(editor)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Delete in an empty paragraph before soft-wrapped prose preserves soft newlines', () => {
|
||||
const editor = createEditor(`\n\n${hardWrappedMarkdown}`)
|
||||
try {
|
||||
editor.commands.setTextSelection(1)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
|
||||
|
||||
expectNoHardBreaks(editor)
|
||||
expect(editor.state.doc.firstChild?.textContent).toBe(
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.'
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Backspace after deleting slash text in an empty command paragraph preserves soft newlines', () => {
|
||||
const editor = createEditor()
|
||||
try {
|
||||
const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
|
||||
editor.commands.setTextSelection(paragraph.to)
|
||||
expect(editor.commands.splitBlock()).toBe(true)
|
||||
const slashPos = editor.state.selection.from
|
||||
editor.view.dispatch(editor.state.tr.insertText('/', slashPos))
|
||||
editor.view.dispatch(editor.state.tr.delete(slashPos, slashPos + 1))
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
|
||||
|
||||
expectHardWrappedParagraphPreserved(editor)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Backspace joining two non-empty paragraphs preserves source soft newlines', () => {
|
||||
const editor = createEditor(hardWrappedTwoParagraphs)
|
||||
try {
|
||||
const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph')
|
||||
editor.commands.setTextSelection(secondParagraph.from)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
|
||||
|
||||
expectNoHardBreaks(editor)
|
||||
expect(editor.state.doc.childCount).toBe(1)
|
||||
expect(editor.state.doc.firstChild?.textContent).toBe(
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.'
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Delete joining two non-empty paragraphs preserves source soft newlines', () => {
|
||||
const editor = createEditor(hardWrappedTwoParagraphs)
|
||||
try {
|
||||
const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
|
||||
editor.commands.setTextSelection(firstParagraph.to)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
|
||||
|
||||
expectNoHardBreaks(editor)
|
||||
expect(editor.state.doc.childCount).toBe(1)
|
||||
expect(editor.state.doc.firstChild?.textContent).toBe(
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.'
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Backspace joining a following heading preserves source soft newlines', () => {
|
||||
const editor = createEditor()
|
||||
try {
|
||||
const heading = findNodeTextPosition(editor, 'heading', 'Next section')
|
||||
editor.commands.setTextSelection(heading.from)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
|
||||
|
||||
expectNoHardBreaks(editor)
|
||||
expect(editor.state.doc.childCount).toBe(1)
|
||||
expect(editor.state.doc.firstChild?.type.name).toBe('paragraph')
|
||||
expect(editor.state.doc.firstChild?.textContent).toBe(
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.Next section'
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Delete joining a following heading preserves source soft newlines', () => {
|
||||
const editor = createEditor()
|
||||
try {
|
||||
const firstParagraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
|
||||
editor.commands.setTextSelection(firstParagraph.to)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
|
||||
|
||||
expectNoHardBreaks(editor)
|
||||
expect(editor.state.doc.childCount).toBe(1)
|
||||
expect(editor.state.doc.firstChild?.type.name).toBe('paragraph')
|
||||
expect(editor.state.doc.firstChild?.textContent).toBe(
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.Next section'
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Backspace joining from a previous heading preserves source soft newlines', () => {
|
||||
const editor = createEditor(headingBeforeHardWrappedParagraph)
|
||||
try {
|
||||
const paragraph = findNodeTextPosition(editor, 'paragraph', 'launch-lifetime')
|
||||
editor.commands.setTextSelection(paragraph.from)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
|
||||
|
||||
expectNoHardBreaks(editor)
|
||||
expect(editor.state.doc.childCount).toBe(2)
|
||||
expect(editor.state.doc.child(0).type.name).toBe('heading')
|
||||
expect(editor.state.doc.child(0).textContent).toBe(
|
||||
'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native'
|
||||
)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('paragraph')
|
||||
expect(editor.state.doc.child(1).textContent).toBe(
|
||||
'chat owns conversion, ordering, and transcript reconciliation.'
|
||||
)
|
||||
expect(editor.getMarkdown()).not.toContain(' \n')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('Delete joining from a previous heading preserves source soft newlines', () => {
|
||||
const editor = createEditor(headingBeforeHardWrappedParagraph)
|
||||
try {
|
||||
const heading = findNodeTextPosition(editor, 'heading', 'Existing section')
|
||||
editor.commands.setTextSelection(heading.to)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'forward')).toBe(true)
|
||||
|
||||
expectNoHardBreaks(editor)
|
||||
expect(editor.state.doc.childCount).toBe(2)
|
||||
expect(editor.state.doc.child(0).type.name).toBe('heading')
|
||||
expect(editor.state.doc.child(0).textContent).toBe(
|
||||
'Existing sectionAlpha owns launch-lifetime state keyed by tab id, while native'
|
||||
)
|
||||
expect(editor.state.doc.child(1).type.name).toBe('paragraph')
|
||||
expect(editor.state.doc.child(1).textContent).toBe(
|
||||
'chat owns conversion, ordering, and transcript reconciliation.'
|
||||
)
|
||||
expect(editor.getMarkdown()).not.toContain(' \n')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('works inside blockquotes without converting soft newlines to hard breaks', () => {
|
||||
const editor = createEditor(
|
||||
'> Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'> chat owns conversion, ordering, and transcript reconciliation.\n' +
|
||||
'>\n' +
|
||||
'> Second paragraph starts here.'
|
||||
)
|
||||
try {
|
||||
const secondParagraph = findNodeTextPosition(editor, 'paragraph', 'Second paragraph')
|
||||
editor.commands.setTextSelection(secondParagraph.from)
|
||||
|
||||
expect(deleteAdjacentEmptyParagraph(editor, 'backward')).toBe(true)
|
||||
|
||||
expectNoHardBreaks(editor)
|
||||
expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe(
|
||||
'Alpha owns launch-lifetime state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation.Second paragraph starts here.'
|
||||
)
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import type { Editor } from '@tiptap/react'
|
||||
import { Fragment, type Node as PmNode, type Schema } from '@tiptap/pm/model'
|
||||
import { TextSelection, type Transaction } from '@tiptap/pm/state'
|
||||
|
||||
const BLOCKED_CONTAINER_TYPES = new Set(['listItem', 'taskItem', 'tableCell', 'tableHeader'])
|
||||
const PARAGRAPH_JOIN_NEIGHBOR_TYPES = new Set(['paragraph', 'heading'])
|
||||
|
||||
function isEmptyParagraph(node: PmNode | null | undefined): boolean {
|
||||
return node?.type.name === 'paragraph' && node.content.size === 0
|
||||
}
|
||||
|
||||
function hasSoftNewline(node: PmNode): boolean {
|
||||
return node.textContent.includes('\n')
|
||||
}
|
||||
|
||||
function setSelectionNear(tr: Transaction, pos: number): void {
|
||||
const clampedPos = Math.max(0, Math.min(pos, tr.doc.content.size))
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(clampedPos)))
|
||||
}
|
||||
|
||||
type TextblockBoundary = {
|
||||
after: PmNode | null
|
||||
before: PmNode | null
|
||||
current: PmNode
|
||||
currentEnd: number
|
||||
currentStart: number
|
||||
}
|
||||
|
||||
function getTextblockBoundary(editor: Editor): TextblockBoundary | null {
|
||||
const { selection } = editor.state
|
||||
if (!selection.empty || !selection.$from.parent.isTextblock) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { $from } = selection
|
||||
const textblockDepth = $from.depth
|
||||
for (let depth = 1; depth < textblockDepth; depth += 1) {
|
||||
if (BLOCKED_CONTAINER_TYPES.has($from.node(depth).type.name)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const parentIndex = $from.index(textblockDepth - 1)
|
||||
const container = $from.node(textblockDepth - 1)
|
||||
return {
|
||||
after: container.maybeChild(parentIndex + 1) ?? null,
|
||||
before: container.maybeChild(parentIndex - 1) ?? null,
|
||||
current: $from.parent,
|
||||
currentEnd: $from.after(textblockDepth),
|
||||
currentStart: $from.before(textblockDepth)
|
||||
}
|
||||
}
|
||||
|
||||
type SoftNewlineJoin = {
|
||||
nodes: PmNode[]
|
||||
selectionOffset: number
|
||||
}
|
||||
|
||||
function splitParagraphAtFirstSoftNewline(
|
||||
paragraph: PmNode,
|
||||
schema: Schema
|
||||
): { after: Fragment; before: Fragment } | null {
|
||||
const beforeNodes: PmNode[] = []
|
||||
const afterNodes: PmNode[] = []
|
||||
let foundSoftNewline = false
|
||||
|
||||
paragraph.content.forEach((child) => {
|
||||
if (foundSoftNewline) {
|
||||
afterNodes.push(child)
|
||||
return
|
||||
}
|
||||
|
||||
if (!child.isText || !child.text?.includes('\n')) {
|
||||
beforeNodes.push(child)
|
||||
return
|
||||
}
|
||||
|
||||
const newlineIndex = child.text.indexOf('\n')
|
||||
if (newlineIndex > 0) {
|
||||
beforeNodes.push(schema.text(child.text.slice(0, newlineIndex), child.marks))
|
||||
}
|
||||
if (newlineIndex + 1 < child.text.length) {
|
||||
afterNodes.push(schema.text(child.text.slice(newlineIndex + 1), child.marks))
|
||||
}
|
||||
foundSoftNewline = true
|
||||
})
|
||||
|
||||
if (!foundSoftNewline) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
after: Fragment.fromArray(afterNodes),
|
||||
before: Fragment.fromArray(beforeNodes)
|
||||
}
|
||||
}
|
||||
|
||||
function createSoftNewlineJoin(
|
||||
left: PmNode,
|
||||
right: PmNode,
|
||||
schema: Schema
|
||||
): SoftNewlineJoin | null {
|
||||
if (left.type.name === 'paragraph' && PARAGRAPH_JOIN_NEIGHBOR_TYPES.has(right.type.name)) {
|
||||
if (!hasSoftNewline(left) && (right.type.name !== 'paragraph' || !hasSoftNewline(right))) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: ProseMirror Transform.join substitutes paragraph text `\n` with
|
||||
// hardBreak nodes at textblock boundaries, which makes hard-wrapped prose narrow.
|
||||
const content = left.content.append(right.content)
|
||||
if (!left.type.validContent(content)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [left.type.create(left.attrs, content, left.marks)],
|
||||
selectionOffset: left.content.size
|
||||
}
|
||||
}
|
||||
|
||||
if (left.type.name !== 'heading' || right.type.name !== 'paragraph' || !hasSoftNewline(right)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const split = splitParagraphAtFirstSoftNewline(right, schema)
|
||||
if (!split) {
|
||||
return null
|
||||
}
|
||||
|
||||
const headingContent = left.content.append(split.before)
|
||||
if (!left.type.validContent(headingContent)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nodes = [left.type.create(left.attrs, headingContent, left.marks)]
|
||||
if (split.after.size > 0) {
|
||||
if (!right.type.validContent(split.after)) {
|
||||
return null
|
||||
}
|
||||
nodes.push(right.type.create(right.attrs, split.after, right.marks))
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
selectionOffset: left.content.size
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchSoftNewlineJoin(
|
||||
editor: Editor,
|
||||
from: number,
|
||||
to: number,
|
||||
join: SoftNewlineJoin
|
||||
): void {
|
||||
const tr = editor.state.tr.replaceWith(from, to, join.nodes)
|
||||
setSelectionNear(tr, from + 1 + join.selectionOffset)
|
||||
editor.view.dispatch(tr)
|
||||
}
|
||||
|
||||
export function deleteAdjacentEmptyParagraph(editor: Editor, direction: 'backward' | 'forward') {
|
||||
const boundary = getTextblockBoundary(editor)
|
||||
if (!boundary) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { selection } = editor.state
|
||||
const { $from } = selection
|
||||
const { after, before, current, currentEnd, currentStart } = boundary
|
||||
|
||||
if (direction === 'backward') {
|
||||
if ($from.parentOffset !== 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isEmptyParagraph(current)) {
|
||||
if (!before) {
|
||||
return false
|
||||
}
|
||||
// Why: ProseMirror's default Backspace join converts soft `\n` text in
|
||||
// the previous paragraph into hardBreak nodes. Delete the blank block only.
|
||||
const tr = editor.state.tr.delete(currentStart, currentEnd)
|
||||
setSelectionNear(tr, currentStart - 1)
|
||||
editor.view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
if (before && isEmptyParagraph(before)) {
|
||||
const from = currentStart - before.nodeSize
|
||||
const tr = editor.state.tr.delete(from, currentStart)
|
||||
setSelectionNear(tr, tr.mapping.map(selection.from, -1))
|
||||
editor.view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
if (before && current.isTextblock && before.isTextblock) {
|
||||
const join = createSoftNewlineJoin(before, current, editor.state.schema)
|
||||
if (!join) {
|
||||
return false
|
||||
}
|
||||
const from = currentStart - before.nodeSize
|
||||
dispatchSoftNewlineJoin(editor, from, currentEnd, join)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if ($from.parentOffset !== current.content.size) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isEmptyParagraph(current)) {
|
||||
if (!after) {
|
||||
return false
|
||||
}
|
||||
const tr = editor.state.tr.delete(currentStart, currentEnd)
|
||||
setSelectionNear(tr, currentStart)
|
||||
editor.view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
if (after && isEmptyParagraph(after)) {
|
||||
// Why: Delete at the end of a soft-wrapped paragraph should remove the
|
||||
// blank line without running ProseMirror's newline-to-hardBreak join path.
|
||||
const tr = editor.state.tr.delete(currentEnd, currentEnd + after.nodeSize)
|
||||
setSelectionNear(tr, currentEnd - 1)
|
||||
editor.view.dispatch(tr)
|
||||
return true
|
||||
}
|
||||
|
||||
if (after && current.isTextblock && after.isTextblock) {
|
||||
const join = createSoftNewlineJoin(current, after, editor.state.schema)
|
||||
if (!join) {
|
||||
return false
|
||||
}
|
||||
dispatchSoftNewlineJoin(editor, currentStart, currentEnd + after.nodeSize, join)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
convertEmptyNestedOrderedItemToContinuation,
|
||||
exitTrailingEmptyOrderedListItem
|
||||
} from './rich-markdown-list-continuation'
|
||||
import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-delete'
|
||||
|
||||
export type KeyHandlerContext = {
|
||||
isMac: boolean
|
||||
|
|
@ -162,7 +163,20 @@ export function createRichMarkdownKeyHandler(
|
|||
ed &&
|
||||
!isComposingMarkdownInput(event, ed) &&
|
||||
(convertEmptyNestedOrderedItemToContinuation(ed) ||
|
||||
collapseEmptyListContinuationParagraph(ed))
|
||||
collapseEmptyListContinuationParagraph(ed) ||
|
||||
deleteAdjacentEmptyParagraph(ed, 'backward'))
|
||||
) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Delete') {
|
||||
const ed = ctx.editorRef.current
|
||||
if (
|
||||
ed &&
|
||||
!isComposingMarkdownInput(event, ed) &&
|
||||
deleteAdjacentEmptyParagraph(ed, 'forward')
|
||||
) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||
import { Editor } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import { normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
import { normalizeEmptyListItems, normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
|
||||
const extensions = [StarterKit, Markdown.configure({ markedOptions: { gfm: true } })]
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ describe('rich markdown normalization', () => {
|
|||
const editor = createEditor('1. Item 1\n2. Item 2\n3. \n\n## Next section\n')
|
||||
|
||||
try {
|
||||
normalizeSoftBreaks(editor)
|
||||
normalizeEmptyListItems(editor)
|
||||
|
||||
const list = editor.state.doc.child(0)
|
||||
const emptyItem = list.child(2)
|
||||
|
|
@ -37,7 +37,21 @@ describe('rich markdown normalization', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('normalizes newline-heavy soft-break paragraphs without splitting text nodes', () => {
|
||||
it('leaves hard-wrapped document prose as one paragraph', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three')
|
||||
|
||||
try {
|
||||
normalizeEmptyListItems(editor)
|
||||
|
||||
expect(editor.state.doc.childCount).toBe(1)
|
||||
expect(editor.state.doc.child(0).type.name).toBe('paragraph')
|
||||
expect(editor.state.doc.child(0).textContent).toBe('Line one\nLine two\nLine three')
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps soft-break splitting without splitting text nodes', () => {
|
||||
const editor = createEditor('Line one\nLine two\nLine three')
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,19 @@
|
|||
import type { Editor } from '@tiptap/core'
|
||||
import { Fragment, type Node as PmNode } from '@tiptap/pm/model'
|
||||
|
||||
/**
|
||||
* Why: the `marked` parser (with `breaks: false`, the default) treats consecutive
|
||||
* lines without a blank separator as a single paragraph with literal `\n` characters
|
||||
* in the text content (e.g. "Line one\nLine two\nLine three"). These `\n` chars are
|
||||
* invisible in the rendered HTML (normal `white-space` collapsing), but they cause
|
||||
* the block-cut handler to remove the entire multi-line paragraph on Cmd+X instead
|
||||
* of just one logical line.
|
||||
*
|
||||
* This function normalises the ProseMirror document by splitting any paragraph whose
|
||||
* text nodes contain `\n` into separate paragraph nodes — one per line — and by
|
||||
* giving empty parsed list items a paragraph caret target. Inline marks (bold,
|
||||
* italic, links, etc.) are preserved on each resulting paragraph. This is
|
||||
* structurally correct for the editing model: each visual line becomes its own block,
|
||||
* so the cut handler (and all other block-level operations) work on a per-line basis.
|
||||
*/
|
||||
type NormalizeOptions = {
|
||||
splitSoftBreakParagraphs: boolean
|
||||
}
|
||||
|
||||
export function normalizeEmptyListItems(editor: Editor): void {
|
||||
normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: false })
|
||||
}
|
||||
|
||||
export function normalizeSoftBreaks(editor: Editor): void {
|
||||
normalizeRichMarkdownDocument(editor, { splitSoftBreakParagraphs: true })
|
||||
}
|
||||
|
||||
function normalizeRichMarkdownDocument(editor: Editor, options: NormalizeOptions): void {
|
||||
// Why: we read from editor.view.state (not editor.state) so that the doc
|
||||
// we traverse and the transaction we later create share the same base state.
|
||||
// After setContent(), editor.state can be stale (last React render), while
|
||||
|
|
@ -52,6 +49,10 @@ export function normalizeSoftBreaks(editor: Editor): void {
|
|||
if (node.type !== paragraphType) {
|
||||
return true // continue descending into container nodes
|
||||
}
|
||||
if (!options.splitSoftBreakParagraphs) {
|
||||
// Document-editor prose reflows soft breaks through CSS, preserving clean diffs.
|
||||
return false
|
||||
}
|
||||
if (!node.textContent.includes('\n')) {
|
||||
return false // no need to descend into inline content
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { Editor } from '@tiptap/react'
|
|||
import type { MarkdownDocument } from '../../../../shared/types'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands'
|
||||
import { normalizeSoftBreaks } from './rich-markdown-normalize'
|
||||
import { normalizeEmptyListItems } from './rich-markdown-normalize'
|
||||
import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands'
|
||||
import {
|
||||
createRichMarkdownImageResolverContext,
|
||||
|
|
@ -133,7 +133,9 @@ function applyExternalRichMarkdownContent(
|
|||
contentType: 'markdown',
|
||||
emitUpdate: false
|
||||
})
|
||||
normalizeSoftBreaks(editor)
|
||||
// Why: normalizeEmptyListItems avoids splitting hard-wrapped paragraphs from
|
||||
// external content, matching onCreate's single-paragraph reflow behavior.
|
||||
normalizeEmptyListItems(editor)
|
||||
lastCommittedMarkdownRef.current = content
|
||||
if (hadFocus) {
|
||||
const docSize = editor.state.doc.content.size
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { isGitRepoKind } from '../../../shared/repo-kind'
|
|||
// VSCode) land as a short burst of `update` events — or `delete + create` on
|
||||
// renamers — within a few milliseconds for the same path. Dispatching an
|
||||
// `ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT` per raw event fan-outs into N full
|
||||
// `setContent` + `normalizeSoftBreaks` doc rebuilds per mounted EditorPanel,
|
||||
// `setContent` + document-repair rebuilds per mounted EditorPanel,
|
||||
// which under split-pane + large markdown is enough to wedge the renderer
|
||||
// and black out the window (issue #826). Coalescing per (worktreeId + path)
|
||||
// on a short debounce collapses that burst into one reload notification.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
cleanupMarkdownFixture,
|
||||
createMarkdownFixture,
|
||||
getActiveWorktreeContext,
|
||||
openMarkdownFixture,
|
||||
waitForRichMarkdownEditor
|
||||
} from './helpers/markdown-ordered-list-exit'
|
||||
|
||||
const HARD_WRAPPED_MARKDOWN =
|
||||
'The store owns launch-lifetime presentation state keyed by tab id, while native\n' +
|
||||
'chat owns conversion, ordering, and transcript reconciliation through pure\n' +
|
||||
'helpers. This keeps the bridge removable later if provider-native launch\n' +
|
||||
'delivery becomes universal.\n\n' +
|
||||
'## Next section\n'
|
||||
|
||||
type ReflowMetrics = {
|
||||
hardBreakCount: number
|
||||
lineCount: number
|
||||
paragraphCount: number
|
||||
sourceLineCount: number
|
||||
textContent: string
|
||||
whiteSpace: string
|
||||
}
|
||||
|
||||
type PageEditorDocNode = {
|
||||
textContent?: string
|
||||
type?: { name?: string }
|
||||
}
|
||||
|
||||
type PageRichMarkdownEditorElement = Element & {
|
||||
editor?: {
|
||||
commands?: {
|
||||
focus?: () => boolean
|
||||
setTextSelection?: (position: number) => boolean
|
||||
}
|
||||
state?: {
|
||||
doc?: {
|
||||
descendants?: (callback: (node: PageEditorDocNode, pos: number) => boolean) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openHardWrappedFixture(
|
||||
page: Parameters<typeof waitForRichMarkdownEditor>[0],
|
||||
testInfo: { workerIndex: number }
|
||||
): Promise<string> {
|
||||
const context = await getActiveWorktreeContext(page)
|
||||
const filePath = await createMarkdownFixture(
|
||||
context,
|
||||
'prose-reflow',
|
||||
testInfo.workerIndex,
|
||||
HARD_WRAPPED_MARKDOWN
|
||||
)
|
||||
await openMarkdownFixture(page, context, filePath)
|
||||
await waitForRichMarkdownEditor(page)
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function getGoalParagraphMetrics(
|
||||
page: Parameters<typeof waitForRichMarkdownEditor>[0]
|
||||
): Promise<ReflowMetrics> {
|
||||
return page.evaluate(() => {
|
||||
const editor = document.querySelector('.rich-markdown-editor')
|
||||
if (!editor) {
|
||||
throw new Error('Rich markdown editor was not mounted')
|
||||
}
|
||||
|
||||
const paragraphs = Array.from(editor.querySelectorAll('p')).filter((paragraph) =>
|
||||
paragraph.textContent?.includes('launch-lifetime')
|
||||
)
|
||||
const paragraph = paragraphs[0]
|
||||
if (!paragraph) {
|
||||
throw new Error('Hard-wrapped paragraph was not rendered')
|
||||
}
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(paragraph)
|
||||
const lineTops: number[] = []
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
if (rect.width <= 0 || rect.height <= 0) {
|
||||
continue
|
||||
}
|
||||
if (!lineTops.some((top) => Math.abs(top - rect.top) < 2)) {
|
||||
lineTops.push(rect.top)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hardBreakCount: paragraph.querySelectorAll('br').length,
|
||||
lineCount: lineTops.length,
|
||||
paragraphCount: paragraphs.length,
|
||||
sourceLineCount: paragraph.textContent?.split('\n').length ?? 0,
|
||||
textContent: paragraph.textContent ?? '',
|
||||
whiteSpace: window.getComputedStyle(paragraph).whiteSpace
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function placeCaretAtGoalParagraphEnd(
|
||||
page: Parameters<typeof waitForRichMarkdownEditor>[0]
|
||||
): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const editorElement = document.querySelector(
|
||||
'.rich-markdown-editor'
|
||||
) as PageRichMarkdownEditorElement | null
|
||||
const paragraph = Array.from(editorElement?.querySelectorAll('p') ?? []).find((candidate) =>
|
||||
candidate.textContent?.includes('launch-lifetime')
|
||||
) as
|
||||
| (HTMLParagraphElement & {
|
||||
pmViewDesc?: {
|
||||
posAtEnd?: number
|
||||
}
|
||||
})
|
||||
| undefined
|
||||
const selectionPosition = paragraph?.pmViewDesc?.posAtEnd
|
||||
if (!editorElement?.editor?.commands || typeof selectionPosition !== 'number') {
|
||||
throw new Error('Cannot place caret at the hard-wrapped paragraph end')
|
||||
}
|
||||
|
||||
editorElement.editor.commands.setTextSelection?.(selectionPosition)
|
||||
editorElement.editor.commands.focus?.()
|
||||
})
|
||||
}
|
||||
|
||||
async function placeCaretAtHeadingStart(
|
||||
page: Parameters<typeof waitForRichMarkdownEditor>[0]
|
||||
): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const editorElement = document.querySelector(
|
||||
'.rich-markdown-editor'
|
||||
) as PageRichMarkdownEditorElement | null
|
||||
const editor = editorElement?.editor
|
||||
let selectionPosition: number | null = null
|
||||
editor?.state?.doc?.descendants?.((node, pos) => {
|
||||
if (node.type?.name === 'heading' && node.textContent?.includes('Next section')) {
|
||||
selectionPosition = pos + 1
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (!editor?.commands || selectionPosition === null) {
|
||||
throw new Error('Cannot place caret at the heading start')
|
||||
}
|
||||
|
||||
editor.commands.setTextSelection?.(selectionPosition)
|
||||
editor.commands.focus?.()
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Markdown prose reflow', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await orcaPage.setViewportSize({ width: 1440, height: 900 })
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
test('hard-wrapped prose reflows as one document paragraph', async ({ orcaPage }, testInfo) => {
|
||||
let filePath: string | null = null
|
||||
|
||||
try {
|
||||
filePath = await openHardWrappedFixture(orcaPage, testInfo)
|
||||
const metrics = await getGoalParagraphMetrics(orcaPage)
|
||||
|
||||
expect(metrics.paragraphCount).toBe(1)
|
||||
expect(metrics.sourceLineCount).toBe(4)
|
||||
expect(metrics.whiteSpace).toBe('normal')
|
||||
expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount)
|
||||
} finally {
|
||||
await cleanupMarkdownFixture(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
test('deleting an inserted empty paragraph keeps hard-wrapped prose reflowing', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
let filePath: string | null = null
|
||||
|
||||
try {
|
||||
filePath = await openHardWrappedFixture(orcaPage, testInfo)
|
||||
await placeCaretAtGoalParagraphEnd(orcaPage)
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await orcaPage.keyboard.press('Backspace')
|
||||
|
||||
const metrics = await getGoalParagraphMetrics(orcaPage)
|
||||
|
||||
expect(metrics.hardBreakCount).toBe(0)
|
||||
expect(metrics.paragraphCount).toBe(1)
|
||||
expect(metrics.sourceLineCount).toBe(4)
|
||||
expect(metrics.whiteSpace).toBe('normal')
|
||||
expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount)
|
||||
} finally {
|
||||
await cleanupMarkdownFixture(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
test('deleting slash text then the empty paragraph keeps hard-wrapped prose reflowing', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
let filePath: string | null = null
|
||||
|
||||
try {
|
||||
filePath = await openHardWrappedFixture(orcaPage, testInfo)
|
||||
await placeCaretAtGoalParagraphEnd(orcaPage)
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await orcaPage.keyboard.type('/')
|
||||
await orcaPage.keyboard.press('Backspace')
|
||||
await orcaPage.keyboard.press('Backspace')
|
||||
|
||||
const metrics = await getGoalParagraphMetrics(orcaPage)
|
||||
|
||||
expect(metrics.hardBreakCount).toBe(0)
|
||||
expect(metrics.paragraphCount).toBe(1)
|
||||
expect(metrics.sourceLineCount).toBe(4)
|
||||
expect(metrics.whiteSpace).toBe('normal')
|
||||
expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount)
|
||||
} finally {
|
||||
await cleanupMarkdownFixture(filePath)
|
||||
}
|
||||
})
|
||||
|
||||
test('deleting the block boundary before a heading keeps hard-wrapped prose reflowing', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
let filePath: string | null = null
|
||||
|
||||
try {
|
||||
filePath = await openHardWrappedFixture(orcaPage, testInfo)
|
||||
await placeCaretAtHeadingStart(orcaPage)
|
||||
await orcaPage.keyboard.press('Backspace')
|
||||
|
||||
const metrics = await getGoalParagraphMetrics(orcaPage)
|
||||
|
||||
expect(metrics.hardBreakCount).toBe(0)
|
||||
expect(metrics.paragraphCount).toBe(1)
|
||||
expect(metrics.sourceLineCount).toBe(4)
|
||||
expect(metrics.whiteSpace).toBe('normal')
|
||||
expect(metrics.lineCount).toBeLessThan(metrics.sourceLineCount)
|
||||
} finally {
|
||||
await cleanupMarkdownFixture(filePath)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue