dsh-sidebar-drawer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +82 -0
- package/README.md +95 -0
- package/cordis.patch.yml +7 -0
- package/docs/drawer-demo.gif +0 -0
- package/lib/client.js +791 -0
- package/lib/index.js +10 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 1dustycy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# dsh-sidebar-drawer
|
|
2
|
+
|
|
3
|
+
English | [简体中文](README.md)
|
|
4
|
+
|
|
5
|
+
An edge-hover drawer for the left sidebar: while the sidebar is collapsed, moving the pointer into **the leftmost 10 px of the screen and holding it there for 500 ms** pulls the sidebar out; it stays out the whole time the pointer remains inside the drawer, and retracts by itself once the pointer leaves.
|
|
6
|
+
|
|
7
|
+
A pure browser-behavior plugin (the host half is empty). It only borrows DSH's own sidebar toggling and changes no built-in file.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## What it does
|
|
12
|
+
|
|
13
|
+
- **Dwelling is what counts** — the pointer must stay inside the leftmost 10 px for 500 ms before anything happens; sweeping past the edge never triggers it by accident.
|
|
14
|
+
- **Inside the drawer means staying** — while the pointer rests inside the drawer (reading, clicking, idling) the drawer stays out and nothing rushes you.
|
|
15
|
+
- **No interrupting an animation** — a second toggle is never issued while an open/close transition is in flight, so the drawer neither jumps nor vanishes mid-flight.
|
|
16
|
+
- **Slides, not jumps** — the shipped frame writes the column width first and publishes its transition marker afterwards, so a plain toggle is instantaneous (a retraction looks like the drawer simply disappearing); before requesting a toggle the plugin pre-arms the frame's own marker, so both the reveal and the retraction really slide.
|
|
17
|
+
- **A hand-opened sidebar is left alone** — a sidebar you opened yourself with the button, Cmd+B, or a width drag is never touched and never retracted.
|
|
18
|
+
- **Yields** — width drags, window blur, and page scroll each have their own yield rule.
|
|
19
|
+
|
|
20
|
+
Row-by-row scenarios, state-machine invariants, and known gaps: **[docs/behavior.md](docs/behavior.md)** (Chinese).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
This is a DSH **plugin bundle**: it ships its own `cordis.patch.yml` and declares `dsh.bundle.patch` in `package.json`, so Plugin Manager installs it and owns the mount row — **you never hand-edit the profile's patch file**.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# From npm (recommended: pins the version)
|
|
28
|
+
dsh plugin --profile desktop add dsh-sidebar-drawer@0.1.0
|
|
29
|
+
|
|
30
|
+
# From GitHub (also version-pinned, through the repo's v0.1.0 tag)
|
|
31
|
+
dsh plugin --profile desktop add "github:1dustycy/dsh-sidebar-drawer#v0.1.0"
|
|
32
|
+
|
|
33
|
+
# Track the latest commit on main (to pick up behavior changes early)
|
|
34
|
+
dsh plugin --profile desktop add github:1dustycy/dsh-sidebar-drawer
|
|
35
|
+
|
|
36
|
+
# From a local directory (for development: writes a link: dependency, edits take effect immediately)
|
|
37
|
+
dsh plugin --profile desktop add /path/to/dsh-sidebar-drawer
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Replace `desktop` with the profile you want (e.g. `web`, `tui`). The command does two things: it adds the package to `dependencies` in `~/.dsh/profiles/<name>/package.json`, and adds `dsh-sidebar-drawer` to that profile's `dsh.profile.bundles`. Once installed, **refresh the page** and it is live — no app restart needed (the client half is hot-loaded through the bundle's mtime polling plus `/plugins/events`).
|
|
41
|
+
|
|
42
|
+
Uninstall:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
dsh plugin --profile desktop remove dsh-sidebar-drawer
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> Why not just add a row to the profile's `cordis.patch.yml`? That file is shared with other sessions, and when it gets overwritten the symptom is a plugin that looks installed but does nothing — with nothing in the plugin itself to show for it. Rationale in [ADR-0003](docs/adr/0003-bundle-patch-ownership.md).
|
|
49
|
+
|
|
50
|
+
**Requirements**: DSH Desktop (or any profile with a Web GUI). The client half injects `@deepseek-ai/dsh-client-ui-layout`, so the target profile must already mount the Web layout bundle. `peerDependencies` is `@deepseek-ai/cordis >=4.0.4 <5` — when installing from a registry, Plugin Manager checks that range first and refuses before anything is downloaded if it does not match.
|
|
51
|
+
|
|
52
|
+
## Development and verification
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node test/client.test.mjs # unit suite: vm + DOM doubles + an advancing virtual clock, no browser needed
|
|
56
|
+
node test/browser.test.mjs # browser suite: real Chromium + CDP real mouse events and real CSS animations
|
|
57
|
+
npm test # runs both suites, in that order
|
|
58
|
+
node tools/probe.mjs # diagnostic tool: prints the decision trail of one edge hover
|
|
59
|
+
npm pack --dry-run # confirm what gets published
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
After editing `lib/client.js`, **refresh the page** and the change is live (provided the profile's `hmr` row is enabled, which the desktop profile does by default).
|
|
63
|
+
|
|
64
|
+
`test/browser.test.mjs` brings its own HTTP server and browser process and exits when it is done; it needs Chromium on the machine (it skips itself when none is found). `SHOT=<path> node test/browser.test.mjs` saves the final "drawer open" frame as a PNG (that is where `docs/harness-open.png` comes from). The animation at the top is the other way round — a **screen recording of the real GUI**, not the fixture — and `node tools/demo-gif.mjs <recording>` regenerates it, which needs ffmpeg on the machine. `test/harness.html` is a faithful replica of the shipped three-column frame: open it in a browser with `?plugin=<URL of client.js>` to try the behavior by hand.
|
|
65
|
+
|
|
66
|
+
> **Read [docs/implementation.md](docs/implementation.md#测试夹具必须与出厂契约同步) before touching the test fixtures.**
|
|
67
|
+
> Both fixtures must model the shipped shell's anchor contract exactly: once a fixture renders a conditional anchor unconditionally, it goes all-green on a broken bundle — which is precisely how this plugin once shipped a defect where a pointer resting motionless on the edge toggled the drawer forever.
|
|
68
|
+
|
|
69
|
+
## Docs
|
|
70
|
+
|
|
71
|
+
| Document | Contents |
|
|
72
|
+
|---|---|
|
|
73
|
+
| [docs/behavior.md](docs/behavior.md) | Behavior spec: row-by-row scenarios, state-machine invariants, known gaps |
|
|
74
|
+
| [docs/implementation.md](docs/implementation.md) | Implementation notes: shipped anchor contract, decision logic, tunable constants |
|
|
75
|
+
| [docs/adr/](docs/adr/) | Decision records (why it is built this way) |
|
|
76
|
+
| [CONTEXT.md](CONTEXT.md) | Glossary: Chinese wording mapped to code identifiers |
|
|
77
|
+
|
|
78
|
+
> The detailed docs are Chinese-only for now; this file is the English entry point.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# dsh-sidebar-drawer
|
|
2
|
+
|
|
3
|
+
[English](README.en.md) | 简体中文
|
|
4
|
+
|
|
5
|
+
左侧栏边缘抽屉:侧栏收起时,把鼠标移到**屏幕最左边 10px 内并停留 500ms** 就会抽出侧栏;鼠标停在抽屉里期间一直保持抽出;鼠标离开抽屉后自动收回。
|
|
6
|
+
|
|
7
|
+
一个纯浏览器行为插件(Host 半边是空的),只借用 DSH 自己的侧栏开合,不改动任何内置文件。
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## 它会做什么
|
|
12
|
+
|
|
13
|
+
- **停留才算数** —— 指针要在左侧 10px 内连续待够 500ms 才抽出;扫过边缘不会误触。
|
|
14
|
+
- **在抽屉里就保持** —— 指针停在抽屉内(阅读、点击、停留)期间一直抽出,不催你。
|
|
15
|
+
- **动画期不打断** —— 开合过渡进行中绝不发第二次开合,所以不会跳变,也不会突然消失。
|
|
16
|
+
- **滑动而不是跳变** —— 出厂 frame 是"先写列宽、后发布过渡标记",直接开合会瞬变(收回看起来
|
|
17
|
+
就是一下子消失);插件在请求开合前先把框架自己的标记预置上,所以抽出与收回都真的滑动。
|
|
18
|
+
- **手开的侧栏不碰** —— 你用按钮、Cmd+B 或拖拽调宽开出来的侧栏(手开),插件完全不碰、从不收回。
|
|
19
|
+
- **让路** —— 拖拽列宽、窗口失焦、页面滚动都有对应的让路逻辑。
|
|
20
|
+
|
|
21
|
+
逐条场景、状态机不变量与已知缺口见 **[docs/behavior.md](docs/behavior.md)**。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
这是一个 DSH **plugin bundle**:包内自带 `cordis.patch.yml`,`package.json` 里声明了
|
|
26
|
+
`dsh.bundle.patch`,所以它由 Plugin Manager 安装并托管挂载行 —— **不需要你手改 profile 的补丁文件**。
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 从 npm 安装(推荐,带版本钉住)
|
|
30
|
+
dsh plugin --profile desktop add dsh-sidebar-drawer@0.1.0
|
|
31
|
+
|
|
32
|
+
# 从 GitHub 安装(同样钉住版本,走仓库的 v0.1.0 tag)
|
|
33
|
+
dsh plugin --profile desktop add "github:1dustycy/dsh-sidebar-drawer#v0.1.0"
|
|
34
|
+
|
|
35
|
+
# 跟随 main 最新提交(便于拿到最新行为改动)
|
|
36
|
+
dsh plugin --profile desktop add github:1dustycy/dsh-sidebar-drawer
|
|
37
|
+
|
|
38
|
+
# 从本地目录安装(开发用:写入 link: 依赖,改动即时生效)
|
|
39
|
+
dsh plugin --profile desktop add /path/to/dsh-sidebar-drawer
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
把 `desktop` 换成你要装的 profile 名(如 `web`、`tui`)。这条命令做两件事:把包装进
|
|
43
|
+
`~/.dsh/profiles/<name>/package.json` 的 `dependencies`,并把 `dsh-sidebar-drawer` 加进该 profile 的
|
|
44
|
+
`dsh.profile.bundles`。装完后**刷新页面**即可生效,不需要重启 App(客户端半边由 bundle 的 mtime
|
|
45
|
+
轮询 + `/plugins/events` 热加载)。
|
|
46
|
+
|
|
47
|
+
卸载:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
dsh plugin --profile desktop remove dsh-sidebar-drawer
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> 为什么不直接往 profile 的 `cordis.patch.yml` 里加一行?那是与其他会话共享的文件,被覆盖时
|
|
54
|
+
> 症状是插件"装了但没生效",且从插件自身看不出任何异常。理由见
|
|
55
|
+
> [ADR-0003](docs/adr/0003-bundle-patch-ownership.md)。
|
|
56
|
+
|
|
57
|
+
**要求**:DSH Desktop(或任何带 Web GUI 的 profile)。客户端半边注入
|
|
58
|
+
`@deepseek-ai/dsh-client-ui-layout`,所以目标 profile 必须已挂载 Web 布局 bundle。
|
|
59
|
+
`peerDependencies` 为 `@deepseek-ai/cordis >=4.0.4 <5` —— 从 registry 安装时 Plugin Manager 会先
|
|
60
|
+
核对这一条,不兼容就在下载之前拒绝。
|
|
61
|
+
|
|
62
|
+
## 开发与验证
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
node test/client.test.mjs # 单元套件:vm + DOM 替身 + 会推进的虚拟时钟,不需要浏览器
|
|
66
|
+
node test/browser.test.mjs # 浏览器套件:真实 Chromium + CDP 真实鼠标事件与真实 CSS 动画
|
|
67
|
+
npm test # 依次跑上面两套
|
|
68
|
+
node tools/probe.mjs # 诊断工具:打印一次边缘悬停的决策轨迹
|
|
69
|
+
npm pack --dry-run # 确认发布内容
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
改 `lib/client.js` 后**刷新页面**即可生效(前提是 profile 的 `hmr` 行处于启用状态,桌面 profile 默认启用)。
|
|
73
|
+
|
|
74
|
+
`test/browser.test.mjs` 自带 HTTP 服务与浏览器进程,跑完即退出;需要本机有 Chromium(找不到会自动跳过)。
|
|
75
|
+
`SHOT=<路径> node test/browser.test.mjs` 会把最后"抽屉打开"的画面存成 PNG(`docs/harness-open.png`
|
|
76
|
+
即由此生成)。顶部那张动图反过来 —— 它是**真实 GUI 的屏幕录制**,不是夹具 —— 用
|
|
77
|
+
`node tools/demo-gif.mjs <录屏文件>` 重新生成,需要本机有 ffmpeg。`test/harness.html` 是内置三栏
|
|
78
|
+
框架的等价复刻,用浏览器直接打开并加 `?plugin=<client.js 的 URL>` 也能手工试。
|
|
79
|
+
|
|
80
|
+
> **改测试夹具前请先读** [docs/implementation.md](docs/implementation.md#测试夹具必须与出厂契约同步)。
|
|
81
|
+
> 两套夹具必须精确建模出厂 shell 的锚点契约:夹具一旦把条件锚点写成常驻,就会在一个已损坏的
|
|
82
|
+
> bundle 上全绿 —— 本插件正是这样发布过一个"指针停在边缘不动就无限开合"的缺陷。
|
|
83
|
+
|
|
84
|
+
## 文档
|
|
85
|
+
|
|
86
|
+
| 文档 | 内容 |
|
|
87
|
+
|---|---|
|
|
88
|
+
| [docs/behavior.md](docs/behavior.md) | 行为规格:逐条场景、状态机不变量、已知缺口 |
|
|
89
|
+
| [docs/implementation.md](docs/implementation.md) | 实现要点:出厂锚点契约、判定逻辑、可调参数 |
|
|
90
|
+
| [docs/adr/](docs/adr/) | 决策记录(为什么这样做) |
|
|
91
|
+
| [CONTEXT.md](CONTEXT.md) | 术语表:中文措辞与代码标识符的对应 |
|
|
92
|
+
|
|
93
|
+
## 许可
|
|
94
|
+
|
|
95
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# The bundle layer this package contributes to a profile: one row mounting the
|
|
2
|
+
# plugin itself. Kept as a bundle patch (rather than an entry in the profile's
|
|
3
|
+
# own cordis.patch.yml) so Plugin Manager owns the row across installs,
|
|
4
|
+
# updates, and other writes to the profile's user layer.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: ui-sidebar-drawer
|
|
7
|
+
name: dsh-sidebar-drawer
|
|
Binary file
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-sidebar-drawer",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Edge-hover drawer for the left sidebar.
|
|
10
|
+
*
|
|
11
|
+
* The sidebar keeps its shipped open/close semantics; this behavior only
|
|
12
|
+
* drives `toggleSidebar()` from pointer position:
|
|
13
|
+
*
|
|
14
|
+
* - the pointer entering the left edge strip while the sidebar is closed
|
|
15
|
+
* opens it (hover-to-reveal);
|
|
16
|
+
* - the pointer inside the revealed drawer keeps it open, so the user can
|
|
17
|
+
* read and click without hurry (each move is checked against the drawer's
|
|
18
|
+
* live box, and every "outside" verdict is confirmed by a second check
|
|
19
|
+
* after the grace delay, which also absorbs the open animation);
|
|
20
|
+
* - the pointer leaving the drawer retracts it, but only when this
|
|
21
|
+
* behavior is the one that opened it — a sidebar the user opened by
|
|
22
|
+
* hand (button, shortcut, width drag, or `narrowExpanded`) is left alone.
|
|
23
|
+
*
|
|
24
|
+
* Every toggle it issues is preceded by arming the frame's own
|
|
25
|
+
* `data-animating` marker, because that marker is what puts the shell's
|
|
26
|
+
* column transition in effect — and the shell publishes it one pass too
|
|
27
|
+
* late for its own write to be covered by it (see {@link armFrameMarker}).
|
|
28
|
+
*
|
|
29
|
+
* The drawer's geometry is read from the live DOM instead of the layout
|
|
30
|
+
* store, so width drags, the collapsed rail, an open right panel, and the
|
|
31
|
+
* frame's open/close animation are all honored without mirroring layout
|
|
32
|
+
* math.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Width of the left screen-edge strip a pointer must rest in to reveal the drawer. */
|
|
36
|
+
const EDGE_SIZE = 10;
|
|
37
|
+
/**
|
|
38
|
+
* How long the pointer must stay in that strip. Revealing on contact alone made a
|
|
39
|
+
* pointer merely crossing the edge — on its way to the content, or flicked there —
|
|
40
|
+
* throw the drawer open; the dwell makes the reveal deliberate, and leaves the
|
|
41
|
+
* strip before it elapses.
|
|
42
|
+
*/
|
|
43
|
+
const DWELL_MS = 500;
|
|
44
|
+
/** Grace before a pointer found outside the drawer retracts it. */
|
|
45
|
+
const CLOSE_DELAY_MS = 260;
|
|
46
|
+
/** Slack allowed past the drawer's outer edge, in px. */
|
|
47
|
+
const POINTER_MARGIN = 4;
|
|
48
|
+
/**
|
|
49
|
+
* Selector of the sidebar's column drag handle, treated as part of the drawer.
|
|
50
|
+
* The shipped handle publishes its side and is mounted only while the sidebar is
|
|
51
|
+
* open, so this deliberately matches nothing in the collapsed state — where the
|
|
52
|
+
* collapsed column is the reveal strip itself and must stay unclaimed.
|
|
53
|
+
*/
|
|
54
|
+
const HANDLE_SELECTOR = '[data-side="sidebar"]';
|
|
55
|
+
/**
|
|
56
|
+
* How far past the reveal strip a pointer must travel before leaning back to it
|
|
57
|
+
* counts as leaving. The drawer grows over one animation from 0 to its full
|
|
58
|
+
* width, so "outside the box" is not yet a verdict while it is still growing:
|
|
59
|
+
* a pointer that stepped a little way in is waiting for the drawer to reach it,
|
|
60
|
+
* and retracting there would make the reveal feel like it snaps shut.
|
|
61
|
+
*/
|
|
62
|
+
const RETAIN_MARGIN = 48;
|
|
63
|
+
/** Width at which a column counts as a settled open drawer rather than a moving edge. */
|
|
64
|
+
const OPEN_SETTLED_WIDTH = 56;
|
|
65
|
+
/**
|
|
66
|
+
* How often a deferred retraction re-checks whether the shell is still animating.
|
|
67
|
+
* The frame publishes its own `data-animating` marker around a discrete column
|
|
68
|
+
* change (and clears it on `transitionend`, self-capped at 600ms), so the
|
|
69
|
+
* retraction polls that marker instead of assuming any duration — a slower
|
|
70
|
+
* animation simply defers longer. Note a viewport-driven collapse publishes no
|
|
71
|
+
* marker at all, so its absence must never be read as "a transition just ended".
|
|
72
|
+
*/
|
|
73
|
+
const ANIMATION_POLL_MS = 120;
|
|
74
|
+
/**
|
|
75
|
+
* Ceiling on that polling, in case the marker is never cleared. The shell caps its
|
|
76
|
+
* own marker at 600ms, so this sits deliberately far above any real transition:
|
|
77
|
+
* the poll, not this bound, is what ends the wait.
|
|
78
|
+
*/
|
|
79
|
+
const ANIMATION_CEILING_MS = 2500;
|
|
80
|
+
/** Absolute cap on a deferred retraction, whatever the markers claim. */
|
|
81
|
+
const ANIMATION_HARD_STOP_MS = 3500;
|
|
82
|
+
/** Marker set on the document element while this behavior is mounted. */
|
|
83
|
+
const MARK = "dsh-sidebar-drawer";
|
|
84
|
+
/** The frame publishes this marker while a column transition is running. */
|
|
85
|
+
const ANIMATING_ATTR = "data-animating";
|
|
86
|
+
/**
|
|
87
|
+
* Value written into that marker when this behavior arms the frame ahead of a
|
|
88
|
+
* toggle. The shell writes `"true"`, and a value of its own is what lets the
|
|
89
|
+
* withdrawal below tell a marker this behavior armed from one the shell owns —
|
|
90
|
+
* a distinction that keeps the withdrawal from cutting a transition the shell
|
|
91
|
+
* is running short (dropping the transition property mid-flight snaps the
|
|
92
|
+
* column to its target).
|
|
93
|
+
*/
|
|
94
|
+
const PREARMED = "drawer";
|
|
95
|
+
/** The frame publishes this marker while the sidebar is closed. */
|
|
96
|
+
const COLLAPSED_ATTR = "data-sidebar-collapsed";
|
|
97
|
+
/** The frame publishes this marker while the user drags a column handle. */
|
|
98
|
+
const DRAGGING_ATTR = "data-dragging";
|
|
99
|
+
/**
|
|
100
|
+
* The frame's window-chrome seat. Mounted only while the sidebar is collapsed
|
|
101
|
+
* (the shipped shell gates it on `darwin && sidebarCollapsed`), so it is a
|
|
102
|
+
* fallback anchor and never the one that always exists.
|
|
103
|
+
*/
|
|
104
|
+
const LEADING_ATTR = "data-shell-leading";
|
|
105
|
+
/**
|
|
106
|
+
* The frame's overlay layer: an unconditional child of the frame, and one of only
|
|
107
|
+
* two frame markers the shipped shell renders in every state (the rightbar column
|
|
108
|
+
* carries the other). It is the one that identifies the frame itself, so the frame
|
|
109
|
+
* lookup tries it first. See `docs/adr/0001-frame-anchor-contract.md`.
|
|
110
|
+
*/
|
|
111
|
+
const OVERLAY_ATTR = "data-shell-overlay";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve the frame element that carries the layout markers.
|
|
115
|
+
*
|
|
116
|
+
* The overlay layer is asked first because it is the only anchor the shipped
|
|
117
|
+
* shell renders unconditionally. Every other anchor here is conditional:
|
|
118
|
+
* `data-sidebar-collapsed` and `data-shell-leading` both leave the document the
|
|
119
|
+
* moment the sidebar opens, so anchoring on them alone loses the frame in
|
|
120
|
+
* exactly the state the drawer is used in — which reads as "the pointer is not
|
|
121
|
+
* over the drawer" and retracts it on every check. See
|
|
122
|
+
* `docs/adr/0001-frame-anchor-contract.md`.
|
|
123
|
+
* @param doc - document to query.
|
|
124
|
+
* @returns the frame element, or null while the shell is not mounted.
|
|
125
|
+
*/
|
|
126
|
+
function frameOf(doc) {
|
|
127
|
+
const overlay = doc.querySelector("[" + OVERLAY_ATTR + "]");
|
|
128
|
+
const overlayFrame = overlay !== null ? overlay.parentElement : null;
|
|
129
|
+
if (overlayFrame !== null) return overlayFrame;
|
|
130
|
+
const collapsed = doc.querySelector("[" + COLLAPSED_ATTR + "]");
|
|
131
|
+
if (collapsed !== null) return collapsed;
|
|
132
|
+
const leading = doc.querySelector("[" + LEADING_ATTR + "]");
|
|
133
|
+
const seatAnchored = leading !== null ? leading.parentElement : null;
|
|
134
|
+
if (seatAnchored !== null) return seatAnchored;
|
|
135
|
+
const column = doc.querySelector("[data-sidebar-col]");
|
|
136
|
+
return column !== null ? column.parentElement : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The frame's first grid child: the shipped sidebar column.
|
|
141
|
+
*
|
|
142
|
+
* This holds only because the shipped frame's first entry in its children array,
|
|
143
|
+
* `DocumentTitle`, renders `null` and therefore creates no element. A real element
|
|
144
|
+
* placed ahead of the sidebar column would silently make this measure the wrong
|
|
145
|
+
* node, so re-check it against the shell before trusting the box.
|
|
146
|
+
*/
|
|
147
|
+
function columnOf(frame) {
|
|
148
|
+
return frame === null ? null : frame.firstElementChild;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the frame reports a closed sidebar.
|
|
153
|
+
* @param frame - frame element, or null.
|
|
154
|
+
* @returns true while the sidebar is collapsed.
|
|
155
|
+
*/
|
|
156
|
+
function isCollapsed(frame) {
|
|
157
|
+
return frame !== null && frame.getAttribute(COLLAPSED_ATTR) === "true";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whether a column drag is in flight (a drag must never be interrupted).
|
|
162
|
+
* @param frame - frame element, or null.
|
|
163
|
+
* @returns true while the frame reports dragging.
|
|
164
|
+
*/
|
|
165
|
+
function isDragging(frame) {
|
|
166
|
+
return frame !== null && frame.hasAttribute(DRAGGING_ATTR);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Mount the edge-hover drawer behavior.
|
|
171
|
+
* @param deps - injectable document, window, and layout service (tests pass doubles).
|
|
172
|
+
* @returns a disposer removing every listener, timer, and DOM mark.
|
|
173
|
+
*/
|
|
174
|
+
function install(deps) {
|
|
175
|
+
const doc = deps.document;
|
|
176
|
+
const win = deps.window;
|
|
177
|
+
const layout = deps.layout;
|
|
178
|
+
|
|
179
|
+
/** True while this behavior opened the drawer and no other actor has touched it. */
|
|
180
|
+
let weOpened = false;
|
|
181
|
+
/** Live handle of the pending close, or null. */
|
|
182
|
+
let closeTimer = null;
|
|
183
|
+
/** Last pointer position seen by a move event; -1 while no sample is live. */
|
|
184
|
+
let lastX = -1;
|
|
185
|
+
let lastY = -1;
|
|
186
|
+
/** False once a leave event reports the pointer gone; re-judged from the sample. */
|
|
187
|
+
let pointerLeftWindow = false;
|
|
188
|
+
/** Set while a move is queued, so moves coalesce into one check per frame. */
|
|
189
|
+
let frameQueued = false;
|
|
190
|
+
/**
|
|
191
|
+
* True from the moment this behavior asks to close until that transition has
|
|
192
|
+
* settled. It is what tells a half-grown column apart from a half-shrunk one:
|
|
193
|
+
* both read as "narrow", and only this flag says which way it is going.
|
|
194
|
+
*/
|
|
195
|
+
let retracting = false;
|
|
196
|
+
/** True while the shell's transition is still running for our last toggle. */
|
|
197
|
+
let togglePending = false;
|
|
198
|
+
let pendingTimer = null;
|
|
199
|
+
/** Start of the current toggle's transition, for the ceiling on deferral. */
|
|
200
|
+
let pendingSince = 0;
|
|
201
|
+
/** Set when the pointer waits on the strip through a retraction. */
|
|
202
|
+
let revealNextCheck = false;
|
|
203
|
+
/** Live handle of the pending dwell in the reveal strip, or null. */
|
|
204
|
+
let dwellTimer = null;
|
|
205
|
+
/** Live handle of a reveal waiting out someone else's transition, or null. */
|
|
206
|
+
let revealTimer = null;
|
|
207
|
+
/** Start of the current reveal deferral, or 0 while no intent is waiting. */
|
|
208
|
+
let revealWaitSince = 0;
|
|
209
|
+
/** Live handle of the withdrawal armed for a pre-armed frame marker, or null. */
|
|
210
|
+
let prearmTimer = null;
|
|
211
|
+
let disposed = false;
|
|
212
|
+
|
|
213
|
+
function cancelClose() {
|
|
214
|
+
if (closeTimer === null) return;
|
|
215
|
+
win.clearTimeout(closeTimer);
|
|
216
|
+
closeTimer = null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Cancel a dwell that has not fired yet. */
|
|
220
|
+
function cancelDwell() {
|
|
221
|
+
if (dwellTimer === null) return;
|
|
222
|
+
win.clearTimeout(dwellTimer);
|
|
223
|
+
dwellTimer = null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Cancel the settled-check armed by the last toggle. */
|
|
227
|
+
function cancelPending() {
|
|
228
|
+
if (pendingTimer === null) return;
|
|
229
|
+
win.clearTimeout(pendingTimer);
|
|
230
|
+
pendingTimer = null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Cancel a reveal waiting out someone else's transition. */
|
|
234
|
+
function cancelRevealWait() {
|
|
235
|
+
if (revealTimer === null) return;
|
|
236
|
+
win.clearTimeout(revealTimer);
|
|
237
|
+
revealTimer = null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Stop waiting for a pre-armed frame marker to settle. */
|
|
241
|
+
function cancelPrearm() {
|
|
242
|
+
if (prearmTimer === null) return;
|
|
243
|
+
win.clearTimeout(prearmTimer);
|
|
244
|
+
prearmTimer = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Withdraw a pre-armed marker the shell never claimed, and stop listening for
|
|
249
|
+
* its transition.
|
|
250
|
+
*
|
|
251
|
+
* A marker still carrying {@link PREARMED} means the shell published none of
|
|
252
|
+
* its own — its toggle changed nothing it animates, or a viewport change made
|
|
253
|
+
* it skip the marker deliberately. Leaving it behind would be a lie every
|
|
254
|
+
* later check reads as "a column is moving", so a reveal would be deferred
|
|
255
|
+
* against a transition that is not running. A marker the shell owns is left
|
|
256
|
+
* exactly as it is, whatever brings us here.
|
|
257
|
+
*/
|
|
258
|
+
function withdrawPrearm() {
|
|
259
|
+
cancelPrearm();
|
|
260
|
+
const frame = frameOf(doc);
|
|
261
|
+
if (frame === null) return;
|
|
262
|
+
frame.removeEventListener("transitionend", onFrameSettled);
|
|
263
|
+
if (frame.getAttribute(ANIMATING_ATTR) !== PREARMED) return;
|
|
264
|
+
frame.removeAttribute(ANIMATING_ATTR);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The frame finished a column transition.
|
|
269
|
+
*
|
|
270
|
+
* For a pre-armed marker this is the moment it has done its job: it is the
|
|
271
|
+
* only thing that held the transition open (the shell marked nothing of its
|
|
272
|
+
* own), and withdrawn any earlier it would cut that transition short. Its
|
|
273
|
+
* own descendants' transitions bubble past here too, hence the target check —
|
|
274
|
+
* the shell's own settle does exactly the same.
|
|
275
|
+
* @param event - the frame's `transitionend`.
|
|
276
|
+
*/
|
|
277
|
+
function onFrameSettled(event) {
|
|
278
|
+
if (event.target !== event.currentTarget) return;
|
|
279
|
+
if (event.propertyName !== "grid-template-columns") return;
|
|
280
|
+
withdrawPrearm();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Arm the frame's own transition marker *before* asking for a toggle.
|
|
285
|
+
*
|
|
286
|
+
* The shell writes the new columns first and publishes this marker in a second
|
|
287
|
+
* pass, and the layout read the app performs in between — its tab strip
|
|
288
|
+
* measures itself in a layout effect inside the same commit — makes the browser
|
|
289
|
+
* adopt the new track sizes while no transition is in effect. The frame's
|
|
290
|
+
* transition property is only published by this marker, so the column then
|
|
291
|
+
* snaps: measured in the shipped app, `transitionend` never fires for the
|
|
292
|
+
* column and the marker is left to the shell's own 600ms cap, in both
|
|
293
|
+
* directions. Writing the marker first puts the transition in effect for the
|
|
294
|
+
* write the shell is about to make, which is what makes the track travel
|
|
295
|
+
* instead of jumping. The shell claims the marker as its own (it renders
|
|
296
|
+
* `"true"`) and clears it when that transition ends.
|
|
297
|
+
*
|
|
298
|
+
* A marker already on the frame is left alone: the shell owns it, and a
|
|
299
|
+
* transition already in effect is the whole point.
|
|
300
|
+
*/
|
|
301
|
+
function armFrameMarker() {
|
|
302
|
+
const frame = frameOf(doc);
|
|
303
|
+
if (frame === null || frame.hasAttribute(ANIMATING_ATTR)) return;
|
|
304
|
+
frame.setAttribute(ANIMATING_ATTR, PREARMED);
|
|
305
|
+
frame.addEventListener("transitionend", onFrameSettled);
|
|
306
|
+
cancelPrearm();
|
|
307
|
+
prearmTimer = win.setTimeout(withdrawPrearm, ANIMATION_CEILING_MS);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Mark our toggle as transitioning and settle the state once the shell stops
|
|
312
|
+
* animating. The marker is polled rather than awaited: it is the frame's own
|
|
313
|
+
* `data-animating` attribute, which covers the whole transition and is cleared
|
|
314
|
+
* on `transitionend`, so no duration has to be guessed here.
|
|
315
|
+
*/
|
|
316
|
+
function markToggle() {
|
|
317
|
+
togglePending = true;
|
|
318
|
+
pendingSince = win.performance.now();
|
|
319
|
+
cancelPending();
|
|
320
|
+
const settle = () => {
|
|
321
|
+
const frame = frameOf(doc);
|
|
322
|
+
const animating = frame !== null && frame.hasAttribute(ANIMATING_ATTR);
|
|
323
|
+
if (animating && win.performance.now() - pendingSince < ANIMATION_CEILING_MS) {
|
|
324
|
+
pendingTimer = win.setTimeout(settle, ANIMATION_POLL_MS);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
pendingTimer = null;
|
|
328
|
+
togglePending = false;
|
|
329
|
+
retracting = false;
|
|
330
|
+
/* One more look once the geometry stops moving: a pointer that waited
|
|
331
|
+
on the strip is revealed there, and one that walked off is not. */
|
|
332
|
+
checkPointer();
|
|
333
|
+
/* A close that was deferred for this transition is not forgotten: the
|
|
334
|
+
same verdict is applied again against the settled geometry — but only
|
|
335
|
+
where a verdict exists (see {@link pointerProvenOutside}). */
|
|
336
|
+
if (weOpened && pointerProvenOutside()) armClose();
|
|
337
|
+
};
|
|
338
|
+
pendingTimer = win.setTimeout(settle, ANIMATION_POLL_MS);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Ask the layout service for the shipped sidebar toggle.
|
|
343
|
+
*
|
|
344
|
+
* The frame is armed first (see {@link armFrameMarker}) so the columns the
|
|
345
|
+
* shell is about to write land on a transition that is already in effect.
|
|
346
|
+
* @returns true when a toggle was issued; false when a previous one is still
|
|
347
|
+
* animating, because a second flip mid-transition cancels the shell's own
|
|
348
|
+
* animation and the drawer snaps instead of sliding.
|
|
349
|
+
*/
|
|
350
|
+
function toggleSidebar() {
|
|
351
|
+
if (togglePending) return false;
|
|
352
|
+
markToggle();
|
|
353
|
+
armFrameMarker();
|
|
354
|
+
try {
|
|
355
|
+
layout.toggleSidebar();
|
|
356
|
+
} catch (error) {
|
|
357
|
+
/* Nothing was written, so nothing will claim the marker: withdraw it
|
|
358
|
+
instead of leaving a transition that is not running standing. */
|
|
359
|
+
withdrawPrearm();
|
|
360
|
+
console.error("sidebar drawer: toggleSidebar failed", error);
|
|
361
|
+
}
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Whether the reveal wish holds right now: a pointer resting in the trigger
|
|
367
|
+
* strip over a sidebar this behavior is allowed to reveal. Whether the
|
|
368
|
+
* pointer is still *there* at all is a separate, evidence question the
|
|
369
|
+
* callers settle first — absence of a sample proves nothing either way.
|
|
370
|
+
*/
|
|
371
|
+
function revealWanted() {
|
|
372
|
+
const frame = frameOf(doc);
|
|
373
|
+
if (lastX > EDGE_SIZE || isDragging(frame)) return false;
|
|
374
|
+
if (weOpened || togglePending) return false;
|
|
375
|
+
return drawerIsClosed() || drawerBarelyOpen();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Keep a reveal intent alive behind a column transition that is not ours.
|
|
380
|
+
*
|
|
381
|
+
* The wait is driven by the frame's own `data-animating` marker and never by
|
|
382
|
+
* a guessed duration — the same polling the deferred retraction uses, so no
|
|
383
|
+
* new timeout constants appear and a slower machine simply waits longer.
|
|
384
|
+
* When the marker clears, the intent is re-judged against the settled state
|
|
385
|
+
* (deferred, not discarded): a live sample that still holds reveals at once;
|
|
386
|
+
* a window-level leave — real evidence — drops the intent; and no live
|
|
387
|
+
* sample is **not** evidence of leaving (see `docs/behavior.md` row 16), so
|
|
388
|
+
* the intent parks on {@link revealNextCheck} and lands on the next fresh
|
|
389
|
+
* strip sample.
|
|
390
|
+
*/
|
|
391
|
+
function deferReveal() {
|
|
392
|
+
if (revealTimer !== null || disposed) return;
|
|
393
|
+
if (revealWaitSince === 0) revealWaitSince = win.performance.now();
|
|
394
|
+
const wait = () => {
|
|
395
|
+
revealTimer = null;
|
|
396
|
+
if (disposed) return;
|
|
397
|
+
const frame = frameOf(doc);
|
|
398
|
+
const animating = frame !== null && frame.hasAttribute(ANIMATING_ATTR);
|
|
399
|
+
if (animating && win.performance.now() - revealWaitSince < ANIMATION_CEILING_MS) {
|
|
400
|
+
revealTimer = win.setTimeout(wait, ANIMATION_POLL_MS);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
revealTimer = null;
|
|
404
|
+
if (pointerLeftWindow) {
|
|
405
|
+
revealWaitSince = 0;
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (!positionIsCurrent()) {
|
|
409
|
+
revealNextCheck = true;
|
|
410
|
+
revealWaitSince = 0;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
revealWaitSince = 0;
|
|
414
|
+
if (revealWanted()) reveal();
|
|
415
|
+
};
|
|
416
|
+
revealTimer = win.setTimeout(wait, ANIMATION_POLL_MS);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Open the drawer and take ownership of it.
|
|
421
|
+
*
|
|
422
|
+
* A column transition already in flight gets the right of way first: a
|
|
423
|
+
* second flip mid-animation cancels the frame's own transition and the
|
|
424
|
+
* sidebar snaps instead of sliding. That gate covers every caller's intent,
|
|
425
|
+
* and the intent is deferred rather than dropped (see {@link deferReveal}).
|
|
426
|
+
* A marker that outlives every real transition is a lie, and
|
|
427
|
+
* {@link ANIMATION_HARD_STOP_MS} stops believing it.
|
|
428
|
+
*/
|
|
429
|
+
function reveal() {
|
|
430
|
+
if (togglePending) return;
|
|
431
|
+
if (drawerIsAnimating() && (revealWaitSince === 0 || win.performance.now() - revealWaitSince < ANIMATION_HARD_STOP_MS)) {
|
|
432
|
+
deferReveal();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
revealWaitSince = 0;
|
|
436
|
+
cancelClose();
|
|
437
|
+
cancelDwell();
|
|
438
|
+
revealNextCheck = false;
|
|
439
|
+
weOpened = true;
|
|
440
|
+
toggleSidebar();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Whether the shell's transition is still running for our last toggle. */
|
|
444
|
+
function toggleInFlight() {
|
|
445
|
+
return togglePending;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Whether the pointer is inside the viewport, judged by the sample itself.
|
|
450
|
+
*
|
|
451
|
+
* A `mouseleave` (or a window blur) marks the pointer as gone, but the only
|
|
452
|
+
* thing that marks it back is another move — and a pointer resting perfectly
|
|
453
|
+
* still after the drawer reveals sends no such event. So the sample is asked
|
|
454
|
+
* directly: a point inside the viewport that still hits an element is a
|
|
455
|
+
* pointer this page can see, whatever the last leave event claimed.
|
|
456
|
+
*/
|
|
457
|
+
function pointerInViewport() {
|
|
458
|
+
if (lastX < 0) return false;
|
|
459
|
+
if (lastX < win.innerWidth && lastY < win.innerHeight) return true;
|
|
460
|
+
const hit = doc.elementFromPoint(lastX, lastY);
|
|
461
|
+
return hit !== null && hit !== undefined;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Whether a live pointer sample exists. */
|
|
465
|
+
function positionIsCurrent() {
|
|
466
|
+
return lastX >= 0;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Live box of the sidebar column, which is the drawer itself.
|
|
471
|
+
* @returns the column's rect, or null while the shell is not mounted.
|
|
472
|
+
*/
|
|
473
|
+
function drawerRect() {
|
|
474
|
+
const column = columnOf(frameOf(doc));
|
|
475
|
+
return column === null ? null : column.getBoundingClientRect();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Whether the shell reports a column transition in progress, for this behavior
|
|
480
|
+
* or for anyone else. `data-animating` is the frame's own marker around every
|
|
481
|
+
* discrete column change, so it covers slow transitions without any guess.
|
|
482
|
+
* @returns true while the frame is animating a column.
|
|
483
|
+
*/
|
|
484
|
+
function drawerIsAnimating() {
|
|
485
|
+
const frame = frameOf(doc);
|
|
486
|
+
return frame !== null && frame.hasAttribute(ANIMATING_ATTR);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Whether the sidebar is collapsed, per the frame's own marker.
|
|
491
|
+
*
|
|
492
|
+
* Deliberately not width-based: a narrow column also means "still opening",
|
|
493
|
+
* and reading that as closed would cancel retractions the pointer asked for.
|
|
494
|
+
* {@link drawerBarelyOpen} carries the width signal for the reveal gate, while
|
|
495
|
+
* retractions already wait out the frame's transitions.
|
|
496
|
+
*/
|
|
497
|
+
function drawerIsClosed() {
|
|
498
|
+
return isCollapsed(frameOf(doc));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Whether the pointer should count as off the drawer while it is still
|
|
503
|
+
* growing: a column narrower than the closed rail is not a drawer anyone can
|
|
504
|
+
* be "inside" yet, so it is only usable as a hint, never as a close verdict.
|
|
505
|
+
*/
|
|
506
|
+
function drawerBarelyOpen() {
|
|
507
|
+
const rect = drawerRect();
|
|
508
|
+
return rect !== null && rect.width <= OPEN_SETTLED_WIDTH;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Whether the pointer is over the drawer, using the drawer's live box.
|
|
513
|
+
*
|
|
514
|
+
* The box is taken as it is, even mid-transition: while the drawer grows, the
|
|
515
|
+
* strip it grows from is already part of it, so a pointer resting there is
|
|
516
|
+
* inside and must never be read as having left. (Refusing "inside" for a narrow
|
|
517
|
+
* column is exactly what made a resting pointer loop the drawer open/closed.)
|
|
518
|
+
*
|
|
519
|
+
* The left edge takes no slack: the column's own left edge is screen x = 0, so
|
|
520
|
+
* slack there would swallow part of the reveal strip. Only the right edge takes
|
|
521
|
+
* {@link POINTER_MARGIN}, because the width handle sits on it and a pointer
|
|
522
|
+
* resting on the handle is still a pointer on the drawer.
|
|
523
|
+
*/
|
|
524
|
+
function pointerOverDrawer() {
|
|
525
|
+
if (!positionIsCurrent()) return false;
|
|
526
|
+
const column = columnOf(frameOf(doc));
|
|
527
|
+
if (column === null) return false;
|
|
528
|
+
const rect = column.getBoundingClientRect();
|
|
529
|
+
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
|
530
|
+
/* The box alone never claims the reveal strip: a closed column is a 0px
|
|
531
|
+
strip at x = 0, so even {@link POINTER_MARGIN} past its right edge would
|
|
532
|
+
swallow the strip and the dwell could never arm. The hit test is what
|
|
533
|
+
covers the width handle sitting just outside that edge. */
|
|
534
|
+
if (
|
|
535
|
+
lastX >= rect.left &&
|
|
536
|
+
lastX <= rect.right + POINTER_MARGIN &&
|
|
537
|
+
lastY >= rect.top - POINTER_MARGIN &&
|
|
538
|
+
lastY <= rect.bottom + POINTER_MARGIN
|
|
539
|
+
) {
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
/* Consult the element under the pointer, but only where the drawer can
|
|
543
|
+
actually be reached: a closed sidebar keeps a full-width content box
|
|
544
|
+
inside its clipping column, so an unguarded hit test would report that
|
|
545
|
+
hidden content as "inside" and make the strip unusable. The handle is
|
|
546
|
+
part of the drawer, so it counts even while the column is collapsed. */
|
|
547
|
+
const handle = column.parentElement !== null ? column.parentElement.querySelector(HANDLE_SELECTOR) : null;
|
|
548
|
+
if (rect.width <= OPEN_SETTLED_WIDTH && handle === null) return false;
|
|
549
|
+
const hit = doc.elementFromPoint(lastX, lastY);
|
|
550
|
+
if (hit === null || hit === undefined) return false;
|
|
551
|
+
if (hit === handle || (handle !== null && handle.contains(hit))) return true;
|
|
552
|
+
return hit === column || column.contains(hit);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Whether the pointer is proven to be off the drawer — the only footing a
|
|
557
|
+
* retraction may stand on.
|
|
558
|
+
*
|
|
559
|
+
* A window-level leave is final. Otherwise a *live* sample must measure
|
|
560
|
+
* outside: after a viewport change the sample is dropped, and with no live
|
|
561
|
+
* sample nothing is proven either way. Absence of evidence is not evidence
|
|
562
|
+
* of leaving, so the drawer keeps its state until a fresh sample arrives.
|
|
563
|
+
*/
|
|
564
|
+
function pointerProvenOutside() {
|
|
565
|
+
return pointerLeftWindow || (positionIsCurrent() && !pointerOverDrawer());
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Retract the drawer this behavior opened.
|
|
570
|
+
* @returns true when the retraction was issued or was already unnecessary.
|
|
571
|
+
*/
|
|
572
|
+
function retract() {
|
|
573
|
+
if (!weOpened || disposed) return true;
|
|
574
|
+
weOpened = false;
|
|
575
|
+
if (drawerIsClosed()) return true;
|
|
576
|
+
retracting = true;
|
|
577
|
+
toggleSidebar();
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Arm the grace timer; the pointer must still be outside when it expires.
|
|
583
|
+
*
|
|
584
|
+
* The verdict the grace expires into is never "not inside" but "proven
|
|
585
|
+
* outside" (see {@link pointerProvenOutside}): a sample invalidated by a
|
|
586
|
+
* reflow proves nothing, so such a grace expires quietly and the drawer
|
|
587
|
+
* waits for a fresh sample instead of retracting on absent evidence.
|
|
588
|
+
*
|
|
589
|
+
* While the frame is animating a column it has no settled box to be judged
|
|
590
|
+
* against, so the timer arms but expires into another arming instead of a
|
|
591
|
+
* retraction. That is what keeps the reveal from closing behind a pointer that
|
|
592
|
+
* simply moved a little way in: the drawer is still coming to it. The wait is
|
|
593
|
+
* bounded so a frame that never clears its marker cannot pin the drawer open.
|
|
594
|
+
*/
|
|
595
|
+
function armClose() {
|
|
596
|
+
if (closeTimer !== null || !weOpened) return;
|
|
597
|
+
const armedAt = win.performance.now();
|
|
598
|
+
closeTimer = win.setTimeout(() => {
|
|
599
|
+
closeTimer = null;
|
|
600
|
+
if (!weOpened) return;
|
|
601
|
+
if (!pointerProvenOutside()) return;
|
|
602
|
+
const waiting = win.performance.now() - armedAt < ANIMATION_HARD_STOP_MS;
|
|
603
|
+
if (waiting && (drawerIsAnimating() || toggleInFlight())) {
|
|
604
|
+
armClose();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
retract();
|
|
608
|
+
}, CLOSE_DELAY_MS);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** One coalesced pointer check. */
|
|
612
|
+
function checkPointer() {
|
|
613
|
+
frameQueued = false;
|
|
614
|
+
if (disposed || !positionIsCurrent()) return;
|
|
615
|
+
/* Ownership ends where the drawer ends: a sidebar the frame reports
|
|
616
|
+
collapsed is not the one this behavior opened, whoever closed it — a
|
|
617
|
+
hand-close, or the viewport-driven auto-collapse below the narrow
|
|
618
|
+
threshold. Holding the flag past that would park a pointer in the
|
|
619
|
+
strip at "leaning on the reveal strip" and the dwell could never arm
|
|
620
|
+
again. (`!togglePending` guards the render lag right after our own
|
|
621
|
+
reveal: the marker leaves the DOM a tick after the store flips.) */
|
|
622
|
+
if (weOpened && !togglePending && drawerIsClosed()) {
|
|
623
|
+
weOpened = false;
|
|
624
|
+
}
|
|
625
|
+
const frame = frameOf(doc);
|
|
626
|
+
const over = pointerOverDrawer();
|
|
627
|
+
/* While the window reports the pointer gone, every recorded position is
|
|
628
|
+
stale: no reveal, no dwell, no verdict may be based on it. */
|
|
629
|
+
const onStrip = !pointerLeftWindow && lastX <= EDGE_SIZE && !isDragging(frame);
|
|
630
|
+
/* The pointer must rest in the strip: leaving it — usually by walking on
|
|
631
|
+
into the window — cancels a reveal that has not fired yet. */
|
|
632
|
+
if (!onStrip) cancelDwell();
|
|
633
|
+
/* A pointer that waited on the strip — through a retraction, or parked
|
|
634
|
+
behind someone else's transition — gets its reveal now, but only while
|
|
635
|
+
it is still there. Fresh evidence of leaving drops the intent; a merely
|
|
636
|
+
dropped sample does not (row 16: absence of evidence is not leaving). */
|
|
637
|
+
if (revealNextCheck) {
|
|
638
|
+
if (onStrip && !pointerLeftWindow) {
|
|
639
|
+
revealNextCheck = false;
|
|
640
|
+
reveal();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (pointerLeftWindow || lastX > EDGE_SIZE) revealNextCheck = false;
|
|
644
|
+
}
|
|
645
|
+
if (onStrip && retracting) {
|
|
646
|
+
/* On its way out with the pointer back on the strip: keep the wish and
|
|
647
|
+
decide again once that transition has settled. */
|
|
648
|
+
if (!weOpened) revealNextCheck = true;
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
/* While nothing of the drawer is shown, the strip *is* the trigger: no
|
|
652
|
+
"is the pointer over the drawer" question applies, because there is no
|
|
653
|
+
drawer yet. (Asking it let the collapsed column's clipped content box —
|
|
654
|
+
full-width inside a zero-width column — answer "yes" and block the
|
|
655
|
+
reveal. The drag handle is *not* the culprit: the shipped shell does not
|
|
656
|
+
mount it at all while the sidebar is collapsed.) See
|
|
657
|
+
`docs/adr/0002-collapsed-strip-is-the-trigger.md`. */
|
|
658
|
+
if (onStrip && !weOpened && (drawerIsClosed() || drawerBarelyOpen())) {
|
|
659
|
+
/* Arm the dwell once; the timer fires only if the pointer stays. */
|
|
660
|
+
if (dwellTimer === null) {
|
|
661
|
+
dwellTimer = win.setTimeout(() => {
|
|
662
|
+
dwellTimer = null;
|
|
663
|
+
if (!pointerInViewport()) return;
|
|
664
|
+
/* The premise the dwell was armed under must still hold — in
|
|
665
|
+
particular a hand-open that settled while the pointer waited
|
|
666
|
+
leaves a normal open sidebar, not a drawer to reveal over. */
|
|
667
|
+
if (!revealWanted()) return;
|
|
668
|
+
reveal();
|
|
669
|
+
}, DWELL_MS);
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (!weOpened) return;
|
|
674
|
+
if (over) {
|
|
675
|
+
cancelClose();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (lastX <= RETAIN_MARGIN) {
|
|
679
|
+
/* Still leaning on the reveal strip while the drawer grows: hold it. */
|
|
680
|
+
cancelClose();
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
armClose();
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function onPointerMove(event) {
|
|
687
|
+
lastX = event.clientX;
|
|
688
|
+
lastY = event.clientY;
|
|
689
|
+
/* A move that lands in the viewport is itself evidence the pointer is
|
|
690
|
+
present, whatever the last leave/enter pair claimed. */
|
|
691
|
+
if (pointerInViewport()) pointerLeftWindow = false;
|
|
692
|
+
if (frameQueued) return;
|
|
693
|
+
frameQueued = true;
|
|
694
|
+
win.requestAnimationFrame(checkPointer);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** A pointer that left the window is outside the drawer by definition. */
|
|
698
|
+
/**
|
|
699
|
+
* The window reports the pointer leaving or entering. Only these events — not a
|
|
700
|
+
* guessed position — decide whether the sample is still meaningful, which is
|
|
701
|
+
* what separates a pointer that really left from one that merely stopped
|
|
702
|
+
* moving (and whose last sample still looks like it is inside the drawer).
|
|
703
|
+
*/
|
|
704
|
+
function onPointerLeave() {
|
|
705
|
+
pointerLeftWindow = true;
|
|
706
|
+
cancelDwell();
|
|
707
|
+
if (!weOpened) return;
|
|
708
|
+
armClose();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** The pointer is back on the page: its next move supplies a fresh sample. */
|
|
712
|
+
function onPointerEnter() {
|
|
713
|
+
pointerLeftWindow = false;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* A reflow moves the drawer under a pointer that never moved, so the live
|
|
718
|
+
* sample is dropped and the next move re-establishes it.
|
|
719
|
+
*
|
|
720
|
+
* A viewport change **only invalidates evidence, never draws a conclusion**:
|
|
721
|
+
* it discards the stale sample and cancels whatever was still waiting on it
|
|
722
|
+
* (a pending dwell, a queued grace close) — but it must not retract a drawer
|
|
723
|
+
* the pointer may well still be in, nor pin one it has left. Both verdicts
|
|
724
|
+
* wait for the next real sample. See `docs/behavior.md` row 16.
|
|
725
|
+
*/
|
|
726
|
+
function onViewportChange() {
|
|
727
|
+
lastX = -1;
|
|
728
|
+
lastY = -1;
|
|
729
|
+
cancelDwell();
|
|
730
|
+
cancelClose();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const listeners = [];
|
|
734
|
+
function listen(target, type, handler) {
|
|
735
|
+
target.addEventListener(type, handler);
|
|
736
|
+
listeners.push([target, type, handler]);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
listen(doc, "pointermove", onPointerMove);
|
|
740
|
+
listen(doc, "pointerleave", onPointerLeave);
|
|
741
|
+
listen(doc, "pointerenter", onPointerEnter);
|
|
742
|
+
listen(win, "blur", onPointerLeave);
|
|
743
|
+
listen(win, "focus", onPointerEnter);
|
|
744
|
+
listen(win, "resize", onViewportChange);
|
|
745
|
+
listen(win, "scroll", onViewportChange);
|
|
746
|
+
doc.documentElement.setAttribute(MARK, "");
|
|
747
|
+
|
|
748
|
+
return () => {
|
|
749
|
+
disposed = true;
|
|
750
|
+
cancelClose();
|
|
751
|
+
cancelPending();
|
|
752
|
+
cancelDwell();
|
|
753
|
+
cancelRevealWait();
|
|
754
|
+
/* Also cancels the withdrawal timer and detaches the frame listener. */
|
|
755
|
+
withdrawPrearm();
|
|
756
|
+
for (const [target, type, handler] of listeners) target.removeEventListener(type, handler);
|
|
757
|
+
listeners.length = 0;
|
|
758
|
+
doc.documentElement.removeAttribute(MARK);
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Required services: the layout service that owns the sidebar toggle. */
|
|
763
|
+
const inject = ["layout"];
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Client plugin body: mount the edge-hover drawer over the shipped sidebar.
|
|
767
|
+
* @param ctx - client root context.
|
|
768
|
+
*/
|
|
769
|
+
function apply(ctx) {
|
|
770
|
+
const layout = ctx.get("layout");
|
|
771
|
+
if (layout === undefined || layout === null) {
|
|
772
|
+
console.error("sidebar drawer: layout service unavailable");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
ctx.effect(
|
|
776
|
+
() =>
|
|
777
|
+
install({
|
|
778
|
+
document: document,
|
|
779
|
+
window: window,
|
|
780
|
+
layout: layout
|
|
781
|
+
}),
|
|
782
|
+
"sidebar drawer: edge-hover reveal"
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
exports.install = install;
|
|
787
|
+
exports.apply = apply;
|
|
788
|
+
exports.inject = inject;
|
|
789
|
+
return module.exports;
|
|
790
|
+
}
|
|
791
|
+
});
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the sidebar edge-hover drawer. This package is a pure browser
|
|
3
|
+
* behavior: the empty `apply` exists so the row appears in the profile's
|
|
4
|
+
* cordis.patch.yml and the Loader mounts it, while the browser half ships
|
|
5
|
+
* through `exports["./client"]`, discovered from the package.json `dsh.client`
|
|
6
|
+
* declaration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Host plugin body — no host-side behavior for this surface plugin. */
|
|
10
|
+
export function apply() {}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-sidebar-drawer",
|
|
3
|
+
"description": "Edge-hover drawer for the Web GUI left sidebar: reveal it by moving the pointer to the left screen edge, keep it while the pointer stays inside, retract it when the pointer leaves",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node test/client.test.mjs && node test/browser.test.mjs"
|
|
9
|
+
},
|
|
10
|
+
"main": "lib/index.js",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"default": "./lib/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./client": {
|
|
16
|
+
"default": "./lib/client.js"
|
|
17
|
+
},
|
|
18
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"lib/index.js",
|
|
23
|
+
"lib/client.js",
|
|
24
|
+
"cordis.patch.yml",
|
|
25
|
+
"docs/drawer-demo.gif"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"dsh",
|
|
29
|
+
"deepseek-harness",
|
|
30
|
+
"cordis",
|
|
31
|
+
"plugin",
|
|
32
|
+
"bundle",
|
|
33
|
+
"sidebar",
|
|
34
|
+
"drawer",
|
|
35
|
+
"web-gui"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/1dustycy/dsh-sidebar-drawer.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/1dustycy/dsh-sidebar-drawer#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/1dustycy/dsh-sidebar-drawer/issues"
|
|
44
|
+
},
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"platform": "web",
|
|
51
|
+
"inject": [
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@deepseek-ai/cordis": ">=4.0.4 <5"
|
|
58
|
+
}
|
|
59
|
+
}
|