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:
Jinjing 2026-07-04 23:29:05 -07:00 committed by GitHub
parent 17d5eff5d6
commit 136cb50bcb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 987 additions and 90 deletions

View File

@ -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;
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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?.()

View File

@ -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()
}
})
})

View File

@ -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
}

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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.

View File

@ -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)
}
})
})