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 {
2828 KEY_ENTER_COMMAND,
2929 KEY_TAB_COMMAND,
3030 COMMAND_PRIORITY_HIGH,
31 COMMAND_PRIORITY_LOW,
3132 KEY_BACKSPACE_COMMAND,
33 BLUR_COMMAND,
34 FOCUS_COMMAND,
3235 $getRoot,
3336 HISTORY_MERGE_TAG,
3437 DecoratorNode,
......@@ -185,7 +188,7 @@ class ComposerMentionNode extends DecoratorNode<React.ReactElement> {
185188
186189 override createDOM(): HTMLElement {
187190 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";
189192 return dom;
190193 }
191194
......@@ -323,7 +326,7 @@ class ComposerSkillNode extends DecoratorNode<React.ReactElement> {
323326
324327 override createDOM(): HTMLElement {
325328 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";
327330 return dom;
328331 }
329332
......@@ -394,7 +397,7 @@ class ComposerTerminalContextNode extends DecoratorNode<React.ReactElement> {
394397
395398 override createDOM(): HTMLElement {
396399 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";
398401 return dom;
399402 }
400403
......@@ -1167,6 +1170,80 @@ function ComposerInlineTokenBackspacePlugin() {
11671170 return null;
11681171}
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
11701247function ComposerInlineTokenPastePlugin() {
11711248 const [editor] = useLexicalComposerContext();
11721249
......@@ -1701,6 +1778,7 @@ function ComposerPromptEditorInner({
17011778 <ComposerInlineTokenSelectionNormalizePlugin />
17021779 <ComposerInlineTokenBackspacePlugin />
17031780 <ComposerInlineTokenPastePlugin />
1781 <ComposerChipSelectionPlugin />
17041782 <HistoryPlugin />
17051783 </div>
17061784 </ComposerTerminalContextActionsContext>
apps/web/src/index.css+12
......@@ -1067,3 +1067,15 @@ label:has(> select#reasoning-effort) select {
10671067.dark .model-picker-list::-webkit-scrollbar-thumb:hover {
10681068 background: rgba(255, 255, 255, 0.15);
10691069}
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}