fix(ui-dockkit): bound the edge return and keep clicks on projected panels
- A release that reaches the press's legal rectangle only raises, so a click on a panel drawn projected no longer records a move or resize. - The return starts at the drawn panel's speed, and launchVelocity caps its outward part so the whole trajectory stays within 120 px. - A panel larger than the viewport draws at the viewport's size, keeping its header controls and resize corner reachable. - A return ends on an undo, dock or close of its panel, and at its target when another panel is pressed; a gesture uses the bounds of a window resized mid-gesture; drawn samples outside the velocity window drop. - Gestures and returns may draw above the top floor; at rest the .float rule clamps every panel into the viewport and below the caption floor. - Web e2e drags a Sidebar float past the right and bottom edges. - The note is renamed to spring-based edge recovery, compressed, and cross-linked to the docking and retained-layout notes.
This commit is contained in:
1 parent
7bfe4ad07f
commit
38c1b9b835
17 files changed
+381
-121
No files matched your search
+28
-28
@@ -2,36 +2,36 @@
|
||||
# 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-force-directed-edge-recovery-for-floating-dock-panels:
|
||||
en: 3742208bc57c921a
|
||||
zh: 1032661b2f07c813
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/problem:
|
||||
en: ed30f1f207a4b288
|
||||
zh: 319bbacba5ce71a6
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision:
|
||||
en: c5ded7c065d856c6
|
||||
zh: a1cf89d15728a2d5
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/legal-region-and-displayed-position:
|
||||
en: 5a2dd1efef511c37
|
||||
zh: 81628ab5cf8ecf11
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/drag-with-resistance:
|
||||
en: 44149d1bb485b486
|
||||
zh: 12b3c05fe87f2ad9
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/release-and-spring:
|
||||
en: b7657b6be917b6a3
|
||||
zh: 1399ffdda70d5d48
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/reduced-motion:
|
||||
/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: be0eda9ced8dd039
|
||||
zh: 54bf0afabfc05fff
|
||||
/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: eef0bb376aa37c31
|
||||
zh: 3edd74b035c7cd2a
|
||||
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/reduced-motion:
|
||||
en: 7e29bf1c45b5386d
|
||||
zh: c913a2ce6d1722d1
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/decision/change-scope:
|
||||
en: 836645900d530e43
|
||||
zh: bba46c2df47c8c81
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/testing:
|
||||
en: 4d823bf96a215c77
|
||||
zh: 45bb98c308bba55c
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/alternatives-considered:
|
||||
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/decision/change-scope:
|
||||
en: d8130d19d381d6f3
|
||||
zh: b2bc6df73f9983c7
|
||||
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/testing:
|
||||
en: 01b778ba0c0dfef2
|
||||
zh: f5d413da8e06e2ac
|
||||
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/alternatives-considered:
|
||||
en: 3d901f0f5e270e29
|
||||
zh: c1fb89b567ea3c70
|
||||
/agent-note-force-directed-edge-recovery-for-floating-dock-panels/consequences:
|
||||
/agent-note-spring-based-edge-recovery-for-floating-dock-panels/consequences:
|
||||
en: 02ef970d57f83067
|
||||
zh: 3c120262e98a0fe6
|
||||
zh: 646acf419161acc4
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Force-directed edge recovery for floating dock panels
|
||||
# Agent Note: Spring-based edge recovery for floating dock panels
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -8,23 +8,25 @@ English | [中文](2026-10-21-floating-window-edge-recovery.zh.md)
|
||||
|
||||
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.
|
||||
|
||||
We reverse engineered Claude Desktop 2.42304.2 as a reference, and it has no such behavior. On macOS its Quick Entry surface is a native Swift `NSPanel`; its binary contains animation primitives, but no edge-recovery rule could be established from it. The Electron fallback used on Windows/Linux is static: after a 750 ms input debounce it makes one instantaneous `setPosition` that clamps only the right and bottom edges (trigger margin 0, target margin 30 DIP, so the first correction jumps by overflow + 30) and never clamps left or top. No `spring` symbol exists in its main bundle; its only window-motion utilities are a 240 ms `easeInOutCubic` bounds tween for main-window size changes, Electron's macOS-only `setBounds(bounds, true)`, and a 300 ms `cubic-bezier(0.16, 1, 0.3, 1)` CSS vocabulary. The behavior had to be designed, not ported.
|
||||
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 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 pins the minimum edge, so the header and its grip stay on screen and the excess hangs off the right or bottom.
|
||||
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 on every window `resize` event. Persisted layouts that hold off-screen rectangles display inside the viewport without migration.
|
||||
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 it into the viewport (`100vw` by `100vh`) and below the caption floor once more. 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 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 is continuous, has slope 1 at the edge, and approaches `K`:
|
||||
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 |
|
||||
|---|---|---|
|
||||
@@ -37,9 +39,9 @@ A resize clamps the bottom-right corner to the region through `boundedResize` an
|
||||
|
||||
### Release and spring
|
||||
|
||||
A release records the legal rectangle: `moveFloat` or `resizeFloat` when it differs from the stored one, otherwise only the raise a click records. History and persistence therefore always hold a legal position, and undo restores the pre-gesture position in one step. 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.
|
||||
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 pointer velocity over the last 100 ms of the drag, capped at `SPRING_MAX_VELOCITY` (1500 px/s), so a fling past the edge travels further before it turns back; an axis that did not overflow keeps still. Per axis the update is:
|
||||
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
|
||||
@@ -47,9 +49,9 @@ 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 resistance caps the released overflow at 120 px. The integrator is pure and takes `dt`, so the engine keeps its rule of reading no clock.
|
||||
`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. A move picks the panel up where it is drawn: `unresistedRect` inverts the resistance, so zero pointer travel draws the same rectangle, and 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, and unmounting cancels the pending frame.
|
||||
A press on the grip or the corner during a return cancels it. A move picks the panel up where it is drawn: `unresistedRect` inverts the resistance, so zero pointer travel draws the same rectangle, and 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
|
||||
|
||||
@@ -62,18 +64,20 @@ The change touches one package, `@deepseek-ai/dsh-client-ui-dockkit`, and leaves
|
||||
| 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 test and its timing limits |
|
||||
| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`: viewport projection, resistance, release spring, velocity sampling, reduced motion, frame loop and cancellation; `rectOf` for both renderers |
|
||||
| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | draws the hook's displayed rectangle |
|
||||
| `packages/client/ui-dockkit/src/engine/spring.ts` | the pure spring integrator, its rest test, `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/dockkit.module.css` | the resting clamp 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, `apps/web`, `apps/desktop`, the shared primitives and the theme are untouched. No dependency was added.
|
||||
`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, and the bounded resize. `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, and determinism.
|
||||
`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, and the bounded resize. `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, 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, unmount cancellation, both reduced-motion answers, the viewport projection on shrink and growth without any intent, a resize ending a return, and an off-screen stored rectangle drawn inside. The existing `FloatLayer` and `TabLayout` specs still pass unchanged, including the rule that starting a gesture forces no layout read; every changed source file is fully covered.
|
||||
`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 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. 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, and waits for the release to settle flush against both edges.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: 悬浮停靠面板的力导向边界收回
|
||||
# Agent Note: 悬浮停靠面板的弹簧式边界收回
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -8,23 +8,25 @@ Status: implemented
|
||||
|
||||
dock kit 的悬浮面板就是客户端里的悬浮窗。`FloatLayer` 与保留式 `DockLayout` 渲染器通过同一个手势 hook `useFloatGestures` 以视口坐标绘制它们,右侧 Sidebar 嵌入了它们,因此同一套面板既运行在 Web 客户端中,也运行在 Desktop 窗口内。它们的拖拽过去一比一跟随指针,只有一个顶部下限(`--dsh-dockkit-float-top`,让标题栏保持在 Windows 标题栏之下)。因此面板可以被拖过左、右或下边界,直到标题栏够不着;视口缩小会把面板留在外面;新建面板的级联位置也可能落在小视口之外。期望行为是:面板被推过边界时可见地受到阻力,松手后以弹簧物理被拉回视口内,而不是像撞墙一样被挡住或被留在外面。
|
||||
|
||||
我们逆向分析了参考实现 Claude Desktop 2.42304.2,它没有这种行为。在 macOS 上它的 Quick Entry 是原生 Swift `NSPanel`;其二进制包含动画原语,但无法从中确认任何边界收回规则。Windows/Linux 上使用的 Electron 回退是静态的:输入防抖 750 ms 后做一次瞬时 `setPosition`,只裁剪右边界和下边界(触发边距为 0、目标边距为 30 DIP,因此首次修正在溢出量之外再跳 30),从不裁剪左边界和上边界。其主 bundle 中不存在 `spring` 符号;仅有的窗口运动工具是用于主窗口尺寸变化的 240 ms `easeInOutCubic` 边界补间、Electron 仅 macOS 可用的 `setBounds(bounds, true)`,以及 300 ms `cubic-bezier(0.16, 1, 0.3, 1)` 的 CSS 运动语汇。该行为必须被设计,而不是移植。
|
||||
所考察的参考实现 Claude Desktop 没有可移植的边界收回规则:它的 Electron 回退只对屏幕外窗口做一次无动画修正,且只修正右边界和下边界。该行为必须被设计,而不是移植。
|
||||
|
||||
变更维持以下义务:面板标题栏始终可达;一个手势仍然最多记录一条历史;遵循减弱动态效果;Sidebar 的持久化布局格式与所有嵌入方保持不变;运动足够确定,无需真实浏览器时钟即可测试。
|
||||
|
||||
## 决定
|
||||
|
||||
该行为完全位于 `packages/client/ui-dockkit` 内,在 `useFloatGestures` 及其调用的纯 engine 辅助函数中实现。两个悬浮渲染器都通过 `rectOf` 从该 hook 取得矩形,因此 Web 与 Desktop 共享该行为,任何嵌入方、壳或持久化格式都没有改变。
|
||||
该行为完全位于 `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` 逐轴把矩形投影进该区域;面板比区域更宽或更高时钉住最小边,使标题栏及其抓手留在屏幕上,多余部分悬在右侧或下方。
|
||||
面板的合法区域是视口(`window.innerWidth` × `window.innerHeight`)加上既有的顶部下限:设置了 `data-windows-titlebar` 时为 Windows 标题栏高度加 20 px(原生全屏时为 20 px),否则为 0。`floatBounds` 只读取这些值和根元素属性,因此开始手势不会触发布局。`legalFloatRect` 逐轴把矩形投影进该区域;面板比区域更宽或更高时,在该轴上按区域尺寸绘制,使标题栏控件和缩放角留在屏幕上;存储的尺寸不变。
|
||||
|
||||
面板绘制在其存储矩形投影到视口后的位置。存储矩形从不因视口而被改写:窗口缩小时面板被推入且不记录操作,窗口变大时面板回到存储位置。hook 在每次窗口 `resize` 事件时重新读取区域。包含屏幕外矩形的持久化布局无需迁移即在视口内显示。
|
||||
面板绘制在其存储矩形投影到视口后的位置。存储矩形从不因视口而被改写:窗口缩小时面板被推入且不记录操作,窗口变大时面板朝存储位置回移。hook 在每次按下和每次窗口 `resize` 事件时重新读取区域,进行中的手势使用新区域。包含屏幕外矩形的持久化布局无需迁移即在视口内显示。
|
||||
|
||||
静止时面板通过 `floatStyle` 把矩形以 CSS 变量发布给 `.float` 规则,该规则再次把它钳制在视口(`100vw` × `100vh`)内并保持在标题栏下限之下。因此即使 hook 读到的区域尚未刷新(例如全屏切换完成前读取的值),面板仍在视口内。手势或回弹把位置写在行内样式中,不受该钳制。
|
||||
|
||||
### 带阻力的拖拽
|
||||
|
||||
移动时由手势起始矩形和指针位移计算期望矩形,然后 `resistedRect` 逐轴把它拆分为合法投影与超出它的溢出量 `o`,并绘制在 `legal + sign(o) * K * |o| / (K + |o|)`,其中 `K = FLOAT_RESISTANCE = 120` px。该映射连续、在边界处斜率为 1、渐近于 `K`:
|
||||
移动时由手势起始矩形和指针位移计算期望矩形,然后 `resistedRect` 逐轴把它拆分为合法投影与超出它的溢出量 `o`,并绘制在 `legal + sign(o) * K * |o| / (K + |o|)`,其中 `K = FLOAT_RESISTANCE = 120` px。该映射作用于全部四条边,包括顶部下限之上,连续、在边界处斜率为 1、渐近于 `K`:
|
||||
|
||||
| 指针溢出(px) | 面板越过边界的位移(px) | 占溢出的比例 |
|
||||
|---|---|---|
|
||||
@@ -37,9 +39,9 @@ dock kit 的悬浮面板就是客户端里的悬浮窗。`FloatLayer` 与保留
|
||||
|
||||
### 松手与弹簧
|
||||
|
||||
松手时记录合法矩形:与存储矩形不同时记录 `moveFloat` 或 `resizeFloat`,否则只记录点击会记录的提升。因此历史与持久化始终保存合法位置,撤销一步即可恢复手势前的位置。若面板绘制在合法区域之外,hook 保留预览,用弹簧把它从绘制位置带到记录的矩形,然后清除预览。在区域内松手不播放动画。
|
||||
松手时记录合法矩形:与按下时的合法矩形不同时记录 `moveFloat` 或 `resizeFloat`,否则只记录点击会记录的提升。因此单击以投影绘制的面板会保留其存储矩形。因此移动或缩放只记录合法矩形,撤销一步即可恢复手势前的位置。创建面板(`floatTab` 的落点矩形与级联)仍可能存储视口外的矩形,由投影把它绘制在视口内。若面板绘制在合法区域之外,hook 保留预览,用弹簧把它从绘制位置带到记录的矩形,然后清除预览。在区域内松手不播放动画。
|
||||
|
||||
`engine/spring.ts` 中的弹簧是欠阻尼的,因为它的回弹朝向内侧,始终合法:响应时间 0.32 s、阻尼比 0.72。溢出的轴以拖拽最后 100 ms 的指针速度起步,上限为 `SPRING_MAX_VELOCITY`(1500 px/s),因此甩过边界的面板会先走得更远再折返;没有溢出的轴保持不动。每轴更新为:
|
||||
`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
|
||||
@@ -47,9 +49,9 @@ 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%` 相差不超过一个百分点;阻力把松手时的溢出限制在 120 px 内。积分器是纯函数并接收 `dt`,因此 engine 保持不读取时钟的规则。
|
||||
`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 最多运行一个回弹;窗口缩放会让进行中的回弹停在记录的矩形,卸载会取消待处理的帧。
|
||||
回弹期间按下抓手或右下角会取消回弹。移动从绘制位置接住面板:`unresistedRect` 反演阻力,因此零指针位移绘制同一矩形,原地松手会恢复回弹且不记录第二次操作。一个 hook 最多运行一个回弹。窗口缩放会让进行中的回弹停在记录的矩形;按下另一个面板会让它停在目标;面板记录的变化若不再投影到目标(撤销、停靠、关闭),会在下一帧让它停在新记录;卸载会取消待处理的帧。
|
||||
|
||||
### 减弱动态效果
|
||||
|
||||
@@ -62,18 +64,20 @@ x = x + v * h
|
||||
| 文件 | 变更 |
|
||||
|---|---|
|
||||
| `packages/client/ui-dockkit/src/engine/geometry.ts` | `FloatBounds`、`FLOAT_RESISTANCE`、`legalFloatRect`、`resistedRect`、`unresistedRect`、`boundedResize` |
|
||||
| `packages/client/ui-dockkit/src/engine/spring.ts` | 纯弹簧积分器、静止判定与时间上限 |
|
||||
| `packages/client/ui-dockkit/src/components/FloatLayer.tsx` | `useFloatGestures`:视口投影、阻力、松手弹簧、速度采样、减弱动态效果、帧循环与取消;供两个渲染器使用的 `rectOf` |
|
||||
| `packages/client/ui-dockkit/src/components/TabLayout.tsx` | 绘制 hook 给出的显示矩形 |
|
||||
| `packages/client/ui-dockkit/src/engine/spring.ts` | 纯弹簧积分器、静止判定、`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/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`、共享基础组件与主题均未改动。没有新增依赖。
|
||||
`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 下与帧率无关、松手速度的作用以及确定性。
|
||||
`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` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。既有的 `FloatLayer` 与 `TabLayout` 测试原样通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。
|
||||
`tests/float-recovery.client.spec.tsx` 在活的控制器上渲染 `FloatLayer`,帧与时钟由测试手动推进:越过边界时带阻力的预览、每次松手恰好一次合法的 `moveFloat` 以及带向内过冲的弹回、一步撤销、甩出与慢推的对比、快速甩出不超出 `K`、拖过顶部下限、区域内松手不播放动画、推到存储位置的手势、回弹中按下接住面板且不跳变、停顿直接跳转、截止时间、回弹让位于撤销、关闭与按下另一个面板、卸载取消、两种减弱动态效果答复、视口缩小与变大时的投影且不调用任何 intent、单击投影面板不记录任何操作、手势中窗口缩放约束其松手结果、缩放结束回弹,以及屏幕外存储矩形在视口内绘制。既有的 `FloatLayer` 与 `TabLayout` 测试在位置断言改为读取静止变量后通过,包括开始手势不做布局读取这一规则;每个改动的源文件都被完全覆盖。`apps/web/tests/sidebar-right.e2e.ts` 在 Chromium 中把 Sidebar 悬浮面板拖过右边界和下边界,检查绘制的面板越过两边均不超过 120 px,并等待松手后面板贴合两条边静止。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -97,4 +101,4 @@ x = x + v * h
|
||||
|
||||
悬浮面板在两个客户端中都不会再丢失到屏幕外,推过边界读起来是被拉回而不是撞墙。历史、持久化与所有嵌入方不受影响,运动由确定性测试而非手动检查覆盖。
|
||||
|
||||
弹簧通过 React state 更新预览,每帧成本与拖拽相同,但承载重内容的面板在松手后最多会重渲染 900 ms;如果测量显示问题,退路是直接在面板元素上写 transform。被缩小窗口推入的面板会在窗口变大时回到存储位置,期望它留在被推到位置的用户可能会感到意外。面板内的内容(例如 webview)在松手后可能观察到最多 900 ms 的运动。常量针对鼠标或触控板调优;触控输入经由同样的 pointer 事件可用,但仍未调优。
|
||||
弹簧通过 React state 更新预览,每帧成本与拖拽相同,但承载重内容的面板在松手后最多会重渲染 900 ms;如果测量显示问题,退路是直接在面板元素上写 transform。被缩小窗口推入的面板会在窗口变大时朝存储位置回移,期望它留在被推到位置的用户可能会感到意外。面板内的内容(例如 webview)在松手后可能观察到最多 900 ms 的运动。常量针对鼠标或触控板调优;触控输入经由同样的 pointer 事件可用,但仍未调优。
|
||||
@@ -1046,6 +1046,40 @@ 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()
|
||||
|
||||
await expect.poll(async () => {
|
||||
const box = await panel.boundingBox()
|
||||
return box === null ? undefined : { right: Math.round(box.x + box.width), bottom: Math.round(box.y + box.height) }
|
||||
}).toEqual({ 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)
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: 718f52cfce426009
|
||||
zh: e8282e2e3713d242
|
||||
/deepseek-ai-dsh-client-ui-dockkit/embedding-it:
|
||||
en: dbc69843ab81c56e
|
||||
zh: 372b6fd3e19e0124
|
||||
en: de13878a3857d0f7
|
||||
zh: 4e760fb2a54b7873
|
||||
/deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping:
|
||||
en: cf2d00ce2b7d5719
|
||||
zh: 5510420f8872e23a
|
||||
|
||||
@@ -64,9 +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 move follows the pointer one to one inside the viewport and resists past an edge, drawing at most 120px beyond it; its release records one `moveFloat` with the nearest legal rectangle, then springs the panel there from where it was drawn, carrying the release velocity and overshooting slightly inward. A press during that return picks the panel up where it is drawn. A resize keeps the corner inside the viewport. Under `prefers-reduced-motion: reduce` a move clamps at the edge and nothing animates.
|
||||
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 during that return picks the panel up where it is drawn. A resize keeps the corner inside the viewport. At rest the stylesheet clamps every panel 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
|
||||
|
||||
@@ -64,9 +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`,再让浮窗从绘制位置以弹簧回到该矩形,携带松手速度并略微向内过冲。回弹过程中按下会从绘制位置接住浮窗。调整大小时右下角保持在视口内。在 `prefers-reduced-motion: reduce` 下,移动在边界处钳制且不播放动画。
|
||||
浮窗始终留在视口内。每个浮窗绘制在其存储矩形投影到视口后的位置,因此窗口缩小时浮窗被推入且不记录操作,窗口变大时浮窗朝存储位置回移;大于视口的浮窗按视口尺寸绘制。移动时浮窗在视口内一比一跟随指针,越过任一边界(包括顶部)后受到阻力,最多越过边界 120px;松手时记录一次带最近合法矩形的 `moveFloat`,若该矩形就是按下时的合法矩形则只提升浮窗,再让浮窗从绘制位置以弹簧回到该矩形,携带绘制速度、不超出这 120px,并略微向内过冲。回弹过程中按下会从绘制位置接住浮窗。调整大小时右下角保持在视口内。静止时样式表会再次把每个浮窗钳制在视口内,因此即使视口读数过期,浮窗仍在视口内。在 `prefers-reduced-motion: reduce` 下,移动在边界处钳制且不播放动画。
|
||||
|
||||
<a id="interaction-rules-worth-keeping"></a>
|
||||
## 值得保留的交互规则
|
||||
|
||||
@@ -14,17 +14,18 @@
|
||||
* and nothing here assumes the docked tree is an ancestor or even present.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode } 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 {
|
||||
boundedResize, legalFloatRect, movedRect, resistedRect, resizedRect, unresistedRect, type FloatBounds,
|
||||
boundedResize, FLOAT_RESISTANCE, legalFloatRect, movedRect, resistedRect, resizedRect, unresistedRect, type FloatBounds,
|
||||
} from '../engine/geometry.ts'
|
||||
import {
|
||||
SPRING_DEADLINE_MS, SPRING_MAX_FRAME_MS, SPRING_MAX_VELOCITY, SPRING_STALL_MS, springSettled, stepSpring, type SpringAxis,
|
||||
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'
|
||||
@@ -88,20 +89,30 @@ function reducedMotion(): boolean {
|
||||
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
/** How much recent pointer history the release velocity is measured over. */
|
||||
/** How much recent drawing history the release velocity is measured over. */
|
||||
const VELOCITY_WINDOW_MS = 100
|
||||
|
||||
/** One pointer position and when it was read. */
|
||||
interface PointerSample {
|
||||
/** One drawn panel position and when it was drawn. */
|
||||
interface DrawnSample {
|
||||
readonly t: number
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
/** Pointer velocity over the last `VELOCITY_WINDOW_MS`, capped per axis, in pixels per second. */
|
||||
function releaseVelocity(samples: readonly PointerSample[]): { readonly x: number; readonly y: number } {
|
||||
const last = samples[samples.length - 1] as PointerSample
|
||||
const first = samples.find(sample => last.t - sample.t <= VELOCITY_WINDOW_MS) as PointerSample
|
||||
/** 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))
|
||||
@@ -119,6 +130,25 @@ interface Settling {
|
||||
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
|
||||
@@ -132,6 +162,11 @@ interface Settling {
|
||||
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) })
|
||||
|
||||
@@ -147,7 +182,8 @@ export function useFloatGestures(state: LayoutState, intents: DockIntents) {
|
||||
useEffect(() => {
|
||||
const resize = (): void => {
|
||||
if (cancelSettling()) setPreview(undefined)
|
||||
setBounds(floatBounds())
|
||||
area.current = floatBounds()
|
||||
setBounds(area.current)
|
||||
}
|
||||
window.addEventListener('resize', resize)
|
||||
return () => {
|
||||
@@ -156,13 +192,21 @@ export function useFloatGestures(state: LayoutState, intents: DockIntents) {
|
||||
}
|
||||
}, [cancelSettling])
|
||||
|
||||
/** One animation frame of a return: advance the spring, or end it at its target. */
|
||||
/**
|
||||
* 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
|
||||
if (gap <= SPRING_STALL_MS && now - running.started < SPRING_DEADLINE_MS) {
|
||||
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)
|
||||
@@ -176,10 +220,10 @@ export function useFloatGestures(state: LayoutState, intents: DockIntents) {
|
||||
setPreview(undefined)
|
||||
}
|
||||
|
||||
/** Spring a panel from where it was drawn to its recorded rectangle; a legal axis keeps still. */
|
||||
/** 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: position === goal ? 0 : speed })
|
||||
({ position, velocity: launchVelocity(position, goal, speed, FLOAT_RESISTANCE) })
|
||||
const now = performance.now()
|
||||
settling.current = {
|
||||
paneId, target, started: now, last: now,
|
||||
@@ -205,40 +249,47 @@ export function useFloatGestures(state: LayoutState, intents: DockIntents) {
|
||||
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 moved nothing is a click. */
|
||||
/**
|
||||
* 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()
|
||||
// A press during a return takes the panel from where it is drawn.
|
||||
// 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
|
||||
cancelSettling()
|
||||
const area = floatBounds()
|
||||
if (cancelSettling() && drawn === undefined) setPreview(undefined)
|
||||
area.current = floatBounds()
|
||||
const reduce = reducedMotion()
|
||||
const stored = floatRect(getPane(state, paneId))
|
||||
const shown = drawn ?? legalFloatRect(stored, area)
|
||||
const rect = mode === 'move' && !reduce ? unresistedRect(shown, area) : legalFloatRect(shown, area)
|
||||
const shown = drawn ?? legalFloatRect(floatRect(getPane(state, paneId)), area.current)
|
||||
const rect = mode === 'move' && !reduce ? unresistedRect(shown, area.current) : legalFloatRect(shown, area.current)
|
||||
const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect }
|
||||
const samples: PointerSample[] = [{ t: performance.now(), x: event.clientX, y: event.clientY }]
|
||||
/** 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') {
|
||||
const resized = boundedResize(desired, area, FLOAT_MIN_SIZE)
|
||||
const resized = boundedResize(desired, area.current, FLOAT_MIN_SIZE)
|
||||
return { drawn: resized, legal: resized }
|
||||
}
|
||||
const legal = legalFloatRect(desired, area)
|
||||
return { drawn: reduce ? legal : resistedRect(desired, area), 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) => {
|
||||
samples.push({ t: performance.now(), x: moved.clientX, y: moved.clientY })
|
||||
setPreview({ paneId, rect: reached(moved.clientX, moved.clientY).drawn })
|
||||
const { drawn } = reached(moved.clientX, moved.clientY)
|
||||
sampleDrawn(samples, performance.now(), drawn)
|
||||
setPreview({ paneId, rect: drawn })
|
||||
},
|
||||
up: (released) => {
|
||||
samples.push({ t: performance.now(), x: released.clientX, y: released.clientY })
|
||||
const { drawn, legal } = reached(released.clientX, released.clientY)
|
||||
if (sameRect(legal, stored)) raise(paneId)
|
||||
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))
|
||||
@@ -311,10 +362,7 @@ 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) }}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { PaneDropHints, TabPanel, TabStrip } from './TabPanel.tsx'
|
||||
import css from './dockkit.module.css'
|
||||
|
||||
@@ -89,9 +89,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}>
|
||||
|
||||
@@ -785,6 +785,14 @@
|
||||
|
||||
.float {
|
||||
position: fixed;
|
||||
/* A resting panel publishes its rectangle in these variables (`floatStyle`).
|
||||
The clamp keeps it inside the viewport and below the caption floor even
|
||||
when the gesture hook read a stale viewport; 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), 100vh - var(--dsh-dockkit-float-height)));
|
||||
width: var(--dsh-dockkit-float-width);
|
||||
height: var(--dsh-dockkit-float-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* The body inside is unpadded and reaches the edges, so the frame clips it
|
||||
|
||||
@@ -219,23 +219,27 @@ export interface FloatBounds {
|
||||
/** 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]`; a span too small for the panel pins it at `min`. */
|
||||
/** 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 pins its leading edge, so its header stays reachable.
|
||||
* 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; the size is unchanged.
|
||||
* @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 {
|
||||
...rect,
|
||||
x: clampAxis(rect.x, 0, bounds.width - rect.width),
|
||||
y: clampAxis(rect.y, bounds.top, bounds.height - rect.height),
|
||||
x: clampAxis(rect.x, 0, bounds.width - width),
|
||||
y: clampAxis(rect.y, bounds.top, bounds.height - height),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +263,11 @@ export function resistedRect(desired: FloatRect, bounds: FloatBounds): FloatRect
|
||||
/** 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`. */
|
||||
/**
|
||||
* 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)
|
||||
|
||||
@@ -49,6 +49,24 @@ export function stepSpring(axis: SpringAxis, target: number, dtSeconds: number):
|
||||
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.
|
||||
|
||||
@@ -31,6 +31,14 @@ 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 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), 100vh - var(--dsh-dockkit-float-height)));',
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
@@ -1518,7 +1518,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 +1526,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')
|
||||
})
|
||||
|
||||
|
||||
@@ -32,9 +32,13 @@ describe('legalFloatRect', () => {
|
||||
expect(legalFloatRect({ ...PANEL, y: 10 }, { ...VIEWPORT, top: 60 }).y).toBe(60)
|
||||
})
|
||||
|
||||
it('pins the leading edge of a panel larger than the viewport, so its header stays reachable', () => {
|
||||
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({ ...big, x: 0, y: 20 })
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -70,19 +70,24 @@ function mounted(rect: FloatRect = { x: 100, y: 80, width: 300, height: 200 }) {
|
||||
view.rerender(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
|
||||
}
|
||||
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: 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={() => null} />)
|
||||
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
|
||||
const left = (): number => Number.parseFloat(panel().style.left)
|
||||
const top = (): number => Number.parseFloat(/(-?[\d.]+)px\)$/.exec(panel().style.top)?.[1] ?? 'NaN')
|
||||
// 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))
|
||||
return { controller, paneId, view, moveFloat, focusPane, left, top, grip, stored }
|
||||
const corner = (): HTMLElement => panel().querySelector<HTMLElement>('[data-dockkit-float-resize]') as HTMLElement
|
||||
return { controller, tabId, paneId, view, redraw, moveFloat, resizeFloat, focusPane, left, top, grip, corner, stored }
|
||||
}
|
||||
|
||||
/** Press the grip at `from`, move to `to`, and optionally release there. */
|
||||
@@ -141,6 +146,36 @@ describe('dragging past an edge', () => {
|
||||
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])
|
||||
@@ -205,6 +240,47 @@ describe('interrupting a return', () => {
|
||||
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('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])
|
||||
@@ -255,6 +331,30 @@ describe('the viewport projection', () => {
|
||||
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('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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* independence, and determinism, all with fixed frame steps.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SPRING_DEADLINE_MS, springSettled, stepSpring } from '../src/engine/spring.ts'
|
||||
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)). */
|
||||
@@ -54,6 +54,31 @@ describe('stepSpring', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user