authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-07-20 07:30:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 13:30:16+02:00
logc710167bde19e665c8517b606718bfbd406f2dd1
treec160106d263ff852446d34f2ca6904a5b6c7724a
parenta135f2c2d32bcded09fca32d4147c06df90ec84d
signature Signed by PGP key B5690EEEBB952194

fix(web): paint text selection over composer chips (#4139)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>

2 files changed, 93 insertions(+), 3 deletions(-)

apps/web/src/components/ComposerPromptEditor.tsx+81-3
...@@ -28,7 +28,10 @@ import {...@@ -28,7 +28,10 @@ import {
28 KEY_ENTER_COMMAND,28 KEY_ENTER_COMMAND,
29 KEY_TAB_COMMAND,29 KEY_TAB_COMMAND,
30 COMMAND_PRIORITY_HIGH,30 COMMAND_PRIORITY_HIGH,
31 COMMAND_PRIORITY_LOW,
31 KEY_BACKSPACE_COMMAND,32 KEY_BACKSPACE_COMMAND,
33 BLUR_COMMAND,
34 FOCUS_COMMAND,
32 $getRoot,35 $getRoot,
33 HISTORY_MERGE_TAG,36 HISTORY_MERGE_TAG,
34 DecoratorNode,37 DecoratorNode,
...@@ -185,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {...@@ -185,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
185188
186 override createDOM(): HTMLElement {189 override createDOM(): HTMLElement {
187 const dom = document.createElement("span");190 const dom = document.createElement("span");
188 dom.className = "inline-flex align-middle leading-none";191 dom.className = "composer-inline-chip relative inline-flex align-middle leading-none";
189 return dom;192 return dom;
190 }193 }
191194
...@@ -323,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode<React.ReactElement> {...@@ -323,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode<React.ReactElement> {
323326
324 override createDOM(): HTMLElement {327 override createDOM(): HTMLElement {
325 const dom = document.createElement("span");328 const dom = document.createElement("span");
326 dom.className = "inline-flex align-middle leading-none";329 dom.className = "composer-inline-chip relative inline-flex align-middle leading-none";
327 return dom;330 return dom;
328 }331 }
329332
...@@ -394,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode<React.ReactElement> {...@@ -394,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode<React.ReactElement> {
394397
395 override createDOM(): HTMLElement {398 override createDOM(): HTMLElement {
396 const dom = document.createElement("span");399 const dom = document.createElement("span");
397 dom.className = "inline-flex align-middle leading-none";400 dom.className = "composer-inline-chip relative inline-flex align-middle leading-none";
398 return dom;401 return dom;
399 }402 }
400403
...@@ -1167,6 +1170,80 @@ function ComposerInlineTokenBackspacePlugin() {...@@ -1167,6 +1170,80 @@ function ComposerInlineTokenBackspacePlugin() {
1167 return null;1170 return null;
1168}1171}
11691172
1173/**
1174 * Chips render as non-editable decorators, so the browser never paints the
1175 * native text selection over them; without help, a selection spanning chips
1176 * is only visible in the slivers between them. Mirror the selection onto the
1177 * chips with a data attribute the stylesheet turns into a highlight overlay.
1178 */
1179function ComposerChipSelectionPlugin() {
1180 const [editor] = useLexicalComposerContext();
1181
1182 useEffect(() => {
1183 let selectedKeys = new Set<string>();
1184 // Lexical keeps the range selection on blur without emitting an update,
1185 // so focus is tracked separately; while blurred the native highlight is
1186 // gone and the mirrored one has to go with it.
1187 let hasFocus = editor.getRootElement() === document.activeElement;
1188
1189 const applyKeys = (nextKeys: Set<string>) => {
1190 for (const key of selectedKeys) {
1191 if (!nextKeys.has(key)) {
1192 editor.getElementByKey(key)?.removeAttribute("data-composer-chip-selected");
1193 }
1194 }
1195 for (const key of nextKeys) {
1196 editor.getElementByKey(key)?.setAttribute("data-composer-chip-selected", "true");
1197 }
1198 selectedKeys = nextKeys;
1199 };
1200
1201 const readSelectedKeys = () => {
1202 const nextKeys = new Set<string>();
1203 editor.getEditorState().read(() => {
1204 const selection = $getSelection();
1205 if ($isRangeSelection(selection) && !selection.isCollapsed()) {
1206 for (const node of selection.getNodes()) {
1207 if (node instanceof DecoratorNode) {
1208 nextKeys.add(node.getKey());
1209 }
1210 }
1211 }
1212 });
1213 return nextKeys;
1214 };
1215
1216 const unregisterUpdate = editor.registerUpdateListener(() => {
1217 applyKeys(hasFocus ? readSelectedKeys() : new Set());
1218 });
1219 const unregisterFocus = editor.registerCommand(
1220 FOCUS_COMMAND,
1221 () => {
1222 hasFocus = true;
1223 applyKeys(readSelectedKeys());
1224 return false;
1225 },
1226 COMMAND_PRIORITY_LOW,
1227 );
1228 const unregisterBlur = editor.registerCommand(
1229 BLUR_COMMAND,
1230 () => {
1231 hasFocus = false;
1232 applyKeys(new Set());
1233 return false;
1234 },
1235 COMMAND_PRIORITY_LOW,
1236 );
1237 return () => {
1238 unregisterUpdate();
1239 unregisterFocus();
1240 unregisterBlur();
1241 };
1242 }, [editor]);
1243
1244 return null;
1245}
1246
1170function ComposerInlineTokenPastePlugin() {1247function ComposerInlineTokenPastePlugin() {
1171 const [editor] = useLexicalComposerContext();1248 const [editor] = useLexicalComposerContext();
11721249
...@@ -1701,6 +1778,7 @@ function ComposerPromptEditorInner({...@@ -1701,6 +1778,7 @@ function ComposerPromptEditorInner({
1701 <ComposerInlineTokenSelectionNormalizePlugin />1778 <ComposerInlineTokenSelectionNormalizePlugin />
1702 <ComposerInlineTokenBackspacePlugin />1779 <ComposerInlineTokenBackspacePlugin />
1703 <ComposerInlineTokenPastePlugin />1780 <ComposerInlineTokenPastePlugin />
1781 <ComposerChipSelectionPlugin />
1704 <HistoryPlugin />1782 <HistoryPlugin />
1705 </div>1783 </div>
1706 </ComposerTerminalContextActionsContext>1784 </ComposerTerminalContextActionsContext>
apps/web/src/index.css+12
...@@ -1067,3 +1067,15 @@ label:has(> select#reasoning-effort) select {...@@ -1067,3 +1067,15 @@ label:has(> select#reasoning-effort) select {
1067.dark .model-picker-list::-webkit-scrollbar-thumb:hover {1067.dark .model-picker-list::-webkit-scrollbar-thumb:hover {
1068 background: rgba(255, 255, 255, 0.15);1068 background: rgba(255, 255, 255, 0.15);
1069}1069}
1070
1071/* Composer chips are non-editable decorators, so the browser skips them when
1072 painting text selection; this overlay stands in for the native highlight. */
1073.composer-inline-chip[data-composer-chip-selected]::after {
1074 content: "";
1075 position: absolute;
1076 inset: 0;
1077 border-radius: 6px;
1078 background-color: Highlight;
1079 opacity: 0.3;
1080 pointer-events: none;
1081}