diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml new file mode 100644 index 0000000000..dad8343dcf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md new file mode 100644 index 0000000000..a4e9ca4e25 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md new file mode 100644 index 0000000000..6ec92d9326 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-21-floating-window-edge-recovery.zh.md @@ -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 事件可用,但仍未调优。 diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 79fc706ec9..307e1adf12 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -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) diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 3dafc30f52..6bff57dde1 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index bc29b54c08..12f5b74938 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -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. ## Interaction rules worth keeping diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 15c5d332d9..06b1a593f3 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -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` 下,移动在边界处钳制且不播放动画。 ## 值得保留的交互规则 diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index dfe028993b..acbce6e88a 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -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(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): 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 (
{ raise(paneId) }} > -
{renderTab(tab)}
+
{ 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 (