mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): stop a chip inserted after a word from swallowing its space
`plainTextInRange` serialized the caret's preceding content through a bare <div>, but `composerPlainText` appends "\n" to any block element that isn't the editor slot. So `beforeText` always looked like it ended in whitespace and the separating space was never inserted — dragging a file in after a word produced `review@file:...` glued together. Marking the scratch container with RICH_INPUT_SLOT makes it serialize in the same coordinates as the editor. Same fix lands in the new `caretOffsetInEditor`, which measures caret offsets the same way.
This commit is contained in:
parent
48bdde1deb
commit
8896fc7500
3 changed files with 136 additions and 1 deletions
|
|
@ -4,7 +4,13 @@ import { contextPath } from '@/lib/chat-runtime'
|
|||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
|
|
@ -92,7 +98,12 @@ function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' |
|
|||
slice.setStart(range.endContainer, range.endOffset)
|
||||
}
|
||||
|
||||
// Carry the editor's slot marker: composerPlainText appends a trailing "\n"
|
||||
// to any other block element, so a bare <div> made `beforeText` always look
|
||||
// like it ended in whitespace and the separating space was never inserted —
|
||||
// a chip dropped after a word came out glued to it (`review@file:...`).
|
||||
const container = document.createElement('div')
|
||||
container.dataset.slot = RICH_INPUT_SLOT
|
||||
container.appendChild(slice.cloneContents())
|
||||
|
||||
return composerPlainText(container)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,30 @@ describe('insertInlineRefsIntoEditor', () => {
|
|||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
|
||||
it('separates a chip from the word the caret sits after', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(document.createTextNode('review'))
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('does not double the space when one is already there', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(document.createTextNode('review '))
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertComposerContentsAtCaret', () => {
|
||||
|
|
|
|||
|
|
@ -332,6 +332,106 @@ export function placeCaretEnd(element: HTMLElement) {
|
|||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** The caret's offset in `composerPlainText` coordinates, so it can be restored
|
||||
* after the editor is re-rendered from text (undo/redo). A chip counts as its
|
||||
* whole `@kind:value` text — the same units the snapshot measures. */
|
||||
export function caretOffsetInEditor(editor: HTMLElement): number {
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
if (!range || !editor.contains(range.commonAncestorContainer)) {
|
||||
return composerPlainText(editor).length
|
||||
}
|
||||
|
||||
const before = range.cloneRange()
|
||||
before.selectNodeContents(editor)
|
||||
before.setEnd(range.startContainer, range.startOffset)
|
||||
|
||||
// The scratch container must carry the editor's slot marker: composerPlainText
|
||||
// appends a trailing "\n" to any other block element, which would inflate
|
||||
// every offset by one and land the restored caret a character late.
|
||||
const container = document.createElement('div')
|
||||
container.dataset.slot = RICH_INPUT_SLOT
|
||||
container.append(before.cloneContents())
|
||||
|
||||
return composerPlainText(container).length
|
||||
}
|
||||
|
||||
/** Place the caret `offset` characters into the editor, in the same
|
||||
* `composerPlainText` coordinates `caretOffsetInEditor` reports. Lands after a
|
||||
* chip it would otherwise split, since a chip is a single atomic unit. */
|
||||
export function placeCaretAtOffset(editor: HTMLElement, offset: number) {
|
||||
const selection = window.getSelection()
|
||||
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
let remaining = offset
|
||||
|
||||
const walk = (node: Node): Range | null => {
|
||||
for (const child of Array.from(node.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const length = (child.textContent || '').length
|
||||
|
||||
if (remaining <= length) {
|
||||
const range = document.createRange()
|
||||
range.setStart(child, remaining)
|
||||
range.collapse(true)
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||||
continue
|
||||
}
|
||||
|
||||
const el = child as HTMLElement
|
||||
|
||||
// Chips and <br> are atomic: consume their serialized length whole.
|
||||
if (el.dataset.refText || el.tagName === 'BR') {
|
||||
const length = el.dataset.refText ? el.dataset.refText.length : 1
|
||||
|
||||
if (remaining < length) {
|
||||
const range = document.createRange()
|
||||
range.setStartBefore(el)
|
||||
range.collapse(true)
|
||||
|
||||
return range
|
||||
}
|
||||
|
||||
remaining -= length
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const hit = walk(el)
|
||||
|
||||
if (hit) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const range = walk(editor)
|
||||
|
||||
if (range) {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
/** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */
|
||||
function isBlankNode(node: ChildNode | null): boolean {
|
||||
if (!node) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue