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)
|
document.body.appendChild(root)
|
||||||
const hello = document.createTextNode('hello')
|
const hello = document.createTextNode('hello')
|
||||||
root.appendChild(hello)
|
root.appendChild(hello)
|
||||||
|
Object.defineProperty(root, 'scrollHeight', { value: 120 })
|
||||||
|
root.scrollTop = 10
|
||||||
placeCaretAtEnd(root, hello)
|
placeCaretAtEnd(root, hello)
|
||||||
|
|
||||||
insertLineBreakAtCaret(root)
|
insertLineBreakAtCaret(root)
|
||||||
@@ -125,6 +127,7 @@ describe('insertLineBreakAtCaret', () => {
|
|||||||
const texts = Array.from(root.childNodes).map((n) => n.textContent ?? '')
|
const texts = Array.from(root.childNodes).map((n) => n.textContent ?? '')
|
||||||
expect(texts).toContain(CARET_PAD)
|
expect(texts).toContain(CARET_PAD)
|
||||||
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n')
|
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n')
|
||||||
|
expect(root.scrollTop).toBe(120)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not pad when there is meaningful content after the caret', () => {
|
it('does not pad when there is meaningful content after the caret', () => {
|
||||||
@@ -132,12 +135,41 @@ describe('insertLineBreakAtCaret', () => {
|
|||||||
document.body.appendChild(root)
|
document.body.appendChild(root)
|
||||||
const text = document.createTextNode('helloworld')
|
const text = document.createTextNode('helloworld')
|
||||||
root.appendChild(text)
|
root.appendChild(text)
|
||||||
|
Object.defineProperty(root, 'scrollHeight', { value: 120 })
|
||||||
|
root.scrollTop = 10
|
||||||
placeCaretInText(text, 5) // between hello|world
|
placeCaretInText(text, 5) // between hello|world
|
||||||
|
|
||||||
insertLineBreakAtCaret(root)
|
insertLineBreakAtCaret(root)
|
||||||
|
|
||||||
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\nworld')
|
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\nworld')
|
||||||
expect(Array.from(root.childNodes).some((n) => n.textContent === CARET_PAD)).toBe(false)
|
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 { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
mirrorOffsetFromPoint,
|
mirrorOffsetFromPoint,
|
||||||
RichComposerInput,
|
RichComposerInput,
|
||||||
|
type RichComposerInputHandle,
|
||||||
segmentsFromEditor,
|
segmentsFromEditor,
|
||||||
} from './RichComposerInput'
|
} from './RichComposerInput'
|
||||||
import { serializeComposerSegments } from '@/lib/composerSegments'
|
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', () => {
|
describe('RichComposerInput controlled synchronization', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
window.getSelection()?.removeAllRanges()
|
window.getSelection()?.removeAllRanges()
|
||||||
@@ -82,4 +120,128 @@ describe('RichComposerInput controlled synchronization', () => {
|
|||||||
expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('external draft')
|
expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('external draft')
|
||||||
expect(selectionOffset(editor)).toBe('external draft'.length)
|
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, '')
|
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 = {
|
type ComposerDomSpan = {
|
||||||
/** Mirror offset at the point inside this node where its visible content begins. */
|
/** Mirror offset at the point inside this node where its visible content begins. */
|
||||||
start: number
|
start: number
|
||||||
@@ -277,6 +306,13 @@ export function insertLineBreakAtCaret(root: HTMLElement): void {
|
|||||||
|
|
||||||
root.focus()
|
root.focus()
|
||||||
const range = sel.getRangeAt(0)
|
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()
|
range.deleteContents()
|
||||||
const nl = document.createTextNode('\n')
|
const nl = document.createTextNode('\n')
|
||||||
range.insertNode(nl)
|
range.insertNode(nl)
|
||||||
@@ -290,6 +326,8 @@ export function insertLineBreakAtCaret(root: HTMLElement): void {
|
|||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
sel.removeAllRanges()
|
sel.removeAllRanges()
|
||||||
sel.addRange(range)
|
sel.addRange(range)
|
||||||
|
// Restore native textarea scrolling for trailing line breaks.
|
||||||
|
if (selectionEndsAtEditorEnd) root.scrollTop = root.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSegmentsToEditor(
|
function renderSegmentsToEditor(
|
||||||
@@ -419,6 +457,10 @@ function mirrorOffsetFromMappedPoint(
|
|||||||
return mapping.mirrorLength
|
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. */
|
/** Exported for unit tests — maps a DOM caret point into mirror-string offset. */
|
||||||
export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number {
|
export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number {
|
||||||
const mapping = mapComposerEditorDom(root)
|
const mapping = mapComposerEditorDom(root)
|
||||||
@@ -589,6 +631,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
const tooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const tooltipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const hoveredChipRef = useRef<HTMLElement | null>(null)
|
const hoveredChipRef = useRef<HTMLElement | null>(null)
|
||||||
const [mentionTooltip, setMentionTooltip] = useState<MentionTooltipState | null>(null)
|
const [mentionTooltip, setMentionTooltip] = useState<MentionTooltipState | null>(null)
|
||||||
|
const [domIsEmpty, setDomIsEmpty] = useState(value.length === 0)
|
||||||
|
|
||||||
const clearMentionTooltip = useCallback(() => {
|
const clearMentionTooltip = useCallback(() => {
|
||||||
if (tooltipTimerRef.current) {
|
if (tooltipTimerRef.current) {
|
||||||
@@ -599,9 +642,15 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
setMentionTooltip(null)
|
setMentionTooltip(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const renderEditorSegments = useCallback((root: HTMLElement, segments: readonly ComposerSegment[]) => {
|
||||||
|
renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip)
|
||||||
|
setDomIsEmpty(editorDomIsEmpty(root))
|
||||||
|
}, [resolveSessionMentionTooltip])
|
||||||
|
|
||||||
const emitFromDom = useCallback(() => {
|
const emitFromDom = useCallback(() => {
|
||||||
const root = rootRef.current
|
const root = rootRef.current
|
||||||
if (!root) return
|
if (!root) return
|
||||||
|
setDomIsEmpty(editorDomIsEmpty(root))
|
||||||
const segments = segmentsFromEditor(root)
|
const segments = segmentsFromEditor(root)
|
||||||
const serialized = serializeComposerSegments(segments)
|
const serialized = serializeComposerSegments(segments)
|
||||||
const selection = getMirrorSelection(root)
|
const selection = getMirrorSelection(root)
|
||||||
@@ -615,7 +664,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
const root = rootRef.current
|
const root = rootRef.current
|
||||||
if (!root) return
|
if (!root) return
|
||||||
const segments = parseComposerSegments(next)
|
const segments = parseComposerSegments(next)
|
||||||
renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip)
|
renderEditorSegments(root, segments)
|
||||||
lastEmittedRef.current = next
|
lastEmittedRef.current = next
|
||||||
clearMentionTooltip()
|
clearMentionTooltip()
|
||||||
const mirror = mirrorComposerSegments(segments)
|
const mirror = mirrorComposerSegments(segments)
|
||||||
@@ -627,7 +676,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
setMirrorSelection(root, sel)
|
setMirrorSelection(root, sel)
|
||||||
}
|
}
|
||||||
onMirrorChange({ text: mirror, selection: sel })
|
onMirrorChange({ text: mirror, selection: sel })
|
||||||
}, [clearMentionTooltip, onMirrorChange, resolveSessionMentionTooltip])
|
}, [clearMentionTooltip, onMirrorChange, renderEditorSegments])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (value === lastEmittedRef.current) return
|
if (value === lastEmittedRef.current) return
|
||||||
@@ -671,7 +720,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
const selection = getMirrorSelection(root)
|
const selection = getMirrorSelection(root)
|
||||||
const result = insertSessionMentionInComposerSegments(segments, selection, mention, prefixes)
|
const result = insertSessionMentionInComposerSegments(segments, selection, mention, prefixes)
|
||||||
const serialized = serializeComposerSegments(result.segments)
|
const serialized = serializeComposerSegments(result.segments)
|
||||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
renderEditorSegments(root, result.segments)
|
||||||
lastEmittedRef.current = serialized
|
lastEmittedRef.current = serialized
|
||||||
setMirrorSelection(root, result.selection)
|
setMirrorSelection(root, result.selection)
|
||||||
onValueChange(serialized)
|
onValueChange(serialized)
|
||||||
@@ -690,7 +739,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
const selection = getMirrorSelection(root)
|
const selection = getMirrorSelection(root)
|
||||||
const result = insertPlainTextInComposerSegments(segments, selection, suggestionText, prefixes)
|
const result = insertPlainTextInComposerSegments(segments, selection, suggestionText, prefixes)
|
||||||
const serialized = serializeComposerSegments(result.segments)
|
const serialized = serializeComposerSegments(result.segments)
|
||||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
renderEditorSegments(root, result.segments)
|
||||||
lastEmittedRef.current = serialized
|
lastEmittedRef.current = serialized
|
||||||
setMirrorSelection(root, result.selection)
|
setMirrorSelection(root, result.selection)
|
||||||
onValueChange(serialized)
|
onValueChange(serialized)
|
||||||
@@ -700,7 +749,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
})
|
})
|
||||||
return { text: serialized, selection: result.selection }
|
return { text: serialized, selection: result.selection }
|
||||||
},
|
},
|
||||||
}), [onMirrorChange, onValueChange, resolveSessionMentionTooltip, value])
|
}), [onMirrorChange, onValueChange, renderEditorSegments, value])
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current)
|
if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current)
|
||||||
@@ -776,7 +825,11 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
|
|
||||||
const handleInput = useCallback((_e: ReactFormEvent<HTMLDivElement>) => {
|
const handleInput = useCallback((_e: ReactFormEvent<HTMLDivElement>) => {
|
||||||
clearMentionTooltip()
|
clearMentionTooltip()
|
||||||
if (composingRef.current) return
|
if (composingRef.current) {
|
||||||
|
const root = rootRef.current
|
||||||
|
if (root) setDomIsEmpty(editorDomIsEmpty(root))
|
||||||
|
return
|
||||||
|
}
|
||||||
onEdit?.()
|
onEdit?.()
|
||||||
emitFromDom()
|
emitFromDom()
|
||||||
}, [clearMentionTooltip, emitFromDom, onEdit])
|
}, [clearMentionTooltip, emitFromDom, onEdit])
|
||||||
@@ -794,7 +847,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
parseComposerSegments(text),
|
parseComposerSegments(text),
|
||||||
)
|
)
|
||||||
const serialized = serializeComposerSegments(result.segments)
|
const serialized = serializeComposerSegments(result.segments)
|
||||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
renderEditorSegments(root, result.segments)
|
||||||
lastEmittedRef.current = serialized
|
lastEmittedRef.current = serialized
|
||||||
setMirrorSelection(root, result.selection)
|
setMirrorSelection(root, result.selection)
|
||||||
onValueChange(serialized)
|
onValueChange(serialized)
|
||||||
@@ -803,7 +856,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
selection: result.selection,
|
selection: result.selection,
|
||||||
})
|
})
|
||||||
onEdit?.()
|
onEdit?.()
|
||||||
}, [onEdit, onMirrorChange, onValueChange, resolveSessionMentionTooltip])
|
}, [onEdit, onMirrorChange, onValueChange, renderEditorSegments])
|
||||||
|
|
||||||
const handleCopyOrCut = useCallback((e: ReactClipboardEvent<HTMLDivElement>, cut: boolean) => {
|
const handleCopyOrCut = useCallback((e: ReactClipboardEvent<HTMLDivElement>, cut: boolean) => {
|
||||||
const root = rootRef.current
|
const root = rootRef.current
|
||||||
@@ -818,7 +871,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
clearMentionTooltip()
|
clearMentionTooltip()
|
||||||
const result = deleteBackwardInComposerSegments(segments, selection)
|
const result = deleteBackwardInComposerSegments(segments, selection)
|
||||||
const serialized = serializeComposerSegments(result.segments)
|
const serialized = serializeComposerSegments(result.segments)
|
||||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
renderEditorSegments(root, result.segments)
|
||||||
lastEmittedRef.current = serialized
|
lastEmittedRef.current = serialized
|
||||||
setMirrorSelection(root, result.selection)
|
setMirrorSelection(root, result.selection)
|
||||||
onValueChange(serialized)
|
onValueChange(serialized)
|
||||||
@@ -832,7 +885,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
onEdit,
|
onEdit,
|
||||||
onMirrorChange,
|
onMirrorChange,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
resolveSessionMentionTooltip,
|
renderEditorSegments,
|
||||||
])
|
])
|
||||||
|
|
||||||
const handlePaste = useCallback((e: ReactClipboardEvent<HTMLDivElement>) => {
|
const handlePaste = useCallback((e: ReactClipboardEvent<HTMLDivElement>) => {
|
||||||
@@ -848,30 +901,15 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
insertPlainClipboardText(e.clipboardData?.getData('text/plain') ?? '')
|
insertPlainClipboardText(e.clipboardData?.getData('text/plain') ?? '')
|
||||||
}, [insertPlainClipboardText, onPaste])
|
}, [insertPlainClipboardText, onPaste])
|
||||||
|
|
||||||
// No onDrop: intercepting without caretRangeFromPoint appends at EOF / no-ops
|
const applyBackwardDelete = useCallback((
|
||||||
// in-editor moves. Native CE drop + plaintext-only / paste path is enough for #1215.
|
root: HTMLElement,
|
||||||
|
segments: readonly ComposerSegment[],
|
||||||
const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
|
selection: ComposerSelection
|
||||||
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()
|
|
||||||
clearMentionTooltip()
|
clearMentionTooltip()
|
||||||
const result = deleteBackwardInComposerSegments(segments, selection)
|
const result = deleteBackwardInComposerSegments(segments, selection)
|
||||||
const serialized = serializeComposerSegments(result.segments)
|
const serialized = serializeComposerSegments(result.segments)
|
||||||
renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip)
|
renderEditorSegments(root, result.segments)
|
||||||
lastEmittedRef.current = serialized
|
lastEmittedRef.current = serialized
|
||||||
setMirrorSelection(root, result.selection)
|
setMirrorSelection(root, result.selection)
|
||||||
onValueChange(serialized)
|
onValueChange(serialized)
|
||||||
@@ -880,6 +918,52 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
selection: result.selection,
|
selection: result.selection,
|
||||||
})
|
})
|
||||||
onEdit?.()
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -900,18 +984,15 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, Props>(func
|
|||||||
emitFromDom()
|
emitFromDom()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
applyBackwardDelete,
|
||||||
emitFromDom,
|
emitFromDom,
|
||||||
onEdit,
|
onEdit,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onMirrorChange,
|
|
||||||
onValueChange,
|
|
||||||
resolveSessionMentionTooltip,
|
|
||||||
clearMentionTooltip,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-w-0 flex-1">
|
<div className="relative min-w-0 flex-1">
|
||||||
{(!value || value.length === 0) && placeholder ? (
|
{domIsEmpty && placeholder ? (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute inset-0 text-base leading-snug text-[var(--app-hint)]"
|
className="pointer-events-none absolute inset-0 text-base leading-snug text-[var(--app-hint)]"
|
||||||
|
|||||||
Reference in New Issue
Block a user