Skip to content

Commit 8863ae0

Browse files
Nakshatra SharmaNakshatra Sharma
authored andcommitted
feat: implement copy/paste buttons using nutjs clipboard API
- Add clipboard case to InputHandler with platform-aware modifier keys - Use try/finally to prevent stuck keys on copy and paste operations - Poll clipboard for changes after copy (up to 500ms) - Validate clipboard messages and enforce 1MB text limit in websocket - Forward clipboard content from server to client via WebSocket - Add clipboardText state and sendClipboard to useRemoteConnection hook - Wire onCopy/onPaste handlers in ControlBar and trackpad page - Add type='button' to all ControlBar buttons - Read device clipboard on paste with server-cache fallback Closes #97
1 parent 2436391 commit 8863ae0

File tree

5 files changed

+101
-27
lines changed

5 files changed

+101
-27
lines changed

src/components/Trackpad/ControlBar.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ interface ControlBarProps {
1010
onRightClick: () => void;
1111
onKeyboardToggle: () => void;
1212
onModifierToggle: () => void;
13+
onCopy: () => void;
14+
onPaste: () => void | Promise<void>;
1315
}
1416

1517
export const ControlBar: React.FC<ControlBarProps> = ({
@@ -21,6 +23,8 @@ export const ControlBar: React.FC<ControlBarProps> = ({
2123
onRightClick,
2224
onKeyboardToggle,
2325
onModifierToggle,
26+
onCopy,
27+
onPaste,
2428
}) => {
2529
const handleInteraction = (e: React.PointerEvent, action: () => void) => {
2630
e.preventDefault();
@@ -55,42 +59,42 @@ export const ControlBar: React.FC<ControlBarProps> = ({
5559
return (
5660
<div className="bg-base-200 p-2 grid grid-cols-5 gap-2 shrink-0">
5761
<button
62+
type="button"
5863
className={`btn btn-sm ${scrollMode ? "btn-primary" : "btn-outline"}`}
5964
onPointerDown={(e) => handleInteraction(e, onToggleScroll)}
6065
>
6166
{scrollMode ? "Scroll" : "Cursor"}
6267
</button>
6368
<button
69+
type="button"
6470
className="btn btn-sm btn-outline"
71+
onPointerDown={(e) => handleInteraction(e, onCopy)}
6572
>
6673
Copy
6774
</button>
6875
<button
76+
type="button"
6977
className="btn btn-sm btn-outline"
78+
onPointerDown={(e) => handleInteraction(e, onPaste)}
7079
>
7180
Paste
7281
</button>
73-
{/*
74-
<button
75-
className="btn btn-sm btn-outline"
76-
onPointerDown={(e) => handleInteraction(e, onLeftClick)}
77-
>
78-
L-Click
79-
</button>
80-
*/}
8182
<button
83+
type="button"
8284
className="btn btn-sm btn-outline"
8385
onPointerDown={(e) => handleInteraction(e, onRightClick)}
8486
>
8587
R-Click
8688
</button>
8789
<button
90+
type="button"
8891
className={`btn btn-sm ${getModifierButtonClass()}`}
8992
onPointerDown={(e) => handleInteraction(e, onModifierToggle)}
9093
>
9194
{getModifierLabel()}
9295
</button>
9396
<button
97+
type="button"
9498
className="btn btn-sm btn-secondary"
9599
onPointerDown={(e) => handleInteraction(e, onKeyboardToggle)}
96100
>

src/hooks/useRemoteConnection.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
33
export const useRemoteConnection = () => {
44
const wsRef = useRef<WebSocket | null>(null);
55
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('disconnected');
6+
const [clipboardText, setClipboardText] = useState('');
67

78
useEffect(() => {
89
let isMounted = true;
@@ -56,6 +57,15 @@ export const useRemoteConnection = () => {
5657
};
5758

5859
wsRef.current = socket;
60+
61+
socket.onmessage = (event) => {
62+
try {
63+
const data = JSON.parse(event.data);
64+
if (isMounted && data.type === 'clipboard-content' && typeof data.text === 'string') {
65+
setClipboardText(data.text);
66+
}
67+
} catch { /* ignore non-JSON or irrelevant messages */ }
68+
};
5969
};
6070

6171
// Defer to next tick so React Strict Mode's immediate unmount
@@ -71,6 +81,7 @@ export const useRemoteConnection = () => {
7181
wsRef.current.onopen = null;
7282
wsRef.current.onclose = null;
7383
wsRef.current.onerror = null;
84+
wsRef.current.onmessage = null;
7485
wsRef.current.close();
7586
wsRef.current = null;
7687
}
@@ -83,7 +94,7 @@ export const useRemoteConnection = () => {
8394
}
8495
}, []);
8596

86-
const sendCombo = useCallback((msg: string[]) => {
97+
const sendCombo = useCallback((msg: readonly string[]) => {
8798
if (wsRef.current?.readyState === WebSocket.OPEN) {
8899
wsRef.current.send(JSON.stringify({
89100
type: "combo",
@@ -92,5 +103,15 @@ export const useRemoteConnection = () => {
92103
}
93104
}, []);
94105

95-
return { status, send, sendCombo };
106+
const sendClipboard = useCallback((action: 'copy' | 'paste', text?: string) => {
107+
if (wsRef.current?.readyState === WebSocket.OPEN) {
108+
wsRef.current.send(JSON.stringify({
109+
type: 'clipboard',
110+
action,
111+
...(text !== undefined && { text }),
112+
}));
113+
}
114+
}, []);
115+
116+
return { status, send, sendCombo, clipboardText, sendClipboard };
96117
};

src/routes/trackpad.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,21 @@ function TrackpadPage() {
1919
const bufferText = buffer.join(" + ");
2020
const hiddenInputRef = useRef<HTMLInputElement>(null);
2121
const isComposingRef = useRef(false);
22-
22+
2323
// Load Client Settings
2424
const [sensitivity] = useState(() => {
2525
if (typeof window === 'undefined') return 1.0;
2626
const s = localStorage.getItem('rein_sensitivity');
2727
return s ? parseFloat(s) : 1.0;
2828
});
29-
29+
3030
const [invertScroll] = useState(() => {
3131
if (typeof window === 'undefined') return false;
3232
const s = localStorage.getItem('rein_invert');
3333
return s ? JSON.parse(s) : false;
3434
});
3535

36-
const { status, send, sendCombo } = useRemoteConnection();
36+
const { status, send, sendCombo, clipboardText, sendClipboard } = useRemoteConnection();
3737
// Pass sensitivity and invertScroll to the gesture hook
3838
const { isTracking, handlers } = useTrackpadGesture(send, scrollMode, sensitivity, invertScroll);
3939

@@ -49,7 +49,7 @@ function TrackpadPage() {
4949

5050
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
5151
const key = e.key.toLowerCase();
52-
52+
5353
if (modifier !== "Release") {
5454
if (key === 'backspace') {
5555
e.preventDefault();
@@ -76,7 +76,7 @@ function TrackpadPage() {
7676
};
7777

7878
const handleModifierState = () => {
79-
switch(modifier){
79+
switch (modifier) {
8080
case "Active":
8181
if (buffer.length > 0) {
8282
setModifier("Hold");
@@ -97,7 +97,7 @@ function TrackpadPage() {
9797

9898
const handleModifier = (key: string) => {
9999
console.log(`handleModifier called with key: ${key}, current modifier: ${modifier}, buffer:`, buffer);
100-
100+
101101
if (modifier === "Hold") {
102102
const comboKeys = [...buffer, key];
103103
console.log(`Sending combo:`, comboKeys);
@@ -139,7 +139,7 @@ function TrackpadPage() {
139139
// Don't send text during modifier mode
140140
if (modifier !== "Release") {
141141
handleModifier(val);
142-
}else{
142+
} else {
143143
sendText(val);
144144
}
145145
(e.target as HTMLInputElement).value = '';
@@ -177,6 +177,15 @@ function TrackpadPage() {
177177
onRightClick={() => handleClick('right')}
178178
onKeyboardToggle={focusInput}
179179
onModifierToggle={handleModifierState}
180+
onCopy={() => sendClipboard('copy')}
181+
onPaste={async () => {
182+
let text = clipboardText;
183+
try {
184+
const deviceText = await navigator.clipboard.readText();
185+
if (deviceText) text = deviceText;
186+
} catch { /* fallback to server-origin cache */ }
187+
sendClipboard('paste', text);
188+
}}
180189
/>
181190

182191
{/* Extra Keys */}

src/server/InputHandler.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { mouse, Point, Button, keyboard, Key } from '@nut-tree-fork/nut-js';
1+
import { mouse, Point, Button, keyboard, Key, clipboard } from '@nut-tree-fork/nut-js';
22
import { KEY_MAP } from './KeyMap';
33

44
export interface InputMessage {
5-
type: 'move' | 'click' | 'scroll' | 'key' | 'text' | 'zoom' | 'combo';
5+
type: 'move' | 'click' | 'scroll' | 'key' | 'text' | 'zoom' | 'combo' | 'clipboard';
66
dx?: number;
77
dy?: number;
88
button?: 'left' | 'right' | 'middle';
@@ -11,6 +11,7 @@ export interface InputMessage {
1111
keys?: string[];
1212
text?: string;
1313
delta?: number;
14+
action?: 'copy' | 'paste';
1415
}
1516

1617
export class InputHandler {
@@ -25,7 +26,7 @@ export class InputHandler {
2526
mouse.config.mouseSpeed = 1000;
2627
}
2728

28-
async handleMessage(msg: InputMessage) {
29+
async handleMessage(msg: InputMessage): Promise<string | void> {
2930
// Validation: Text length sanitation
3031
if (msg.text && msg.text.length > 500) {
3132
msg.text = msg.text.substring(0, 500);
@@ -52,8 +53,8 @@ export class InputHandler {
5253
const pending = this.pendingMove;
5354
this.pendingMove = null;
5455
this.handleMessage(pending).catch((err) => {
55-
console.error('Error processing pending move event:', err);
56-
});
56+
console.error('Error processing pending move event:', err);
57+
});
5758
}
5859
}, 8);
5960
}
@@ -71,8 +72,8 @@ export class InputHandler {
7172
const pending = this.pendingScroll;
7273
this.pendingScroll = null;
7374
this.handleMessage(pending).catch((err) => {
74-
console.error('Error processing pending move event:', err);
75-
});
75+
console.error('Error processing pending move event:', err);
76+
});
7677
}
7778
}, 8);
7879
}
@@ -104,8 +105,8 @@ export class InputHandler {
104105
msg.button === 'left'
105106
? Button.LEFT
106107
: msg.button === 'right'
107-
? Button.RIGHT
108-
: Button.MIDDLE;
108+
? Button.RIGHT
109+
: Button.MIDDLE;
109110

110111
if (msg.press) {
111112
await mouse.pressButton(btn);
@@ -236,6 +237,35 @@ export class InputHandler {
236237
}
237238
break;
238239

240+
case 'clipboard': {
241+
const modifier = process.platform === 'darwin' ? Key.LeftSuper : Key.LeftControl;
242+
if (msg.action === 'copy') {
243+
const before = await clipboard.getContent();
244+
try {
245+
await keyboard.pressKey(modifier, Key.C);
246+
} finally {
247+
await keyboard.releaseKey(modifier, Key.C);
248+
}
249+
// poll until clipboard changes or ~500ms elapses
250+
for (let i = 0; i < 10; i++) {
251+
await new Promise(r => setTimeout(r, 50));
252+
const content = await clipboard.getContent();
253+
if (content !== before) return content;
254+
}
255+
return before;
256+
} else if (msg.action === 'paste') {
257+
if (msg.text) {
258+
await clipboard.setContent(msg.text);
259+
}
260+
try {
261+
await keyboard.pressKey(modifier, Key.V);
262+
} finally {
263+
await keyboard.releaseKey(modifier, Key.V);
264+
}
265+
}
266+
break;
267+
}
268+
239269
case 'text':
240270
if (msg.text) {
241271
await keyboard.type(msg.text);

src/server/websocket.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import fs from 'fs';
66
import { IncomingMessage } from 'http';
77
import { Socket } from 'net';
88

9+
const MAX_CLIPBOARD_LENGTH = 1_000_000;
10+
911
function getLocalIp(): string {
1012
const nets = os.networkInterfaces();
1113
for (const name of Object.keys(nets)) {
@@ -131,7 +133,15 @@ export function createWsServer(server: any) {
131133
return;
132134
}
133135

134-
await inputHandler.handleMessage(msg as InputMessage);
136+
if (msg.type === 'clipboard') {
137+
if (msg.action !== 'copy' && msg.action !== 'paste') return;
138+
if (msg.action === 'paste' && typeof msg.text === 'string' && msg.text.length > MAX_CLIPBOARD_LENGTH) return;
139+
}
140+
141+
const result = await inputHandler.handleMessage(msg as InputMessage);
142+
if (typeof result === 'string') {
143+
ws.send(JSON.stringify({ type: 'clipboard-content', text: result }));
144+
}
135145
} catch (err) {
136146
console.error('Error processing message:', err);
137147
}

0 commit comments

Comments
 (0)