Promote nested pane to full row/column by dragging gutter past sibling
This commit is contained in:
parent
dbd6c163c3
commit
150e5f09cb
5 changed files with 445 additions and 25 deletions
|
|
@ -396,6 +396,24 @@ export interface LeafSlot {
|
|||
box: Box;
|
||||
}
|
||||
|
||||
/** Metadata that lets a gutter implement the "drag past sibling to
|
||||
* promote" gesture. Present iff the gutter's split has an immediate
|
||||
* parent split with **perpendicular** orientation (otherwise extending
|
||||
* the gutter across the workspace would just be a noop rearrangement
|
||||
* of the same axis). */
|
||||
export interface GutterPromoteContext {
|
||||
/** Which side of the workspace the cursor must exit toward to arm
|
||||
* the gesture — always perpendicular to the gutter's drag axis. */
|
||||
exitDirection: Direction;
|
||||
/** Sibling pane's bounding box (in workspace fractions). The gesture
|
||||
* arms when the cursor has crossed >75% of this box along
|
||||
* {@link exitDirection}. */
|
||||
siblingBox: Box;
|
||||
/** Where the promoted leaf will land in the new layout. App uses this
|
||||
* to render a translucent preview while the gesture is armed. */
|
||||
promotedBox: Box;
|
||||
}
|
||||
|
||||
/** A draggable gutter at a split boundary. `box` is where to render the
|
||||
* draggable strip; `parentBox` is the area the gutter divides (needed to
|
||||
* convert pointer position → ratio). */
|
||||
|
|
@ -405,6 +423,9 @@ export interface GutterInfo {
|
|||
ratio: number;
|
||||
box: Box;
|
||||
parentBox: Box;
|
||||
/** Promote-gesture data, populated only when the gesture is available
|
||||
* at this gutter. See {@link GutterPromoteContext}. */
|
||||
promote?: GutterPromoteContext;
|
||||
}
|
||||
|
||||
/** Walk the tree and produce a flat list of leaf slots + draggable gutters.
|
||||
|
|
@ -415,11 +436,33 @@ export function flattenLayout(
|
|||
root: TreeNode,
|
||||
box: Box = { top: 0, left: 0, width: 1, height: 1 },
|
||||
): { leaves: LeafSlot[]; gutters: GutterInfo[] } {
|
||||
if (root.kind === "leaf") {
|
||||
return { leaves: [{ leaf: root, box }], gutters: [] };
|
||||
return flattenInner(root, box, null);
|
||||
}
|
||||
|
||||
/** Info passed down during recursion so each split can decide whether the
|
||||
* promote gesture is available at its gutter. */
|
||||
interface ParentCtx {
|
||||
/** The split whose child we are. */
|
||||
parent: SplitNode;
|
||||
/** Box of the **parent** (not us). When the gesture fires we replace
|
||||
* this entire region with the new outer split. */
|
||||
parentBox: Box;
|
||||
/** True iff we are parent.a (so the sibling is parent.b). */
|
||||
isFirstChild: boolean;
|
||||
/** Bounding box of our sibling pane in the parent. */
|
||||
siblingBox: Box;
|
||||
}
|
||||
|
||||
function flattenInner(
|
||||
node: TreeNode,
|
||||
box: Box,
|
||||
parent: ParentCtx | null,
|
||||
): { leaves: LeafSlot[]; gutters: GutterInfo[] } {
|
||||
if (node.kind === "leaf") {
|
||||
return { leaves: [{ leaf: node, box }], gutters: [] };
|
||||
}
|
||||
const isH = root.orientation === "h";
|
||||
const r = root.ratio;
|
||||
const isH = node.orientation === "h";
|
||||
const r = node.ratio;
|
||||
let boxA: Box;
|
||||
let boxB: Box;
|
||||
let gutter: GutterInfo;
|
||||
|
|
@ -433,7 +476,7 @@ export function flattenLayout(
|
|||
height: box.height,
|
||||
};
|
||||
gutter = {
|
||||
splitId: root.id,
|
||||
splitId: node.id,
|
||||
orientation: "h",
|
||||
ratio: r,
|
||||
box: {
|
||||
|
|
@ -454,7 +497,7 @@ export function flattenLayout(
|
|||
height: box.height - splitPos,
|
||||
};
|
||||
gutter = {
|
||||
splitId: root.id,
|
||||
splitId: node.id,
|
||||
orientation: "v",
|
||||
ratio: r,
|
||||
box: {
|
||||
|
|
@ -466,14 +509,89 @@ export function flattenLayout(
|
|||
parentBox: box,
|
||||
};
|
||||
}
|
||||
const a = flattenLayout(root.a, boxA);
|
||||
const b = flattenLayout(root.b, boxB);
|
||||
|
||||
// Promote-gesture metadata: available when the parent split is
|
||||
// perpendicular to us (otherwise extending the gutter across the
|
||||
// workspace would not change orientation, and the gesture would have
|
||||
// no semantic meaning).
|
||||
if (parent && parent.parent.orientation !== node.orientation) {
|
||||
gutter.promote = {
|
||||
exitDirection: exitDirectionToward(
|
||||
parent.parent.orientation,
|
||||
parent.isFirstChild,
|
||||
),
|
||||
siblingBox: parent.siblingBox,
|
||||
promotedBox: computePromotedBox(
|
||||
parent.parentBox,
|
||||
node.orientation,
|
||||
node.ratio,
|
||||
parent.isFirstChild,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Recurse, passing each child its own parent context.
|
||||
const a = flattenInner(node.a, boxA, {
|
||||
parent: node,
|
||||
parentBox: box,
|
||||
isFirstChild: true,
|
||||
siblingBox: boxB,
|
||||
});
|
||||
const b = flattenInner(node.b, boxB, {
|
||||
parent: node,
|
||||
parentBox: box,
|
||||
isFirstChild: false,
|
||||
siblingBox: boxA,
|
||||
});
|
||||
return {
|
||||
leaves: [...a.leaves, ...b.leaves],
|
||||
gutters: [gutter, ...a.gutters, ...b.gutters],
|
||||
};
|
||||
}
|
||||
|
||||
/** From the perspective of `parent.X` (X is a or b), which direction is the
|
||||
* sibling, i.e., which way would the cursor have to leave our box to
|
||||
* enter the sibling? */
|
||||
function exitDirectionToward(parentOrientation: Orientation, isFirstChild: boolean): Direction {
|
||||
if (parentOrientation === "h") {
|
||||
return isFirstChild ? "right" : "left";
|
||||
}
|
||||
return isFirstChild ? "down" : "up";
|
||||
}
|
||||
|
||||
/** Where the promoted leaf will land after the gesture commits.
|
||||
* - `splitOrientation` is the inner split's orientation (becomes the new
|
||||
* outer split's orientation).
|
||||
* - `splitRatio` is the inner split's ratio (becomes the new outer's
|
||||
* ratio so the gutter stays at the same absolute position).
|
||||
* - `isFirstChild` says which side the promoted leaf ends up on: matches
|
||||
* our side in the original parent. */
|
||||
function computePromotedBox(
|
||||
parentBox: Box,
|
||||
splitOrientation: Orientation,
|
||||
splitRatio: number,
|
||||
isFirstChild: boolean,
|
||||
): Box {
|
||||
if (splitOrientation === "h") {
|
||||
const leftFrac = isFirstChild ? 0 : splitRatio;
|
||||
const widthFrac = isFirstChild ? splitRatio : 1 - splitRatio;
|
||||
return {
|
||||
top: parentBox.top,
|
||||
left: parentBox.left + leftFrac * parentBox.width,
|
||||
width: widthFrac * parentBox.width,
|
||||
height: parentBox.height,
|
||||
};
|
||||
}
|
||||
const topFrac = isFirstChild ? 0 : splitRatio;
|
||||
const heightFrac = isFirstChild ? splitRatio : 1 - splitRatio;
|
||||
return {
|
||||
top: parentBox.top + topFrac * parentBox.height,
|
||||
left: parentBox.left,
|
||||
width: parentBox.width,
|
||||
height: heightFrac * parentBox.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** Update a split's ratio by its id. */
|
||||
export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number): TreeNode {
|
||||
return replaceById(root, splitId, (node) => {
|
||||
|
|
@ -482,6 +600,72 @@ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number)
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote-out gesture. Given a split S whose immediate parent P has
|
||||
* **perpendicular** orientation, restructure the tree so the gutter S
|
||||
* was nested inside extends out one level:
|
||||
*
|
||||
* HSplit(a, VSplit(b, c)) ──> VSplit(HSplit(a, b), c)
|
||||
*
|
||||
* The promoted child of S is the one on the SAME side as S itself in P
|
||||
* (so the gesture is symmetric: applying it to the result un-does it).
|
||||
* The other S child joins P's sibling on the combined side, in P's
|
||||
* orientation, preserving sibling's original P-side. Ratios are inherited
|
||||
* — the gutter stays at the same absolute position (modulo parent box
|
||||
* being the full workspace; in nested cases it shifts but stays sensible).
|
||||
*
|
||||
* Returns `null` if the gesture is not applicable (no parent, same
|
||||
* orientation as parent, or splitId not found).
|
||||
*/
|
||||
export function promoteFromGutter(root: TreeNode, splitId: NodeId): TreeNode | null {
|
||||
const found = findSplitWithParent(root, splitId);
|
||||
if (!found) return null;
|
||||
const { s, p, isFirstInP } = found;
|
||||
if (s.orientation === p.orientation) return null;
|
||||
|
||||
const sibling = isFirstInP ? p.b : p.a;
|
||||
// Promoted is S's child on the SAME side that S occupies in P.
|
||||
const promoted = isFirstInP ? s.a : s.b;
|
||||
const other = isFirstInP ? s.b : s.a;
|
||||
|
||||
const combined: SplitNode = {
|
||||
kind: "split",
|
||||
id: newId(),
|
||||
orientation: p.orientation,
|
||||
ratio: p.ratio,
|
||||
a: isFirstInP ? other : sibling,
|
||||
b: isFirstInP ? sibling : other,
|
||||
};
|
||||
const newOuter: SplitNode = {
|
||||
kind: "split",
|
||||
id: newId(),
|
||||
orientation: s.orientation,
|
||||
ratio: s.ratio,
|
||||
a: isFirstInP ? promoted : combined,
|
||||
b: isFirstInP ? combined : promoted,
|
||||
};
|
||||
|
||||
return replaceById(root, p.id, () => newOuter);
|
||||
}
|
||||
|
||||
/** Locate a split node and its immediate parent split. Returns null if
|
||||
* `splitId` is the root, doesn't exist, or refers to a leaf. */
|
||||
function findSplitWithParent(
|
||||
root: TreeNode,
|
||||
splitId: NodeId,
|
||||
): { s: SplitNode; p: SplitNode; isFirstInP: boolean } | null {
|
||||
if (root.kind !== "split") return null;
|
||||
if (root.a.kind === "split" && root.a.id === splitId) {
|
||||
return { s: root.a, p: root, isFirstInP: true };
|
||||
}
|
||||
if (root.b.kind === "split" && root.b.id === splitId) {
|
||||
return { s: root.b, p: root, isFirstInP: false };
|
||||
}
|
||||
return (
|
||||
findSplitWithParent(root.a, splitId) ?? findSplitWithParent(root.b, splitId)
|
||||
);
|
||||
}
|
||||
|
||||
export type Direction = "left" | "right" | "up" | "down";
|
||||
|
||||
/** Spatial pane navigation: given an active leaf, find the nearest neighbor
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue