Skip to content

Commit d8d4b6e

Browse files
committed
feat(frontend): add clipboard support to terminal for enhanced paste functionality
1 parent 9673a14 commit d8d4b6e

3 files changed

Lines changed: 136 additions & 8 deletions

File tree

frontend/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
"version": "1.0.0-alpha",
44
"description": "Webmud3 Frontend",
55
"license": "MIT",
6-
"authors": ["Myonara", "Felag"],
6+
"authors": [
7+
"Myonara",
8+
"Felag"
9+
],
710
"engines": {
811
"node": "~22.20.0"
912
},
@@ -34,6 +37,7 @@
3437
"@angular/platform-browser-dynamic": "~20.3.4",
3538
"@angular/router": "~20.3.4",
3639
"@xterm/addon-attach": "~0.11.0",
40+
"@xterm/addon-clipboard": "~0.2.0",
3741
"@xterm/addon-fit": "~0.10.0",
3842
"@xterm/xterm": "~5.5.0",
3943
"normalize.css": "~8.0.1",

frontend/src/app/core/mud/components/mud-client/mud-client.component.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ViewChild,
88
} from '@angular/core';
99
import { AttachAddon } from '@xterm/addon-attach';
10+
import { ClipboardAddon } from '@xterm/addon-clipboard';
1011
import { FitAddon } from '@xterm/addon-fit';
1112
import { IDisposable, Terminal } from '@xterm/xterm';
1213
import { Subscription } from 'rxjs';
@@ -66,6 +67,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
6667
private readonly inputController: MudInputController;
6768
private readonly promptManager: MudPromptManager;
6869
private screenReader?: MudScreenReaderAnnouncer;
70+
private readonly terminalClipboardAddon = new ClipboardAddon();
6971
private readonly terminalFitAddon = new FitAddon();
7072
private socketAdapter?: MudSocketAdapter;
7173
private terminalAttachAddon?: AttachAddon;
@@ -89,6 +91,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
8991

9092
private showEchoSubscription?: Subscription;
9193
private linemodeSubscription?: Subscription;
94+
private pasteHandler?: (event: ClipboardEvent) => void;
9295
private state: MudClientState = {
9396
isEditMode: true,
9497
showEcho: true,
@@ -172,6 +175,7 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
172175
this.applyResponsiveFontSize(window.innerWidth);
173176

174177
this.terminal.open(this.terminalRef.nativeElement);
178+
this.terminal.loadAddon(this.terminalClipboardAddon);
175179
this.terminal.loadAddon(this.terminalFitAddon);
176180
this.terminal.loadAddon(this.terminalAttachAddon);
177181
this.terminal.focus();
@@ -187,6 +191,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
187191
this.helperTextarea.setAttribute('aria-label', 'Eingabe');
188192
}
189193

194+
// Set up paste handler on terminal container (for native paste events)
195+
this.setupPasteHandler(this.terminalRef.nativeElement);
196+
190197
this.terminalDisposables.push(
191198
this.terminal.onData((data) => this.handleInput(data)),
192199
this.terminal.onBell(() => this.playBell()),
@@ -227,10 +234,22 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
227234
this.handleVisibilityChange,
228235
);
229236

237+
// Unregister paste handler
238+
if (this.pasteHandler) {
239+
if (this.terminalRef?.nativeElement) {
240+
this.terminalRef.nativeElement.removeEventListener(
241+
'paste',
242+
this.pasteHandler,
243+
);
244+
}
245+
document.removeEventListener('paste', this.pasteHandler);
246+
}
247+
230248
this.terminalDisposables.forEach((disposable) => disposable.dispose());
231249
this.showEchoSubscription?.unsubscribe();
232250
this.linemodeSubscription?.unsubscribe();
233251

252+
this.terminalClipboardAddon.dispose();
234253
this.terminalAttachAddon?.dispose();
235254
this.socketAdapter?.dispose();
236255
this.terminal.dispose();
@@ -343,8 +362,23 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
343362
/**
344363
* Routes terminal keystrokes either directly to the socket (when not in edit mode)
345364
* or through the {@link MudInputController}.
365+
* Special handling for Ctrl+V: intercepts clipboard content and injects it properly.
346366
*/
347367
private handleInput(data: string) {
368+
console.log('[PASTE-DEBUG] Edit mode:', this.state.isEditMode);
369+
console.log('[PASTE-DEBUG] Data received:', JSON.stringify(data));
370+
console.log('[PASTE-DEBUG] Data length:', data.length);
371+
372+
// Special handling for Ctrl+V (paste): xterm converts paste to \u0016 in onData()
373+
// We need to read the clipboard and inject the actual content
374+
if (data === '\u0016') {
375+
console.log(
376+
'[MudClient] Ctrl+V detected, reading clipboard from native event...',
377+
);
378+
this.handlePasteFromClipboard();
379+
return;
380+
}
381+
348382
// Unlock audio context on first user interaction (browser autoplay policy)
349383
if (!this.audioUnlocked) {
350384
this.unlockAudio();
@@ -595,4 +629,71 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
595629

596630
this.helperTextarea.value = `${prompt}${effectiveBuffer ?? ''}`;
597631
}
632+
633+
/**
634+
* Sets up a paste event handler to intercept native clipboard paste operations.
635+
* This method listens on the terminal container for paste events that come
636+
* directly from the browser (Ctrl+V, right-click paste, etc.) without requiring
637+
* explicit clipboard API permissions.
638+
*
639+
* The paste event automatically includes clipboard access through event.clipboardData,
640+
* so no navigator.clipboard.readText() call is needed.
641+
*/
642+
private setupPasteHandler(element: HTMLElement): void {
643+
this.pasteHandler = (event: ClipboardEvent) => {
644+
console.log('[MudClient] Native paste event intercepted');
645+
646+
// Don't prevent default for now - let xterm handle the visual part
647+
// We'll just read the clipboard data and inject it properly
648+
const pastedText = event.clipboardData?.getData('text/plain');
649+
650+
console.log('[MudClient] Clipboard content:', {
651+
length: pastedText?.length ?? 0,
652+
preview: pastedText?.substring(0, 50),
653+
});
654+
655+
if (pastedText) {
656+
// Prevent the default onData behavior (which sends \u0016 only)
657+
event.preventDefault();
658+
659+
if (!this.state.isEditMode) {
660+
// In non-edit mode, send paste content directly to server
661+
this.mudService.sendMessage(pastedText);
662+
} else {
663+
// In edit mode, route through input controller for buffering and echo
664+
this.inputController.handleData(pastedText);
665+
}
666+
}
667+
};
668+
669+
element.addEventListener('paste', this.pasteHandler);
670+
}
671+
672+
/**
673+
* Handles paste by reading clipboard content when Ctrl+V is detected.
674+
* Uses the Clipboard API which is safe to call here since it's triggered
675+
* by a user gesture (Ctrl+V keypress).
676+
*/
677+
private async handlePasteFromClipboard(): Promise<void> {
678+
try {
679+
const pastedText = await navigator.clipboard.readText();
680+
681+
console.log('[MudClient] Clipboard content read:', {
682+
length: pastedText.length,
683+
preview: pastedText.substring(0, 50),
684+
});
685+
686+
if (pastedText) {
687+
if (!this.state.isEditMode) {
688+
// In non-edit mode, send paste content directly to server
689+
this.mudService.sendMessage(pastedText);
690+
} else {
691+
// In edit mode, route through input controller for buffering and echo
692+
this.inputController.handleData(pastedText);
693+
}
694+
}
695+
} catch (err) {
696+
console.error('[MudClient] Failed to read clipboard:', err);
697+
}
698+
}
598699
}

package-lock.json

Lines changed: 30 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)