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