fix(desktop): track the pointer when dragging a nested section's sash
The workspace | right-rail seam resizes the rail's inner review zone, but the drag preview wrote that zone's px as the flex-basis of the whole section wrapper, so the rail snapped narrower mid-drag and jumped on release. The preview now grows each fixed track's wrapper by the zone's delta from its own pointerdown width, sizes the inner zone item to the planned px, and restores both on release. This applies per track, so a cascade that reaches a nested section past the seam partner previews correctly too. Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
|||||||
|
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 } from '../model'
|
||||||
|
import { $hiddenTreePanes, $layoutTree } 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 })
|
||||||
|
// Flush the drag preview on the same turn so mid-drag styles are observable.
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||||
|
cb(0)
|
||||||
|
|
||||||
|
return 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: 'workspace',
|
||||||
|
render: () => null,
|
||||||
|
title: 'Chat'
|
||||||
|
}),
|
||||||
|
registry.register({
|
||||||
|
area: 'panes',
|
||||||
|
data: { maxWidth: '320px', minWidth: '160px', placement: 'right', width: '237px' },
|
||||||
|
id: 'review',
|
||||||
|
render: () => null,
|
||||||
|
title: 'Review'
|
||||||
|
}),
|
||||||
|
registry.register({
|
||||||
|
area: 'panes',
|
||||||
|
data: { maxWidth: '320px', minWidth: '160px', placement: 'right', width: '237px' },
|
||||||
|
id: 'files',
|
||||||
|
render: () => null,
|
||||||
|
title: 'Files'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default-tree shape: workspace | column[row[review|files]]. The seam's resize
|
||||||
|
* target is the inner review zone, not the section wrapper. */
|
||||||
|
function nestedRail() {
|
||||||
|
return split(
|
||||||
|
'row',
|
||||||
|
[
|
||||||
|
group(['workspace'], { id: 'grp-main' }),
|
||||||
|
split(
|
||||||
|
'column',
|
||||||
|
[
|
||||||
|
split(
|
||||||
|
'row',
|
||||||
|
[group(['review'], { id: 'grp-review' }), group(['files'], { id: 'grp-files' })],
|
||||||
|
[1, 1.2],
|
||||||
|
'spl-rail'
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[1],
|
||||||
|
'spl-right'
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[3.4, 1.25],
|
||||||
|
'spl-root'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TreeSplit nested section sash preview', () => {
|
||||||
|
it('sizes the section wrapper from its pointerdown width and previews the inner zone', () => {
|
||||||
|
const tree = nestedRail()
|
||||||
|
|
||||||
|
$layoutTree.set(tree)
|
||||||
|
render(<TreeSplit node={tree} root rootRow />)
|
||||||
|
|
||||||
|
const container = document.querySelector<HTMLElement>('[data-tree-split="spl-root"]')!
|
||||||
|
const [workspace, section] = [...container.children] as HTMLElement[]
|
||||||
|
const review = document.querySelector<HTMLElement>('[data-tree-group="grp-review"]')!
|
||||||
|
const reviewItem = review.parentElement as HTMLElement
|
||||||
|
|
||||||
|
setWidth(container, 1000)
|
||||||
|
setWidth(workspace, 526)
|
||||||
|
setWidth(section, 474)
|
||||||
|
setWidth(review, 237)
|
||||||
|
setWidth(document.querySelector<HTMLElement>('[data-tree-group="grp-files"]')!, 237)
|
||||||
|
|
||||||
|
const sash = section.querySelector('[role="separator"]')!
|
||||||
|
|
||||||
|
fireEvent.pointerDown(sash, { button: 0, clientX: 700, pointerId: 1, pointerType: 'mouse' })
|
||||||
|
fireEvent.pointerMove(window, { clientX: 620, pointerId: 1, pointerType: 'mouse' })
|
||||||
|
|
||||||
|
// 474px section + 80px drag, not the 237px review zone wearing the section's basis.
|
||||||
|
expect(section.style.flexBasis).toBe('554px')
|
||||||
|
expect(reviewItem).not.toBe(section)
|
||||||
|
expect(reviewItem.style.flexBasis).toBe('317px')
|
||||||
|
|
||||||
|
fireEvent.pointerUp(window, { clientX: 620, pointerId: 1, pointerType: 'mouse' })
|
||||||
|
|
||||||
|
expect($paneStates.get().review?.widthOverride).toBe(317)
|
||||||
|
expect($paneStates.get().files?.widthOverride).toBeUndefined()
|
||||||
|
// React commits the section as a calc() of the zone override plus its
|
||||||
|
// siblings. A stuck preview pin would still be the raw `554px` basis.
|
||||||
|
expect(section.style.flexBasis).not.toBe('554px')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews a nested section reached by a cascade past the seam partner', () => {
|
||||||
|
disposers.push(
|
||||||
|
registry.register({
|
||||||
|
area: 'panes',
|
||||||
|
data: { placement: 'main', width: '100px' },
|
||||||
|
id: 'cron',
|
||||||
|
render: () => null,
|
||||||
|
title: 'Cron'
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const tree = split(
|
||||||
|
'row',
|
||||||
|
[
|
||||||
|
group(['workspace'], { id: 'grp-main' }),
|
||||||
|
group(['cron'], { id: 'grp-cron' }),
|
||||||
|
split(
|
||||||
|
'column',
|
||||||
|
[
|
||||||
|
split(
|
||||||
|
'row',
|
||||||
|
[group(['review'], { id: 'grp-review' }), group(['files'], { id: 'grp-files' })],
|
||||||
|
[1, 1.2],
|
||||||
|
'spl-rail'
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[1],
|
||||||
|
'spl-right'
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[3, 1, 1.25],
|
||||||
|
'spl-root'
|
||||||
|
)
|
||||||
|
|
||||||
|
$layoutTree.set(tree)
|
||||||
|
render(<TreeSplit node={tree} root rootRow />)
|
||||||
|
|
||||||
|
const container = document.querySelector<HTMLElement>('[data-tree-split="spl-root"]')!
|
||||||
|
const [workspace, cron, section] = [...container.children] as HTMLElement[]
|
||||||
|
const review = document.querySelector<HTMLElement>('[data-tree-group="grp-review"]')!
|
||||||
|
const reviewItem = review.parentElement as HTMLElement
|
||||||
|
|
||||||
|
setWidth(container, 1000)
|
||||||
|
setWidth(workspace, 426)
|
||||||
|
setWidth(cron, 100)
|
||||||
|
setWidth(document.querySelector<HTMLElement>('[data-tree-group="grp-cron"]')!, 100)
|
||||||
|
setWidth(section, 474)
|
||||||
|
setWidth(review, 237)
|
||||||
|
setWidth(document.querySelector<HTMLElement>('[data-tree-group="grp-files"]')!, 237)
|
||||||
|
|
||||||
|
const sash = cron.querySelector('[role="separator"]')!
|
||||||
|
|
||||||
|
fireEvent.pointerDown(sash, { button: 0, clientX: 426, pointerId: 1, pointerType: 'mouse' })
|
||||||
|
fireEvent.pointerMove(window, { clientX: 486, pointerId: 1, pointerType: 'mouse' })
|
||||||
|
|
||||||
|
// Cron gives 20px down to its 80px floor, then the section's review zone
|
||||||
|
// gives the other 40px: the wrapper shrinks from 474px, the zone from 237px.
|
||||||
|
expect(cron.style.flexBasis).toBe('80px')
|
||||||
|
expect(section.style.flexBasis).toBe('434px')
|
||||||
|
expect(reviewItem.style.flexBasis).toBe('197px')
|
||||||
|
|
||||||
|
fireEvent.pointerUp(window, { clientX: 486, pointerId: 1, pointerType: 'mouse' })
|
||||||
|
|
||||||
|
expect($paneStates.get().cron?.widthOverride).toBe(80)
|
||||||
|
expect($paneStates.get().review?.widthOverride).toBe(197)
|
||||||
|
expect($paneStates.get().files?.widthOverride).toBeUndefined()
|
||||||
|
expect(section.style.flexBasis).not.toBe('434px')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -232,8 +232,9 @@ export function TreeSplit({
|
|||||||
const zone = fixed ? edgeFixedZone(child, edge, axis, trackCtx) : null
|
const zone = fixed ? edgeFixedZone(child, edge, axis, trackCtx) : null
|
||||||
const zoneEl = zone ? container.querySelector<HTMLElement>(`[data-tree-group="${zone.id}"]`) : null
|
const zoneEl = zone ? container.querySelector<HTMLElement>(`[data-tree-group="${zone.id}"]`) : null
|
||||||
// Clamps live on the zone's split-child WRAPPER (where we render them).
|
// Clamps live on the zone's split-child WRAPPER (where we render them).
|
||||||
const el = zoneEl?.parentElement ?? wrapper
|
// For a nested section this is the INNER flex item, not the seam partner.
|
||||||
const cs = window.getComputedStyle(el)
|
const zoneItem = zoneEl?.parentElement ?? wrapper
|
||||||
|
const cs = window.getComputedStyle(zoneItem)
|
||||||
// A tool panel (terminal / logs) may be dragged down to its collapsed
|
// A tool panel (terminal / logs) may be dragged down to its collapsed
|
||||||
// header — the generic 80px floor is not its floor. Below that the
|
// header — the generic 80px floor is not its floor. Below that the
|
||||||
// release minimizes the zone instead of leaving a useless sliver.
|
// release minimizes the zone instead of leaving a useless sliver.
|
||||||
@@ -251,7 +252,10 @@ export function TreeSplit({
|
|||||||
min: toolZone ? floor : Math.max(floor, computedPx(horizontal ? cs.minWidth : cs.minHeight, 0)),
|
min: toolZone ? floor : Math.max(floor, computedPx(horizontal ? cs.minWidth : cs.minHeight, 0)),
|
||||||
max: computedPx(horizontal ? cs.maxWidth : cs.maxHeight, Number.POSITIVE_INFINITY),
|
max: computedPx(horizontal ? cs.maxWidth : cs.maxHeight, Number.POSITIVE_INFINITY),
|
||||||
collapseId: toolZone ? (zone?.id ?? groupIdOf(child)) : null,
|
collapseId: toolZone ? (zone?.id ?? groupIdOf(child)) : null,
|
||||||
floor
|
floor,
|
||||||
|
// The flex item the release commit resizes: the seam partner itself
|
||||||
|
// for a direct group, the inner zone wrapper for a nested section.
|
||||||
|
zoneItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +280,9 @@ export function TreeSplit({
|
|||||||
element,
|
element,
|
||||||
index,
|
index,
|
||||||
initial: side.fixed ? side.size : sizeOf(element),
|
initial: side.fixed ? side.size : sizeOf(element),
|
||||||
|
// Seam-partner width at pointerdown. A nested section is wider than
|
||||||
|
// its edge zone, so the preview grows the wrapper from this width.
|
||||||
|
wrapperSize: sizeOf(element),
|
||||||
// A minimized rail is its 28px strip: it neither donates nor takes,
|
// A minimized rail is its 28px strip: it neither donates nor takes,
|
||||||
// and its remembered weight must survive the gesture so restoring
|
// and its remembered weight must survive the gesture so restoring
|
||||||
// it brings back the size it had before it was folded.
|
// it brings back the size it had before it was folded.
|
||||||
@@ -373,16 +380,17 @@ export function TreeSplit({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const styleSnapshots = sashTracks.map(track => track.element.getAttribute('style'))
|
// Nested sections also preview their inner zone wrapper, so snapshot it too.
|
||||||
|
const styleSnapshots = [...new Set(sashTracks.flatMap(track => [track.element, track.zoneItem]))].map(
|
||||||
|
el => [el, el.getAttribute('style')] as const
|
||||||
|
)
|
||||||
|
|
||||||
const restoreStyles = () => {
|
const restoreStyles = () => {
|
||||||
sashTracks.forEach((track, index) => {
|
styleSnapshots.forEach(([el, style]) => {
|
||||||
const style = styleSnapshots[index]
|
|
||||||
|
|
||||||
if (style === null) {
|
if (style === null) {
|
||||||
track.element.removeAttribute('style')
|
el.removeAttribute('style')
|
||||||
} else {
|
} else {
|
||||||
track.element.setAttribute('style', style)
|
el.setAttribute('style', style)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -414,7 +422,11 @@ export function TreeSplit({
|
|||||||
const px = plan.sizes[index]
|
const px = plan.sizes[index]
|
||||||
|
|
||||||
if (track.fixed) {
|
if (track.fixed) {
|
||||||
track.element.style.flexBasis = `${px}px`
|
// Fixed tracks plan in zone space. A nested section's wrapper moves
|
||||||
|
// by the zone's delta from its own width. For a direct group both
|
||||||
|
// are one element, and the second write leaves it at `px`.
|
||||||
|
track.element.style.flexBasis = `${track.wrapperSize + px - track.initial}px`
|
||||||
|
track.zoneItem.style.flexBasis = `${px}px`
|
||||||
} else {
|
} else {
|
||||||
track.element.style.flex = `0 1 ${px}px`
|
track.element.style.flex = `0 1 ${px}px`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user