mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): improve rich composer IME and line-break handling (#1328)
This commit is contained in:
@@ -118,6 +118,8 @@ describe('insertLineBreakAtCaret', () => {
|
||||
document.body.appendChild(root)
|
||||
const hello = document.createTextNode('hello')
|
||||
root.appendChild(hello)
|
||||
Object.defineProperty(root, 'scrollHeight', { value: 120 })
|
||||
root.scrollTop = 10
|
||||
placeCaretAtEnd(root, hello)
|
||||
|
||||
insertLineBreakAtCaret(root)
|
||||
@@ -125,6 +127,7 @@ describe('insertLineBreakAtCaret', () => {
|
||||
const texts = Array.from(root.childNodes).map((n) => n.textContent ?? '')
|
||||
expect(texts).toContain(CARET_PAD)
|
||||
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n')
|
||||
expect(root.scrollTop).toBe(120)
|
||||
})
|
||||
|
||||
it('does not pad when there is meaningful content after the caret', () => {
|
||||
@@ -132,12 +135,41 @@ describe('insertLineBreakAtCaret', () => {
|
||||
document.body.appendChild(root)
|
||||
const text = document.createTextNode('helloworld')
|
||||
root.appendChild(text)
|
||||
Object.defineProperty(root, 'scrollHeight', { value: 120 })
|
||||
root.scrollTop = 10
|
||||
placeCaretInText(text, 5) // between hello|world
|
||||
|
||||
insertLineBreakAtCaret(root)
|
||||
|
||||
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\nworld')
|
||||
expect(Array.from(root.childNodes).some((n) => n.textContent === CARET_PAD)).toBe(false)
|
||||
expect(root.scrollTop).toBe(10)
|
||||
})
|
||||
|
||||
it('does not scroll for a nested middle-line break', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
root.innerHTML = '<div>first</div><div>second</div>'
|
||||
Object.defineProperty(root, 'scrollHeight', { value: 120 })
|
||||
root.scrollTop = 10
|
||||
placeCaretAtEnd(root, root.firstElementChild!.firstChild as Text)
|
||||
|
||||
insertLineBreakAtCaret(root)
|
||||
|
||||
expect(root.scrollTop).toBe(10)
|
||||
})
|
||||
|
||||
it('scrolls for a line break at the end of nested blocks', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
root.innerHTML = '<div>first</div><div>second</div>'
|
||||
Object.defineProperty(root, 'scrollHeight', { value: 120 })
|
||||
root.scrollTop = 10
|
||||
placeCaretAtEnd(root, root.lastElementChild!.firstChild as Text)
|
||||
|
||||
insertLineBreakAtCaret(root)
|
||||
|
||||
expect(root.scrollTop).toBe(120)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { useRef, useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
mirrorOffsetFromPoint,
|
||||
RichComposerInput,
|
||||
type RichComposerInputHandle,
|
||||
segmentsFromEditor,
|
||||
} from './RichComposerInput'
|
||||
import { serializeComposerSegments } from '@/lib/composerSegments'
|
||||
@@ -50,6 +51,43 @@ function SynchronousControlledHarness() {
|
||||
)
|
||||
}
|
||||
|
||||
function ProgrammaticEditHarness() {
|
||||
const [value, setValue] = useState('')
|
||||
const ref = useRef<RichComposerInputHandle>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => ref.current?.applyPlainSuggestion('hello')}>
|
||||
Insert suggestion
|
||||
</button>
|
||||
<RichComposerInput
|
||||
ref={ref}
|
||||
value={value}
|
||||
placeholder="Type a message"
|
||||
onValueChange={setValue}
|
||||
onMirrorChange={() => {}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LineBreakDeletionHarness() {
|
||||
const [value, setValue] = useState('hello')
|
||||
|
||||
return (
|
||||
<>
|
||||
<output data-testid="controlled-value">{value}</output>
|
||||
<RichComposerInput
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
flushSync(() => setValue(next))
|
||||
}}
|
||||
onMirrorChange={() => {}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('RichComposerInput controlled synchronization', () => {
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges()
|
||||
@@ -82,4 +120,128 @@ describe('RichComposerInput controlled synchronization', () => {
|
||||
expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('external draft')
|
||||
expect(selectionOffset(editor)).toBe('external draft'.length)
|
||||
})
|
||||
|
||||
it('tracks placeholder visibility from the DOM during composition', () => {
|
||||
render(
|
||||
<RichComposerInput
|
||||
value=""
|
||||
placeholder="Type a message"
|
||||
onValueChange={() => {}}
|
||||
onMirrorChange={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
const editor = screen.getByTestId('rich-composer-input')
|
||||
expect(screen.getByText('Type a message')).toBeInTheDocument()
|
||||
|
||||
fireEvent.compositionStart(editor)
|
||||
editor.textContent = 'dictated text'
|
||||
fireEvent.input(editor)
|
||||
expect(screen.queryByText('Type a message')).not.toBeInTheDocument()
|
||||
|
||||
editor.replaceChildren(document.createElement('br'))
|
||||
fireEvent.input(editor)
|
||||
expect(screen.getByText('Type a message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tracks placeholder visibility for programmatic insertion and deletion', () => {
|
||||
render(<ProgrammaticEditHarness />)
|
||||
|
||||
const editor = screen.getByTestId('rich-composer-input')
|
||||
expect(screen.getByText('Type a message')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Insert suggestion' }))
|
||||
expect(screen.queryByText('Type a message')).not.toBeInTheDocument()
|
||||
|
||||
const text = editor.firstChild
|
||||
expect(text).toBeInstanceOf(Text)
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(editor)
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
fireEvent.keyDown(editor, { key: 'Backspace' })
|
||||
|
||||
expect(screen.getByText('Type a message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes exactly one trailing line break per Backspace', () => {
|
||||
render(<LineBreakDeletionHarness />)
|
||||
|
||||
const editor = screen.getByTestId('rich-composer-input')
|
||||
const text = editor.firstChild
|
||||
expect(text).toBeInstanceOf(Text)
|
||||
editor.focus()
|
||||
placeCaret(text as Text, text?.textContent?.length ?? 0)
|
||||
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n')
|
||||
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n\n')
|
||||
|
||||
fireEvent.keyDown(editor, { key: 'Backspace' })
|
||||
expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n')
|
||||
|
||||
fireEvent.keyDown(editor, { key: 'Backspace' })
|
||||
expect(screen.getByTestId('controlled-value').textContent).toBe('hello')
|
||||
expect(selectionOffset(editor)).toBe('hello'.length)
|
||||
})
|
||||
|
||||
it('handles soft-keyboard backward deletion through beforeinput', () => {
|
||||
render(<LineBreakDeletionHarness />)
|
||||
|
||||
const editor = screen.getByTestId('rich-composer-input')
|
||||
const text = editor.firstChild
|
||||
expect(text).toBeInstanceOf(Text)
|
||||
editor.focus()
|
||||
placeCaret(text as Text, text?.textContent?.length ?? 0)
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
const event = new InputEvent('beforeinput', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
inputType: 'deleteContentBackward',
|
||||
})
|
||||
expect(editor.dispatchEvent(event)).toBe(false)
|
||||
expect(screen.getByTestId('controlled-value').textContent).toBe('hello')
|
||||
expect(selectionOffset(editor)).toBe('hello'.length)
|
||||
})
|
||||
|
||||
it('leaves ordinary beforeinput deletion to the browser', () => {
|
||||
render(<LineBreakDeletionHarness />)
|
||||
|
||||
const editor = screen.getByTestId('rich-composer-input')
|
||||
const text = editor.firstChild
|
||||
expect(text).toBeInstanceOf(Text)
|
||||
editor.focus()
|
||||
placeCaret(text as Text, text?.textContent?.length ?? 0)
|
||||
|
||||
const event = new InputEvent('beforeinput', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
inputType: 'deleteContentBackward',
|
||||
})
|
||||
expect(editor.dispatchEvent(event)).toBe(true)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('does not forward Enter while composition is active', () => {
|
||||
const onKeyDown = vi.fn()
|
||||
render(
|
||||
<RichComposerInput
|
||||
value="hello"
|
||||
onValueChange={() => {}}
|
||||
onMirrorChange={() => {}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
|
||||
const editor = screen.getByTestId('rich-composer-input')
|
||||
fireEvent.compositionStart(editor)
|
||||
fireEvent.keyDown(editor, { key: 'Enter', isComposing: false })
|
||||
|
||||
expect(onKeyDown).not.toHaveBeenCalled()
|
||||
expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,6 +111,35 @@ function stripCaretPad(text: string): string {
|
||||
return text.replaceAll(CARET_PAD, '')
|
||||
}
|
||||
|
||||
function caretIsAfterCaretPad(root: HTMLElement): boolean {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return false
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!range.collapsed || !root.contains(range.startContainer)) return false
|
||||
|
||||
const { startContainer, startOffset } = range
|
||||
if (startContainer.nodeType === Node.TEXT_NODE) {
|
||||
const text = startContainer.textContent ?? ''
|
||||
return startOffset > 0 && text[startOffset - 1] === CARET_PAD
|
||||
}
|
||||
if (startContainer.nodeType !== Node.ELEMENT_NODE || startOffset === 0) return false
|
||||
|
||||
const previous = startContainer.childNodes[startOffset - 1]
|
||||
return previous?.nodeType === Node.TEXT_NODE
|
||||
&& (previous.textContent ?? '').endsWith(CARET_PAD)
|
||||
}
|
||||
|
||||
function selectionIsAfterCaretPad(
|
||||
root: HTMLElement,
|
||||
mirror: string,
|
||||
selection: ComposerSelection
|
||||
): boolean {
|
||||
return selection.start === selection.end
|
||||
&& selection.start > 0
|
||||
&& mirror[selection.start - 1] === '\n'
|
||||
&& caretIsAfterCaretPad(root)
|
||||
}
|
||||
|
||||
type ComposerDomSpan = {
|
||||
/** Mirror offset at the point inside this node where its visible content begins. */
|
||||
start: number
|
||||
@@ -277,6 +306,13 @@ export function insertLineBreakAtCaret(root: HTMLElement): void {
|
||||
|
||||
root.focus()
|
||||
const range = sel.getRangeAt(0)
|
||||
const trailingRange = range.cloneRange()
|
||||
trailingRange.collapse(false)
|
||||
trailingRange.setEnd(root, root.childNodes.length)
|
||||
const trailingRoot = document.createElement('div')
|
||||
trailingRoot.append(trailingRange.cloneContents())
|
||||
const selectionEndsAtEditorEnd =
|
||||
mirrorComposerSegments(segmentsFromEditor(trailingRoot)).length === 0
|
||||
range.deleteContents()
|
||||
const nl = document.createTextNode('\n')
|
||||
range.insertNode(nl)
|
||||
@@ -290,6 +326,8 @@ export function insertLineBreakAtCaret(root: HTMLElement): void {
|
||||
range.collapse(true)
|
||||
sel.removeAllRanges()
|
||||
sel.addRange(range)
|
||||
// Restore native textarea scrolling for trailing line breaks.
|
||||
if (selectionEndsAtEditorEnd) root.scrollTop = root.scrollHeight
|
||||
}
|
||||
|
||||
function renderSegmentsToEditor(
|
||||
@@ -419,6 +457,10 @@ function mirrorOffsetFromMappedPoint(
|
||||
return mapping.mirrorLength
|
||||
}
|
||||
|
||||
function editorDomIsEmpty(root: HTMLElement): boolean {
|
||||
return (root.textContent ?? '').length === 0
|
||||
}
|
||||
|
||||
/** Exported for unit tests — maps a DOM caret point into mirror-string offset. */
|
||||
export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number {
|
||||
const mapping = mapComposerEditorDom(root)
|
||||
@@ -589,6 +631,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
const tooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hoveredChipRef = useRef<HTMLElement | null>(null)
|
||||
const [mentionTooltip, setMentionTooltip] = useState<MentionTooltipState | null>(null)
|
||||
const [domIsEmpty, setDomIsEmpty] = useState(value.length === 0)
|
||||
|
||||
const clearMentionTooltip = useCallback(() => {
|
||||
if (tooltipTimerRef.current) {
|
||||
@@ -599,9 +642,15 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
setMentionTooltip(null)
|
||||
}, [])
|
||||
|
||||
const renderEditorSegments = useCallback((root: HTMLElement, segments: readonly ComposerSegment[]) => {
|
||||
renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip)
|
||||
setDomIsEmpty(editorDomIsEmpty(root))
|
||||
}, [resolveSessionMentionTooltip])
|
||||
|
||||
const emitFromDom = useCallback(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
setDomIsEmpty(editorDomIsEmpty(root))
|
||||
const segments = segmentsFromEditor(root)
|
||||
const serialized = serializeComposerSegments(segments)
|
||||
const selection = getMirrorSelection(root)
|
||||
@@ -615,7 +664,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
const segments = parseComposerSegments(next)
|
||||
renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip)
|
||||
renderEditorSegments(root, segments)
|
||||
lastEmittedRef.current = next
|
||||
clearMentionTooltip()
|
||||
const mirror = mirrorComposerSegments(segments)
|
||||
@@ -627,7 +676,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
setMirrorSelection(root, sel)
|
||||
}
|
||||
onMirrorChange({ text: mirror, selection: sel })
|
||||
}, [clearMentionTooltip, onMirrorChange, resolveSessionMentionTooltip])
|
||||
}, [clearMentionTooltip, onMirrorChange, renderEditorSegments])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (value === lastEmittedRef.current) return
|
||||
@@ -671,7 +720,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
const selection = getMirrorSelection(root)
|
||||
const result = insertSessionMentionInComposerSegments(segments, selection, mention, prefixes)
|
||||
const serialized = serializeComposerSegments(result.segments)
|
||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
||||
renderEditorSegments(root, result.segments)
|
||||
lastEmittedRef.current = serialized
|
||||
setMirrorSelection(root, result.selection)
|
||||
onValueChange(serialized)
|
||||
@@ -690,7 +739,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
const selection = getMirrorSelection(root)
|
||||
const result = insertPlainTextInComposerSegments(segments, selection, suggestionText, prefixes)
|
||||
const serialized = serializeComposerSegments(result.segments)
|
||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
||||
renderEditorSegments(root, result.segments)
|
||||
lastEmittedRef.current = serialized
|
||||
setMirrorSelection(root, result.selection)
|
||||
onValueChange(serialized)
|
||||
@@ -700,7 +749,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
})
|
||||
return { text: serialized, selection: result.selection }
|
||||
},
|
||||
}), [onMirrorChange, onValueChange, resolveSessionMentionTooltip, value])
|
||||
}), [onMirrorChange, onValueChange, renderEditorSegments, value])
|
||||
|
||||
useEffect(() => () => {
|
||||
if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current)
|
||||
@@ -776,7 +825,11 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
|
||||
const handleInput = useCallback((_e: ReactFormEvent<HTMLDivElement>) => {
|
||||
clearMentionTooltip()
|
||||
if (composingRef.current) return
|
||||
if (composingRef.current) {
|
||||
const root = rootRef.current
|
||||
if (root) setDomIsEmpty(editorDomIsEmpty(root))
|
||||
return
|
||||
}
|
||||
onEdit?.()
|
||||
emitFromDom()
|
||||
}, [clearMentionTooltip, emitFromDom, onEdit])
|
||||
@@ -794,7 +847,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
parseComposerSegments(text),
|
||||
)
|
||||
const serialized = serializeComposerSegments(result.segments)
|
||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
||||
renderEditorSegments(root, result.segments)
|
||||
lastEmittedRef.current = serialized
|
||||
setMirrorSelection(root, result.selection)
|
||||
onValueChange(serialized)
|
||||
@@ -803,7 +856,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
selection: result.selection,
|
||||
})
|
||||
onEdit?.()
|
||||
}, [onEdit, onMirrorChange, onValueChange, resolveSessionMentionTooltip])
|
||||
}, [onEdit, onMirrorChange, onValueChange, renderEditorSegments])
|
||||
|
||||
const handleCopyOrCut = useCallback((e: ReactClipboardEvent<HTMLDivElement>, cut: boolean) => {
|
||||
const root = rootRef.current
|
||||
@@ -818,7 +871,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
clearMentionTooltip()
|
||||
const result = deleteBackwardInComposerSegments(segments, selection)
|
||||
const serialized = serializeComposerSegments(result.segments)
|
||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
||||
renderEditorSegments(root, result.segments)
|
||||
lastEmittedRef.current = serialized
|
||||
setMirrorSelection(root, result.selection)
|
||||
onValueChange(serialized)
|
||||
@@ -832,7 +885,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
onEdit,
|
||||
onMirrorChange,
|
||||
onValueChange,
|
||||
resolveSessionMentionTooltip,
|
||||
renderEditorSegments,
|
||||
])
|
||||
|
||||
const handlePaste = useCallback((e: ReactClipboardEvent<HTMLDivElement>) => {
|
||||
@@ -848,30 +901,15 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
insertPlainClipboardText(e.clipboardData?.getData('text/plain') ?? '')
|
||||
}, [insertPlainClipboardText, onPaste])
|
||||
|
||||
// No onDrop: intercepting without caretRangeFromPoint appends at EOF / no-ops
|
||||
// in-editor moves. Native CE drop + plaintext-only / paste path is enough for #1215.
|
||||
|
||||
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.nativeEvent.isComposing) {
|
||||
onKeyDown?.(e)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Backspace' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const root = rootRef.current
|
||||
if (root) {
|
||||
const segments = segmentsFromEditor(root)
|
||||
const selection = getMirrorSelection(root)
|
||||
const mirror = mirrorComposerSegments(segments)
|
||||
const againstAtom =
|
||||
selection.start === selection.end
|
||||
&& selection.start > 0
|
||||
&& mirror[selection.start - 1] === COMPOSER_MENTION_MIRROR_CHAR
|
||||
if (againstAtom || selection.start !== selection.end) {
|
||||
e.preventDefault()
|
||||
const applyBackwardDelete = useCallback((
|
||||
root: HTMLElement,
|
||||
segments: readonly ComposerSegment[],
|
||||
selection: ComposerSelection
|
||||
) => {
|
||||
clearMentionTooltip()
|
||||
const result = deleteBackwardInComposerSegments(segments, selection)
|
||||
const serialized = serializeComposerSegments(result.segments)
|
||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
||||
renderEditorSegments(root, result.segments)
|
||||
lastEmittedRef.current = serialized
|
||||
setMirrorSelection(root, result.selection)
|
||||
onValueChange(serialized)
|
||||
@@ -880,6 +918,52 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
selection: result.selection,
|
||||
})
|
||||
onEdit?.()
|
||||
}, [clearMentionTooltip, onEdit, onMirrorChange, onValueChange, renderEditorSegments])
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root) return
|
||||
const handleBeforeInput = (event: InputEvent) => {
|
||||
if (
|
||||
event.inputType !== 'deleteContentBackward'
|
||||
|| event.isComposing
|
||||
|| composingRef.current
|
||||
|| !event.cancelable
|
||||
) return
|
||||
|
||||
const segments = segmentsFromEditor(root)
|
||||
const selection = getMirrorSelection(root)
|
||||
const mirror = mirrorComposerSegments(segments)
|
||||
if (!selectionIsAfterCaretPad(root, mirror, selection)) return
|
||||
|
||||
event.preventDefault()
|
||||
applyBackwardDelete(root, segments, selection)
|
||||
}
|
||||
root.addEventListener('beforeinput', handleBeforeInput)
|
||||
return () => root.removeEventListener('beforeinput', handleBeforeInput)
|
||||
}, [applyBackwardDelete])
|
||||
|
||||
// No onDrop: intercepting without caretRangeFromPoint appends at EOF / no-ops
|
||||
// in-editor moves. Native CE drop + plaintext-only / paste path is enough for #1215.
|
||||
|
||||
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.nativeEvent.isComposing || composingRef.current) {
|
||||
return
|
||||
}
|
||||
if (e.key === 'Backspace' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const root = rootRef.current
|
||||
if (root) {
|
||||
const segments = segmentsFromEditor(root)
|
||||
const selection = getMirrorSelection(root)
|
||||
const mirror = mirrorComposerSegments(segments)
|
||||
const afterCaretPad = selectionIsAfterCaretPad(root, mirror, selection)
|
||||
const againstAtom =
|
||||
selection.start === selection.end
|
||||
&& selection.start > 0
|
||||
&& mirror[selection.start - 1] === COMPOSER_MENTION_MIRROR_CHAR
|
||||
if (afterCaretPad || againstAtom || selection.start !== selection.end) {
|
||||
e.preventDefault()
|
||||
applyBackwardDelete(root, segments, selection)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -900,18 +984,15 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
||||
emitFromDom()
|
||||
}
|
||||
}, [
|
||||
applyBackwardDelete,
|
||||
emitFromDom,
|
||||
onEdit,
|
||||
onKeyDown,
|
||||
onMirrorChange,
|
||||
onValueChange,
|
||||
resolveSessionMentionTooltip,
|
||||
clearMentionTooltip,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 flex-1">
|
||||
{(!value || value.length === 0) && placeholder ? (
|
||||
{domIsEmpty && placeholder ? (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 text-base leading-snug text-[var(--app-hint)]"
|
||||
|
||||
Reference in New Issue
Block a user