diff --git a/lib/addons/image-paste.test.ts b/lib/addons/image-paste.test.ts new file mode 100644 index 0000000..b2710f6 --- /dev/null +++ b/lib/addons/image-paste.test.ts @@ -0,0 +1,181 @@ +/** + * Test suite for ImagePasteAddon + */ + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { ImagePasteAddon } from './image-paste'; + +// ============================================================================ +// Mock Terminal Implementation +// ============================================================================ + +class MockTerminal { + public element?: HTMLElement; + public cols = 80; + public rows = 24; +} + +// ============================================================================ +// Test Suite +// ============================================================================ + +describe('ImagePasteAddon', () => { + let addon: ImagePasteAddon; + let terminal: MockTerminal; + + beforeEach(() => { + addon = new ImagePasteAddon(); + terminal = new MockTerminal(); + }); + + afterEach(() => { + addon.dispose(); + }); + + // ========================================================================== + // Activation & Disposal Tests + // ========================================================================== + + test('activates successfully', () => { + expect(() => addon.activate(terminal as any)).not.toThrow(); + }); + + test('activates with element and attaches paste listener', () => { + terminal.element = document.createElement('div'); + expect(() => addon.activate(terminal as any)).not.toThrow(); + }); + + test('disposes successfully', () => { + addon.activate(terminal as any); + expect(() => addon.dispose()).not.toThrow(); + }); + + test('disposes with element cleans up listener', () => { + terminal.element = document.createElement('div'); + addon.activate(terminal as any); + expect(() => addon.dispose()).not.toThrow(); + }); + + test('can activate and dispose multiple times', () => { + addon.activate(terminal as any); + addon.dispose(); + addon = new ImagePasteAddon(); + addon.activate(terminal as any); + addon.dispose(); + }); + + // ========================================================================== + // Event Tests + // ========================================================================== + + test('onImagePaste is a subscribable event', () => { + const disposable = addon.onImagePaste(() => {}); + expect(disposable).toBeDefined(); + expect(typeof disposable.dispose).toBe('function'); + disposable.dispose(); + }); + + test('fires onImagePaste when image is pasted', (done) => { + terminal.element = document.createElement('div'); + addon.activate(terminal as any); + + addon.onImagePaste((data) => { + expect(data.name).toMatch(/^clipboard_\d+\.png$/); + expect(data.dataBase64).toBe('aW1hZ2VkYXRh'); + done(); + }); + + // Create a mock paste event with an image file + const mockFile = new File(['imagedata'], 'test.png', { type: 'image/png' }); + + // Mock FileReader to return synchronously for testing + const originalFileReader = globalThis.FileReader; + class MockFileReader { + onload: (() => void) | null = null; + result: string | null = null; + + readAsDataURL(_file: File) { + this.result = 'data:image/png;base64,aW1hZ2VkYXRh'; + if (this.onload) this.onload(); + } + } + globalThis.FileReader = MockFileReader as any; + + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(mockFile); + + const pasteEvent = new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }); + + terminal.element.dispatchEvent(pasteEvent); + + // Restore + globalThis.FileReader = originalFileReader; + }); + + test('does not fire for non-image pastes', () => { + terminal.element = document.createElement('div'); + addon.activate(terminal as any); + + let fired = false; + addon.onImagePaste(() => { + fired = true; + }); + + // Paste event with only text + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', 'hello'); + + const pasteEvent = new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }); + + terminal.element.dispatchEvent(pasteEvent); + expect(fired).toBe(false); + }); + + test('dispose removes paste listener', () => { + terminal.element = document.createElement('div'); + addon.activate(terminal as any); + + let fired = false; + addon.onImagePaste(() => { + fired = true; + }); + + addon.dispose(); + + // Dispatch after dispose - should not fire + const mockFile = new File(['imagedata'], 'test.png', { type: 'image/png' }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(mockFile); + + const pasteEvent = new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }); + + terminal.element.dispatchEvent(pasteEvent); + expect(fired).toBe(false); + }); + + // ========================================================================== + // Integration Tests + // ========================================================================== + + test('full workflow: activate → subscribe → dispose', () => { + terminal.element = document.createElement('div'); + addon.activate(terminal as any); + + const disposable = addon.onImagePaste(() => {}); + disposable.dispose(); + + addon.dispose(); + }); +}); diff --git a/lib/addons/image-paste.ts b/lib/addons/image-paste.ts new file mode 100644 index 0000000..132761e --- /dev/null +++ b/lib/addons/image-paste.ts @@ -0,0 +1,107 @@ +/** + * ImagePasteAddon - Handle image paste events + * + * Listens for paste events containing image data and emits them as + * base64-encoded payloads. This is a ghostty-web extension addon, + * not part of the xterm.js core API. + * + * Usage: + * ```typescript + * const imagePasteAddon = new ImagePasteAddon(); + * term.loadAddon(imagePasteAddon); + * + * imagePasteAddon.onImagePaste((data) => { + * console.log(data.name); // e.g. "clipboard_1234567890.png" + * console.log(data.dataBase64); // base64-encoded image data + * }); + * ``` + */ + +import type { IDisposable, IEvent, ITerminalAddon, ITerminalCore } from '../interfaces'; +import { EventEmitter } from '../event-emitter'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface IImagePasteData { + name: string; + dataBase64: string; +} + +// ============================================================================ +// ImagePasteAddon Class +// ============================================================================ + +export class ImagePasteAddon implements ITerminalAddon { + private _terminal?: ITerminalCore; + private _pasteListener: ((e: ClipboardEvent) => void) | null = null; + private _emitter = new EventEmitter(); + + /** + * Event fired when an image is pasted from the clipboard. + */ + public readonly onImagePaste: IEvent = this._emitter.event; + + /** + * Activate the addon (called by Terminal.loadAddon) + */ + public activate(terminal: ITerminalCore): void { + this._terminal = terminal; + + const element = terminal.element; + if (element) { + this._attachListener(element); + } + } + + /** + * Dispose the addon and clean up resources + */ + public dispose(): void { + this._detachListener(); + this._emitter.dispose(); + this._terminal = undefined; + } + + private _attachListener(element: HTMLElement): void { + this._pasteListener = (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + if (!clipboardData?.items) return; + + for (const item of Array.from(clipboardData.items)) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + event.preventDefault(); + event.stopPropagation(); + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + if (base64) { + const ext = file.type.split('/')[1] || 'png'; + this._emitter.fire({ + name: `clipboard_${Date.now()}.${ext}`, + dataBase64: base64, + }); + } + }; + reader.readAsDataURL(file); + return; + } + } + } + }; + + element.addEventListener('paste', this._pasteListener); + } + + private _detachListener(): void { + if (this._pasteListener && this._terminal?.element) { + this._terminal.element.removeEventListener('paste', this._pasteListener); + } + this._pasteListener = null; + } +} diff --git a/lib/index.ts b/lib/index.ts index b46e05b..50e7aab 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -93,6 +93,8 @@ export type { SelectionCoordinates } from './selection-manager'; // Addons export { FitAddon } from './addons/fit'; export type { ITerminalDimensions } from './addons/fit'; +export { ImagePasteAddon } from './addons/image-paste'; +export type { IImagePasteData } from './addons/image-paste'; // Link providers export { OSC8LinkProvider } from './providers/osc8-link-provider'; diff --git a/lib/input-handler.ts b/lib/input-handler.ts index 83d6f3f..83ee10f 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -562,24 +562,23 @@ export class InputHandler { private handlePaste(event: ClipboardEvent): void { if (this.isDisposed) return; - // Prevent default paste behavior - event.preventDefault(); - event.stopPropagation(); - // Get clipboard data const clipboardData = event.clipboardData; if (!clipboardData) { - console.warn('No clipboard data available'); return; } - // Get text from clipboard + // Get text from clipboard — if there's no text (e.g. image-only paste), + // let the event continue bubbling so addons like ImagePasteAddon can handle it. const text = clipboardData.getData('text/plain'); if (!text) { - console.warn('No text in clipboard'); return; } + // We have text to handle — claim the event + event.preventDefault(); + event.stopPropagation(); + if (this.shouldIgnorePasteEvent(text, 'paste')) { return; }