mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(dockkit): record the strip offset when the strip unmounts
Reading scrollLeft in the layout cleanup also captures positions that fire no scroll event, such as a restore the browser clamps to the current offset. Refs #7049
This commit is contained in:
1 parent
7623307aaf
commit
0b8eabaf66
6 files changed
+40
-43
No files matched your search
@@ -21,8 +21,8 @@
|
||||
en: c4ba08038bfa6f30
|
||||
zh: 95db30618b52babe
|
||||
/deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
|
||||
en: 6ebe16d5610bb8f8
|
||||
zh: 8442e617bc1f52b2
|
||||
en: cf2d00ce2b7d5719
|
||||
zh: 5510420f8872e23a
|
||||
/deepseek-ai-dsh-client-ui-dockkit/build-shape:
|
||||
en: 21216ba5798d7f5f
|
||||
zh: ac505fdcd5c114d4
|
||||
|
||||
@@ -74,7 +74,7 @@ The split control accepts localized tooltip text, separate effective keys throug
|
||||
These are not stylistic; each one fixes a defect found in a real browser.
|
||||
|
||||
- **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works.
|
||||
- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. Where `DockLayout` draws a new strip for each newly selected tab, that strip first jumps to the offset the pane's previous strip recorded, so only the remaining distance animates; a strip with no recorded offset jumps straight to its first position instead of scrolling from the row's start. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen.
|
||||
- **The chips give way; the strip's end controls never do.** The chip box is the strip's one shrinking part (`flex: 0 1 auto; min-width: 0; overflow-x: auto`): chips shrink down to an 80px floor and then scroll on the wheel with no scrollbar drawn, and the box fades its chips out over 24px at each side that hides some (`data-dockkit-strip-scroll`, written from the box's scroll reading after each commit, scroll, and resize). Whenever the active tab or the row of chips changes, the box scrolls so the active chip stands clear of the fade band; a chip already in view moves nothing. Where `DockLayout` draws a new strip for each newly selected tab, that strip first jumps to the offset the pane's previous strip recorded, so only the remaining distance animates; a strip with no recorded offset jumps straight to the active chip instead of scrolling from the row's start. A chip's title is never ellipsized: `TabTitle` reads its text against its box and sets `data-dockkit-tab-clipped` while the text is wider, which fades the text out over its last 16px. A chip's close control shows while the chip is active, hovered, or holds focus, over the title's last 14px, which fade under it, so the chip is the same width either way. The two slots beside the active chip draw no hairline, so the filled capsule stands between bare chips. The add, split, and chrome controls are `flex: none`, so they keep their width and place in any pane at least as wide as they are (about 130px with the chrome, 72px without). The surface's `min-width: 0` and the pane's `overflow: hidden` stop a body's longest unwrapped line from widening the pane past its box, which is what carried the controls and the body's scrollbar off-screen.
|
||||
- **The chip box scrolls, but never claims a gesture.** A horizontal scroller would take press-and-move for itself and cancel the pointer; the box, the chips, and the strip set `touch-action: none` and the gesture captures the pointer, so a press-and-move on a chip is a drag and only the wheel scrolls the box.
|
||||
- **A split needs room for two working halves.** A pane splits into equal halves, so each half must hold what cannot shrink: the strip's fixed part — measured as the strip's width minus the chip box and the fill, which is the padding, the gaps, and every control that pane draws (its own chrome included, so the top-right pane asks more) — plus one chip at its minimum — `.tab` declares `min-width: 80px` on a content-box, so its footprint is 80px plus 10px + 10px of padding, 100px, read from a rendered chip's computed style (the stylesheet figure when none can be read); the divider between the halves takes its rendered thickness (0 — its hairline paints over the seam without taking layout room, so a body's own rules run unbroken past it). A column split, which only an edge drop makes, needs each half to hold the strip (34px) plus a 48px body: one 13px secondary line at 1.6 line-height inside 12px of the body's own insets — the pane body itself is unpadded, so a tab's body reaches the strip's bottom edge and the pane's edges and draws its own. `halvesFit` in `geometry.ts` is the arithmetic; `measure.ts` reads the rectangles after every commit and whenever the surface resizes, because the layout state carries fractions, never pixels, and the engine's planners stay that way. A pane without room keeps its split control, disabled with `splitPaneNarrow` (hidden instead under `hideSplitWhenBlocked`), and offers no edge drop zone for that axis (the release is then not a move). Under `hideSplitWhenBlocked` the split control's own footprint — its box plus the strip's gap — is left out of the fixed part: hiding the control sheds exactly that footprint from the strip, so a reading that counted it would flip with the control's visibility and re-render forever; leaving it out is also what the half being asked about would carry, since a half too narrow to split hides its own control. A pane the user narrows afterwards — a divider or the embedder's column dragged — keeps its size: the rule only decides its next split.
|
||||
- **Focus lands on click, not on press.** A state change between `pointerdown` and the first `pointermove` rebuilds the pressed subtree, and a replaced element cancels the pointer. It also keeps a drag from recording a redundant focus operation first. Clicks on the chips, the strip's controls, and the embedder's chrome stop at the strip: the intent each reports already decides the active pane, or is the embedder's own, so the pane's click-to-focus records nothing extra. A floating panel's grip and corner report through their gesture the same way — a press released in place is a click that raises the panel, and a drag records only the move or resize, whose operation raises it — while a press on the panel's body raises it directly. A click on the pane that is active already, a click or key on that pane's selected chip, or a press on the panel that is active and on top already, changes nothing and records nothing.
|
||||
|
||||
@@ -74,7 +74,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间
|
||||
这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
|
||||
|
||||
- **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。
|
||||
- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。`DockLayout` 为每个新选中的 tab 画出新 tab 条时,新 tab 条先跳到该格上一条 tab 条记录的偏移,只让剩余距离带动画;没有记录偏移的 tab 条直接跳到它的初始位置,而不是从排列起点滚动过去。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。
|
||||
- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。`DockLayout` 为每个新选中的 tab 画出新 tab 条时,新 tab 条先跳到该格上一条 tab 条记录的偏移,只让剩余距离带动画;没有记录偏移的 tab 条直接跳到活动 chip,而不是从排列起点滚动过去。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。
|
||||
- **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动的手势据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子。
|
||||
- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后拖窄的格——通过拖动分隔条或嵌入方的列——会保持原尺寸:规则只决定它的下一次分栏。
|
||||
- **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。
|
||||
|
||||
@@ -194,9 +194,9 @@ function jumpTo(element: HTMLElement, left: number): void {
|
||||
* A box newly mounted, or newly showing another pane, first jumps to that
|
||||
* pane's offset in `offsets`, so a strip that replaces another for the same
|
||||
* pane continues from where that one stood and glides only the remaining
|
||||
* distance. With no recorded offset the
|
||||
* first placement jumps rather than glides from the row's start. The box
|
||||
* records its offset in `offsets` on every scroll.
|
||||
* distance. With no recorded offset the first placement jumps rather than
|
||||
* glides from the row's start. The box records its offset in `offsets` when
|
||||
* it unmounts or leaves its pane.
|
||||
*/
|
||||
function useActiveChipInView(
|
||||
box: RefObject<HTMLDivElement | null>,
|
||||
@@ -213,9 +213,8 @@ function useActiveChipInView(
|
||||
const element = box.current
|
||||
/* v8 ignore next -- the box is rendered unconditionally with the strip. */
|
||||
if (element === null) return undefined
|
||||
const record = (): void => { offsets.set(paneId, element.scrollLeft) }
|
||||
element.addEventListener('scroll', record, { passive: true })
|
||||
return () => { element.removeEventListener('scroll', record) }
|
||||
// Layout cleanup runs before React detaches the box, so it still reads the live offset.
|
||||
return () => { offsets.set(paneId, element.scrollLeft) }
|
||||
}, [box, paneId, offsets])
|
||||
useLayoutEffect(() => {
|
||||
const element = box.current
|
||||
@@ -252,7 +251,9 @@ function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock):
|
||||
interface TabStripProps extends TabPanelProps {
|
||||
/**
|
||||
* Chip-box offsets by pane, shared by the strips that replace one another
|
||||
* as a pane's selected tab changes; omitted for a strip that stays mounted.
|
||||
* as a pane's selected tab changes. Keys are valid within one layout only,
|
||||
* and entries outlive their panes. Omitted where no strip continues from
|
||||
* another's position.
|
||||
*/
|
||||
readonly offsets?: Map<PaneId, number>
|
||||
}
|
||||
|
||||
@@ -263,7 +263,8 @@
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* The scroll to a newly active chip glides rather than jumps. */
|
||||
/* The scroll to a newly active chip glides; `jumpTo` in TabPanel.tsx
|
||||
overrides this for the placements that must not animate. */
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: none;
|
||||
touch-action: none;
|
||||
|
||||
@@ -271,19 +271,32 @@ it('keeps a superseding gesture marker when an older follower detaches', () => {
|
||||
expect(element.dataset.dockkitPointer).toBeUndefined()
|
||||
})
|
||||
|
||||
it('continues a selected tab\'s strip from its pane\'s last offset and jumps on a first placement', () => {
|
||||
let scrollLeft = 0
|
||||
// Each assignment with the inline `scroll-behavior` it ran under: `auto` jumps, empty leaves the stylesheet's glide.
|
||||
/**
|
||||
* Give each element its own `scrollLeft`, starting at 0, and log every
|
||||
* assignment with the inline `scroll-behavior` it ran under: `auto` jumps,
|
||||
* empty leaves the stylesheet's glide.
|
||||
*/
|
||||
function stubScrollLeft() {
|
||||
const offsets = new WeakMap<Element, number>()
|
||||
const writes: (readonly [number, string])[] = []
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft')
|
||||
Object.defineProperty(Element.prototype, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get() { return scrollLeft },
|
||||
get(this: Element) { return offsets.get(this) ?? 0 },
|
||||
set(this: HTMLElement, value: number) {
|
||||
scrollLeft = value
|
||||
offsets.set(this, value)
|
||||
writes.push([value, this.style.scrollBehavior])
|
||||
},
|
||||
})
|
||||
const restore = (): void => {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft')
|
||||
else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor)
|
||||
}
|
||||
return { offsets, writes, restore }
|
||||
}
|
||||
|
||||
it('continues a selected tab\'s strip from its pane\'s last offset and jumps on a first placement', () => {
|
||||
const { writes, restore } = stubScrollLeft()
|
||||
const rect = (left: number, width: number): DOMRect => ({
|
||||
left, right: left + width, top: 0, bottom: 28, x: left, y: 0, width, height: 28, toJSON: () => ({}),
|
||||
})
|
||||
@@ -301,39 +314,23 @@ it('continues a selected tab\'s strip from its pane\'s last offset and jumps on
|
||||
})
|
||||
try {
|
||||
const h = mounted(controller)
|
||||
const scrolled = (): void => { fireEvent.scroll(h.node('[data-dockkit-strip-tabs]')) }
|
||||
// The first strip has no recorded offset: it jumps to the chip.
|
||||
expect(writes).toEqual([[164, 'auto']])
|
||||
scrolled()
|
||||
// The seeded tab's strip starts where the previous one stood, where its chip is in view.
|
||||
scrollLeft = 0
|
||||
// The seeded tab's new strip starts where the previous one stood, where its chip is in view.
|
||||
fireEvent.click(h.node(`[data-dockkit-tab="${seeded}"]`))
|
||||
expect(writes.slice(1)).toEqual([[164, 'auto']])
|
||||
scrolled()
|
||||
// Back on the opened tab, now 30 past the left edge: restored, then glided the rest.
|
||||
openedAt = -30
|
||||
scrollLeft = 0
|
||||
fireEvent.click(h.node(`[data-dockkit-tab="${opened}"]`))
|
||||
expect(writes.slice(2)).toEqual([[164, 'auto'], [164 - 30 - 24, '']])
|
||||
} finally {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft')
|
||||
else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor)
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('places a selected tab\'s strip moved to another pane from that pane\'s last offset', () => {
|
||||
// Offsets per chip box, with the inline `scroll-behavior` of each assignment.
|
||||
const offsets = new WeakMap<Element, number>()
|
||||
const writes: (readonly [string | undefined, number, string])[] = []
|
||||
const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft')
|
||||
Object.defineProperty(Element.prototype, 'scrollLeft', {
|
||||
configurable: true,
|
||||
get(this: Element) { return offsets.get(this) ?? 0 },
|
||||
set(this: HTMLElement, value: number) {
|
||||
offsets.set(this, value)
|
||||
writes.push([this.dataset.dockkitStripTabs, value, this.style.scrollBehavior])
|
||||
},
|
||||
})
|
||||
// jsdom lays every chip and box out at 0x0, so only restores write offsets here.
|
||||
const { offsets, writes, restore } = stubScrollLeft()
|
||||
try {
|
||||
const controller = seededController()
|
||||
controller.setExpanded(true)
|
||||
@@ -346,15 +343,13 @@ it('places a selected tab\'s strip moved to another pane from that pane\'s last
|
||||
if (split.kind !== 'split') throw new Error('expected a split')
|
||||
const target = getPane(state, split.children[1]!).id
|
||||
act(() => { controller.focusTab(moved); h.redraw() })
|
||||
const strip = h.node(`[data-dockkit-strip-tabs="${target}"]`)
|
||||
offsets.set(strip, 70)
|
||||
fireEvent.scroll(strip)
|
||||
offsets.set(h.node(`[data-dockkit-strip-tabs="${target}"]`), 70)
|
||||
writes.length = 0
|
||||
act(() => { controller.placeTab(moved, target, 0); h.redraw() })
|
||||
expect(h.node(`[data-dockkit-strip-tabs="${target}"] [data-dockkit-tab="${moved}"]`).getAttribute('aria-selected')).toBe('true')
|
||||
expect(writes).toEqual([[target, 70, 'auto']])
|
||||
// The moved strip takes the target pane's 70; the source pane's next strip restores the 0 the moved one left there.
|
||||
expect([...writes].sort((x, y) => x[0] - y[0])).toEqual([[0, 'auto'], [70, 'auto']])
|
||||
} finally {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft')
|
||||
else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor)
|
||||
restore()
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user