Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212export type SplitOrientation = 'row' | 'column';
export type TileNode = { kind: 'tile'; id: string;};
export type SplitNode = { kind: 'split'; id: string; orientation: SplitOrientation; ratio: number; first: TilingNode; second: TilingNode;};
export type TilingNode = TileNode | SplitNode;
export const DEFAULT_SPLIT_RATIO = 0.5;export const MIN_TILE_WIDTH_PX = 220;export const MIN_TILE_HEIGHT_PX = 160;
export function createInitialTree(tileId: string): TilingNode { return { kind: 'tile', id: tileId };}
type SplitResult = | { found: true; node: TilingNode; newTileId: string; } | { found: false; node: TilingNode; };
export function splitLeaf( tree: TilingNode, activeTileId: string, orientation: SplitOrientation, ids: { splitId: string; tileId: string }, /** When true the new tile becomes the first child (left/top); else second. */ before = false,): { nextTree: TilingNode; newTileId: string } | null { const res = splitLeafInternal(tree, activeTileId, orientation, ids, before); if (!res.found) return null; return { nextTree: res.node, newTileId: res.newTileId };}
function splitLeafInternal( node: TilingNode, activeTileId: string, orientation: SplitOrientation, ids: { splitId: string; tileId: string }, before: boolean,): SplitResult { if (node.kind === 'tile') { if (node.id !== activeTileId) { return { found: false, node }; }
const newTile: TileNode = { kind: 'tile', id: ids.tileId }; return { found: true, newTileId: ids.tileId, node: { kind: 'split', id: ids.splitId, orientation, ratio: DEFAULT_SPLIT_RATIO, first: before ? newTile : node, second: before ? node : newTile, }, }; }
const firstRes = splitLeafInternal( node.first, activeTileId, orientation, ids, before, ); if (firstRes.found) { return { found: true, newTileId: firstRes.newTileId, node: { ...node, first: firstRes.node, }, }; }
const secondRes = splitLeafInternal( node.second, activeTileId, orientation, ids, before, ); if (secondRes.found) { return { found: true, newTileId: secondRes.newTileId, node: { ...node, second: secondRes.node, }, }; }
return { found: false, node };}
export function clampRatioForAxis( ratio: number, orientation: SplitOrientation, axisSizePx: number,): number { if (axisSizePx <= 0) return ratio; const minPx = orientation === 'row' ? MIN_TILE_WIDTH_PX : MIN_TILE_HEIGHT_PX; const minRatio = minPx / axisSizePx; const maxRatio = 1 - minRatio; if (minRatio >= maxRatio) return DEFAULT_SPLIT_RATIO; return Math.max(minRatio, Math.min(maxRatio, ratio));}
export function updateSplitRatio( node: TilingNode, splitId: string, ratio: number,): TilingNode { if (node.kind === 'tile') return node; if (node.id === splitId) { return { ...node, ratio }; } return { ...node, first: updateSplitRatio(node.first, splitId, ratio), second: updateSplitRatio(node.second, splitId, ratio), };}
/** * Remove a tile from the tree, collapsing its parent split into the sibling * subtree (the sibling expands to fill the freed space — VS Code / Obsidian * behavior on closing the last tab in a pane). * * Removing the root (only) tile is a **no-op**: there is always at least one * tile, so callers should keep it as a single empty tile rather than emptying * the workspace. Returns the same reference when `tileId` is not found. */export function removeLeaf(node: TilingNode, tileId: string): TilingNode { if (node.kind === 'tile') { // The only tile cannot be removed; the workspace always has >= 1 tile. return node; } if (node.first.kind === 'tile' && node.first.id === tileId) { return node.second; } if (node.second.kind === 'tile' && node.second.id === tileId) { return node.first; } const first = removeLeaf(node.first, tileId); const second = removeLeaf(node.second, tileId); if (first === node.first && second === node.second) { return node; } return { ...node, first, second };}
/** * In-order (left-to-right, depth-first) list of every tile id in the tree. * Used to detect the single-tile case and to pick a focus target after a * collapse. */export function listTileIds(node: TilingNode): string[] { if (node.kind === 'tile') return [node.id]; return [...listTileIds(node.first), ...listTileIds(node.second)];}
/** * What to do with the split tree when a tile loses its last tab. * - `reseed`: it was the only tile — keep it and refill it with an empty tab. * - `collapse`: remove the tile (sibling expands) and report the next focus. */export type TileClosePlan = | { kind: 'reseed' } | { kind: 'collapse'; nextTree: TilingNode; nextActiveTileId: string };
/** * Decide how to update the tree when `tileId`'s last tab closes. Pure so the * component can stay a thin applier. When the closed tile was active, focus * moves to the first surviving tile (left-to-right). */export function planTileRemoval( tree: TilingNode, tileId: string, activeTileId: string,): TileClosePlan { if (listTileIds(tree).length <= 1) { return { kind: 'reseed' }; } const nextTree = removeLeaf(tree, tileId); const survivors = listTileIds(nextTree); const nextActiveTileId = activeTileId === tileId ? (survivors[0] ?? tileId) : activeTileId; return { kind: 'collapse', nextTree, nextActiveTileId };}