diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 585d46150a8..169126788ae 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -28,7 +28,10 @@ import { KEY_ENTER_COMMAND, KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, + BLUR_COMMAND, + FOCUS_COMMAND, $getRoot, HISTORY_MERGE_TAG, DecoratorNode, @@ -185,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -323,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -394,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode { override createDOM(): HTMLElement { const dom = document.createElement("span"); - dom.className = "inline-flex align-middle leading-none"; + dom.className = "composer-inline-chip relative inline-flex align-middle leading-none"; return dom; } @@ -1167,6 +1170,80 @@ function ComposerInlineTokenBackspacePlugin() { 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(); @@ -1701,6 +1778,7 @@ function ComposerPromptEditorInner({ + diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c500eb68d7f..8c8a773c58d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1031,3 +1031,15 @@ label:has(> select#reasoning-effort) select { .dark .model-picker-list::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.15); } + +/* Composer chips are non-editable decorators, so the browser skips them when + painting text selection; this overlay stands in for the native highlight. */ +.composer-inline-chip[data-composer-chip-selected]::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 6px; + background-color: Highlight; + opacity: 0.3; + pointer-events: none; +}