fix(desktop): the composer hint stops acting like text you typed
The empty-composer prompt was painted with an inline `::before`, which puts a real box in the contenteditable's text flow. Click an empty composer and the caret lands past the hint instead of at the field's left edge, and a hint that wraps — a narrow composer, a long locale string — makes the empty composer two lines tall. It is a hint, so it now sits out of flow: absolutely positioned, clipped to one line, unselectable and untouchable by the pointer. Measured in Chromium against the built stylesheet, the caret lands at the left edge for a wide composer, a narrow one, and a Japanese hint alike. Backspace on a fresh `@folder:` chip had a second, related problem. Committing a completion empties the typed token's text node rather than removing it, and `Range.insertNode` splits the line around the caret, so the chip ends up between zero-length text nodes. Those read as content: the atomic chip-delete declined, Chromium's own backspace bounced between the leftovers, and the chip took extra presses to remove — leaving a `"\n"` draft with the hint still hidden behind it. Emptiness, the chip-delete, and the DOM normalizer now all step over that litter. The stylesheet owns the rule now that it needs `position: relative` on the editor, so the utility-class constant both composers imported is gone.
This commit is contained in:
parent
c5be6e7792
commit
414e5af114
|
|
@ -1,6 +1,12 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { composerPlainText, normalizeComposerEditorDom, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteChipBeforeCaret,
|
||||
normalizeComposerEditorDom,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
function editor(): HTMLDivElement {
|
||||
const el = document.createElement('div')
|
||||
|
|
@ -131,4 +137,113 @@ describe('an emptied composer shows its placeholder again', () => {
|
|||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
|
||||
})
|
||||
|
||||
// Chromium leaves zero-length text nodes behind whenever an edit lands next
|
||||
// to a contenteditable=false chip. They render as nothing, so an editor
|
||||
// holding only those is empty to the user — counting them as contents left
|
||||
// the placeholder hidden under a composer that looked blank.
|
||||
it('advertises emptiness for an editor holding only zero-length text nodes', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode(''), document.createTextNode(''))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not advertise emptiness while real text sits beside that litter', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode(''), document.createTextNode('one'), document.createTextNode(''))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
/** A directive chip, as `refChipElement` builds it. */
|
||||
function chip(): HTMLSpanElement {
|
||||
const el = document.createElement('span')
|
||||
|
||||
el.contentEditable = 'false'
|
||||
el.dataset.refText = '@folder:`apps/desktop/`'
|
||||
el.append(document.createTextNode('apps/desktop/'))
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
function caretAt(node: Node, offset: number) {
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** Committing a completion empties the typed token's text node instead of
|
||||
* removing it, and `Range.insertNode` splits the line around the caret — so a
|
||||
* freshly-chipped directive sits between zero-length text nodes. Backspace has
|
||||
* to see past them or the chip can't be deleted at all. */
|
||||
describe('backspace deletes a chip surrounded by Chromium litter', () => {
|
||||
it('deletes the chip when the caret sits in a zero-length text node after it', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode(''), chip(), document.createTextNode(''))
|
||||
caretAt(el.childNodes[2] as Node, 0)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(true)
|
||||
expect(el.querySelector('[data-ref-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('deletes the chip when the caret is past a zero-length text node at editor level', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(chip(), document.createTextNode(''))
|
||||
caretAt(el, 2)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(true)
|
||||
expect(el.querySelector('[data-ref-text]')).toBeNull()
|
||||
})
|
||||
|
||||
it('still swallows the auto-inserted trailing space through that litter', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(chip(), document.createTextNode(''), document.createTextNode(' '))
|
||||
caretAt(el, 2)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(true)
|
||||
expect(composerPlainText(el)).toBe('')
|
||||
})
|
||||
|
||||
it('keeps real following text when it deletes the chip', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(chip(), document.createTextNode(''), document.createTextNode(' and this'))
|
||||
caretAt(el, 2)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(true)
|
||||
expect(composerPlainText(el)).toBe('and this')
|
||||
})
|
||||
|
||||
it('leaves plain text to the native backspace', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode('hello'))
|
||||
caretAt(el.firstChild as Node, 5)
|
||||
|
||||
expect(deleteChipBeforeCaret(el)).toBe(false)
|
||||
})
|
||||
|
||||
it('sweeps the litter out of the editor when it normalizes', () => {
|
||||
const el = editor()
|
||||
|
||||
el.append(document.createTextNode(''), chip(), document.createTextNode(''))
|
||||
normalizeComposerEditorDom(el)
|
||||
|
||||
expect(Array.from(el.childNodes).map(node => node.nodeName)).toEqual(['SPAN'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -57,7 +57,6 @@ import { ActionBadges } from './micro-actions'
|
|||
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
composerPlainText,
|
||||
deleteChipBeforeCaret,
|
||||
deleteSelectionInEditor,
|
||||
|
|
@ -947,7 +946,6 @@ export function ChatBar({
|
|||
autoCorrect="off"
|
||||
className={cn(
|
||||
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
'**:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
|
|
|
|||
|
|
@ -21,22 +21,50 @@ import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
|
|||
|
||||
export const RICH_INPUT_SLOT = 'composer-rich-input'
|
||||
|
||||
/** Paints `data-placeholder` while the editor is empty.
|
||||
/** Chromium's litter: editing beside a `contenteditable=false` chip splits the
|
||||
* line and leaves zero-length text nodes behind. They render as nothing and
|
||||
* serialize as nothing, so no reader of the editor should count them. */
|
||||
function isEmptyTextNode(node: ChildNode | null): boolean {
|
||||
return node?.nodeType === Node.TEXT_NODE && !node.textContent
|
||||
}
|
||||
|
||||
/** The node before `node`, stepping over that litter. */
|
||||
function meaningfulPreviousSibling(node: ChildNode | null): ChildNode | null {
|
||||
let prev = node?.previousSibling ?? null
|
||||
|
||||
while (isEmptyTextNode(prev)) {
|
||||
prev = prev?.previousSibling ?? null
|
||||
}
|
||||
|
||||
return prev
|
||||
}
|
||||
|
||||
/** The node after `node`, stepping over that litter. */
|
||||
function meaningfulNextSibling(node: ChildNode | null): ChildNode | null {
|
||||
let next = node?.nextSibling ?? null
|
||||
|
||||
while (isEmptyTextNode(next)) {
|
||||
next = next?.nextSibling ?? null
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/** Keep the `data-empty` marker the placeholder paints on in step with the
|
||||
* editor root's contents.
|
||||
*
|
||||
* `:empty` can't be the whole test: a cleared editor keeps a scaffolding <br>
|
||||
* so the contenteditable doesn't collapse, and that break makes `:empty`
|
||||
* false. Nor can CSS infer it on its own — a text node is invisible to
|
||||
* selectors, so `one<br>` and a lone `<br>` are the same shape, and
|
||||
* `:has(> br:only-child)` would paint the placeholder straight over the
|
||||
* user's text. The code that empties the editor is what knows, so it marks it.
|
||||
* `:has(> br:only-child)` would paint the placeholder over the user's text.
|
||||
* The code that empties the editor is what knows, so it marks it.
|
||||
*
|
||||
* @see markEditorEmptiness */
|
||||
export const COMPOSER_PLACEHOLDER_CLASS =
|
||||
'[&:is(:empty,[data-empty])]:before:content-[attr(data-placeholder)] [&:is(:empty,[data-empty])]:before:text-muted-foreground/60'
|
||||
|
||||
/** Keep that marker in step with the editor root's contents. */
|
||||
* Zero-length text nodes don't count as contents. Chromium leaves them behind
|
||||
* whenever an edit lands next to a `contenteditable=false` chip, and counting
|
||||
* them left an editor the user had emptied looking occupied. */
|
||||
export function markEditorEmptiness(editor: HTMLElement) {
|
||||
if (editor.childNodes.length === 0) {
|
||||
if (Array.from(editor.childNodes).every(isEmptyTextNode)) {
|
||||
editor.dataset.empty = ''
|
||||
} else {
|
||||
delete editor.dataset.empty
|
||||
|
|
@ -407,7 +435,14 @@ export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment
|
|||
/** 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
|
||||
* was unreliable). Returns whether it ran. */
|
||||
* was unreliable). Returns whether it ran.
|
||||
*
|
||||
* "Immediately after" has to be read through Chromium's litter. Committing a
|
||||
* completion empties the typed token's text node rather than removing it, and
|
||||
* `Range.insertNode` splits around the caret, so the chip routinely sits
|
||||
* between zero-length text nodes. Reading those as content made the caret look
|
||||
* like it was after plain text; the delete declined and Chromium's own
|
||||
* backspace bounced between the leftovers instead of removing the chip. */
|
||||
export function deleteChipBeforeCaret(editor: HTMLElement): boolean {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
|
|
@ -419,16 +454,20 @@ export function deleteChipBeforeCaret(editor: HTMLElement): boolean {
|
|||
let chip: ChildNode | null = null
|
||||
|
||||
if (startContainer === editor) {
|
||||
chip = startOffset > 0 ? editor.childNodes[startOffset - 1] : null
|
||||
chip = startOffset > 0 ? (editor.childNodes[startOffset - 1] ?? null) : null
|
||||
|
||||
if (isEmptyTextNode(chip)) {
|
||||
chip = meaningfulPreviousSibling(chip)
|
||||
}
|
||||
} else if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
|
||||
chip = startContainer.previousSibling
|
||||
chip = meaningfulPreviousSibling(startContainer as ChildNode)
|
||||
}
|
||||
|
||||
if (chip?.nodeType !== Node.ELEMENT_NODE || !(chip as HTMLElement).dataset.refText) {
|
||||
return false
|
||||
}
|
||||
|
||||
const after = chip.nextSibling
|
||||
const after = meaningfulNextSibling(chip)
|
||||
chip.remove()
|
||||
|
||||
// Drop the auto-inserted trailing space; keep any real following text.
|
||||
|
|
@ -666,6 +705,14 @@ function isBlankNode(node: ChildNode | null): boolean {
|
|||
* rendering emits (we use text nodes + <br> + chips). Real <br> line breaks
|
||||
* (Shift+Enter, which sit after actual text) are preserved. */
|
||||
export function normalizeComposerEditorDom(editor: HTMLElement) {
|
||||
// Chromium's zero-length text nodes first: every check below reads siblings,
|
||||
// and litter between them makes a chip look like it has text either side.
|
||||
for (const child of Array.from(editor.childNodes)) {
|
||||
if (isEmptyTextNode(child)) {
|
||||
child.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// A trailing block wrapper holding only a break/whitespace is the phantom
|
||||
// "new line" Chromium adds after a chip on backspace — drop it.
|
||||
const tailBlock = editor.lastChild as HTMLElement | null
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import {
|
|||
} from '@/app/chat/composer/inline-refs'
|
||||
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
|
||||
import {
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
composerPlainText,
|
||||
insertComposerContentsAtCaret,
|
||||
placeCaretEnd,
|
||||
|
|
@ -773,7 +772,6 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
|
|||
autoCorrect="off"
|
||||
className={cn(
|
||||
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
|
||||
COMPOSER_PLACEHOLDER_CLASS,
|
||||
'**:data-ref-text:cursor-default',
|
||||
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -732,6 +732,40 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────────────
|
||||
Composer placeholder.
|
||||
|
||||
The prompt is a PAINTED HINT, never content. Rendering `::before` inline —
|
||||
the obvious way — puts a box in the contenteditable's text flow, and the
|
||||
caret sits after it: click an empty composer and the insertion point lands
|
||||
at the right edge of the hint rather than the left edge of the field, and a
|
||||
hint that wraps makes the empty composer two lines tall. Taking it out of
|
||||
flow is what makes it a hint: the caret keeps the field's real origin, the
|
||||
box keeps its real height, and no pointer or selection can reach the text.
|
||||
|
||||
`data-empty` carries emptiness because `:empty` can't: a cleared editor
|
||||
keeps a scaffolding <br>. See markEditorEmptiness in rich-editor.ts.
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
[data-slot='composer-rich-input'] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-slot='composer-rich-input']:is(:empty, [data-empty])::before {
|
||||
content: attr(data-placeholder);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: inherit;
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--muted-foreground) 60%, transparent);
|
||||
/* One line, clipped — the hint states the field's purpose; it never reflows
|
||||
the box it sits in. A narrow composer or a long locale string would
|
||||
otherwise wrap and double the empty composer's height. */
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Primitive-level pointer cursor for every interactive control (buttons,
|
||||
selects, menu items, switches, tabs, summaries). Keeps individual
|
||||
components from having to hardcode `cursor-pointer`; explicit cursor
|
||||
|
|
|
|||
Loading…
Reference in New Issue