import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; import { type ServerProviderSkill } from "@t3tools/contracts"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { $applyNodeReplacement, $createRangeSelectionFromDom, $createRangeSelection, $getSelection, $setSelection, $isElementNode, $isLineBreakNode, $isRangeSelection, $isTextNode, $createLineBreakNode, $createParagraphNode, $createTextNode, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_DOWN_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, BLUR_COMMAND, FOCUS_COMMAND, $getRoot, $getNodeByKey, HISTORY_MERGE_TAG, HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG, DecoratorNode, type ElementNode, type LexicalNode, type SerializedLexicalNode, type EditorState, type NodeKey, type Spread, } from "lexical"; import { createContext, use, useCallback, useEffect, useEffectEvent, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; import { selectionTouchesMentionBoundary, splitPromptIntoComposerSegments, } from "~/composer-editor-mentions"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, } from "~/lib/terminalContext"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME, COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, SKILL_CHIP_ICON_SVG, } from "./composerInlineChip"; import { FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; import { getTimelinePageScrollKey } from "./chat/pageScrollController"; import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { registerComposerInlineTokenPaste } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import { $consumeComposerCitationCommentRequest, $createComposerCitationNode, ComposerCitationCommentContext, ComposerCitationNode, type ComposerCitationCommentRequest, type ComposerCitationCommentTarget, } from "./ComposerCitationNode"; const COMPOSER_EDITOR_HMR_KEY = `composer-editor-${Math.random().toString(36).slice(2)}`; const SURROUND_SYMBOLS: [string, string][] = [ ["(", ")"], ["[", "]"], ["{", "}"], ["'", "'"], ['"', '"'], ["“", "”"], ["`", "`"], ["<", ">"], ["«", "»"], ["*", "*"], ["_", "_"], ]; const SURROUND_SYMBOLS_MAP = new Map(SURROUND_SYMBOLS); const BACKTICK_SURROUND_CLOSE_SYMBOL = SURROUND_SYMBOLS_MAP.get("`") ?? null; type SerializedComposerMentionNode = Spread< { path: string; source?: string; type: "composer-mention"; version: 1; }, SerializedLexicalNode >; type SerializedComposerSkillNode = Spread< { skillName: string; skillLabel?: string; skillDescription?: string; type: "composer-skill"; version: 1; }, SerializedLexicalNode >; type SerializedComposerTerminalContextNode = Spread< { context: TerminalContextDraft; type: "composer-terminal-context"; version: 1; }, SerializedLexicalNode >; const ComposerTerminalContextActionsContext = createContext<{ onRemoveTerminalContext: (contextId: string) => void; }>({ onRemoveTerminalContext: () => {}, }); function ComposerMentionDecorator(props: { path: string }) { const theme = resolvedThemeFromDocument(); const chip = ( ); return ( {props.path} ); } class ComposerMentionNode extends DecoratorNode { __path: string; __source: string; static override getType(): string { return "composer-mention"; } static override clone(node: ComposerMentionNode): ComposerMentionNode { return new ComposerMentionNode(node.__path, node.__source, node.__key); } static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode { return $createComposerMentionNode(serializedNode.path, serializedNode.source).updateFromJSON( serializedNode, ); } constructor(path: string, source = serializeComposerFileLink(path), key?: NodeKey) { super(key); this.__path = path; this.__source = source; } override exportJSON(): SerializedComposerMentionNode { return { ...super.exportJSON(), path: this.__path, source: this.__source, type: "composer-mention", version: 1, }; } override createDOM(): HTMLElement { const dom = document.createElement("span"); dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } override updateDOM(): false { return false; } override getTextContent(): string { return this.__source; } override isInline(): true { return true; } override decorate(): React.ReactElement { return ; } } function $createComposerMentionNode(path: string, source?: string): ComposerMentionNode { return $applyNodeReplacement(new ComposerMentionNode(path, source)); } function resolveSkillDescription( skill: Pick, ): string | null { const shortDescription = skill.shortDescription?.trim(); if (shortDescription) { return shortDescription; } const description = skill.description?.trim(); return description || null; } type ComposerSkillMetadata = { label: string; description: string | null; }; function skillMetadataByName( skills: ReadonlyArray, ): ReadonlyMap { return new Map( skills.map((skill) => [ skill.name, { label: formatProviderSkillDisplayName(skill), description: resolveSkillDescription(skill), }, ]), ); } function ComposerSkillDecorator(props: { skillLabel: string; skillDescription: string | null }) { const chip = ( ); if (!props.skillDescription) { return chip; } return ( {props.skillDescription} ); } class ComposerSkillNode extends DecoratorNode { __skillName: string; __skillLabel: string; __skillDescription: string | null; static override getType(): string { return "composer-skill"; } static override clone(node: ComposerSkillNode): ComposerSkillNode { return new ComposerSkillNode( node.__skillName, node.__skillLabel, node.__skillDescription, node.__key, ); } static override importJSON(serializedNode: SerializedComposerSkillNode): ComposerSkillNode { return $createComposerSkillNode( serializedNode.skillName, serializedNode.skillLabel ?? serializedNode.skillName, serializedNode.skillDescription ?? null, ).updateFromJSON(serializedNode); } constructor( skillName: string, skillLabel: string, skillDescription: string | null, key?: NodeKey, ) { super(key); const normalizedSkillName = skillName.startsWith("$") ? skillName.slice(1) : skillName; this.__skillName = normalizedSkillName; this.__skillLabel = skillLabel; this.__skillDescription = skillDescription; } override exportJSON(): SerializedComposerSkillNode { return { ...super.exportJSON(), skillName: this.__skillName, skillLabel: this.__skillLabel, ...(this.__skillDescription ? { skillDescription: this.__skillDescription } : {}), type: "composer-skill", version: 1, }; } override createDOM(): HTMLElement { const dom = document.createElement("span"); dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } override updateDOM(): false { return false; } override getTextContent(): string { return `$${this.__skillName}`; } override isInline(): true { return true; } override decorate(): React.ReactElement { return ( ); } } function $createComposerSkillNode( skillName: string, skillLabel: string, skillDescription: string | null, ): ComposerSkillNode { return $applyNodeReplacement(new ComposerSkillNode(skillName, skillLabel, skillDescription)); } function ComposerTerminalContextDecorator(props: { context: TerminalContextDraft }) { return ; } class ComposerTerminalContextNode extends DecoratorNode { __context: TerminalContextDraft; static override getType(): string { return "composer-terminal-context"; } static override clone(node: ComposerTerminalContextNode): ComposerTerminalContextNode { return new ComposerTerminalContextNode(node.__context, node.__key); } static override importJSON( serializedNode: SerializedComposerTerminalContextNode, ): ComposerTerminalContextNode { return $createComposerTerminalContextNode(serializedNode.context); } constructor(context: TerminalContextDraft, key?: NodeKey) { super(key); this.__context = context; } override exportJSON(): SerializedComposerTerminalContextNode { return { ...super.exportJSON(), context: this.__context, type: "composer-terminal-context", version: 1, }; } override createDOM(): HTMLElement { const dom = document.createElement("span"); dom.className = COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME; return dom; } override updateDOM(): false { return false; } override getTextContent(): string { return INLINE_TERMINAL_CONTEXT_PLACEHOLDER; } override isInline(): true { return true; } override decorate(): React.ReactElement { return ; } } function $createComposerTerminalContextNode( context: TerminalContextDraft, ): ComposerTerminalContextNode { return $applyNodeReplacement(new ComposerTerminalContextNode(context)); } type ComposerInlineTokenNode = | ComposerMentionNode | ComposerSkillNode | ComposerCitationNode | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { return ( candidate instanceof ComposerMentionNode || candidate instanceof ComposerSkillNode || candidate instanceof ComposerCitationNode || candidate instanceof ComposerTerminalContextNode ); } function resolvedThemeFromDocument(): "light" | "dark" { return document.documentElement.classList.contains("dark") ? "dark" : "light"; } function terminalContextSignature(contexts: ReadonlyArray): string { return contexts .map((context) => [ context.id, context.threadId, context.terminalId, context.terminalLabel, context.lineStart, context.lineEnd, context.createdAt, context.text, ].join("\u001f"), ) .join("\u001e"); } function skillSignature(skills: ReadonlyArray): string { return skills .map((skill) => [ skill.name, skill.displayName ?? "", skill.shortDescription ?? "", skill.description ?? "", skill.path, skill.scope ?? "", skill.enabled ? "1" : "0", ].join("\u001f"), ) .join("\u001e"); } function clampExpandedCursor(value: string, cursor: number): number { if (!Number.isFinite(cursor)) return value.length; return Math.max(0, Math.min(value.length, Math.floor(cursor))); } function getComposerInlineTokenTextLength(_node: ComposerInlineTokenNode): 1 { return 1; } function getComposerInlineTokenExpandedTextLength(node: ComposerInlineTokenNode): number { return node.getTextContentSize(); } function getAbsoluteOffsetForInlineTokenPoint( node: ComposerInlineTokenNode, absoluteOffset: number, pointOffset: number, ): number { return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenTextLength(node) : 0); } function getExpandedAbsoluteOffsetForInlineTokenPoint( node: ComposerInlineTokenNode, absoluteOffset: number, pointOffset: number, ): number { return absoluteOffset + (pointOffset > 0 ? getComposerInlineTokenExpandedTextLength(node) : 0); } function findSelectionPointForInlineToken( node: ComposerInlineTokenNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "element" } | null { const parent = node.getParent(); if (!parent || !$isElementNode(parent)) return null; const index = node.getIndexWithinParent(); if (remainingRef.value === 0) { return { key: parent.getKey(), offset: index, type: "element", }; } if (remainingRef.value === getComposerInlineTokenTextLength(node)) { return { key: parent.getKey(), offset: index + 1, type: "element", }; } remainingRef.value -= getComposerInlineTokenTextLength(node); return null; } function getComposerNodeTextLength(node: LexicalNode): number { if (isComposerInlineTokenNode(node)) { return getComposerInlineTokenTextLength(node); } if ($isTextNode(node)) { return node.getTextContentSize(); } if ($isLineBreakNode(node)) { return 1; } if ($isElementNode(node)) { return node.getChildren().reduce((total, child) => total + getComposerNodeTextLength(child), 0); } return 0; } function getComposerNodeExpandedTextLength(node: LexicalNode): number { if (isComposerInlineTokenNode(node)) { return getComposerInlineTokenExpandedTextLength(node); } if ($isTextNode(node)) { return node.getTextContentSize(); } if ($isLineBreakNode(node)) { return 1; } if ($isElementNode(node)) { return node .getChildren() .reduce((total, child) => total + getComposerNodeExpandedTextLength(child), 0); } return 0; } function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { let offset = 0; let current: LexicalNode | null = node; while (current) { const nextParent = current.getParent() as LexicalNode | null; if (!nextParent || !$isElementNode(nextParent)) { break; } const siblings = nextParent.getChildren(); const index = current.getIndexWithinParent(); for (let i = 0; i < index; i += 1) { const sibling = siblings[i]; if (!sibling) continue; offset += getComposerNodeTextLength(sibling); } current = nextParent; } if ($isTextNode(node)) { return offset + Math.min(pointOffset, node.getTextContentSize()); } if (isComposerInlineTokenNode(node)) { return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } if ($isLineBreakNode(node)) { return offset + Math.min(pointOffset, 1); } if ($isElementNode(node)) { const children = node.getChildren(); const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); for (let i = 0; i < clampedOffset; i += 1) { const child = children[i]; if (!child) continue; offset += getComposerNodeTextLength(child); } return offset; } return offset; } function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): number { let offset = 0; let current: LexicalNode | null = node; while (current) { const nextParent = current.getParent() as LexicalNode | null; if (!nextParent || !$isElementNode(nextParent)) { break; } const siblings = nextParent.getChildren(); const index = current.getIndexWithinParent(); for (let i = 0; i < index; i += 1) { const sibling = siblings[i]; if (!sibling) continue; offset += getComposerNodeExpandedTextLength(sibling); } current = nextParent; } if ($isTextNode(node)) { return offset + Math.min(pointOffset, node.getTextContentSize()); } if (isComposerInlineTokenNode(node)) { return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset); } if ($isLineBreakNode(node)) { return offset + Math.min(pointOffset, 1); } if ($isElementNode(node)) { const children = node.getChildren(); const clampedOffset = Math.max(0, Math.min(pointOffset, children.length)); for (let i = 0; i < clampedOffset; i += 1) { const child = children[i]; if (!child) continue; offset += getComposerNodeExpandedTextLength(child); } return offset; } return offset; } function findSelectionPointAtOffset( node: LexicalNode, remainingRef: { value: number }, ): { key: string; offset: number; type: "text" | "element" } | null { if (isComposerInlineTokenNode(node)) { return findSelectionPointForInlineToken(node, remainingRef); } if ($isTextNode(node)) { const size = node.getTextContentSize(); if (remainingRef.value <= size) { return { key: node.getKey(), offset: remainingRef.value, type: "text", }; } remainingRef.value -= size; return null; } if ($isLineBreakNode(node)) { const parent = node.getParent(); if (!parent) return null; const index = node.getIndexWithinParent(); if (remainingRef.value === 0) { return { key: parent.getKey(), offset: index, type: "element", }; } if (remainingRef.value === 1) { return { key: parent.getKey(), offset: index + 1, type: "element", }; } remainingRef.value -= 1; return null; } if ($isElementNode(node)) { const children = node.getChildren(); for (const child of children) { const point = findSelectionPointAtOffset(child, remainingRef); if (point) { return point; } } if (remainingRef.value === 0) { return { key: node.getKey(), offset: children.length, type: "element", }; } } return null; } function $getComposerRootLength(): number { const root = $getRoot(); const children = root.getChildren(); return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); } function $setSelectionAtComposerOffset(nextOffset: number): void { const root = $getRoot(); const composerLength = $getComposerRootLength(); const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); const remainingRef = { value: boundedOffset }; const point = findSelectionPointAtOffset(root, remainingRef) ?? { key: root.getKey(), offset: root.getChildren().length, type: "element" as const, }; const selection = $createRangeSelection(); selection.anchor.set(point.key, point.offset, point.type); selection.focus.set(point.key, point.offset, point.type); $setSelection(selection); } function $setSelectionRangeAtComposerOffsets(startOffset: number, endOffset: number): void { const root = $getRoot(); const composerLength = $getComposerRootLength(); const boundedStart = Math.max(0, Math.min(startOffset, composerLength)); const boundedEnd = Math.max(0, Math.min(endOffset, composerLength)); const anchorRemainingRef = { value: boundedStart }; const focusRemainingRef = { value: boundedEnd }; const anchorPoint = findSelectionPointAtOffset(root, anchorRemainingRef) ?? { key: root.getKey(), offset: root.getChildren().length, type: "element" as const, }; const focusPoint = findSelectionPointAtOffset(root, focusRemainingRef) ?? { key: root.getKey(), offset: root.getChildren().length, type: "element" as const, }; const selection = $createRangeSelection(); selection.anchor.set(anchorPoint.key, anchorPoint.offset, anchorPoint.type); selection.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type); $setSelection(selection); } function getSelectionRangeForExpandedComposerOffsets(selection: ReturnType): { start: number; end: number; } | null { if (!$isRangeSelection(selection)) { return null; } const anchorNode = selection.anchor.getNode(); const focusNode = selection.focus.getNode(); const anchorOffset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); const focusOffset = getExpandedAbsoluteOffsetForPoint(focusNode, selection.focus.offset); return { start: Math.min(anchorOffset, focusOffset), end: Math.max(anchorOffset, focusOffset), }; } function $selectionTouchesInlineToken(selection: ReturnType): boolean { if (!$isRangeSelection(selection)) { return false; } return selection.getNodes().some((node) => isComposerInlineTokenNode(node)); } function $readSelectionOffsetFromEditorState(fallback: number): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return fallback; } const anchorNode = selection.anchor.getNode(); const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); const composerLength = $getComposerRootLength(); return Math.max(0, Math.min(offset, composerLength)); } function $readExpandedSelectionOffsetFromEditorState(fallback: number): number { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return fallback; } const anchorNode = selection.anchor.getNode(); const offset = getExpandedAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); const expandedLength = $getRoot().getTextContent().length; return Math.max(0, Math.min(offset, expandedLength)); } function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { const lines = text.split("\n"); for (let index = 0; index < lines.length; index += 1) { const line = lines[index] ?? ""; if (line.length > 0) { parent.append($createTextNode(line)); } if (index < lines.length - 1) { parent.append($createLineBreakNode()); } } } function $setComposerEditorPrompt( prompt: string, terminalContexts: ReadonlyArray, skillMetadata: ReadonlyMap, ): void { const root = $getRoot(); root.clear(); const paragraph = $createParagraphNode(); root.append(paragraph); const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); for (const segment of segments) { if (segment.type === "citation") { paragraph.append($createComposerCitationNode(segment.citation, segment.source)); continue; } if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path, segment.source)); continue; } if (segment.type === "skill") { const metadata = skillMetadata.get(segment.name); paragraph.append( $createComposerSkillNode( segment.name, metadata?.label ?? formatProviderSkillDisplayName({ name: segment.name }), metadata?.description ?? null, ), ); continue; } if (segment.type === "terminal-context") { if (segment.context) { paragraph.append($createComposerTerminalContextNode(segment.context)); } continue; } $appendTextWithLineBreaks(paragraph, segment.text); } } function collectTerminalContextIds(node: LexicalNode): string[] { if (node instanceof ComposerTerminalContextNode) { return [node.__context.id]; } if ($isElementNode(node)) { return node.getChildren().flatMap((child) => collectTerminalContextIds(child)); } return []; } export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; cursor: number; expandedCursor: number; terminalContextIds: string[]; }; /** * True when a collapsed caret sits on the first ("start") or last ("end") * visual line, counting soft wraps. Prompt history only claims ArrowUp and * ArrowDown at these edges so arrows still move the caret inside multiline * text. */ isCaretOnVisualEdge: (edge: "start" | "end") => boolean; } interface ComposerPromptEditorProps { value: string; cursor: number; terminalContexts: ReadonlyArray; skills: ReadonlyArray; disabled: boolean; placeholder: string; containerClassName?: string; className?: string; placeholderClassName?: string; onRemoveTerminalContext: (contextId: string) => void; onChange: ( nextValue: string, nextCursor: number, expandedCursor: number, cursorAdjacentToMention: boolean, terminalContextIds: string[], ) => void; onVisibleSelectionChange?: () => void; onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; onPageScrollKeyDown?: (key: "PageUp" | "PageDown") => void; onPageScrollKeyUp?: (key: string) => void; onPageScrollRelease?: () => void; onCitationSubmitAndSend?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; } /** * Client rect of the line the collapsed caret is on, as seen from `edge`. * A caret at a soft-wrap boundary belongs to two visual lines and the * range reports a rect for each, so take the one farthest from the edge * under test: an ambiguous caret then never claims the key and the arrow * moves the caret as usual. A collapsed range reports zero-height rects at * some positions, so probe the adjacent character on the same side. When * the range container is the paragraph itself (an empty line, or a caret * beside an inline chip) measure the child next to the caret before * falling back to the paragraph. */ function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; if (collapsedRect) return collapsedRect; const container = range.startContainer; if (container.nodeType === Node.TEXT_NODE) { const textNode = container as Text; if (textNode.data.length === 0) return null; const probeStart = Math.max( 0, Math.min( edge === "start" ? range.startOffset : range.startOffset - 1, textNode.data.length - 1, ), ); const probeRange = document.createRange(); probeRange.setStart(textNode, probeStart); probeRange.setEnd(textNode, probeStart + 1); const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); if (probeRect) return probeRect; const boundingRect = probeRange.getBoundingClientRect(); return boundingRect.height > 0 ? boundingRect : null; } if (!(container instanceof HTMLElement)) return null; // The caret sits between the paragraph's children, which is where Lexical // puts it next to an inline chip. Measure the neighbouring child. const neighbour = container.childNodes[Math.max(0, range.startOffset - 1)] ?? container.childNodes[range.startOffset]; if (neighbour instanceof HTMLElement) { const neighbourRect = neighbour.getBoundingClientRect(); if (neighbourRect.height > 0) return neighbourRect; } else if (neighbour instanceof Text && neighbour.data.length > 0) { // Probe the character on the caret's side. A soft-wrapped text node's // first rect is its first visual line, which may not be the caret's. const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; const probeRange = document.createRange(); probeRange.setStart(neighbour, probeStart); probeRange.setEnd(neighbour, probeStart + 1); const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); if (probeRect) return probeRect; } const containerRect = container.getBoundingClientRect(); return containerRect.height > 0 ? containerRect : null; } function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent, ) => boolean; }) { const [editor] = useLexicalComposerContext(); useEffect(() => { const handleCommand = ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", event: KeyboardEvent | null, ): boolean => { if (!props.onCommandKeyDown || !event) { return false; } if (key === "Enter" && (event.isComposing || event.keyCode === 229)) { event.stopPropagation(); return true; } const handled = props.onCommandKeyDown(key, event); if (handled) { event.preventDefault(); event.stopPropagation(); } return handled; }; const unregisterArrowDown = editor.registerCommand( KEY_ARROW_DOWN_COMMAND, (event) => handleCommand("ArrowDown", event), COMMAND_PRIORITY_HIGH, ); const unregisterArrowUp = editor.registerCommand( KEY_ARROW_UP_COMMAND, (event) => handleCommand("ArrowUp", event), COMMAND_PRIORITY_HIGH, ); const unregisterEnter = editor.registerCommand( KEY_ENTER_COMMAND, (event) => handleCommand("Enter", event), COMMAND_PRIORITY_HIGH, ); const unregisterTab = editor.registerCommand( KEY_TAB_COMMAND, (event) => handleCommand("Tab", event), COMMAND_PRIORITY_HIGH, ); return () => { unregisterArrowDown(); unregisterArrowUp(); unregisterEnter(); unregisterTab(); }; }, [editor, props]); return null; } function ComposerInlineTokenArrowPlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { const unregisterLeft = editor.registerCommand( KEY_ARROW_LEFT_COMMAND, (event) => { let nextOffset: number | null = null; editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; const currentOffset = $readSelectionOffsetFromEditorState(0); if (currentOffset <= 0) return; const promptValue = $getRoot().getTextContent(); if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "left")) { return; } nextOffset = currentOffset - 1; }); if (nextOffset === null) return false; const selectionOffset = nextOffset; event?.preventDefault(); event?.stopPropagation(); editor.update(() => { $setSelectionAtComposerOffset(selectionOffset); }); return true; }, COMMAND_PRIORITY_HIGH, ); const unregisterRight = editor.registerCommand( KEY_ARROW_RIGHT_COMMAND, (event) => { let nextOffset: number | null = null; editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; const currentOffset = $readSelectionOffsetFromEditorState(0); const composerLength = $getComposerRootLength(); if (currentOffset >= composerLength) return; const promptValue = $getRoot().getTextContent(); if (!isCollapsedCursorAdjacentToInlineToken(promptValue, currentOffset, "right")) { return; } nextOffset = currentOffset + 1; }); if (nextOffset === null) return false; const selectionOffset = nextOffset; event?.preventDefault(); event?.stopPropagation(); editor.update(() => { $setSelectionAtComposerOffset(selectionOffset); }); return true; }, COMMAND_PRIORITY_HIGH, ); return () => { unregisterLeft(); unregisterRight(); }; }, [editor]); return null; } function ComposerHomeEndKeyPlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { return editor.registerCommand( KEY_DOWN_COMMAND, (event) => { if (!isMacPlatform(navigator.platform)) { return false; } if (event.key !== "Home" && event.key !== "End") { return false; } if (event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { return false; } const rootElement = editor.getRootElement(); const selection = window.getSelection(); const anchorNode = selection?.anchorNode; if (!rootElement || !selection || !anchorNode || !rootElement.contains(anchorNode)) { return false; } if (selection.rangeCount === 0 || typeof selection.modify !== "function") { return false; } event.preventDefault(); event.stopPropagation(); selection.modify( event.shiftKey ? "extend" : "move", event.key === "Home" ? "backward" : "forward", "lineboundary", ); editor.update(() => { $setSelection($createRangeSelectionFromDom(selection, editor)); }); return true; }, COMMAND_PRIORITY_HIGH, ); }, [editor]); return null; } function ComposerInlineTokenSelectionNormalizePlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { return editor.registerUpdateListener(({ editorState }) => { let afterOffset: number | null = null; editorState.read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; const anchorNode = selection.anchor.getNode(); if (!isComposerInlineTokenNode(anchorNode)) return; if (selection.anchor.offset === 0) return; const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); afterOffset = beforeOffset + 1; }); if (afterOffset !== null) { queueMicrotask(() => { editor.update(() => { $setSelectionAtComposerOffset(afterOffset!); }); }); } }); }, [editor]); return null; } function ComposerInlineTokenBackspacePlugin() { const [editor] = useLexicalComposerContext(); const { onRemoveTerminalContext } = use(ComposerTerminalContextActionsContext); useEffect(() => { return editor.registerCommand( KEY_BACKSPACE_COMMAND, (event) => { const selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) { return false; } const anchorNode = selection.anchor.getNode(); const selectionOffset = $readSelectionOffsetFromEditorState(0); const removeInlineTokenNode = (candidate: unknown): boolean => { if (!isComposerInlineTokenNode(candidate)) { return false; } const tokenStart = getAbsoluteOffsetForPoint(candidate, 0); candidate.remove(); if (candidate instanceof ComposerTerminalContextNode) { onRemoveTerminalContext(candidate.__context.id); $setSelectionAtComposerOffset(selectionOffset); } else { $setSelectionAtComposerOffset(tokenStart); } event?.preventDefault(); return true; }; if (removeInlineTokenNode(anchorNode)) { return true; } if ($isTextNode(anchorNode)) { if (selection.anchor.offset > 0) { return false; } if (removeInlineTokenNode(anchorNode.getPreviousSibling())) { return true; } const parent = anchorNode.getParent(); if ($isElementNode(parent)) { const index = anchorNode.getIndexWithinParent(); if (index > 0 && removeInlineTokenNode(parent.getChildAtIndex(index - 1))) { return true; } } return false; } if ($isElementNode(anchorNode)) { const childIndex = selection.anchor.offset - 1; if (childIndex >= 0 && removeInlineTokenNode(anchorNode.getChildAtIndex(childIndex))) { return true; } } return false; }, COMMAND_PRIORITY_HIGH, ); }, [editor, onRemoveTerminalContext]); return null; } /** * Chips render as non-editable decorators, so the browser never paints the * native text selection over them; without help, a selection spanning chips * is only visible in the slivers between them. Mirror the selection onto the * chips with a data attribute the stylesheet turns into a highlight overlay. */ function ComposerChipSelectionPlugin() { const [editor] = useLexicalComposerContext(); useEffect(() => { let selectedKeys = new Set(); // Lexical keeps the range selection on blur without emitting an update, // so focus is tracked separately; while blurred the native highlight is // gone and the mirrored one has to go with it. let hasFocus = editor.getRootElement() === document.activeElement; const applyKeys = (nextKeys: Set) => { for (const key of selectedKeys) { if (!nextKeys.has(key)) { editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected"); } } for (const key of nextKeys) { editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true"); } selectedKeys = nextKeys; }; const readSelectedKeys = () => { const nextKeys = new Set(); editor.getEditorState().read(() => { const selection = $getSelection(); if ($isRangeSelection(selection) && !selection.isCollapsed()) { for (const node of selection.getNodes()) { if (node instanceof DecoratorNode) { nextKeys.add(node.getKey()); } } } }); return nextKeys; }; const unregisterUpdate = editor.registerUpdateListener(() => { applyKeys(hasFocus ? readSelectedKeys() : new Set()); }); const unregisterFocus = editor.registerCommand( FOCUS_COMMAND, () => { hasFocus = true; applyKeys(readSelectedKeys()); return false; }, COMMAND_PRIORITY_LOW, ); const unregisterBlur = editor.registerCommand( BLUR_COMMAND, () => { hasFocus = false; applyKeys(new Set()); return false; }, COMMAND_PRIORITY_LOW, ); return () => { unregisterUpdate(); unregisterFocus(); unregisterBlur(); }; }, [editor]); return null; } function ComposerInlineTokenPastePlugin() { const [editor] = useLexicalComposerContext(); useEffect( () => registerComposerInlineTokenPaste(editor, { createMentionNode: $createComposerMentionNode, createCitationNode: $createComposerCitationNode, getExpandedAbsoluteOffsetForPoint, }), [editor], ); return null; } function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; skills: ReadonlyArray; }) { const [editor] = useLexicalComposerContext(); const terminalContextsRef = useRef(props.terminalContexts); const skillMetadataRef = useRef(skillMetadataByName(props.skills)); const pendingSurroundSelectionRef = useRef<{ value: string; expandedStart: number; expandedEnd: number; } | null>(null); const pendingDeadKeySelectionRef = useRef<{ value: string; expandedStart: number; expandedEnd: number; } | null>(null); useEffect(() => { terminalContextsRef.current = props.terminalContexts; }, [props.terminalContexts]); useEffect(() => { skillMetadataRef.current = skillMetadataByName(props.skills); }, [props.skills]); const applySurroundInsertion = useEffectEvent((inputData: string): boolean => { const surroundCloseSymbol = SURROUND_SYMBOLS_MAP.get(inputData); const pendingSurroundSelection = pendingSurroundSelectionRef.current; if (!surroundCloseSymbol) { pendingSurroundSelectionRef.current = null; return false; } let handled = false; editor.update(() => { const selectionSnapshot = pendingSurroundSelection ?? (() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || selection.isCollapsed()) { return null; } if ($selectionTouchesInlineToken(selection)) { return null; } const range = getSelectionRangeForExpandedComposerOffsets(selection); if (!range || range.start === range.end) { return null; } const value = $getRoot().getTextContent(); if (selectionTouchesMentionBoundary(value, range.start, range.end)) { return null; } return { value, expandedStart: range.start, expandedEnd: range.end, }; })(); if (!selectionSnapshot || !surroundCloseSymbol) { return; } const selectedText = selectionSnapshot.value.slice( selectionSnapshot.expandedStart, selectionSnapshot.expandedEnd, ); const nextValue = `${selectionSnapshot.value.slice(0, selectionSnapshot.expandedStart)}${inputData}${selectedText}${surroundCloseSymbol}${selectionSnapshot.value.slice(selectionSnapshot.expandedEnd)}`; $setComposerEditorPrompt(nextValue, terminalContextsRef.current, skillMetadataRef.current); const selectionStart = collapseExpandedComposerCursor( nextValue, selectionSnapshot.expandedStart, ); $setSelectionRangeAtComposerOffsets( selectionStart + inputData.length, selectionStart + inputData.length + selectedText.length, ); handled = true; pendingSurroundSelectionRef.current = null; }); return handled; }); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (pendingDeadKeySelectionRef.current) { if (event.key === "Dead" || event.key === " " || event.code === "Space") { return; } pendingDeadKeySelectionRef.current = null; } if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; } editor.getEditorState().read(() => { const selection = $getSelection(); if (!$isRangeSelection(selection) || selection.isCollapsed()) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; } if ($selectionTouchesInlineToken(selection)) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; } const range = getSelectionRangeForExpandedComposerOffsets(selection); if (!range || range.start === range.end) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; } const value = $getRoot().getTextContent(); if (selectionTouchesMentionBoundary(value, range.start, range.end)) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; } const snapshot = { value, expandedStart: range.start, expandedEnd: range.end, }; pendingSurroundSelectionRef.current = snapshot; pendingDeadKeySelectionRef.current = null; }); }; const onBeforeInput = (event: InputEvent) => { if ( event.inputType === "insertCompositionText" && event.data === "`" && BACKTICK_SURROUND_CLOSE_SYMBOL !== null && pendingSurroundSelectionRef.current ) { pendingDeadKeySelectionRef.current = pendingSurroundSelectionRef.current; return; } if (pendingDeadKeySelectionRef.current) { return; } if (event.inputType === "insertCompositionText") { return; } if (typeof event.data !== "string") { pendingSurroundSelectionRef.current = null; return; } const inputData = event.inputType === "insertText" ? event.data : null; if (!inputData || inputData.length !== 1) { pendingSurroundSelectionRef.current = null; return; } if (!applySurroundInsertion(inputData)) { return; } event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); }; const tryApplyDeadKeyBacktickSurround = (options?: { finalAttempt?: boolean }) => { queueMicrotask(() => { editor.update( () => { const pendingDeadKeySelection = pendingDeadKeySelectionRef.current; if (!pendingDeadKeySelection) { return; } const currentValue = $getRoot().getTextContent(); const backtickCloseSymbol = BACKTICK_SURROUND_CLOSE_SYMBOL; if (backtickCloseSymbol === null) { pendingDeadKeySelectionRef.current = null; return; } const expectedResolvedValue = `${pendingDeadKeySelection.value.slice(0, pendingDeadKeySelection.expandedStart)}\`${pendingDeadKeySelection.value.slice(pendingDeadKeySelection.expandedEnd)}`; if (currentValue !== expectedResolvedValue) { if (options?.finalAttempt) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; } return; } const selectedText = pendingDeadKeySelection.value.slice( pendingDeadKeySelection.expandedStart, pendingDeadKeySelection.expandedEnd, ); const replacementStart = collapseExpandedComposerCursor( currentValue, pendingDeadKeySelection.expandedStart, ); $setSelectionRangeAtComposerOffsets(replacementStart, replacementStart + 1); const replacementSelection = $getSelection(); if (!$isRangeSelection(replacementSelection)) { pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; return; } replacementSelection.insertText(`\`${selectedText}${backtickCloseSymbol}`); $setSelectionRangeAtComposerOffsets( replacementStart + 1, replacementStart + 1 + selectedText.length, ); pendingSurroundSelectionRef.current = null; pendingDeadKeySelectionRef.current = null; }, { tag: HISTORY_MERGE_TAG }, ); }); }; const onInput = (event: Event) => { const inputEvent = event as InputEvent; if ( inputEvent.inputType === "insertText" || inputEvent.inputType === "insertCompositionText" ) { tryApplyDeadKeyBacktickSurround(); } }; const onCompositionEnd = () => { tryApplyDeadKeyBacktickSurround({ finalAttempt: true }); }; let activeRootElement: HTMLElement | null = null; const unregisterRootListener = editor.registerRootListener((rootElement, prevRootElement) => { prevRootElement?.removeEventListener("keydown", onKeyDown); prevRootElement?.removeEventListener("beforeinput", onBeforeInput, true); prevRootElement?.removeEventListener("input", onInput); prevRootElement?.removeEventListener("compositionend", onCompositionEnd); rootElement?.addEventListener("keydown", onKeyDown); rootElement?.addEventListener("beforeinput", onBeforeInput, true); rootElement?.addEventListener("input", onInput); rootElement?.addEventListener("compositionend", onCompositionEnd); activeRootElement = rootElement; }); return () => { if (activeRootElement) { activeRootElement.removeEventListener("keydown", onKeyDown); activeRootElement.removeEventListener("beforeinput", onBeforeInput, true); activeRootElement.removeEventListener("input", onInput); activeRootElement.removeEventListener("compositionend", onCompositionEnd); } unregisterRootListener(); }; }, [editor]); return null; } function ComposerPromptEditorInner({ value, cursor, terminalContexts, skills, disabled, placeholder, containerClassName, className, placeholderClassName, onRemoveTerminalContext, onChange, onVisibleSelectionChange, onCommandKeyDown, onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); const onVisibleSelectionChangeRef = useRef(onVisibleSelectionChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); const initialExpandedCursor = expandCollapsedComposerCursor(value, initialCursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); const skillsSignature = skillSignature(skills); const skillsSignatureRef = useRef(skillsSignature); const skillMetadataRef = useRef(skillMetadataByName(skills)); const snapshotRef = useRef({ value, cursor: initialCursor, expandedCursor: initialExpandedCursor, terminalContextIds: terminalContexts.map((context) => context.id), }); const selectionRangeRef = useRef({ start: initialExpandedCursor, end: initialExpandedCursor }); const isApplyingControlledUpdateRef = useRef(false); const citationCommentRequestRef = useRef(null); const [openCitationComment, setOpenCitationComment] = useState(null); const citationCommentActions = useMemo( () => ({ openComment: openCitationComment, onOpenChange: (nodeKey: NodeKey, open: boolean) => { setOpenCitationComment((current) => open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, ); }, onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), }), [onCitationSubmitAndSend, openCitationComment], ); const terminalContextActions = useMemo( () => ({ onRemoveTerminalContext }), [onRemoveTerminalContext], ); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { onVisibleSelectionChangeRef.current = onVisibleSelectionChange; }, [onVisibleSelectionChange]); useLayoutEffect(() => { skillMetadataRef.current = skillMetadataByName(skills); }, [skills]); useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); useEffect(() => { const openCitationNodeKey = openCitationComment?.nodeKey; if (!openCitationNodeKey) return; return editor.registerUpdateListener(({ editorState }) => { const isAttached = editorState.read(() => { const node = $getNodeByKey(openCitationNodeKey); return node instanceof ComposerCitationNode && node.isAttached(); }); if (!isAttached) { setOpenCitationComment((current) => current?.nodeKey === openCitationNodeKey ? null : current, ); } }); }, [editor, openCitationComment?.nodeKey]); useLayoutEffect(() => { const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; const contextsChanged = terminalContextsSignatureRef.current !== terminalContextsSignature; const skillsChanged = skillsSignatureRef.current !== skillsSignature; if ( previousSnapshot.value === value && previousSnapshot.cursor === normalizedCursor && !contextsChanged && !skillsChanged ) { return; } const normalizedExpandedCursor = expandCollapsedComposerCursor(value, normalizedCursor); snapshotRef.current = { value, cursor: normalizedCursor, expandedCursor: normalizedExpandedCursor, terminalContextIds: terminalContexts.map((context) => context.id), }; selectionRangeRef.current = { start: normalizedExpandedCursor, end: normalizedExpandedCursor, }; terminalContextsSignatureRef.current = terminalContextsSignature; skillsSignatureRef.current = skillsSignature; const rootElement = editor.getRootElement(); const isFocused = Boolean(rootElement && document.activeElement === rootElement); if (previousSnapshot.value === value && !contextsChanged && !skillsChanged && !isFocused) { return; } isApplyingControlledUpdateRef.current = true; const isCiteInsertion = citationCommentRequestRef.current?.value === value; let citationToOpen: ComposerCitationCommentTarget | null = null; editor.update( () => { const shouldRewriteEditorState = previousSnapshot.value !== value || contextsChanged || skillsChanged; if (shouldRewriteEditorState) { $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); } if (shouldRewriteEditorState || isFocused) { $setSelectionAtComposerOffset(normalizedCursor); } citationToOpen = $consumeComposerCitationCommentRequest(citationCommentRequestRef); }, { ...(isCiteInsertion ? { tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] } : {}), onUpdate: () => { if (citationToOpen) setOpenCitationComment(citationToOpen); }, }, ); queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); }, [cursor, editor, skillsSignature, terminalContexts, terminalContextsSignature, value]); const focusAt = useCallback( (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); rootElement.focus({ preventScroll: true }); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); snapshotRef.current = { value: snapshotRef.current.value, cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, }; selectionRangeRef.current = { start: snapshotRef.current.expandedCursor, end: snapshotRef.current.expandedCursor, }; onChangeRef.current( snapshotRef.current.value, boundedCursor, snapshotRef.current.expandedCursor, false, snapshotRef.current.terminalContextIds, ); }, [editor], ); const readSnapshot = useCallback((): { value: string; cursor: number; expandedCursor: number; terminalContextIds: string[]; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { const nextValue = $getRoot().getTextContent(); const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, snapshotRef.current.expandedCursor, ); const nextExpandedCursor = clampExpandedCursor( nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const selectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); const terminalContextIds = collectTerminalContextIds($getRoot()); snapshot = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, }; selectionRangeRef.current = selectionRange ?? { start: nextExpandedCursor, end: nextExpandedCursor, }; }); snapshotRef.current = snapshot; return snapshot; }, [editor]); useImperativeHandle( editorRef, () => ({ focus: () => { focusAt(snapshotRef.current.cursor); }, focusAt, focusAtEnd: () => { focusAt( collapseExpandedComposerCursor( snapshotRef.current.value, snapshotRef.current.value.length, ), ); }, requestCitationComment: (request) => { citationCommentRequestRef.current = request; const target = editor .getEditorState() .read(() => $consumeComposerCitationCommentRequest(citationCommentRequestRef)); if (target) setOpenCitationComment(target); }, readSnapshot, isCaretOnVisualEdge: (edge) => { const snapshot = readSnapshot(); if (snapshot.value.length === 0) return true; const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); const afterCaret = snapshot.value.slice(snapshot.expandedCursor); if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { return false; } const rootElement = editor.getRootElement(); const selection = window.getSelection(); if ( !rootElement || !selection || !selection.isCollapsed || selection.rangeCount === 0 || !selection.anchorNode || !rootElement.contains(selection.anchorNode) ) { return false; } const caretRect = caretLineRect(selection.getRangeAt(0), edge); if (!caretRect) return false; const edgeElement = edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); const threshold = caretRect.height / 2; return edge === "start" ? caretRect.top - edgeRect.top < threshold : edgeRect.bottom - caretRect.bottom < threshold; }, }), [editor, focusAt, readSnapshot], ); const handleEditorChange = useCallback((editorState: EditorState) => { editorState.read(() => { const nextValue = $getRoot().getTextContent(); const fallbackCursor = clampCollapsedComposerCursor(nextValue, snapshotRef.current.cursor); const nextCursor = clampCollapsedComposerCursor( nextValue, $readSelectionOffsetFromEditorState(fallbackCursor), ); const fallbackExpandedCursor = clampExpandedCursor( nextValue, snapshotRef.current.expandedCursor, ); const nextExpandedCursor = clampExpandedCursor( nextValue, $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const nextSelectionRange = getSelectionRangeForExpandedComposerOffsets($getSelection()); const previousSelectionRange = selectionRangeRef.current; selectionRangeRef.current = nextSelectionRange ?? { start: nextExpandedCursor, end: nextExpandedCursor, }; const terminalContextIds = collectTerminalContextIds($getRoot()); const previousSnapshot = snapshotRef.current; const snapshotChanged = !( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && previousSnapshot.expandedCursor === nextExpandedCursor && previousSnapshot.terminalContextIds.length === terminalContextIds.length && previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) ); if (isApplyingControlledUpdateRef.current) { return; } if (!snapshotChanged) { if (didComposerSelectionChangeVisibly(previousSelectionRange, nextSelectionRange)) { onVisibleSelectionChangeRef.current?.(); } return; } snapshotRef.current = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); onChangeRef.current( nextValue, nextCursor, nextExpandedCursor, cursorAdjacentToMention, terminalContextIds, ); }); }, []); return (
} onKeyDown={(event) => { if ( event.key === "Control" || event.key === "Meta" || event.key === "Alt" || event.key === "Shift" ) { onPageScrollRelease?.(); } if (event.key !== "PageUp" && event.key !== "PageDown") { return; } const pageScrollKey = getTimelinePageScrollKey({ altKey: event.altKey, clientHeight: event.currentTarget.clientHeight, ctrlKey: event.ctrlKey, defaultPrevented: event.defaultPrevented, isComposing: event.nativeEvent.isComposing, key: event.key, keyCode: event.keyCode, metaKey: event.metaKey, scrollHeight: event.currentTarget.scrollHeight, scrollTop: event.currentTarget.scrollTop, shiftKey: event.shiftKey, }); if (!pageScrollKey) { onPageScrollRelease?.(); return; } if (!onPageScrollKeyDown) { return; } event.preventDefault(); onPageScrollKeyDown(pageScrollKey); }} onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} onBlur={onPageScrollRelease} onPaste={onPaste} /> } placeholder={ terminalContexts.length > 0 ? null : (
{placeholder}
) } ErrorBoundary={LexicalErrorBoundary} />
); } export function ComposerPromptEditor({ value, cursor, terminalContexts, skills, disabled, placeholder, containerClassName, className, placeholderClassName, onRemoveTerminalContext, onChange, onVisibleSelectionChange, onCommandKeyDown, onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, onCitationSubmitAndSend, onPaste, editorRef, }: ComposerPromptEditorProps) { const initialValueRef = useRef(value); const initialTerminalContextsRef = useRef(terminalContexts); const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); const initialConfig = useMemo( () => ({ namespace: "t3tools-composer-editor", editable: true, nodes: [ ComposerMentionNode, ComposerSkillNode, ComposerCitationNode, ComposerTerminalContextNode, ], editorState: () => { $setComposerEditorPrompt( initialValueRef.current, initialTerminalContextsRef.current, initialSkillMetadataRef.current, ); }, onError: (error) => { throw error; }, }), [], ); return ( ); }