| ... | @@ -21,6 +21,8 @@ import { | ... | @@ -21,6 +21,8 @@ import { |
| 21 | $createParagraphNode, | 21 | $createParagraphNode, |
| 22 | $createTextNode, | 22 | $createTextNode, |
| 23 | KEY_ARROW_DOWN_COMMAND, | 23 | KEY_ARROW_DOWN_COMMAND, |
| | 24 | KEY_ARROW_LEFT_COMMAND, |
| | 25 | KEY_ARROW_RIGHT_COMMAND, |
| 24 | KEY_ARROW_UP_COMMAND, | 26 | KEY_ARROW_UP_COMMAND, |
| 25 | KEY_ENTER_COMMAND, | 27 | KEY_ENTER_COMMAND, |
| 26 | KEY_TAB_COMMAND, | 28 | KEY_TAB_COMMAND, |
| ... | @@ -97,6 +99,8 @@ class ComposerMentionNode extends TextNode { | ... | @@ -97,6 +99,8 @@ class ComposerMentionNode extends TextNode { |
| 97 | const dom = document.createElement("span"); | 99 | const dom = document.createElement("span"); |
| 98 | dom.className = | 100 | dom.className = |
| 99 | "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"; | 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 | renderMentionChipDom(dom, this.__path); | 104 | renderMentionChipDom(dom, this.__path); |
| 101 | return dom; | 105 | return dom; |
| 102 | } | 106 | } |
| ... | @@ -106,6 +110,7 @@ class ComposerMentionNode extends TextNode { | ... | @@ -106,6 +110,7 @@ class ComposerMentionNode extends TextNode { |
| 106 | dom: HTMLElement, | 110 | dom: HTMLElement, |
| 107 | _config: EditorConfig, | 111 | _config: EditorConfig, |
| 108 | ): boolean { | 112 | ): boolean { |
| | 113 | dom.contentEditable = "false"; |
| 109 | if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { | 114 | if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) { |
| 110 | renderMentionChipDom(dom, this.__path); | 115 | renderMentionChipDom(dom, this.__path); |
| 111 | } | 116 | } |
| ... | @@ -144,18 +149,25 @@ function inferMentionPathKind(pathValue: string): "file" | "directory" { | ... | @@ -144,18 +149,25 @@ function inferMentionPathKind(pathValue: string): "file" | "directory" { |
| 144 | return "directory"; | 149 | return "directory"; |
| 145 | } | 150 | } |
| 146 | | 151 | |
| | 152 | function resolvedThemeFromDocument(): "light" | "dark" { |
| | 153 | return document.documentElement.classList.contains("dark") ? "dark" : "light"; |
| | 154 | } |
| | 155 | |
| 147 | function renderMentionChipDom(container: HTMLElement, pathValue: string): void { | 156 | function renderMentionChipDom(container: HTMLElement, pathValue: string): void { |
| 148 | container.textContent = ""; | 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 | const icon = document.createElement("img"); | 162 | const icon = document.createElement("img"); |
| 151 | icon.alt = ""; | 163 | icon.alt = ""; |
| 152 | icon.ariaHidden = "true"; | 164 | icon.ariaHidden = "true"; |
| 153 | icon.className = "size-3.5 shrink-0 opacity-85"; | 165 | icon.className = "size-3.5 shrink-0 opacity-85"; |
| 154 | icon.loading = "lazy"; | 166 | icon.loading = "lazy"; |
| 155 | icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), "dark"); | 167 | icon.src = getVscodeIconUrlForEntry(pathValue, inferMentionPathKind(pathValue), theme); |
| 156 | | 168 | |
| 157 | const label = document.createElement("span"); | 169 | const label = document.createElement("span"); |
| 158 | label.className = "truncate leading-tight"; | 170 | label.className = "truncate select-none leading-tight"; |
| 159 | label.textContent = basenameOfPath(pathValue); | 171 | label.textContent = basenameOfPath(pathValue); |
| 160 | | 172 | |
| 161 | container.append(icon, label); | 173 | container.append(icon, label); |
| ... | @@ -167,6 +179,9 @@ function clampCursor(value: string, cursor: number): number { | ... | @@ -167,6 +179,9 @@ function clampCursor(value: string, cursor: number): number { |
| 167 | } | 179 | } |
| 168 | | 180 | |
| 169 | function getComposerNodeTextLength(node: LexicalNode): number { | 181 | function getComposerNodeTextLength(node: LexicalNode): number { |
| | 182 | if (node instanceof ComposerMentionNode) { |
| | 183 | return 1; |
| | 184 | } |
| 170 | if ($isTextNode(node)) { | 185 | if ($isTextNode(node)) { |
| 171 | return node.getTextContentSize(); | 186 | return node.getTextContentSize(); |
| 172 | } | 187 | } |
| ... | @@ -199,6 +214,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb | ... | @@ -199,6 +214,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb |
| 199 | } | 214 | } |
| 200 | | 215 | |
| 201 | if ($isTextNode(node)) { | 216 | if ($isTextNode(node)) { |
| | 217 | if (node instanceof ComposerMentionNode) { |
| | 218 | return offset + (pointOffset > 0 ? 1 : 0); |
| | 219 | } |
| 202 | return offset + Math.min(pointOffset, node.getTextContentSize()); | 220 | return offset + Math.min(pointOffset, node.getTextContentSize()); |
| 203 | } | 221 | } |
| 204 | | 222 | |
| ... | @@ -224,6 +242,28 @@ function findSelectionPointAtOffset( | ... | @@ -224,6 +242,28 @@ function findSelectionPointAtOffset( |
| 224 | node: LexicalNode, | 242 | node: LexicalNode, |
| 225 | remainingRef: { value: number }, | 243 | remainingRef: { value: number }, |
| 226 | ): { key: string; offset: number; type: "text" | "element" } | null { | 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 | if ($isTextNode(node)) { | 267 | if ($isTextNode(node)) { |
| 228 | const size = node.getTextContentSize(); | 268 | const size = node.getTextContentSize(); |
| 229 | if (remainingRef.value <= size) { | 269 | if (remainingRef.value <= size) { |
| ... | @@ -279,10 +319,16 @@ function findSelectionPointAtOffset( | ... | @@ -279,10 +319,16 @@ function findSelectionPointAtOffset( |
| 279 | return null; | 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 | function $setSelectionAtComposerOffset(nextOffset: number): void { | 328 | function $setSelectionAtComposerOffset(nextOffset: number): void { |
| 283 | const root = $getRoot(); | 329 | const root = $getRoot(); |
| 284 | const rootText = root.getTextContent(); | 330 | const composerLength = $getComposerRootLength(); |
| 285 | const boundedOffset = clampCursor(rootText, nextOffset); | 331 | const boundedOffset = Math.max(0, Math.min(nextOffset, composerLength)); |
| 286 | const remainingRef = { value: boundedOffset }; | 332 | const remainingRef = { value: boundedOffset }; |
| 287 | const point = | 333 | const point = |
| 288 | findSelectionPointAtOffset(root, remainingRef) ?? { | 334 | findSelectionPointAtOffset(root, remainingRef) ?? { |
| ... | @@ -301,9 +347,10 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { | ... | @@ -301,9 +347,10 @@ function $readSelectionOffsetFromEditorState(fallback: number): number { |
| 301 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { | 347 | if (!$isRangeSelection(selection) || !selection.isCollapsed()) { |
| 302 | return fallback; | 348 | return fallback; |
| 303 | } | 349 | } |
| 304 | const rootText = $getRoot().getTextContent(); | | |
| 305 | const anchorNode = selection.anchor.getNode(); | 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 | function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { | 356 | function $appendTextWithLineBreaks(parent: ElementNode, text: string): void { |
| ... | @@ -416,6 +463,83 @@ function ComposerCommandKeyPlugin(props: { | ... | @@ -416,6 +463,83 @@ function ComposerCommandKeyPlugin(props: { |
| 416 | return null; | 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 | function ComposerMentionBackspacePlugin() { | 543 | function ComposerMentionBackspacePlugin() { |
| 420 | const [editor] = useLexicalComposerContext(); | 544 | const [editor] = useLexicalComposerContext(); |
| 421 | | 545 | |
| ... | @@ -618,6 +742,8 @@ function ComposerPromptEditorInner({ | ... | @@ -618,6 +742,8 @@ function ComposerPromptEditorInner({ |
| 618 | /> | 742 | /> |
| 619 | <OnChangePlugin onChange={handleEditorChange} /> | 743 | <OnChangePlugin onChange={handleEditorChange} /> |
| 620 | <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} /> | 744 | <ComposerCommandKeyPlugin {...(onCommandKeyDown ? { onCommandKeyDown } : {})} /> |
| | 745 | <ComposerMentionArrowPlugin /> |
| | 746 | <ComposerMentionSelectionNormalizePlugin /> |
| 621 | <ComposerMentionBackspacePlugin /> | 747 | <ComposerMentionBackspacePlugin /> |
| 622 | <HistoryPlugin /> | 748 | <HistoryPlugin /> |
| 623 | </div> | 749 | </div> |