feat: native clipboard support — Ctrl+Shift+C to copy, Ctrl+Shift+V to paste
Uses xterm.js attachCustomKeyEventHandler to intercept Ctrl+Shift+C/V. Copy: getSelection() → navigator.clipboard.writeText (with execCommand fallback for non-HTTPS contexts). Paste: navigator.clipboard.readText() → send to ttyd via WebSocket binary protocol (0x30 prefix). Ctrl+C/V continue to work as normal terminal keys (SIGINT / literal paste). The Shift modifier distinguishes clipboard from terminal operations. Also hoists encodePayload + TextEncoder to module level (_encodePayload, _encoder) so the clipboard handler can reuse them without duplication.
This commit is contained in:
@@ -10,6 +10,39 @@ let _currentSession = null;
|
|||||||
let _vpHandler = null;
|
let _vpHandler = null;
|
||||||
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
||||||
|
|
||||||
|
// ─── Module-level encoding helpers ──────────────────────────────────────────
|
||||||
|
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
||||||
|
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
||||||
|
|
||||||
|
function _encodePayload(typeChar, str) {
|
||||||
|
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
||||||
|
var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
|
||||||
|
var payload = new Uint8Array(1 + strBytes.length);
|
||||||
|
payload[0] = typeChar;
|
||||||
|
payload.set(strBytes, 1);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Clipboard helpers ───────────────────────────────────────────────────────
|
||||||
|
// Ctrl+Shift+C: copy terminal selection to system clipboard
|
||||||
|
// Ctrl+Shift+V: paste from system clipboard into terminal
|
||||||
|
|
||||||
|
function _copyToClipboard(text) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).catch(function() {});
|
||||||
|
} else {
|
||||||
|
// Fallback for non-HTTPS contexts (HTTP over LAN)
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.left = '-9999px';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); } catch(e) {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Forward declarations ─────────────────────────────────────────────────────
|
// ─── Forward declarations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function connectWebSocket(name, sourceUrl) {
|
function connectWebSocket(name, sourceUrl) {
|
||||||
@@ -23,16 +56,8 @@ function connectWebSocket(name, sourceUrl) {
|
|||||||
url = proto + '//' + location.host + '/terminal/ws';
|
url = proto + '//' + location.host + '/terminal/ws';
|
||||||
}
|
}
|
||||||
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
||||||
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
// Use module-level _encodePayload (hoisted above connectWebSocket)
|
||||||
|
var encodePayload = _encodePayload;
|
||||||
function encodePayload(typeChar, str) {
|
|
||||||
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
|
||||||
var strBytes = encoder ? encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
|
|
||||||
var payload = new Uint8Array(1 + strBytes.length);
|
|
||||||
payload[0] = typeChar;
|
|
||||||
payload.set(strBytes, 1);
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register terminal event handlers once on this _term instance.
|
// Register terminal event handlers once on this _term instance.
|
||||||
// These handlers read the module-level _ws at call time (not a captured reference),
|
// These handlers read the module-level _ws at call time (not a captured reference),
|
||||||
@@ -259,6 +284,34 @@ function openTerminal(sessionName, sourceUrl) {
|
|||||||
|
|
||||||
_term.open(container);
|
_term.open(container);
|
||||||
|
|
||||||
|
// --- Clipboard integration ---
|
||||||
|
// Ctrl+Shift+C: copy selection to system clipboard (Ctrl+C still sends SIGINT)
|
||||||
|
// Ctrl+Shift+V: paste from system clipboard (Ctrl+V still sends literal bytes)
|
||||||
|
_term.attachCustomKeyEventHandler(function(e) {
|
||||||
|
if (e.type !== 'keydown') return true;
|
||||||
|
|
||||||
|
// Ctrl+Shift+C → copy selection to clipboard
|
||||||
|
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
|
||||||
|
var sel = _term.getSelection();
|
||||||
|
if (sel) _copyToClipboard(sel);
|
||||||
|
return false; // prevent xterm from processing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Shift+V → paste from clipboard into terminal
|
||||||
|
if (e.ctrlKey && e.shiftKey && (e.key === 'V' || e.code === 'KeyV')) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||||
|
navigator.clipboard.readText().then(function(text) {
|
||||||
|
if (text && _ws && _ws.readyState === WebSocket.OPEN) {
|
||||||
|
_ws.send(_encodePayload(0x30, text));
|
||||||
|
}
|
||||||
|
}).catch(function() {});
|
||||||
|
}
|
||||||
|
return false; // prevent xterm from processing
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // let xterm handle all other keys normally
|
||||||
|
});
|
||||||
|
|
||||||
if (_fitAddon) {
|
if (_fitAddon) {
|
||||||
// requestAnimationFrame guarantees one full browser layout pass after the flex
|
// requestAnimationFrame guarantees one full browser layout pass after the flex
|
||||||
// container becomes visible before fit() measures dimensions.
|
// container becomes visible before fit() measures dimensions.
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ function loadTerminal() {
|
|||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
write: (data) => { termWriteMessages.push(data); },
|
write: (data) => { termWriteMessages.push(data); },
|
||||||
focus: () => { focusCallCount++; },
|
focus: () => { focusCallCount++; },
|
||||||
|
attachCustomKeyEventHandler: () => {},
|
||||||
|
getSelection: () => '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture all messages sent via WebSocket.send()
|
// Capture all messages sent via WebSocket.send()
|
||||||
@@ -331,6 +333,8 @@ function createMultiSessionEnv() {
|
|||||||
loadAddon: () => {},
|
loadAddon: () => {},
|
||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
focus: () => {},
|
focus: () => {},
|
||||||
|
attachCustomKeyEventHandler: () => {},
|
||||||
|
getSelection: () => '',
|
||||||
writeMessages: [],
|
writeMessages: [],
|
||||||
};
|
};
|
||||||
t.write = (data) => t.writeMessages.push(data);
|
t.write = (data) => t.writeMessages.push(data);
|
||||||
@@ -882,6 +886,17 @@ test('terminal.js resets _reconnectAttempts on first message, not on open', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Clipboard integration ---
|
||||||
|
|
||||||
|
test('terminal.js has clipboard integration with Ctrl+Shift+C and Ctrl+Shift+V', () => {
|
||||||
|
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
|
||||||
|
assert.ok(source.includes('attachCustomKeyEventHandler'), 'must register custom key handler');
|
||||||
|
assert.ok(source.includes('getSelection'), 'must use getSelection() for copy');
|
||||||
|
assert.ok(source.includes('clipboard'), 'must interact with clipboard API');
|
||||||
|
assert.ok(source.includes('Shift'), 'must use Shift modifier to avoid conflict with terminal Ctrl+C/V');
|
||||||
|
assert.ok(source.includes('_copyToClipboard') || source.includes('writeText'), 'must have copy mechanism');
|
||||||
|
});
|
||||||
|
|
||||||
// --- Issue 4: setTerminalFontSize ---
|
// --- Issue 4: setTerminalFontSize ---
|
||||||
|
|
||||||
test('terminal.js exposes window._setTerminalFontSize function', () => {
|
test('terminal.js exposes window._setTerminalFontSize function', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user