diff --git a/src/components/BlockquoteCopyButton.test.tsx b/src/components/BlockquoteCopyButton.test.tsx new file mode 100644 index 0000000..dcec1f1 --- /dev/null +++ b/src/components/BlockquoteCopyButton.test.tsx @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import BlockquoteCopyButton from './BlockquoteCopyButton'; + +// Mock clipboard API +const mockWriteText = vi.fn(); +Object.assign(navigator, { + clipboard: { + writeText: mockWriteText, + }, +}); + +// Mock console methods +const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + +describe('BlockquoteCopyButton', () => { + let mockBlockquoteElement: HTMLElement; + + beforeEach(() => { + // Create a mock blockquote element + mockBlockquoteElement = document.createElement('blockquote'); + mockBlockquoteElement.innerHTML = '

Test content

'; + document.body.appendChild(mockBlockquoteElement); + + // Reset mocks + mockWriteText.mockClear(); + consoleSpy.mockClear(); + }); + + afterEach(() => { + // Clean up + document.body.removeChild(mockBlockquoteElement); + vi.clearAllTimers(); + }); + + it('renders with default "Copy" text', () => { + render( + + ); + + expect(screen.getByRole('button')).toHaveTextContent('Copy'); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Copy blockquote content'); + }); + + it('copies text content when clicked', async () => { + mockWriteText.mockResolvedValueOnce(undefined); + + render( + + ); + + fireEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith('Test content'); + }); + }); + + it('copies markdown content when contentType is text/markdown', async () => { + mockWriteText.mockResolvedValueOnce(undefined); + mockBlockquoteElement.innerHTML = '

Bold text and italic text

'; + + render( + + ); + + fireEvent.click(screen.getByRole('button')); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith('**Bold text** and *italic text*'); + }); + }); + + it('shows "Copied!" feedback after successful copy', async () => { + mockWriteText.mockResolvedValueOnce(undefined); + + render( + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + // Wait for the state to change to "Copied!" + await waitFor(() => { + expect(button).toHaveTextContent('Copied!'); + expect(button).toHaveClass('copied'); + }, { timeout: 1000 }); + + expect(mockWriteText).toHaveBeenCalledWith('Test content'); + }); + + it('shows "Error" feedback when copy fails', async () => { + const error = new Error('Clipboard not available'); + mockWriteText.mockRejectedValueOnce(error); + + render( + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + // Wait for the state to change to "Error" + await waitFor(() => { + expect(button).toHaveTextContent('Error'); + expect(button).toHaveClass('error'); + }, { timeout: 1000 }); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to copy text: ', error); + expect(mockWriteText).toHaveBeenCalledWith('Test content'); + }); + + it('removes existing copy buttons from cloned content', async () => { + mockWriteText.mockResolvedValueOnce(undefined); + + // Add a copy button to the mock blockquote + const existingButton = document.createElement('button'); + existingButton.className = 'blockquote-copy-button'; + existingButton.textContent = 'Copy'; + mockBlockquoteElement.appendChild(existingButton); + + render( + + ); + + // Get all buttons and click the React-rendered one (should be the one with aria-label) + const reactButton = screen.getByLabelText('Copy blockquote content'); + fireEvent.click(reactButton); + + await waitFor(() => { + // Should copy only the text content, not including the button text + expect(mockWriteText).toHaveBeenCalledWith('Test content'); + }, { timeout: 1000 }); + }); + + it('prevents event propagation and default behavior', () => { + const mockEvent = { + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + target: document.createElement('button'), + }; + + render( + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button, mockEvent); + + // Note: This test verifies the click handler exists and works + // The actual preventDefault/stopPropagation calls are tested indirectly + expect(button).toBeInTheDocument(); + }); + + it('handles empty blockquote content', async () => { + mockWriteText.mockResolvedValueOnce(undefined); + mockBlockquoteElement.innerHTML = ''; + + render( + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + await vi.waitFor(() => expect(mockWriteText).toHaveBeenCalled()); + + expect(mockWriteText).toHaveBeenCalledWith(''); + }); + + it('trims whitespace from copied content', async () => { + mockWriteText.mockResolvedValueOnce(undefined); + mockBlockquoteElement.innerHTML = '

Test content

'; + + render( + + ); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + await vi.waitFor(() => expect(mockWriteText).toHaveBeenCalled()); + + expect(mockWriteText).toHaveBeenCalledWith('Test content'); + }); + + it('has correct accessibility attributes', () => { + render( + + ); + + const button = screen.getByRole('button'); + expect(button).toHaveAttribute('type', 'button'); + expect(button).toHaveAttribute('aria-label', 'Copy blockquote content'); + }); +}); \ No newline at end of file diff --git a/src/components/BlockquoteCopyButton.tsx b/src/components/BlockquoteCopyButton.tsx new file mode 100644 index 0000000..7deb982 --- /dev/null +++ b/src/components/BlockquoteCopyButton.tsx @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import TurndownService from 'turndown'; + +interface BlockquoteCopyButtonProps { + blockquoteElement: HTMLElement; + contentType?: string; +} + +const BlockquoteCopyButton: React.FC = ({ + blockquoteElement, + contentType +}) => { + const [buttonState, setButtonState] = useState<'default' | 'copied' | 'error'>('default'); + + // Create TurndownService instance + const turndownService = new TurndownService({ headingStyle: 'atx', emDelimiter: '*' }); + + const handleCopyClick = async (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + try { + // Clone the blockquote to avoid modifying the live DOM + const clonedBlockquote = blockquoteElement.cloneNode(true) as HTMLElement; + + // Remove any existing copy buttons from the clone + const buttonsInClone = clonedBlockquote.querySelectorAll('.blockquote-copy-button'); + buttonsInClone.forEach(button => button.remove()); + + let textToCopy: string; + + // Check if the content type is markdown + if (contentType === 'text/markdown') { + // Get the inner HTML of the clone (without the button) + const htmlContent = clonedBlockquote.innerHTML; + // Convert HTML to Markdown using Turndown + textToCopy = turndownService.turndown(htmlContent); + } else { + // Default behavior: Get text content from the clone + textToCopy = clonedBlockquote.textContent || ''; + } + + await navigator.clipboard.writeText(textToCopy.trim()); + + // Visual feedback: Change to copied state + setButtonState('copied'); + setTimeout(() => { + setButtonState('default'); + }, 1500); + + } catch (err) { + console.error('Failed to copy text: ', err); + + // Error feedback + setButtonState('error'); + setTimeout(() => { + setButtonState('default'); + }, 1500); + } + }; + + const getButtonText = () => { + switch (buttonState) { + case 'copied': return 'Copied!'; + case 'error': return 'Error'; + default: return 'Copy'; + } + }; + + return ( + + ); +}; + +export default BlockquoteCopyButton; \ No newline at end of file diff --git a/src/components/BlockquoteWithCopy.test.tsx b/src/components/BlockquoteWithCopy.test.tsx new file mode 100644 index 0000000..0b1c8f8 --- /dev/null +++ b/src/components/BlockquoteWithCopy.test.tsx @@ -0,0 +1,159 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import BlockquoteWithCopy from './BlockquoteWithCopy'; + +// Mock the BlockquoteCopyButton component +vi.mock('./BlockquoteCopyButton', () => ({ + default: ({ blockquoteElement, contentType }: { blockquoteElement: HTMLElement, contentType?: string }) => ( + + ), +})); + +describe('BlockquoteWithCopy', () => { + it('renders blockquote with wrapper div', () => { + render( + +

Test content

+
+ ); + + const wrapper = screen.getByText('Test content').closest('.blockquote-with-copy'); + expect(wrapper).toBeInTheDocument(); + expect(wrapper).toHaveStyle({ position: 'relative' }); + + const blockquote = screen.getByRole('blockquote'); + expect(blockquote).toBeInTheDocument(); + expect(blockquote).toContainElement(screen.getByText('Test content')); + }); + + it('renders copy button after component mounts', async () => { + render( + +

Test content

+
+ ); + + await waitFor(() => { + expect(screen.getByTestId('mock-copy-button')).toBeInTheDocument(); + }); + }); + + it('passes contentType to copy button', async () => { + render( + +

Test markdown content

+
+ ); + + await waitFor(() => { + const copyButton = screen.getByTestId('mock-copy-button'); + expect(copyButton).toHaveAttribute('data-content-type', 'text/markdown'); + }); + }); + + it('sets data-content-type attribute on blockquote', () => { + render( + +

Test content

+
+ ); + + const blockquote = screen.getByRole('blockquote'); + expect(blockquote).toHaveAttribute('data-content-type', 'text/markdown'); + }); + + it('handles string children with dangerouslySetInnerHTML', () => { + const htmlContent = '

Bold text

'; + + render( + + {htmlContent} + + ); + + const blockquote = screen.getByRole('blockquote'); + expect(blockquote).toContainHTML(htmlContent); + }); + + it('handles React element children normally', () => { + render( + +

Bold text

+

Another paragraph

+
+ ); + + const blockquote = screen.getByRole('blockquote'); + expect(blockquote).toContainElement(screen.getByText('Bold')); + expect(blockquote).toContainElement(screen.getByText('Another paragraph')); + }); + + it('does not set data-content-type when contentType is undefined', () => { + render( + +

Test content

+
+ ); + + const blockquote = screen.getByRole('blockquote'); + expect(blockquote).not.toHaveAttribute('data-content-type'); + }); + + it('copy button receives blockquote element reference', async () => { + render( + +

Test content

+
+ ); + + // Wait for component to mount and copy button to appear + await waitFor(() => { + expect(screen.getByTestId('mock-copy-button')).toBeInTheDocument(); + }); + + // The mock component should render, indicating that blockquoteRef.current was not null + expect(screen.getByTestId('mock-copy-button')).toBeInTheDocument(); + }); + + it('handles empty children', async () => { + render( + + {''} + + ); + + const blockquote = screen.getByRole('blockquote'); + expect(blockquote).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('mock-copy-button')).toBeInTheDocument(); + }); + }); + + it('wrapper has correct CSS class', () => { + render( + +

Test content

+
+ ); + + const wrapper = screen.getByText('Test content').closest('div'); + expect(wrapper).toHaveClass('blockquote-with-copy'); + }); + + it('renders copy button after component mounts', async () => { + render( + +

Test content

+
+ ); + + // After mount, it should appear + await waitFor(() => { + expect(screen.getByTestId('mock-copy-button')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/src/components/BlockquoteWithCopy.tsx b/src/components/BlockquoteWithCopy.tsx new file mode 100644 index 0000000..35f2754 --- /dev/null +++ b/src/components/BlockquoteWithCopy.tsx @@ -0,0 +1,40 @@ +import React, { useState, useEffect, useRef } from 'react'; +import BlockquoteCopyButton from './BlockquoteCopyButton'; + +interface BlockquoteWithCopyProps { + children: React.ReactNode; + contentType?: string; +} + +const BlockquoteWithCopy: React.FC = ({ + children, + contentType +}) => { + const blockquoteRef = useRef(null); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + // Ensure component is mounted before accessing ref + setMounted(true); + }, []); + + return ( +
+
+ {typeof children !== 'string' ? children : undefined} +
+ {mounted && blockquoteRef.current && ( + + )} +
+ ); +}; + +export default BlockquoteWithCopy; \ No newline at end of file diff --git a/src/components/output.css b/src/components/output.css index 3c4f675..3169b86 100644 --- a/src/components/output.css +++ b/src/components/output.css @@ -85,3 +85,56 @@ a.exit { font-weight: bold; /* Keep errors bold */ display: inline; /* Prevent h2 from taking full width if not desired */ } + +/* BlockquoteWithCopy wrapper styles */ +.blockquote-with-copy { + position: relative; + margin: 0.5rem 0; +} + +.blockquote-with-copy blockquote { + margin: 0; + padding: 1rem; + background-color: rgba(255, 255, 255, 0.05); + border-left: 4px solid #87ceeb; + border-radius: 4px; + position: relative; +} + +/* BlockquoteCopyButton styles */ +.blockquote-copy-button { + position: absolute; + top: 8px; + right: 8px; + background-color: rgba(0, 0, 0, 0.7); + color: #ffffff; + border: 1px solid #555; + border-radius: 4px; + padding: 4px 8px; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; + z-index: 10; +} + +.blockquote-copy-button:hover { + background-color: rgba(0, 0, 0, 0.9); + border-color: #87ceeb; +} + +.blockquote-copy-button:active { + transform: translateY(1px); +} + +/* Visual feedback styles for copy button */ +.blockquote-copy-button.copied { + background-color: #28a745; + border-color: #28a745; + color: #ffffff; +} + +.blockquote-copy-button.error { + background-color: #dc3545; + border-color: #dc3545; + color: #ffffff; +} diff --git a/src/components/output.tsx b/src/components/output.tsx index 62de32b..3449cab 100644 --- a/src/components/output.tsx +++ b/src/components/output.tsx @@ -8,6 +8,7 @@ import DOMPurify from 'dompurify'; import { setInputText } from '../InputStore'; import TurndownService from 'turndown'; // <-- Import TurndownService import { preferencesStore } from '../PreferencesStore'; // Import preferences store +import BlockquoteWithCopy from './BlockquoteWithCopy'; export enum OutputType { Command = 'command', @@ -20,12 +21,17 @@ export interface OutputLine { id: number; // Unique key for React list type: OutputType; content: JSX.Element; // The actual JSX to render for this line + sourceType: string; // Track what created this message + sourceContent: string; // Store original input data + metadata?: Record; // Optional additional data } // For storing in localStorage interface SavedOutputLine { type: OutputType; - htmlContent: string; + sourceType: string; // 'ansi' | 'html' | 'command' | 'system' | 'error' + sourceContent: string; // Original input data + metadata?: Record; // Optional additional data } interface StoredOutputLog { @@ -33,7 +39,7 @@ interface StoredOutputLog { lines: SavedOutputLine[]; } -const OUTPUT_LOG_VERSION = 1; +const OUTPUT_LOG_VERSION = 2; interface Props { client: MudClient; @@ -77,38 +83,13 @@ class Output extends React.Component { saveOutput = () => { const linesToSave: SavedOutputLine[] = this.state.output.map((line: OutputLine) => { - let htmlContent = ""; - const outerDivProps = line.content.props; // Props of the
- - if (outerDivProps.dangerouslySetInnerHTML && outerDivProps.dangerouslySetInnerHTML.__html) { - // Case 1: Line loaded from storage, or from handleHtml's direct inner div. - // The content is already HTML. - htmlContent = outerDivProps.dangerouslySetInnerHTML.__html; - } else if (outerDivProps.children) { - // Case 2: Line created dynamically, children JSX exists. - const innerElement = outerDivProps.children; - - // Check if the innerElement itself was created with dangerouslySetInnerHTML (e.g. from handleHtml) - if (React.isValidElement(innerElement) && innerElement.props.dangerouslySetInnerHTML && innerElement.props.dangerouslySetInnerHTML.__html) { - htmlContent = innerElement.props.dangerouslySetInnerHTML.__html; - } else { - // Otherwise, the innerElement is JSX that needs to be rendered to string. - if (React.isValidElement(innerElement) || (Array.isArray(innerElement) && innerElement.every(ch => React.isValidElement(ch) || typeof ch === 'string'))) { - htmlContent = ReactDOMServer.renderToStaticMarkup(innerElement); - } else if (typeof innerElement === 'string') { - htmlContent = innerElement; // Should not typically happen as we wrap strings in addToOutput - } else { - console.warn("Unexpected children structure in OutputLine for saving:", innerElement); - htmlContent = ""; - } - } - } - return { type: line.type, - htmlContent: htmlContent, + sourceType: line.sourceType, + sourceContent: line.sourceContent, + metadata: line.metadata, }; - }).filter(savedLine => savedLine.htmlContent !== ""); // Filter out lines that ended up empty + }).filter(savedLine => savedLine.sourceContent !== ""); // Filter out lines that ended up empty const storedLog: StoredOutputLog = { version: OUTPUT_LOG_VERSION, @@ -117,6 +98,91 @@ class Output extends React.Component { localStorage.setItem(Output.LOCAL_STORAGE_KEY, JSON.stringify(storedLog)); }; + // Helper method to re-create content from source data + recreateContentFromSource = (savedLine: SavedOutputLine): React.ReactElement[] => { + switch (savedLine.sourceType) { + case 'ansi': + return parseToElements(savedLine.sourceContent, this.handleExitClick); + + case 'html': + // Re-process through handleHtml logic + const clean = DOMPurify.sanitize(savedLine.sourceContent); + const parser = new DOMParser(); + const doc = parser.parseFromString(clean, 'text/html'); + const blockquotes = doc.querySelectorAll('blockquote'); + + if (blockquotes.length > 0) { + const elements: React.ReactElement[] = []; + const bodyElement = doc.body; + let currentContent = ''; + + Array.from(bodyElement.childNodes).forEach((node, index) => { + if (node.nodeName === 'BLOCKQUOTE') { + if (currentContent.trim()) { + elements.push( +
+ ); + currentContent = ''; + } + + const blockquoteElement = node as HTMLElement; + const contentType = blockquoteElement.getAttribute('data-content-type') || undefined; + + elements.push( + + {blockquoteElement.innerHTML} + + ); + } else { + if (node.nodeType === Node.ELEMENT_NODE) { + currentContent += (node as HTMLElement).outerHTML; + } else if (node.nodeType === Node.TEXT_NODE) { + currentContent += node.textContent || ''; + } + } + }); + + if (currentContent.trim()) { + elements.push( +
+ ); + } + + return elements; + } else { + return [
]; + } + + case 'command': + return [ + + {savedLine.sourceContent} + + ]; + + case 'error': + return [

Error: {savedLine.sourceContent}

]; + + case 'system': + return [

{savedLine.sourceContent}

]; + + default: + console.warn(`Unknown sourceType: ${savedLine.sourceType}, falling back to text display`); + return [{savedLine.sourceContent}]; + } + }; + loadOutput = (): OutputLine[] => { const savedOutputString = localStorage.getItem(Output.LOCAL_STORAGE_KEY); if (savedOutputString) { @@ -127,44 +193,35 @@ class Output extends React.Component { if (parsedData && typeof parsedData === 'object' && 'version' in parsedData && 'lines' in parsedData) { const storedLog = parsedData as StoredOutputLog; if (storedLog.version === OUTPUT_LOG_VERSION) { + // Re-process source data through handlers to recreate proper React components return storedLog.lines.map((savedLine: SavedOutputLine): OutputLine => { const currentKey = this.messageKey++; + const recreatedElements = this.recreateContentFromSource(savedLine); + + // Create the wrapper div with the recreated content + const wrappedContent = recreatedElements.length === 1 ? + recreatedElements[0] : + <>{recreatedElements}; + return { id: currentKey, type: savedLine.type, - content: React.createElement("div", { - key: currentKey, - className: `output-line output-line-${savedLine.type}`, - dangerouslySetInnerHTML: { __html: savedLine.htmlContent }, - }) + content:
{wrappedContent}
, + sourceType: savedLine.sourceType, + sourceContent: savedLine.sourceContent, + metadata: savedLine.metadata }; }); } else { - // Handle other versions if/when they exist, for now, treat as old or discard - console.warn(`Unsupported log version: ${storedLog.version}. Discarding.`); + // Clear old incompatible data + console.warn(`Unsupported log version: ${storedLog.version}. Clearing old data.`); localStorage.removeItem(Output.LOCAL_STORAGE_KEY); return []; } - } else if (Array.isArray(parsedData)) { - // Handle old format (array of strings) for backward compatibility - console.log("Loading old format output log."); - const outputContentHtml = parsedData as string[]; - return outputContentHtml.map((htmlString: string): OutputLine => { - const currentKey = this.messageKey++; - return { - id: currentKey, - type: OutputType.ServerMessage, // Default type for old format - content: React.createElement("div", { - key: currentKey, - className: `output-line output-line-${OutputType.ServerMessage}`, // Add class for old format - dangerouslySetInnerHTML: { __html: htmlString }, - }) - }; - }); } else { - // Data is in an unexpected format - console.error("Saved output log is in an unrecognized format:", parsedData); - localStorage.removeItem(Output.LOCAL_STORAGE_KEY); // Clear corrupted data + // Clear old format data + console.log("Clearing old format output log."); + localStorage.removeItem(Output.LOCAL_STORAGE_KEY); return []; } } catch (error) { @@ -184,17 +241,19 @@ class Output extends React.Component { , ], OutputType.Command, // Specify type - false + false, + 'command', + command ); }; addError = (error: Error) => - this.addToOutput([

Error: {error.message}

], OutputType.ErrorMessage); + this.addToOutput([

Error: {error.message}

], OutputType.ErrorMessage, true, 'error', error.message); - handleConnected = () => this.addToOutput([

Connected

], OutputType.SystemInfo); + handleConnected = () => this.addToOutput([

Connected

], OutputType.SystemInfo, true, 'system', 'Connected'); handleDisconnected = () => { - this.addToOutput([

Disconnected

], OutputType.SystemInfo); + this.addToOutput([

Disconnected

], OutputType.SystemInfo, true, 'system', 'Disconnected'); this.setState({ sidebarVisible: false }); }; @@ -238,32 +297,8 @@ componentDidUpdate( } this.saveOutput(); // Save output to LocalStorage whenever it updates - this.addCopyButtons(); // Add copy buttons to new blockquotes } - // Method to add copy buttons to blockquotes that don't have them yet - addCopyButtons = () => { - const outputDiv = this.outputRef.current; - if (!outputDiv) return; - - const blockquotes = outputDiv.querySelectorAll('blockquote:not(:has(.blockquote-copy-button))'); - - blockquotes.forEach(blockquote => { - const button = document.createElement('button'); - button.classList.add('blockquote-copy-button'); - button.textContent = 'Copy'; - // Removed aria-label as button text is sufficient - button.setAttribute('type', 'button'); // Good practice for buttons not submitting forms - - // Ensure the blockquote itself can contain the absolutely positioned button - // This might already be handled by CSS, but setting it here ensures it - if (window.getComputedStyle(blockquote).position === 'static') { - blockquote.style.position = 'relative'; - } - - blockquote.appendChild(button); - }); - } handleScroll = () => { @@ -313,7 +348,14 @@ componentDidUpdate( return doc.body.textContent || ""; } - addToOutput(elements: React.ReactNode[], type: OutputType, shouldAnnounce: boolean = true) { + addToOutput( + elements: React.ReactNode[], + type: OutputType, + shouldAnnounce: boolean = true, + sourceType: string = 'unknown', + sourceContent: string = '', + metadata?: Record + ) { if (shouldAnnounce) { elements.forEach((element) => { if (React.isValidElement(element)) { @@ -332,7 +374,10 @@ componentDidUpdate( return { id: currentKey, type: type, - content:
{element}
+ content:
{element}
, + sourceType: sourceType, + sourceContent: sourceContent, + metadata: metadata }; }); const combinedOutput = [...state.output, ...newOutputLines]; @@ -349,76 +394,89 @@ scrollToBottom = () => { const output = this.outputRef.current; if (output) { return; } const elements = parseToElements(message, this.handleExitClick); - this.addToOutput(elements, OutputType.ServerMessage); + this.addToOutput(elements, OutputType.ServerMessage, true, 'ansi', message); }; handleHtml = (html: string) => { const clean = DOMPurify.sanitize(html); - const e =
; - this.addToOutput([e], OutputType.ServerMessage); + + // Parse the cleaned HTML to detect blockquotes + const parser = new DOMParser(); + const doc = parser.parseFromString(clean, 'text/html'); + const blockquotes = doc.querySelectorAll('blockquote'); + + if (blockquotes.length > 0) { + // If we have blockquotes, we need to process them individually + const elements: React.ReactElement[] = []; + + // Split content around blockquotes + const bodyElement = doc.body; + let currentContent = ''; + + Array.from(bodyElement.childNodes).forEach((node, index) => { + if (node.nodeName === 'BLOCKQUOTE') { + // Add any accumulated content before this blockquote + if (currentContent.trim()) { + elements.push( +
+ ); + currentContent = ''; + } + + // Add the blockquote with copy functionality + const blockquoteElement = node as HTMLElement; + const contentType = blockquoteElement.getAttribute('data-content-type') || undefined; + + elements.push( + + {blockquoteElement.innerHTML} + + ); + } else { + // Accumulate non-blockquote content + if (node.nodeType === Node.ELEMENT_NODE) { + currentContent += (node as HTMLElement).outerHTML; + } else if (node.nodeType === Node.TEXT_NODE) { + currentContent += node.textContent || ''; + } + } + }); + + // Add any remaining content + if (currentContent.trim()) { + elements.push( +
+ ); + } + + this.addToOutput(elements, OutputType.ServerMessage, true, 'html', html); + } else { + // No blockquotes, use original logic + const e =
; + this.addToOutput([e], OutputType.ServerMessage, true, 'html', html); + } } handleExitClick = (exit: string) => { this.props.client.sendCommand(exit); }; - // --- Modified Method to handle both data-text links and copy buttons --- + // --- Method to handle data-text links --- handleDataTextClick = (event: React.MouseEvent) => { const targetElement = event.target as HTMLElement; - // --- Handle Blockquote Copy Button Clicks --- - const copyButton = targetElement.closest('.blockquote-copy-button'); - if (copyButton instanceof HTMLButtonElement) { - event.preventDefault(); // Prevent any default button behavior - event.stopPropagation(); // Stop the event from bubbling further - - const blockquote = copyButton.closest('blockquote'); - if (blockquote) { - // Clone the blockquote to avoid modifying the live DOM - const clonedBlockquote = blockquote.cloneNode(true) as HTMLElement; - // Find and remove the button *from the clone* - const buttonInClone = clonedBlockquote.querySelector('.blockquote-copy-button'); - if (buttonInClone) { - buttonInClone.remove(); - } - - let textToCopy: string; - const contentType = blockquote.dataset.contentType; // Check for data-content-type - - // Check if the content type is markdown - if (contentType === 'text/markdown') { - // Get the inner HTML of the clone (without the button) - const htmlContent = clonedBlockquote.innerHTML; - // Convert HTML to Markdown using Turndown - textToCopy = this.turndownService.turndown(htmlContent); - } else { - // Default behavior: Get text content from the clone - textToCopy = clonedBlockquote.textContent || ''; - } - - navigator.clipboard.writeText(textToCopy.trim()) - .then(() => { - // Visual feedback: Change text, add class, then revert (targets the original button) - copyButton.textContent = 'Copied!'; - copyButton.classList.add('copied'); - setTimeout(() => { - copyButton.textContent = 'Copy'; - copyButton.classList.remove('copied'); - }, 1500); // Revert after 1.5 seconds - }) - .catch(err => { - console.error('Failed to copy text: ', err); - // Optional: Provide error feedback to the user - copyButton.textContent = 'Error'; - setTimeout(() => { - copyButton.textContent = 'Copy'; - }, 1500); - }); - } - return; // Stop processing here if it was a copy button click - } - - // --- Handle data-text link clicks (existing logic) --- + // --- Handle data-text link clicks --- const linkElement = targetElement.closest('a.command[data-text]'); if (linkElement instanceof HTMLAnchorElement) { event.preventDefault(); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..64a9193 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/vitest.setup.ts'] + } +}); \ No newline at end of file