mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
ee09162e45
commit
456b2f9c7d
2 changed files with 110 additions and 9 deletions
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue