Drag a pane's toolbar onto another pane to swap them
New interaction: click-and-drag any pane's toolbar onto another pane to swap their positions in the tree. The shells / scrollback stay intact (each leaf keeps its data; only the tree slot it occupies changes). Implementation: - tree.ts: `swapLeaves(root, idA, idB)` walks the tree once, substituting one leaf for the other at each occurrence. The leaf objects themselves carry their id/distro/cwd/label/broadcast across, so React preserves the LeafPane instances via the flat-list keying. - orchestration.tsx: add drag lifecycle to the context — dragSourceId / dragOverId (reactive) plus beginHeaderDrag, setHeaderDragOver, endHeaderDrag (stable methods). - App.tsx: implement those methods. endHeaderDrag(true) swaps if source and over are different leaves. - LeafPane.tsx: pointerdown on .pane-toolbar (skipped if the target is a button/input). 5px movement threshold before drag commits to prevent accidental swaps when clicking a chip etc. Pointer-capture the toolbar so we keep getting move events even outside it. Use document.elementFromPoint to find the leaf under the cursor. - CSS: source pane fades to 40% opacity during drag; target pane shows a 3px dashed blue outline; toolbar shows grab/grabbing cursors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c4747546e0
commit
c93ebddfa5
5 changed files with 172 additions and 2 deletions
37
src/App.tsx
37
src/App.tsx
|
|
@ -25,6 +25,7 @@ import {
|
||||||
reshapeToPreset,
|
reshapeToPreset,
|
||||||
flattenLayout,
|
flattenLayout,
|
||||||
updateSplitRatio,
|
updateSplitRatio,
|
||||||
|
swapLeaves,
|
||||||
serialize,
|
serialize,
|
||||||
deserialize,
|
deserialize,
|
||||||
presetSingle,
|
presetSingle,
|
||||||
|
|
@ -238,6 +239,32 @@ export default function App() {
|
||||||
setNotifications((ns) => ns.filter((n) => n.id !== id));
|
setNotifications((ns) => ns.filter((n) => n.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ---- header-drag swap ---------------------------------------------------
|
||||||
|
const [dragSourceId, setDragSourceId] = useState<NodeId | null>(null);
|
||||||
|
const [dragOverId, setDragOverId] = useState<NodeId | null>(null);
|
||||||
|
const beginHeaderDrag = useCallback((leafId: NodeId) => {
|
||||||
|
setDragSourceId(leafId);
|
||||||
|
setDragOverId(null);
|
||||||
|
}, []);
|
||||||
|
const setHeaderDragOver = useCallback((leafId: NodeId | null) => {
|
||||||
|
setDragOverId(leafId);
|
||||||
|
}, []);
|
||||||
|
const endHeaderDrag = useCallback(
|
||||||
|
(commitSwap: boolean) => {
|
||||||
|
if (
|
||||||
|
commitSwap &&
|
||||||
|
dragSourceId &&
|
||||||
|
dragOverId &&
|
||||||
|
dragSourceId !== dragOverId
|
||||||
|
) {
|
||||||
|
setTree((t) => swapLeaves(t, dragSourceId, dragOverId));
|
||||||
|
}
|
||||||
|
setDragSourceId(null);
|
||||||
|
setDragOverId(null);
|
||||||
|
},
|
||||||
|
[dragSourceId, dragOverId],
|
||||||
|
);
|
||||||
|
|
||||||
const orch = useMemo<Orchestration>(
|
const orch = useMemo<Orchestration>(
|
||||||
() => ({
|
() => ({
|
||||||
activeLeafId,
|
activeLeafId,
|
||||||
|
|
@ -251,6 +278,11 @@ export default function App() {
|
||||||
registerPaneId,
|
registerPaneId,
|
||||||
broadcastFrom,
|
broadcastFrom,
|
||||||
notify,
|
notify,
|
||||||
|
dragSourceId,
|
||||||
|
dragOverId,
|
||||||
|
beginHeaderDrag,
|
||||||
|
setHeaderDragOver,
|
||||||
|
endHeaderDrag,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeLeafId,
|
activeLeafId,
|
||||||
|
|
@ -264,6 +296,11 @@ export default function App() {
|
||||||
registerPaneId,
|
registerPaneId,
|
||||||
broadcastFrom,
|
broadcastFrom,
|
||||||
notify,
|
notify,
|
||||||
|
dragSourceId,
|
||||||
|
dragOverId,
|
||||||
|
beginHeaderDrag,
|
||||||
|
setHeaderDragOver,
|
||||||
|
endHeaderDrag,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,21 @@
|
||||||
.leaf.active.broadcasting {
|
.leaf.active.broadcasting {
|
||||||
border-color: #ffb840;
|
border-color: #ffb840;
|
||||||
}
|
}
|
||||||
|
.leaf.drag-source {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.leaf.drag-target {
|
||||||
|
outline: 3px dashed #5a8cd8;
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag handle hint on the toolbar */
|
||||||
|
.pane-toolbar {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.pane-toolbar:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
.pane-toolbar {
|
.pane-toolbar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
useCallback,
|
useCallback,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { LeafNode } from "./tree";
|
import type { LeafNode } from "./tree";
|
||||||
import { useOrchestration } from "./orchestration";
|
import { useOrchestration } from "./orchestration";
|
||||||
|
|
@ -143,17 +144,104 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
setStatusOk(ok);
|
setStatusOk(ok);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ---- header-drag swap ---------------------------------------------------
|
||||||
|
// Drag the toolbar onto another pane's toolbar/body to swap their tree
|
||||||
|
// positions. Uses a movement threshold so accidental tiny moves while
|
||||||
|
// clicking a label etc don't initiate a drag.
|
||||||
|
const DRAG_THRESHOLD_PX = 5;
|
||||||
|
const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const isDragSource = orch.dragSourceId === leaf.id;
|
||||||
|
const isDragTarget =
|
||||||
|
orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id;
|
||||||
|
|
||||||
|
const onToolbarPointerDown = useCallback(
|
||||||
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
// Skip if the click landed on an interactive child.
|
||||||
|
if (target.closest("button, input, .distro-menu")) return;
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
dragStartRef.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
armed: true,
|
||||||
|
dragging: false,
|
||||||
|
};
|
||||||
|
// Make this pane active (since clicking the toolbar should focus it).
|
||||||
|
orch.setActive(leaf.id);
|
||||||
|
},
|
||||||
|
[orch.setActive, leaf.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToolbarPointerMove = useCallback(
|
||||||
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const st = dragStartRef.current;
|
||||||
|
if (!st || !st.armed) return;
|
||||||
|
const dx = e.clientX - st.x;
|
||||||
|
const dy = e.clientY - st.y;
|
||||||
|
if (!st.dragging) {
|
||||||
|
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
||||||
|
st.dragging = true;
|
||||||
|
orch.beginHeaderDrag(leaf.id);
|
||||||
|
document.body.style.cursor = "grabbing";
|
||||||
|
}
|
||||||
|
// Find the leaf under the cursor.
|
||||||
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const tEl = el?.closest("[data-leaf-id]");
|
||||||
|
const targetId = tEl?.getAttribute("data-leaf-id") ?? null;
|
||||||
|
orch.setHeaderDragOver(targetId);
|
||||||
|
},
|
||||||
|
[orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToolbarPointerUp = useCallback(
|
||||||
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const st = dragStartRef.current;
|
||||||
|
if (!st) return;
|
||||||
|
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
const wasDragging = st.dragging;
|
||||||
|
dragStartRef.current = null;
|
||||||
|
if (wasDragging) {
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
orch.endHeaderDrag(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[orch.endHeaderDrag],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToolbarPointerCancel = useCallback(
|
||||||
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
const st = dragStartRef.current;
|
||||||
|
if (!st) return;
|
||||||
|
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
const wasDragging = st.dragging;
|
||||||
|
dragStartRef.current = null;
|
||||||
|
if (wasDragging) {
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
orch.endHeaderDrag(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[orch.endHeaderDrag],
|
||||||
|
);
|
||||||
|
|
||||||
const labelText = leaf.label ?? "(unnamed)";
|
const labelText = leaf.label ?? "(unnamed)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}`}
|
className={`leaf${isActive ? " active" : ""}${isBroadcasting ? " broadcasting" : ""}${isDragSource ? " drag-source" : ""}${isDragTarget ? " drag-target" : ""}`}
|
||||||
role="group"
|
role="group"
|
||||||
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
|
aria-label={`Terminal pane: ${leaf.label ?? leaf.distro ?? "unnamed"}`}
|
||||||
data-leaf-id={leaf.id}
|
data-leaf-id={leaf.id}
|
||||||
onPointerDown={onPaneClick}
|
onPointerDown={onPaneClick}
|
||||||
>
|
>
|
||||||
<div className="pane-toolbar">
|
<div
|
||||||
|
className="pane-toolbar"
|
||||||
|
onPointerDown={onToolbarPointerDown}
|
||||||
|
onPointerMove={onToolbarPointerMove}
|
||||||
|
onPointerUp={onToolbarPointerUp}
|
||||||
|
onPointerCancel={onToolbarPointerCancel}
|
||||||
|
>
|
||||||
{editingLabel ? (
|
{editingLabel ? (
|
||||||
<input
|
<input
|
||||||
ref={labelInputRef}
|
ref={labelInputRef}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,14 @@ export interface Orchestration {
|
||||||
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
|
registerPaneId: (leafId: NodeId, paneId: PaneId | null) => void;
|
||||||
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
|
broadcastFrom: (originLeafId: NodeId, dataB64: string) => void;
|
||||||
notify: (message: string) => void;
|
notify: (message: string) => void;
|
||||||
|
|
||||||
|
// Drag-header-to-swap. dragSourceId / dragOverId are reactive so leaves
|
||||||
|
// can apply hover/source styling. The lifecycle methods are stable.
|
||||||
|
dragSourceId: NodeId | null;
|
||||||
|
dragOverId: NodeId | null;
|
||||||
|
beginHeaderDrag: (leafId: NodeId) => void;
|
||||||
|
setHeaderDragOver: (leafId: NodeId | null) => void;
|
||||||
|
endHeaderDrag: (commitSwap: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OrchestrationContext = createContext<Orchestration | null>(null);
|
const OrchestrationContext = createContext<Orchestration | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,28 @@ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Swap two leaves' tree positions. Each leaf carries its own data
|
||||||
|
* (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay
|
||||||
|
* alive because React keys on leaf.id and our renderer is flat. */
|
||||||
|
export function swapLeaves(root: TreeNode, idA: NodeId, idB: NodeId): TreeNode {
|
||||||
|
if (idA === idB) return root;
|
||||||
|
const a = findLeaf(root, idA);
|
||||||
|
const b = findLeaf(root, idB);
|
||||||
|
if (!a || !b) return root;
|
||||||
|
function walk(n: TreeNode): TreeNode {
|
||||||
|
if (n.kind === "leaf") {
|
||||||
|
if (n.id === idA) return b!;
|
||||||
|
if (n.id === idB) return a!;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
const na = walk(n.a);
|
||||||
|
const nb = walk(n.b);
|
||||||
|
if (na === n.a && nb === n.b) return n;
|
||||||
|
return { ...n, a: na, b: nb };
|
||||||
|
}
|
||||||
|
return walk(root);
|
||||||
|
}
|
||||||
|
|
||||||
export function serialize(root: TreeNode): string {
|
export function serialize(root: TreeNode): string {
|
||||||
return JSON.stringify(root);
|
return JSON.stringify(root);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue