| ... | ... | @@ -21,6 +21,8 @@ import { |
| 21 | 21 | $createParagraphNode, |
| 22 | 22 | $createTextNode, |
| 23 | 23 | KEY_ARROW_DOWN_COMMAND, |
| 24 | KEY_ARROW_LEFT_COMMAND, |
| 25 | KEY_ARROW_RIGHT_COMMAND, |
| 24 | 26 | KEY_ARROW_UP_COMMAND, |
| 25 | 27 | KEY_ENTER_COMMAND, |
| 26 | 28 | KEY_TAB_COMMAND, |
| ... | ... | @@ -97,6 +99,8 @@ class ComposerMentionNode extends TextNode { |
| 97 | 99 | const dom = document.createElement("span"); |
| 98 | 100 | dom.className = |
| 99 | 101 | "inline-flex select-none items-center gap-1 rounded-md border border-border/70 bg-accent/40 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-foreground align-middle"; |
| 102 | dom.contentEditable = "false"; |
| 103 | dom.setAttribute("spellcheck", "false"); |
| 100 | 104 | renderMentionChipDom(dom, this.__path); |
| 101 | 105 | return dom; |
| 102 | 106 | } |
| ... | ... | @@ -106,6 +110,7 @@ class ComposerMentionNode extends TextNode { |
| 106 | 110 | dom: HTMLElement, |
| 107 | 111 | _config: EditorConfig, |
| 108 | 112 | ): boolean { |
| 113 | dom.contentEditable = "false"; |
| 109 | 114 | if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { |
| 110 | 115 | renderMentionChipDom(dom, this.__path); |
| 111 | 116 | } |
| ... | ... | @@ -144,18 +149,25 @@ function inferMentionPathKind(pathValue: string): "file" | "directory" { |
| 144 | 149 | return "directory"; |
| 145 | 150 | } |
| 146 | 151 | |
| 152 | function resolvedThemeFromDocument(): "light" | "dark" { |
| 153 | return document.documentElement.classList.contains("dark") ? "dark" : "light"; |
| 154 | } |
| 155 | |
| 147 | 156 | function renderMentionChipDom(container: HTMLElement, pathValue: string): void { |
| 148 | 157 | container.textContent = ""; |
| 158 | container.style.setProperty("user-select", "none"); |
| 159 | container.style.setProperty("-webkit-user-select", "none"); |
| 149 | 160 | |
| 161 | const theme = resolvedThemeFromDocument(); |
| 150 | 162 | const icon = document.createElement("img"); |
| 151 | 163 | icon.alt = ""; |
| 152 | 164 | icon.ariaHidden = "true"; |
| 153 | 165 | icon.className = "size-3.5 shrink-0 opacity-85"; |
| 154 | 166 | icon.loading = "lazy"; |
| 155 | | icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), "dark"); |
| 167 | icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme); |
| 156 | 168 | |
| 157 | 169 | const label = document.createElement("span"); |
| 158 | | label.className = "truncate leading-tight"; |
| 170 | label.className = "truncate select-none leading-tight"; |
| 159 | 171 | label.textContent = basenameOfPath(pathValue); |
| 160 | 172 | |
| 161 | 173 | container.append(icon, label); |
| ... | ... | @@ -167,6 +179,9 @@ function clampCursor(value: string, cursor: number): number { |
| 167 | 179 | } |
| 168 | 180 | |
| 169 | 181 | function getComposerNodeTextLength(node: LexicalNode): number { |
| 182 | if (node instanceof ComposerMentionNode) { |
| 183 | return 1; |
| 184 | } |
| 170 | 185 | if ($isTextNode(node)) { |
| 171 | 186 | return node.getTextContentSize(); |
| 172 | 187 | } |
| ... | ... | @@ -199,6 +214,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb |
| 199 | 214 | } |
| 200 | 215 | |
| 201 | 216 | if ($isTextNode(node)) { |
| 217 | if (node instanceof ComposerMentionNode) { |
| 218 | return offset + (pointOffset > 0 ? 1 : 0); |
| 219 | } |
| 202 | 220 | return offset + Math.min(pointOffset, node.getTextContentSize()); |
| 203 | 221 | } |
| 204 | 222 | |
| ... | ... | @@ -224,6 +242,28 @@ function findSelectionPointAtOffset( |
| 224 | 242 | node: LexicalNode, |
| 225 | 243 | remainingRef: { value: number }, |
| 226 | 244 | ): { key: string; offset: number; type: "text" | "element" } | null { |
| 245 | if (node instanceof ComposerMentionNode) { |
| 246 | const parent = node.getParent(); |
| 247 | if (!parent || !$isElementNode(parent)) return null; |
| 248 | const index = node.getIndexWithinParent(); |
| 249 | if (remainingRef.value === 0) { |
| 250 | return { |
| 251 | key: parent.getKey(), |
| 252 | offset: index, |
| 253 | type: "element", |
| 254 | }; |
| 255 | } |
| 256 | if (remainingRef.value === 1) { |
| 257 | return { |
| 258 | key: parent.getKey(), |
| 259 | offset: index + 1, |
| 260 | type: "element", |
| 261 | }; |
| 262 | } |
| 263 | remainingRef.value -= 1; |
| 264 | return null; |
| 265 | } |
| 266 | |
| 227 | 267 | if ($isTextNode(node)) { |
| 228 | 268 | const size = node.getTextContentSize(); |
| 229 | 269 | if (remainingRef.value <= size) { |
| ... | ... | @@ -279,10 +319,16 @@ function findSelectionPointAtOffset( |
| 279 | 319 | return null; |
| 280 | 320 | } |
| 281 | 321 | |
| 322 | function $getComposerRootLength(): number { |
| 323 | const root = $getRoot(); |
| 324 | const children = root.getChildren(); |
| 325 | return children.reduce((sum, child) => sum + getComposerNodeTextLength(child), 0); |
| 326 | } |
| 327 | |
| 282 | 328 | function $setSelectionAtComposerOffset(nextOffset: number): void { |
| 283 | 329 | const root = $getRoot(); |
| 284 | | const rootText = root.getTextContent(); |
| 285 | | const boundedOffset = clampCursor(rootText, nextOffset); |
| 330 | const composerLength = $getComposerRootLength(); |
| 331 | const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); |
| 286 | 332 | const remainingRef = { value: boundedOffset }; |
| 287 | 333 | const point = |
| 288 | 334 | findSelectionPointAtOffset(root, remainingRef) ?? { |
| ... | ... | @@ -301,9 +347,10 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { |
| 301 | 347 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { |
| 302 | 348 | return fallback; |
| 303 | 349 | } |
| 304 | | const rootText = $getRoot().getTextContent(); |
| 305 | 350 | const anchorNode = selection.anchor.getNode(); |
| 306 | | return clampCursor(rootText, getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset)); |
| 351 | const offset = getAbsoluteOffsetForPoint(anchorNode, selection.anchor.offset); |
| 352 | const composerLength = $getComposerRootLength(); |
| 353 | return Math.max(0, Math.min(offset, composerLength)); |
| 307 | 354 | } |
| 308 | 355 | |
| 309 | 356 | function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { |
| ... | ... | @@ -416,6 +463,83 @@ function ComposerCommandKeyPlugin(props: { |
| 416 | 463 | return null; |
| 417 | 464 | } |
| 418 | 465 | |
| 466 | function ComposerMentionArrowPlugin() { |
| 467 | const [editor] = useLexicalComposerContext(); |
| 468 | |
| 469 | useEffect(() => { |
| 470 | const unregisterLeft = editor.registerCommand( |
| 471 | KEY_ARROW_LEFT_COMMAND, |
| 472 | () => { |
| 473 | let currentOffset = -1; |
| 474 | editor.getEditorState().read(() => { |
| 475 | const selection = $getSelection(); |
| 476 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; |
| 477 | currentOffset = $readSelectionOffsetFromEditorState(0); |
| 478 | }); |
| 479 | if (currentOffset <= 0) return false; |
| 480 | editor.update(() => { |
| 481 | $setSelectionAtComposerOffset(currentOffset - 1); |
| 482 | }); |
| 483 | return true; |
| 484 | }, |
| 485 | COMMAND_PRIORITY_HIGH, |
| 486 | ); |
| 487 | const unregisterRight = editor.registerCommand( |
| 488 | KEY_ARROW_RIGHT_COMMAND, |
| 489 | () => { |
| 490 | let currentOffset = -1; |
| 491 | let composerLength = 0; |
| 492 | editor.getEditorState().read(() => { |
| 493 | const selection = $getSelection(); |
| 494 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; |
| 495 | composerLength = $getComposerRootLength(); |
| 496 | currentOffset = $readSelectionOffsetFromEditorState(0); |
| 497 | }); |
| 498 | if (currentOffset < 0 || currentOffset >= composerLength) return false; |
| 499 | editor.update(() => { |
| 500 | $setSelectionAtComposerOffset(currentOffset + 1); |
| 501 | }); |
| 502 | return true; |
| 503 | }, |
| 504 | COMMAND_PRIORITY_HIGH, |
| 505 | ); |
| 506 | return () => { |
| 507 | unregisterLeft(); |
| 508 | unregisterRight(); |
| 509 | }; |
| 510 | }, [editor]); |
| 511 | |
| 512 | return null; |
| 513 | } |
| 514 | |
| 515 | function ComposerMentionSelectionNormalizePlugin() { |
| 516 | const [editor] = useLexicalComposerContext(); |
| 517 | |
| 518 | useEffect(() => { |
| 519 | return editor.registerUpdateListener(({ editorState }) => { |
| 520 | let afterOffset: number | null = null; |
| 521 | editorState.read(() => { |
| 522 | const selection = $getSelection(); |
| 523 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; |
| 524 | const anchorNode = selection.anchor.getNode(); |
| 525 | if (!(anchorNode instanceof ComposerMentionNode)) return; |
| 526 | if (selection.anchor.offset === 0) return; |
| 527 | const beforeOffset = getAbsoluteOffsetForPoint(anchorNode, 0); |
| 528 | afterOffset = beforeOffset + 1; |
| 529 | }); |
| 530 | if (afterOffset !== null) { |
| 531 | queueMicrotask(() => { |
| 532 | editor.update(() => { |
| 533 | $setSelectionAtComposerOffset(afterOffset!); |
| 534 | }); |
| 535 | }); |
| 536 | } |
| 537 | }); |
| 538 | }, [editor]); |
| 539 | |
| 540 | return null; |
| 541 | } |
| 542 | |
| 419 | 543 | function ComposerMentionBackspacePlugin() { |
| 420 | 544 | const [editor] = useLexicalComposerContext(); |
| 421 | 545 | |
| ... | ... | @@ -618,6 +742,8 @@ function ComposerPromptEditorInner({ |
| 618 | 742 | /> |
| 619 | 743 | <OnChangePlugin onChange={handleEditorChange} /> |
| 620 | 744 | <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} /> |
| 745 | <ComposerMentionArrowPlugin /> |
| 746 | <ComposerMentionSelectionNormalizePlugin /> |
| 621 | 747 | <ComposerMentionBackspacePlugin /> |
| 622 | 748 | <HistoryPlugin /> |
| 623 | 749 | </div> |