返回 DeepSeek-Reasonix
RichComposerInput.tsx
根目录 / desktop / frontend / src / components / RichComposerInput.tsx
1 import {
2 forwardRef,
3 useImperativeHandle,
4 useLayoutEffect,
5 useMemo,
6 useRef,
7 type ClipboardEvent,
8 type CSSProperties,
9 type FormEvent,
10 type KeyboardEvent,
11 type MouseEvent,
12 } from "react";
13 import {
14 invocationDisplayForCommand,
15 replaceInvocationTextRange,
16 sortComposerInvocations,
17 type ComposerInvocation,
18 } from "../lib/invocationDisplay";
19 import { activeRefTokenRe } from "../lib/refToken";
20 import { newComposerInvocationID } from "../lib/composerInvocationIdentity";
21 import type { CommandInfo } from "../lib/types";
22 import { InvocationBadge } from "./InvocationBadge";
23
24 export type RichComposerSelection = {
25 start: number;
26 end: number;
27 afterInvocationId?: string;
28 };
29
30 export type RichComposerChangeOrigin = {
31 source: "browser" | "programmatic";
32 inputType?: string;
33 beforeSelection: RichComposerSelection;
34 afterSelection: RichComposerSelection;
35 };
36
37 export type RichSlashQuery = {
38 from: number;
39 to: number;
40 query: string;
41 };
42
43 export type RichComposerInputHandle = {
44 focus: () => void;
45 getSelection: () => RichComposerSelection;
46 setSelectionRange: (start: number, end?: number, afterInvocationId?: string) => void;
47 replaceRange: (value: string, start: number, end: number) => void;
48 insertInvocation: (command: CommandInfo, query: RichSlashQuery) => void;
49 scrollHeight: () => number;
50 };
51
52 export type DomSelectionRead =
53 | { ok: true; selection: RichComposerSelection }
54 | { ok: false };
55
56 type PendingSelection = RichComposerSelection | null;
57
58 type ComposerModel = {
59 text: string;
60 invocations: ComposerInvocation[];
61 };
62
63 type RenderedComposerModel = ComposerModel & {
64 version: number;
65 };
66
67 type DomPoint = {
68 node: Node;
69 offset: number;
70 };
71
72 type EditSnapshot = {
73 text: string;
74 selection: RichComposerSelection;
75 inputType: string;
76 data: string | null;
77 };
78
79 const CARET_SENTINEL = "\u00A0";
80
81 function sameComposerModel(left: ComposerModel, right: ComposerModel | null): boolean {
82 if (!right || left.text !== right.text || left.invocations.length !== right.invocations.length) return false;
83 return left.invocations.every((item, index) => {
84 const candidate = right.invocations[index];
85 return item.id === candidate.id && item.offset === candidate.offset && item.command === candidate.command;
86 });
87 }
88
89 function isHTMLElement(node: Node): node is HTMLElement {
90 return node instanceof HTMLElement;
91 }
92
93 function isInvocationToken(node: Node): node is HTMLElement {
94 return isHTMLElement(node) && Boolean(node.dataset.invocationId);
95 }
96
97 function isCaretAnchor(node: Node): node is HTMLElement {
98 return isHTMLElement(node) && Boolean(node.dataset.composerCaretAnchor);
99 }
100
101 function isBreak(node: Node): node is HTMLElement {
102 return isHTMLElement(node) && node.tagName === "BR";
103 }
104
105 /**
106 * Shared DOM walk for model text, selection reads, and selection restores.
107 *
108 * Logical length rules:
109 * - invocation tokens are zero-length atoms (children ignored)
110 * - the first CARET_SENTINEL inside a caret anchor is zero-length
111 * - remaining user text in the anchor counts
112 * - <br> is one newline
113 * - ordinary / nested text uses JavaScript UTF-16 offsets
114 */
115 function walkComposerDom(
116 root: Node,
117 visitor: {
118 onInvocation?: (id: string, element: HTMLElement) => boolean | void;
119 onText?: (node: Text, start: number, end: number) => boolean | void;
120 onBreak?: (element: HTMLElement) => boolean | void;
121 },
122 ): void {
123 const visit = (node: Node, inAnchor: boolean, anchorState: { skippedSentinel: boolean }): boolean => {
124 if (isInvocationToken(node)) {
125 const id = node.dataset.invocationId;
126 if (id && visitor.onInvocation?.(id, node)) return true;
127 return false;
128 }
129 if (isCaretAnchor(node)) {
130 const state = { skippedSentinel: false };
131 for (const child of Array.from(node.childNodes)) {
132 if (visit(child, true, state)) return true;
133 }
134 return false;
135 }
136 if (isBreak(node)) {
137 return Boolean(visitor.onBreak?.(node));
138 }
139 if (node.nodeType === Node.TEXT_NODE) {
140 const textNode = node as Text;
141 const value = textNode.textContent ?? "";
142 if (!value) return false;
143 if (!inAnchor) {
144 return Boolean(visitor.onText?.(textNode, 0, value.length));
145 }
146 let start = 0;
147 if (!anchorState.skippedSentinel) {
148 const sentinelAt = value.indexOf(CARET_SENTINEL);
149 if (sentinelAt === 0) {
150 anchorState.skippedSentinel = true;
151 start = 1;
152 } else if (sentinelAt > 0) {
153 // Count text before the first sentinel, then skip the sentinel once.
154 if (visitor.onText?.(textNode, 0, sentinelAt)) return true;
155 anchorState.skippedSentinel = true;
156 start = sentinelAt + 1;
157 }
158 }
159 if (start < value.length) {
160 return Boolean(visitor.onText?.(textNode, start, value.length));
161 }
162 return false;
163 }
164 if (isHTMLElement(node) || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
165 for (const child of Array.from(node.childNodes)) {
166 if (visit(child, inAnchor, anchorState)) return true;
167 }
168 }
169 return false;
170 };
171 visit(root, false, { skippedSentinel: false });
172 }
173
174 function normalizeModelText(text: string): string {
175 return text.replace(/\u00a0/g, " ");
176 }
177
178 export function modelFromDom(root: HTMLElement, known: Map<string, ComposerInvocation>): ComposerModel {
179 let text = "";
180 const invocations: ComposerInvocation[] = [];
181 walkComposerDom(root, {
182 onInvocation: (id) => {
183 const invocation = known.get(id);
184 if (invocation) invocations.push({ ...invocation, offset: text.length });
185 },
186 onText: (node, start, end) => {
187 text += (node.textContent ?? "").slice(start, end);
188 },
189 onBreak: () => {
190 text += "\n";
191 },
192 });
193 return {
194 text: normalizeModelText(text),
195 invocations: sortComposerInvocations(invocations),
196 };
197 }
198
199 function logicalLength(root: HTMLElement): number {
200 return modelFromDom(root, new Map()).text.length;
201 }
202
203 function pointFromDom(
204 root: HTMLElement,
205 known: Map<string, ComposerInvocation>,
206 node: Node,
207 offset: number,
208 ): { offset: number; afterInvocationId?: string } {
209 // Build a range [root start, point) and measure with the same walk rules.
210 // Walking a cloned fragment preserves nested structure (including caret anchors).
211 const range = document.createRange();
212 range.setStart(root, 0);
213 try {
214 range.setEnd(node, offset);
215 } catch {
216 return { offset: logicalLength(root) };
217 }
218 const fragment = range.cloneContents();
219 const shell = document.createElement("div");
220 shell.appendChild(fragment);
221 const model = modelFromDom(shell, known);
222 const lastInvocation = model.invocations[model.invocations.length - 1];
223 return {
224 offset: model.text.length,
225 afterInvocationId: lastInvocation?.offset === model.text.length ? lastInvocation.id : undefined,
226 };
227 }
228
229 export function selectionFromDom(root: HTMLElement, known: Map<string, ComposerInvocation>): DomSelectionRead {
230 const selection = document.getSelection();
231 if (
232 !selection
233 || selection.rangeCount === 0
234 || !selection.anchorNode
235 || !selection.focusNode
236 || !root.contains(selection.anchorNode)
237 || !root.contains(selection.focusNode)
238 ) {
239 return { ok: false };
240 }
241 const anchor = pointFromDom(root, known, selection.anchorNode, selection.anchorOffset);
242 const focus = pointFromDom(root, known, selection.focusNode, selection.focusOffset);
243 return {
244 ok: true,
245 selection: {
246 start: Math.min(anchor.offset, focus.offset),
247 end: Math.max(anchor.offset, focus.offset),
248 afterInvocationId: selection.isCollapsed ? focus.afterInvocationId : undefined,
249 },
250 };
251 }
252
253 function locateDomPoint(
254 root: HTMLElement,
255 targetOffset: number,
256 options: { afterInvocationId?: string } = {},
257 ): DomPoint {
258 if (options.afterInvocationId) {
259 const token = Array.from(root.querySelectorAll<HTMLElement>("[data-invocation-id]"))
260 .find((candidate) => candidate.dataset.invocationId === options.afterInvocationId);
261 if (token?.parentNode) {
262 const index = Array.prototype.indexOf.call(token.parentNode.childNodes, token);
263 return { node: token.parentNode, offset: index + 1 };
264 }
265 }
266
267 const total = logicalLength(root);
268 let remaining = Math.max(0, Math.min(targetOffset, total));
269 let found: DomPoint | null = null;
270 let lastTextPoint: DomPoint | null = null;
271
272 walkComposerDom(root, {
273 onText: (node, start, end) => {
274 const length = end - start;
275 if (remaining <= length) {
276 found = { node, offset: start + remaining };
277 return true;
278 }
279 remaining -= length;
280 lastTextPoint = { node, offset: end };
281 return false;
282 },
283 onBreak: (element) => {
284 if (remaining === 0) {
285 // Caret sits on the break boundary: place before the BR when possible.
286 if (element.parentNode) {
287 const index = Array.prototype.indexOf.call(element.parentNode.childNodes, element);
288 found = { node: element.parentNode, offset: index };
289 return true;
290 }
291 }
292 if (remaining <= 1) {
293 if (element.parentNode) {
294 const index = Array.prototype.indexOf.call(element.parentNode.childNodes, element);
295 found = { node: element.parentNode, offset: index + 1 };
296 return true;
297 }
298 }
299 remaining -= 1;
300 if (element.parentNode) {
301 const index = Array.prototype.indexOf.call(element.parentNode.childNodes, element);
302 lastTextPoint = { node: element.parentNode, offset: index + 1 };
303 }
304 return false;
305 },
306 });
307
308 if (found) return found;
309 if (lastTextPoint) return lastTextPoint;
310 return { node: root, offset: root.childNodes.length };
311 }
312
313 export function setDomSelection(root: HTMLElement, target: RichComposerSelection) {
314 const selection = document.getSelection();
315 if (!selection) return;
316
317 const total = logicalLength(root);
318 const start = Math.max(0, Math.min(target.start, total));
319 const end = Math.max(0, Math.min(target.end, total));
320 const collapsed = start === end;
321
322 if (collapsed && target.afterInvocationId) {
323 const point = locateDomPoint(root, start, { afterInvocationId: target.afterInvocationId });
324 const range = document.createRange();
325 range.setStart(point.node, point.offset);
326 range.collapse(true);
327 selection.removeAllRanges();
328 selection.addRange(range);
329 return;
330 }
331
332 const startPoint = locateDomPoint(root, start);
333 const endPoint = collapsed ? startPoint : locateDomPoint(root, end);
334 const range = document.createRange();
335 try {
336 range.setStart(startPoint.node, startPoint.offset);
337 range.setEnd(endPoint.node, endPoint.offset);
338 } catch {
339 range.selectNodeContents(root);
340 range.collapse(false);
341 }
342 selection.removeAllRanges();
343 selection.addRange(range);
344 }
345
346 /**
347 * Recover a caret using the pre-edit selection as the edit locus.
348 * Prefer this over a maximal prefix/suffix diff: repeated characters make
349 * the longest common prefix claim the whole string and push the caret to the
350 * end (e.g. insert "a" at offset 1 in "aaa" → "aaaa").
351 */
352 function selectionAnchoredCaret(
353 beforeText: string,
354 afterText: string,
355 from: number,
356 to: number,
357 ): number | null {
358 const head = beforeText.slice(0, from);
359 const tail = beforeText.slice(to);
360 if (!afterText.startsWith(head)) return null;
361 if (tail.length === 0) {
362 // Caret was at (or replaced through) the end: everything after `head` is new.
363 return afterText.length;
364 }
365 if (afterText.length < head.length + tail.length) return null;
366 if (afterText.slice(afterText.length - tail.length) !== tail) return null;
367 const middle = afterText.slice(head.length, afterText.length - tail.length);
368 if (head + middle + tail !== afterText) return null;
369 return head.length + middle.length;
370 }
371
372 export function recoverSelectionAfterEdit(
373 before: EditSnapshot,
374 afterText: string,
375 fallback: RichComposerSelection,
376 ): RichComposerSelection {
377 const clamp = (value: number) => Math.max(0, Math.min(value, afterText.length));
378 const collapsedAt = (offset: number): RichComposerSelection => {
379 const caret = clamp(offset);
380 return { start: caret, end: caret };
381 };
382
383 const { start, end } = before.selection;
384 const from = Math.max(0, Math.min(start, end, before.text.length));
385 const to = Math.max(from, Math.min(Math.max(start, end), before.text.length));
386 const inputType = before.inputType;
387 const data = before.data;
388 const hasData = data !== null && data !== undefined && data.length > 0;
389
390 const matches = (candidate: string) => candidate === afterText;
391
392 if (
393 hasData
394 && (
395 inputType === "insertText"
396 || inputType === "insertCompositionText"
397 || inputType === "insertFromPaste"
398 || inputType === "insertFromDrop"
399 || inputType === "insertReplacementText"
400 || inputType === "insertFromYank"
401 || inputType === ""
402 )
403 ) {
404 const candidate = before.text.slice(0, from) + data + before.text.slice(to);
405 if (matches(candidate)) return collapsedAt(from + data.length);
406 }
407
408 if (inputType === "insertLineBreak" || inputType === "insertParagraph") {
409 const candidate = before.text.slice(0, from) + "\n" + before.text.slice(to);
410 if (matches(candidate)) return collapsedAt(from + 1);
411 }
412
413 if (inputType === "deleteContentBackward" || inputType === "deleteByCut" || inputType === "deleteByDrag") {
414 if (from === to) {
415 const delFrom = Math.max(0, from - 1);
416 const candidate = before.text.slice(0, delFrom) + before.text.slice(from);
417 if (matches(candidate)) return collapsedAt(delFrom);
418 } else {
419 const candidate = before.text.slice(0, from) + before.text.slice(to);
420 if (matches(candidate)) return collapsedAt(from);
421 }
422 }
423
424 if (inputType === "deleteContentForward" || inputType === "deleteContent") {
425 if (from === to) {
426 const candidate = before.text.slice(0, from) + before.text.slice(Math.min(before.text.length, from + 1));
427 if (matches(candidate)) return collapsedAt(from);
428 } else {
429 const candidate = before.text.slice(0, from) + before.text.slice(to);
430 if (matches(candidate)) return collapsedAt(from);
431 }
432 }
433
434 // Selection-anchored reconstruction: works for data=null replacement /
435 // dictation / composition commits and for repeated-character inserts where a
436 // pure prefix/suffix diff would jump to the end.
437 const anchored = selectionAnchoredCaret(before.text, afterText, from, to);
438 if (anchored !== null) return collapsedAt(anchored);
439
440 // Collapsed delete without a reliable inputType (some WebView paths).
441 if (from === to && afterText.length < before.text.length) {
442 const delCount = before.text.length - afterText.length;
443 const delFrom = Math.max(0, from - delCount);
444 if (before.text.slice(0, delFrom) + before.text.slice(from) === afterText) {
445 return collapsedAt(delFrom);
446 }
447 if (before.text.slice(0, from) + before.text.slice(from + delCount) === afterText) {
448 return collapsedAt(from);
449 }
450 }
451
452 // Last-resort prefix/suffix diff. Prefer selectionAnchoredCaret above for
453 // repeated-character inserts; this path is for edits that cannot be explained
454 // as a single splice at the pre-edit selection.
455 let prefix = 0;
456 const minLen = Math.min(before.text.length, afterText.length);
457 while (prefix < minLen && before.text.charCodeAt(prefix) === afterText.charCodeAt(prefix)) {
458 prefix += 1;
459 }
460 let suffix = 0;
461 while (
462 suffix < before.text.length - prefix
463 && suffix < afterText.length - prefix
464 && before.text.charCodeAt(before.text.length - 1 - suffix) === afterText.charCodeAt(afterText.length - 1 - suffix)
465 ) {
466 suffix += 1;
467 }
468 if (prefix + suffix <= Math.max(before.text.length, afterText.length)) {
469 return collapsedAt(afterText.length - suffix);
470 }
471
472 return collapsedAt(fallback.end);
473 }
474
475 export function slashQueryAt(text: string, selection: RichComposerSelection): RichSlashQuery | null {
476 if (selection.start !== selection.end) return null;
477 const before = text.slice(0, selection.start);
478 if (activeRefTokenRe.test(before)) return null;
479 const match = /\/([A-Za-z0-9_.:-]*)$/.exec(before);
480 if (!match) return null;
481 const slashOffset = before.length - match[1].length - 1;
482 let tokenEnd = selection.start;
483 while (tokenEnd < text.length && /[A-Za-z0-9_.:-]/.test(text[tokenEnd])) tokenEnd += 1;
484 return { from: slashOffset, to: tokenEnd, query: match[1].toLowerCase() };
485 }
486
487
488 export const RichComposerInput = forwardRef<RichComposerInputHandle, {
489 text: string;
490 invocations: ComposerInvocation[];
491 placeholder: string;
492 disabled: boolean;
493 style?: CSSProperties;
494 onChange: (
495 text: string,
496 invocations: ComposerInvocation[],
497 origin: RichComposerChangeOrigin,
498 ) => void;
499 onSelectionChange: (selection: RichComposerSelection, slashQuery: RichSlashQuery | null) => void;
500 onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
501 onContextMenu: (event: MouseEvent<HTMLDivElement>) => void;
502 onPaste: (event: ClipboardEvent<HTMLDivElement>) => void;
503 onCompositionStart: () => void;
504 onCompositionEnd: () => void;
505 }>(({
506 text,
507 invocations,
508 placeholder,
509 disabled,
510 style,
511 onChange,
512 onSelectionChange,
513 onKeyDown,
514 onContextMenu,
515 onPaste,
516 onCompositionStart,
517 onCompositionEnd,
518 }, ref) => {
519 const rootRef = useRef<HTMLDivElement>(null);
520 const pendingSelectionRef = useRef<PendingSelection>(null);
521 // contentEditable mutates its DOM before input fires. Keep that browser-owned
522 // DOM for the matching controlled-state echo; rendering the same text again
523 // would append a duplicate node because React does not own the browser's
524 // mutation. External model changes bump the root key and rebuild a clean DOM.
525 const domModelRef = useRef<ComposerModel | null>(null);
526 const renderedModelRef = useRef<RenderedComposerModel>({ text, invocations, version: 0 });
527 const incomingModel: ComposerModel = { text, invocations };
528 // The rendered snapshot intentionally lags accepted browser echoes.
529 const acceptedModelRef = useRef<ComposerModel>(incomingModel);
530 if (sameComposerModel(incomingModel, domModelRef.current)) {
531 acceptedModelRef.current = incomingModel;
532 } else if (!sameComposerModel(incomingModel, acceptedModelRef.current)) {
533 renderedModelRef.current = {
534 text,
535 invocations,
536 version: renderedModelRef.current.version + 1,
537 };
538 acceptedModelRef.current = incomingModel;
539 domModelRef.current = null;
540 }
541 const renderedModel = renderedModelRef.current;
542 // True between compositionstart and compositionend. While an IME is
543 // composing, the browser owns the DOM text node and the selection: syncing
544 // the controlled model (a re-render patches the composing text node) or
545 // restoring the selection (removeAllRanges/addRange) cancels or commits the
546 // composition mid-word, so every model→DOM and DOM→model path below stays
547 // silent until compositionend performs one authoritative resync.
548 const composingRef = useRef(false);
549 const lastValidSelectionRef = useRef<RichComposerSelection>({ start: 0, end: 0 });
550 const beforeInputRef = useRef<EditSnapshot | null>(null);
551 const compositionFinalizePendingRef = useRef(false);
552 const compositionFinalizeFrameRef = useRef<number | null>(null);
553 const known = useMemo(() => new Map(invocations.map((invocation) => [invocation.id, invocation])), [invocations]);
554 const ordered = useMemo(() => sortComposerInvocations(invocations), [invocations]);
555
556 const readSelection = (root: HTMLElement): RichComposerSelection => {
557 const read = selectionFromDom(root, known);
558 if (read.ok) {
559 lastValidSelectionRef.current = read.selection;
560 return read.selection;
561 }
562 return lastValidSelectionRef.current;
563 };
564
565 const reportSelection = () => {
566 if (composingRef.current) return;
567 const root = rootRef.current;
568 if (!root) return;
569 const selection = readSelection(root);
570 const liveText = modelFromDom(root, known).text;
571 // A real selection event supersedes any browser-echo caret restoration
572 // that has not reached the layout effect yet.
573 if (pendingSelectionRef.current) pendingSelectionRef.current = selection;
574 // A keyup can arrive before React has echoed the preceding browser input
575 // back through props. Read the live DOM so slash completion sees the
576 // just-typed token instead of one render behind.
577 onSelectionChange(selection, slashQueryAt(liveText, selection));
578 };
579
580 const replaceRange = (value: string, start: number, end: number) => {
581 const next = replaceInvocationTextRange(text, invocations, start, end, value);
582 const afterSelection = { start: start + value.length, end: start + value.length };
583 pendingSelectionRef.current = afterSelection;
584 onChange(next.text, next.invocations, {
585 source: "programmatic",
586 beforeSelection: { start, end },
587 afterSelection,
588 });
589 };
590
591 useImperativeHandle(ref, () => ({
592 focus: () => rootRef.current?.focus(),
593 getSelection: () => {
594 const root = rootRef.current;
595 if (!root) return { start: text.length, end: text.length };
596 return readSelection(root);
597 },
598 setSelectionRange: (start, end = start, afterInvocationId) => {
599 const target = { start, end, afterInvocationId };
600 pendingSelectionRef.current = target;
601 requestAnimationFrame(() => {
602 const pending = pendingSelectionRef.current;
603 if (
604 !pending
605 || pending.start !== target.start
606 || pending.end !== target.end
607 || pending.afterInvocationId !== target.afterInvocationId
608 ) {
609 return;
610 }
611 const root = rootRef.current;
612 if (!root) return;
613 root.focus();
614 setDomSelection(root, target);
615 lastValidSelectionRef.current = target;
616 reportSelection();
617 });
618 },
619 replaceRange,
620 insertInvocation: (command, query) => {
621 const next = replaceInvocationTextRange(text, invocations, query.from, query.to, "");
622 const id = newComposerInvocationID(invocations);
623 const invocation: ComposerInvocation = { id, offset: query.from, command };
624 const afterSelection = { start: query.from, end: query.from, afterInvocationId: id };
625 pendingSelectionRef.current = afterSelection;
626 onChange(next.text, sortComposerInvocations([...next.invocations, invocation]), {
627 source: "programmatic",
628 beforeSelection: { start: query.from, end: query.to },
629 afterSelection,
630 });
631 },
632 scrollHeight: () => rootRef.current?.scrollHeight ?? 0,
633 }), [invocations, known, text]);
634
635 useLayoutEffect(() => {
636 if (composingRef.current) return;
637 const pending = pendingSelectionRef.current;
638 const root = rootRef.current;
639 if (!pending || !root) return;
640 pendingSelectionRef.current = null;
641 // Only restore an explicit pending caret when this editor owns focus, or
642 // when nothing else is focused. External draft replacements must not steal
643 // focus from other controls.
644 const active = document.activeElement;
645 const ownsFocus = !active || active === document.body || root === active || root.contains(active);
646 if (ownsFocus) {
647 root.focus();
648 setDomSelection(root, pending);
649 lastValidSelectionRef.current = {
650 start: pending.start,
651 end: pending.end,
652 afterInvocationId: pending.afterInvocationId,
653 };
654 reportSelection();
655 } else {
656 lastValidSelectionRef.current = {
657 start: pending.start,
658 end: pending.end,
659 afterInvocationId: pending.afterInvocationId,
660 };
661 }
662 }, [ordered, text]);
663
664 // Selection is reported before onChange so the parent sees the fresh caret
665 // when handling the model change — it matters when a change empties the
666 // invocation list and the parent must hand focus/caret to the plain
667 // textarea that replaces this component.
668 const syncFromDom = () => {
669 const root = rootRef.current;
670 if (!root) return;
671 const next = modelFromDom(root, known);
672 const live = selectionFromDom(root, known);
673 const snapshot = beforeInputRef.current;
674 let selection: RichComposerSelection;
675
676 const recoverFromSnapshot = (): RichComposerSelection | null => {
677 if (!snapshot || snapshot.text === next.text) return null;
678 return recoverSelectionAfterEdit(snapshot, next.text, lastValidSelectionRef.current);
679 };
680
681 // WebView2 sometimes keeps a live selection but parks it at the end after an
682 // edit that started mid-text (or drops selection entirely). Prefer the
683 // beforeinput / compositionstart snapshot whenever the live caret is not a
684 // plausible post-edit position.
685 if (live.ok) {
686 selection = live.selection;
687 const recovered = recoverFromSnapshot();
688 if (recovered) {
689 const liveCollapsed = live.selection.start === live.selection.end;
690 const liveAtEnd = liveCollapsed && live.selection.start === next.text.length;
691 const preCollapsed = snapshot!.selection.start === snapshot!.selection.end;
692 const preAtEnd = preCollapsed && snapshot!.selection.start === snapshot!.text.length;
693 const recoveredDiffers = recovered.start !== live.selection.start
694 || recovered.end !== live.selection.end;
695 if (liveAtEnd && !preAtEnd && recoveredDiffers) {
696 selection = recovered;
697 setDomSelection(root, selection);
698 }
699 }
700 lastValidSelectionRef.current = selection;
701 } else if (snapshot) {
702 selection = recoverFromSnapshot() ?? {
703 start: Math.min(snapshot.selection.start, next.text.length),
704 end: Math.min(snapshot.selection.end, next.text.length),
705 afterInvocationId: snapshot.selection.afterInvocationId,
706 };
707 lastValidSelectionRef.current = selection;
708 // Re-apply so the visible caret matches the recovered model offset.
709 setDomSelection(root, selection);
710 } else {
711 selection = {
712 start: Math.min(lastValidSelectionRef.current.start, next.text.length),
713 end: Math.min(lastValidSelectionRef.current.end, next.text.length),
714 afterInvocationId: lastValidSelectionRef.current.afterInvocationId,
715 };
716 lastValidSelectionRef.current = selection;
717 }
718 beforeInputRef.current = null;
719 domModelRef.current = next;
720 pendingSelectionRef.current = selection;
721 onSelectionChange(selection, slashQueryAt(next.text, selection));
722 onChange(next.text, next.invocations, {
723 source: "browser",
724 inputType: snapshot?.inputType,
725 beforeSelection: snapshot?.selection ?? lastValidSelectionRef.current,
726 afterSelection: selection,
727 });
728 };
729
730 const cancelCompositionFinalize = () => {
731 compositionFinalizePendingRef.current = false;
732 if (compositionFinalizeFrameRef.current !== null) {
733 cancelAnimationFrame(compositionFinalizeFrameRef.current);
734 compositionFinalizeFrameRef.current = null;
735 }
736 };
737
738 const onInput = (event: FormEvent<HTMLDivElement>) => {
739 // Chromium variants disagree about final IME event order. Some fire the
740 // commit input before compositionend with isComposing=true; WebView2 may
741 // fire compositionend first and expose the committed DOM in a following
742 // non-composing input. In the latter case this input owns the resync and
743 // cancels the deferred compositionend fallback.
744 if (composingRef.current || (event.nativeEvent as InputEvent).isComposing) return;
745 cancelCompositionFinalize();
746 syncFromDom();
747 };
748
749 // Composition tracking uses native listeners rather than React's synthetic
750 // onCompositionStart/onCompositionEnd: React's composition plugin decides at
751 // module load whether CompositionEvent exists and otherwise synthesizes from
752 // key events, and the IME guard must not depend on that fallback. The ref
753 // indirection keeps the mount-once listeners reading the current props and
754 // model instead of a stale first-render closure.
755 const compositionHandlersRef = useRef({ start: () => {}, end: () => {} });
756 compositionHandlersRef.current = {
757 start: () => {
758 cancelCompositionFinalize();
759 // Freeze the pre-composition model and caret. Intermediate beforeinput
760 // events are ignored while composing (provisional DOM text would poison
761 // the snapshot), so compositionend blackout recovery depends on this.
762 const root = rootRef.current;
763 if (root) {
764 const live = selectionFromDom(root, known);
765 const selection = live.ok ? live.selection : lastValidSelectionRef.current;
766 if (live.ok) lastValidSelectionRef.current = live.selection;
767 const model = modelFromDom(root, known);
768 beforeInputRef.current = {
769 text: model.text,
770 selection,
771 inputType: "insertCompositionText",
772 data: null,
773 };
774 }
775 composingRef.current = true;
776 onCompositionStart();
777 },
778 end: () => {
779 composingRef.current = false;
780 onCompositionEnd();
781 const root = rootRef.current;
782 const snapshot = beforeInputRef.current;
783 // Most browsers expose the committed DOM by compositionend. Preserve the
784 // synchronous path in that case so submit/read-after-composition observes
785 // the final text immediately. Windows WebView2 can instead dispatch
786 // compositionend while the DOM still matches the pre-composition snapshot;
787 // only that blackout needs to wait for the following non-composing input
788 // (or the next-frame fallback).
789 if (!root || !snapshot || modelFromDom(root, known).text !== snapshot.text) {
790 cancelCompositionFinalize();
791 syncFromDom();
792 return;
793 }
794 cancelCompositionFinalize();
795 compositionFinalizePendingRef.current = true;
796 compositionFinalizeFrameRef.current = requestAnimationFrame(() => {
797 compositionFinalizeFrameRef.current = null;
798 if (!compositionFinalizePendingRef.current) return;
799 compositionFinalizePendingRef.current = false;
800 syncFromDom();
801 });
802 },
803 };
804 useLayoutEffect(() => () => {
805 cancelCompositionFinalize();
806 }, []);
807 useLayoutEffect(() => {
808 const root = rootRef.current;
809 if (!root) return;
810 const start = () => compositionHandlersRef.current.start();
811 const end = () => compositionHandlersRef.current.end();
812 const onBeforeInput = (event: Event) => {
813 // Keep the compositionstart baseline intact. Provisional composition DOM
814 // must not replace the snapshot used when compositionend has no selection.
815 if (composingRef.current) return;
816 const inputEvent = event as InputEvent;
817 const live = selectionFromDom(root, known);
818 const selection = live.ok ? live.selection : lastValidSelectionRef.current;
819 if (live.ok) lastValidSelectionRef.current = live.selection;
820 const model = modelFromDom(root, known);
821 beforeInputRef.current = {
822 text: model.text,
823 selection,
824 inputType: inputEvent.inputType || "",
825 data: inputEvent.data ?? null,
826 };
827 };
828 root.addEventListener("compositionstart", start);
829 root.addEventListener("compositionend", end);
830 root.addEventListener("beforeinput", onBeforeInput);
831 return () => {
832 root.removeEventListener("compositionstart", start);
833 root.removeEventListener("compositionend", end);
834 root.removeEventListener("beforeinput", onBeforeInput);
835 };
836 }, [known, renderedModel.version]);
837
838 const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
839 if (event.key === "Backspace" && !event.nativeEvent.isComposing) {
840 const root = rootRef.current;
841 if (root) {
842 const selection = readSelection(root);
843 if (selection.start === selection.end && selection.afterInvocationId) {
844 const target = known.get(selection.afterInvocationId);
845 if (target && target.offset === selection.start) {
846 event.preventDefault();
847 const next = invocations.filter((invocation) => invocation.id !== target.id);
848 const afterSelection = { start: selection.start, end: selection.start };
849 pendingSelectionRef.current = afterSelection;
850 onSelectionChange(afterSelection, null);
851 onChange(text, next, {
852 source: "programmatic",
853 beforeSelection: selection,
854 afterSelection,
855 });
856 return;
857 }
858 }
859 }
860 }
861 onKeyDown(event);
862 };
863
864 const renderedOrdered = sortComposerInvocations(renderedModel.invocations);
865 const children: React.ReactNode[] = [];
866 let cursor = 0;
867 renderedOrdered.forEach((item) => {
868 const offset = Math.max(cursor, Math.min(renderedModel.text.length, item.offset));
869 if (offset > cursor) children.push(renderedModel.text.slice(cursor, offset));
870 const invocation = invocationDisplayForCommand(item.command);
871 children.push(
872 <span
873 key={item.id}
874 className="composer-invocation-token"
875 contentEditable={false}
876 data-invocation-id={item.id}
877 >
878 <InvocationBadge
879 invocation={invocation}
880 kind={invocation.kind}
881 description={item.command.description}
882 onRemove={() => {
883 const current = known.get(item.id);
884 const currentOffset = current?.offset ?? offset;
885 const afterSelection = { start: currentOffset, end: currentOffset };
886 pendingSelectionRef.current = afterSelection;
887 onSelectionChange(afterSelection, null);
888 onChange(text, invocations.filter((candidate) => candidate.id !== item.id), {
889 source: "programmatic",
890 beforeSelection: lastValidSelectionRef.current,
891 afterSelection,
892 });
893 }}
894 variant="composer"
895 />
896 </span>,
897 );
898 // WebKit needs a hit-testable editable position after a non-editable token.
899 // The sentinel is stripped from the composer value by modelFromDom.
900 children.push(
901 <span
902 key={`${item.id}-caret`}
903 className="composer-invocation-caret-anchor"
904 data-composer-caret-anchor="true"
905 aria-hidden="true"
906 >
907 {CARET_SENTINEL}
908 </span>,
909 );
910 cursor = offset;
911 });
912 if (cursor < renderedModel.text.length) children.push(renderedModel.text.slice(cursor));
913
914 return (
915 <div
916 key={renderedModel.version}
917 id="composer-input"
918 ref={rootRef}
919 className="composer__rich-input"
920 contentEditable={!disabled} spellCheck={false} autoCorrect="off" autoCapitalize="off"
921 suppressContentEditableWarning
922 role="textbox"
923 aria-multiline="true"
924 aria-label={placeholder}
925 data-placeholder={placeholder}
926 data-empty={text === "" && invocations.length === 0 ? "true" : undefined}
927 style={style}
928 onInput={onInput}
929 onKeyDown={handleKeyDown}
930 onKeyUp={reportSelection}
931 onClick={reportSelection}
932 onFocus={reportSelection}
933 onContextMenu={onContextMenu}
934 onPaste={onPaste}
935 >
936 {children}
937 </div>
938 );
939 });
940
941 RichComposerInput.displayName = "RichComposerInput";
942
942 lines Plain Text