diff --git a/.changeset/beige-snakes-call.md b/.changeset/beige-snakes-call.md new file mode 100644 index 000000000..257ed2c48 --- /dev/null +++ b/.changeset/beige-snakes-call.md @@ -0,0 +1,5 @@ +--- +'@tanstack/devtools': patch +--- + +Reset splitter panes on double-click diff --git a/packages/devtools/src/components/plugin-workspace.tsx b/packages/devtools/src/components/plugin-workspace.tsx index 9f630e5e0..6f563294d 100644 --- a/packages/devtools/src/components/plugin-workspace.tsx +++ b/packages/devtools/src/components/plugin-workspace.tsx @@ -34,6 +34,7 @@ import { findGroupOfTab, layoutRects, moveTab, + resetSplit, resize, resizeFromPointer, setTabs, @@ -600,6 +601,10 @@ export const PluginWorkspace = (props: { document.addEventListener('pointerup', up) } + const resetSplitterDrag = (handle: SplitterHandle) => { + setLayout(resetSplit(layout(), handle.path, handle.gutterIndex)) + } + const resizeFromKeyboard = (handle: SplitterHandle, event: KeyboardEvent) => { const step = event.shiftKey ? KEYBOARD_STEP_COARSE : KEYBOARD_STEP const grows = @@ -867,6 +872,7 @@ export const PluginWorkspace = (props: { // Read through the accessor at gesture time, so a gutter that has // been re-measured since render still moves the right sizes. onPointerDown={(event) => startSplitterDrag(handle(), event)} + onDblClick={() => resetSplitterDrag(handle())} onKeyDown={(event) => resizeFromKeyboard(handle(), event)} /> ) diff --git a/packages/devtools/src/utils/layout-tree.test.ts b/packages/devtools/src/utils/layout-tree.test.ts index 327ba71c9..345928938 100644 --- a/packages/devtools/src/utils/layout-tree.test.ts +++ b/packages/devtools/src/utils/layout-tree.test.ts @@ -13,6 +13,7 @@ import { nextGroupId, paneRects, repairLayout, + resetSplit, resize, resizeFromPointer, singleGroup, @@ -354,6 +355,64 @@ describe('resize', () => { }) }) +describe('resetSplit', () => { + it('evens out an unequal pair', () => { + const tree = split( + 'row', + [group('g0', ['a']), group('g1', ['b'])], + [0.7, 0.3], + ) + const next = resetSplit(tree, [], 0) as SplitNode + expect(next.sizes[0]).toBeCloseTo(0.5, 10) + expect(next.sizes[1]).toBeCloseTo(0.5, 10) + expectWellFormed(next) + }) + + it('only touches the gutter pair, leaving the rest alone', () => { + const tree = split( + 'row', + [group('g0', ['a']), group('g1', ['b']), group('g2', ['c'])], + [0.1, 0.7, 0.2], + ) + const next = resetSplit(tree, [], 0) as SplitNode + expect(next.sizes[0]).toBeCloseTo(0.4, 10) + expect(next.sizes[1]).toBeCloseTo(0.4, 10) + expect(next.sizes[2]).toBeCloseTo(0.2, 10) + expectWellFormed(next) + }) + + it('resets a nested split by path', () => { + const tree = split('row', [ + group('g0', ['a']), + split('col', [group('g1', ['b']), group('g2', ['c'])], [0.8, 0.2]), + ]) + const next = resetSplit(tree, [1], 0) as SplitNode + const inner = next.children[1] as SplitNode + expect(inner.sizes[0]).toBeCloseTo(0.5, 10) + expect(inner.sizes[1]).toBeCloseTo(0.5, 10) + // The outer split is untouched. + expect(next.sizes[0]).toBeCloseTo(0.5, 10) + expectWellFormed(next) + }) + + it('is a no-op when the pair is already even', () => { + const tree = split( + 'row', + [group('g0', ['a']), group('g1', ['b'])], + [0.5, 0.5], + ) + expect(resetSplit(tree, [], 0)).toEqual(tree) + }) + + it('is a no-op for a bad path or gutter', () => { + const tree = split('row', [group('g0', ['a']), group('g1', ['b'])]) + expect(resetSplit(tree, [9], 0)).toEqual(tree) + expect(resetSplit(tree, [], 5)).toEqual(tree) + expect(resetSplit(group('g0', ['a']), [], 0)).toEqual(group('g0', ['a'])) + expect(resetSplit(null, [], 0)).toBeNull() + }) +}) + describe('resizeFromPointer', () => { it('applies total mouse travel to the snapshot so later moves do not compound', () => { const tree = split( diff --git a/packages/devtools/src/utils/layout-tree.ts b/packages/devtools/src/utils/layout-tree.ts index abb5dbda9..08b417f6f 100644 --- a/packages/devtools/src/utils/layout-tree.ts +++ b/packages/devtools/src/utils/layout-tree.ts @@ -427,6 +427,37 @@ export const resize = ( return replace(tree, 0) } +/** Evens out the two panes either side of a gutter, e.g. on splitter double-click. */ +export const resetSplit = ( + tree: LayoutNode | null, + path: Path, + gutterIndex: number, +): 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 even = (before + after) / 2 + if (Math.abs(before - even) < EPSILON && Math.abs(after - even) < EPSILON) { + return tree + } + + const sizes = [...target.sizes] + sizes[gutterIndex] = even + sizes[gutterIndex + 1] = even + + 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) +} + /** * Apply a pointer drag to the layout as it was at pointer-down. `deltaPx` is * the total movement from that start, not a per-frame increment. Always pass