Skip to content

Commit 19e7f77

Browse files
committed
feat(textarea): support parsing mentions from paste
1 parent 680ab86 commit 19e7f77

File tree

2 files changed

+251
-63
lines changed

2 files changed

+251
-63
lines changed

webview-ui/src/components/chat/ChatLexicalTextArea.tsx

Lines changed: 6 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { LexicalMentionPlugin, type MentionInfo, type LexicalMentionPluginRef }
3535
import { LexicalSelectAllPlugin } from "./lexical/LexicalSelectAllPlugin"
3636
import { LexicalPromptHistoryPlugin } from "./lexical/LexicalPromptHistoryPlugin"
3737
import { LexicalContextMenuPlugin } from "./lexical/LexicalContextMenuPlugin"
38+
import { LexicalPastePlugin } from "./lexical/LexicalPastePlugin"
3839
import ContextMenu from "./ContextMenu"
3940
import { ContextMenuOptionType, SearchResult } from "@/utils/context-mentions"
4041
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
@@ -196,68 +197,6 @@ export const ChatLexicalTextArea = forwardRef<LexicalEditor, ChatTextAreaProps>(
196197
[setSelectedImages],
197198
)
198199

199-
const handlePaste = useCallback(
200-
async (e: React.ClipboardEvent) => {
201-
const items = e.clipboardData.items
202-
const pastedText = e.clipboardData.getData("text")
203-
204-
const urlRegex = /^\S+:\/\/\S+$/
205-
if (urlRegex.test(pastedText.trim())) {
206-
e.preventDefault()
207-
// Lexical will handle inserting text, but we may need to adjust cursor
208-
// or ensure the space is added, depending on how Lexical handles pastes.
209-
// For now, let Lexical's default paste handle it.
210-
return
211-
}
212-
213-
const acceptedTypes = ["png", "jpeg", "webp"]
214-
215-
const imageItems = Array.from(items).filter((item) => {
216-
const [type, subtype] = item.type.split("/")
217-
return type === "image" && acceptedTypes.includes(subtype)
218-
})
219-
220-
if (!shouldDisableImages && imageItems.length > 0) {
221-
e.preventDefault()
222-
223-
const imagePromises = imageItems.map((item) => {
224-
return new Promise<string | null>((resolve) => {
225-
const blob = item.getAsFile()
226-
227-
if (!blob) {
228-
resolve(null)
229-
return
230-
}
231-
232-
const reader = new FileReader()
233-
234-
reader.onloadend = () => {
235-
if (reader.error) {
236-
console.error(t("chat:errorReadingFile"), reader.error)
237-
resolve(null)
238-
} else {
239-
const result = reader.result
240-
resolve(typeof result === "string" ? result : null)
241-
}
242-
}
243-
244-
reader.readAsDataURL(blob)
245-
})
246-
})
247-
248-
const imageDataArray = await Promise.all(imagePromises)
249-
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
250-
251-
if (dataUrls.length > 0) {
252-
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
253-
} else {
254-
console.warn(t("chat:noValidImages"))
255-
}
256-
}
257-
},
258-
[shouldDisableImages, setSelectedImages, t],
259-
)
260-
261200
const handleDrop = useCallback(
262201
async (e: React.DragEvent<HTMLDivElement>) => {
263202
e.preventDefault()
@@ -725,14 +664,18 @@ export const ChatLexicalTextArea = forwardRef<LexicalEditor, ChatTextAreaProps>(
725664
setIsFocused(false)
726665
setIsMouseDownOnMenu(false)
727666
}}
728-
onPaste={handlePaste}
729667
/>
730668
}
731669
ErrorBoundary={LexicalErrorBoundary}
732670
/>
733671
<OnChangePlugin onChange={handleEditorChange} />
734672
<HistoryPlugin />
735673
<AutoFocusPlugin />
674+
<LexicalPastePlugin
675+
materialIconsBaseUri={materialIconsBaseUri}
676+
shouldDisableImages={shouldDisableImages}
677+
setSelectedImages={setSelectedImages}
678+
/>
736679
<LexicalMentionPlugin
737680
ref={mentionPluginRef}
738681
onMentionTrigger={handleMentionTrigger}
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
2+
import {
3+
$getSelection,
4+
$isRangeSelection,
5+
$createTextNode,
6+
COMMAND_PRIORITY_HIGH,
7+
PASTE_COMMAND,
8+
TextNode,
9+
} from "lexical"
10+
import { useEffect } from "react"
11+
import { $createMentionNode } from "./MentionNode"
12+
import { mentionRegexGlobal, commandRegexGlobal } from "@roo/context-mentions"
13+
import { ContextMenuOptionType } from "@/utils/context-mentions"
14+
import { MAX_IMAGES_PER_MESSAGE } from "../ChatView"
15+
16+
interface LexicalPastePluginProps {
17+
materialIconsBaseUri?: string
18+
shouldDisableImages: boolean
19+
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
20+
}
21+
22+
export const LexicalPastePlugin = ({
23+
materialIconsBaseUri = "",
24+
shouldDisableImages,
25+
setSelectedImages,
26+
}: LexicalPastePluginProps) => {
27+
const [editor] = useLexicalComposerContext()
28+
29+
useEffect(() => {
30+
return editor.registerCommand(
31+
PASTE_COMMAND,
32+
(event: ClipboardEvent) => {
33+
const selection = $getSelection()
34+
35+
if (!$isRangeSelection(selection)) {
36+
return false
37+
}
38+
39+
const clipboardData = event.clipboardData
40+
if (!clipboardData) {
41+
return false
42+
}
43+
44+
const text = clipboardData.getData("text/plain")
45+
const items = clipboardData.items
46+
47+
// Handle image pasting
48+
const acceptedTypes = ["png", "jpeg", "webp"]
49+
const imageItems = Array.from(items).filter((item) => {
50+
const [type, subtype] = item.type.split("/")
51+
return type === "image" && acceptedTypes.includes(subtype)
52+
})
53+
54+
if (!shouldDisableImages && imageItems.length > 0) {
55+
event.preventDefault()
56+
57+
// Handle image paste asynchronously
58+
handleImagePaste(imageItems, setSelectedImages)
59+
return true
60+
}
61+
62+
// Handle URL pasting - let Lexical handle it normally
63+
const urlRegex = /^\S+:\/\/\S+$/
64+
if (text && urlRegex.test(text.trim())) {
65+
// Let Lexical's default paste handle URLs
66+
return false
67+
}
68+
69+
if (!text) {
70+
return false
71+
}
72+
73+
// Check if the text contains any mentions or commands
74+
const hasMentions = mentionRegexGlobal.test(text)
75+
const hasCommands = commandRegexGlobal.test(text)
76+
77+
// Reset regex lastIndex
78+
mentionRegexGlobal.lastIndex = 0
79+
commandRegexGlobal.lastIndex = 0
80+
81+
if (!hasMentions && !hasCommands) {
82+
// Let Lexical handle normal paste
83+
return false
84+
}
85+
86+
// Prevent default paste behavior
87+
event.preventDefault()
88+
89+
// Parse the text and create nodes
90+
editor.update(() => {
91+
const nodes = parseTextIntoNodes(text)
92+
93+
// Get selection again in update
94+
const currentSelection = $getSelection()
95+
if (!$isRangeSelection(currentSelection)) {
96+
return
97+
}
98+
99+
// Insert the parsed nodes
100+
for (const node of nodes) {
101+
currentSelection.insertNodes([node])
102+
}
103+
})
104+
105+
return true
106+
},
107+
COMMAND_PRIORITY_HIGH,
108+
)
109+
}, [editor, materialIconsBaseUri, shouldDisableImages, setSelectedImages])
110+
111+
return null
112+
}
113+
114+
/**
115+
* Parse text and convert mentions/commands into appropriate nodes
116+
*/
117+
function parseTextIntoNodes(text: string): Array<TextNode> {
118+
const nodes: Array<TextNode> = []
119+
let lastIndex = 0
120+
121+
// Collect all mentions and commands with their positions
122+
const items: Array<{ index: number; match: string; type: "mention" | "command"; mentionText: string }> = []
123+
124+
// Find all mentions
125+
let mentionMatch: RegExpExecArray | null
126+
while ((mentionMatch = mentionRegexGlobal.exec(text)) !== null) {
127+
items.push({
128+
index: mentionMatch.index,
129+
match: mentionMatch[0],
130+
type: "mention",
131+
mentionText: mentionMatch[1], // The captured group without @
132+
})
133+
}
134+
135+
// Find all commands
136+
let commandMatch: RegExpExecArray | null
137+
while ((commandMatch = commandRegexGlobal.exec(text)) !== null) {
138+
items.push({
139+
index: commandMatch.index,
140+
match: commandMatch[0].trim(), // Remove leading space
141+
type: "command",
142+
mentionText: commandMatch[1], // The captured group without /
143+
})
144+
}
145+
146+
// Sort by index
147+
items.sort((a, b) => a.index - b.index)
148+
149+
// Create nodes
150+
for (const item of items) {
151+
// Add text before this mention/command
152+
if (item.index > lastIndex) {
153+
const textBefore = text.slice(lastIndex, item.index)
154+
if (textBefore) {
155+
// For commands with leading space, include that space
156+
if (item.type === "command" && textBefore.endsWith(" ")) {
157+
nodes.push($createTextNode(textBefore))
158+
} else {
159+
nodes.push($createTextNode(textBefore))
160+
}
161+
}
162+
}
163+
164+
// Determine the type for the mention node
165+
let contextType: ContextMenuOptionType | undefined
166+
167+
if (item.type === "mention") {
168+
const mentionText = item.mentionText
169+
if (mentionText === "problems") {
170+
contextType = ContextMenuOptionType.Problems
171+
} else if (mentionText === "terminal") {
172+
contextType = ContextMenuOptionType.Terminal
173+
} else if (mentionText === "git-changes") {
174+
contextType = ContextMenuOptionType.Git
175+
} else if (mentionText.startsWith("http://") || mentionText.startsWith("https://")) {
176+
contextType = ContextMenuOptionType.URL
177+
} else if (mentionText.startsWith("/")) {
178+
// File or folder path
179+
contextType = mentionText.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File
180+
} else if (/^[a-f0-9]{7,40}$/i.test(mentionText)) {
181+
// Git commit hash
182+
contextType = ContextMenuOptionType.Git
183+
}
184+
185+
const data = contextType ? { type: contextType } : undefined
186+
nodes.push($createMentionNode(item.mentionText, "@", undefined, data))
187+
} else if (item.type === "command") {
188+
nodes.push($createMentionNode(item.mentionText, "/", undefined, { type: ContextMenuOptionType.Command }))
189+
}
190+
191+
lastIndex = item.index + item.match.length
192+
}
193+
194+
// Add remaining text
195+
if (lastIndex < text.length) {
196+
const remainingText = text.slice(lastIndex)
197+
if (remainingText) {
198+
nodes.push($createTextNode(remainingText))
199+
}
200+
}
201+
202+
return nodes
203+
}
204+
205+
/**
206+
* Handle pasting images from clipboard
207+
*/
208+
async function handleImagePaste(
209+
imageItems: DataTransferItem[],
210+
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>,
211+
) {
212+
const imagePromises = imageItems.map((item) => {
213+
return new Promise<string | null>((resolve) => {
214+
const blob = item.getAsFile()
215+
216+
if (!blob) {
217+
resolve(null)
218+
return
219+
}
220+
221+
const reader = new FileReader()
222+
223+
reader.onloadend = () => {
224+
if (reader.error) {
225+
console.error("Error reading file:", reader.error)
226+
resolve(null)
227+
} else {
228+
const result = reader.result
229+
resolve(typeof result === "string" ? result : null)
230+
}
231+
}
232+
233+
reader.readAsDataURL(blob)
234+
})
235+
})
236+
237+
const imageDataArray = await Promise.all(imagePromises)
238+
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
239+
240+
if (dataUrls.length > 0) {
241+
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
242+
} else {
243+
console.warn("No valid images found in clipboard")
244+
}
245+
}

0 commit comments

Comments
 (0)