返回 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 type { CommandInfo } from "../lib/types";
21 import { InvocationBadge } from "./InvocationBadge";
22
23 export type RichComposerSelection = {
24 start: number;
25 end: number;
26 afterInvocationId?: string;
27 };
28
29 export type RichComposerChangeOrigin = {
30 source: "browser" | "programmatic";
31 inputType?: string;
32 beforeSelection: RichComposerSelection;
33 afterSelection: RichComposerSelection;
34 };
35
36 export type RichSlashQuery = {
37 from: number;
38 to: number;
39 query: string;
40 };
41
42 export type RichComposerInputHandle = {
43 focus: () => void;
44 getSelection: () => RichComposerSelection;
45 setSelectionRange: (start: number, end?: number, afterInvocationId?: string) => void;
46 replaceRange: (value: string, start: number, end: number) => void;
47 insertInvocation: (command: CommandInfo, query: RichSlashQuery) => void;
48 scrollHeight: () => number;
49 };
50
51 export type DomSelectionRead =
52 | { ok: true; selection: RichComposerSelection }
53 | { ok: false };
54
55 type PendingSelection = RichComposerSelection | null;
56
57 type ComposerModel = {
58 text: string;
59 invocations: ComposerInvocation[];
60 };
61
62 type RenderedComposerModel = ComposerModel & {
63 version: number;
64 };
65
66 type DomPoint = {
67 node: Node;
68 offset: number;
69 };
70
71 type EditSnapshot = {
72 text: string;
73 selection: RichComposerSelection;
74 inputType: string;
75 data: string | null;
76 };
77
78 const CARET_SENTINEL = "\u00A0";
79
80 function sameComposerModel(left: ComposerModel, right: ComposerModel | null): boolean {
81 if (!right || left.text !== right.text || left.invocations.length !== right.invocations.length) return false;
82 return left.invocations.every((item, index) => {
83 const candidate = right.invocations[index];
84 return item.id === candidate.id && item.offset === candidate.offset && item.command === candidate.command;
85 });
86 }
87
88 function isHTMLElement(node: Node): node is HTMLElement {
89 return node instanceof HTMLElement;
90 }
91
92 function isInvocationToken(node: Node): node is HTMLElement {
93 return isHTMLElement(node) && Boolean(node.dataset.invocationId);
94 }
95
96 function isCaretAnchor(node: Node): node is HTMLElement {
97 return isHTMLElement(node) && Boolean(node.dataset.composerCaretAnchor);
98 }
99
100 function isBreak(node: Node): node is HTMLElement {
101 return isHTMLElement(node) && node.tagName === "BR";
102 }
103
104 /**
105 * Shared DOM walk for model text, selection reads, and selection restores.
106 *
107 * Logical length rules:
108 * - invocation tokens are zero-length atoms (children ignored)
109 * - the first CARET_SENTINEL inside a caret anchor is zero-length
110 * - remaining user text in the anchor counts
111 * - <br> is one newline
112 * - ordinary / nested text uses JavaScript UTF-16 offsets
113 */
114 function walkComposerDom(
115 root: Node,
116 visitor: {
117 onInvocation?: (id: string, element: HTMLElement) => boolean | void;
118 onText?: (node: Text, start: number, end: number) => boolean | void;
119 onBreak?: (element: HTMLElement) => boolean | void;
120 },
121 ): void {
122 const visit = (node: Node, inAnchor: boolean, anchorState: { skippedSentinel: boolean }): boolean => {
123 if (isInvocationToken(node)) {
124 const id = node.dataset.invocationId;
125 if (id && visitor.onInvocation?.(id, node)) return true;
126 return false;
127 }
128 if (isCaretAnchor(node)) {
129 const state = { skippedSentinel: false };
130 for (const child of Array.from(node.childNodes)) {
131 if (visit(child, true, state)) return true;
132 }
133 return false;
134 }
135 if (isBreak(node)) {
136 return Boolean(visitor.onBreak?.(node));
137 }
138 if (node.nodeType === Node.TEXT_NODE) {
139 const textNode = node as Text;
140 const value = textNode.textContent ?? "";
141 if (!value) return false;
142 if (!inAnchor) {
143 return Boolean(visitor.onText?.(textNode, 0, value.length));
144 }
145 let start = 0;
146 if (!anchorState.skippedSentinel) {
147 const sentinelAt = value.indexOf(CARET_SENTINEL);
148 if (sentinelAt === 0) {
149 anchorState.skippedSentinel = true;
150 start = 1;
151 } else if (sentinelAt > 0) {
152 // Count text before the first sentinel, then skip the sentinel once.
153 if (visitor.onText?.(textNode, 0, sentinelAt)) return true;
154 anchorState.skippedSentinel = true;
155 start = sentinelAt + 1;
156 }
157 }
158 if (start < value.length) {
159 return Boolean(visitor.onText?.(textNode, start, value.length));
160 }
161 return false;
162 }
163 if (isHTMLElement(node) || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
164 for (const child of Array.from(node.childNodes)) {
165 if (visit(child, inAnchor, anchorState)) return true;
166 }
167 }
168 return false;
169 };
170 visit(root, false, { skippedSentinel: false });
171 }
172
173 function normalizeModelText(text: string): string {
174 return text.replace(/\u00a0/g, " ");
175 }
176
177 export function modelFromDom(root: HTMLElement, known: Map<string, ComposerInvocation>): ComposerModel {
178 let text = "";
179 const invocations: ComposerInvocation[] = [];
180 walkComposerDom(root, {
181 onInvocation: (id) => {
182 const invocation = known.get(id);
183 if (invocation) invocations.push({ ...invocation, offset: text.length });
184 },
185 onText: (node, start, end) => {
186 text += (node.textContent ?? "").slice(start, end);
187 },
188 onBreak: () => {
189 text += "\n";
190 },
191 });
192 return {
193 text: normalizeModelText(text),
194 invocations: sortComposerInvocations(invocations),
195 };
196 }
197
198 function logicalLength(root: HTMLElement): number {
199 return modelFromDom(root, new Map()).text.length;
200 }
201
202 function pointFromDom(
203 root: HTMLElement,
204 known: Map<string, ComposerInvocation>,
205 node: Node,
206 offset: number,
207 ): { offset: number; afterInvocationId?: string } {
208 // Build a range [root start, point) and measure with the same walk rules.
209 // Walking a cloned fragment preserves nested structure (including caret anchors).
210 const range = document.createRange();
211 range.setStart(root, 0);
212 try {
213 range.setEnd(node, offset);
214 } catch {
215 return { offset: logicalLength(root) };
216 }
217 const fragment = range.cloneContents();
218 const shell = document.createElement("div");
219 shell.appendChild(fragment);
220 const model = modelFromDom(shell, known);
221 const lastInvocation = model.invocations[model.invocations.length - 1];
222 return {
223 offset: model.text.length,
224 afterInvocationId: lastInvocation?.offset === model.text.length ? lastInvocation.id : undefined,
225 };
226 }
227
228 export function selectionFromDom(root: HTMLElement, known: Map<string, ComposerInvocation>): DomSelectionRead {
229 const selection = document.getSelection();
230 if (
231 !selection
232 || selection.rangeCount === 0
233 || !selection.anchorNode
234 || !selection.focusNode
235 || !root.contains(selection.anchorNode)
236 || !root.contains(selection.focusNode)
237 ) {
238 return { ok: false };
239 }
240 const anchor = pointFromDom(root, known, selection.anchorNode, selection.anchorOffset);
241 const focus = pointFromDom(root, known, selection.focusNode, selection.focusOffset);
242 return {
243 ok: true,
244 selection: {
245 start: Math.min(anchor.offset, focus.offset),
246 end: Math.max(anchor.offset, focus.offset),
247 afterInvocationId: selection.isCollapsed ? focus.afterInvocationId : undefined,
248 },
249 };
250 }
251
252 function locateDomPoint(
253 root: HTMLElement,
254 targetOffset: number,
255 options: { afterInvocationId?: string } = {},
256 ): DomPoint {
257 if (options.afterInvocationId) {
258 const token = Array.from(root.querySelectorAll<HTMLElement>("[data-invocation-id]"))
259 .find((candidate) => candidate.dataset.invocationId === options.afterInvocationId);
260 if (token?.parentNode) {
261 const index = Array.prototype.indexOf.call(token.parentNode.childNodes, token);
262 return { node: token.parentNode, offset: index + 1 };
263 }
264 }
265
266 const total = logicalLength(root);
267 let remaining = Math.max(0, Math.min(targetOffset, total));
268 let found: DomPoint | null = null;
269 let lastTextPoint: DomPoint | null = null;
270
271 walkComposerDom(root, {
272 onText: (node, start, end) => {
273 const length = end - start;
274 if (remaining <= length) {
275 found = { node, offset: start + remaining };
276 return true;
277 }
278 remaining -= length;
279 lastTextPoint = { node, offset: end };
280 return false;
281 },
282 onBreak: (element) => {
283 if (remaining === 0) {
284 // Caret sits on the break boundary: place before the BR when possible.
285 if (element.parentNode) {
286 const index = Array.prototype.indexOf.call(element.parentNode.childNodes, element);
287 found = { node: element.parentNode, offset: index };
288 return true;
289 }
290 }
291 if (remaining <= 1) {
292 if (element.parentNode) {
293 const index = Array.prototype.indexOf.call(element.parentNode.childNodes, element);
294 found = { node: element.parentNode, offset: index + 1 };
295 return true;
296 }
297 }
298 remaining -= 1;
299 if (element.parentNode) {
300 const index = Array.prototype.indexOf.call(element.parentNode.childNodes, element);
301 lastTextPoint = { node: element.parentNode, offset: index + 1 };
302 }
303 return false;
304 },
305 });
306
307 if (found) return found;
308 if (lastTextPoint) return lastTextPoint;
309 return { node: root, offset: root.childNodes.length };
310 }
311
312 export function setDomSelection(root: HTMLElement, target: RichComposerSelection) {
313 const selection = document.getSelection();
314 if (!selection) return;
315
316 const total = logicalLength(root);
317 const start = Math.max(0, Math.min(target.start, total));
318 const end = Math.max(0, Math.min(target.end, total));
319 const collapsed = start === end;
320
321 if (collapsed && target.afterInvocationId) {
322 const point = locateDomPoint(root, start, { afterInvocationId: target.afterInvocationId });
323 const range = document.createRange();
324 range.setStart(point.node, point.offset);
325 range.collapse(true);
326 selection.removeAllRanges();
327 selection.addRange(range);
328 return;
329 }
330
331 const startPoint = locateDomPoint(root, start);
332 const endPoint = collapsed ? startPoint : locateDomPoint(root, end);
333 const range = document.createRange();
334 try {
335 range.setStart(startPoint.node, startPoint.offset);
336 range.setEnd(endPoint.node, endPoint.offset);
337 } catch {
338 range.selectNodeContents(root);
339 range.collapse(false);
340 }
341 selection.removeAllRanges();
342 selection.addRange(range);
343 }
344
345 /**
346 * Recover a caret using the pre-edit selection as the edit locus.
347 * Prefer this over a maximal prefix/suffix diff: repeated characters make
348 * the longest common prefix claim the whole string and push the caret to the
349 * end (e.g. insert "a" at offset 1 in "aaa" → "aaaa").
350 */
351 function selectionAnchoredCaret(
352 beforeText: string,
353 afterText: string,
354 from: number,
355 to: number,
356 ): number | null {
357 const head = beforeText.slice(0, from);
358 const tail = beforeText.slice(to);
359 if (!afterText.startsWith(head)) return null;
360 if (tail.length === 0) {
361 // Caret was at (or replaced through) the end: everything after `head` is new.
362 return afterText.length;
363 }
364 if (afterText.length < head.length + tail.length) return null;
365 if (afterText.slice(afterText.length - tail.length) !== tail) return null;
366 const middle = afterText.slice(head.length, afterText.length - tail.length);
367 if (head + middle + tail !== afterText) return null;
368 return head.length + middle.length;
369 }
370
371 export function recoverSelectionAfterEdit(
372 before: EditSnapshot,
373 afterText: string,
374 fallback: RichComposerSelection,
375 ): RichComposerSelection {
376 const clamp = (value: number) => Math.max(0, Math.min(value, afterText.length));
377 const collapsedAt = (offset: number): RichComposerSelection => {
378 const caret = clamp(offset);
379 return { start: caret, end: caret };
380 };
381
382 const { start, end } = before.selection;
383 const from = Math.max(0, Math.min(start, end, before.text.length));
384 const to = Math.max(from, Math.min(Math.max(start, end), before.text.length));
385 const inputType = before.inputType;
386 const data = before.data;
387 const hasData = data !== null && data !== undefined && data.length > 0;
388
389 const matches = (candidate: string) => candidate === afterText;
390
391 if (
392 hasData
393 && (
394 inputType === "insertText"
395 || inputType === "insertCompositionText"
396 || inputType === "insertFromPaste"
397 || inputType === "insertFromDrop"
398 || inputType === "insertReplacementText"
399 || inputType === "insertFromYank"
400 || inputType === ""
401 )
402 ) {
403 const candidate = before.text.slice(0, from) + data + before.text.slice(to);
404 if (matches(candidate)) return collapsedAt(from + data.length);
405 }
406
407 if (inputType === "insertLineBreak" || inputType === "insertParagraph") {
408 const candidate = before.text.slice(0, from) + "\n" + before.text.slice(to);
409 if (matches(candidate)) return collapsedAt(from + 1);
410 }
411
412 if (inputType === "deleteContentBackward" || inputType === "deleteByCut" || inputType === "deleteByDrag") {
413 if (from === to) {
414 const delFrom = Math.max(0, from - 1);
415 const candidate = before.text.slice(0, delFrom) + before.text.slice(from);
416 if (matches(candidate)) return collapsedAt(delFrom);
417 } else {
418 const candidate = before.text.slice(0, from) + before.text.slice(to);
419 if (matches(candidate)) return collapsedAt(from);
420 }
421 }
422
423 if (inputType === "deleteContentForward" || inputType === "deleteContent") {
424 if (from === to) {
425 const candidate = before.text.slice(0, from) + before.text.slice(Math.min(before.text.length, from + 1));
426 if (matches(candidate)) return collapsedAt(from);
427 } else {
428 const candidate = before.text.slice(0, from) + before.text.slice(to);
429 if (matches(candidate)) return collapsedAt(from);
430 }
431 }
432
433 // Selection-anchored reconstruction: works for data=null replacement /
434 // dictation / composition commits and for repeated-character inserts where a
435 // pure prefix/suffix diff would jump to the end.
436 const anchored = selectionAnchoredCaret(before.text, afterText, from, to);
437 if (anchored !== null) return collapsedAt(anchored);
438
439 // Collapsed delete without a reliable inputType (some WebView paths).
440 if (from === to && afterText.length < before.text.length) {
441 const delCount = before.text.length - afterText.length;
442 const delFrom = Math.max(0, from - delCount);
443 if (before.text.slice(0, delFrom) + before.text.slice(from) === afterText) {
444 return collapsedAt(delFrom);
445 }
446 if (before.text.slice(0, from) + before.text.slice(from + delCount) === afterText) {
447 return collapsedAt(from);
448 }
449 }
450
451 // Last-resort prefix/suffix diff. Prefer selectionAnchoredCaret above for
452 // repeated-character inserts; this path is for edits that cannot be explained
453 // as a single splice at the pre-edit selection.
454 let prefix = 0;
455 const minLen = Math.min(before.text.length, afterText.length);
456 while (prefix < minLen && before.text.charCodeAt(prefix) === afterText.charCodeAt(prefix)) {
457 prefix += 1;
458 }
459 let suffix = 0;
460 while (
461 suffix < before.text.length - prefix
462 && suffix < afterText.length - prefix
463 && before.text.charCodeAt(before.text.length - 1 - suffix) === afterText.charCodeAt(afterText.length - 1 - suffix)
464 ) {
465 suffix += 1;
466 }
467 if (prefix + suffix <= Math.max(before.text.length, afterText.length)) {
468 return collapsedAt(afterText.length - suffix);
469 }
470
471 return collapsedAt(fallback.end);
472 }
473
474 export function slashQueryAt(text: string, selection: RichComposerSelection): RichSlashQuery | null {
475 if (selection.start !== selection.end) return null;
476 const before = text.slice(0, selection.start);
477 if (activeRefTokenRe.test(before)) return null;
478 const match = /\/([A-Za-z0-9_.:-]*)$/.exec(before);
479 if (!match) return null;
480 const slashOffset = before.length - match[1].length - 1;
481 let tokenEnd = selection.start;
482 while (tokenEnd < text.length && /[A-Za-z0-9_.:-]/.test(text[tokenEnd])) tokenEnd += 1;
483 return { from: slashOffset, to: tokenEnd, query: match[1].toLowerCase() };
484 }
485
486 let nextInvocationID = 1;
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 = `invocation-${nextInvocationID++}`;
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}
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