mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add quote selection feature to chat UI
- Add text selection detection with floating quote button in Markdown component - Implement quote preview above ChatTextArea with dismiss control - Add keyboard shortcut support (Cmd/Ctrl+Shift+Q) for quick quoting - Format quoted text with [context] wrapper for AI context - Add comprehensive tests for quote selection functionality - Add translation strings for quote feature Closes #8837
This commit is contained in:
parent
f5d7ba1959
commit
8fca912051
9 changed files with 636 additions and 1173 deletions
|
|
@ -181,6 +181,10 @@
|
|||
"task_prompt": "What should Roo do?",
|
||||
"task_placeholder": "Type your task here"
|
||||
},
|
||||
"chat": {
|
||||
"quotePreview": "Quote",
|
||||
"quoteSelection": "Quote"
|
||||
},
|
||||
"customModes": {
|
||||
"errors": {
|
||||
"yamlParseError": "Invalid YAML in .roomodes file at line {{line}}. Please check for:\n• Proper indentation (use spaces, not tabs)\n• Matching quotes and brackets\n• Valid YAML syntax",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export interface ExtensionMessage {
|
|||
| "theme"
|
||||
| "workspaceUpdated"
|
||||
| "invoke"
|
||||
| "addQuoteToComposer"
|
||||
| "messageUpdated"
|
||||
| "mcpServers"
|
||||
| "enhancedPrompt"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface WebviewMessage {
|
|||
| "deleteApiConfiguration"
|
||||
| "loadApiConfiguration"
|
||||
| "loadApiConfigurationById"
|
||||
| "addQuoteToComposer"
|
||||
| "renameApiConfiguration"
|
||||
| "getListApiConfiguration"
|
||||
| "customInstructions"
|
||||
|
|
|
|||
|
|
@ -1092,7 +1092,7 @@ export const ChatRowContent = ({
|
|||
<span style={{ fontWeight: "bold" }}>{t("chat:text.rooSaid")}</span>
|
||||
</div>
|
||||
<div className="pl-6">
|
||||
<Markdown markdown={message.text} partial={message.partial} />
|
||||
<Markdown markdown={message.text} partial={message.partial} messageTs={message.ts} />
|
||||
{message.images && message.images.length > 0 && (
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
{message.images.map((image, index) => (
|
||||
|
|
@ -1201,7 +1201,7 @@ export const ChatRowContent = ({
|
|||
{title}
|
||||
</div>
|
||||
<div className="border-l border-green-600/30 ml-2 pl-4 pb-1">
|
||||
<Markdown markdown={message.text} />
|
||||
<Markdown markdown={message.text} messageTs={message.ts} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
@ -1355,7 +1355,7 @@ export const ChatRowContent = ({
|
|||
</div>
|
||||
)}
|
||||
<div style={{ paddingTop: 10 }}>
|
||||
<Markdown markdown={message.text} partial={message.partial} />
|
||||
<Markdown markdown={message.text} partial={message.partial} messageTs={message.ts} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
@ -1441,7 +1441,11 @@ export const ChatRowContent = ({
|
|||
{title}
|
||||
</div>
|
||||
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
|
||||
<Markdown markdown={message.text} partial={message.partial} />
|
||||
<Markdown
|
||||
markdown={message.text}
|
||||
partial={message.partial}
|
||||
messageTs={message.ts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1460,6 +1464,7 @@ export const ChatRowContent = ({
|
|||
<div className="flex flex-col gap-2 ml-6">
|
||||
<Markdown
|
||||
markdown={message.partial === true ? message?.text : followUpData?.question}
|
||||
messageTs={message.ts}
|
||||
/>
|
||||
<FollowUpSuggest
|
||||
suggestions={followUpData?.suggest}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ interface ChatTextAreaProps {
|
|||
// Edit mode props
|
||||
isEditMode?: boolean
|
||||
onCancel?: () => void
|
||||
// Quote selection props
|
||||
quotedText?: string
|
||||
onClearQuote?: () => void
|
||||
}
|
||||
|
||||
export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
|
|
@ -71,6 +74,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
modeShortcutText,
|
||||
isEditMode = false,
|
||||
onCancel,
|
||||
quotedText,
|
||||
onClearQuote,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
|
|
@ -913,6 +918,31 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
"flex flex-col gap-1 bg-editor-background outline-none border border-none box-border",
|
||||
isEditMode ? "p-2 w-full" : "relative px-1.5 pb-1 w-[calc(100%-16px)] ml-auto mr-auto",
|
||||
)}>
|
||||
{/* Quote preview above text area */}
|
||||
{quotedText && !isEditMode && (
|
||||
<div className="mx-1.5 mb-1 p-2 bg-vscode-textCodeBlock-background rounded border border-vscode-widget-border relative">
|
||||
<button
|
||||
className="absolute top-1 right-1 p-1 hover:bg-vscode-toolbar-hoverBackground rounded"
|
||||
onClick={() => onClearQuote?.()}
|
||||
aria-label="Clear quote">
|
||||
<span className="codicon codicon-close text-xs" />
|
||||
</button>
|
||||
<div className="text-xs text-vscode-descriptionForeground mb-1">{t("chat:quotePreview")}</div>
|
||||
<div className="text-xs max-h-20 overflow-y-auto pr-6">
|
||||
{quotedText
|
||||
.split("\n")
|
||||
.slice(0, 3)
|
||||
.map((line, idx) => (
|
||||
<div key={idx} className="text-vscode-foreground opacity-75">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{quotedText.split("\n").length > 3 && (
|
||||
<div className="text-vscode-descriptionForeground">...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(!isEditMode && "relative")}>
|
||||
<div
|
||||
className={cn("chat-text-area", !isEditMode && "relative", "flex", "flex-col", "outline-none")}
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
>(undefined)
|
||||
const [isCondensing, setIsCondensing] = useState<boolean>(false)
|
||||
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
|
||||
const [quotedText, setQuotedText] = useState<string>("")
|
||||
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
|
||||
new LRUCache({
|
||||
max: 100,
|
||||
|
|
@ -783,6 +784,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const message: ExtensionMessage = e.data
|
||||
|
||||
switch (message.type) {
|
||||
case "addQuoteToComposer":
|
||||
// Handle quote from message selection
|
||||
if (message.text) {
|
||||
// Format the quoted text with [context] wrapper
|
||||
const formattedQuote = `[context]\n${message.text
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")}\n[/context]\n`
|
||||
setQuotedText(formattedQuote)
|
||||
}
|
||||
break
|
||||
case "action":
|
||||
switch (message.action!) {
|
||||
case "didBecomeVisible":
|
||||
|
|
@ -1720,6 +1732,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// Add keyboard event handler
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Check for Command/Ctrl + Shift + Q for quote selection
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "q") {
|
||||
event.preventDefault()
|
||||
|
||||
// Get current text selection
|
||||
const selection = window.getSelection()
|
||||
if (selection && !selection.isCollapsed) {
|
||||
const selectedText = selection.toString().trim()
|
||||
if (selectedText) {
|
||||
// Format the quoted text with [context] wrapper
|
||||
const formattedQuote = `[context]\n${selectedText
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")}\n[/context]\n`
|
||||
setQuotedText(formattedQuote)
|
||||
|
||||
// Clear the selection
|
||||
selection.removeAllRanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Command/Ctrl + Period (with or without Shift)
|
||||
// Using event.key to respect keyboard layouts (e.g., Dvorak)
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === ".") {
|
||||
|
|
@ -1992,7 +2026,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
placeholderText={placeholderText}
|
||||
selectedImages={selectedImages}
|
||||
setSelectedImages={setSelectedImages}
|
||||
onSend={() => handleSendMessage(inputValue, selectedImages)}
|
||||
onSend={() => {
|
||||
// Include quoted text when sending
|
||||
const finalMessage = quotedText ? quotedText + inputValue : inputValue
|
||||
handleSendMessage(finalMessage, selectedImages)
|
||||
setQuotedText("") // Clear quote after sending
|
||||
}}
|
||||
onSelectImages={selectImages}
|
||||
shouldDisableImages={shouldDisableImages}
|
||||
onHeightChange={() => {
|
||||
|
|
@ -2003,6 +2042,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
mode={mode}
|
||||
setMode={setMode}
|
||||
modeShortcutText={modeShortcutText}
|
||||
quotedText={quotedText}
|
||||
onClearQuote={() => setQuotedText("")}
|
||||
/>
|
||||
|
||||
{isProfileDisabled && (
|
||||
|
|
|
|||
|
|
@ -1,67 +1,179 @@
|
|||
import { memo, useState } from "react"
|
||||
import { memo, useState, useEffect, useRef } from "react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { MessageSquareQuote } from "lucide-react"
|
||||
|
||||
import { useCopyToClipboard } from "@src/utils/clipboard"
|
||||
import { StandardTooltip } from "@src/components/ui"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
|
||||
export const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => {
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
export const Markdown = memo(
|
||||
({ markdown, partial, messageTs }: { markdown?: string; partial?: boolean; messageTs?: number }) => {
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [selectedText, setSelectedText] = useState("")
|
||||
const [quoteButtonPosition, setQuoteButtonPosition] = useState<{ top: number; left: number } | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Shorter feedback duration for copy button flash.
|
||||
const { copyWithFeedback } = useCopyToClipboard(200)
|
||||
// Shorter feedback duration for copy button flash.
|
||||
const { copyWithFeedback } = useCopyToClipboard(200)
|
||||
|
||||
if (!markdown || markdown.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Handle text selection
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.isCollapsed || !containerRef.current) {
|
||||
setSelectedText("")
|
||||
setQuoteButtonPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
style={{ position: "relative" }}>
|
||||
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
|
||||
<MarkdownBlock markdown={markdown} />
|
||||
</div>
|
||||
{markdown && !partial && isHovering && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-4px",
|
||||
right: "8px",
|
||||
opacity: 0,
|
||||
animation: "fadeIn 0.2s ease-in-out forwards",
|
||||
borderRadius: "4px",
|
||||
}}>
|
||||
<style>{`@keyframes fadeIn { from { opacity: 0; } to { opacity: 1.0; } }`}</style>
|
||||
<StandardTooltip content="Copy as markdown">
|
||||
<VSCodeButton
|
||||
className="copy-button"
|
||||
appearance="icon"
|
||||
style={{
|
||||
height: "24px",
|
||||
border: "none",
|
||||
background: "var(--vscode-editor-background)",
|
||||
transition: "background 0.2s ease-in-out",
|
||||
}}
|
||||
onClick={async () => {
|
||||
const success = await copyWithFeedback(markdown)
|
||||
if (success) {
|
||||
const button = document.activeElement as HTMLElement
|
||||
if (button) {
|
||||
button.style.background = "var(--vscode-button-background)"
|
||||
setTimeout(() => {
|
||||
button.style.background = ""
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<span className="codicon codicon-copy" />
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
// Check if selection is within our container
|
||||
const range = selection.getRangeAt(0)
|
||||
const commonAncestor = range.commonAncestorContainer
|
||||
const isInContainer = containerRef.current.contains(commonAncestor)
|
||||
|
||||
if (isInContainer) {
|
||||
const text = selection.toString().trim()
|
||||
if (text) {
|
||||
setSelectedText(text)
|
||||
|
||||
// Get the selection bounds to position the quote button
|
||||
const rect = range.getBoundingClientRect()
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
|
||||
// Position the button above and centered on the selection
|
||||
setQuoteButtonPosition({
|
||||
top: rect.top - containerRect.top - 36, // 36px above selection
|
||||
left: rect.left - containerRect.left + rect.width / 2 - 16, // centered (button is ~32px wide)
|
||||
})
|
||||
} else {
|
||||
setSelectedText("")
|
||||
setQuoteButtonPosition(null)
|
||||
}
|
||||
} else {
|
||||
setSelectedText("")
|
||||
setQuoteButtonPosition(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for selection changes
|
||||
document.addEventListener("selectionchange", handleSelectionChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", handleSelectionChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleQuoteClick = () => {
|
||||
if (selectedText) {
|
||||
// Send message to add quote to composer
|
||||
vscode.postMessage({
|
||||
type: "addQuoteToComposer",
|
||||
text: selectedText,
|
||||
messageTs: messageTs,
|
||||
})
|
||||
|
||||
// Clear selection
|
||||
window.getSelection()?.removeAllRanges()
|
||||
setSelectedText("")
|
||||
setQuoteButtonPosition(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!markdown || markdown.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
style={{ position: "relative" }}>
|
||||
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
|
||||
<MarkdownBlock markdown={markdown} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
{/* Quote button that appears on text selection */}
|
||||
{selectedText && quoteButtonPosition && !partial && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${quoteButtonPosition.top}px`,
|
||||
left: `${quoteButtonPosition.left}px`,
|
||||
zIndex: 1000,
|
||||
animation: "fadeIn 0.15s ease-in-out forwards",
|
||||
}}>
|
||||
<StandardTooltip content="Quote selection to reply">
|
||||
<button
|
||||
onClick={handleQuoteClick}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--vscode-widget-border)",
|
||||
background: "var(--vscode-button-background)",
|
||||
color: "var(--vscode-button-foreground)",
|
||||
cursor: "pointer",
|
||||
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--vscode-button-hoverBackground)"
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "var(--vscode-button-background)"
|
||||
}}>
|
||||
<MessageSquareQuote size={16} />
|
||||
</button>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy button that appears on hover */}
|
||||
{markdown && !partial && isHovering && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "-4px",
|
||||
right: "8px",
|
||||
opacity: 0,
|
||||
animation: "fadeIn 0.2s ease-in-out forwards",
|
||||
borderRadius: "4px",
|
||||
}}>
|
||||
<style>{`@keyframes fadeIn { from { opacity: 0; } to { opacity: 1.0; } }`}</style>
|
||||
<StandardTooltip content="Copy as markdown">
|
||||
<VSCodeButton
|
||||
className="copy-button"
|
||||
appearance="icon"
|
||||
style={{
|
||||
height: "24px",
|
||||
border: "none",
|
||||
background: "var(--vscode-editor-background)",
|
||||
transition: "background 0.2s ease-in-out",
|
||||
}}
|
||||
onClick={async () => {
|
||||
const success = await copyWithFeedback(markdown)
|
||||
if (success) {
|
||||
const button = document.activeElement as HTMLElement
|
||||
if (button) {
|
||||
button.style.background = "var(--vscode-button-background)"
|
||||
setTimeout(() => {
|
||||
button.style.background = ""
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<span className="codicon codicon-copy" />
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
204
webview-ui/src/components/chat/__tests__/Markdown.spec.tsx
Normal file
204
webview-ui/src/components/chat/__tests__/Markdown.spec.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { render, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import { Markdown } from "../Markdown"
|
||||
import React from "react"
|
||||
|
||||
// Mock vscode API
|
||||
const mockPostMessage = vi.fn()
|
||||
const vscodeApi = {
|
||||
postMessage: mockPostMessage,
|
||||
}
|
||||
|
||||
// @ts-expect-error - Mocking global window API for testing
|
||||
global.window.acquireVsCodeApi = vi.fn(() => vscodeApi)
|
||||
|
||||
describe("Markdown Quote Selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Mock getSelection
|
||||
global.window.getSelection = vi.fn(
|
||||
() =>
|
||||
({
|
||||
toString: vi.fn(() => ""),
|
||||
removeAllRanges: vi.fn(),
|
||||
getRangeAt: vi.fn(() => ({
|
||||
getBoundingClientRect: vi.fn(() => ({
|
||||
top: 100,
|
||||
left: 50,
|
||||
bottom: 120,
|
||||
right: 200,
|
||||
width: 150,
|
||||
height: 20,
|
||||
})),
|
||||
})),
|
||||
rangeCount: 1,
|
||||
}) as any,
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("should show quote button when text is selected", async () => {
|
||||
const { container } = render(
|
||||
<Markdown markdown="This is some test content that can be selected" messageTs={123456} />,
|
||||
)
|
||||
|
||||
// Mock text selection
|
||||
const mockSelection = {
|
||||
toString: vi.fn(() => "test content"),
|
||||
removeAllRanges: vi.fn(),
|
||||
getRangeAt: vi.fn(() => ({
|
||||
getBoundingClientRect: vi.fn(() => ({
|
||||
top: 100,
|
||||
left: 50,
|
||||
bottom: 120,
|
||||
right: 200,
|
||||
width: 150,
|
||||
height: 20,
|
||||
})),
|
||||
})),
|
||||
rangeCount: 1,
|
||||
}
|
||||
global.window.getSelection = vi.fn(() => mockSelection as any)
|
||||
|
||||
// Trigger selection change event
|
||||
const selectionEvent = new Event("selectionchange", { bubbles: true })
|
||||
document.dispatchEvent(selectionEvent)
|
||||
|
||||
// Wait for the quote button to appear
|
||||
await waitFor(() => {
|
||||
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
|
||||
expect(quoteButton).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it("should send addQuoteToComposer message when quote button is clicked", async () => {
|
||||
const { container } = render(
|
||||
<Markdown markdown="This is some test content that can be selected" messageTs={123456} />,
|
||||
)
|
||||
|
||||
// Mock text selection
|
||||
const selectedText = "test content"
|
||||
const mockSelection = {
|
||||
toString: vi.fn(() => selectedText),
|
||||
removeAllRanges: vi.fn(),
|
||||
getRangeAt: vi.fn(() => ({
|
||||
getBoundingClientRect: vi.fn(() => ({
|
||||
top: 100,
|
||||
left: 50,
|
||||
bottom: 120,
|
||||
right: 200,
|
||||
width: 150,
|
||||
height: 20,
|
||||
})),
|
||||
})),
|
||||
rangeCount: 1,
|
||||
}
|
||||
global.window.getSelection = vi.fn(() => mockSelection as any)
|
||||
|
||||
// Trigger selection change event
|
||||
const selectionEvent = new Event("selectionchange", { bubbles: true })
|
||||
document.dispatchEvent(selectionEvent)
|
||||
|
||||
// Wait for the quote button to appear
|
||||
await waitFor(() => {
|
||||
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
|
||||
expect(quoteButton).toBeTruthy()
|
||||
})
|
||||
|
||||
// Click the quote button
|
||||
const quoteButton = container.querySelector('[aria-label="Quote selected text"]') as HTMLElement
|
||||
fireEvent.click(quoteButton)
|
||||
|
||||
// Verify the message was sent
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "addQuoteToComposer",
|
||||
text: selectedText,
|
||||
messageTs: 123456,
|
||||
})
|
||||
})
|
||||
|
||||
it("should hide quote button when selection is cleared", async () => {
|
||||
const { container } = render(
|
||||
<Markdown markdown="This is some test content that can be selected" messageTs={123456} />,
|
||||
)
|
||||
|
||||
// Mock text selection
|
||||
const mockSelection = {
|
||||
toString: vi.fn(() => "test content"),
|
||||
removeAllRanges: vi.fn(),
|
||||
getRangeAt: vi.fn(() => ({
|
||||
getBoundingClientRect: vi.fn(() => ({
|
||||
top: 100,
|
||||
left: 50,
|
||||
bottom: 120,
|
||||
right: 200,
|
||||
width: 150,
|
||||
height: 20,
|
||||
})),
|
||||
})),
|
||||
rangeCount: 1,
|
||||
}
|
||||
global.window.getSelection = vi.fn(() => mockSelection as any)
|
||||
|
||||
// Trigger selection change event
|
||||
const selectionEvent = new Event("selectionchange", { bubbles: true })
|
||||
document.dispatchEvent(selectionEvent)
|
||||
|
||||
// Wait for the quote button to appear
|
||||
await waitFor(() => {
|
||||
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
|
||||
expect(quoteButton).toBeTruthy()
|
||||
})
|
||||
|
||||
// Clear the selection
|
||||
mockSelection.toString = vi.fn(() => "")
|
||||
mockSelection.rangeCount = 0
|
||||
|
||||
// Trigger selection change event again
|
||||
document.dispatchEvent(selectionEvent)
|
||||
|
||||
// Wait for the quote button to disappear
|
||||
await waitFor(() => {
|
||||
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
|
||||
expect(quoteButton).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
it("should not show quote button when messageTs is not provided", async () => {
|
||||
const { container } = render(<Markdown markdown="This is some test content that can be selected" />)
|
||||
|
||||
// Mock text selection
|
||||
const mockSelection = {
|
||||
toString: vi.fn(() => "test content"),
|
||||
removeAllRanges: vi.fn(),
|
||||
getRangeAt: vi.fn(() => ({
|
||||
getBoundingClientRect: vi.fn(() => ({
|
||||
top: 100,
|
||||
left: 50,
|
||||
bottom: 120,
|
||||
right: 200,
|
||||
width: 150,
|
||||
height: 20,
|
||||
})),
|
||||
})),
|
||||
rangeCount: 1,
|
||||
}
|
||||
global.window.getSelection = vi.fn(() => mockSelection as any)
|
||||
|
||||
// Trigger selection change event
|
||||
const selectionEvent = new Event("selectionchange", { bubbles: true })
|
||||
document.dispatchEvent(selectionEvent)
|
||||
|
||||
// Wait a bit and verify no quote button appears
|
||||
await waitFor(
|
||||
() => {
|
||||
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
|
||||
expect(quoteButton).toBeFalsy()
|
||||
},
|
||||
{ timeout: 100 },
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue