Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions lib/addons/image-paste.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
107 changes: 107 additions & 0 deletions lib/addons/image-paste.ts
Original file line number Diff line number Diff line change
@@ -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<IImagePasteData>();

/**
* Event fired when an image is pasted from the clipboard.
*/
public readonly onImagePaste: IEvent<IImagePasteData> = 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;
}
}
2 changes: 2 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 6 additions & 7 deletions lib/input-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down