refactor(desktop): give the rich editor a chip-aware caret insert

`insertPlainTextAtCaret` dropped its text in verbatim, so a caller with
directives in hand had no way to land them as chips. It becomes
`insertComposerContentsAtCaret`, sharing the parse `renderComposerContents`
already uses, and gains a sibling `replaceBeforeCaret` for swapping a
just-typed token for a chip.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 15:23:39 -05:00
parent ee09162e45
commit 456b2f9c7d
2 changed files with 110 additions and 9 deletions

View file

@ -4,10 +4,11 @@ import { insertInlineRefsIntoEditor } from './inline-refs'
import {
composerPlainText,
deleteSelectionInEditor,
insertPlainTextAtCaret,
insertComposerContentsAtCaret,
normalizeComposerEditorDom,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
RICH_INPUT_SLOT
} from './rich-editor'
@ -72,14 +73,14 @@ describe('insertInlineRefsIntoEditor', () => {
})
})
describe('insertPlainTextAtCaret', () => {
describe('insertComposerContentsAtCaret', () => {
it('inserts multiline text as text nodes + br', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
insertComposerContentsAtCaret(editor, 'one\ntwo\nthree')
expect(editor.querySelectorAll('br').length).toBe(2)
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
@ -102,12 +103,76 @@ describe('insertPlainTextAtCaret', () => {
selection.removeAllRanges()
selection.addRange(range)
insertPlainTextAtCaret(editor, 'cd')
insertComposerContentsAtCaret(editor, 'cd')
expect(composerPlainText(editor)).toBe('abcdef')
editor.remove()
})
it('lands directives in the text as chips', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
document.body.append(editor)
caretIn(editor)
insertComposerContentsAtCaret(editor, 'read @url:`https://example.dev/a` now')
expect(editor.querySelectorAll('[data-ref-kind="url"]').length).toBe(1)
expect(composerPlainText(editor)).toBe('read @url:`https://example.dev/a` now')
editor.remove()
})
})
describe('replaceBeforeCaret', () => {
it('swaps the token before the caret and leaves the caret after the insert', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'see foo'
document.body.append(editor)
const text = editor.firstChild!
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(text, 7)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
const fragment = document.createDocumentFragment()
fragment.append(refChipElement('file', '`src/foo.ts`'), document.createTextNode(' '))
expect(replaceBeforeCaret(editor, 3, fragment)).toBe(true)
expect(composerPlainText(editor)).toBe('see @file:`src/foo.ts` ')
expect(selection.getRangeAt(0).collapsed).toBe(true)
editor.remove()
})
it('leaves the editor alone when the caret has no room for the token', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.textContent = 'hi'
document.body.append(editor)
const selection = window.getSelection()!
const range = document.createRange()
range.setStart(editor.firstChild!, 2)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode('x'))
expect(replaceBeforeCaret(editor, 20, fragment)).toBe(false)
expect(composerPlainText(editor)).toBe('hi')
editor.remove()
})
})
describe('deleteSelectionInEditor', () => {

View file

@ -144,14 +144,15 @@ function composerSelectionRange(editor: HTMLElement) {
return { range, selection }
}
/** Insert plain text at the caret (replacing any selection). Pastes use this
* instead of `execCommand('insertText')` Chromium's editing pipeline is
* ~O(n²) on large multiline blobs. */
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
/** Insert text at the caret (replacing any selection), with any `@kind:value`
* directives in it landing as chips. Pastes use this instead of
* `execCommand('insertText')` Chromium's editing pipeline is ~O(n²) on large
* multiline blobs. */
export function insertComposerContentsAtCaret(editor: HTMLElement, text: string) {
const hit = composerSelectionRange(editor)
const fragment = document.createDocumentFragment()
appendTextWithBreaks(fragment, text)
appendComposerContents(fragment, text)
const tail = fragment.lastChild
@ -172,6 +173,41 @@ export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
}
}
/** Swap the `length` characters immediately before a collapsed caret for
* `fragment`, leaving the caret after it. Returns whether it ran a caret that
* isn't inside a text node holding the whole token is left alone. */
export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) {
const hit = composerSelectionRange(editor)
if (!hit?.range.collapsed) {
return false
}
const { startContainer, startOffset } = hit.range
if (startContainer.nodeType !== Node.TEXT_NODE || startOffset < length) {
return false
}
const range = document.createRange()
const tail = fragment.lastChild
range.setStart(startContainer, startOffset - length)
range.setEnd(startContainer, startOffset)
range.deleteContents()
range.insertNode(fragment)
if (tail) {
range.setStartAfter(tail)
}
range.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(range)
return true
}
/** Backspace at a collapsed caret immediately after a chip: delete the chip AND
* the single trailing space we auto-insert after it, atomically so removing a
* directive never strands an orphaned space (the contenteditable-driven cleanup