UNPKG

@tanstack/devtools

Version:

TanStack Devtools is a set of tools for building advanced devtools for your application.

720 lines (657 loc) 23.5 kB
/** * The plugin workspace layout, as a tree of splits and tab groups, plus every * operation that can change it. * * Everything here is pure and imports nothing: no DOM, no Solid, no store. That * is deliberate. jsdom has no layout engine, so `getBoundingClientRect` returns * zeros and rect maths verified through the DOM would only be verifying its own * mocks. Keeping the maths here means it is exhaustively testable, and it keeps * the components free of layout logic. * * Invariants every returned tree upholds: * 1. a group has at least one tab * 2. a split has at least two children * 3. `sizes.length === children.length`, every size > 0, and they sum to 1 * 4. `active` indexes a real tab * 5. a plugin id appears at most once in the tree */ /** Where on a pane a drop landed. `center` stacks, the four edges split. */ export type DropZone = 'left' | 'right' | 'top' | 'bottom' | 'center' /** Internal: nothing outside this module names it, so it stays unexported. */ type Size = { w: number; h: number } export type Rect = { left: number; top: number; width: number; height: number } export type GroupNode = { kind: 'group' id: string /** Plugin ids, in tab order. Never empty. */ tabs: Array<string> /** Index into `tabs` of the visible one. */ active: number } export type SplitNode = { kind: 'split' /** `row` lays children out left to right, `col` top to bottom. */ dir: 'row' | 'col' /** Fractions summing to 1, one per child. */ sizes: Array<number> children: Array<LayoutNode> } export type LayoutNode = GroupNode | SplitNode /** Path from the root to a node, as the child index taken at each split. */ export type Path = Array<number> const EPSILON = 1e-9 /** * Depth ceiling when reading a layout out of storage. Nine panes cannot nest * deeper than nine, so anything past this is either corrupt or a self- * referencing object, and recursing into it would blow the stack. */ const MAX_STORED_DEPTH = 32 const isGroup = (node: LayoutNode): node is GroupNode => node.kind === 'group' const isSplit = (node: LayoutNode): node is SplitNode => node.kind === 'split' /** Every plugin id in visual order: left to right, top to bottom. */ export const flattenTabs = (tree: LayoutNode | null): Array<string> => tree === null ? [] : isGroup(tree) ? [...tree.tabs] : tree.children.flatMap(flattenTabs) export const allGroups = (tree: LayoutNode | null): Array<GroupNode> => tree === null ? [] : isGroup(tree) ? [tree] : tree.children.flatMap(allGroups) export const findGroupOfTab = ( tree: LayoutNode | null, tabId: string, ): GroupNode | null => allGroups(tree).find((group) => group.tabs.includes(tabId)) ?? null const findGroupById = ( tree: LayoutNode | null, groupId: string, ): GroupNode | null => allGroups(tree).find((group) => group.id === groupId) ?? null /** * A group id no other group is using. Derived from the ids already in the tree * rather than a counter or a random value, so the same inputs always give the * same output and tests stay readable. */ export const nextGroupId = (tree: LayoutNode | null): string => { let highest = -1 for (const group of allGroups(tree)) { const match = /^g(\d+)$/.exec(group.id) if (match) highest = Math.max(highest, Number(match[1])) } return `g${highest + 1}` } export const singleGroup = ( tabs: Array<string>, id = 'g0', ): LayoutNode | null => tabs.length === 0 ? null : { kind: 'group', id, tabs: [...tabs], active: 0 } /** Scale a list of weights so it sums to 1. Falls back to equal shares. */ const normalise = (sizes: Array<number>, count: number): Array<number> => { const usable = sizes.length === count && sizes.every((n) => Number.isFinite(n) && n > 0) ? sizes : Array.from({ length: count }, () => 1) const total = usable.reduce((sum, n) => sum + n, 0) return total > EPSILON ? usable.map((n) => n / total) : Array.from({ length: count }, () => 1 / count) } const split = ( dir: SplitNode['dir'], children: Array<LayoutNode>, sizes?: Array<number>, ): SplitNode => ({ kind: 'split', dir, sizes: normalise(sizes ?? [], children.length), children, }) /** * Restore invariants 1-4 bottom up: drop empty groups, unwrap single-child * splits, flatten a split directly inside a same-direction split, and clamp * `active`. Returns null when nothing is left. */ const prune = (node: LayoutNode | null): LayoutNode | null => { if (node === null) return null if (isGroup(node)) { if (node.tabs.length === 0) return null const active = Math.min(Math.max(node.active, 0), node.tabs.length - 1) return active === node.active ? node : { ...node, active } } const kept: Array<LayoutNode> = [] const keptSizes: Array<number> = [] node.children.forEach((child, index) => { const pruned = prune(child) if (pruned === null) return // A same-direction split nested directly inside this one is the same layout // written two ways; splicing it in keeps depth (and gutter count) honest. if (isSplit(pruned) && pruned.dir === node.dir) { const share = node.sizes[index] ?? 1 / node.children.length pruned.children.forEach((grandchild, inner) => { kept.push(grandchild) keptSizes.push(share * (pruned.sizes[inner] ?? 0)) }) return } kept.push(pruned) keptSizes.push(node.sizes[index] ?? 1 / node.children.length) }) if (kept.length === 0) return null if (kept.length === 1) return kept[0]! return split(node.dir, kept, keptSizes) } /** Remove a tab wherever it is, then restore invariants. */ export const closeTab = ( tree: LayoutNode | null, tabId: string, ): LayoutNode | null => { const strip = (node: LayoutNode): LayoutNode => { if (isGroup(node)) { const index = node.tabs.indexOf(tabId) if (index === -1) return node const tabs = node.tabs.filter((id) => id !== tabId) // Keep the neighbour that took its place selected, as an editor would. const active = node.active > index ? node.active - 1 : node.active return { ...node, tabs, active } } return { ...node, children: node.children.map(strip) } } return tree === null ? null : prune(strip(tree)) } /** Remove a whole group and everything in it. */ export const closeGroup = ( tree: LayoutNode | null, groupId: string, ): LayoutNode | null => { const group = findGroupById(tree, groupId) if (group === null) return tree return group.tabs.reduce<LayoutNode | null>( (acc, tabId) => closeTab(acc, tabId), tree, ) } /** * Replace a group's tab order wholesale, keeping the same tab selected. * * This is the seam a sortable tab bar reports into: it hands back the new order * and this writes it to the tree, which stays the source of truth. Ids that are * not already in the group are ignored — a transfer is `moveTab`, not this. */ export const setTabs = ( tree: LayoutNode | null, groupId: string, tabIds: Array<string>, ): LayoutNode | null => { const group = findGroupById(tree, groupId) if (tree === null || group === null) return tree const existing = new Set(group.tabs) const seen = new Set<string>() const reordered = tabIds.filter((id) => { if (!existing.has(id) || seen.has(id)) return false seen.add(id) return true }) // Anything the caller dropped stays, appended, so a partial report cannot // silently close a pane. const tabs = [...reordered, ...group.tabs.filter((id) => !seen.has(id))] if (tabs.length === 0) return tree const activeId = group.tabs[group.active] const active = Math.max( tabs.findIndex((id) => id === activeId), 0, ) const visit = (node: LayoutNode): LayoutNode => { if (isGroup(node)) { return node.id === groupId ? { ...node, tabs, active } : node } return { ...node, children: node.children.map(visit) } } return visit(tree) } /** Show a tab that is already open, without moving it. */ export const activateTab = ( tree: LayoutNode | null, tabId: string, ): LayoutNode | null => { if (tree === null) return null const visit = (node: LayoutNode): LayoutNode => { if (isGroup(node)) { const index = node.tabs.indexOf(tabId) return index === -1 || index === node.active ? node : { ...node, active: index } } return { ...node, children: node.children.map(visit) } } return visit(tree) } /** * Put `tabId` into `groupId` at `index`, removing it from wherever it was. Used * for reordering inside one group and for moving between groups; they are the * same operation with a different target. */ export const moveTab = ( tree: LayoutNode | null, tabId: string, groupId: string, index: number, ): LayoutNode | null => { if (tree === null) return null const target = findGroupById(tree, groupId) if (target === null) return tree const source = findGroupOfTab(tree, tabId) // Moving within one group is a reorder, so compute the destination against // the list as it looks with the tab already lifted out. const withoutTab = source === null ? tree : (closeTab(tree, tabId) ?? null) // Closing the tab may have pruned the target group out of existence, which // happens when it was that group's only tab. Then there is nothing to move. if (withoutTab === null) return singleGroup([tabId], groupId) if (findGroupById(withoutTab, groupId) === null) return tree const insert = (node: LayoutNode): LayoutNode => { if (isGroup(node)) { if (node.id !== groupId) return node const at = Math.min(Math.max(index, 0), node.tabs.length) const tabs = [...node.tabs.slice(0, at), tabId, ...node.tabs.slice(at)] return { ...node, tabs, active: at } } return { ...node, children: node.children.map(insert) } } return prune(insert(withoutTab)) } /** Add a tab to a group as a new stacked tab, and select it. */ export const stackInto = ( tree: LayoutNode | null, groupId: string, tabId: string, ): LayoutNode | null => { const group = findGroupById(tree, groupId) return group === null ? tree : moveTab(tree, tabId, groupId, group.tabs.length) } const zoneAxis = (zone: DropZone): SplitNode['dir'] => zone === 'left' || zone === 'right' ? 'row' : 'col' const zoneLeads = (zone: DropZone): boolean => zone === 'left' || zone === 'top' /** * Split the group under `groupId`, putting `tabId` in a new group on the given * side. `center` is not a split; callers should use `stackInto` for that. */ export const splitAt = ( tree: LayoutNode | null, groupId: string, zone: DropZone, tabId: string, ): LayoutNode | null => { if (tree === null) return singleGroup([tabId], 'g0') if (zone === 'center') return stackInto(tree, groupId, tabId) if (findGroupById(tree, groupId) === null) return tree // Lift the tab out first: it may currently live in the very group we are // about to split, and it must not end up on both sides. const lifted = closeTab(tree, tabId) if (lifted === null) return singleGroup([tabId], groupId) const host = findGroupById(lifted, groupId) // The group vanished when the dragged tab was its only occupant, so a split // would just be the tab beside itself. if (host === null) return tree const newGroup: GroupNode = { kind: 'group', id: nextGroupId(lifted), tabs: [tabId], active: 0, } const dir = zoneAxis(zone) const place = (node: LayoutNode): LayoutNode => { if (isGroup(node)) { if (node.id !== groupId) return node return split(dir, zoneLeads(zone) ? [newGroup, node] : [node, newGroup]) } return { ...node, children: node.children.map(place) } } return prune(place(lifted)) } /** * Add a pane alongside the existing ones, all sharing the space equally. * * This is what opening a plugin from the strip does, and it is deliberately not * `splitAt`: splitting the last pane halves *it*, so opening three would give * 1/2, 1/4, 1/4. Panes opened side by side should be the same size. */ export const appendPane = ( tree: LayoutNode | null, tabId: string, dir: SplitNode['dir'] = 'row', ): LayoutNode | null => { if (tree === null) return singleGroup([tabId]) const lifted = closeTab(tree, tabId) if (lifted === null) return singleGroup([tabId]) const newGroup: GroupNode = { kind: 'group', id: nextGroupId(lifted), tabs: [tabId], active: 0, } const children = isSplit(lifted) && lifted.dir === dir ? [...lifted.children, newGroup] : [lifted, newGroup] // No sizes argument, so `normalise` gives every child an equal share. return prune(split(dir, children)) } /** Locate a node by walking child indices from the root. */ const nodeAtPath = (tree: LayoutNode | null, path: Path): LayoutNode | null => { let node = tree for (const index of path) { if (node === null || !isSplit(node)) return null node = node.children[index] ?? null } return node } /** * Move `delta` (a fraction of the split's main axis) across the gutter between * children `gutterIndex` and `gutterIndex + 1`. One grows by exactly what the * other loses, and neither drops below `minFraction`, so the total stays 1 and * no other pane in the split is disturbed. */ export const resize = ( tree: LayoutNode | null, path: Path, gutterIndex: number, delta: number, minFraction = 0, ): LayoutNode | null => { const target = nodeAtPath(tree, path) if (tree === null || target === null || !isSplit(target)) return tree const before = target.sizes[gutterIndex] const after = target.sizes[gutterIndex + 1] if (before === undefined || after === undefined) return tree const budget = before + after // A min bigger than half the pair cannot be honoured for both, so fall back // to an even share instead of letting one side go negative. const min = Math.min(minFraction, budget / 2) const nextBefore = Math.min(Math.max(before + delta, min), budget - min) if (Math.abs(nextBefore - before) < EPSILON) return tree const sizes = [...target.sizes] sizes[gutterIndex] = nextBefore sizes[gutterIndex + 1] = budget - nextBefore const replace = (node: LayoutNode, depth: number): LayoutNode => { if (depth === path.length) return { ...(node as SplitNode), sizes } const index = path[depth]! const children = [...(node as SplitNode).children] children[index] = replace(children[index]!, depth + 1) return { ...(node as SplitNode), children } } return replace(tree, 0) } /** * Rect per group, from a walk of the tree. Tabs in the same group share its * rect because only the active one is displayed. */ export const layoutRects = ( tree: LayoutNode | null, box: Size, gutter = 0, ): Record<string, Rect> => { const out: Record<string, Rect> = {} const walk = (node: LayoutNode, rect: Rect): void => { if (isGroup(node)) { out[node.id] = rect return } const horizontal = node.dir === 'row' const gutters = gutter * (node.children.length - 1) const available = Math.max( (horizontal ? rect.width : rect.height) - gutters, 0, ) let offset = horizontal ? rect.left : rect.top node.children.forEach((child, index) => { const extent = available * (node.sizes[index] ?? 0) walk( child, horizontal ? { left: offset, top: rect.top, width: extent, height: rect.height } : { left: rect.left, top: offset, width: rect.width, height: extent }, ) offset += extent + gutter }) } if (tree !== null) { walk(tree, { left: 0, top: 0, width: box.w, height: box.h }) } return out } /** One draggable gutter: where it sits and which sizes it moves. */ export type SplitterHandle = { /** Path to the split this gutter belongs to. */ path: Path /** Gutter between children `gutterIndex` and `gutterIndex + 1`. */ gutterIndex: number dir: SplitNode['dir'] rect: Rect /** * The split's usable extent along its own axis, in px, excluding gutters. * Converts a pointer delta into a size fraction. */ extent: number } /** Every gutter in the tree, positioned in the same space as the pane rects. */ export const splitterHandles = ( tree: LayoutNode | null, box: Size, gutter = 0, ): Array<SplitterHandle> => { const handles: Array<SplitterHandle> = [] const walk = (node: LayoutNode, rect: Rect, path: Path): void => { if (isGroup(node)) return const horizontal = node.dir === 'row' const gutters = gutter * (node.children.length - 1) const available = Math.max( (horizontal ? rect.width : rect.height) - gutters, 0, ) let offset = horizontal ? rect.left : rect.top node.children.forEach((child, index) => { const extent = available * (node.sizes[index] ?? 0) const childRect: Rect = horizontal ? { left: offset, top: rect.top, width: extent, height: rect.height } : { left: rect.left, top: offset, width: rect.width, height: extent } walk(child, childRect, [...path, index]) offset += extent if (index < node.children.length - 1) { handles.push({ path, gutterIndex: index, dir: node.dir, extent: available, rect: horizontal ? { left: offset, top: rect.top, width: gutter, height: rect.height, } : { left: rect.left, top: offset, width: rect.width, height: gutter, }, }) offset += gutter } }) } if (tree !== null) { walk(tree, { left: 0, top: 0, width: box.w, height: box.h }, []) } return handles } /** Rect per plugin id, which is what the panes are keyed by. */ export const paneRects = ( tree: LayoutNode | null, box: Size, gutter = 0, ): Record<string, Rect> => { const byGroup = layoutRects(tree, box, gutter) const out: Record<string, Rect> = {} for (const group of allGroups(tree)) { const rect = byGroup[group.id] if (rect) for (const tabId of group.tabs) out[tabId] = rect } return out } /** * Whether splitting a group leaves both halves at least `min`. The workspace is * short, so a split that would produce an unreadable cell is refused and the * caller stacks instead. */ export const canSplit = ( tree: LayoutNode | null, groupId: string, zone: DropZone, min: Size, box: Size, gutter = 0, ): boolean => { if (zone === 'center') return true const rect = layoutRects(tree, box, gutter)[groupId] if (!rect) return false return zoneAxis(zone) === 'row' ? (rect.width - gutter) / 2 >= min.w : (rect.height - gutter) / 2 >= min.h } /** Which zone a pointer at `point` is in, given the pane's rect. */ export const zoneAt = ( point: { x: number; y: number }, rect: Rect, edge = 0.25, ): DropZone => { const x = rect.width > 0 ? (point.x - rect.left) / rect.width : 0.5 const y = rect.height > 0 ? (point.y - rect.top) / rect.height : 0.5 const distances: Array<[DropZone, number]> = [ ['left', x], ['right', 1 - x], ['top', y], ['bottom', 1 - y], ] const [zone, distance] = distances.reduce((best, entry) => entry[1] < best[1] ? entry : best, ) return distance <= edge ? zone : 'center' } /** Insertion index for a tab dropped at `x` over a row of tab rects. */ export const insertionIndex = (x: number, tabs: Array<Rect>): number => { let index = 0 for (const rect of tabs) { if (x < rect.left + rect.width / 2) break index += 1 } return index } const isRawGroup = (value: Record<string, unknown>): boolean => value.kind === 'group' && typeof value.id === 'string' && Array.isArray(value.tabs) && value.tabs.every((tab) => typeof tab === 'string') const isRawSplit = (value: Record<string, unknown>): boolean => value.kind === 'split' && (value.dir === 'row' || value.dir === 'col') && Array.isArray(value.children) /** * Turn whatever was in storage into a tree that upholds every invariant, or * null. Never throws: a malformed layout is a data problem, the same as the * unknown plugin ids that are already pruned on load, and it must not stop the * panel from opening. Storage *access* errors still propagate to the caller. */ export const repairLayout = ( raw: unknown, known: ReadonlySet<string>, ): LayoutNode | null => { const seen = new Set<string>() const rebuild = (value: unknown, depth = 0): LayoutNode | null => { if (depth > MAX_STORED_DEPTH) return null if (typeof value !== 'object' || value === null) return null const record = value as Record<string, unknown> if (isRawGroup(record)) { const tabs = (record.tabs as Array<string>).filter((tab) => { if (!known.has(tab) || seen.has(tab)) return false seen.add(tab) return true }) if (tabs.length === 0) return null const active = typeof record.active === 'number' && Number.isInteger(record.active) ? Math.min(Math.max(record.active, 0), tabs.length - 1) : 0 return { kind: 'group', id: String(record.id), tabs, active } } if (isRawSplit(record)) { const children = (record.children as Array<unknown>) .map((child) => rebuild(child, depth + 1)) .filter((child): child is LayoutNode => child !== null) if (children.length === 0) return null const rawSizes = Array.isArray(record.sizes) ? (record.sizes as Array<unknown>).filter( (size): size is number => typeof size === 'number', ) : [] return split(record.dir as SplitNode['dir'], children, rawSizes) } return null } const rebuilt = prune(rebuild(raw)) if (rebuilt !== null) return dedupeIds(rebuilt) // Unsalvageable shape. Recover whatever plugin ids are in there so a bad // write costs the arrangement but not the open plugins. const salvaged = collectKnownIds(raw, known) return singleGroup(salvaged) } /** Every string in `raw` that names a known plugin, in encounter order. */ const collectKnownIds = ( raw: unknown, known: ReadonlySet<string>, ): Array<string> => { const found: Array<string> = [] const seen = new Set<string>() // Storage can hold a self-referencing object, so remember what has been // visited rather than trusting the shape to be a tree. const visited = new WeakSet<object>() const walk = (value: unknown): void => { if (typeof value === 'string') { if (known.has(value) && !seen.has(value)) { seen.add(value) found.push(value) } return } if (typeof value !== 'object' || value === null) return if (visited.has(value)) return visited.add(value) if (Array.isArray(value)) { value.forEach(walk) return } Object.values(value).forEach(walk) } walk(raw) return found } /** Give any groups that share an id a fresh one, so lookups stay unambiguous. */ const dedupeIds = (tree: LayoutNode): LayoutNode => { const used = new Set<string>() let counter = 0 const visit = (node: LayoutNode): LayoutNode => { if (isGroup(node)) { if (!used.has(node.id)) { used.add(node.id) return node } let id = `g${counter++}` while (used.has(id)) id = `g${counter++}` used.add(id) return { ...node, id } } return { ...node, children: node.children.map(visit) } } return visit(tree) }