fix(desktop): cascade a sash drag through sibling panes past their floors
A sash drag used to stop the moment its seam partner hit its min size, so a row of panes behaved like unrelated boxes: growing the Browser tile could not take space from Chat once the pane between them was at its floor. The drag now plans the whole run from the pointerdown sizes — the partner donates first, then its next visible sibling, and so on — and commits once on release (fixed zones get px overrides, the flex run gets weights). A reverse drag after a cascade stays local to the seam. Release only folds a tool zone that THIS gesture took to its floor, so an unrelated rail already resting there (a minimized Terminal) no longer cancels the Files/Chat commit. Salvaged from #103166 (Jerry Gooch), trimmed to the sash-drag change; the width/height lock feature, zone-body context menu and the restored-tool CSS floor are held as separate decisions.
This commit is contained in:
+183
@@ -0,0 +1,183 @@
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { $paneStates } from '@/store/panes'
|
||||
|
||||
import { group, split, type SplitNode } from '../model'
|
||||
import { $hiddenTreePanes, $layoutTree, markCollapsePane, setTreeGroupMinimized } from '../store'
|
||||
|
||||
import { TreeSplit } from './tree-split'
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const disposers: (() => void)[] = []
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => undefined)
|
||||
Element.prototype.hasPointerCapture ??= () => false
|
||||
Element.prototype.setPointerCapture ??= () => undefined
|
||||
Element.prototype.releasePointerCapture ??= () => undefined
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
$hiddenTreePanes.set(new Set())
|
||||
$paneStates.set({})
|
||||
|
||||
disposers.push(
|
||||
registry.register({ area: 'panes', data: { placement: 'main' }, id: 'chat', render: () => null, title: 'Chat' }),
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'main', width: '100px' },
|
||||
id: 'cron',
|
||||
render: () => null,
|
||||
title: 'Cron'
|
||||
}),
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'main' },
|
||||
id: 'browser',
|
||||
render: () => null,
|
||||
title: 'Browser'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$layoutTree.set(null)
|
||||
$paneStates.set({})
|
||||
disposers.splice(0).forEach(dispose => dispose())
|
||||
})
|
||||
|
||||
function rect(width: number): DOMRect {
|
||||
return {
|
||||
bottom: 600,
|
||||
height: 600,
|
||||
left: 0,
|
||||
right: width,
|
||||
toJSON: () => ({}),
|
||||
top: 0,
|
||||
width,
|
||||
x: 0,
|
||||
y: 0
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function setWidth(element: HTMLElement, width: number) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => rect(width) })
|
||||
}
|
||||
|
||||
function row(): SplitNode {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
if (!tree || tree.type !== 'split') {
|
||||
throw new Error('expected root row split')
|
||||
}
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
describe('TreeSplit cascading expansion', () => {
|
||||
it('grows Browser through Cron into Chat after Cron reaches its minimum', () => {
|
||||
const tree = split(
|
||||
'row',
|
||||
[
|
||||
group(['chat'], { id: 'chat-zone' }),
|
||||
group(['cron'], { id: 'cron-zone' }),
|
||||
group(['browser'], { id: 'browser-zone' })
|
||||
],
|
||||
[5, 1, 2],
|
||||
'root-row'
|
||||
)
|
||||
|
||||
$layoutTree.set(tree)
|
||||
|
||||
render(<TreeSplit node={tree} root rootRow />)
|
||||
|
||||
const container = document.querySelector<HTMLElement>('[data-tree-split="root-row"]')!
|
||||
const [chat, cron, browser] = [...container.children] as HTMLElement[]
|
||||
setWidth(container, 800)
|
||||
setWidth(chat, 500)
|
||||
setWidth(cron, 100)
|
||||
setWidth(browser, 200)
|
||||
setWidth(document.querySelector<HTMLElement>('[data-tree-group="cron-zone"]')!, 100)
|
||||
|
||||
const browserSash = document.querySelectorAll('[role="separator"]')[1]!
|
||||
fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
|
||||
|
||||
// Browser's 300px requested growth first takes Cron from 100px to its
|
||||
// 80px floor, then takes the remaining 280px from Chat. The browser gets
|
||||
// every released pixel instead of stopping at Cron's local floor.
|
||||
expect($paneStates.get().cron?.widthOverride).toBe(80)
|
||||
expect(row().weights).toEqual([2.2, 1, 5])
|
||||
})
|
||||
it('commits a regular cascade when an unrelated tool rail is already minimized', () => {
|
||||
markCollapsePane('terminal')
|
||||
disposers.push(
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { maxWidth: '600px', minWidth: '160px', placement: 'right', width: '200px' },
|
||||
id: 'browser',
|
||||
render: () => null,
|
||||
title: 'Browser'
|
||||
}),
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: { placement: 'bottom' },
|
||||
id: 'terminal',
|
||||
render: () => null,
|
||||
title: 'Terminal'
|
||||
})
|
||||
)
|
||||
|
||||
const tree = split(
|
||||
'row',
|
||||
[
|
||||
group(['chat'], { id: 'chat-zone' }),
|
||||
group(['cron'], { id: 'cron-zone' }),
|
||||
group(['browser'], { id: 'browser-zone' }),
|
||||
group(['terminal'], { id: 'terminal-zone' })
|
||||
],
|
||||
[5, 1, 2, 0.28],
|
||||
'root-row'
|
||||
)
|
||||
|
||||
$layoutTree.set(tree)
|
||||
$paneStates.set({ browser: { open: true, widthOverride: 200 } })
|
||||
setTreeGroupMinimized('terminal-zone', true)
|
||||
|
||||
render(<TreeSplit node={row()} root rootRow />)
|
||||
|
||||
const container = document.querySelector<HTMLElement>('[data-tree-split="root-row"]')!
|
||||
const [chat, cron, browser, terminal] = [...container.children] as HTMLElement[]
|
||||
setWidth(container, 828)
|
||||
setWidth(chat, 500)
|
||||
setWidth(cron, 100)
|
||||
setWidth(browser, 200)
|
||||
setWidth(terminal, 28)
|
||||
setWidth(document.querySelector<HTMLElement>('[data-tree-group="cron-zone"]')!, 100)
|
||||
setWidth(document.querySelector<HTMLElement>('[data-tree-group="browser-zone"]')!, 200)
|
||||
setWidth(document.querySelector<HTMLElement>('[data-tree-group="terminal-zone"]')!, 28)
|
||||
|
||||
const browserSash = document.querySelectorAll('[role="separator"]')[1]!
|
||||
fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
|
||||
fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
|
||||
|
||||
expect($paneStates.get().cron?.widthOverride).toBe(80)
|
||||
expect($paneStates.get().browser?.widthOverride).toBe(500)
|
||||
expect(row().weights[0]).toBeCloseTo(2.2)
|
||||
expect(row().children[3]).toMatchObject({ id: 'terminal-zone', minimized: true })
|
||||
})
|
||||
})
|
||||
@@ -73,6 +73,7 @@ function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['override
|
||||
|
||||
const snapshot = useCallback(() => {
|
||||
const all = $paneStates.get()
|
||||
|
||||
const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|')
|
||||
|
||||
if (cache.current.sig !== sig) {
|
||||
@@ -245,15 +246,167 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
||||
return
|
||||
}
|
||||
|
||||
const a = sideFor(node.children[aIndex], kidA, 'end')
|
||||
const b = sideFor(node.children[bIndex], kidB, 'start')
|
||||
const a0px = a.fixed ? a.size : sizeOf(kidA)
|
||||
const b0px = b.fixed ? b.size : sizeOf(kidB)
|
||||
const lo = Math.max(a.min - a0px, b0px - b.max)
|
||||
const hi = Math.min(a.max - a0px, b0px - b.min)
|
||||
const tracks = node.children.map((child, index) => {
|
||||
const element = container.children[index] as HTMLElement | undefined
|
||||
|
||||
if (!element) {
|
||||
return null
|
||||
}
|
||||
|
||||
const side = sideFor(child, element, index <= aIndex ? 'end' : 'start')
|
||||
|
||||
return {
|
||||
...side,
|
||||
element,
|
||||
index,
|
||||
initial: side.fixed ? side.size : sizeOf(element),
|
||||
// A minimized rail is its 28px strip: it neither donates nor takes,
|
||||
// and its remembered weight must survive the gesture so restoring
|
||||
// it brings back the size it had before it was folded.
|
||||
minimized: child.type === 'group' && Boolean(child.minimized),
|
||||
visible: !isCollapsed(child)
|
||||
}
|
||||
})
|
||||
|
||||
if (tracks.some(track => !track)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sashTracks = tracks as Array<NonNullable<(typeof tracks)[number]>>
|
||||
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
|
||||
|
||||
// The seam is ONE boundary of a whole run: the side the pointer moves
|
||||
// toward gives up space, its seam partner first. When the partner hits
|
||||
// its floor the drag keeps going and takes from the partner's next
|
||||
// visible sibling, and so on — a row behaves like one workspace instead
|
||||
// of a set of unrelated boxes. Plans are absolute (from the sizes at
|
||||
// pointerdown), so every frame is a pure function of the pointer.
|
||||
const planFor = (requestedShift: number, allowCascade: boolean) => {
|
||||
const toward = requestedShift >= 0 ? 1 : -1
|
||||
const targetIndex = toward > 0 ? aIndex : bIndex
|
||||
// The partner is the nearest VISIBLE sibling (`aIndex`/`bIndex`), not
|
||||
// `target ± 1` — a hidden zone parked between them (the closed preview
|
||||
// pane) is display:none and must neither donate nor block.
|
||||
const partnerIndex = toward > 0 ? bIndex : aIndex
|
||||
const target = sashTracks[targetIndex]
|
||||
const next = sashTracks.map(track => track.initial)
|
||||
let remaining = Math.min(Math.abs(requestedShift), Math.max(0, target.max - target.initial))
|
||||
let transferred = 0
|
||||
let cascaded = false
|
||||
// A tool rail is locally resizable at its own seam, but must not make
|
||||
// every unrelated seam in the row local-only. It also remains the
|
||||
// terminal donor when reached only after another regular track.
|
||||
const cascade = allowCascade && !target.collapseId
|
||||
|
||||
for (let donorIndex = partnerIndex; donorIndex >= 0 && donorIndex < sashTracks.length; donorIndex += toward) {
|
||||
const donor = sashTracks[donorIndex]
|
||||
|
||||
if (!donor.visible) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (donor.collapseId && donorIndex !== partnerIndex) {
|
||||
break
|
||||
}
|
||||
|
||||
const take = Math.min(remaining, Math.max(0, donor.initial - donor.min))
|
||||
next[donorIndex] -= take
|
||||
transferred += take
|
||||
remaining -= take
|
||||
|
||||
if (take > 0 && donorIndex !== partnerIndex) {
|
||||
cascaded = true
|
||||
}
|
||||
|
||||
if (remaining === 0 || !cascade) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
next[targetIndex] += transferred
|
||||
|
||||
return { cascaded, moved: Math.sign(requestedShift) * transferred, sizes: next }
|
||||
}
|
||||
|
||||
// One store commit on release: fixed zones get their px override
|
||||
// (sidebar semantics), the flex run gets weights. Clamped px → weights
|
||||
// so persisted weights match what's on screen.
|
||||
const commitPlan = (plan: ReturnType<typeof planFor>) => {
|
||||
const weights = [...node.weights]
|
||||
let weightsChanged = false
|
||||
|
||||
sashTracks.forEach((track, index) => {
|
||||
if (!track.visible || track.minimized) {
|
||||
return
|
||||
}
|
||||
|
||||
const px = Math.round(plan.sizes[index])
|
||||
|
||||
if (track.fixed) {
|
||||
if (px !== Math.round(track.initial)) {
|
||||
track.paneIds.forEach(id => setOverride(id, px))
|
||||
}
|
||||
} else {
|
||||
weights[index] = Math.max(0.01, plan.sizes[index] / pxPerWeight)
|
||||
weightsChanged = true
|
||||
}
|
||||
})
|
||||
|
||||
if (weightsChanged) {
|
||||
setTreeSplitWeights(node.id, weights)
|
||||
}
|
||||
}
|
||||
|
||||
const styleSnapshots = sashTracks.map(track => track.element.getAttribute('style'))
|
||||
|
||||
const restoreStyles = () => {
|
||||
sashTracks.forEach((track, index) => {
|
||||
const style = styleSnapshots[index]
|
||||
|
||||
if (style === null) {
|
||||
track.element.removeAttribute('style')
|
||||
} else {
|
||||
track.element.setAttribute('style', style)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// During the gesture the store is NOT written. setTreeSplitWeights /
|
||||
// setPaneWidthOverride each mint a new tree/pane-state object, and the
|
||||
// resulting commit walks every mounted pane — measured live on a real
|
||||
// 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with
|
||||
// TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The
|
||||
// store is written ONCE on release; during the drag the run is
|
||||
// previewed with inline styles on the same wrappers React sizes.
|
||||
//
|
||||
// Preview rules (learned the hard way — a wrong shape here left a
|
||||
// phantom gap where a hidden sidebar lived):
|
||||
// - a FIXED track gets ONLY a flex-basis override. Its wrapper renders
|
||||
// as `flex: 0 1 <track>`, so basis is the whole difference; grow and
|
||||
// shrink stay React's.
|
||||
// - every visible flex track is pinned to `0 1 <px>`. The plan keeps
|
||||
// the run's total px constant, so no leftover gap can open.
|
||||
// - hidden (display:none) and minimized tracks are left untouched.
|
||||
// - cleanup: a real drag commits the store once, and React's re-render
|
||||
// rewrites the `flex` shorthand, which clears the overrides (writing
|
||||
// the shorthand resets the longhands). A no-movement click restores
|
||||
// the captured style attributes instead, since nothing re-renders.
|
||||
const previewPlan = (plan: ReturnType<typeof planFor>) => {
|
||||
sashTracks.forEach((track, index) => {
|
||||
if (!track.visible || track.minimized) {
|
||||
return
|
||||
}
|
||||
|
||||
const px = plan.sizes[index]
|
||||
|
||||
if (track.fixed) {
|
||||
track.element.style.flexBasis = `${px}px`
|
||||
} else {
|
||||
track.element.style.flex = `0 1 ${px}px`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
handle.setPointerCapture?.(pointerId)
|
||||
} catch {
|
||||
@@ -272,70 +425,26 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
||||
|
||||
// pointermove outpaces 60fps and each write relayouts the whole pane tree,
|
||||
// so coalesce to one apply per frame (rafCoalesce commits on cleanup).
|
||||
//
|
||||
// During the gesture the store is NOT written. setTreeSplitWeights /
|
||||
// setPaneWidthOverride each mint a new tree/pane-state object, and the
|
||||
// resulting commit walks every mounted pane — measured live on a real
|
||||
// 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with
|
||||
// TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The
|
||||
// store is written ONCE on release; during the drag the seam is
|
||||
// previewed with inline styles on the same wrappers React sizes.
|
||||
//
|
||||
// Preview rules (learned the hard way — a wrong shape here left a
|
||||
// phantom gap where a hidden sidebar lived):
|
||||
// - a FIXED side gets ONLY a flex-basis override. Its wrapper renders
|
||||
// as `flex: 0 1 <track>`, so basis is the whole difference; grow and
|
||||
// shrink stay React's. Crucially the flex partner is left untouched,
|
||||
// so it keeps absorbing the remainder and no leftover gap can open.
|
||||
// - a flex-vs-flex seam pins both sides to `0 1 <px>`. Their combined
|
||||
// px is constant, so sibling flex tracks see the same leftover.
|
||||
// - cleanup: a real drag commits the store once, and React's re-render
|
||||
// rewrites the `flex` shorthand, which clears the overrides (writing
|
||||
// the shorthand resets the longhands). A no-movement click restores
|
||||
// the captured style attribute instead, since nothing re-renders.
|
||||
const applyShift = (shiftPx: number) => {
|
||||
if (a.fixed) {
|
||||
a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx)))
|
||||
}
|
||||
|
||||
if (b.fixed) {
|
||||
b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx)))
|
||||
}
|
||||
|
||||
if (!a.fixed && !b.fixed) {
|
||||
const weights = [...node.weights]
|
||||
// Clamped px → weights so persisted weights match what's on screen.
|
||||
weights[aIndex] = (a0px + shiftPx) / pxPerWeight
|
||||
weights[bIndex] = (b0px - shiftPx) / pxPerWeight
|
||||
setTreeSplitWeights(node.id, weights)
|
||||
}
|
||||
}
|
||||
|
||||
const styleA = kidA.getAttribute('style')
|
||||
const styleB = kidB.getAttribute('style')
|
||||
|
||||
const previewSide = (el: HTMLElement, fixed: boolean, px: number) => {
|
||||
if (fixed) {
|
||||
el.style.flexBasis = `${px}px`
|
||||
} else if (!a.fixed && !b.fixed) {
|
||||
el.style.flex = `0 1 ${px}px`
|
||||
}
|
||||
// Mixed seam, flex side: untouched — it absorbs what the fixed side
|
||||
// gives up, exactly as the track model would render it.
|
||||
}
|
||||
|
||||
const previewShift = (shiftPx: number) => {
|
||||
previewSide(kidA, a.fixed, a0px + shiftPx)
|
||||
previewSide(kidB, b.fixed, b0px - shiftPx)
|
||||
}
|
||||
|
||||
const resize = rafCoalesce(previewShift)
|
||||
let lastShift: null | number = null
|
||||
const resize = rafCoalesce(previewPlan)
|
||||
// A return drag stays local only after this gesture has actually crossed
|
||||
// a second donor. A one-pixel opposite wobble must not poison the real
|
||||
// forward movement that follows.
|
||||
let cascadeDirection: -1 | 1 | null = null
|
||||
let lastPlan: null | ReturnType<typeof planFor> = null
|
||||
let done = false
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
|
||||
resize.push(lastShift)
|
||||
const shift = (horizontal ? ev.clientX : ev.clientY) - start
|
||||
const direction = Math.sign(shift) as -1 | 0 | 1
|
||||
|
||||
const allowCascade = cascadeDirection === null || direction === cascadeDirection
|
||||
lastPlan = planFor(shift, allowCascade)
|
||||
|
||||
if (cascadeDirection === null && direction !== 0 && lastPlan.cascaded) {
|
||||
cascadeDirection = direction
|
||||
}
|
||||
|
||||
resize.push(lastPlan)
|
||||
}
|
||||
|
||||
// Ends through several racing paths (pointerup, pointercancel, window
|
||||
@@ -349,36 +458,25 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
|
||||
done = true
|
||||
resize.finish()
|
||||
|
||||
if (lastShift !== null) {
|
||||
if (lastPlan && lastPlan.moved !== 0) {
|
||||
// Dragged a tool panel down to its collapsed header? Fold the zone
|
||||
// to its rail instead of persisting a sliver — and DON'T write the
|
||||
// sliver size, so restoring brings back the size it had before.
|
||||
const collapsedSide =
|
||||
(a.collapseId && a0px + lastShift <= a.floor && a.collapseId) ||
|
||||
(b.collapseId && b0px - lastShift <= b.floor && b.collapseId) ||
|
||||
null
|
||||
// Only a track THIS gesture took to its floor counts: an unrelated
|
||||
// rail already resting there must not cancel the commit.
|
||||
const collapsedSide = sashTracks.find(
|
||||
(track, index) => track.collapseId && track.initial > track.floor && lastPlan!.sizes[index] <= track.floor
|
||||
)?.collapseId
|
||||
|
||||
if (collapsedSide) {
|
||||
setTreeGroupMinimized(collapsedSide, true)
|
||||
} else {
|
||||
// One store commit; the re-render rewrites `flex` and clears the
|
||||
// preview overrides.
|
||||
applyShift(lastShift)
|
||||
commitPlan(lastPlan)
|
||||
}
|
||||
} else {
|
||||
// Click without movement: nothing will re-render, so put the
|
||||
// wrappers' inline styles back exactly as React last wrote them.
|
||||
if (styleA === null) {
|
||||
kidA.removeAttribute('style')
|
||||
} else {
|
||||
kidA.setAttribute('style', styleA)
|
||||
}
|
||||
|
||||
if (styleB === null) {
|
||||
kidB.removeAttribute('style')
|
||||
} else {
|
||||
kidB.setAttribute('style', styleB)
|
||||
}
|
||||
restoreStyles()
|
||||
}
|
||||
|
||||
// Geometry vars re-enable AFTER the final store commit above, so the
|
||||
|
||||
Reference in New Issue
Block a user