tiletopia/src/lib/layout/Gutter.tsx
megaproxy c4747546e0 Flat-list layout: render leaves as siblings keyed by id
The fix for the real preset bug: previously, presetSingle/2H/3H/2V/2×2
appeared to preserve panes (we copied id/distro/cwd/label/broadcast
into the preset's slots), but React's reconciliation tore down every
LeafPane and re-mounted it because the tree structure changed —
killing all PTYs and spawning fresh shells. The "preservation" was
data-only; the React components didn't survive.

Solution: stop rendering the Pane → SplitNode → LeafPane recursion.
Walk the tree to produce a FLAT layout of `{leaf, box}` entries (each
box is top/left/width/height as fractions 0–1). Render all leaves as
siblings of a relative-positioned container, each absolutely
positioned by its box. Key each one by leaf.id — React preserves the
component (and its XtermPane → PTY) across any tree reshape; only the
inline style changes.

Gutters render as separate sibling overlays at the split boundaries,
each with its own pointer handlers. Dragging mutates the split's
ratio via `updateSplitRatio(tree, splitId, r)`; the layout
recomputes; leaf boxes change; nothing remounts.

Now: clicking 2×2 on 4 stacked panes keeps all 4 shells alive and
just rearranges them into the grid. Same for any preset that doesn't
overflow.

Side benefit: removed the recursive Pane.tsx + SplitNode.tsx + their
CSS. The render path is now straightforward, no recursion, easier to
reason about.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:39:58 +01:00

98 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useRef, useState, type PointerEvent } from "react";
import type { GutterInfo } from "./tree";
/**
* A draggable gutter at a split boundary.
*
* `info.box` is where to render the strip (in container fractions 01);
* `info.parentBox` is the parent split's bounding box, used to convert
* pointer position back into a 01 ratio.
*
* The actual draggable hitbox is a few pixels thick (and centered on the
* boundary), but we render a thin visible line via CSS pseudo-elements.
*/
const HITBOX_PX = 8;
export default function Gutter({
info,
containerRef,
onRatioChange,
}: {
info: GutterInfo;
containerRef: React.RefObject<HTMLDivElement | null>;
onRatioChange: (splitId: string, ratio: number) => void;
}) {
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true);
draggingRef.current = true;
e.preventDefault();
e.stopPropagation();
}, []);
const onPointerMove = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const xFrac = (e.clientX - rect.left) / rect.width;
const yFrac = (e.clientY - rect.top) / rect.height;
const pb = info.parentBox;
const rawRatio =
info.orientation === "h"
? (xFrac - pb.left) / pb.width
: (yFrac - pb.top) / pb.height;
const ratio = Math.max(0.05, Math.min(0.95, rawRatio));
onRatioChange(info.splitId, ratio);
},
[containerRef, info, onRatioChange],
);
const onPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
if (!draggingRef.current) return;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
draggingRef.current = false;
setDragging(false);
}, []);
const isH = info.orientation === "h";
// Visible 4px line, but the draggable hitbox is wider for grabbability.
const halfHit = HITBOX_PX / 2;
const style: React.CSSProperties = isH
? {
position: "absolute",
top: `${info.box.top * 100}%`,
left: `calc(${info.box.left * 100}% - ${halfHit}px)`,
height: `${info.box.height * 100}%`,
width: `${HITBOX_PX}px`,
cursor: "col-resize",
zIndex: 10,
}
: {
position: "absolute",
top: `calc(${info.box.top * 100}% - ${halfHit}px)`,
left: `${info.box.left * 100}%`,
width: `${info.box.width * 100}%`,
height: `${HITBOX_PX}px`,
cursor: "row-resize",
zIndex: 10,
};
return (
<div
className={`gutter ${isH ? "gutter-h" : "gutter-v"}${dragging ? " active" : ""}`}
style={style}
role="separator"
aria-orientation={isH ? "vertical" : "horizontal"}
aria-valuenow={Math.round(info.ratio * 100)}
tabIndex={-1}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
);
}