Skip to content

Commit 0311e7d

Browse files
refactor(web): migrate ComposerMentionNode to DecoratorNode for tooltip support
1 parent 4559467 commit 0311e7d

1 file changed

Lines changed: 48 additions & 64 deletions

File tree

apps/web/src/components/ComposerPromptEditor.tsx

Lines changed: 48 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,8 @@ import {
3232
type ElementNode,
3333
type LexicalNode,
3434
type SerializedLexicalNode,
35-
TextNode,
36-
type EditorConfig,
3735
type EditorState,
3836
type NodeKey,
39-
type SerializedTextNode,
4037
type Spread,
4138
} from "lexical";
4239
import {
@@ -103,7 +100,7 @@ type SerializedComposerMentionNode = Spread<
103100
type: "composer-mention";
104101
version: 1;
105102
},
106-
SerializedTextNode
103+
SerializedLexicalNode
107104
>;
108105

109106
type SerializedComposerSkillNode = Spread<
@@ -132,7 +129,40 @@ const ComposerTerminalContextActionsContext = createContext<{
132129
onRemoveTerminalContext: () => {},
133130
});
134131

135-
class ComposerMentionNode extends TextNode {
132+
function ComposerMentionDecorator(props: { path: string }) {
133+
const theme = resolvedThemeFromDocument();
134+
const chip = (
135+
<span
136+
className={COMPOSER_INLINE_CHIP_CLASS_NAME}
137+
contentEditable={false}
138+
spellCheck={false}
139+
data-composer-mention-chip="true"
140+
>
141+
<img
142+
alt=""
143+
aria-hidden="true"
144+
className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME}
145+
loading="lazy"
146+
src={getVscodeIconUrlForEntry(props.path, inferEntryKindFromPath(props.path), theme)}
147+
/>
148+
<span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}>{basenameOfPath(props.path)}</span>
149+
</span>
150+
);
151+
152+
return (
153+
<Tooltip>
154+
<TooltipTrigger render={chip} />
155+
<TooltipPopup
156+
side="top"
157+
className="max-w-[30rem] whitespace-normal leading-tight wrap-anywhere"
158+
>
159+
{props.path}
160+
</TooltipPopup>
161+
</Tooltip>
162+
);
163+
}
164+
165+
class ComposerMentionNode extends DecoratorNode<ReactElement> {
136166
__path: string;
137167

138168
static override getType(): string {
@@ -144,12 +174,12 @@ class ComposerMentionNode extends TextNode {
144174
}
145175

146176
static override importJSON(serializedNode: SerializedComposerMentionNode): ComposerMentionNode {
147-
return $createComposerMentionNode(serializedNode.path);
177+
return $createComposerMentionNode(serializedNode.path).updateFromJSON(serializedNode);
148178
}
149179

150180
constructor(path: string, key?: NodeKey) {
181+
super(key);
151182
const normalizedPath = path.startsWith("@") ? path.slice(1) : path;
152-
super(`@${normalizedPath}`, key);
153183
this.__path = normalizedPath;
154184
}
155185

@@ -162,41 +192,26 @@ class ComposerMentionNode extends TextNode {
162192
};
163193
}
164194

165-
override createDOM(_config: EditorConfig): HTMLElement {
195+
override createDOM(): HTMLElement {
166196
const dom = document.createElement("span");
167-
dom.className = COMPOSER_INLINE_CHIP_CLASS_NAME;
168-
dom.contentEditable = "false";
169-
dom.setAttribute("spellcheck", "false");
170-
renderMentionChipDom(dom, this.__path);
197+
dom.className = "inline-flex align-middle leading-none";
171198
return dom;
172199
}
173200

174-
override updateDOM(
175-
prevNode: ComposerMentionNode,
176-
dom: HTMLElement,
177-
_config: EditorConfig,
178-
): boolean {
179-
dom.contentEditable = "false";
180-
if (prevNode.__text !== this.__text || prevNode.__path !== this.__path) {
181-
renderMentionChipDom(dom, this.__path);
182-
}
183-
return false;
184-
}
185-
186-
override canInsertTextBefore(): false {
201+
override updateDOM(): false {
187202
return false;
188203
}
189204

190-
override canInsertTextAfter(): false {
191-
return false;
205+
override getTextContent(): string {
206+
return `@${this.__path}`;
192207
}
193208

194-
override isTextEntity(): true {
209+
override isInline(): true {
195210
return true;
196211
}
197212

198-
override isToken(): true {
199-
return true;
213+
override decorate(): ReactElement {
214+
return <ComposerMentionDecorator path={this.__path} />;
200215
}
201216
}
202217

@@ -434,28 +449,6 @@ function resolvedThemeFromDocument(): "light" | "dark" {
434449
return document.documentElement.classList.contains("dark") ? "dark" : "light";
435450
}
436451

437-
function renderMentionChipDom(container: HTMLElement, pathValue: string): void {
438-
container.textContent = "";
439-
container.style.setProperty("user-select", "none");
440-
container.style.setProperty("-webkit-user-select", "none");
441-
container.title = pathValue;
442-
container.setAttribute("aria-label", pathValue);
443-
444-
const theme = resolvedThemeFromDocument();
445-
const icon = document.createElement("img");
446-
icon.alt = "";
447-
icon.ariaHidden = "true";
448-
icon.className = COMPOSER_INLINE_CHIP_ICON_CLASS_NAME;
449-
icon.loading = "lazy";
450-
icon.src = getVscodeIconUrlForEntry(pathValue, inferEntryKindFromPath(pathValue), theme);
451-
452-
const label = document.createElement("span");
453-
label.className = COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME;
454-
label.textContent = basenameOfPath(pathValue);
455-
456-
container.append(icon, label);
457-
}
458-
459452
function terminalContextSignature(contexts: ReadonlyArray<TerminalContextDraft>): string {
460453
return contexts
461454
.map((context) =>
@@ -597,12 +590,9 @@ function getAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: number): numb
597590
}
598591

599592
if ($isTextNode(node)) {
600-
if (node instanceof ComposerMentionNode) {
601-
return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
602-
}
603593
return offset + Math.min(pointOffset, node.getTextContentSize());
604594
}
605-
if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) {
595+
if (isComposerInlineTokenNode(node)) {
606596
return getAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
607597
}
608598

@@ -644,12 +634,9 @@ function getExpandedAbsoluteOffsetForPoint(node: LexicalNode, pointOffset: numbe
644634
}
645635

646636
if ($isTextNode(node)) {
647-
if (node instanceof ComposerMentionNode) {
648-
return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
649-
}
650637
return offset + Math.min(pointOffset, node.getTextContentSize());
651638
}
652-
if (node instanceof ComposerSkillNode || node instanceof ComposerTerminalContextNode) {
639+
if (isComposerInlineTokenNode(node)) {
653640
return getExpandedAbsoluteOffsetForInlineTokenPoint(node, offset, pointOffset);
654641
}
655642

@@ -675,10 +662,7 @@ function findSelectionPointAtOffset(
675662
node: LexicalNode,
676663
remainingRef: { value: number },
677664
): { key: string; offset: number; type: "text" | "element" } | null {
678-
if (node instanceof ComposerMentionNode || node instanceof ComposerSkillNode) {
679-
return findSelectionPointForInlineToken(node, remainingRef);
680-
}
681-
if (node instanceof ComposerTerminalContextNode) {
665+
if (isComposerInlineTokenNode(node)) {
682666
return findSelectionPointForInlineToken(node, remainingRef);
683667
}
684668

0 commit comments

Comments
 (0)