Merge pull request #6830 from deepseek-harness/design/floating-window-snap-back

feat(ui-dockkit): spring floating panels back inside the viewport
This commit is contained in:
imccyu
2026-10-26 11:34:38 +08:00
committed by GitHub
21 changed files with 1451 additions and 40 deletions
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for 2026-10-21-floating-window-edge-recovery.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md
/agent-note-spring-based-edge-recovery-for-floating-dock-panels:
en: 73438e22c1bef8d3
zh: 5f817eb06259f8ef
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/problem:
en: c3b007f5cce80d8b
zh: c08b07f7e71eaa5f
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision:
en: f88f165ca80a57b8
zh: 98559f2239b03445
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/legal-region-and-displayed-position:
en: 6dea90dc406eacf5
zh: 6ecae0fdad78c855
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/drag-with-resistance:
en: 5214241925b52bc3
zh: bbd66dc72c03f960
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/release-and-spring:
en: ba2b5a3b4923d585
zh: 99544462a464ac50
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/reduced-motion:
en: 7e29bf1c45b5386d
zh: c913a2ce6d1722d1
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/change-scope:
en: 6459103379aefe41
zh: 501dee08a977b6a0
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/testing:
en: da3b2b6781aa99f3
zh: 37e49fa22f72fb84
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/alternatives-considered:
en: 3d901f0f5e270e29
zh: c1fb89b567ea3c70
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/consequences:
en: 360bd69c918f23fe
zh: 48642fd59ad3279b
@@ -0,0 +1,106 @@
# Agent Note: Spring-based edge recovery for floating dock panels
Status: implemented
English | [中文](2026-10-21-floating-window-edge-recovery.zh.md)
## Problem
The dock kit's floating panels are the client's floating windows. `FloatLayer` and the retained `DockLayout` renderer draw them in viewport coordinates through one shared gesture hook, `useFloatGestures`, and the right Sidebar embeds them, so the same panels run in the Web client and inside the Desktop window. Their drag followed the pointer one to one with only a top floor (`--dsh-dockkit-float-top`, which keeps the header below the Windows caption). A panel could therefore be dragged past the left, right or bottom edge until its header was out of reach; a viewport that shrank left a panel outside it; and the creation cascade could place a new panel outside a small viewport. The required behavior is that a panel pushed past an edge visibly resists, and on release is pulled back inside with spring physics instead of being stopped by a wall or left outside.
The reference implementation examined, Claude Desktop, has no portable edge-recovery rule: its Electron fallback corrects an off-screen window once, without animation, on the right and bottom edges only. The behavior had to be designed, not ported.
The change keeps these obligations: a panel header is always reachable; one gesture still records at most one history entry; reduced motion is honored; the persisted Sidebar layout format and every embedder stay unchanged; and the motion is deterministic enough to test without a real browser clock.
## Decision
The behavior lives entirely in `packages/client/ui-dockkit`, inside `useFloatGestures` and the pure engine helpers it calls. Both float renderers take their rectangles from that hook through `rectOf`, so Web and Desktop share the behavior, and no embedder, shell or persisted format changed. The rule extends the floating panels of [right Sidebar docking](2026-09-04-right-sidebar-docking-infrastructure.md) and the fixed-position floating frames of the [retained tab layout](../architecture/2026-09-20-sidebar-retained-tab-layout.md); it supersedes neither note.
### Legal region and displayed position
The legal region of a panel is the viewport (`window.innerWidth` by `window.innerHeight`) with the existing top floor: the Windows caption height plus 20 px when `data-windows-titlebar` is set (20 px in native fullscreen), otherwise 0. `floatBounds` reads only these values and the root's attributes, so starting a gesture forces no layout. `legalFloatRect` projects a rectangle into the region per axis; when a panel is wider or taller than the region it is drawn at the region's size on that axis, so its header controls and its resize corner stay on screen; the stored size is unchanged.
A panel draws at its stored rectangle projected into the viewport. The stored rectangle is never rewritten for the viewport: a shrinking window pushes the panel in without recording an operation, and a growing window returns it toward its stored position. The hook re-reads the bounds at every press and on every window `resize` event, and a gesture in progress uses the new bounds. Persisted layouts that hold off-screen rectangles display inside the viewport without migration.
At rest a panel publishes its rectangle to the `.float` rule as CSS variables through `floatStyle`, and the rule clamps its position and size into the viewport (`100vw` by `100dvh`, which follows `window.innerHeight` as the hook does) and below the caption floor once more; an oversized panel takes the viewport's size there as it does in `legalFloatRect`. A bounds reading the hook has not refreshed, such as one taken before a fullscreen change settles, therefore still leaves the panel inside. A gesture or a return writes its position and size inline, outside the clamp.
### Drag with resistance
A move computes the desired rectangle from the gesture's start rectangle and the pointer travel, then `resistedRect` splits it per axis into the legal projection and the overflow `o` beyond it and draws it at `legal + sign(o) * K * |o| / (K + |o|)` with `K = FLOAT_RESISTANCE = 120` px. The mapping applies on all four edges, including above the top floor, is continuous, has slope 1 at the edge, and approaches `K`:
| pointer overflow (px) | panel displacement past the edge (px) | share of the overflow |
|---|---|---|
| 10 | 9.2 | 92.3% |
| 40 | 30.0 | 75.0% |
| 120 | 60.0 | 50.0% |
| 400 | 92.3 | 23.1% |
A resize clamps the bottom-right corner to the region through `boundedResize` and keeps `FLOAT_MIN_SIZE`; resizing has no resistance, because a corner that lags the pointer reads as a broken handle.
### Release and spring
A release records the legal rectangle: `moveFloat` or `resizeFloat` when it differs from the legal rectangle at the press, otherwise only the raise a click records. A click on a panel drawn projected therefore keeps its stored rectangle. A move or resize therefore records only legal rectangles, and undo restores the pre-gesture position in one step. Panel creation (the drop rectangle of `floatTab` and the cascade) can still store a rectangle outside the viewport; the projection draws it inside. When the panel was drawn outside the legal region, the hook keeps a preview and springs it from where it was drawn to the recorded rectangle, then clears the preview. A release inside the region animates nothing.
The spring in `engine/spring.ts` is underdamped, because its bounce goes inward and is always legal: response 0.32 s and damping ratio 0.72. An overflowing axis starts with the velocity of the drawn panel over the last 100 ms of the drag, so the speed is continuous across the release; it is capped at `SPRING_MAX_VELOCITY` (1500 px/s), and `launchVelocity` caps its outward part at `omega0 * (K - d)` for a drawn overflow `d`. A fling past the edge travels further before it turns back, and because the spring carries an offset `d` with speed `v` at most `d + 0.46 * v / omega0` from its target, the whole return stays within `K` of the edge. An axis that did not overflow keeps still. Per axis the update is:
```
a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v
v = v + a * h
x = x + v * h
```
`stepSpring` integrates in equal substeps of at most `h = 1/480 s`, so the motion does not depend on the frame rate. One `requestAnimationFrame` loop in the hook clamps a frame to 64 ms, ends at the target when a frame gap exceeds 250 ms or 900 ms have passed, and otherwise stops when each axis is within 1 px of the target and slower than 60 px/s. From rest, a 200 px overflow settles in 367 ms with an inward overshoot within one percentage point of the analytic bound `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%`. The integrator is pure and takes `dt`, so the engine keeps its rule of reading no clock.
A press on the grip or the corner during a return cancels it and picks the panel up where it is drawn. A move does so through `unresistedRect`: it inverts the resistance, so zero pointer travel draws the same rectangle. A resize keeps the drawn origin and caps only the corner by the viewport; its release records the legal rectangle and springs the origin inside, as does a resize whose viewport shrank past its origin. Either way a release in place resumes the return without recording a second operation. One hook runs at most one return. A window resize ends a running return at its recorded rectangle, a press on another panel ends it at its target, a change of the panel's record that does not project to the target (an undo, a dock, a close) ends it at the new record on the next frame, and unmounting cancels the pending frame.
### Reduced motion
When `matchMedia('(prefers-reduced-motion: reduce)')` matches, a move clamps to the legal region with no resistance, and release records the legal rectangle with no animation. Environments without `matchMedia` report no preference. The rule only removes motion; it never removes the legality correction.
### Change scope
The change touches one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves its exports and `DockIntents` unchanged; the `TabRenderer` contract now states when the kit reuses a body.
| File | Change |
|---|---|
| `packages/client/ui-dockkit/src/engine/geometry.ts` | `FloatBounds`, `FLOAT_RESISTANCE`, `legalFloatRect`, `resistedRect`, `unresistedRect`, `boundedResize` |
| `packages/client/ui-dockkit/src/engine/spring.ts` | the pure spring integrator, its rest predicate `springSettled`, `launchVelocity` and its timing limits |
| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`: viewport projection, resistance, release spring, drawn-velocity sampling, reduced motion, frame loop and cancellation; `rectOf` and `floatStyle` for both renderers |
| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | draws the hook's displayed rectangle through `floatStyle` |
| `packages/client/ui-dockkit/src/components/TabBody.tsx` | the memoized body every layout (`FloatLayer`, `TabLayout`, `TabPanel`) draws |
| `packages/client/ui-dockkit/src/contract/adapter.ts` | when `TabRenderer` is called again |
| `packages/client/ui-dockkit/src/components/dockkit.module.css` | the resting clamp of position and size on `.float` |
| `packages/client/ui-dockkit/README.md` pair | the edge rule beside the float renderer description |
| `apps/web/tests/sidebar-right.e2e.ts` | a browser case for the resistance and the return |
`ui-sidebar-right` and its persisted layout schema, the `apps/web` source, `apps/desktop`, the shared primitives and the theme are untouched. No dependency was added.
## Testing
`tests/float-bounds.client.spec.ts` covers the projection (inside, each edge, corner, the top floor, an oversized panel), the resistance (the table values, slope 1 at the edge, monotone growth bounded by `K`, independent axes), its inverse and its cap, the bounded resize, and the legal size kept by the resistance and its inverse. `tests/spring.client.spec.ts` covers settling inside the deadline, the overshoot bound, frame-rate independence at 30, 60 and 120 Hz, the effect of release velocity, determinism, and the launch cap keeping every return within `K`. `tests/app-region-styles.client.spec.ts` pins the resting clamp in the stylesheet.
`tests/float-recovery.client.spec.tsx` renders `FloatLayer` over a live controller with hand-driven frames and clock: the resisted preview past an edge, exactly one legal `moveFloat` per release and the spring to it with an inward overshoot, undo in one step, fling against slow push, a fast fling staying within `K`, a drag above the top floor, no animation for a release inside, a push that ends at the stored position, a press mid-return picking the panel up without a jump, the stall snap, the deadline, a body not re-rendered through a drag and its return but re-rendered for a new renderer, a corner press during a return picking the panel up where it is drawn and a resize from there springing in, a resize whose viewport shrank past its origin recording a legal rectangle, with no animation under reduced motion, the 20 px floor on a Windows root without a published caption height, a return yielding to an undo, a close and a press on another panel, unmount cancellation, both reduced-motion answers, the viewport projection on shrink and growth without any intent, a click on a projected panel recording nothing, a window resize during a gesture bounding its release, a resize ending a return, and an off-screen stored rectangle drawn inside. `tests/tab-layout.client.spec.tsx` adds that a float drag re-renders no docked or floating body until the renderer changes, and `components.client.spec.tsx` the same for a `DockSurface` divider drag. The existing `FloatLayer` and `TabLayout` specs still pass with their position assertions read from the resting variables, including the rule that starting a gesture forces no layout read; every changed source file is fully covered. `apps/web/tests/sidebar-right.e2e.ts` drags a Sidebar float past the right and bottom edges in Chromium, checks that the drawn panel stays within 120 px beyond them, waits for the release to settle flush against both edges, then publishes a resting rectangle far outside and larger than the viewport and checks that the stylesheet alone draws it exactly over the viewport.
## Alternatives considered
**Hard clamp at the edge.** Stopping the panel at the viewport edge is the cheapest correct rule, but it feels like hitting a wall and gives no sense of the boundary before contact. It remains as the reduced-motion path.
**Clamp during the drag and rebound on release.** Clamping the drag at the edge and converting the outward pointer speed into an inward bounce keeps every frame inside the viewport. It hides how far the user pushed, so the bounce has no visible cause; the resistance shows the push and still bounds the overflow at 120 px.
**Eased tween on release.** A fixed 240-300 ms ease-out from the released position is predictable, but it carries no velocity, so a fling and a slow push return identically, and a press during it either jumps or restarts. The spring keeps velocity continuous and is interruptible.
**Store the off-screen position and correct it later.** Recording the released rectangle and fixing it on the next render would persist off-screen positions and make undo restore them. Recording the legal rectangle keeps one history entry per gesture with a legal value.
**Rewrite stored rectangles when the viewport shrinks.** Moving panels through operations on resize would add history entries the user never made and change persisted layouts on every window resize. Projecting at render achieves the visible result without either, at the cost of the elastic return described below.
**Native always-on-top OS window.** A real Electron floating window could float above other applications, but only on Desktop; the Web client cannot do it at all, and native frameless dragging gives no per-frame control, so the resistance would be impossible and the spring could only start after release. It is a separate product decision with its own window, IPC and platform work, and this decision does not preclude it.
**Animation library dependency.** A spring library would replace a pure integrator of a few dozen lines and its tests with a new dependency in a statically linked package. The integrator also has to be clock-free for the engine's deterministic tests.
**A shared primitive in `ui-primitives`.** Placing the hook in the primitives package would widen the change to a second package with no second consumer. It can move there when another floating surface needs it.
## Consequences
Floating panels can no longer be lost off screen, in either client, and a push past an edge reads as a pull back rather than a wall. History, persistence and every embedder are unaffected, and the motion is covered by deterministic tests instead of manual checks.
A drag and the spring update the preview through React state, so the layout re-renders on every frame, for up to 900 ms after release as well. Every layout draws tab bodies through `TabBody`, which is memoized on the renderer and the tab record, so such a frame re-renders only the panel frames and headers (the header still calls `renderTabTitle`), never the tab bodies; writing a transform on the panel element directly is the fallback if the remaining cost measures as a problem. A panel pushed in by a shrinking window returns toward its stored position when the window grows, which a user who expects it to stay where it was pushed may find surprising. Content inside a panel, such as a webview, can observe up to 900 ms of motion after release. The constants are tuned for a mouse or trackpad; touch input works through the same pointer events but remains untuned.
@@ -0,0 +1,106 @@
# Agent Note: 悬浮停靠面板的弹簧式边界收回
Status: implemented
[English](2026-10-21-floating-window-edge-recovery.md) | 中文
## 问题
dock kit 的悬浮面板就是客户端里的悬浮窗。`FloatLayer` 与保留式 `DockLayout` 渲染器通过同一个手势 hook `useFloatGestures` 以视口坐标绘制它们,右侧 Sidebar 嵌入了它们,因此同一套面板既运行在 Web 客户端中,也运行在 Desktop 窗口内。它们的拖拽过去一比一跟随指针,只有一个顶部下限(`--dsh-dockkit-float-top`,让标题栏保持在 Windows 标题栏之下)。因此面板可以被拖过左、右或下边界,直到标题栏够不着;视口缩小会把面板留在外面;新建面板的级联位置也可能落在小视口之外。期望行为是:面板被推过边界时可见地受到阻力,松手后以弹簧物理被拉回视口内,而不是像撞墙一样被挡住或被留在外面。
所考察的参考实现 Claude Desktop 没有可移植的边界收回规则:它的 Electron 回退只对屏幕外窗口做一次无动画修正,且只修正右边界和下边界。该行为必须被设计,而不是移植。
变更维持以下义务:面板标题栏始终可达;一个手势仍然最多记录一条历史;遵循减弱动态效果;Sidebar 的持久化布局格式与所有嵌入方保持不变;运动足够确定,无需真实浏览器时钟即可测试。
## 决定
该行为完全位于 `packages/client/ui-dockkit` 内,在 `useFloatGestures` 及其调用的纯 engine 辅助函数中实现。两个悬浮渲染器都通过 `rectOf` 从该 hook 取得矩形,因此 Web 与 Desktop 共享该行为,任何嵌入方、壳或持久化格式都没有改变。该规则扩展了 [右侧 Sidebar 停靠](2026-09-04-right-sidebar-docking-infrastructure.zh.md) 的悬浮面板与 [保留式标签布局](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md) 的固定定位悬浮框,不取代其中任何一篇。
### 合法区域与显示位置
面板的合法区域是视口(`window.innerWidth` × `window.innerHeight`)加上既有的顶部下限:设置了 `data-windows-titlebar` 时为 Windows 标题栏高度加 20 px(原生全屏时为 20 px),否则为 0。`floatBounds` 只读取这些值和根元素属性,因此开始手势不会触发布局。`legalFloatRect` 逐轴把矩形投影进该区域;面板比区域更宽或更高时,在该轴上按区域尺寸绘制,使标题栏控件和缩放角留在屏幕上;存储的尺寸不变。
面板绘制在其存储矩形投影到视口后的位置。存储矩形从不因视口而被改写:窗口缩小时面板被推入且不记录操作,窗口变大时面板朝存储位置回移。hook 在每次按下和每次窗口 `resize` 事件时重新读取区域,进行中的手势使用新区域。包含屏幕外矩形的持久化布局无需迁移即在视口内显示。
静止时面板通过 `floatStyle` 把矩形以 CSS 变量发布给 `.float` 规则,该规则再次把它的位置和尺寸钳制在视口(`100vw` × `100dvh`;`dvh` 与 hook 读取的 `window.innerHeight` 一致)内并保持在标题栏下限之下;超尺寸面板在这里同样按视口尺寸绘制,与 `legalFloatRect` 一致。因此即使 hook 读到的区域尚未刷新(例如全屏切换完成前读取的值),面板仍在视口内。手势或回弹把位置和尺寸写在行内样式中,不受该钳制。
### 带阻力的拖拽
移动时由手势起始矩形和指针位移计算期望矩形,然后 `resistedRect` 逐轴把它拆分为合法投影与超出它的溢出量 `o`,并绘制在 `legal + sign(o) * K * |o| / (K + |o|)`,其中 `K = FLOAT_RESISTANCE = 120` px。该映射作用于全部四条边,包括顶部下限之上,连续、在边界处斜率为 1、渐近于 `K`:
| 指针溢出(px) | 面板越过边界的位移(px) | 占溢出的比例 |
|---|---|---|
| 10 | 9.2 | 92.3% |
| 40 | 30.0 | 75.0% |
| 120 | 60.0 | 50.0% |
| 400 | 92.3 | 23.1% |
缩放通过 `boundedResize` 把右下角钳制在区域内并保持 `FLOAT_MIN_SIZE`;缩放不加阻力,因为落后于指针的角看起来像坏掉的手柄。
### 松手与弹簧
松手时记录合法矩形:与按下时的合法矩形不同时记录 `moveFloat` 或 `resizeFloat`,否则只记录点击会记录的提升。因此单击以投影绘制的面板会保留其存储矩形。因此移动或缩放只记录合法矩形,撤销一步即可恢复手势前的位置。创建面板(`floatTab` 的落点矩形与级联)仍可能存储视口外的矩形,由投影把它绘制在视口内。若面板绘制在合法区域之外,hook 保留预览,用弹簧把它从绘制位置带到记录的矩形,然后清除预览。在区域内松手不播放动画。
`engine/spring.ts` 中的弹簧是欠阻尼的,因为它的回弹朝向内侧,始终合法:响应时间 0.32 s、阻尼比 0.72。溢出的轴以拖拽最后 100 ms 内绘制面板的速度起步,因此速度在松手前后连续;该速度上限为 `SPRING_MAX_VELOCITY`(1500 px/s),且对绘制溢出量 `d`,`launchVelocity` 把其向外分量限制在 `omega0 * (K - d)` 以内。甩过边界的面板会先走得更远再折返;由于弹簧从偏移 `d`、速度 `v` 出发时距目标至多 `d + 0.46 * v / omega0`,整条回弹轨迹都不超出边界 `K`。没有溢出的轴保持不动。每轴更新为:
```
a = -omega0^2 * (x - target) - 2 * zeta * omega0 * v
v = v + a * h
x = x + v * h
```
`stepSpring` 以等长且不超过 `h = 1/480 s` 的子步积分,因此运动与帧率无关。hook 中唯一的 `requestAnimationFrame` 循环把单帧钳制到 64 ms,帧间隔超过 250 ms 或已过 900 ms 时直接停在目标,否则在每轴距目标 1 px 内且慢于 60 px/s 时停止。从静止开始,200 px 溢出在 367 ms 内稳定,向内过冲与解析上限 `exp(-pi * zeta / sqrt(1 - zeta^2)) = 3.8%` 相差不超过一个百分点。积分器是纯函数并接收 `dt`,因此 engine 保持不读取时钟的规则。
回弹期间按下抓手或右下角会取消回弹,并从绘制位置接住面板。移动通过 `unresistedRect` 做到这一点:它反演阻力,因此零指针位移绘制同一矩形。缩放保留绘制时的原点,只用视口约束右下角;松手时记录合法矩形并用弹簧把原点收回视口,手势中视口缩到原点以内的缩放也是如此。两种情况下原地松手都会恢复回弹且不记录第二次操作。一个 hook 最多运行一个回弹。窗口缩放会让进行中的回弹停在记录的矩形;按下另一个面板会让它停在目标;面板记录的变化若不再投影到目标(撤销、停靠、关闭),会在下一帧让它停在新记录;卸载会取消待处理的帧。
### 减弱动态效果
`matchMedia('(prefers-reduced-motion: reduce)')` 匹配时,移动钳制在合法区域内且无阻力,松手时记录合法矩形且不播放动画。没有 `matchMedia` 的环境视为无偏好。该规则只移除运动,从不移除合法性修正。
### 改动范围
变更只涉及一个包 `@deepseek-ai/dsh-client-ui-dockkit`,其导出与 `DockIntents` 保持不变;`TabRenderer` 的约定写明了套件何时复用标签页内容。
| 文件 | 变更 |
|---|---|
| `packages/client/ui-dockkit/src/engine/geometry.ts` | `FloatBounds`、`FLOAT_RESISTANCE`、`legalFloatRect`、`resistedRect`、`unresistedRect`、`boundedResize` |
| `packages/client/ui-dockkit/src/engine/spring.ts` | 纯弹簧积分器、静止判定函数 `springSettled`、`launchVelocity` 与时间上限 |
| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`:视口投影、阻力、松手弹簧、绘制速度采样、减弱动态效果、帧循环与取消;供两个渲染器使用的 `rectOf` 与 `floatStyle` |
| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | 通过 `floatStyle` 绘制 hook 给出的显示矩形 |
| `packages/client/ui-dockkit/src/components/TabBody.tsx` | 各布局(`FloatLayer`、`TabLayout`、`TabPanel`)共用的 memo 化标签页内容 |
| `packages/client/ui-dockkit/src/contract/adapter.ts` | `TabRenderer` 何时被再次调用 |
| `packages/client/ui-dockkit/src/components/dockkit.module.css` | `.float` 上位置与尺寸的静止钳制 |
| `packages/client/ui-dockkit/README.md` 对 | 在悬浮渲染器说明旁补充边界规则 |
| `apps/web/tests/sidebar-right.e2e.ts` | 覆盖阻力与回弹的浏览器用例 |
`ui-sidebar-right` 及其持久化布局 schema、`apps/web` 源码、`apps/desktop`、共享基础组件与主题均未改动。没有新增依赖。
## 测试
`tests/float-bounds.client.spec.ts` 覆盖投影(内部、每条边、角、顶部下限、超尺寸面板)、阻力(表中数值、边界处斜率为 1、以 `K` 为上界的单调增长、各轴独立)、其反演与上限,受限缩放,以及阻力与其反演都保持合法尺寸。`tests/spring.client.spec.ts` 覆盖在截止时间内稳定、过冲上限、30/60/120 Hz 下与帧率无关、松手速度的作用、确定性,以及启动速度上限让每条回弹都不超出 `K`。`tests/app-region-styles.client.spec.ts` 固定样式表中的静止钳制。
`tests/float-recovery.client.spec.tsx` 在活的控制器上渲染 `FloatLayer`,帧与时钟由测试手动推进:越过边界时带阻力的预览、每次松手恰好一次合法的 `moveFloat` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、快速甩出不超出 `K`、拖过顶部下限、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、拖拽与回弹期间不重渲染标签页内容而换新渲染函数后重渲染、回弹中按下右下角从绘制位置接住面板、从那里缩放后弹回、手势中视口缩到缩放原点以内时记录合法矩形且减弱动态效果下不播放动画、未发布标题栏高度的 Windows 根元素仍保持 20 px 下限、回弹让位于撤销、关闭与按下另一个面板、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、单击投影面板不记录任何操作、手势中窗口缩放约束其松手结果、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。`tests/tab-layout.client.spec.tsx` 新增:悬浮面板拖拽期间停靠与悬浮的标签页内容都不重渲染,直到渲染函数改变;`components.client.spec.tsx` 对 `DockSurface` 拖动分隔条做同样的检查。既有的 `FloatLayer` 与 `TabLayout` 测试在位置断言改为读取静止变量后通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。`apps/web/tests/sidebar-right.e2e.ts` 在 Chromium 中把 Sidebar 悬浮面板拖过右边界和下边界,检查绘制的面板越过两边均不超过 120 px,等待松手后面板贴合两条边静止,再发布一个远在视口外且大于视口的静止矩形,检查仅靠样式表就把它恰好绘制成覆盖整个视口。
## 考虑过的替代方案
**在边界处硬钳制。** 在视口边界停住面板是成本最低的正确规则,但手感像撞墙,接触之前也感觉不到边界。它保留为减弱动态效果的路径。
**拖拽中钳制、松手时回弹。** 在边界处钳制拖拽,并把向外的指针速度转换为向内的回弹,能让每一帧都留在视口内。但它隐藏了用户推了多远,回弹因此没有可见的起因;阻力显示了推动,同时把溢出限制在 120 px 内。
**松手后的缓动补间。** 从松手位置开始的固定 240–300 ms 缓出补间可预测,但不携带速度,因此甩出与慢推的返回完全相同,补间中按下要么跳变要么重启。弹簧保持速度连续且可打断。
**存储屏幕外位置并在之后修正。** 记录松手矩形、在下一次渲染时修正,会持久化屏幕外位置,并让撤销恢复它们。记录合法矩形则保持每个手势一条历史且值合法。
**视口缩小时改写存储矩形。** 在缩放时通过操作移动面板会增加用户从未做过的历史条目,并在每次窗口缩放时改变持久化布局。渲染时投影无需二者即可得到可见结果,代价是下文描述的弹性返回。
**原生始终置顶 OS 窗口。** 真正的 Electron 悬浮窗可以浮在其他应用之上,但只在 Desktop 上可行;Web 客户端完全无法做到,而且原生无边框拖拽不提供逐帧控制,因此阻力无法实现,弹簧只能在松手后启动。它是一个独立的产品决策,有自己的窗口、IPC 与平台工作,本决定不排斥它。
**动画库依赖。** 弹簧库会用一个静态链接包里的新依赖替换几十行的纯积分器及其测试。积分器还必须不依赖时钟,才能用于 engine 的确定性测试。
**放进 `ui-primitives` 的共享基础组件。** 把 hook 放进基础组件包会把改动扩大到第二个包,而没有第二个使用方。等另一个悬浮界面需要它时再迁移。
## 后果
悬浮面板在两个客户端中都不会再丢失到屏幕外,推过边界读起来是被拉回而不是撞墙。历史、持久化与所有嵌入方不受影响,运动由确定性测试而非手动检查覆盖。
拖拽与弹簧都通过 React state 更新预览,因此布局每帧都会重渲染,松手后最多还会持续 900 ms。各布局都通过 `TabBody` 绘制标签页内容,它以渲染函数和标签记录为键做了 memo,因此这样的一帧只重渲染面板框与标题栏(标题栏仍会调用 `renderTabTitle`),从不重渲染标签页内容;如果剩余成本经测量构成问题,退路是直接在面板元素上写 transform。被缩小窗口推入的面板会在窗口变大时朝存储位置回移,期望它留在被推到位置的用户可能会感到意外。面板内的内容(例如 webview)在松手后可能观察到最多 900 ms 的运动。常量针对鼠标或触控板调优;触控输入经由同样的 pointer 事件可用,但仍未调优。
+48
View File
@@ -1046,6 +1046,54 @@ describe('web e2e: shipped right Sidebar', () => {
expect(tripwire.warnings).toEqual([])
}, 90_000)
it('resists a floating panel dragged past the viewport edges and springs it back inside', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-float-edge'))
const column = await resetSidebar(page)
const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]')
await proseChip(page).click()
await expect.poll(async () => await tabTitles(paneAt(column, 0))).toEqual(['Files', SAMPLE_NAME])
await floatByDrag(page, paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }))
await expect.poll(async () => await floats.count()).toBe(1)
const panel = floats.first()
const viewport = page.viewportSize()
if (viewport === null) throw new Error('page has no viewport')
// Carrying the grip to the bottom-right corner asks for the panel well past both edges.
const grip = await centre(panel.locator('[data-dockkit-float-grip]'))
await page.mouse.move(grip.x, grip.y)
await page.mouse.down()
await page.mouse.move(viewport.width - 2, viewport.height - 2, { steps: 8 })
const pushed = await panel.boundingBox()
if (pushed === null) throw new Error('float is not rendered')
expect(pushed.x + pushed.width).toBeGreaterThan(viewport.width)
expect(pushed.x + pushed.width).toBeLessThan(viewport.width + 120)
expect(pushed.y + pushed.height).toBeGreaterThan(viewport.height)
expect(pushed.y + pushed.height).toBeLessThan(viewport.height + 120)
await page.mouse.up()
// The return overshoots inward and can pass the edges before it ends, so
// wait for the resting style, which drops the inline position.
await expect.poll(async () => await panel.evaluate(node => (node as HTMLElement).style.left)).toBe('')
const rest = await panel.boundingBox()
if (rest === null) throw new Error('float is not rendered')
expect({ right: Math.round(rest.x + rest.width), bottom: Math.round(rest.y + rest.height) })
.toEqual({ right: viewport.width, bottom: viewport.height })
// At rest the stylesheet clamps on its own: a rectangle published from a
// stale viewport reading, far outside and larger than the viewport, still
// draws inside it.
const clamped = await panel.evaluate((node) => {
const element = node as HTMLElement
for (const name of ['x', 'y', 'width', 'height']) element.style.setProperty(`--dsh-dockkit-float-${name}`, '5000px')
const box = element.getBoundingClientRect()
return { left: box.left, top: box.top, right: box.right, bottom: box.bottom }
})
expect(clamped).toEqual({ left: 0, top: 0, right: viewport.width, bottom: viewport.height })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
const column = await resetSidebar(page)
+2 -2
View File
@@ -18,8 +18,8 @@
en: 718f52cfce426009
zh: e8282e2e3713d242
/deepseek-ai-dsh-client-ui-dockkit/embedding-it:
en: c4ba08038bfa6f30
zh: 95db30618b52babe
en: 87fab28e0608044e
zh: 2d5f80a367f1852b
/deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
en: cf2d00ce2b7d5719
zh: 5510420f8872e23a
+3 -1
View File
@@ -64,7 +64,9 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp
`DockLayout`, used by the Sidebar, keeps each tab in a stable DOM cell across selection, pane moves and floating. It accepts one docked pane or two horizontal panes; CSS Grid resolves their widths. `keepMounted(tab)` retains visited bodies and `active` controls Session visibility without reparenting content.
`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. Both float renderers keep the header at least 20px below the Windows caption (20px below the viewport top in native fullscreen), including newly opened and restored panels. Dragging and resizing start at the displayed position.
`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. At rest both float renderers keep the header at least 20px below the Windows caption (20px below the viewport top in native fullscreen), including newly opened and restored panels. Dragging and resizing start at the displayed position.
Floating panels stay inside the viewport. Each draws at its stored rectangle projected into the viewport, so a shrinking window pushes it in without recording an operation and a growing window returns it toward the stored position; a panel larger than the viewport draws at the viewport's size. A move follows the pointer one to one inside the viewport and resists past any edge, the top included, drawing at most 120px beyond it; its release records one `moveFloat` with the nearest legal rectangle, or only a raise when that is the legal rectangle of the press, then springs the panel there from where it was drawn, carrying the drawn velocity, staying within those 120px, and overshooting slightly inward. A press on the header or the resize corner during that return picks the panel up where it is drawn; a resize from there keeps its origin and springs it inside on release. A resize keeps the corner inside the viewport. At rest the stylesheet clamps every panel's position and size into the viewport once more, so a stale viewport reading still leaves it inside. Under `prefers-reduced-motion: reduce` a move clamps at the edge and nothing animates.
<a id="interaction-rules-worth-keeping"></a>
## Interaction rules worth keeping
+3 -1
View File
@@ -64,7 +64,9 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt
Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间,把每个 tab 保留在稳定的 DOM 容器中。它接受一个停靠格或左右两个格,由 CSS Grid 计算宽度。`keepMounted(tab)` 保留已访问正文,`active` 控制 Session 可见性,不改变内容的父子关系。
`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 两种浮窗渲染器都让标题栏与 Windows 顶栏至少间隔 20px(原生全屏时距视口顶部 20px),新建和恢复的浮窗均适用。拖动和调整大小从实际显示位置开始。
`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 静止时两种浮窗渲染器都让标题栏与 Windows 顶栏至少间隔 20px(原生全屏时距视口顶部 20px),新建和恢复的浮窗均适用。拖动和调整大小从实际显示位置开始。
浮窗始终留在视口内。每个浮窗绘制在其存储矩形投影到视口后的位置,因此窗口缩小时浮窗被推入且不记录操作,窗口变大时浮窗朝存储位置回移;大于视口的浮窗按视口尺寸绘制。移动时浮窗在视口内一比一跟随指针,越过任一边界(包括顶部)后受到阻力,最多越过边界 120px;松手时记录一次带最近合法矩形的 `moveFloat`,若该矩形就是按下时的合法矩形则只提升浮窗,再让浮窗从绘制位置以弹簧回到该矩形,携带绘制速度、不超出这 120px,并略微向内过冲。回弹过程中按下标题栏或缩放角都会从绘制位置接住浮窗;从那里缩放时保留原点,松手后再以弹簧收回视口。调整大小时右下角保持在视口内。静止时样式表会再次把每个浮窗的位置和尺寸钳制在视口内,因此即使视口读数过期,浮窗仍在视口内。在 `prefers-reduced-motion: reduce` 下,移动在边界处钳制且不播放动画。
<a id="interaction-rules-worth-keeping"></a>
## 值得保留的交互规则
@@ -13,16 +13,23 @@
* only `state` and the outward contracts. An embedder may portal it anywhere,
* and nothing here assumes the docked tree is an ancestor or even present.
*/
import { useState } from 'react'
import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts'
import type { FloatRect, LayoutState, PaneId, TabId, TabRecord } from '../contract/types.ts'
import { FLOAT_MIN_SIZE } from '../engine/constraints.ts'
import { movedRect, resizedRect } from '../engine/geometry.ts'
import {
boundedResize, FLOAT_RESISTANCE, legalFloatRect, movedRect, resistedRect, resizedRect, unresistedRect, type FloatBounds,
} from '../engine/geometry.ts'
import {
launchVelocity, SPRING_DEADLINE_MS, SPRING_MAX_FRAME_MS, SPRING_MAX_VELOCITY, SPRING_STALL_MS, springSettled, stepSpring,
type SpringAxis,
} from '../engine/spring.ts'
import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts'
import { useGesture } from './pointer.ts'
import { TabBody } from './TabBody.tsx'
import { TabTitle } from './TabTitle.tsx'
import css from './dockkit.module.css'
@@ -46,7 +53,7 @@ interface FloatDrag {
readonly rect: FloatRect
}
/** The rectangle a gesture has reached. */
/** The rectangle the pointer travel asks for, before the viewport has its say. */
function draggedRect(drag: FloatDrag, x: number, y: number): FloatRect {
const dx = x - drag.originX
const dy = y - drag.originY
@@ -65,47 +72,238 @@ function raised(state: LayoutState, paneId: PaneId): boolean {
return state.activePaneId === paneId && state.floats.at(-1) === paneId
}
/**
* The viewport region panels stay inside. It reads the window size and the
* root's caption attributes only, so starting a gesture never forces layout.
*/
function floatBounds(): FloatBounds {
const root = document.documentElement
const windows = root.hasAttribute('data-windows-titlebar')
// Desktop publishes the caption height in pixels on the root's inline style;
// a root without it keeps the 20px floor.
const caption = windows && !root.hasAttribute('data-fullscreen')
? Number.parseFloat(root.style.getPropertyValue('--dsh-windows-titlebar-height')) : 0
return { top: windows ? (Number.isFinite(caption) ? caption : 0) + 20 : 0, width: window.innerWidth, height: window.innerHeight }
}
/** Whether the platform asks for reduced motion; environments without `matchMedia` (jsdom) report no preference. */
function reducedMotion(): boolean {
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
/** How much recent drawing history the release velocity is measured over. */
const VELOCITY_WINDOW_MS = 100
/** One drawn panel position and when it was drawn. */
interface DrawnSample {
readonly t: number
readonly x: number
readonly y: number
}
/** Append a drawn position and drop the samples more than `VELOCITY_WINDOW_MS` older than it. */
function sampleDrawn(samples: DrawnSample[], t: number, rect: FloatRect): void {
samples.push({ t, x: rect.x, y: rect.y })
while (t - (samples[0] as DrawnSample).t > VELOCITY_WINDOW_MS) samples.shift()
}
/**
* Drawn panel velocity over the kept samples, capped per axis, in pixels per
* second. It follows the drawn panel rather than the pointer, so the speed stays
* continuous across a release where the resistance slows the panel.
*/
function releaseVelocity(samples: readonly DrawnSample[]): { readonly x: number; readonly y: number } {
const first = samples[0] as DrawnSample
const last = samples[samples.length - 1] as DrawnSample
if (last.t === first.t) return { x: 0, y: 0 }
const rate = (travel: number): number =>
Math.max(-SPRING_MAX_VELOCITY, Math.min(SPRING_MAX_VELOCITY, travel / (last.t - first.t) * 1000))
return { x: rate(last.x - first.x), y: rate(last.y - first.y) }
}
/** A panel springing back inside the viewport after a release past an edge. */
interface Settling {
readonly paneId: PaneId
readonly target: FloatRect
readonly started: number
x: SpringAxis
y: SpringAxis
last: number
frame: number
}
/**
* Inline position of a floating panel. A gesture or return draws it exactly at
* `rect`. At rest the panel only publishes `rect` to the `.float` rule, which
* clamps it into the viewport and below the caption floor, so a stale viewport
* reading in `useFloatGestures` still leaves the panel inside.
* @param rect - the rectangle to draw.
* @param lifted - whether a gesture or return is drawing the panel.
* @returns the panel's inline position and size.
*/
export function floatStyle(rect: FloatRect, lifted: boolean): CSSProperties {
if (lifted) return { left: rect.x, top: rect.y, width: rect.width, height: rect.height }
return {
'--dsh-dockkit-float-x': `${rect.x}px`,
'--dsh-dockkit-float-y': `${rect.y}px`,
'--dsh-dockkit-float-width': `${rect.width}px`,
'--dsh-dockkit-float-height': `${rect.height}px`,
} as CSSProperties
}
/**
* Shared move/resize gestures; changing layout mode never owns the content DOM.
* Panels draw at their stored rectangle projected into the viewport, so a
* shrinking window pushes them in without recording anything. A move past an
* edge draws with resistance; its release records the legal rectangle and the
* panel springs there from where it was drawn.
* @param state - committed layout and float stacking order.
* @param intents - gesture settlements.
* @returns the current preview and focus/gesture callbacks.
* @returns the current preview, the rectangle to draw per panel, and focus/gesture callbacks.
*/
export function useFloatGestures(state: LayoutState, intents: DockIntents) {
const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined)
const [bounds, setBounds] = useState(floatBounds)
// The bounds a gesture reads: re-read at each press and on each window resize.
const area = useRef(bounds)
// The committed layout, for a return to notice its panel's record changing.
const latest = useRef(state)
latest.current = state
const settling = useRef<Settling | undefined>(undefined)
const begin = useGesture(() => { setPreview(undefined) })
/** Stop a running return where it is; reports whether one was running. */
const cancelSettling = useRef((): boolean => {
const running = settling.current
if (running === undefined) return false
cancelAnimationFrame(running.frame)
settling.current = undefined
return true
}).current
useEffect(() => {
const resize = (): void => {
if (cancelSettling()) setPreview(undefined)
area.current = floatBounds()
setBounds(area.current)
}
window.addEventListener('resize', resize)
return () => {
window.removeEventListener('resize', resize)
cancelSettling()
}
}, [cancelSettling])
/**
* One animation frame of a return: advance the spring, or end it at its
* target. It also ends once the panel's record no longer projects to the
* target, as after an undo, a dock or a close, so the release's `moveFloat`
* or `resizeFloat` must reach `state` before the first frame.
*/
const frame = (): void => {
const running = settling.current as Settling
const now = performance.now()
const gap = now - running.last
running.last = now
const layout = latest.current
const recorded = layout.floats.includes(running.paneId)
&& sameRect(legalFloatRect(floatRect(getPane(layout, running.paneId)), area.current), running.target)
if (recorded && gap <= SPRING_STALL_MS && now - running.started < SPRING_DEADLINE_MS) {
const dt = Math.min(gap, SPRING_MAX_FRAME_MS) / 1000
running.x = stepSpring(running.x, running.target.x, dt)
running.y = stepSpring(running.y, running.target.y, dt)
if (!springSettled(running.x, running.target.x) || !springSettled(running.y, running.target.y)) {
setPreview({ paneId: running.paneId, rect: { ...running.target, x: running.x.position, y: running.y.position } })
running.frame = requestAnimationFrame(frame)
return
}
}
settling.current = undefined
setPreview(undefined)
}
/** Spring a panel from where it was drawn to its recorded rectangle, never farther than `FLOAT_RESISTANCE` outside it. */
const settle = (paneId: PaneId, from: FloatRect, target: FloatRect, velocity: { x: number; y: number }): void => {
const axis = (position: number, goal: number, speed: number): SpringAxis =>
({ position, velocity: launchVelocity(position, goal, speed, FLOAT_RESISTANCE) })
const now = performance.now()
settling.current = {
paneId, target, started: now, last: now,
x: axis(from.x, target.x, velocity.x), y: axis(from.y, target.y, velocity.y),
frame: requestAnimationFrame(frame),
}
setPreview({ paneId, rect: from })
}
/** Focus and raise a panel from a press or click on it, unless it is raised already. */
const raise = (paneId: PaneId): void => {
if (raised(state, paneId)) return
intents.focusPane(paneId)
}
/** Start a move or resize from a press on the panel's grip or corner; a release that moved nothing is a click. */
/**
* Where a panel draws: its gesture or return preview, otherwise its stored
* rectangle projected into the viewport.
* @param paneId - the floating pane.
* @param stored - its recorded rectangle.
* @returns the rectangle to draw.
*/
const rectOf = (paneId: PaneId, stored: FloatRect): FloatRect =>
preview?.paneId === paneId ? preview.rect : legalFloatRect(stored, bounds)
/**
* Start a move or resize from a press on the panel's grip or corner. A release
* that reaches the same legal rectangle as the press is a click and only raises,
* also for a panel drawn projected from its record.
*/
const drag = (mode: 'move' | 'resize', paneId: PaneId, event: ReactPointerEvent<HTMLElement>): void => {
// The press stops here: the panel's own press-to-focus would record a focus
// entry before the drag's, and the release below decides which one it is.
event.stopPropagation()
const root = document.documentElement
const windows = root.hasAttribute('data-windows-titlebar')
// Desktop publishes the caption height in pixels on the root's inline style.
const caption = windows && !root.hasAttribute('data-fullscreen')
? Number.parseFloat(root.style.getPropertyValue('--dsh-windows-titlebar-height')) : 0
const saved = floatRect(getPane(state, paneId))
const rect = { ...saved, y: Math.max(saved.y, windows ? caption + 20 : 0) }
// A press during its own return takes the panel from where it is drawn;
// another panel's return ends at its target.
const drawn = settling.current?.paneId === paneId ? preview?.rect : undefined
if (cancelSettling() && drawn === undefined) setPreview(undefined)
area.current = floatBounds()
const reduce = reducedMotion()
const shown = drawn ?? legalFloatRect(floatRect(getPane(state, paneId)), area.current)
const rect = mode === 'move' && !reduce ? unresistedRect(shown, area.current) : shown
const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect }
/** The rectangle to draw for a pointer position, and the legal one a release there records. */
const reached = (x: number, y: number): { readonly drawn: FloatRect; readonly legal: FloatRect } => {
const desired = draggedRect(start, x, y)
if (mode === 'resize') {
// A resize keeps its origin, which lies outside the viewport when the
// press caught a return or the viewport shrank mid-gesture; the
// release records the legal rectangle and springs the panel there.
const resized = boundedResize(desired, area.current, FLOAT_MIN_SIZE)
const legal = legalFloatRect(resized, area.current)
return { drawn: reduce ? legal : resized, legal }
}
const legal = legalFloatRect(desired, area.current)
return { drawn: reduce ? legal : resistedRect(desired, area.current), legal }
}
const pressed = reached(event.clientX, event.clientY)
const samples: DrawnSample[] = []
sampleDrawn(samples, performance.now(), pressed.drawn)
begin(event.currentTarget, event.pointerId, {
move: (moved) => { setPreview({ paneId, rect: draggedRect(start, moved.clientX, moved.clientY) }) },
move: (moved) => {
const { drawn } = reached(moved.clientX, moved.clientY)
sampleDrawn(samples, performance.now(), drawn)
setPreview({ paneId, rect: drawn })
},
up: (released) => {
const rect = draggedRect(start, released.clientX, released.clientY)
if (sameRect(rect, start.rect)) raise(paneId)
else if (mode === 'move') intents.moveFloat(paneId, rect.x, rect.y)
else intents.resizeFloat(paneId, rect)
const { drawn, legal } = reached(released.clientX, released.clientY)
sampleDrawn(samples, performance.now(), drawn)
if (sameRect(legal, pressed.legal)) raise(paneId)
else if (mode === 'move') intents.moveFloat(paneId, legal.x, legal.y)
else intents.resizeFloat(paneId, legal)
if (!sameRect(drawn, legal)) settle(paneId, drawn, legal, releaseVelocity(samples))
},
})
}
return { preview, raise, drag }
return { preview, raise, drag, rectOf }
}
/**
@@ -151,7 +349,7 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC
/** Every floating panel, in z order. */
export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode {
const { preview, raise, drag } = useFloatGestures(state, intents)
const { preview, raise, drag, rectOf } = useFloatGestures(state, intents)
return (
<>
@@ -161,7 +359,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
// A panel mid-gesture draws where the pointer has taken it and on top,
// as the operation its release records will leave it.
const lifted = preview?.paneId === paneId ? preview.rect : undefined
const live = lifted ?? floatRect(pane)
const live = rectOf(paneId, floatRect(pane))
return (
<div
key={paneId}
@@ -170,16 +368,13 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
tabIndex={-1}
data-dockkit-float-active={state.activePaneId === paneId || undefined}
style={{
left: live.x,
top: `max(var(--dsh-dockkit-float-top, 0px), ${live.y}px)`,
width: live.width,
height: live.height,
...floatStyle(live, lifted !== undefined),
zIndex: lifted === undefined ? depth + 1 : state.floats.length + 1,
}}
onPointerDown={() => { raise(paneId) }}
>
<FloatHeader {...{ paneId, tab, labels, intents, renderTabTitle, canCloseTab, drag }} />
<div className={css.floatBody}>{renderTab(tab)}</div>
<div className={css.floatBody}><TabBody tab={tab} renderTab={renderTab} /></div>
<div
className={css.floatResize}
data-dockkit-float-resize={paneId}
@@ -0,0 +1,18 @@
/**
* A tab's body from the embedder's renderer, shared by every layout. A layout
* re-renders for its own previews (a float gesture or return, a divider or tab
* drag) on every frame; the body is memoized on the renderer and the tab
* record, so such a frame leaves it untouched.
*/
import { memo } from 'react'
import type { ReactNode } from 'react'
import type { TabRenderer } from '../contract/adapter.ts'
import type { TabRecord } from '../contract/types.ts'
/** One tab's body, re-rendered only when `renderTab` or `tab` is a new object. */
export const TabBody = memo(function TabBody({ tab, renderTab }: {
readonly tab: TabRecord
readonly renderTab: TabRenderer
}): ReactNode {
return renderTab(tab)
})
@@ -7,7 +7,8 @@ import type { LayoutState, PaneId, PaneNode, TabId, TabRecord } from '../contrac
import { findTabPane, floatRect, getNode, getPane } from '../engine/tree.ts'
import type { SizePreview } from './PaneTree.tsx'
import type { PaneCallbacks } from './render.ts'
import { FloatHeader, useFloatGestures } from './FloatLayer.tsx'
import { FloatHeader, floatStyle, useFloatGestures } from './FloatLayer.tsx'
import { TabBody } from './TabBody.tsx'
import { PaneDropHints, TabPanel, TabStrip } from './TabPanel.tsx'
import css from './dockkit.module.css'
@@ -75,7 +76,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
return () => { element.removeEventListener('focus', focus, true) }
}, [visible, floating, floats, pane.id, state.activePaneId, callbacks])
const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : undefined
const rect = floating ? lifted ?? floatRect(pane) : undefined
const rect = floating ? floats.rectOf(pane.id, floatRect(pane)) : undefined
const depth = lifted === undefined ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1
return (
<div className={clsx(css.tabCell, floating && css.floatingCell)} hidden={!selected}
@@ -89,9 +90,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
data-dockkit-float={floating ? pane.id : undefined}
data-dockkit-float-active={floating && state.activePaneId === pane.id || undefined}
data-dockkit-column={!floating ? column : undefined}
style={rect === undefined ? undefined : {
left: rect.x, top: `max(var(--dsh-dockkit-float-top, 0px), ${rect.y}px)`, width: rect.width, height: rect.height,
}}
style={rect === undefined ? undefined : floatStyle(rect, lifted !== undefined)}
onPointerDown={() => { if (floating) floats.raise(pane.id) }}
onClick={() => { if (!floating && state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) }}>
<div className={css.tabHostHeader}>
@@ -101,7 +100,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
: <TabStrip state={state} pane={pane} callbacks={callbacks} offsets={offsets} />)}
</div>
<div ref={body} className={clsx(css.tabHostBody, floating ? css.floatBody : css.paneBody)}>
{visited && (retained || (active && selected)) ? callbacks.renderTab(tab) : null}
{visited && (retained || (active && selected)) ? <TabBody tab={tab} renderTab={callbacks.renderTab} /> : null}
{!floating && <PaneDropHints pane={pane} callbacks={callbacks} />}
</div>
{floating && <div className={css.floatResize} data-dockkit-float-resize={pane.id}
@@ -29,6 +29,7 @@ import { IconCloseFillRegular, IconPlusOutlineRegular, Tooltip } from '@deepseek
import type { DockZone, LayoutState, PaneId, PaneNode, TabId } from '../contract/types.ts'
import { getTab } from '../engine/tree.ts'
import type { PaneCallbacks, SplitBlock } from './render.ts'
import { TabBody } from './TabBody.tsx'
import { TabMenu } from './TabMenu.tsx'
import { TabTitle } from './TabTitle.tsx'
import css from './dockkit.module.css'
@@ -498,7 +499,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
<div className={css.paneBody}>
{active === undefined
? <p className={css.empty}>{callbacks.labels.emptyPane}</p>
: callbacks.renderTab(active)}
: <TabBody tab={active} renderTab={callbacks.renderTab} />}
<PaneDropHints pane={pane} callbacks={callbacks} />
</div>
</section>
@@ -785,6 +785,15 @@
.float {
position: fixed;
/* A resting panel publishes its rectangle in these variables (`floatStyle`).
The clamp keeps its position and size inside the viewport and below the
caption floor even when the gesture hook read a stale viewport; `dvh`
follows `window.innerHeight` as the hook does. A gesture or return writes
left, top and the size inline instead. */
left: max(0px, min(var(--dsh-dockkit-float-x), 100vw - var(--dsh-dockkit-float-width)));
top: max(var(--dsh-dockkit-float-top, 0px), min(var(--dsh-dockkit-float-y), 100dvh - var(--dsh-dockkit-float-height)));
width: min(var(--dsh-dockkit-float-width), 100vw);
height: min(var(--dsh-dockkit-float-height), 100dvh - var(--dsh-dockkit-float-top, 0px));
display: flex;
flex-direction: column;
/* The body inside is unpadded and reaches the edges, so the frame clips it
@@ -45,7 +45,11 @@ export interface DockLabels {
/**
* Renders one tab's body. The embedder dispatches on `tab.kind`, which is the
* only place that string carries meaning.
* only place that string carries meaning. As `renderTab`, the kit calls it
* again only when the renderer or the tab record is a new object and reuses the
* previous body across its own re-renders, such as a gesture's frames: the body
* must follow from `tab` and the values the renderer captures, and any other
* input reaches it through a new renderer or the body's own hooks and context.
*/
export type TabRenderer = (tab: TabRecord) => ReactNode
@@ -206,6 +206,102 @@ export function resizedRect(rect: FloatRect, dx: number, dy: number, min: Size):
}
}
/**
* The viewport region floating panels stay inside: `[0, width]` horizontally and
* `[top, height]` vertically, where `top` keeps the header clear of window chrome.
*/
export interface FloatBounds {
readonly top: number
readonly width: number
readonly height: number
}
/** How far past an edge the resistance lets a dragged panel travel, at most, in CSS pixels. */
export const FLOAT_RESISTANCE = 120
/** One axis clamped into `[min, max]`; an empty span pins it at `min`. */
function clampAxis(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max))
}
/**
* The nearest rectangle that lies inside the bounds, per axis. A panel larger
* than the bounds on an axis takes the bounds' size on it, so its header
* controls and resize corner stay reachable.
* @param rect - any rectangle, possibly outside the bounds.
* @param bounds - the viewport region.
* @returns the rectangle moved inside the bounds, its size capped by them.
*/
export function legalFloatRect(rect: FloatRect, bounds: FloatBounds): FloatRect {
const width = Math.min(rect.width, bounds.width)
const height = Math.min(rect.height, bounds.height - bounds.top)
return {
x: clampAxis(rect.x, 0, bounds.width - width),
y: clampAxis(rect.y, bounds.top, bounds.height - height),
width,
height,
}
}
/** Displacement past an edge for a pointer overflow: slope 1 at the edge, approaching `FLOAT_RESISTANCE`. */
function resist(overflow: number): number {
return Math.sign(overflow) * FLOAT_RESISTANCE * Math.abs(overflow) / (FLOAT_RESISTANCE + Math.abs(overflow))
}
/**
* Where a dragged panel draws when the pointer has taken it past an edge: the
* legal rectangle plus a resisted share of the overflow on each axis.
* @param desired - the rectangle the pointer travel asks for.
* @param bounds - the viewport region.
* @returns the drawn rectangle at the legal size; equal to `desired` while it is legal.
*/
export function resistedRect(desired: FloatRect, bounds: FloatBounds): FloatRect {
const legal = legalFloatRect(desired, bounds)
return { ...legal, x: legal.x + resist(desired.x - legal.x), y: legal.y + resist(desired.y - legal.y) }
}
/** The largest displacement `unresistedRect` inverts; `resist` only approaches `FLOAT_RESISTANCE`. */
const MAX_INVERTIBLE = FLOAT_RESISTANCE * 0.99
/**
* Pointer overflow that `resist` maps to a displacement, the displacement capped
* at `MAX_INVERTIBLE`. A panel drawn further out than that is picked up at the
* cap, which moves it inward by less than 1.2 px.
*/
function unresist(displacement: number): number {
const shown = Math.min(Math.abs(displacement), MAX_INVERTIBLE)
return Math.sign(displacement) * FLOAT_RESISTANCE * shown / (FLOAT_RESISTANCE - shown)
}
/**
* The rectangle a drag must start from so that zero pointer travel draws
* `drawn`, the inverse of `resistedRect`: a press on a panel still outside the
* bounds picks it up where it is.
* @param drawn - where the panel is drawn now.
* @param bounds - the viewport region.
* @returns the start rectangle at the legal size; equal to `drawn` while it is legal.
*/
export function unresistedRect(drawn: FloatRect, bounds: FloatBounds): FloatRect {
const legal = legalFloatRect(drawn, bounds)
return { ...legal, x: legal.x + unresist(drawn.x - legal.x), y: legal.y + unresist(drawn.y - legal.y) }
}
/**
* A resized rectangle whose bottom-right corner stays inside the bounds, never
* below the minimum size.
* @param rect - the resized rectangle; its origin may lie outside the bounds.
* @param bounds - the viewport region.
* @param min - smallest size the panel may take.
* @returns the rectangle with its size capped by the bounds.
*/
export function boundedResize(rect: FloatRect, bounds: FloatBounds, min: Size): FloatRect {
return {
...rect,
width: Math.max(min.width, Math.min(rect.width, bounds.width - rect.x)),
height: Math.max(min.height, Math.min(rect.height, bounds.height - rect.y)),
}
}
/**
* Where a panel should appear when a tab is dropped outside the docked area.
* @param x - drop point x.
@@ -0,0 +1,78 @@
/**
* The spring a floating panel follows back inside the viewport after a release
* past an edge. Pure and clock-free: the caller supplies each frame's elapsed
* time, so trajectories are deterministic for a given sequence of frames.
*/
/** One axis of a spring: where it is and how fast it moves, in CSS pixels and pixels per second. */
export interface SpringAxis {
readonly position: number
readonly velocity: number
}
/** Seconds the spring takes for one undamped period; sets its stiffness. */
const RESPONSE = 0.32
/** Below 1, so the return overshoots slightly inward (about 3.5%), which always stays legal. */
const DAMPING_RATIO = 0.72
const OMEGA = 2 * Math.PI / RESPONSE
/** Fixed integration step, so the motion does not depend on the frame rate. */
const SUBSTEP = 1 / 480
/** Position and speed below which an axis counts as at rest. */
const REST_DISTANCE = 1
const REST_SPEED = 60
/** Fastest release velocity a return starts with, in pixels per second. */
export const SPRING_MAX_VELOCITY = 1500
/** A return ends at its target after this long even if it is still moving. */
export const SPRING_DEADLINE_MS = 900
/** A frame longer than this is clamped, so a slow frame cannot inject a jump. */
export const SPRING_MAX_FRAME_MS = 64
/** A gap longer than this (sleep, a stalled tab) ends the return at its target instead of animating catch-up. */
export const SPRING_STALL_MS = 250
/**
* Advance one axis by an elapsed time, in equal substeps no longer than `SUBSTEP`
* (semi-implicit Euler).
* @param axis - the axis state.
* @param target - the position the spring pulls toward.
* @param dtSeconds - elapsed time, already clamped by the caller.
* @returns the advanced state.
*/
export function stepSpring(axis: SpringAxis, target: number, dtSeconds: number): SpringAxis {
const steps = Math.max(1, Math.ceil(dtSeconds / SUBSTEP))
const h = dtSeconds / steps
let { position, velocity } = axis
for (let step = 0; step < steps; step++) {
velocity += (-OMEGA * OMEGA * (position - target) - 2 * DAMPING_RATIO * OMEGA * velocity) * h
position += velocity * h
}
return { position, velocity }
}
/**
* The velocity an axis starts its return with. An axis already at its target
* keeps still. Outward speed is capped at `omega0 * (reach - |offset|)`: the
* spring carries a start offset `d` and speed `v` at most `d + 0.46 * v / omega0`
* from its target, so the whole return stays within `reach`.
* @param position - where the axis is drawn at release.
* @param target - the position the spring pulls toward.
* @param velocity - the drawn release velocity, in pixels per second.
* @param reach - the farthest the return may be from the target.
* @returns the starting velocity.
*/
export function launchVelocity(position: number, target: number, velocity: number, reach: number): number {
const offset = position - target
if (offset === 0) return 0
if (Math.sign(velocity) !== Math.sign(offset)) return velocity
return Math.sign(offset) * Math.min(Math.abs(velocity), OMEGA * Math.max(0, reach - Math.abs(offset)))
}
/**
* Whether an axis has come to rest at its target.
* @param axis - the axis state.
* @param target - the spring's target.
* @returns true within `REST_DISTANCE` of the target and slower than `REST_SPEED`.
*/
export function springSettled(axis: SpringAxis, target: number): boolean {
return Math.abs(axis.position - target) <= REST_DISTANCE && Math.abs(axis.velocity) <= REST_SPEED
}
@@ -31,6 +31,16 @@ it('keeps Windows floating headers 20px below the caption, including restored po
expect(css).toMatch(/:global\(html\[data-windows-titlebar\]\[data-fullscreen\]\) \.float\s*\{\s*--dsh-dockkit-float-top: 20px;/)
})
it('clamps a resting floating panel\'s position and size into the viewport and below the caption floor', () => {
const floatRule = /(?:^|\})\s*\.float\s*\{(?<body>[^}]*)\}/.exec(css)?.groups?.['body']
expect(floatRule).toContain('left: max(0px, min(var(--dsh-dockkit-float-x), 100vw - var(--dsh-dockkit-float-width)));')
expect(floatRule).toContain(
'top: max(var(--dsh-dockkit-float-top, 0px), min(var(--dsh-dockkit-float-y), 100dvh - var(--dsh-dockkit-float-height)));',
)
expect(floatRule).toContain('width: min(var(--dsh-dockkit-float-width), 100vw);')
expect(floatRule).toContain('height: min(var(--dsh-dockkit-float-height), 100dvh - var(--dsh-dockkit-float-top, 0px));')
})
describe('macOS app-region ownership', () => {
it('subtracts the chip run, strip-end chrome, pane body, and divider, and declares no drag itself', () => {
// The strip row's drag is the markup mark's job: a sheet that declares it
@@ -1353,6 +1353,34 @@ describe('divider drags', () => {
expect(intents.placeTab).not.toHaveBeenCalled()
expect([first, second]).toHaveLength(2)
})
it('does not re-render the docked bodies while the divider preview redraws the panes', () => {
const controller = seededController()
controller.setExpanded(true)
controller.splitPane()
const [first, second] = dockPaneIds(controller.getSnapshot().state)
if (first === undefined || second === undefined) throw new Error('expected two docked panes')
layOut([first, second], 420)
const renderTab = vi.fn(() => null)
const surface = (renderer: typeof renderTab) => (
<DockSurface state={controller.getSnapshot().state} canSplit intents={spyIntents()} labels={TEST_LABELS} renderTab={renderer} />
)
const view = render(surface(renderTab))
const rendered = renderTab.mock.calls.length
expect(rendered).toBeGreaterThan(0)
const divider = document.querySelector('[data-dockkit-divider]')
if (divider === null) throw new Error('expected a divider between the two panes')
const splitId = divider.getAttribute('data-dockkit-divider')?.split(':')[0]
drag(divider, [420, 300], [504, 300], false)
fireEvent.pointerMove(window, { pointerId: 7, clientX: 460, clientY: 300 })
expect(Number(document.querySelector<HTMLElement>(`[data-dockkit-cell="${splitId}:0"]`)?.style.flexGrow)).toBeCloseTo(0.55)
expect(renderTab).toHaveBeenCalledTimes(rendered)
fireEvent.pointerUp(window, { pointerId: 7, clientX: 460, clientY: 300 })
// A new renderer is how an embedder refreshes its bodies.
const next = vi.fn(() => null)
view.rerender(surface(next))
expect(next).toHaveBeenCalledTimes(rendered)
})
})
describe('FloatLayer', () => {
@@ -1518,7 +1546,7 @@ describe('FloatLayer', () => {
drag(grip, [150, 90], [180, 110], false)
expect(panel.style.left).toBe('130px')
expect(panel.style.top).toBe('max(var(--dsh-dockkit-float-top, 0px), 100px)')
expect(panel.style.top).toBe('100px')
// Mid-gesture the panel draws on top, as the move it records will leave it.
expect(panel.style.zIndex).toBe('3')
fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 })
@@ -1526,7 +1554,8 @@ describe('FloatLayer', () => {
expect(intents.moveFloat).toHaveBeenCalledTimes(1)
expect(intents.focusPane).not.toHaveBeenCalled()
// The recorded rectangle and depth are drawn again once the preview clears.
expect(panel.style.left).toBe('100px')
expect(panel.style.left).toBe('')
expect(panel.style.getPropertyValue('--dsh-dockkit-float-x')).toBe('100px')
expect(panel.style.zIndex).toBe('1')
})
@@ -0,0 +1,118 @@
/**
* Floating-panel edge recovery geometry: the legal projection into the
* viewport, the drag resistance past an edge and its inverse, and the
* viewport-bounded resize.
*/
import { describe, expect, it } from 'vitest'
import {
boundedResize, FLOAT_RESISTANCE, legalFloatRect, resistedRect, unresistedRect,
} from '../src/engine/geometry.ts'
import type { FloatBounds } from '../src/engine/geometry.ts'
import { FLOAT_MIN_SIZE } from '../src/index.ts'
const VIEWPORT: FloatBounds = { top: 0, width: 1000, height: 800 }
const PANEL = { x: 100, y: 80, width: 300, height: 200 }
describe('legalFloatRect', () => {
it('keeps a panel that is already inside, and returns a fresh rectangle of the same size', () => {
expect(legalFloatRect(PANEL, VIEWPORT)).toEqual(PANEL)
})
it.each([
{ edge: 'left', rect: { ...PANEL, x: -50 }, expected: { x: 0, y: 80 } },
{ edge: 'right', rect: { ...PANEL, x: 900 }, expected: { x: 700, y: 80 } },
{ edge: 'top', rect: { ...PANEL, y: -10 }, expected: { x: 100, y: 0 } },
{ edge: 'bottom', rect: { ...PANEL, y: 700 }, expected: { x: 100, y: 600 } },
{ edge: 'corner', rect: { ...PANEL, x: 2000, y: -400 }, expected: { x: 700, y: 0 } },
])('moves a panel past the $edge edge back by the overflow only', ({ rect, expected }) => {
expect(legalFloatRect(rect, VIEWPORT)).toEqual({ ...rect, ...expected })
})
it('honours the top floor that keeps the header below window chrome', () => {
expect(legalFloatRect({ ...PANEL, y: 10 }, { ...VIEWPORT, top: 60 }).y).toBe(60)
})
it('draws a panel larger than the viewport at the viewport size, so its header controls and corner stay reachable', () => {
const big = { x: 300, y: 300, width: 1200, height: 900 }
expect(legalFloatRect(big, { ...VIEWPORT, top: 20 })).toEqual({ x: 0, y: 20, width: 1000, height: 780 })
})
it('caps only the axis that does not fit', () => {
expect(legalFloatRect({ ...PANEL, x: 900, width: 1200 }, VIEWPORT)).toEqual({ ...PANEL, x: 0, width: 1000 })
})
})
describe('resistedRect', () => {
it('is the desired rectangle while it is legal', () => {
expect(resistedRect(PANEL, VIEWPORT)).toEqual(PANEL)
})
it.each([
{ overflow: 10, shown: 9.2308 },
{ overflow: 40, shown: 30 },
{ overflow: 120, shown: 60 },
{ overflow: 400, shown: 92.3077 },
])('draws $overflow px of pointer overflow as $shown px past the edge', ({ overflow, shown }) => {
const right = resistedRect({ ...PANEL, x: 700 + overflow }, VIEWPORT)
expect(right.x - 700).toBeCloseTo(shown, 3)
const left = resistedRect({ ...PANEL, x: -overflow }, VIEWPORT)
expect(left.x).toBeCloseTo(-shown, 3)
})
it('starts with slope 1 at the edge, grows monotonically, and never reaches the resistance limit', () => {
let previous = 0
for (let overflow = 1; overflow <= 5000; overflow *= 2) {
const shown = resistedRect({ ...PANEL, y: 600 + overflow }, VIEWPORT).y - 600
expect(shown).toBeGreaterThan(previous)
expect(shown).toBeLessThan(FLOAT_RESISTANCE)
previous = shown
}
expect(resistedRect({ ...PANEL, y: 600.001 }, VIEWPORT).y - 600).toBeCloseTo(0.001, 6)
})
it('resists each axis independently, leaving a legal axis untouched', () => {
const shown = resistedRect({ ...PANEL, x: 820, y: 80 }, VIEWPORT)
expect(shown.y).toBe(80)
expect(shown.x).toBeCloseTo(760)
})
it('draws at the legal size when the desired rectangle no longer fits the viewport', () => {
const narrow = { ...VIEWPORT, width: 500 }
// 60 px of overflow past the legal x of 0 draws 40 px past it, at the 500 px the viewport allows.
expect(resistedRect({ ...PANEL, x: 60, width: 700 }, narrow)).toEqual({ ...PANEL, x: 40, width: 500 })
expect(unresistedRect({ ...PANEL, x: 0, width: 700 }, narrow)).toEqual({ ...PANEL, x: 0, width: 500 })
})
})
describe('unresistedRect', () => {
it('inverts the resistance, so a press on a drawn panel picks it up where it is', () => {
for (const desired of [{ ...PANEL, x: 760 }, { ...PANEL, x: -300, y: 950 }, PANEL]) {
const drawn = resistedRect(desired, VIEWPORT)
const start = unresistedRect(drawn, VIEWPORT)
expect(start.x).toBeCloseTo(desired.x, 6)
expect(start.y).toBeCloseTo(desired.y, 6)
}
})
it('caps a displacement at the resistance limit instead of producing an infinite overflow', () => {
const start = unresistedRect({ ...PANEL, x: 700 + FLOAT_RESISTANCE * 2 }, VIEWPORT)
expect(Number.isFinite(start.x)).toBe(true)
expect(start.x - 700).toBeCloseTo(FLOAT_RESISTANCE * 99, 6)
})
})
describe('boundedResize', () => {
it('keeps the bottom-right corner inside the viewport', () => {
expect(boundedResize({ ...PANEL, width: 2000, height: 2000 }, VIEWPORT, FLOAT_MIN_SIZE))
.toEqual({ ...PANEL, width: 900, height: 720 })
})
it('never shrinks a panel below the minimum size', () => {
expect(boundedResize({ x: 950, y: 780, width: 400, height: 400 }, VIEWPORT, FLOAT_MIN_SIZE))
.toEqual({ x: 950, y: 780, ...FLOAT_MIN_SIZE })
})
it('leaves a size that fits unchanged', () => {
expect(boundedResize(PANEL, VIEWPORT, FLOAT_MIN_SIZE)).toEqual(PANEL)
})
})
@@ -0,0 +1,441 @@
// @vitest-environment jsdom
/**
* Floating-panel edge recovery through the rendered layer: resistance while a
* move is past an edge, one legal `moveFloat` per release, the spring back to
* it, cancellation, reduced motion, and the viewport projection. Frames and
* the clock are driven by hand so every trajectory is deterministic.
*/
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { FloatLayer } from '../src/components/FloatLayer.tsx'
import type { DockIntents } from '../src/contract/adapter.ts'
import type { FloatRect, PaneId } from '../src/contract/types.ts'
import { floatRect, getPane } from '../src/engine/tree.ts'
import { seededController, TEST_LABELS } from './fixtures.client.ts'
let now = 0
let queued = new Map<number, FrameRequestCallback>()
let nextFrame = 1
beforeEach(() => {
now = 0
queued = new Map()
nextFrame = 1
vi.spyOn(performance, 'now').mockImplementation(() => now)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextFrame++
queued.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => { queued.delete(id) })
viewport(1024, 768)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** Set the window size the layer reads. */
function viewport(width: number, height: number): void {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height })
}
/** Advance the clock and run every frame callback that was waiting. */
function frame(ms = 16): void {
now += ms
const due = [...queued.values()]
queued.clear()
act(() => { for (const callback of due) callback(now) })
}
/** Run frames until no callback is waiting; returns the elapsed time. */
function runOut(): number {
const started = now
for (let guard = 0; queued.size > 0; guard++) {
if (guard > 200) throw new Error('animation never ended')
frame()
}
return now - started
}
/** One floating panel over a live controller whose recorded moves redraw the layer. */
function mounted(rect: FloatRect = { x: 100, y: 80, width: 300, height: 200 }) {
const controller = seededController()
const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
const paneId = controller.floatTab(tabId, rect)
const renderTab = vi.fn(() => null)
const redraw = (): void => {
view.rerender(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={renderTab} />)
}
const moveFloat = vi.fn((id: PaneId, x: number, y: number) => { controller.moveFloat(id, x, y); redraw() })
const resizeFloat = vi.fn((id: PaneId, next: FloatRect) => { controller.resizeFloat(id, next); redraw() })
const focusPane = vi.fn((id: PaneId) => { controller.focusPane(id); redraw() })
const intents: DockIntents = {
focusTab: vi.fn(), focusPane, splitPane: vi.fn(), addTab: vi.fn(), closeTab: vi.fn(), duplicateTab: vi.fn(),
floatTab: vi.fn(), unfloatPane: vi.fn(), placeTab: vi.fn(), dropTab: vi.fn(), moveFloat, resizeFloat,
resizeSplit: vi.fn(),
}
const view = render(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={renderTab} />)
const panel = (): HTMLElement => view.container.querySelector<HTMLElement>(`[data-dockkit-float="${paneId}"]`) as HTMLElement
const grip = (): HTMLElement => panel().querySelector<HTMLElement>('[data-dockkit-float-grip]') as HTMLElement
// A lifted panel writes left and top inline; a resting one publishes them to the `.float` rule.
const drawn = (side: 'left' | 'top', axis: 'x' | 'y'): number =>
Number.parseFloat(panel().style.getPropertyValue(side) || panel().style.getPropertyValue(`--dsh-dockkit-float-${axis}`))
const left = (): number => drawn('left', 'x')
const top = (): number => drawn('top', 'y')
const stored = (): FloatRect => floatRect(getPane(controller.getSnapshot().state, paneId))
const corner = (): HTMLElement => panel().querySelector<HTMLElement>('[data-dockkit-float-resize]') as HTMLElement
return { controller, tabId, paneId, view, redraw, renderTab, intents, moveFloat, resizeFloat, focusPane, left, top, grip, corner, stored }
}
/** Press the grip at `from`, move to `to`, and optionally release there. */
function drag(grip: HTMLElement, from: readonly [number, number], to: readonly [number, number], release = true): void {
fireEvent.pointerDown(grip, { clientX: from[0], clientY: from[1], pointerId: 7, button: 0 })
fireEvent.pointerMove(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
if (release) fireEvent.pointerUp(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
}
describe('dragging past an edge', () => {
it('draws the panel with resistance, records one legal move, and springs there', () => {
const h = mounted()
// The panel's legal x tops out at 1024 - 300 = 724; the pointer asks for 120 px beyond it.
drag(h.grip(), [150, 90], [150 + 744, 90], false)
expect(h.left()).toBeCloseTo(784)
fireEvent.pointerUp(window, { pointerId: 7, clientX: 894, clientY: 90 })
expect(h.moveFloat).toHaveBeenCalledTimes(1)
expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 724, 80)
expect(h.stored()).toMatchObject({ x: 724, y: 80 })
expect(h.left()).toBeCloseTo(784)
const trail: number[] = []
for (let guard = 0; queued.size > 0 && guard < 100; guard++) {
frame()
trail.push(h.left())
}
expect(queued.size).toBe(0)
expect(now).toBeLessThan(900)
expect(h.left()).toBe(724)
// The return overshoots inward by a few pixels, never back outside.
expect(Math.min(...trail)).toBeLessThan(724)
expect(Math.min(...trail)).toBeGreaterThan(724 - 120 * 0.06)
expect(h.top()).toBe(80)
})
it('does not re-render the body through the drag and the return, and re-renders it for a new renderer', () => {
const h = mounted()
const rendered = h.renderTab.mock.calls.length
drag(h.grip(), [150, 90], [150 + 744, 90])
runOut()
expect(h.left()).toBe(724)
// The release's moveFloat redraws the layer, but the tab record and the renderer stay the same.
expect(h.renderTab).toHaveBeenCalledTimes(rendered)
const next = vi.fn(() => null)
h.view.rerender(<FloatLayer state={h.controller.getSnapshot().state} intents={h.intents} labels={TEST_LABELS} renderTab={next} />)
expect(next).toHaveBeenCalledTimes(1)
})
it('undoes the whole gesture in one step', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 - 400, 90])
runOut()
expect(h.stored().x).toBe(0)
expect(h.controller.undo()).toBe(true)
expect(h.stored().x).toBe(100)
})
it('carries a fling: a fast release outward keeps travelling before it turns back', () => {
const fast = mounted()
fireEvent.pointerDown(fast.grip(), { clientX: 150, clientY: 90, pointerId: 7, button: 0 })
now += 50
fireEvent.pointerMove(window, { pointerId: 7, clientX: 850, clientY: 90 })
now += 20
fireEvent.pointerUp(window, { pointerId: 7, clientX: 894, clientY: 90 })
const released = fast.left()
frame()
expect(fast.left()).toBeGreaterThan(released)
runOut()
expect(fast.left()).toBe(724)
})
it('starts a fling at the drawn speed, so even the fastest one stays within the resistance limit', () => {
const h = mounted()
fireEvent.pointerDown(h.grip(), { clientX: 150, clientY: 90, pointerId: 7, button: 0 })
// A long push that ends in a fast flick: the samples older than the velocity window drop out.
for (let x = 150; x <= 850; x += 50) {
now += 50
fireEvent.pointerMove(window, { pointerId: 7, clientX: x, clientY: 90 })
}
now += 10
fireEvent.pointerMove(window, { pointerId: 7, clientX: 3000, clientY: 90 })
fireEvent.pointerUp(window, { pointerId: 7, clientX: 3000, clientY: 90 })
const trail = [h.left()]
for (let guard = 0; queued.size > 0 && guard < 100; guard++) {
frame()
trail.push(h.left())
}
expect(Math.max(...trail)).toBeLessThan(724 + 120)
expect(h.left()).toBe(724)
})
it('draws above the top edge while dragged and springs back below it', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150, 90 - 200], false)
expect(h.top()).toBeLessThan(0)
fireEvent.pointerUp(window, { pointerId: 7, clientX: 150, clientY: -110 })
expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 100, 0)
runOut()
expect(h.top()).toBe(0)
})
it('springs straight back after a slow push, which has no release velocity', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
const released = h.left()
frame()
expect(h.left()).toBeLessThan(released)
})
it('leaves a release inside the viewport where it landed, with no animation', () => {
const h = mounted()
drag(h.grip(), [150, 90], [170, 110])
expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 120, 100)
expect(queued.size).toBe(0)
expect(h.left()).toBe(120)
})
it('records nothing for a push that ends where the panel already was, yet still springs back', () => {
const h = mounted({ x: 724, y: 80, width: 300, height: 200 })
drag(h.grip(), [800, 90], [900, 90])
expect(h.moveFloat).not.toHaveBeenCalled()
expect(queued.size).toBe(1)
runOut()
expect(h.left()).toBe(724)
})
})
describe('interrupting a return', () => {
it('stops on a new press and picks the panel up where it is drawn', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
frame()
frame()
const drawn = h.left()
expect(drawn).toBeGreaterThan(724)
fireEvent.pointerDown(h.grip(), { clientX: 800, clientY: 90, pointerId: 8, button: 0 })
expect(queued.size).toBe(0)
fireEvent.pointerMove(window, { pointerId: 8, clientX: 800, clientY: 90 })
expect(h.left()).toBeCloseTo(drawn, 6)
// Released in place, outside the viewport: no second move, and the spring resumes.
fireEvent.pointerUp(window, { pointerId: 8, clientX: 800, clientY: 90 })
expect(h.moveFloat).toHaveBeenCalledTimes(1)
runOut()
expect(h.left()).toBe(724)
})
it('ends at the target when the frames stall', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
frame(300)
expect(queued.size).toBe(0)
expect(h.left()).toBe(724)
})
it('ends at the target at the deadline even while still moving', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
// Each 200 ms frame advances the spring by the 64 ms frame cap, so it is still moving at 900 ms.
for (let elapsed = 0; elapsed < 800; elapsed += 200) frame(200)
expect(queued.size).toBe(1)
frame(200)
expect(queued.size).toBe(0)
expect(h.left()).toBe(724)
})
it('yields to an undo: the panel draws at the restored record at once', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
frame()
act(() => {
h.controller.undo()
h.redraw()
})
frame()
expect(queued.size).toBe(0)
expect(h.left()).toBe(100)
})
it('picks the panel up where it is drawn on a corner press, and a release in place resumes the return', () => {
const h = mounted({ x: 0, y: 80, width: 300, height: 200 })
drag(h.grip(), [150, 90], [150 - 400, 90])
frame()
const drawn = h.left()
expect(drawn).toBeLessThan(0)
fireEvent.pointerDown(h.corner(), { clientX: 250, clientY: 275, pointerId: 8, button: 0 })
expect(queued.size).toBe(0)
expect(h.left()).toBe(drawn)
fireEvent.pointerMove(window, { pointerId: 8, clientX: 250, clientY: 275 })
expect(h.left()).toBe(drawn)
fireEvent.pointerUp(window, { pointerId: 8, clientX: 250, clientY: 275 })
expect(h.left()).toBe(drawn)
expect(h.resizeFloat).not.toHaveBeenCalled()
runOut()
expect(h.left()).toBe(0)
})
it('resizes a panel caught during a return from where it is drawn, then springs it inside', () => {
const h = mounted({ x: 0, y: 80, width: 300, height: 200 })
drag(h.grip(), [150, 90], [150 - 400, 90])
frame()
const drawn = h.left()
fireEvent.pointerDown(h.corner(), { clientX: 250, clientY: 275, pointerId: 8, button: 0 })
fireEvent.pointerMove(window, { pointerId: 8, clientX: 270, clientY: 295 })
// The origin stays where it was drawn; only the size follows the corner.
expect(h.left()).toBe(drawn)
fireEvent.pointerUp(window, { pointerId: 8, clientX: 270, clientY: 295 })
expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 0, y: 80, width: 320, height: 220 })
expect(h.left()).toBe(drawn)
runOut()
expect(h.left()).toBe(0)
})
it('yields when its panel closes', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
act(() => {
h.controller.closeTab(h.tabId)
h.redraw()
})
frame()
expect(queued.size).toBe(0)
})
it('ends at its target when another panel is pressed', () => {
const h = mounted()
const other = h.controller.floatTab(
h.controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' }),
{ x: 100, y: 400, width: 300, height: 200 },
)
act(() => { h.redraw() })
drag(h.grip(), [150, 90], [150 + 744, 90])
frame()
expect(h.left()).toBeGreaterThan(724)
const otherGrip = h.view.container.querySelector<HTMLElement>(`[data-dockkit-float="${other}"] [data-dockkit-float-grip]`) as HTMLElement
fireEvent.pointerDown(otherGrip, { clientX: 150, clientY: 410, pointerId: 8, button: 0 })
expect(queued.size).toBe(0)
expect(h.left()).toBe(724)
fireEvent.pointerUp(window, { pointerId: 8, clientX: 150, clientY: 410 })
})
it('cancels its frame when the layer unmounts', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
expect(queued.size).toBe(1)
h.view.unmount()
expect(queued.size).toBe(0)
})
})
describe('reduced motion', () => {
it('clamps at the edge with no resistance and never schedules a frame', () => {
vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)' }))
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90], false)
expect(h.left()).toBe(724)
fireEvent.pointerUp(window, { pointerId: 7, clientX: 894, clientY: 90 })
expect(h.moveFloat).toHaveBeenCalledWith(h.paneId, 724, 80)
expect(queued.size).toBe(0)
})
it('draws a resize at its legal rectangle, so a viewport that shrank past its origin animates nothing', () => {
vi.stubGlobal('matchMedia', (query: string) => ({ matches: query === '(prefers-reduced-motion: reduce)' }))
const h = mounted({ x: 700, y: 80, width: 300, height: 200 })
fireEvent.pointerDown(h.corner(), { clientX: 1000, clientY: 280, pointerId: 7, button: 0 })
viewport(500, 768)
act(() => { window.dispatchEvent(new Event('resize')) })
fireEvent.pointerMove(window, { pointerId: 7, clientX: 990, clientY: 280 })
expect(h.left()).toBe(280)
fireEvent.pointerUp(window, { pointerId: 7, clientX: 990, clientY: 280 })
expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 280, y: 80, width: 220, height: 200 })
expect(queued.size).toBe(0)
})
it('keeps the resistance when the platform states no preference', () => {
vi.stubGlobal('matchMedia', () => ({ matches: false }))
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90], false)
expect(h.left()).toBeCloseTo(784)
})
})
describe('the viewport projection', () => {
it('pushes a panel in when the window shrinks and returns it when the window grows, recording nothing', () => {
const h = mounted()
viewport(350, 768)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(h.left()).toBe(50)
viewport(1024, 768)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(h.left()).toBe(100)
expect(h.moveFloat).not.toHaveBeenCalled()
expect(h.focusPane).not.toHaveBeenCalled()
})
it('ends a running return at its recorded rectangle when the window resizes', () => {
const h = mounted()
drag(h.grip(), [150, 90], [150 + 744, 90])
frame()
act(() => { window.dispatchEvent(new Event('resize')) })
expect(queued.size).toBe(0)
expect(h.left()).toBe(724)
})
it('treats a click on a projected panel as a raise, keeping its record for a larger window', () => {
const h = mounted()
viewport(350, 768)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(h.left()).toBe(50)
drag(h.grip(), [100, 90], [100, 90])
drag(h.corner(), [340, 270], [340, 270])
expect(h.moveFloat).not.toHaveBeenCalled()
expect(h.resizeFloat).not.toHaveBeenCalled()
viewport(1024, 768)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(h.left()).toBe(100)
})
it('bounds a gesture by the viewport the window resized to mid-gesture', () => {
const h = mounted()
fireEvent.pointerDown(h.corner(), { clientX: 400, clientY: 280, pointerId: 7, button: 0 })
fireEvent.pointerMove(window, { pointerId: 7, clientX: 800, clientY: 280 })
viewport(500, 768)
act(() => { window.dispatchEvent(new Event('resize')) })
fireEvent.pointerUp(window, { pointerId: 7, clientX: 800, clientY: 280 })
expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 100, y: 80, width: 400, height: 200 })
})
it('keeps a resize origin inside a viewport that shrank past it mid-gesture', () => {
const h = mounted({ x: 700, y: 80, width: 300, height: 200 })
fireEvent.pointerDown(h.corner(), { clientX: 1000, clientY: 280, pointerId: 7, button: 0 })
fireEvent.pointerMove(window, { pointerId: 7, clientX: 990, clientY: 280 })
viewport(500, 768)
act(() => { window.dispatchEvent(new Event('resize')) })
fireEvent.pointerUp(window, { pointerId: 7, clientX: 990, clientY: 280 })
expect(h.resizeFloat).toHaveBeenCalledWith(h.paneId, { x: 280, y: 80, width: 220, height: 200 })
})
it('keeps the 20px floor on a Windows root that publishes no caption height', () => {
document.documentElement.setAttribute('data-windows-titlebar', '')
onTestFinished(() => { document.documentElement.removeAttribute('data-windows-titlebar') })
const h = mounted({ x: 100, y: 0, width: 300, height: 200 })
expect(h.top()).toBe(20)
})
it('draws a stored rectangle from outside the viewport inside it', () => {
const h = mounted({ x: 2000, y: 900, width: 300, height: 200 })
expect(h.left()).toBe(724)
expect(h.top()).toBe(568)
})
})
@@ -0,0 +1,88 @@
/**
* The edge-recovery spring: settling, the inward overshoot bound, frame-rate
* independence, and determinism, all with fixed frame steps.
*/
import { describe, expect, it } from 'vitest'
import { launchVelocity, SPRING_DEADLINE_MS, SPRING_MAX_VELOCITY, springSettled, stepSpring } from '../src/engine/spring.ts'
import type { SpringAxis } from '../src/engine/spring.ts'
/** Analytic overshoot bound for the shipped damping ratio, exp(-pi*zeta/sqrt(1-zeta^2)). */
const OVERSHOOT_BOUND = Math.exp(-Math.PI * 0.72 / Math.sqrt(1 - 0.72 ** 2))
/** Run a return at a fixed frame rate until it rests; positions sampled once per frame. */
function settle(start: SpringAxis, target: number, hz: number): { readonly ms: number; readonly positions: number[] } {
let axis = start
const positions: number[] = []
for (let frame = 1; frame <= 1000; frame++) {
axis = stepSpring(axis, target, 1 / hz)
positions.push(axis.position)
if (springSettled(axis, target)) return { ms: frame * 1000 / hz, positions }
}
throw new Error('spring did not settle')
}
describe('stepSpring', () => {
it('returns a 200 px overflow inside the deadline with an inward overshoot within the analytic bound', () => {
const { ms, positions } = settle({ position: -200, velocity: 0 }, 0, 60)
expect(ms).toBeLessThan(SPRING_DEADLINE_MS)
expect(ms).toBeCloseTo(366.7, 0)
const overshoot = Math.max(...positions) / 200
expect(overshoot).toBeGreaterThan(0)
expect(Math.abs(overshoot - OVERSHOOT_BOUND)).toBeLessThan(0.01)
})
it('moves the same way whatever the frame rate, because it integrates in fixed substeps', () => {
const reference = settle({ position: 120, velocity: 0 }, 0, 60).ms
for (const hz of [30, 120]) {
const { ms } = settle({ position: 120, velocity: 0 }, 0, hz)
expect(Math.abs(ms - reference)).toBeLessThanOrEqual(1000 / 30)
}
})
it('carries a release velocity: a fling outward travels further before it returns', () => {
const still = settle({ position: 60, velocity: 0 }, 0, 60).positions
const flung = settle({ position: 60, velocity: 1500 }, 0, 60).positions
expect(Math.max(...flung)).toBeGreaterThan(Math.max(...still))
})
it('is deterministic for the same inputs', () => {
expect(settle({ position: -90, velocity: 400 }, 10, 60)).toEqual(settle({ position: -90, velocity: 400 }, 10, 60))
})
it('stays put at its target', () => {
expect(stepSpring({ position: 5, velocity: 0 }, 5, 1 / 60)).toEqual({ position: 5, velocity: 0 })
})
})
describe('launchVelocity', () => {
it('keeps an axis that is already at its target still', () => {
expect(launchVelocity(40, 40, 900, 120)).toBe(0)
})
it('passes an inward velocity through unchanged', () => {
expect(launchVelocity(60, 0, -900, 120)).toBe(-900)
expect(launchVelocity(-60, 0, 900, 120)).toBe(900)
})
it('caps an outward velocity on either side, to nothing once the axis is at the reach', () => {
expect(launchVelocity(119, 0, 1500, 120)).toBeLessThan(1500)
expect(launchVelocity(-119, 0, -1500, 120)).toBeGreaterThan(-1500)
expect(launchVelocity(130, 0, 1500, 120)).toBe(0)
})
it('keeps every return within the reach of its target, however fast the fling', () => {
for (let offset = 1; offset < 120; offset += 1) {
const velocity = launchVelocity(offset, 0, SPRING_MAX_VELOCITY, 120)
const { positions } = settle({ position: offset, velocity }, 0, 60)
expect(Math.max(offset, ...positions)).toBeLessThan(120)
}
})
})
describe('springSettled', () => {
it('needs both closeness and slowness', () => {
expect(springSettled({ position: 0.5, velocity: 10 }, 0)).toBe(true)
expect(springSettled({ position: 2, velocity: 0 }, 0)).toBe(false)
expect(springSettled({ position: 0, velocity: 100 }, 0)).toBe(false)
})
})
@@ -176,6 +176,30 @@ it('keeps body ancestors through pane moves, floating, raising and docking', ()
expect(body.closest('[data-dockkit-host]')).toBe(host)
})
it('does not re-render a docked or floating body while a float gesture redraws the layout', () => {
const controller = seededController()
controller.setExpanded(true)
const tab = controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' })
controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' })
controller.floatTab(tab, { x: 100, y: 80, width: 300, height: 200 })
const h = mounted(controller, { keepMounted: () => true })
const renderTab = vi.fn(h.callbacks.renderTab)
h.redraw({ callbacks: { ...h.callbacks, renderTab } })
const rendered = renderTab.mock.calls.length
expect(rendered).toBeGreaterThan(0)
const floating = controller.getSnapshot().state.floats[0]!
fireEvent.pointerDown(h.node(`[data-dockkit-float="${floating}"] [data-dockkit-float-grip]`),
{ pointerId: 3, button: 0, clientX: 150, clientY: 90 })
for (const x of [170, 190, 210]) fireEvent.pointerMove(window, { pointerId: 3, clientX: x, clientY: 90 })
expect(h.node(`[data-dockkit-float="${floating}"]`).style.left).toBe('160px')
expect(renderTab).toHaveBeenCalledTimes(rendered)
fireEvent.pointerUp(window, { pointerId: 3, clientX: 210, clientY: 90 })
// A new renderer is how an embedder refreshes its bodies.
const next = vi.fn(h.callbacks.renderTab)
h.redraw({ callbacks: { ...h.callbacks, renderTab: next } })
expect(next).toHaveBeenCalledTimes(rendered)
})
it('unmounts unretained bodies and restores focus to their replacement strip', () => {
const controller = seededController()
controller.setExpanded(true)