dsh-snap-line 0.0.0-stage → 1.3.1
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 +202 -0
- package/README.md +201 -2
- package/client.js +627 -0
- package/cordis.patch.yml +8 -0
- package/docs/icon.png +0 -0
- package/docs/images/hero.png +0 -0
- package/docs/images/lightbox.png +0 -0
- package/docs/images/preview.png +0 -0
- package/icon.svg +23 -0
- package/index.js +21 -0
- package/lib/photos.js +117 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +59 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lhuo
|
|
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,202 @@
|
|
|
1
|
+
# SnapLine
|
|
2
|
+
|
|
3
|
+
> A clothesline of polaroid photos hanging under the DeepSeek Harness message composer.
|
|
4
|
+
|
|
5
|
+
[中文](README.md) · **English**
|
|
6
|
+
|
|
7
|
+
[](https://github.com/LMhuo/dsh-snap-line/actions/workflows/ci.yml)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](package.json)
|
|
10
|
+
[](https://www.npmjs.com/package/dsh-snap-line)
|
|
11
|
+
[](https://github.com/deepseek-ai)
|
|
12
|
+
|
|
13
|
+
SnapLine is a UI bundle for [DeepSeek Harness](https://github.com/deepseek-ai). It draws a gently sagging clothesline just under the chat composer, hung with a row of polaroid photos that sway in the breeze. Five hand-drawn illustrations ship by default; drop your own images into `photos/` and they replace the illustrations within seconds, no restart needed.
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## Table of contents
|
|
18
|
+
|
|
19
|
+
- [Features](#features)
|
|
20
|
+
- [Screenshots](#screenshots)
|
|
21
|
+
- [Install](#install)
|
|
22
|
+
- [Use your own photos](#use-your-own-photos)
|
|
23
|
+
- [How it works](#how-it-works)
|
|
24
|
+
- [Development](#development)
|
|
25
|
+
- [Compatibility](#compatibility)
|
|
26
|
+
- [FAQ](#faq)
|
|
27
|
+
- [Contributing](#contributing)
|
|
28
|
+
- [Changelog](#changelog)
|
|
29
|
+
- [License](#license)
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- **Two places, one artwork**: hangs in the conversation's `conversation.composer.dock` slot (tight under the composer, never covering controls) and on the blank New Session hero;
|
|
34
|
+
- **Five built-in illustrations**: the line is never empty when `photos/` has no images;
|
|
35
|
+
- **Your own photos**: `.jpg` `.jpeg` `.png` `.webp` `.gif` `.avif`, natural filename order, live refresh within five seconds after adding, removing or replacing files;
|
|
36
|
+
- **Adaptive layout**: up to six photos, evenly sampled when there are more; the more photos, the tighter they sit, each with its own tilt and sway rhythm;
|
|
37
|
+
- **Restrained interaction**: hovering only uses CSS `:hover` and `transform` to give a photo a small nudge that settles back; clicking opens a lightbox — no mouse listeners, no extra re-renders;
|
|
38
|
+
- **Keyboard friendly**: photos are focusable with Tab and open the lightbox with Enter or Space; while the lightbox is open Tab stays on its close button, Escape / backdrop / close button all dismiss it, and focus returns to the photo that opened it;
|
|
39
|
+
- **Named for screen readers**: the clothesline, every photo, the lightbox and its close button carry bilingual Chinese/English accessible names;
|
|
40
|
+
- **Respects system preferences**: `prefers-reduced-motion` disables the sway animation;
|
|
41
|
+
- **Zero runtime dependencies**: plain ESM, no build step; `react` / `react-dom` come from the Harness module table;
|
|
42
|
+
- **Photos never leave your machine**: the Host half serves them over the Harness web server's same-origin route; the plugin makes no external requests.
|
|
43
|
+
|
|
44
|
+
## Screenshots
|
|
45
|
+
|
|
46
|
+
| New Session hero | Lightbox |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
|  |  |
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
SnapLine is a Harness bundle, so it can be installed from npm, GitHub or a local directory — an agent inside Harness does it through `plugin_manager`.
|
|
53
|
+
|
|
54
|
+
### Option 1: npm (recommended)
|
|
55
|
+
|
|
56
|
+
Published on npm as [`dsh-snap-line`](https://www.npmjs.com/package/dsh-snap-line).
|
|
57
|
+
|
|
58
|
+
- Ask the Harness agent to install `dsh-snap-line` with `plugin_manager`'s `install_bundle`; or
|
|
59
|
+
- run this in a terminal:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
dsh plugin --profile <profile> add dsh-snap-line
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
DSH installs it from npm, composes the Host half from `dsh.bundle.patch` and loads the Client half from `dsh.client`. Once the plugin is listed in the community plugin market (awesome-dsh-plugin), it can also be installed by searching for it there.
|
|
66
|
+
|
|
67
|
+
### Option 2: install from GitHub
|
|
68
|
+
|
|
69
|
+
Send the repository URL to the Harness agent and ask it to install the bundle with `install_bundle`:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
https://github.com/LMhuo/dsh-snap-line.git
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The `github:LMhuo/dsh-snap-line` shorthand works too. DSH installs it into the current profile with pnpm, then composes the Host half from `dsh.bundle.patch` and loads the Client half from `dsh.client`.
|
|
76
|
+
|
|
77
|
+
When it finishes, the plugin appears in Harness's plugin list (the bundle list in `~/.dsh/profiles/<profile>/package.json`) and starts rendering under the composer.
|
|
78
|
+
|
|
79
|
+
### Option 3: clone locally, then install (best for development)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/LMhuo/dsh-snap-line.git
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then ask the Harness agent to install that directory's **absolute path** with `install_bundle`. It writes a `link:` dependency into the current profile's `package.json` and adds it to the bundle list — no manual profile editing.
|
|
86
|
+
|
|
87
|
+
### Disable and remove
|
|
88
|
+
|
|
89
|
+
- Toggle the plugin off in Harness's plugin settings; or
|
|
90
|
+
- Ask the agent to call `plugin_manager`'s `set_bundle` (disable / enable) or `remove_bundle` (remove).
|
|
91
|
+
|
|
92
|
+
## Use your own photos
|
|
93
|
+
|
|
94
|
+
The photo folder is **`photos/` inside the plugin package**:
|
|
95
|
+
|
|
96
|
+
| Install style | Photo folder |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| Local directory / `link:` | `photos/` in this repository |
|
|
99
|
+
| npm / GitHub install | `~/.dsh/profiles/<profile>/node_modules/dsh-snap-line/photos/` |
|
|
100
|
+
|
|
101
|
+
> npm and GitHub installs ship without a `photos/` folder (private photos are never packed), so create that folder yourself.
|
|
102
|
+
|
|
103
|
+
Drop images in and that's it:
|
|
104
|
+
|
|
105
|
+
- Sorted by natural filename order (`1.jpg`, `2.jpg`, `10.jpg` — correctly ordered); non-ASCII filenames work too;
|
|
106
|
+
- Up to six photos are shown; extras are evenly sampled;
|
|
107
|
+
- Added, removed or replaced files show up within five seconds, no restart;
|
|
108
|
+
- An empty folder brings back the built-in illustrations.
|
|
109
|
+
|
|
110
|
+
> **Privacy**: images in `photos/` never enter Git and are never packed into the npm tarball (see `.gitignore` and `files` in `package.json`), so private photos are fine. They are served only by the local Harness port and never uploaded — but any local process that can reach that port can read them.
|
|
111
|
+
|
|
112
|
+
## How it works
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
photos/ ── Host half (index.js) ── GET /dsh-snap-line/photos.json ──┐
|
|
116
|
+
├── Client half (client.js)
|
|
117
|
+
photos/<name> ── GET /dsh-snap-line/photos/<name> ──────────────────┘ └── SVG clothesline + polaroid frames
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- **Host half** (`index.js`): registers a `kind: 'prefix'` route at `/dsh-snap-line` for the photo manifest and the images. `lib/photos.js` is plain Node and rejects path traversal, hidden filenames and unknown media types, so it is unit-testable on its own;
|
|
121
|
+
- **Client half** (`client.js`): registers in the `conversation.composer.dock` slot (`order: 2`) and polls the manifest every five seconds, rendering user photos or the built-in illustrations;
|
|
122
|
+
- **Lightbox**: open it by clicking a photo, or by focusing one with Tab and pressing Enter / Space; Escape, the backdrop or the close button dismiss it, focus returns to the photo, and the page behind it does not scroll while it is open.
|
|
123
|
+
- **New Session hero**: the Host renders `conversation.composer.dock` only for an active Session, so the blank hero has just an empty `[data-composer-dock]` container. The Client half also registers `HeroClothesline` on `shell.overlay`, checks every 250 ms for `[data-content-phase="hero"] [data-composer-dock]`, and portals the same clothesline into it via `react-dom`'s `createPortal`, scaled to 2× the measured composer width (capped at 600px). Once a real conversation mounts its dock entry, the hero copy steps aside; if `react-dom` or `shell.overlay` is unavailable it silently skips, leaving the normal dock rendering intact;
|
|
124
|
+
- the dock slot is a fit-content flex row, so an SVG with `width: 100%` collapses to its intrinsic 300px — the conversation clothesline stays ~300px wide. The hero copy therefore uses an explicit pixel width instead of `max-width`.
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npm test # node --test: photo listing, file route, manifest consistency — all tests
|
|
130
|
+
npm run check # syntax check + full test suite
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
No build step and no runtime dependencies. Editing `client.js` hot-reloads the browser half via HMR; editing `index.js` needs the plugin entry to be disabled and re-enabled once.
|
|
134
|
+
|
|
135
|
+
For local debugging, install this repository as a `link:` bundle (option 3) so edits take effect immediately.
|
|
136
|
+
|
|
137
|
+
### Repository layout
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
dsh-snap-line/
|
|
141
|
+
├── index.js # Host half: registers the /dsh-snap-line prefix route
|
|
142
|
+
├── lib/photos.js # Photo listing / reading / route resource (plain Node, unit-tested)
|
|
143
|
+
├── client.js # Client half: clothesline SVG, slot registration, lightbox
|
|
144
|
+
├── cordis.patch.yml # Host plugin row inserted by the bundle
|
|
145
|
+
├── locale/ # English and Chinese display metadata for the plugin manager
|
|
146
|
+
├── icon.svg # Vector icon shown in the plugin list
|
|
147
|
+
├── screenshots.json # Market screenshot list (points at docs/images)
|
|
148
|
+
├── photos/ # Your photos (not committed by default)
|
|
149
|
+
├── docs/ # README previews, market screenshots and the PNG icon
|
|
150
|
+
├── test/ # node:test suites
|
|
151
|
+
└── .github/workflows/ # GitHub Actions CI
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Compatibility
|
|
155
|
+
|
|
156
|
+
| Item | Requirement |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| DeepSeek Harness | 0.2.x (web platform; developed against 0.2.2) |
|
|
159
|
+
| Node.js | ≥ 20 (development and tests only; the runtime uses Harness's bundled Node) |
|
|
160
|
+
| Plugin shape | bundle (`dsh` field in `package.json` + `cordis.patch.yml`) |
|
|
161
|
+
|
|
162
|
+
The plugin declares `engines.dsh: ^0.2.0` in `package.json`: a Harness outside that range skips the bundle instead of loading something incompatible. If you need a wider range, the profile's `compatibility.json` supports per-bundle version exemptions.
|
|
163
|
+
|
|
164
|
+
## FAQ
|
|
165
|
+
|
|
166
|
+
**I added photos but the clothesline did not change.**
|
|
167
|
+
Make sure they are in the `photos/` folder from the table above, then wait five seconds (the polling interval). Check that the extension is supported and the filename does not start with `.` (hidden files are ignored). With more than six photos only six are shown, evenly sampled.
|
|
168
|
+
|
|
169
|
+
**Are my photos uploaded or published?**
|
|
170
|
+
No. They are served by the local Harness web server over a same-origin route; the plugin makes no external requests and never writes photos into Git or the npm tarball.
|
|
171
|
+
|
|
172
|
+
**Does it block clicks on the composer?**
|
|
173
|
+
No. The wrapper is `pointer-events: none`; only the photo frames opt back in for hover and the lightbox click.
|
|
174
|
+
|
|
175
|
+
**Can I change the number or size of photos?**
|
|
176
|
+
Yes — edit `MAX_PHOTOS` (count), `HERO_SVG_WIDTH` (hero width) and `TILTS` / `DELAYS` / `DURATIONS` (tilt and sway rhythm) in `client.js`.
|
|
177
|
+
|
|
178
|
+
**Does it support dark theme?**
|
|
179
|
+
Currently it uses a fixed warm paper palette and does not follow the Harness theme. PRs welcome.
|
|
180
|
+
|
|
181
|
+
**How do I upgrade?**
|
|
182
|
+
npm installs: check for updates in the plugin market, or ask the agent to `install_bundle` again. Git installs: ask the agent to install the latest commit again, or `remove_bundle` first and reinstall. Every release is documented in [CHANGELOG.md](CHANGELOG.md).
|
|
183
|
+
|
|
184
|
+
**Can I use it with a keyboard?**
|
|
185
|
+
Yes. Photos are focusable with Tab and Enter / Space opens the lightbox; Tab stays on the close button while it is open, Escape closes it, and focus returns to the photo.
|
|
186
|
+
|
|
187
|
+
**Is commercial use allowed?**
|
|
188
|
+
Yes, under the MIT license.
|
|
189
|
+
|
|
190
|
+
## Contributing
|
|
191
|
+
|
|
192
|
+
- Bugs and feature requests: open an issue with your Harness version, install style and reproduction steps;
|
|
193
|
+
- Code: run `npm run check` first so all tests pass, then open a PR;
|
|
194
|
+
- The project is also mirrored on [Gitee](https://gitee.com/Lhuo/dsh-snap-line); issues are welcome in either place.
|
|
195
|
+
|
|
196
|
+
## Changelog
|
|
197
|
+
|
|
198
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
[MIT](LICENSE) © 2026 Lhuo
|
package/README.md
CHANGED
|
@@ -1,3 +1,202 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SnapLine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 一条挂在 DeepSeek Harness 消息输入框下方的照片晾衣绳。
|
|
4
|
+
|
|
5
|
+
**中文** · [English](README.en.md)
|
|
6
|
+
|
|
7
|
+
[](https://github.com/LMhuo/dsh-snap-line/actions/workflows/ci.yml)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](package.json)
|
|
10
|
+
[](https://www.npmjs.com/package/dsh-snap-line)
|
|
11
|
+
[](https://github.com/deepseek-ai)
|
|
12
|
+
|
|
13
|
+
SnapLine 是 [DeepSeek Harness](https://github.com/deepseek-ai) 的界面插件(bundle)。它在聊天输入框下方画一条微微下垂的晾衣绳,夹上一排拍立得照片,照片随风轻轻摆动。默认是五张手绘小画;把照片放进 `photos/` 文件夹,就会换成你自己的照片,几秒内自动刷新,无需重启。
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## 目录
|
|
18
|
+
|
|
19
|
+
- [特性](#特性)
|
|
20
|
+
- [截图](#截图)
|
|
21
|
+
- [安装](#安装)
|
|
22
|
+
- [使用自己的照片](#使用自己的照片)
|
|
23
|
+
- [工作原理](#工作原理)
|
|
24
|
+
- [开发](#开发)
|
|
25
|
+
- [兼容性](#兼容性)
|
|
26
|
+
- [常见问题](#常见问题)
|
|
27
|
+
- [贡献](#贡献)
|
|
28
|
+
- [更新日志](#更新日志)
|
|
29
|
+
- [License](#license)
|
|
30
|
+
|
|
31
|
+
## 特性
|
|
32
|
+
|
|
33
|
+
- **两个位置都会出现**:既挂在会话页的 `conversation.composer.dock` 插槽里(紧贴输入框下方、不遮挡操作),也出现在新会话首页的空白 Hero 里;
|
|
34
|
+
- **自带五张手绘插画**:`photos/` 为空时使用内置插画,不会出现空荡荡的绳子;
|
|
35
|
+
- **换成自己的照片**:支持 `.jpg` `.jpeg` `.png` `.webp` `.gif` `.avif`,按文件名自然排序,增删后 5 秒内自动刷新;
|
|
36
|
+
- **排版自适应**:最多显示 6 张,超过时均匀抽样;照片越多排得越密,每张有自己的倾斜角度与摆动节奏;
|
|
37
|
+
- **交互克制**:悬停只靠 CSS `:hover` 与 `transform` 让照片轻轻晃一下再回正,点击打开灯箱预览,没有鼠标监听、没有多余重渲染;
|
|
38
|
+
- **键盘友好**:照片可以用 Tab 聚焦,Enter / 空格打开灯箱;灯箱打开后 Tab 被约束在关闭按钮上,Esc、点击背景或关闭按钮都能退出,关闭后焦点回到打开它的照片;
|
|
39
|
+
- **读屏器可读**:晾衣绳、每张照片、灯箱与关闭按钮都有中英双语的无障碍名称;
|
|
40
|
+
- **尊重系统偏好**:`prefers-reduced-motion` 开启时不做摆动动画;
|
|
41
|
+
- **零运行时依赖**:纯 ESM、无构建步骤,`react` / `react-dom` 复用 Harness 内置模块;
|
|
42
|
+
- **照片不出本机**:Host 半通过 Harness web server 的同源路由提供图片,插件不发起任何外部请求。
|
|
43
|
+
|
|
44
|
+
## 截图
|
|
45
|
+
|
|
46
|
+
| 新会话首页 | 灯箱预览 |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
|  |  |
|
|
49
|
+
|
|
50
|
+
## 安装
|
|
51
|
+
|
|
52
|
+
SnapLine 是 Harness 的 bundle 插件,可以走 npm、GitHub 或本地目录;安装由 Harness 里的 Agent 通过 `plugin_manager` 完成。
|
|
53
|
+
|
|
54
|
+
### 方式一:npm(推荐)
|
|
55
|
+
|
|
56
|
+
已发布到 npm:[`dsh-snap-line`](https://www.npmjs.com/package/dsh-snap-line)。
|
|
57
|
+
|
|
58
|
+
- 让 Harness Agent 用 `plugin_manager` 的 `install_bundle` 安装 `dsh-snap-line`;
|
|
59
|
+
- 或在终端运行:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
dsh plugin --profile <profile> add dsh-snap-line
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
DSH 会从 npm 装好,并按 `dsh.bundle.patch` 组合 Host 半、按 `dsh.client` 加载 Client 半。收录到社区插件市场(awesome-dsh-plugin)后,也可以直接在 Harness 的插件市场里搜索安装。
|
|
66
|
+
|
|
67
|
+
### 方式二:从 GitHub 安装
|
|
68
|
+
|
|
69
|
+
把仓库地址发给 Harness Agent,并让它用 `install_bundle` 安装:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
https://github.com/LMhuo/dsh-snap-line.git
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
也支持 `github:LMhuo/dsh-snap-line` 简写。DSH 会先用 pnpm 把它装进当前 profile,再按 `dsh.bundle.patch` 组合 Host 半、按 `dsh.client` 加载 Client 半。
|
|
76
|
+
|
|
77
|
+
安装成功后,插件会出现在 Harness 的插件列表(`~/.dsh/profiles/<profile>/package.json` 的 bundle 列表)里,并自动在输入框下方生效。
|
|
78
|
+
|
|
79
|
+
### 方式三:本地克隆后安装(开发 / 调试推荐)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git clone https://github.com/LMhuo/dsh-snap-line.git
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
然后让 Harness Agent 用 `plugin_manager` 的 `install_bundle` 安装这个目录的**绝对路径**。`install_bundle` 会在当前 profile 的 `package.json` 中写入 `link:` 依赖并加入 bundle 列表,无需手动改 profile 文件。
|
|
86
|
+
|
|
87
|
+
### 停用与卸载
|
|
88
|
+
|
|
89
|
+
- 在 Harness 的插件设置里停用;
|
|
90
|
+
- 或让 Agent 调用 `plugin_manager` 的 `set_bundle`(停用 / 启用)与 `remove_bundle`(移除)。
|
|
91
|
+
|
|
92
|
+
## 使用自己的照片
|
|
93
|
+
|
|
94
|
+
照片目录是**插件包目录下的 `photos/`**:
|
|
95
|
+
|
|
96
|
+
| 安装方式 | 照片目录 |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| 本地目录 / `link:` 安装 | 本仓库的 `photos/` |
|
|
99
|
+
| npm / GitHub 安装 | `~/.dsh/profiles/<profile>/node_modules/dsh-snap-line/photos/` |
|
|
100
|
+
|
|
101
|
+
> npm 与 GitHub 安装的包默认不带 `photos/` 目录(私人照片不会被打进包),需要自己新建这个文件夹。
|
|
102
|
+
|
|
103
|
+
把图片放进去即可:
|
|
104
|
+
|
|
105
|
+
- 按文件名自然排序(`1.jpg`、`2.jpg`、`10.jpg` 顺序正确),中文文件名同样支持;
|
|
106
|
+
- 最多显示 6 张,更多照片会均匀抽取 6 张;
|
|
107
|
+
- 增删或替换后 5 秒内自动刷新,无需重启 Harness;
|
|
108
|
+
- 清空文件夹会恢复插件内置的插画照片。
|
|
109
|
+
|
|
110
|
+
> **隐私**:`photos/` 中的照片不会进入 Git,也不会被打进 npm 包(见 `.gitignore` 与 `package.json` 的 `files`),可以放心放私人照片。照片只由本机 Harness 端口提供,插件不会上传;但能访问该端口的本机进程可以看到这些图片。
|
|
111
|
+
|
|
112
|
+
## 工作原理
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
photos/ ──Host 半 (index.js)── GET /dsh-snap-line/photos.json ──┐
|
|
116
|
+
├── Client 半 (client.js)
|
|
117
|
+
photos/<name> ── GET /dsh-snap-line/photos/<name> ──────────────┘ └── SVG 晾衣绳 + 拍立得相框
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- **Host 半**(`index.js`):用 `webServer.register({ kind: 'prefix', path: '/dsh-snap-line' })` 提供照片清单与图片。`lib/photos.js` 是纯 Node 实现,拒绝目录穿越、隐藏文件名和未知媒体类型,可独立测试;
|
|
121
|
+
- **Client 半**(`client.js`):注册到 `conversation.composer.dock` 插槽(`order: 2`),每 5 秒轮询一次清单,按清单渲染照片或内置插画;
|
|
122
|
+
- **灯箱**:点击照片(或 Tab 聚焦后按 Enter / 空格)打开;Esc、点击背景或关闭按钮退出,关闭后焦点回到打开它的照片,灯箱打开期间背景不滚动。
|
|
123
|
+
- **新会话首页**:Host 只在有 Session 时渲染 `conversation.composer.dock`,空白 Hero 里只有一个空的 `[data-composer-dock]` 容器。Client 半额外向 `shell.overlay` 注册 `HeroClothesline`,每 250ms 检查页面里是否存在 `[data-content-phase="hero"] [data-composer-dock]`,若有则通过 `react-dom` 的 `createPortal` 把同一条晾衣绳画进该容器,并按量到的输入框宽度放大到 2 倍(600px 封顶)。进入正常会话、dock 版出现后自动让位,不会重复显示;拿不到 `react-dom` 或 `shell.overlay` 时静默跳过,不影响 dock 渲染;
|
|
124
|
+
- dock 插槽本身是 fit-content 的 flex 行,SVG 的 `width: 100%` 会退回固有 300px,所以会话页晾衣绳一直是 300px 左右;Hero 版因此使用显式像素宽度,而不是改 `max-width`。
|
|
125
|
+
|
|
126
|
+
## 开发
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npm test # node --test:照片清单、静态文件路由、清单一致性等全部用例
|
|
130
|
+
npm run check # 语法检查 + 全部测试
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
没有构建步骤,也没有运行时依赖。修改 `client.js` 会通过 HMR 自动刷新浏览器半;修改 `index.js` 后需要在插件设置里停用再启用一次。
|
|
134
|
+
|
|
135
|
+
本地调试时推荐用上面的「方式三」以 `link:` 方式安装本仓库,这样改代码即时生效。
|
|
136
|
+
|
|
137
|
+
### 目录结构
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
dsh-snap-line/
|
|
141
|
+
├── index.js # Host 半:注册 /dsh-snap-line 前缀路由
|
|
142
|
+
├── lib/photos.js # 照片清单 / 读取 / 路由资源(纯 Node,可单独测试)
|
|
143
|
+
├── client.js # Client 半:晾衣绳 SVG、插槽注册、灯箱
|
|
144
|
+
├── cordis.patch.yml # bundle 插入的 Host 插件行
|
|
145
|
+
├── locale/ # 插件管理器中显示的中英文标题与描述
|
|
146
|
+
├── icon.svg # 插件列表里显示的矢量图标
|
|
147
|
+
├── screenshots.json # 插件市场卡片用的截图清单(指向 docs/images)
|
|
148
|
+
├── photos/ # 你自己的照片(默认不提交)
|
|
149
|
+
├── docs/ # README 预览图、市场截图与 PNG 图标
|
|
150
|
+
├── test/ # node:test 测试
|
|
151
|
+
└── .github/workflows/ # GitHub Actions CI
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## 兼容性
|
|
155
|
+
|
|
156
|
+
| 项目 | 要求 |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| DeepSeek Harness | 0.2.x(Web 平台;开发验证于 0.2.2) |
|
|
159
|
+
| Node.js | ≥ 20(仅开发与测试需要,运行时使用 Harness 内置 Node) |
|
|
160
|
+
| 插件形态 | bundle(`package.json` 的 `dsh` 字段 + `cordis.patch.yml`) |
|
|
161
|
+
|
|
162
|
+
插件在 `package.json` 的 `engines.dsh` 中声明 `^0.2.0`:Harness 低于该范围时会跳过加载,避免在不兼容的版本上出错。需要更宽的兼容性时,可以用 profile 的 `compatibility.json` 做版本豁免。
|
|
163
|
+
|
|
164
|
+
## 常见问题
|
|
165
|
+
|
|
166
|
+
**放了照片但晾衣绳上没有变化?**
|
|
167
|
+
先确认照片放进了上表对应的 `photos/` 目录,再等 5 秒(轮询间隔)。检查扩展名是否在支持列表内、文件名是否以 `.` 开头(隐藏文件会被忽略)。超过 6 张时只会均匀显示 6 张。
|
|
168
|
+
|
|
169
|
+
**照片会被上传或公开吗?**
|
|
170
|
+
不会。Photo 由本机 Harness web server 的同源路由提供,插件不发外部请求,也不会把照片写进 Git 或 npm 包。
|
|
171
|
+
|
|
172
|
+
**会挡住消息输入框的点击吗?**
|
|
173
|
+
不会。外层容器是 `pointer-events: none`,只有照片本身恢复指针事件,用于悬停与点击灯箱。
|
|
174
|
+
|
|
175
|
+
**可以改照片数量或尺寸吗?**
|
|
176
|
+
可以,在 `client.js` 中调整 `MAX_PHOTOS`(数量)、`HERO_SVG_WIDTH`(Hero 版宽度)、`TILTS` / `DELAYS` / `DURATIONS`(倾斜与摆动节奏)。
|
|
177
|
+
|
|
178
|
+
**支持深色主题吗?**
|
|
179
|
+
目前使用固定的暖色纸张配色,不随 Harness 主题切换;欢迎提交 PR。
|
|
180
|
+
|
|
181
|
+
**怎么升级到新版本?**
|
|
182
|
+
npm 安装:在插件市场里检查更新,或让 Agent 重新用 `install_bundle` 安装;Git 安装:让 Agent 重新安装最新提交,或先 `remove_bundle` 再安装。每个版本的变更见 [CHANGELOG.md](CHANGELOG.md)。
|
|
183
|
+
|
|
184
|
+
**可以用键盘操作吗?**
|
|
185
|
+
可以。照片可以用 Tab 聚焦,Enter / 空格打开灯箱;灯箱打开后 Tab 被约束在关闭按钮上,Esc 关闭,关闭后焦点回到照片。
|
|
186
|
+
|
|
187
|
+
**可以商用吗?**
|
|
188
|
+
可以,MIT 协议。
|
|
189
|
+
|
|
190
|
+
## 贡献
|
|
191
|
+
|
|
192
|
+
- 发现 bug、想要新功能:欢迎开 issue,尽量附上 Harness 版本、安装方式与复现步骤;
|
|
193
|
+
- 提交代码:先 `npm run check` 保证全部测试通过,再发起 PR;
|
|
194
|
+
- 本项目同时镜像在 [Gitee](https://gitee.com/Lhuo/dsh-snap-line),两边都可以提 issue。
|
|
195
|
+
|
|
196
|
+
## 更新日志
|
|
197
|
+
|
|
198
|
+
见 [CHANGELOG.md](CHANGELOG.md)。
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
[MIT](LICENSE) © 2026 Lhuo
|
package/client.js
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-snap-line',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
/* Rope: quadratic from (18,14) to (682,14) with control point (350,46). */
|
|
8
|
+
const ROPE_LEFT = 18;
|
|
9
|
+
const ROPE_RIGHT = 682;
|
|
10
|
+
const ROPE_TOP = 14;
|
|
11
|
+
const ROPE_SAG = 32;
|
|
12
|
+
|
|
13
|
+
function ropeY(x) {
|
|
14
|
+
const t = (x - ROPE_LEFT) / (ROPE_RIGHT - ROPE_LEFT);
|
|
15
|
+
return ROPE_TOP + 2 * ROPE_SAG * t * (1 - t);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CSS = `
|
|
19
|
+
.sl-sway {
|
|
20
|
+
transform-box: fill-box;
|
|
21
|
+
transform-origin: 50% 7px;
|
|
22
|
+
animation: sl-sway 6.8s ease-in-out infinite;
|
|
23
|
+
}
|
|
24
|
+
@keyframes sl-sway {
|
|
25
|
+
0%, 100% { transform: rotate(-1.15deg); }
|
|
26
|
+
50% { transform: rotate(1.15deg); }
|
|
27
|
+
}
|
|
28
|
+
.sl-wobble {
|
|
29
|
+
transform-box: fill-box;
|
|
30
|
+
transform-origin: 50% 7px;
|
|
31
|
+
}
|
|
32
|
+
@keyframes sl-wobble {
|
|
33
|
+
0% { transform: rotate(0deg); }
|
|
34
|
+
14% { transform: rotate(2.8deg); }
|
|
35
|
+
34% { transform: rotate(-2.1deg); }
|
|
36
|
+
54% { transform: rotate(1.45deg); }
|
|
37
|
+
74% { transform: rotate(-0.8deg); }
|
|
38
|
+
90% { transform: rotate(0.35deg); }
|
|
39
|
+
100% { transform: rotate(0deg); }
|
|
40
|
+
}
|
|
41
|
+
/*
|
|
42
|
+
* The wrapper is pointer-events: none so the artwork never steals clicks
|
|
43
|
+
* from the composer. Each photo frame opts back in so it can be hovered
|
|
44
|
+
* and clicked; the rest of the SVG stays transparent to the pointer. The
|
|
45
|
+
* nudge itself is transform-only: no layout, no JS listeners and no React
|
|
46
|
+
* re-renders while it plays.
|
|
47
|
+
*/
|
|
48
|
+
.sl-photo {
|
|
49
|
+
pointer-events: auto;
|
|
50
|
+
touch-action: manipulation;
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
}
|
|
53
|
+
.sl-photo:focus {
|
|
54
|
+
outline: none;
|
|
55
|
+
}
|
|
56
|
+
.sl-photo:focus-visible {
|
|
57
|
+
outline: 2px solid #a98d63;
|
|
58
|
+
outline-offset: 2px;
|
|
59
|
+
border-radius: 4px;
|
|
60
|
+
}
|
|
61
|
+
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
|
|
62
|
+
.sl-photo:hover .sl-wobble {
|
|
63
|
+
animation: sl-wobble 1.25s ease-in-out;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
.sl-lightbox {
|
|
67
|
+
position: fixed;
|
|
68
|
+
inset: 0;
|
|
69
|
+
z-index: 2147483000;
|
|
70
|
+
display: grid;
|
|
71
|
+
pointer-events: auto;
|
|
72
|
+
place-items: center;
|
|
73
|
+
padding: 24px;
|
|
74
|
+
background: rgba(20, 17, 13, 0.62);
|
|
75
|
+
animation: sl-lightbox-fade 160ms ease-out;
|
|
76
|
+
}
|
|
77
|
+
.sl-lightbox-photo {
|
|
78
|
+
display: inline-block;
|
|
79
|
+
max-width: 92vw;
|
|
80
|
+
padding: 12px;
|
|
81
|
+
background: #fcf8ef;
|
|
82
|
+
border-radius: 4px;
|
|
83
|
+
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.45);
|
|
84
|
+
animation: sl-lightbox-pop 200ms ease-out;
|
|
85
|
+
}
|
|
86
|
+
.sl-lightbox-photo img {
|
|
87
|
+
display: block;
|
|
88
|
+
width: auto;
|
|
89
|
+
height: auto;
|
|
90
|
+
max-width: min(88vw, 1200px);
|
|
91
|
+
max-height: 78vh;
|
|
92
|
+
border-radius: 1px;
|
|
93
|
+
}
|
|
94
|
+
.sl-lightbox-polaroid {
|
|
95
|
+
width: min(360px, 72vw);
|
|
96
|
+
animation: sl-lightbox-pop 200ms ease-out;
|
|
97
|
+
}
|
|
98
|
+
.sl-lightbox-polaroid svg {
|
|
99
|
+
display: block;
|
|
100
|
+
width: 100%;
|
|
101
|
+
height: auto;
|
|
102
|
+
}
|
|
103
|
+
.sl-lightbox-close {
|
|
104
|
+
position: absolute;
|
|
105
|
+
top: 18px;
|
|
106
|
+
right: 20px;
|
|
107
|
+
width: 40px;
|
|
108
|
+
height: 40px;
|
|
109
|
+
padding: 0;
|
|
110
|
+
border: none;
|
|
111
|
+
border-radius: 50%;
|
|
112
|
+
background: #fcf8ef;
|
|
113
|
+
color: #3c352c;
|
|
114
|
+
font-family: inherit;
|
|
115
|
+
font-size: 22px;
|
|
116
|
+
line-height: 1;
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
|
|
119
|
+
}
|
|
120
|
+
.sl-lightbox-close:hover {
|
|
121
|
+
background: #ffffff;
|
|
122
|
+
}
|
|
123
|
+
.sl-lightbox-close:focus {
|
|
124
|
+
outline: none;
|
|
125
|
+
}
|
|
126
|
+
.sl-lightbox-close:focus-visible {
|
|
127
|
+
outline: 2px solid #f0e2c4;
|
|
128
|
+
outline-offset: 2px;
|
|
129
|
+
}
|
|
130
|
+
@keyframes sl-lightbox-fade {
|
|
131
|
+
from { opacity: 0; }
|
|
132
|
+
to { opacity: 1; }
|
|
133
|
+
}
|
|
134
|
+
@keyframes sl-lightbox-pop {
|
|
135
|
+
from { transform: scale(0.94); }
|
|
136
|
+
to { transform: scale(1); }
|
|
137
|
+
}
|
|
138
|
+
@media (prefers-reduced-motion: reduce) {
|
|
139
|
+
.sl-sway { animation: none; }
|
|
140
|
+
.sl-lightbox,
|
|
141
|
+
.sl-lightbox-photo,
|
|
142
|
+
.sl-lightbox-polaroid { animation: none; }
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
const defs = h('defs', null,
|
|
147
|
+
h('linearGradient', { id: 'slSunset', x1: '0', y1: '0', x2: '0', y2: '1' },
|
|
148
|
+
h('stop', { offset: '0%', stopColor: '#ffd27a' }),
|
|
149
|
+
h('stop', { offset: '52%', stopColor: '#ff9d76' }),
|
|
150
|
+
h('stop', { offset: '100%', stopColor: '#ea6a7c' })),
|
|
151
|
+
h('linearGradient', { id: 'slSea', x1: '0', y1: '0', x2: '0', y2: '1' },
|
|
152
|
+
h('stop', { offset: '0%', stopColor: '#4a7e9e' }),
|
|
153
|
+
h('stop', { offset: '100%', stopColor: '#1e405c' })),
|
|
154
|
+
h('linearGradient', { id: 'slMountainSky', x1: '0', y1: '0', x2: '0', y2: '1' },
|
|
155
|
+
h('stop', { offset: '0%', stopColor: '#b9def7' }),
|
|
156
|
+
h('stop', { offset: '100%', stopColor: '#eef8ff' })),
|
|
157
|
+
h('linearGradient', { id: 'slNight', x1: '0', y1: '0', x2: '0', y2: '1' },
|
|
158
|
+
h('stop', { offset: '0%', stopColor: '#141c33' }),
|
|
159
|
+
h('stop', { offset: '100%', stopColor: '#35486f' })),
|
|
160
|
+
h('linearGradient', { id: 'slMeadowSky', x1: '0', y1: '0', x2: '0', y2: '1' },
|
|
161
|
+
h('stop', { offset: '0%', stopColor: '#cfeaff' }),
|
|
162
|
+
h('stop', { offset: '100%', stopColor: '#ffe9ef' })),
|
|
163
|
+
h('linearGradient', { id: 'slGrass', x1: '0', y1: '0', x2: '0', y2: '1' },
|
|
164
|
+
h('stop', { offset: '0%', stopColor: '#6aa84f' }),
|
|
165
|
+
h('stop', { offset: '100%', stopColor: '#3f7a35' })),
|
|
166
|
+
h('linearGradient', { id: 'slBalloonSky', x1: '0', y1: '0', x2: '0', y2: '1' },
|
|
167
|
+
h('stop', { offset: '0%', stopColor: '#d6ecff' }),
|
|
168
|
+
h('stop', { offset: '100%', stopColor: '#fff3da' })),
|
|
169
|
+
h('linearGradient', { id: 'slHills', x1: '0', y1: '0', x2: '0', y2: '1' },
|
|
170
|
+
h('stop', { offset: '0%', stopColor: '#8fc27a' }),
|
|
171
|
+
h('stop', { offset: '100%', stopColor: '#5f9c55' })),
|
|
172
|
+
h('linearGradient', { id: 'slGlare', x1: '0', y1: '0', x2: '0.85', y2: '1' },
|
|
173
|
+
h('stop', { offset: '0%', stopColor: '#ffffff', stopOpacity: '0.16' }),
|
|
174
|
+
h('stop', { offset: '55%', stopColor: '#ffffff', stopOpacity: '0' })),
|
|
175
|
+
h('clipPath', { id: 'slClip' },
|
|
176
|
+
h('rect', { x: -26, y: 11, width: 52, height: 52, rx: 1.6 })),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
function peg() {
|
|
180
|
+
return h('g', null,
|
|
181
|
+
h('rect', { x: -3.4, y: -6, width: 6.8, height: 13, rx: 1.4, fill: '#cf9e60', stroke: '#8d6335', strokeWidth: 0.7 }),
|
|
182
|
+
h('rect', { x: -2.5, y: -4.4, width: 1.5, height: 8, rx: 0.7, fill: '#e8c18b', opacity: 0.9 }),
|
|
183
|
+
h('rect', { x: -4.6, y: -0.5, width: 9.2, height: 2.1, rx: 1, fill: '#aeb6bf' }),
|
|
184
|
+
h('rect', { x: -4.6, y: -0.5, width: 9.2, height: 0.8, rx: 0.4, fill: '#d6dde3', opacity: 0.85 }),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function frame(content) {
|
|
189
|
+
return h('g', null,
|
|
190
|
+
h('rect', { x: -29.4, y: 7.8, width: 62, height: 80, rx: 3.4, fill: '#000000', opacity: 0.09 }),
|
|
191
|
+
h('rect', { x: -31, y: 6, width: 62, height: 80, rx: 3.4, fill: '#fcf8ef', stroke: '#d8ccb6', strokeWidth: 0.8 }),
|
|
192
|
+
content,
|
|
193
|
+
h('rect', { x: -26, y: 11, width: 52, height: 52, rx: 1.6, fill: 'url(#slGlare)' }),
|
|
194
|
+
h('path', { d: 'M-13 79.5 q6.5 -2.6 13 0 q6.5 -2.6 13 0', fill: 'none', stroke: '#cfc3ad', strokeWidth: 1.1, strokeLinecap: 'round' }),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* Five little illustrated photographs. */
|
|
199
|
+
function sceneSunset() {
|
|
200
|
+
return h('g', { clipPath: 'url(#slClip)' },
|
|
201
|
+
h('rect', { x: -26, y: 11, width: 52, height: 36, fill: 'url(#slSunset)' }),
|
|
202
|
+
h('circle', { cx: 5, cy: 40, r: 12, fill: '#ffe2a3', opacity: 0.22 }),
|
|
203
|
+
h('circle', { cx: 5, cy: 40, r: 8.5, fill: '#ffe2a3' }),
|
|
204
|
+
h('path', { d: 'M-26 46 H26 V63 H-26 Z', fill: 'url(#slSea)' }),
|
|
205
|
+
h('path', { d: 'M-26 46 Q-12 44.5 0 46 T26 46', fill: 'none', stroke: '#f6cd93', strokeWidth: 1.1, opacity: 0.65 }),
|
|
206
|
+
h('path', { d: 'M-20 53 q4 -1.6 8 0 t8 0', fill: 'none', stroke: '#ffffff', strokeWidth: 0.9, opacity: 0.35 }),
|
|
207
|
+
h('path', { d: 'M-10 58 q4 -1.6 8 0 t8 0', fill: 'none', stroke: '#ffffff', strokeWidth: 0.9, opacity: 0.28 }),
|
|
208
|
+
h('path', { d: 'M-15 27 q3 -3 6 0 M2 22 q2.5 -2.5 5 0', fill: 'none', stroke: '#8c5a4d', strokeWidth: 1, strokeLinecap: 'round', opacity: 0.75 }),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sceneMountains() {
|
|
213
|
+
return h('g', { clipPath: 'url(#slClip)' },
|
|
214
|
+
h('rect', { x: -26, y: 11, width: 52, height: 52, fill: 'url(#slMountainSky)' }),
|
|
215
|
+
h('circle', { cx: -13, cy: 25, r: 5.5, fill: '#fff3c4' }),
|
|
216
|
+
h('path', { d: 'M-26 63 L-12 41 L-2 52 L9 30 L26 63 Z', fill: '#a9bfd0' }),
|
|
217
|
+
h('path', { d: 'M9 30 L5.4 38 L12.6 38 Z', fill: '#f4f9ff' }),
|
|
218
|
+
h('path', { d: 'M-26 63 L-6 47 L6 56 L17 46 L26 63 Z', fill: '#7d99ad' }),
|
|
219
|
+
h('path', { d: 'M17 46 L14 51 L20 51 Z', fill: '#eef6fc' }),
|
|
220
|
+
h('path', { d: 'M-26 63 L-22 58 L-18 63 Z', fill: '#56707f' }),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sceneNight() {
|
|
225
|
+
const stars = [[-18, 20], [-8, 31], [-1, 18], [17, 18], [20, 33], [-20, 36], [1, 37]];
|
|
226
|
+
return h('g', { clipPath: 'url(#slClip)' },
|
|
227
|
+
h('rect', { x: -26, y: 11, width: 52, height: 52, fill: 'url(#slNight)' }),
|
|
228
|
+
h('circle', { cx: 8, cy: 25, r: 7.5, fill: '#f7e8b6' }),
|
|
229
|
+
h('circle', { cx: 5.6, cy: 22.5, r: 1.5, fill: '#e3cf95', opacity: 0.8 }),
|
|
230
|
+
h('circle', { cx: 10.5, cy: 27.5, r: 1.1, fill: '#e3cf95', opacity: 0.7 }),
|
|
231
|
+
stars.map(([x, y], i) => h('circle', { key: i, cx: x, cy: y, r: 0.7, fill: '#ffffff', opacity: 0.9 })),
|
|
232
|
+
h('path', { d: 'M-26 63 L-13 49 L-4 57 L6 47 L15 55 L22 50 L26 63 Z', fill: '#101a30' }),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function sceneMeadow() {
|
|
237
|
+
const blossoms = [[-14, 51, '#f6a6c1'], [1, 46, '#f7e08a'], [14, 52, '#c9b6f2']];
|
|
238
|
+
return h('g', { clipPath: 'url(#slClip)' },
|
|
239
|
+
h('rect', { x: -26, y: 11, width: 52, height: 30, fill: 'url(#slMeadowSky)' }),
|
|
240
|
+
h('circle', { cx: 16, cy: 22, r: 4.5, fill: '#fff4c9' }),
|
|
241
|
+
h('path', { d: 'M-26 41 Q-6 36 12 41 T26 40 V63 H-26 Z', fill: 'url(#slGrass)' }),
|
|
242
|
+
blossoms.map(([x, y, color], i) => h('g', { key: i },
|
|
243
|
+
h('path', { d: 'M' + x + ' ' + (y + 8) + ' Q' + (x + 1.6) + ' ' + (y + 4) + ' ' + x + ' ' + y, fill: 'none', stroke: '#4f7a3f', strokeWidth: 1 }),
|
|
244
|
+
h('circle', { cx: x - 2, cy: y - 1.6, r: 1.5, fill: color }),
|
|
245
|
+
h('circle', { cx: x + 2, cy: y - 1.6, r: 1.5, fill: color }),
|
|
246
|
+
h('circle', { cx: x, cy: y - 4, r: 1.5, fill: color }),
|
|
247
|
+
h('circle', { cx: x, cy: y - 0.6, r: 1.4, fill: '#f3c14a' }),
|
|
248
|
+
)),
|
|
249
|
+
h('path', { d: 'M-21 24 q2.4 -3 4.8 0 q2.4 -3 4.8 0', fill: 'none', stroke: '#7f8ec9', strokeWidth: 1, opacity: 0.8 }),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function sceneBalloon() {
|
|
254
|
+
return h('g', { clipPath: 'url(#slClip)' },
|
|
255
|
+
h('rect', { x: -26, y: 11, width: 52, height: 52, fill: 'url(#slBalloonSky)' }),
|
|
256
|
+
h('ellipse', { cx: -14, cy: 22, rx: 7, ry: 3, fill: '#ffffff', opacity: 0.85 }),
|
|
257
|
+
h('ellipse', { cx: 13, cy: 18, rx: 8, ry: 3.2, fill: '#ffffff', opacity: 0.75 }),
|
|
258
|
+
h('path', { d: 'M-26 63 L-8 47 L6 56 L18 45 L26 63 Z', fill: 'url(#slHills)' }),
|
|
259
|
+
h('g', { transform: 'translate(4 24)' },
|
|
260
|
+
h('path', { d: 'M0 -14 C 11 -14 13 -4 6 4 L0 9 L-6 4 C -13 -4 -11 -14 0 -14 Z', fill: '#e2695b' }),
|
|
261
|
+
h('path', { d: 'M0 -14 C 4 -14 5 -4 3 6 L0 9 L-3 6 C -5 -4 -4 -14 0 -14 Z', fill: '#f4d06f' }),
|
|
262
|
+
h('path', { d: 'M-5 8 L-3 16 M5 8 L3 16', fill: 'none', stroke: '#8a6c4a', strokeWidth: 0.8 }),
|
|
263
|
+
h('rect', { x: -3, y: 15.5, width: 6, height: 4.2, rx: 1, fill: '#a97b45', stroke: '#7c572d', strokeWidth: 0.6 }),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const PHOTO_SCALE = 0.82;
|
|
269
|
+
|
|
270
|
+
/* Illustrations shown while the photos folder has no images. */
|
|
271
|
+
const ILLUSTRATIONS = [sceneSunset, sceneMountains, sceneNight, sceneMeadow, sceneBalloon];
|
|
272
|
+
|
|
273
|
+
const TILTS = [-2.2, 1.4, -1.1, 2.0, -1.6, 0.9];
|
|
274
|
+
const DELAYS = ['0s', '-1.8s', '-3.2s', '-0.7s', '-2.5s', '-4.1s'];
|
|
275
|
+
const DURATIONS = ['7.6s', '6.8s', '8.2s', '7.1s', '6.5s', '7.8s'];
|
|
276
|
+
const MAX_PHOTOS = 6;
|
|
277
|
+
const PHOTO_ENDPOINT = '/dsh-snap-line/photos.json';
|
|
278
|
+
const POLL_INTERVAL_MS = 5000;
|
|
279
|
+
|
|
280
|
+
/** Evenly spread photo centers along the rope, capped at the old five-photo spacing. */
|
|
281
|
+
function photoPositions(count) {
|
|
282
|
+
if (count <= 1) return [350];
|
|
283
|
+
const spacing = Math.min(116, 464 / (count - 1));
|
|
284
|
+
const first = 350 - (spacing * (count - 1)) / 2;
|
|
285
|
+
return Array.from({ length: count }, (_, index) => Math.round((first + spacing * index) * 100) / 100);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function samplePhotos(photos, limit) {
|
|
289
|
+
if (photos.length <= limit) return photos;
|
|
290
|
+
const picked = [];
|
|
291
|
+
for (let index = 0; index < limit; index += 1) {
|
|
292
|
+
picked.push(photos[Math.round((index * (photos.length - 1)) / (limit - 1))]);
|
|
293
|
+
}
|
|
294
|
+
return picked;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Poll the host's photo listing so dropped or removed images show up on their own. */
|
|
298
|
+
function usePhotoList() {
|
|
299
|
+
const [photos, setPhotos] = React.useState(null);
|
|
300
|
+
React.useEffect(() => {
|
|
301
|
+
let active = true;
|
|
302
|
+
let timer;
|
|
303
|
+
let lastKey = '';
|
|
304
|
+
const poll = async () => {
|
|
305
|
+
if (document.visibilityState === 'hidden') {
|
|
306
|
+
if (active) timer = setTimeout(poll, POLL_INTERVAL_MS);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
try {
|
|
310
|
+
const response = await fetch(PHOTO_ENDPOINT, { cache: 'no-store' });
|
|
311
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
312
|
+
const data = await response.json();
|
|
313
|
+
const next = Array.isArray(data.photos)
|
|
314
|
+
? data.photos
|
|
315
|
+
.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.url === 'string')
|
|
316
|
+
.map((entry) => ({ url: entry.url, name: typeof entry.name === 'string' ? entry.name : '' }))
|
|
317
|
+
: [];
|
|
318
|
+
const key = next.map((entry) => entry.url).join('|');
|
|
319
|
+
if (active && key !== lastKey) {
|
|
320
|
+
lastKey = key;
|
|
321
|
+
setPhotos(next);
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
if (active) setPhotos((previous) => (previous === null ? [] : previous));
|
|
325
|
+
} finally {
|
|
326
|
+
if (active) timer = setTimeout(poll, POLL_INTERVAL_MS);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
poll();
|
|
330
|
+
return () => {
|
|
331
|
+
active = false;
|
|
332
|
+
clearTimeout(timer);
|
|
333
|
+
};
|
|
334
|
+
}, []);
|
|
335
|
+
return photos;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function userPhotoImage(url) {
|
|
339
|
+
return h('g', { clipPath: 'url(#slClip)' },
|
|
340
|
+
h('image', {
|
|
341
|
+
href: url,
|
|
342
|
+
x: -26,
|
|
343
|
+
y: 11,
|
|
344
|
+
width: 52,
|
|
345
|
+
height: 52,
|
|
346
|
+
preserveAspectRatio: 'xMidYMid slice',
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function Photo(props) {
|
|
352
|
+
const { x, tilt, delay, duration, content, onOpen, label } = props;
|
|
353
|
+
const onKeyDown = (event) => {
|
|
354
|
+
if (event.key !== 'Enter' && event.key !== ' ' && event.key !== 'Spacebar') return;
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
onOpen();
|
|
357
|
+
};
|
|
358
|
+
return h('g', {
|
|
359
|
+
className: 'sl-photo',
|
|
360
|
+
transform: 'translate(' + x + ' ' + ropeY(x).toFixed(2) + ')',
|
|
361
|
+
role: 'button',
|
|
362
|
+
tabIndex: 0,
|
|
363
|
+
'aria-label': label,
|
|
364
|
+
onClick: onOpen,
|
|
365
|
+
onKeyDown,
|
|
366
|
+
},
|
|
367
|
+
h('g', { transform: 'rotate(' + tilt + ')' },
|
|
368
|
+
h('g', { transform: 'scale(' + PHOTO_SCALE + ')' },
|
|
369
|
+
h('g', {
|
|
370
|
+
className: 'sl-sway',
|
|
371
|
+
style: { animationDelay: delay, animationDuration: duration },
|
|
372
|
+
},
|
|
373
|
+
h('g', { className: 'sl-wobble' },
|
|
374
|
+
peg(),
|
|
375
|
+
frame(content),
|
|
376
|
+
),
|
|
377
|
+
),
|
|
378
|
+
),
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Full-screen viewer opened by clicking a photo; closes on Esc, backdrop or the close button. */
|
|
384
|
+
function Lightbox(props) {
|
|
385
|
+
const { preview, onClose } = props;
|
|
386
|
+
const closeRef = React.useRef(null);
|
|
387
|
+
React.useEffect(() => {
|
|
388
|
+
if (preview === null) return;
|
|
389
|
+
const previous = document.activeElement;
|
|
390
|
+
const previousOverflow = document.body.style.overflow;
|
|
391
|
+
document.body.style.overflow = 'hidden';
|
|
392
|
+
if (closeRef.current !== null) closeRef.current.focus();
|
|
393
|
+
return () => {
|
|
394
|
+
document.body.style.overflow = previousOverflow;
|
|
395
|
+
if (previous !== null && typeof previous.focus === 'function') previous.focus();
|
|
396
|
+
};
|
|
397
|
+
}, [preview]);
|
|
398
|
+
if (preview === null) return null;
|
|
399
|
+
const label = preview.kind === 'photo'
|
|
400
|
+
? preview.name || '照片 / Photo'
|
|
401
|
+
: '照片插画 / Photo illustration';
|
|
402
|
+
const card = preview.kind === 'photo'
|
|
403
|
+
? h('div', { className: 'sl-lightbox-photo' },
|
|
404
|
+
h('img', {
|
|
405
|
+
src: preview.url,
|
|
406
|
+
alt: label,
|
|
407
|
+
draggable: 'false',
|
|
408
|
+
}))
|
|
409
|
+
: h('div', { className: 'sl-lightbox-polaroid' },
|
|
410
|
+
h('svg', {
|
|
411
|
+
viewBox: '-31 6 62 80',
|
|
412
|
+
role: 'img',
|
|
413
|
+
'aria-label': label,
|
|
414
|
+
focusable: 'false',
|
|
415
|
+
},
|
|
416
|
+
frame(preview.content)));
|
|
417
|
+
const overlay = h('div', {
|
|
418
|
+
className: 'sl-lightbox',
|
|
419
|
+
role: 'dialog',
|
|
420
|
+
'aria-modal': 'true',
|
|
421
|
+
'aria-label': '照片预览 / Photo preview',
|
|
422
|
+
onKeyDown: (event) => {
|
|
423
|
+
if (event.key !== 'Tab') return;
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
if (closeRef.current !== null) closeRef.current.focus();
|
|
426
|
+
},
|
|
427
|
+
onClick: (event) => {
|
|
428
|
+
if (event.target === event.currentTarget) onClose();
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
card,
|
|
432
|
+
h('button', {
|
|
433
|
+
type: 'button',
|
|
434
|
+
className: 'sl-lightbox-close',
|
|
435
|
+
'aria-label': '关闭预览 / Close preview',
|
|
436
|
+
ref: closeRef,
|
|
437
|
+
onClick: onClose,
|
|
438
|
+
}, '×'),
|
|
439
|
+
);
|
|
440
|
+
return createPortal === null ? overlay : createPortal(overlay, document.body);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const DOCK_SVG_WIDTH = 700;
|
|
444
|
+
/*
|
|
445
|
+
* The dock entry sits in a fit-content flex row, so its `width: 100%` SVG
|
|
446
|
+
* resolves to its intrinsic 300px; `max-width` alone never changes that.
|
|
447
|
+
* The New Session hero therefore sets an explicit pixel width: twice that
|
|
448
|
+
* 300px, clamped to the composer card measured by the hero watcher.
|
|
449
|
+
*/
|
|
450
|
+
const HERO_SVG_WIDTH = 600;
|
|
451
|
+
|
|
452
|
+
function Clothesline(props) {
|
|
453
|
+
const source = props !== null && props !== undefined && props.source === 'hero' ? 'hero' : 'dock';
|
|
454
|
+
const heroWidth = source === 'hero' && props !== null && props !== undefined
|
|
455
|
+
&& typeof props.width === 'number' && props.width > 0
|
|
456
|
+
? Math.round(props.width)
|
|
457
|
+
: 0;
|
|
458
|
+
const photos = usePhotoList();
|
|
459
|
+
const [preview, setPreview] = React.useState(null);
|
|
460
|
+
React.useEffect(() => {
|
|
461
|
+
if (preview === null) return;
|
|
462
|
+
const onKeyDown = (event) => {
|
|
463
|
+
if (event.key !== 'Escape' && event.key !== 'Esc') return;
|
|
464
|
+
event.stopPropagation();
|
|
465
|
+
setPreview(null);
|
|
466
|
+
};
|
|
467
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
468
|
+
return () => document.removeEventListener('keydown', onKeyDown, true);
|
|
469
|
+
}, [preview]);
|
|
470
|
+
const usingOwnPhotos = Array.isArray(photos) && photos.length > 0;
|
|
471
|
+
const items = usingOwnPhotos
|
|
472
|
+
? samplePhotos(photos, MAX_PHOTOS).map((photo) => ({ kind: 'photo', url: photo.url, name: photo.name }))
|
|
473
|
+
: ILLUSTRATIONS.map((scene) => ({ kind: 'illustration', scene }));
|
|
474
|
+
const xs = photoPositions(items.length);
|
|
475
|
+
return h('div', {
|
|
476
|
+
role: 'group',
|
|
477
|
+
'aria-label': '照片晾衣绳 / Photo clothesline',
|
|
478
|
+
'data-dsh-snap-line': source,
|
|
479
|
+
style: {
|
|
480
|
+
display: 'flex',
|
|
481
|
+
justifyContent: 'center',
|
|
482
|
+
padding: '0 12px',
|
|
483
|
+
pointerEvents: 'none',
|
|
484
|
+
userSelect: 'none',
|
|
485
|
+
WebkitUserSelect: 'none',
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
h('style', { key: 'css' }, CSS),
|
|
489
|
+
h('svg', {
|
|
490
|
+
key: 'art',
|
|
491
|
+
viewBox: '0 0 700 108',
|
|
492
|
+
role: 'group',
|
|
493
|
+
'aria-label': '照片 / Photos',
|
|
494
|
+
focusable: 'false',
|
|
495
|
+
width: '100%',
|
|
496
|
+
style: {
|
|
497
|
+
display: 'block',
|
|
498
|
+
width: heroWidth > 0 ? heroWidth + 'px' : '100%',
|
|
499
|
+
maxWidth: heroWidth > 0 ? 'none' : DOCK_SVG_WIDTH,
|
|
500
|
+
height: 'auto',
|
|
501
|
+
margin: '2px auto 0',
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
defs,
|
|
505
|
+
h('path', { d: 'M18 14 Q350 46 682 14', fill: 'none', stroke: '#9b8b72', strokeWidth: 3.2, strokeLinecap: 'round' }),
|
|
506
|
+
h('path', { d: 'M18 14 Q350 46 682 14', fill: 'none', stroke: '#c9bca4', strokeWidth: 1, strokeLinecap: 'round', strokeDasharray: '1 4', opacity: 0.9 }),
|
|
507
|
+
h('circle', { cx: 18, cy: 14, r: 2.6, fill: '#8a7a63' }),
|
|
508
|
+
h('circle', { cx: 682, cy: 14, r: 2.6, fill: '#8a7a63' }),
|
|
509
|
+
items.map((item, index) => {
|
|
510
|
+
const content = item.kind === 'photo' ? userPhotoImage(item.url) : item.scene();
|
|
511
|
+
return h(Photo, {
|
|
512
|
+
key: item.kind === 'photo' ? 'photo-' + item.url : 'illustration-' + index,
|
|
513
|
+
x: xs[index],
|
|
514
|
+
tilt: TILTS[index % TILTS.length],
|
|
515
|
+
delay: DELAYS[index % DELAYS.length],
|
|
516
|
+
duration: DURATIONS[index % DURATIONS.length],
|
|
517
|
+
content,
|
|
518
|
+
label: item.kind === 'photo'
|
|
519
|
+
? '照片 ' + (index + 1) + (item.name === '' ? '' : ':' + item.name) + ' / Photo ' + (index + 1)
|
|
520
|
+
: '内置插画 ' + (index + 1) + ' / Built-in illustration ' + (index + 1),
|
|
521
|
+
onOpen: () => setPreview(
|
|
522
|
+
item.kind === 'photo'
|
|
523
|
+
? { kind: 'photo', url: item.url, name: item.name }
|
|
524
|
+
: { kind: 'illustration', content },
|
|
525
|
+
),
|
|
526
|
+
});
|
|
527
|
+
}),
|
|
528
|
+
),
|
|
529
|
+
h(Lightbox, { key: 'lightbox', preview, onClose: () => setPreview(null) }),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/*
|
|
534
|
+
* The host only renders `conversation.composer.dock` once a Session is
|
|
535
|
+
* active (the New Session hero shows an empty dock). While that hero is on
|
|
536
|
+
* screen, hang the same clothesline from the frame-wide overlay layer and
|
|
537
|
+
* portal it into the hero composer's always-rendered dock container, so the
|
|
538
|
+
* home page looks like every other conversation.
|
|
539
|
+
*/
|
|
540
|
+
const HERO_DOCK_SELECTOR = '[data-content-phase="hero"] [data-composer-dock]';
|
|
541
|
+
const HERO_WATCH_INTERVAL_MS = 250;
|
|
542
|
+
|
|
543
|
+
let createPortal = null;
|
|
544
|
+
try {
|
|
545
|
+
const reactDom = require('react-dom');
|
|
546
|
+
if (reactDom !== null && reactDom !== undefined && typeof reactDom.createPortal === 'function') {
|
|
547
|
+
createPortal = reactDom.createPortal;
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
createPortal = null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The widest visible hero composer dock that has no in-flow clothesline yet, plus its card width. */
|
|
554
|
+
function findHeroDock() {
|
|
555
|
+
let target = null;
|
|
556
|
+
let targetWidth = 0;
|
|
557
|
+
for (const dock of document.querySelectorAll(HERO_DOCK_SELECTOR)) {
|
|
558
|
+
if (dock.querySelector('[data-dsh-snap-line="dock"]') !== null) continue;
|
|
559
|
+
const content = dock.closest('[data-content-phase="hero"]');
|
|
560
|
+
const width = content === null ? dock.clientWidth : content.getBoundingClientRect().width;
|
|
561
|
+
if (width <= 0) continue;
|
|
562
|
+
if (target === null || width > targetWidth) {
|
|
563
|
+
target = { dock, width: heroCardWidth(dock, width) };
|
|
564
|
+
targetWidth = width;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return target;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Width of the hero composer card, used to cap the enlarged hero artwork. */
|
|
571
|
+
function heroCardWidth(dock, fallback) {
|
|
572
|
+
const parent = dock.parentElement;
|
|
573
|
+
const card = parent === null ? null : parent.querySelector('[data-composer-card]');
|
|
574
|
+
if (card === null) return Math.max(0, fallback - 16);
|
|
575
|
+
return card.getBoundingClientRect().width;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function sameHeroDock(left, right) {
|
|
579
|
+
if (left === null || right === null) return left === right;
|
|
580
|
+
return left.dock === right.dock && Math.abs(left.width - right.width) < 1;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function useHeroDock() {
|
|
584
|
+
const [dock, setDock] = React.useState(null);
|
|
585
|
+
React.useEffect(() => {
|
|
586
|
+
const update = () => {
|
|
587
|
+
if (document.visibilityState === 'hidden') return;
|
|
588
|
+
const next = findHeroDock();
|
|
589
|
+
setDock((current) => (sameHeroDock(current, next) ? current : next));
|
|
590
|
+
};
|
|
591
|
+
update();
|
|
592
|
+
const timer = setInterval(update, HERO_WATCH_INTERVAL_MS);
|
|
593
|
+
return () => clearInterval(timer);
|
|
594
|
+
}, []);
|
|
595
|
+
return dock;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Stays mounted in the shell overlay and paints into the hero composer. */
|
|
599
|
+
function HeroClothesline() {
|
|
600
|
+
const target = useHeroDock();
|
|
601
|
+
if (createPortal === null || target === null) return null;
|
|
602
|
+
const width = Math.min(HERO_SVG_WIDTH, Math.max(0, Math.round(target.width) - 24));
|
|
603
|
+
return createPortal(h(Clothesline, { source: 'hero', width }), target.dock);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
inject: ['slots'],
|
|
608
|
+
apply(ctx) {
|
|
609
|
+
ctx.slots.inject('conversation.composer.dock', () => ctx.slots.register({
|
|
610
|
+
name: 'conversation.composer.dock',
|
|
611
|
+
id: 'dsh-snap-line',
|
|
612
|
+
order: 2,
|
|
613
|
+
}, Clothesline));
|
|
614
|
+
if (createPortal === null) return;
|
|
615
|
+
try {
|
|
616
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
617
|
+
name: 'shell.overlay',
|
|
618
|
+
id: 'dsh-snap-line-hero',
|
|
619
|
+
order: 0,
|
|
620
|
+
}, HeroClothesline));
|
|
621
|
+
} catch (error) {
|
|
622
|
+
console.warn('dsh-snap-line: hero clothesline unavailable', error);
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
},
|
|
627
|
+
});
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# SnapLine bundle layer.
|
|
2
|
+
# The Host half is the plugin row below; the Client half is declared by
|
|
3
|
+
# `dsh.client` in package.json and loaded automatically by the web runtime.
|
|
4
|
+
# `name` is the npm package name, so this file works for a linked local
|
|
5
|
+
# checkout as well as for an installed (npm / git) package.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: dsh-snap-line
|
|
8
|
+
name: 'dsh-snap-line'
|
package/docs/icon.png
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/icon.svg
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="pc-icon-sky" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#ffd27a"/>
|
|
5
|
+
<stop offset="1" stop-color="#ee7286"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<path d="M3 16 Q32 28 61 16" fill="none" stroke="#8d7c63" stroke-width="2.6" stroke-linecap="round"/>
|
|
9
|
+
<circle cx="3" cy="16" r="1.8" fill="#8d7c63"/>
|
|
10
|
+
<circle cx="61" cy="16" r="1.8" fill="#8d7c63"/>
|
|
11
|
+
<g transform="translate(22 21) rotate(-5)">
|
|
12
|
+
<rect x="-8.5" y="0" width="17" height="22" rx="1.6" fill="#fcf8ef" stroke="#cdbfa6" stroke-width="0.8"/>
|
|
13
|
+
<rect x="-6.6" y="2" width="13.2" height="12" rx="0.8" fill="url(#pc-icon-sky)"/>
|
|
14
|
+
<circle cx="-1.5" cy="9.5" r="2.2" fill="#ffe2a3"/>
|
|
15
|
+
<rect x="-1.8" y="-3.6" width="3.6" height="7" rx="1.1" fill="#cf9e60" stroke="#8d6335" stroke-width="0.5"/>
|
|
16
|
+
</g>
|
|
17
|
+
<g transform="translate(41 19) rotate(4)">
|
|
18
|
+
<rect x="-8.5" y="0" width="17" height="22" rx="1.6" fill="#fcf8ef" stroke="#cdbfa6" stroke-width="0.8"/>
|
|
19
|
+
<rect x="-6.6" y="2" width="13.2" height="12" rx="0.8" fill="#a9cfe6"/>
|
|
20
|
+
<path d="M-6.6 14 L-1 7.5 L3.2 11.5 L6.6 8.5 L6.6 14 Z" fill="#6d8ea3"/>
|
|
21
|
+
<rect x="-1.8" y="-3.6" width="3.6" height="7" rx="1.1" fill="#cf9e60" stroke="#8d6335" stroke-width="0.5"/>
|
|
22
|
+
</g>
|
|
23
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SnapLine — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Serves the package's `photos/` folder to the Client half, which hangs the
|
|
5
|
+
* images on a clothesline under the message composer. Everything is served
|
|
6
|
+
* from this machine over the Harness web server; no external requests.
|
|
7
|
+
*/
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { createPhotoResource, ROUTE } from './lib/photos.js'
|
|
10
|
+
|
|
11
|
+
export const inject = ['webServer']
|
|
12
|
+
|
|
13
|
+
const PHOTOS_DIR = fileURLToPath(new URL('./photos/', import.meta.url))
|
|
14
|
+
|
|
15
|
+
export function apply(ctx) {
|
|
16
|
+
ctx.effect(() => ctx.webServer.register({
|
|
17
|
+
kind: 'prefix',
|
|
18
|
+
path: ROUTE,
|
|
19
|
+
resource: createPhotoResource(PHOTOS_DIR),
|
|
20
|
+
}), 'dsh-snap-line: photo files')
|
|
21
|
+
}
|
package/lib/photos.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Photo folder helpers for SnapLine.
|
|
3
|
+
*
|
|
4
|
+
* These functions are plain Node.js (no Cordis, no DOM) so they can be tested
|
|
5
|
+
* on their own: the Host half only has to wire them to a route.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
9
|
+
import { extname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/** Browser-facing base path for the photo manifest and the images. */
|
|
12
|
+
export const ROUTE = '/dsh-snap-line'
|
|
13
|
+
|
|
14
|
+
/** Image extensions served from the photo folder, mapped to their media type. */
|
|
15
|
+
export const MEDIA_TYPES = new Map([
|
|
16
|
+
['.jpg', 'image/jpeg'],
|
|
17
|
+
['.jpeg', 'image/jpeg'],
|
|
18
|
+
['.png', 'image/png'],
|
|
19
|
+
['.webp', 'image/webp'],
|
|
20
|
+
['.gif', 'image/gif'],
|
|
21
|
+
['.avif', 'image/avif'],
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
function notFound() {
|
|
25
|
+
return new Response(null, { status: 404 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One photo entry's browser URL, versioned by mtime so replacements are not cached. */
|
|
29
|
+
export function photoUrl(name, mtimeMs) {
|
|
30
|
+
return `${ROUTE}/photos/${encodeURIComponent(name)}?v=${Math.round(mtimeMs)}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Current photo folder contents as `{ photos: [{ name, url }], revision }`.
|
|
35
|
+
* Missing folder counts as empty; each photo's mtime feeds the revision.
|
|
36
|
+
*/
|
|
37
|
+
export async function listPhotos(dir) {
|
|
38
|
+
let entries
|
|
39
|
+
try {
|
|
40
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === 'ENOENT') return { photos: [], revision: 'empty' }
|
|
43
|
+
throw error
|
|
44
|
+
}
|
|
45
|
+
const photos = []
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue
|
|
48
|
+
/* Hidden names (macOS `._*`, `.DS_Store`, dotfiles) never reach the manifest. */
|
|
49
|
+
if (entry.name.startsWith('.')) continue
|
|
50
|
+
if (!MEDIA_TYPES.has(extname(entry.name).toLowerCase())) continue
|
|
51
|
+
const path = join(dir, entry.name)
|
|
52
|
+
try {
|
|
53
|
+
const info = await stat(path)
|
|
54
|
+
if (!info.isFile()) continue
|
|
55
|
+
photos.push({ name: entry.name, mtimeMs: info.mtimeMs })
|
|
56
|
+
} catch {
|
|
57
|
+
// Removed while listing.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
photos.sort((left, right) => left.name.localeCompare(right.name, 'en', { numeric: true }))
|
|
61
|
+
const revision = createHash('sha1')
|
|
62
|
+
.update(photos.map((photo) => `${photo.name}\u0000${photo.mtimeMs}`).join('\n'))
|
|
63
|
+
.digest('hex')
|
|
64
|
+
.slice(0, 12)
|
|
65
|
+
return {
|
|
66
|
+
photos: photos.map(({ name, mtimeMs }) => ({ name, url: photoUrl(name, mtimeMs) })),
|
|
67
|
+
revision,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One image from the photo folder.
|
|
73
|
+
* Path traversal, hidden names, and unknown media types answer 404.
|
|
74
|
+
*/
|
|
75
|
+
export async function servePhoto(dir, encodedName) {
|
|
76
|
+
let name
|
|
77
|
+
try {
|
|
78
|
+
name = decodeURIComponent(encodedName)
|
|
79
|
+
} catch {
|
|
80
|
+
return new Response(null, { status: 400 })
|
|
81
|
+
}
|
|
82
|
+
if (name.includes('/') || name.includes('\\') || name.startsWith('.')) return notFound()
|
|
83
|
+
const mediaType = MEDIA_TYPES.get(extname(name).toLowerCase())
|
|
84
|
+
if (mediaType === undefined) return notFound()
|
|
85
|
+
try {
|
|
86
|
+
const body = await readFile(join(dir, name))
|
|
87
|
+
return new Response(body, {
|
|
88
|
+
headers: {
|
|
89
|
+
'content-type': mediaType,
|
|
90
|
+
'content-length': String(body.byteLength),
|
|
91
|
+
'cache-control': 'private, max-age=60',
|
|
92
|
+
'x-content-type-options': 'nosniff',
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
} catch {
|
|
96
|
+
return notFound()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Route resource for a `kind: 'prefix'` webServer registration:
|
|
102
|
+
* `GET <route>/photos.json` and `GET <route>/photos/<name>`.
|
|
103
|
+
*/
|
|
104
|
+
export function createPhotoResource(dir, route = ROUTE) {
|
|
105
|
+
const photosPrefix = `${route}/photos/`
|
|
106
|
+
return async (request) => {
|
|
107
|
+
const { pathname } = new URL(request.url)
|
|
108
|
+
if (pathname === `${route}/photos.json`) {
|
|
109
|
+
const listing = await listPhotos(dir)
|
|
110
|
+
return new Response(JSON.stringify(listing), {
|
|
111
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
if (pathname.startsWith(photosPrefix)) return servePhoto(dir, pathname.slice(photosPrefix.length))
|
|
115
|
+
return notFound()
|
|
116
|
+
}
|
|
117
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-snap-line",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"icon": "icon.svg",
|
|
5
|
+
"description": "Hangs a clothesline of polaroid photos under the message composer in DeepSeek Harness.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "LMhuo (https://github.com/LMhuo)",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"dsh",
|
|
11
|
+
"dsh-plugin",
|
|
12
|
+
"dsh-bundle",
|
|
13
|
+
"deepseek-harness",
|
|
14
|
+
"clothesline",
|
|
15
|
+
"polaroid",
|
|
16
|
+
"photo",
|
|
17
|
+
"decoration"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/LMhuo/dsh-snap-line.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/LMhuo/dsh-snap-line#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/LMhuo/dsh-snap-line/issues"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20",
|
|
29
|
+
"dsh": "^0.2.0"
|
|
30
|
+
},
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.js",
|
|
33
|
+
"./client": "./client.js",
|
|
34
|
+
"./locale/*.json": "./locale/*.json",
|
|
35
|
+
"./icon": "./icon.svg"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"index.js",
|
|
39
|
+
"lib",
|
|
40
|
+
"client.js",
|
|
41
|
+
"cordis.patch.yml",
|
|
42
|
+
"locale/*.json",
|
|
43
|
+
"icon.svg",
|
|
44
|
+
"docs/images",
|
|
45
|
+
"docs/icon.png"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"check": "node --check index.js && node --check client.js && node --check lib/photos.js && node --test",
|
|
49
|
+
"test": "node --test",
|
|
50
|
+
"prepublishOnly": "npm run check"
|
|
51
|
+
},
|
|
52
|
+
"dsh": {
|
|
53
|
+
"manifestVersion": 1,
|
|
54
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
55
|
+
"client": {
|
|
56
|
+
"platform": "web",
|
|
57
|
+
"immediately": true,
|
|
58
|
+
"inject": ["@deepseek-ai/dsh-client-ui-conversation"]
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|