Add keyboard shortcuts (Ctrl+Shift chord style)
| Ctrl+K | palette | | Ctrl+Shift+E | split active pane right | | Ctrl+Shift+O | split active pane down | | Ctrl+Shift+W | close active pane | | Ctrl+Shift+B | toggle broadcast on active | | Ctrl+Shift+Alt+B | toggle broadcast on ALL panes | | Ctrl+Shift+Arrow | focus neighbour pane in that direction | The handler attaches at capture phase on window so it wins against xterm.js. It bails when a non-terminal <input>/<textarea> is focused so label edits and the palette input keep working normally. Spatial neighbour-finding lives in tree.ts as findNeighborInDirection — picks the leaf whose centre is most aligned in the perpendicular axis, breaking ties by primary-axis distance. Tooltips on toolbar/titlebar buttons now mention their shortcuts; README has a key-binding table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9aa0c5a828
commit
a4cd82440b
4 changed files with 188 additions and 21 deletions
122
src/App.tsx
122
src/App.tsx
|
|
@ -26,6 +26,8 @@ import {
|
|||
flattenLayout,
|
||||
updateSplitRatio,
|
||||
swapLeaves,
|
||||
findNeighborInDirection,
|
||||
type Direction,
|
||||
serialize,
|
||||
deserialize,
|
||||
presetSingle,
|
||||
|
|
@ -128,19 +130,6 @@ export default function App() {
|
|||
return () => clearTimeout(id);
|
||||
}, [tree, ready]);
|
||||
|
||||
// ---- Ctrl+K palette toggle (capture phase to beat xterm) ----------------
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPaletteOpen((v) => !v);
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, []);
|
||||
|
||||
// ---- focus polling → setActive (xterm.js eats pointerdown) --------------
|
||||
useEffect(() => {
|
||||
let lastLeafId: string | null = null;
|
||||
|
|
@ -199,6 +188,107 @@ export default function App() {
|
|||
setActiveLeafId(leafId);
|
||||
}, []);
|
||||
|
||||
// ---- global keyboard shortcuts ------------------------------------------
|
||||
// Capture phase beats xterm.js's own keystroke handlers. We intentionally
|
||||
// don't intercept when the user is typing into a regular <input> (label
|
||||
// edits etc.) — but DO intercept when focus is in the xterm textarea,
|
||||
// which is what makes shortcuts work while a terminal is focused.
|
||||
const kbdStateRef = useRef({ activeLeafId, tree });
|
||||
useEffect(() => {
|
||||
kbdStateRef.current = { activeLeafId, tree };
|
||||
});
|
||||
useEffect(() => {
|
||||
const DIR_MAP: Record<string, Direction | undefined> = {
|
||||
arrowleft: "left",
|
||||
arrowright: "right",
|
||||
arrowup: "up",
|
||||
arrowdown: "down",
|
||||
};
|
||||
|
||||
function shouldIgnore(): boolean {
|
||||
const ae = document.activeElement as HTMLElement | null;
|
||||
if (!ae) return false;
|
||||
const tag = ae.tagName;
|
||||
if (tag !== "INPUT" && tag !== "TEXTAREA") return false;
|
||||
if (ae.classList.contains("xterm-helper-textarea")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (shouldIgnore()) return;
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
const shift = e.shiftKey;
|
||||
const alt = e.altKey;
|
||||
const key = e.key.toLowerCase();
|
||||
const { activeLeafId, tree } = kbdStateRef.current;
|
||||
|
||||
// Ctrl+K — palette
|
||||
if (ctrl && !shift && !alt && key === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPaletteOpen((v) => !v);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Alt+B — global broadcast all/none
|
||||
if (ctrl && shift && alt && key === "b") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
let anyOn = false;
|
||||
for (const leaf of walkLeaves(tree)) {
|
||||
if (leaf.broadcast) {
|
||||
anyOn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setTree((t) => setAllBroadcast(t, !anyOn));
|
||||
return;
|
||||
}
|
||||
|
||||
// All remaining shortcuts require Ctrl+Shift with no Alt.
|
||||
if (!ctrl || !shift || alt) return;
|
||||
|
||||
// Ctrl+Shift+Arrow — pane navigation
|
||||
const dir = DIR_MAP[key];
|
||||
if (dir) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const layout = flattenLayout(tree);
|
||||
if (!activeLeafId) {
|
||||
const first = layout.leaves[0]?.leaf.id;
|
||||
if (first) setActiveLeafId(first);
|
||||
return;
|
||||
}
|
||||
const nextId = findNeighborInDirection(layout.leaves, activeLeafId, dir);
|
||||
if (nextId) setActiveLeafId(nextId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeLeafId) return;
|
||||
|
||||
if (key === "e") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
split(activeLeafId, "h");
|
||||
} else if (key === "o") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
split(activeLeafId, "v");
|
||||
} else if (key === "w") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close(activeLeafId);
|
||||
} else if (key === "b") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleBroadcast(activeLeafId);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [split, close, toggleBroadcast]);
|
||||
|
||||
const registerPaneId = useCallback(
|
||||
(leafId: NodeId, paneId: PaneId | null) => {
|
||||
if (paneId == null) paneIdByLeafRef.current.delete(leafId);
|
||||
|
|
@ -432,10 +522,10 @@ export default function App() {
|
|||
onClick={toggleBroadcastAll}
|
||||
title={
|
||||
broadcastStats.on === 0
|
||||
? "Click to broadcast input to ALL panes"
|
||||
? "Broadcast to ALL panes (Ctrl+Shift+Alt+B)"
|
||||
: broadcastStats.on === broadcastStats.total
|
||||
? "All panes broadcasting — click to disable"
|
||||
: `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all`
|
||||
? "All panes broadcasting — click to disable (Ctrl+Shift+Alt+B)"
|
||||
: `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all (Ctrl+Shift+Alt+B)`
|
||||
}
|
||||
>
|
||||
{broadcastBtnLabel}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue