hop2 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +6 -2
- package/hay/apps/server/src/rooms.ts +7 -0
- package/hay/apps/server/test/rooms.test.ts +21 -0
- package/hay/apps/web/src/App.tsx +113 -20
- package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +289 -52
- package/hay/apps/web/src/styles.css +218 -41
- package/hay/apps/web/src/utils/switcherModel.ts +41 -4
- package/hay/apps/web/test/SessionSwitcher.test.tsx +51 -0
- package/hay/apps/web/test/switcherModel.test.ts +65 -0
- package/hay-web/assets/{index--cf1ILsi.css → index-BohAWpcL.css} +1 -1
- package/hay-web/assets/index-DfGT3DuO.js +130 -0
- package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +149 -12
- package/package.json +1 -1
- package/scripts/hay-host.js +3 -1
- package/hay-web/assets/index-_wMnETjm.js +0 -130
- package/hay-web/assets/index-_wMnETjm.js.map +0 -1
|
@@ -64,8 +64,12 @@ jobs:
|
|
|
64
64
|
node-version: 22
|
|
65
65
|
registry-url: https://registry.npmjs.org
|
|
66
66
|
|
|
67
|
-
# Trusted publishing needs npm >= 11.5.1
|
|
68
|
-
|
|
67
|
+
# Trusted publishing needs npm >= 11.5.1 and the runner ships older.
|
|
68
|
+
# Pinned to the 11.x line rather than @latest: npm 12 requires Node
|
|
69
|
+
# >= 22.22, so `npm@latest` breaks the moment npm raises its engine
|
|
70
|
+
# floor above whatever Node this workflow pins — which is exactly how
|
|
71
|
+
# the first nebula-notebook publish failed.
|
|
72
|
+
- run: npm install -g npm@^11
|
|
69
73
|
|
|
70
74
|
# Nothing new since the last nightly? Then there is nothing to publish —
|
|
71
75
|
# quiet days should not mint versions. npm records the commit each
|
|
@@ -28,6 +28,12 @@ export type ClientInfo = {
|
|
|
28
28
|
// Optional per-connection cap for the join snapshot (clamped to the
|
|
29
29
|
// room-level bound). Monitors request ~64KB.
|
|
30
30
|
replayBytes?: number;
|
|
31
|
+
// False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
|
|
32
|
+
// nudge). Wall tiles decline it: they arrive already showing the current
|
|
33
|
+
// grid (the daemon's headless screen, complete — not a raw byte tail), so
|
|
34
|
+
// the nudge's two SIGWINCHes only produce a visible reflow of the running
|
|
35
|
+
// app at the exact moment the tile goes live — the "twitch".
|
|
36
|
+
nudge?: boolean;
|
|
31
37
|
};
|
|
32
38
|
|
|
33
39
|
export type RoomCreateOptions = {
|
|
@@ -371,6 +377,7 @@ export class Room extends EventEmitter {
|
|
|
371
377
|
// anyway; when sizes are EQUAL nothing would repaint, so nudge: a one-
|
|
372
378
|
// column wiggle (tmux's redraw trick) makes full-screen apps repaint.
|
|
373
379
|
if (
|
|
380
|
+
info.nudge !== false &&
|
|
374
381
|
client.cols === this.activeCols &&
|
|
375
382
|
client.rows === this.activeRows &&
|
|
376
383
|
this.activeCols > 4 &&
|
|
@@ -100,6 +100,27 @@ describe("Room", () => {
|
|
|
100
100
|
expect(capturedOptions?.shell).toBe("/bin/zsh");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it("an equal-size attach nudges a repaint unless the client declines", () => {
|
|
104
|
+
let ptyInstance: FakePty | null = null;
|
|
105
|
+
const factory: PtyFactory = () => {
|
|
106
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
107
|
+
return ptyInstance as any;
|
|
108
|
+
};
|
|
109
|
+
const manager = new RoomManager(factory);
|
|
110
|
+
const room = manager.getRoom("wiggle", { cols: 80, rows: 24 }, "/tmp");
|
|
111
|
+
|
|
112
|
+
// Default attach at the PTY's exact size: the −1 wiggle fires (the
|
|
113
|
+
// bounce-back half is on a timer; the first resize is the evidence).
|
|
114
|
+
room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, new FakeSocket());
|
|
115
|
+
expect(ptyInstance!.resizes.some((r: { cols: number }) => r.cols === 79)).toBe(true);
|
|
116
|
+
|
|
117
|
+
// A wall tile declines (nudge: false): it already shows the current
|
|
118
|
+
// grid, and the wiggle's SIGWINCH reflow is the click-twitch. No resize.
|
|
119
|
+
const before = ptyInstance!.resizes.length;
|
|
120
|
+
room.attachClient({ id: "b", name: "Tile", colorIndex: 1, cols: 80, rows: 24, nudge: false }, new FakeSocket());
|
|
121
|
+
expect(ptyInstance!.resizes.length).toBe(before);
|
|
122
|
+
});
|
|
123
|
+
|
|
103
124
|
it("broadcasts pty output to all clients", () => {
|
|
104
125
|
let ptyInstance: FakePty | null = null;
|
|
105
126
|
const factory: PtyFactory = () => {
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -375,6 +375,9 @@ const App = () => {
|
|
|
375
375
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
376
376
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
|
377
377
|
const [sessionsError, setSessionsError] = useState(false);
|
|
378
|
+
// Server-owned folders (Manual mode). Kept in App because the sessions
|
|
379
|
+
// fetch already carries them.
|
|
380
|
+
const [folders, setFolders] = useState<Array<{ id: string; name: string }>>([]);
|
|
378
381
|
// Attention plumbing: tab-title alert for a bell in the attached session
|
|
379
382
|
// while the tab is hidden; inline editors for display name / rename / create.
|
|
380
383
|
const [titleAlert, setTitleAlert] = useState(false);
|
|
@@ -1132,6 +1135,16 @@ const App = () => {
|
|
|
1132
1135
|
// Rooms the user asked full history for: their next connect replays the
|
|
1133
1136
|
// server maximum instead of the fast bounded snapshot.
|
|
1134
1137
|
const deepReplayRoomsRef = useRef(new Set<string>());
|
|
1138
|
+
// Guards for the automatic deep-history load. The buffer sits at viewport
|
|
1139
|
+
// 0 during every attach write (the terminal is reset before the snapshot
|
|
1140
|
+
// lands), so "y === 0" alone misfired the reload DURING attach on any
|
|
1141
|
+
// session busy enough to have a capped snapshot — which then restored the
|
|
1142
|
+
// viewport to old-top with follow-mode off: terminal parked above the
|
|
1143
|
+
// bottom, new output invisible until a manual scroll. The trigger now
|
|
1144
|
+
// requires a real gesture: a transition from scrolled-up to top, after the
|
|
1145
|
+
// snapshot has settled.
|
|
1146
|
+
const lastViewportYRef = useRef(0);
|
|
1147
|
+
const snapshotSettledAtRef = useRef(0);
|
|
1135
1148
|
const snapshotWasCappedRef = useRef(false);
|
|
1136
1149
|
// Set while an automatic deep-history reload is in flight: carries the old
|
|
1137
1150
|
// buffer length so the post-reload snapshot can restore the reading
|
|
@@ -1309,7 +1322,14 @@ const App = () => {
|
|
|
1309
1322
|
&& (termRef.current.cols !== cols || termRef.current.rows !== rows)) {
|
|
1310
1323
|
try { termRef.current.resize(cols, rows); } catch { /* keep local size */ }
|
|
1311
1324
|
}
|
|
1312
|
-
termRef.current.write(screen.data, () =>
|
|
1325
|
+
termRef.current.write(screen.data, () => {
|
|
1326
|
+
const t = termRef.current;
|
|
1327
|
+
if (!t) return;
|
|
1328
|
+
// Pin to the newest line — the fast paint is a stand-in for the
|
|
1329
|
+
// snapshot and must land where the snapshot would.
|
|
1330
|
+
t.scrollToBottom();
|
|
1331
|
+
t.refresh(0, t.rows - 1);
|
|
1332
|
+
});
|
|
1313
1333
|
})
|
|
1314
1334
|
.catch(() => { /* fast paint is best-effort */ });
|
|
1315
1335
|
|
|
@@ -1454,13 +1474,21 @@ const App = () => {
|
|
|
1454
1474
|
if (pendingRestore) {
|
|
1455
1475
|
// The user was reading at the top of the OLD buffer; that
|
|
1456
1476
|
// content now sits newLen - oldLen lines down. Land there,
|
|
1457
|
-
// not at the bottom — the reload should be invisible.
|
|
1477
|
+
// not at the bottom — the reload should be invisible. (This
|
|
1478
|
+
// used to fall through to scrollToBottom anyway, yanking the
|
|
1479
|
+
// view away from the spot it had just restored.)
|
|
1458
1480
|
const line = Math.max(0, t.buffer.active.length - pendingRestore.oldLen);
|
|
1459
1481
|
t.scrollToLine(line);
|
|
1460
|
-
userScrolledUpRef.current =
|
|
1482
|
+
userScrolledUpRef.current = line < Math.max(0, t.buffer.active.length - t.rows);
|
|
1483
|
+
} else {
|
|
1484
|
+
t.scrollToBottom();
|
|
1485
|
+
userScrolledUpRef.current = false;
|
|
1461
1486
|
}
|
|
1462
|
-
t.scrollToBottom();
|
|
1463
1487
|
t.refresh(0, t.rows - 1);
|
|
1488
|
+
// The snapshot has fully landed: only from here on can a
|
|
1489
|
+
// viewport-top event mean a user gesture.
|
|
1490
|
+
lastViewportYRef.current = t.buffer.active.viewportY;
|
|
1491
|
+
snapshotSettledAtRef.current = performance.now();
|
|
1464
1492
|
});
|
|
1465
1493
|
} else {
|
|
1466
1494
|
writeToTerminal(message.data);
|
|
@@ -1735,6 +1763,15 @@ const App = () => {
|
|
|
1735
1763
|
|
|
1736
1764
|
// Prevent browser from intercepting common terminal shortcuts
|
|
1737
1765
|
terminal.attachCustomKeyEventHandler((event) => {
|
|
1766
|
+
// ⌘⏎ leaves full screen for the wall — the mirror of ⌘⏎ on a wall
|
|
1767
|
+
// tile entering it. One chord toggles the mode from either side.
|
|
1768
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
|
|
1769
|
+
if (event.type === 'keydown') {
|
|
1770
|
+
event.preventDefault();
|
|
1771
|
+
setSwitcherOpen(true);
|
|
1772
|
+
}
|
|
1773
|
+
return false;
|
|
1774
|
+
}
|
|
1738
1775
|
// Long-press SPACE = voice input (Claude sessions, when the browser has
|
|
1739
1776
|
// SpeechRecognition). First press types its space with zero latency;
|
|
1740
1777
|
// holding past the threshold erases it and dictates until release.
|
|
@@ -1865,12 +1902,14 @@ const App = () => {
|
|
|
1865
1902
|
const onBufferScroll = () => {
|
|
1866
1903
|
const { y, base } = scrollState();
|
|
1867
1904
|
userScrolledUpRef.current = y < base;
|
|
1868
|
-
// Hitting the very TOP of a bounded-replay buffer means the user wants
|
|
1869
|
-
// more history than the fast snapshot carried — offer the full reload.
|
|
1870
1905
|
// Top of a capped snapshot: the user wants more history than the fast
|
|
1871
|
-
// snapshot carried
|
|
1872
|
-
//
|
|
1873
|
-
|
|
1906
|
+
// snapshot carried — load it automatically. "The user wants" must mean
|
|
1907
|
+
// a real gesture: arriving at 0 FROM above, on a settled snapshot.
|
|
1908
|
+
const cameFromAbove = lastViewportYRef.current > 0;
|
|
1909
|
+
const settled = snapshotSettledAtRef.current > 0
|
|
1910
|
+
&& performance.now() - snapshotSettledAtRef.current > 600;
|
|
1911
|
+
lastViewportYRef.current = y;
|
|
1912
|
+
if (y === 0 && cameFromAbove && settled && snapshotWasCappedRef.current) {
|
|
1874
1913
|
const room = activeSessionRoomRef.current;
|
|
1875
1914
|
if (room && !deepReplayRoomsRef.current.has(room)) {
|
|
1876
1915
|
deepReplayRoomsRef.current.add(room);
|
|
@@ -2337,7 +2376,7 @@ const App = () => {
|
|
|
2337
2376
|
const row = Math.floor(((tapStartY - rect.top) / rect.height) * term.rows);
|
|
2338
2377
|
if (col >= 0 && col < term.cols && row >= 0 && row < term.rows) {
|
|
2339
2378
|
const url = extractUrlAtCell(term.buffer.active.viewportY + row, col);
|
|
2340
|
-
if (url) setLinkPrompt(url);
|
|
2379
|
+
if (url) setLinkPrompt({ url, x: tapStartX, y: tapStartY });
|
|
2341
2380
|
}
|
|
2342
2381
|
}
|
|
2343
2382
|
}
|
|
@@ -2512,6 +2551,32 @@ const App = () => {
|
|
|
2512
2551
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2513
2552
|
}, [switcherOpen]);
|
|
2514
2553
|
|
|
2554
|
+
// THE RENDER INVARIANT, enforced continuously instead of per-transition.
|
|
2555
|
+
// Three rounds of patching individual transitions (wall close, tab
|
|
2556
|
+
// visibility, context loss, attach paths) each fixed the reproduced case
|
|
2557
|
+
// and missed a sibling — the class kept coming back as "renders only
|
|
2558
|
+
// after I scroll". Scrolling "fixed" it because a viewport move repaints
|
|
2559
|
+
// everything; so enforce what scrolling incidentally provides:
|
|
2560
|
+
// while following, the newest line is on screen — and the screen is
|
|
2561
|
+
// repainted from the buffer every tick, no matter which code path
|
|
2562
|
+
// forgot to.
|
|
2563
|
+
// A full refresh of ~50 rows at 0.7Hz is microseconds on the GPU renderer
|
|
2564
|
+
// and cheap on the DOM one; correctness beats elegance here.
|
|
2565
|
+
useEffect(() => {
|
|
2566
|
+
if (!session) return;
|
|
2567
|
+
const id = window.setInterval(() => {
|
|
2568
|
+
if (document.hidden || switcherOpenRef.current) return;
|
|
2569
|
+
const t = termRef.current;
|
|
2570
|
+
if (!t) return;
|
|
2571
|
+
try {
|
|
2572
|
+
const buf = t.buffer.active;
|
|
2573
|
+
if (!userScrolledUpRef.current && buf.viewportY < buf.baseY) t.scrollToBottom();
|
|
2574
|
+
t.refresh(0, t.rows - 1);
|
|
2575
|
+
} catch { /* disposed mid-tick */ }
|
|
2576
|
+
}, 1400);
|
|
2577
|
+
return () => window.clearInterval(id);
|
|
2578
|
+
}, [session]);
|
|
2579
|
+
|
|
2515
2580
|
// Tab or window coming back to the foreground: same stale-frame risk as
|
|
2516
2581
|
// the wall closing, from browser throttling rather than our own hiding.
|
|
2517
2582
|
useEffect(() => {
|
|
@@ -2629,7 +2694,8 @@ const App = () => {
|
|
|
2629
2694
|
tagline: typeof s.tagline === "string" ? s.tagline : undefined,
|
|
2630
2695
|
parked: s.parked === true,
|
|
2631
2696
|
archived: s.archived === true,
|
|
2632
|
-
hasLocalCli: s.hasLocalCli === true
|
|
2697
|
+
hasLocalCli: s.hasLocalCli === true,
|
|
2698
|
+
folderId: typeof s.folderId === "string" ? s.folderId : null
|
|
2633
2699
|
});
|
|
2634
2700
|
}
|
|
2635
2701
|
|
|
@@ -2701,6 +2767,11 @@ const App = () => {
|
|
|
2701
2767
|
// Drop a response superseded by a newer fetch started while this was
|
|
2702
2768
|
// in flight (see the sequence-guard note above).
|
|
2703
2769
|
if (seq !== fetchSeqRef.current) return;
|
|
2770
|
+
setFolders(Array.isArray(data.folders)
|
|
2771
|
+
? data.folders
|
|
2772
|
+
.filter((f: { id?: string; name?: string }) => f && f.id && f.name)
|
|
2773
|
+
.map((f: { id: string; name: string }) => ({ id: String(f.id), name: String(f.name) }))
|
|
2774
|
+
: []);
|
|
2704
2775
|
setSessions(list);
|
|
2705
2776
|
setSessionsError(false);
|
|
2706
2777
|
sessionListLoadedRef.current = true;
|
|
@@ -3151,7 +3222,7 @@ const App = () => {
|
|
|
3151
3222
|
// layer disables native pointer events on terminal text, and remote apps
|
|
3152
3223
|
// can't open the phone's browser — claude login prints a URL that was
|
|
3153
3224
|
// otherwise un-tappable.)
|
|
3154
|
-
const [linkPrompt, setLinkPrompt] = useState<string | null>(null);
|
|
3225
|
+
const [linkPrompt, setLinkPrompt] = useState<{ url: string; x: number; y: number } | null>(null);
|
|
3155
3226
|
// Passkey nudge: biometric sign-in works but is invisible — the login page
|
|
3156
3227
|
// only offers Touch ID / Face ID once a credential exists for this
|
|
3157
3228
|
// hostname, so a user who never opened the settings drawer never learns it
|
|
@@ -3421,6 +3492,7 @@ const App = () => {
|
|
|
3421
3492
|
sessions={sessions}
|
|
3422
3493
|
currentRoom={null}
|
|
3423
3494
|
dismissable={false}
|
|
3495
|
+
folders={folders}
|
|
3424
3496
|
onClose={() => {}}
|
|
3425
3497
|
onSwitch={(next) => {
|
|
3426
3498
|
window.location.href = buildSessionPath(next.displayName || next.name);
|
|
@@ -4075,6 +4147,7 @@ const App = () => {
|
|
|
4075
4147
|
onNotice={showToast}
|
|
4076
4148
|
tileWsBase={resolveWsUrl()}
|
|
4077
4149
|
onFocusSession={focusSessionInPlace}
|
|
4150
|
+
folders={folders}
|
|
4078
4151
|
userName={name}
|
|
4079
4152
|
terminalTheme={resolveTerminalTheme(themeMode)}
|
|
4080
4153
|
onOpenSettings={() => {
|
|
@@ -4145,23 +4218,34 @@ const App = () => {
|
|
|
4145
4218
|
</div>
|
|
4146
4219
|
)}
|
|
4147
4220
|
{linkPrompt && (
|
|
4148
|
-
<div
|
|
4149
|
-
|
|
4221
|
+
<div
|
|
4222
|
+
className="context-menu link-menu"
|
|
4223
|
+
role="menu"
|
|
4224
|
+
aria-label="Open link"
|
|
4225
|
+
style={{
|
|
4226
|
+
left: Math.max(8, Math.min(linkPrompt.x, window.innerWidth - 268)),
|
|
4227
|
+
top: Math.max(8, Math.min(linkPrompt.y + 10, window.innerHeight - 150))
|
|
4228
|
+
}}
|
|
4229
|
+
>
|
|
4230
|
+
<span className="context-menu-title">{linkPrompt.url}</span>
|
|
4150
4231
|
<button
|
|
4151
4232
|
type="button"
|
|
4152
|
-
|
|
4233
|
+
role="menuitem"
|
|
4234
|
+
className="context-menu-item"
|
|
4153
4235
|
onClick={() => {
|
|
4154
|
-
window.open(linkPrompt, "_blank", "noopener");
|
|
4236
|
+
window.open(linkPrompt.url, "_blank", "noopener");
|
|
4155
4237
|
setLinkPrompt(null);
|
|
4156
4238
|
}}
|
|
4157
4239
|
>
|
|
4158
|
-
Open
|
|
4240
|
+
<span>Open link</span>
|
|
4159
4241
|
</button>
|
|
4160
4242
|
<button
|
|
4161
4243
|
type="button"
|
|
4244
|
+
role="menuitem"
|
|
4245
|
+
className="context-menu-item"
|
|
4162
4246
|
onClick={async () => {
|
|
4163
4247
|
try {
|
|
4164
|
-
await navigator.clipboard.writeText(linkPrompt);
|
|
4248
|
+
await navigator.clipboard.writeText(linkPrompt.url);
|
|
4165
4249
|
showToast("Link copied");
|
|
4166
4250
|
} catch {
|
|
4167
4251
|
showToast("Clipboard denied — long-press the URL text instead");
|
|
@@ -4169,9 +4253,18 @@ const App = () => {
|
|
|
4169
4253
|
setLinkPrompt(null);
|
|
4170
4254
|
}}
|
|
4171
4255
|
>
|
|
4172
|
-
Copy
|
|
4256
|
+
<span>Copy link</span>
|
|
4257
|
+
</button>
|
|
4258
|
+
<div className="context-menu-separator" role="separator" />
|
|
4259
|
+
<button
|
|
4260
|
+
type="button"
|
|
4261
|
+
role="menuitem"
|
|
4262
|
+
className="context-menu-item"
|
|
4263
|
+
onClick={() => setLinkPrompt(null)}
|
|
4264
|
+
>
|
|
4265
|
+
<span>Cancel</span>
|
|
4266
|
+
<kbd>esc</kbd>
|
|
4173
4267
|
</button>
|
|
4174
|
-
<button type="button" aria-label="Dismiss" onClick={() => setLinkPrompt(null)}>✕</button>
|
|
4175
4268
|
</div>
|
|
4176
4269
|
)}
|
|
4177
4270
|
{isMobile && (
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
// One context menu for the whole app.
|
|
4
|
+
//
|
|
5
|
+
// Right-click is a shortcut to the actions a surface already has — it must
|
|
6
|
+
// never be the ONLY way to reach something, and it must never invent a
|
|
7
|
+
// different vocabulary from the buttons beside it. So this renders the same
|
|
8
|
+
// items a surface's visible affordances expose, in the same order, with the
|
|
9
|
+
// same words.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately NOT applied to the terminal: there, right-click already means
|
|
12
|
+
// something (paste on Linux/Windows, the native copy/paste menu everywhere),
|
|
13
|
+
// and mouse-reporting apps — Claude, vim, less — receive button events the
|
|
14
|
+
// moment tracking is on. Overriding it would either steal a click the app
|
|
15
|
+
// asked for or replace a menu people rely on with a worse one.
|
|
16
|
+
|
|
17
|
+
export type MenuItem =
|
|
18
|
+
| { kind?: "item"; label: string; onSelect: () => void; danger?: boolean; hint?: string; disabled?: boolean }
|
|
19
|
+
| { kind: "separator" };
|
|
20
|
+
|
|
21
|
+
export type MenuRequest = { x: number; y: number; items: MenuItem[] } | null;
|
|
22
|
+
|
|
23
|
+
const isItem = (i: MenuItem): i is Extract<MenuItem, { kind?: "item" }> => i.kind !== "separator";
|
|
24
|
+
|
|
25
|
+
export const ContextMenu = ({ request, onClose }: { request: MenuRequest; onClose: () => void }) => {
|
|
26
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
27
|
+
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
|
28
|
+
const [active, setActive] = useState(0);
|
|
29
|
+
|
|
30
|
+
// Clamp into the viewport BEFORE paint: a menu opened near an edge would
|
|
31
|
+
// otherwise flash off-screen and jump.
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
if (!request) { setPos(null); return; }
|
|
34
|
+
const el = ref.current;
|
|
35
|
+
const w = el?.offsetWidth || 200;
|
|
36
|
+
const h = el?.offsetHeight || 200;
|
|
37
|
+
const pad = 8;
|
|
38
|
+
setPos({
|
|
39
|
+
left: Math.max(pad, Math.min(request.x, window.innerWidth - w - pad)),
|
|
40
|
+
top: Math.max(pad, Math.min(request.y, window.innerHeight - h - pad))
|
|
41
|
+
});
|
|
42
|
+
setActive(request.items.findIndex(isItem));
|
|
43
|
+
}, [request]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!request) return;
|
|
47
|
+
const onKey = (e: KeyboardEvent) => {
|
|
48
|
+
if (e.key === "Escape") { e.preventDefault(); onClose(); return; }
|
|
49
|
+
const idxs = request.items.map((it, i) => (isItem(it) && !it.disabled ? i : -1)).filter((i) => i >= 0);
|
|
50
|
+
if (idxs.length === 0) return;
|
|
51
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
const at = idxs.indexOf(active);
|
|
54
|
+
const next = e.key === "ArrowDown"
|
|
55
|
+
? idxs[(at + 1 + idxs.length) % idxs.length]
|
|
56
|
+
: idxs[(at - 1 + idxs.length) % idxs.length];
|
|
57
|
+
setActive(next);
|
|
58
|
+
} else if (e.key === "Enter") {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
const item = request.items[active];
|
|
61
|
+
if (item && isItem(item) && !item.disabled) { onClose(); item.onSelect(); }
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// Capture so a menu click never also reaches the surface underneath.
|
|
65
|
+
const onDown = (e: MouseEvent) => {
|
|
66
|
+
if (!ref.current?.contains(e.target as Node)) onClose();
|
|
67
|
+
};
|
|
68
|
+
window.addEventListener("keydown", onKey, true);
|
|
69
|
+
window.addEventListener("mousedown", onDown, true);
|
|
70
|
+
window.addEventListener("resize", onClose);
|
|
71
|
+
window.addEventListener("blur", onClose);
|
|
72
|
+
return () => {
|
|
73
|
+
window.removeEventListener("keydown", onKey, true);
|
|
74
|
+
window.removeEventListener("mousedown", onDown, true);
|
|
75
|
+
window.removeEventListener("resize", onClose);
|
|
76
|
+
window.removeEventListener("blur", onClose);
|
|
77
|
+
};
|
|
78
|
+
}, [request, active, onClose]);
|
|
79
|
+
|
|
80
|
+
if (!request) return null;
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
ref={ref}
|
|
84
|
+
className="context-menu"
|
|
85
|
+
role="menu"
|
|
86
|
+
style={pos ? { left: pos.left, top: pos.top } : { left: -9999, top: -9999 }}
|
|
87
|
+
onContextMenu={(e) => e.preventDefault()}
|
|
88
|
+
>
|
|
89
|
+
{request.items.map((item, i) =>
|
|
90
|
+
isItem(item) ? (
|
|
91
|
+
<button
|
|
92
|
+
key={item.label + i}
|
|
93
|
+
type="button"
|
|
94
|
+
role="menuitem"
|
|
95
|
+
className={"context-menu-item" + (item.danger ? " danger" : "") + (i === active ? " active" : "")}
|
|
96
|
+
disabled={item.disabled}
|
|
97
|
+
onMouseEnter={() => setActive(i)}
|
|
98
|
+
onClick={() => { onClose(); item.onSelect(); }}
|
|
99
|
+
>
|
|
100
|
+
<span>{item.label}</span>
|
|
101
|
+
{item.hint && <kbd>{item.hint}</kbd>}
|
|
102
|
+
</button>
|
|
103
|
+
) : (
|
|
104
|
+
<div key={"sep" + i} className="context-menu-separator" role="separator" />
|
|
105
|
+
)
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
};
|