@restregular/dsh-notepad 0.6.0 → 0.7.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/README.md +41 -31
- package/client.js +149 -85
- package/index.js +1 -1
- package/lib/note-store.js +14 -8
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# dsh-notepad
|
|
2
2
|
|
|
3
|
-
在 DeepSeek Harness
|
|
3
|
+
在 DeepSeek Harness 的会话界面里放一张**属于当前工作区**的分段便签浮窗:一行一个问题、一段一个卡片,回车就能拆出下一段;需要时把某一段、或勾选后的多段一起插入 / 替换 / 发送到对话框。除了文字,也可以直接**粘贴或选择图片**,输入框里会用缩略芯片显示并可点击预览。0.4 起支持选中、拖拽排序、合并多段;0.5 起空标题只在点击后显示输入框,方向键可切换分段,AI 优化只处理选中的分段并支持撤销 / 缓存;0.6 起插入 / 发送按钮 hover 时展开成「当前会话 / 新会话」按钮组,图片可在浮窗内预览,底部快捷键提示点击轮换,复制全部排除归档,发送行为跟随「设置 > 通用设置」的繁忙发送策略;0.7 起分段改为从上到下、旧→新排列,回车拆分的新卡片紧跟原卡片,段首 Backspace 与上一段合并,空分段按 Backspace 或点删除会直接移除,插入 / 发送 / 归档+删除按钮组会稍候再展开以避免快速划过闪烁,文本达到最大高度才出现滚动条,复位快捷键改为 `Ctrl/Cmd + Shift + R`。
|
|
4
4
|
|
|
5
|
-
> A per-workspace floating segmented notepad for DeepSeek Harness sessions.
|
|
5
|
+
> A per-workspace floating segmented notepad for DeepSeek Harness sessions. Cards run oldest-to-newest; Enter splits after the source card and Backspace at the start merges into the previous card, while empty cards are removed by Backspace or the delete action without a prompt. An empty title is a blank click target, arrow keys move between cards, and AI improvement only touches the selected cards — the button toggles to undo and unchanged content reuses the cached answer. Hover groups wait briefly before opening: insert/send include current-session and new-session actions, archive+delete shares one group, and replace stays single. Image chips open an in-panel preview, the footer rotates shortcut tips, copy-all skips archived content, sending follows the General Settings busy-send policy, and the textarea only scrolls at its height limit. Auto refresh preserves the list scroll position, `Ctrl/Cmd + Shift + R` (or the header reset button) resets the geometry, the panel never covers the host title bar, and notes live in `<workspace>/.dsh/notepad.json` shared by every session from that workspace.
|
|
6
6
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
@@ -20,39 +20,46 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
20
20
|
│ ☐ 全选 勾选分段可批量操作 ↙ ⇄ ➤ ⊕ 合并 ✦ 归档 │
|
|
21
21
|
│ ┌────────────────────────────────────────┐ │
|
|
22
22
|
│ │ ☐ 01 [标题] 🖼 ↙ ⇄ ➤ ✦ 归档 │
|
|
23
|
-
│ │
|
|
23
|
+
│ │ 记录较早的一段…… │ │
|
|
24
24
|
│ └────────────────────────────────────────┘ │
|
|
25
25
|
│ ┌────────────────────────────────────────┐ │
|
|
26
26
|
│ │ ☐ 02 [标题] 🖼 ↙ ⇄ ➤ ✦ 归档 │
|
|
27
|
-
│ │
|
|
27
|
+
│ │ 记录最新的一段…… │ │
|
|
28
28
|
│ └────────────────────────────────────────┘ │
|
|
29
29
|
│ 已保存 · 42 字符 · 2 段 Enter 新建分段… │
|
|
30
30
|
└────────────────────────────────────────────┘
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
-
|
|
33
|
+
- **排列方向**:卡片从上到下按旧分段到新分段排列;`+` 新建的分段追加在列表底部,拖拽后按你的顺序保存。
|
|
34
34
|
- **标题**:空标题不显示任何占位文字,只保留一块可点击的空白;点击后才出现输入框。已有标题的卡片直接显示输入框,边输入边自动保存;在输入框里清空后不会立刻消失,失焦才回到空白状态。
|
|
35
35
|
- **序号**:显示当前顺序(`01`、`02`…),点击可选中 / 取消选中这一段,拖动序号可调整卡片顺序。
|
|
36
36
|
- **AI 优化**:勾选分段后,顶部工具栏的 ✦ 只优化选中的分段;卡片工具栏的 ✦ 优化此段。优化成功后,刚点过的按钮变成「撤销优化」,再点一次恢复原文;相同且未改动的标题 + 内容再次优化时直接复用上次结果,不再调用大模型。
|
|
37
37
|
- **方向键**:光标在分段首行按 `↑` 跳到上一张卡片末尾;在末行按 `↓` 跳到下一张卡片开头。多行分段内部仍按正常文本光标移动。
|
|
38
|
-
-
|
|
38
|
+
- **按钮组**:插入、发送、归档+删除按钮在鼠标 hover 约 0.2 秒后才横向展开成按钮组,并用细圆角边框框起,快速划过不会闪烁:
|
|
39
|
+
- 插入 / 发送组:当前会话 + 在同一工作区新会话中执行;
|
|
40
|
+
- 归档+删除组:归档(主按钮)+ 直接删除(悬停后出现的危险按钮);
|
|
41
|
+
- 替换按钮保持单键。
|
|
42
|
+
- **回车拆分**:在分段中按回车会从光标处拆开,新卡片**紧跟原卡片下方**;在末尾回车就是在该段后面新建一个空分段。
|
|
43
|
+
- **Backspace 合并 / 删除**:光标在段首按 `Backspace` 会与上一段合并;如果当前段完全为空(无标题、无文字、无图片),则直接删除该段,不弹确认框。删除按钮对空分段同样跳过确认。
|
|
39
44
|
- **图片预览**:点击任意图片芯片会在浮窗内打开预览(带「打开原图」和关闭按钮),点击遮罩或按 `Esc` 关闭。
|
|
40
|
-
-
|
|
45
|
+
- **删除分段**:非空分段的删除按钮位于归档+删除按钮组的展开项中,点击后弹窗二次确认,并明确提示删除后无法恢复;确认后直接删除,不进入归档,也不提供撤销。
|
|
41
46
|
- **复制全部**:只复制未归档分段,归档内容不会混入结果。
|
|
42
47
|
- **快捷键提示**:底部一次只显示一条快捷键提示,点击它会轮换显示下一条。
|
|
43
48
|
- **发送行为**:发送按钮在智能体繁忙时使用「设置 > 通用设置 > 繁忙时的发送行为」中选择的排队 / 插话策略;空闲时按普通发送处理。
|
|
44
|
-
- **复位浮窗**:`Ctrl / Cmd + Shift +
|
|
49
|
+
- **复位浮窗**:`Ctrl / Cmd + Shift + R` 或标题栏的复位按钮把位置和尺寸恢复为右下角的默认 `420 × 460`,最小化 / 归档展开等状态不变。
|
|
50
|
+
- **高度上限**:分段文本区最大 240px,只有内容确实超过该高度时才出现纵向滚动条;未到上限前不会预留滚动条。
|
|
45
51
|
- **合并**:勾选 2 段以上后点顶部工具栏的合并按钮,合并结果留在最靠上的选中位置。
|
|
46
|
-
-
|
|
47
|
-
- **Shift + Enter**:分段内换行。
|
|
48
|
-
- **Ctrl / Cmd + Enter**:把当前分段(有勾选时改为勾选的多段)插入对话框。
|
|
52
|
+
- **Enter**:拆分当前分段(见上);**Shift + Enter** 是分段内换行;**Ctrl / Cmd + Enter** 把当前分段(有勾选时改为勾选的多段)插入对话框。
|
|
49
53
|
- 卡片上的 **↙** 插入此段;**⇄** 用此段替换整个对话框内容;**➤** 直接发送此段。
|
|
50
54
|
|
|
51
55
|
## 功能
|
|
52
56
|
|
|
53
57
|
| 操作 | 入口 | 说明 |
|
|
54
58
|
| --- | --- | --- |
|
|
55
|
-
| 新建分段 | 标题栏 `+`,或分段末尾按回车 |
|
|
59
|
+
| 新建分段 | 标题栏 `+`,或分段末尾按回车 | 新卡片追加在列表底部(旧→新),并自动获得焦点 |
|
|
60
|
+
| 回车拆分 | 分段文本内按 `Enter` | 从光标处拆开,新卡片紧跟当前卡片下方 |
|
|
61
|
+
| Backspace 合并 | 光标在分段最前面按 `Backspace` | 与上一张卡片合并,光标停在新旧文字交界处 |
|
|
62
|
+
| 空分段删除 | 空分段内按 `Backspace`,或空分段的删除按钮 | 无标题、无文字、无图片时直接删除,不弹确认 |
|
|
56
63
|
| 编辑分段 | 点击卡片文本区 | 停止输入约 0.5 秒后自动保存 |
|
|
57
64
|
| 分段标题 | 空标题的空白点击区 / 已有标题的输入框 | 空标题不显示文字和 hover 效果,点击后才出现输入框;停止输入约 0.5 秒后自动保存 |
|
|
58
65
|
| 选中分段 | 点击左上角序号,或左侧复选框 | 序号与复选框状态同步;点击序号即可参与批量操作 |
|
|
@@ -70,23 +77,24 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
70
77
|
| 带图插入 / 发送 | 与文字相同入口 | 图片以 `` 追加在分段文字之后 |
|
|
71
78
|
| 单段插入 | 卡片上的左下箭头 | 插入到当前会话输入框光标处,不发送 |
|
|
72
79
|
| 多段插入 | 勾选分段 → 工具栏左下箭头,或全选后操作 | 按卡片顺序用空行合并后一次插入 |
|
|
73
|
-
| 在新会话中插入 | hover
|
|
80
|
+
| 在新会话中插入 | hover 插入按钮片刻后出现的第二个按钮 | 在同一工作区新建会话并打开,内容填进它的输入框 |
|
|
74
81
|
| 替换对话框 | 卡片上的 ⇄,或勾选后工具栏 ⇄ | 用该段 / 多段合并内容替换整个输入框(没有按钮组) |
|
|
75
82
|
| 单段发送 | 卡片上的纸飞机 | 直接发进当前会话;发送成功后自动归档该段 |
|
|
76
83
|
| 多段发送 | 勾选分段 → 工具栏纸飞机 | 合并成一条消息发送;发送成功后自动归档这些段 |
|
|
77
|
-
| 在新会话中发送 | hover
|
|
84
|
+
| 在新会话中发送 | hover 发送按钮片刻后出现的第二个按钮 | 在同一工作区新建会话并发送,随后切换到该会话 |
|
|
78
85
|
| 全选 | 列表上方「全选」复选框 | 一键选中所有未归档分段,再点一次取消 |
|
|
79
|
-
| 归档 |
|
|
86
|
+
| 归档 | 卡片上归档+删除按钮组的主按钮,或工具栏归档图标 | 把分段移入归档区(不发送) |
|
|
80
87
|
| 显示 / 隐藏归档 | 标题栏归档图标 | 展开 / 收起归档区,状态按浏览器记忆 |
|
|
81
88
|
| 恢复 | 归档卡片上的恢复图标 | 把归档分段放回未归档列表 |
|
|
82
|
-
| 删除(未归档) |
|
|
89
|
+
| 删除(未归档) | 归档+删除按钮组中 hover 出现的垃圾桶 | 空分段直接删除;非空分段弹窗二次确认并提示 **删除后无法恢复** |
|
|
83
90
|
| 删除(已归档) | 归档卡片上的垃圾桶 | 删除归档分段,可撤销 |
|
|
84
91
|
| 清空归档 | 归档区标题「清空归档」 | 批量删除归档,可撤销 |
|
|
85
92
|
| 复制全部 | 标题栏复制按钮 | 只复制未归档分段,用空行合并;归档内容不包含在内 |
|
|
86
93
|
| 快捷键提示 | 底部的一条提示文字 | 点击轮换显示下一条快捷键说明 |
|
|
87
94
|
| 发送策略 | 卡片 / 工具栏发送按钮 | 繁忙时按「设置 > 通用设置 > 繁忙时的发送行为」选择排队或插话 |
|
|
95
|
+
| 滚动条 | 分段文本达到 240px 高度上限后 | 未到上限不显示纵向滚动条,超过后才出现 |
|
|
88
96
|
| 调整尺寸 | 拖动右下角斜纹手柄 | 宽高按浏览器记忆,最小 320 × 240 |
|
|
89
|
-
| 复位位置和尺寸 | `Ctrl/Cmd + Shift +
|
|
97
|
+
| 复位位置和尺寸 | `Ctrl/Cmd + Shift + R`,或标题栏复位按钮 | 恢复默认 420 × 460 并停到右下角,其它状态不变 |
|
|
90
98
|
| 折叠 / 展开 | 标题栏上下折叠箭头 | 只保留标题栏,内容继续自动保存 |
|
|
91
99
|
| 最小化 / 恢复 | 标题栏最小化图标,或 `Ctrl/Cmd + Shift + N` | 收成右下角小图标,带平滑过渡 |
|
|
92
100
|
| 拖动位置 | 按住标题栏拖动 | 位置按浏览器记忆;不会越过 DSH 窗口最顶部的标题栏(最大化 / 最小化 / 关闭那一栏) |
|
|
@@ -105,8 +113,14 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
105
113
|
|
|
106
114
|
```json
|
|
107
115
|
{
|
|
108
|
-
"version":
|
|
116
|
+
"version": 5,
|
|
109
117
|
"segments": [
|
|
118
|
+
{
|
|
119
|
+
"id": "seg-1",
|
|
120
|
+
"text": "遇到的问题\n下一步:补测试",
|
|
121
|
+
"title": "待办",
|
|
122
|
+
"createdAt": "2026-10-23T03:19:57.000Z"
|
|
123
|
+
},
|
|
110
124
|
{
|
|
111
125
|
"id": "seg-2",
|
|
112
126
|
"text": "看这张报错截图",
|
|
@@ -114,23 +128,17 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
114
128
|
"createdAt": "2026-10-23T03:19:58.000Z",
|
|
115
129
|
"images": [{ "path": ".dsh/notepad/images/m1x2-image.png", "name": "image.png" }]
|
|
116
130
|
},
|
|
117
|
-
{
|
|
118
|
-
"id": "seg-1",
|
|
119
|
-
"text": "遇到的问题\n下一步:补测试",
|
|
120
|
-
"title": "待办",
|
|
121
|
-
"createdAt": "2026-10-23T03:19:57.000Z"
|
|
122
|
-
},
|
|
123
131
|
{ "id": "seg-3", "text": "已发送的分段", "archived": true, "archivedAt": "2026-10-23T03:20:00.000Z" }
|
|
124
132
|
],
|
|
125
|
-
"text": "看这张报错截图\n\n\n
|
|
133
|
+
"text": "遇到的问题\n下一步:补测试\n\n看这张报错截图\n\n\n已发送的分段",
|
|
126
134
|
"updatedAt": "2026-10-23T03:20:01.000Z"
|
|
127
135
|
}
|
|
128
136
|
```
|
|
129
137
|
|
|
130
|
-
`text` 是 `segments
|
|
138
|
+
`text` 是 `segments`(含图片引用)用空行合并后的副本,方便人工查看,也兼容旧版本。v5 起卡片按从旧到新、从上到下的顺序保存;读取到 v4(0.4–0.6)文件时,客户端会**反转一次**以迁移顺序,之后按卡片数组顺序保存(拖拽后的顺序不会被自动重排)。旧版单段文件(只有 `text`)、v2/v3 分段文件和手动改成纯文本的文件会在读取时迁移为分段;`images[].path` 工作区内用相对路径,工作区外保留绝对路径;`name` 仅用于显示和 Markdown 替代文本。`title` 最长 200 字符。
|
|
131
139
|
- **粘贴的图片**保存在 `<工作区>/.dsh/notepad/images/`,由宿主接口原子写入,只接受 PNG / JPEG / WebP / GIF,单张最大 20 MB;笔记只保存路径,不内嵌 base64。**选择的图片**直接引用原路径,不复制。每段最多 20 张。
|
|
132
140
|
- **删除图片芯片**会同时删除插件自己保存在 `.dsh/notepad/images/` 下的对应文件;引用工作区外原路径的图片只删引用,不碰原文件。删除已归档分段 / 清空归档 / 直接删除未归档分段时,未被其它分段引用的插件图片会在撤销窗口(未归档删除没有撤销窗口,按同样延迟)结束后自动清理。
|
|
133
|
-
- 浮窗位置、尺寸、折叠 / 最小化 / 隐藏状态、归档区是否展开保存在浏览器 `localStorage` 的 `dsh-notepad.ui.v1` 中,按浏览器 / 设备生效,不写入工作区。位置会被限制在宿主标题栏下方(读取 DSH 的 `--dsh-frame-chrome-top` / `--dsh-frame-top-clearance`),`Ctrl/Cmd + Shift +
|
|
141
|
+
- 浮窗位置、尺寸、折叠 / 最小化 / 隐藏状态、归档区是否展开保存在浏览器 `localStorage` 的 `dsh-notepad.ui.v1` 中,按浏览器 / 设备生效,不写入工作区。位置会被限制在宿主标题栏下方(读取 DSH 的 `--dsh-frame-chrome-top` / `--dsh-frame-top-clearance`),`Ctrl/Cmd + Shift + R` 或标题栏复位按钮可随时复位位置和尺寸。
|
|
134
142
|
- 每个工作区一份笔记,最多 **200 段 / 512 KB**(单段输入框限制 16 万字符,每段最多 20 张图片)。浮窗内是纯文本编辑,不做 Markdown 渲染;图片以缩略芯片展示。
|
|
135
143
|
- 浮窗只在**会话视图**出现:打开设置等非会话页面时自动隐藏,回到会话后恢复。
|
|
136
144
|
- 写入走宿主同源回环路由 `/dsh-notepad/api/note`,粘贴图片走 `/dsh-notepad/api/image`,AI 优化走 `/dsh-notepad/api/optimize`,侧边栏角标计数走 `/dsh-notepad/api/unsent`;这些都只接受 127.0.0.1 的同源请求,并且只允许当前会话的 cwd 或已注册工作区。图片接口只会在固定的 `.dsh/notepad/images/` 下写入 / 删除合法的图片文件,不能用来读写或删除工作区里的其它文件。AI 优化使用当前默认模型,未配置模型时返回 `unavailable`,不会影响记事本的其它功能。
|
|
@@ -140,9 +148,9 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
140
148
|
## 实现
|
|
141
149
|
|
|
142
150
|
- **Host**(`index.js` + `lib/note-store.js` + `lib/image-store.js`):`ctx.inject(['webServer'])` 注册 `/dsh-notepad/api` 下的 `GET/PUT /note`、`POST/DELETE /image`、`GET /unsent` 与 `POST /optimize`,用临时文件 + rename 原子写入 `.dsh/notepad.json`;`GET` 返回 `segments`(含 `title` / `createdAt` / `images`)与合并后的 `text`,`PUT` 接受 `{ segments: [...] }`,也兼容旧的 `{ text }`。`POST /image` 校验 base64、MIME 与文件签名后写入 `.dsh/notepad/images/`;`DELETE /image` 只允许删除该目录下的普通文件。`/unsent` 为已注册工作区统计未发送段数;`/optimize` 先用 `workspace + title + text` 查进程内缓存,命中时返回 `{ cached: true }`,否则通过 `agentDefaultModel.currentSelection()` + `llm.prepareCall()` 调用当前默认模型,流式拼接文本后写入缓存并返回。
|
|
143
|
-
- **Client**(`client.js`):整个 bundle 包在 IIFE 里;通过 `shell.overlay` 注册浮窗,读取 `uiSession.adapter.current`
|
|
151
|
+
- **Client**(`client.js`):整个 bundle 包在 IIFE 里;通过 `shell.overlay` 注册浮窗,读取 `uiSession.adapter.current` 得到当前会话与工作区。卡片按从旧到新排列,新建/拆分都追加在原卡片之后;光标在段首按 `Backspace` 时,空分段直接删除,非空分段与上一段合并(合并标题、文字与图片,并保持光标在交界处)。标题、序号、拖拽排序和合并都在本地分段数组上完成,数组顺序就是保存顺序;空标题用无内容的点击区渲染,点击后才挂载输入框,`↑` / `↓` 在首 / 末行时把焦点和光标移到相邻卡片。插入、发送、归档+删除使用同一个 `actionGroup()` 组件;按钮组用纯 CSS 延迟约 0.2 秒展开(收起也有短暂延迟),避免鼠标快速划过时闪烁。插入 / 发送组的新会话通过 `uiWorkspace.startSession()`(插入)或 `sessions.create()` + 会话发送(发送)完成;归档+删除组主按钮归档,悬停出现的危险按钮删除。文本区由 `autoGrow()` 在 26–240px 之间自适应,只有 `scrollHeight > 240` 时才把 `overflow-y` 切到 `auto`。图片芯片打开面板内的预览遮罩,支持打开原图与 `Esc` 关闭;非空删除走自定义二次确认,空分段跳过确认,确认后立即从分段数组移除并清理未被引用的图片。复制全部只合并未归档分段;底部快捷键提示是一个按钮,每次只渲染一条并通过 `HINT_KEYS` 轮换。侧边栏角标用 `MutationObserver` 监听 `[data-row-key^="workspace:"]` 并把未发送数量写成 `data-np-unsent` 属性,由 CSS 伪元素显示。粘贴图片用 `FileReader` 读成 base64 交给宿主保存;选择 / 拖入图片优先通过桌面桥 `__DSH_HOST_PATHS__.pathFor(file)` 拿宿主路径。插入 / 替换通过会话绑定里的 `inputActions.captureInsertion()/insertText()` 与 `setDraft()`;发送优先按繁忙策略选择 `sessions.scope(id).conversation.send()`(排队)或 `sessions.binding(id).session.prompt(..., 'steer')`(插话),steer 不可用时回退到排队。图片在插入 / 发送时以 Markdown 图片语法追加,聊天记录里的 DSH 渲染器会按工作区 cwd 解析这些路径。
|
|
144
152
|
- AI 优化只改分段文字,不动标题和图片;单段优化后该卡片按钮变为「撤销优化」,批量优化后工具栏按钮变为「撤销优化」,撤销会恢复优化前的文字。优化结果按内容缓存在客户端与宿主两侧,相同内容再次优化不会重复请求;没有默认模型或模型调用失败时只提示错误,不影响笔记读写。
|
|
145
|
-
- 浮窗坐标同时受视口和宿主标题栏约束:拖动、调整尺寸、窗口缩放、标题栏复位按钮和快捷键复位都走同一个 `clampUi()`,最小 `top` 取 `--dsh-frame-chrome-top`(全屏时为 0)/ `--dsh-frame-top-clearance` 再加 8px。复位快捷键优先注册到 DSH 的 `shortcuts` 服务(默认 `Ctrl/Cmd + Shift +
|
|
153
|
+
- 浮窗坐标同时受视口和宿主标题栏约束:拖动、调整尺寸、窗口缩放、标题栏复位按钮和快捷键复位都走同一个 `clampUi()`,最小 `top` 取 `--dsh-frame-chrome-top`(全屏时为 0)/ `--dsh-frame-top-clearance` 再加 8px。复位快捷键优先注册到 DSH 的 `shortcuts` 服务(默认 `Ctrl/Cmd + Shift + R`、`regions` 含 `editable`),注册失败时保留原始 keydown 监听,标题栏也有可点击的复位按钮。
|
|
146
154
|
- 发送模式与 DSH 输入框一致:读取 `sessions.list` 的 `running`,繁忙时读取 `configForms.get('ui-conversation')` 快照里的 `busyEnter`(`queue` / `steer`);空闲时始终排队。
|
|
147
155
|
- 文案通过宿主 `locale` 服务注册中英文;找不到 locale 时按浏览器语言回退。
|
|
148
156
|
|
|
@@ -167,16 +175,18 @@ node test/package.test.mjs
|
|
|
167
175
|
npm test
|
|
168
176
|
```
|
|
169
177
|
|
|
170
|
-
`test/client-smoke.mjs` 会真正执行 `apply(ctx)`
|
|
178
|
+
`test/client-smoke.mjs` 会真正执行 `apply(ctx)` 并渲染浮窗,覆盖:分段加载、编辑自动保存、自动刷新保持列表滚动位置、新分段追加到底部、空标题空白点击区与点击后出现输入框、标题自动保存、序号选中、方向键跨分段切换光标、段首 Backspace 合并上一段、空分段 Backspace / 删除按钮无确认删除、回车拆分后新卡片紧跟原卡片、文本区未到高度上限不显示滚动条、合并、单段 / 选中分段 AI 优化、优化后按钮切换为撤销、相同内容命中缓存、插入 / 发送 / 归档+删除 hover 按钮组(含延迟 CSS)与在新会话中插入 / 发送、图片面板内预览、非空删除的二次确认与不可撤销、底部快捷键轮换、复制全部排除归档、繁忙发送策略、拖拽排序、未发送角标、侧边栏工作区角标、单段/多段插入与替换、单段/多段发送与自动归档、归档显示隐藏、归档恢复与删除(含撤销)、全选、折叠、最小化 / 恢复、拖动调整尺寸、标题栏下方位置限制、全局快捷键与 `Ctrl+Shift+R` 复位、DSH `shortcuts` 命令注册(`KeyR`)、深色模式与字体 CSS、重复执行 bundle 的兼容性,以及粘贴 / 选择图片、图片芯片、删除芯片时清理磁盘文件与带图插入发送。
|
|
171
179
|
|
|
172
180
|
## 已知限制
|
|
173
181
|
|
|
174
182
|
- 浮窗不会自动把内容推给 Agent;发送使用当前会话的发送接口,繁忙时按「设置 > 通用设置」的繁忙发送策略排队或插话。
|
|
175
183
|
- 发送按钮依赖 DSH 的会话接口;接口不可用时降级为插入输入框并提示。
|
|
176
184
|
- 「在新会话中插入 / 发送」依赖 DSH 的 `uiWorkspace` / `sessions` 服务;不可用时插入会降级为复制,发送会提示失败。
|
|
185
|
+
- `Ctrl/Cmd + Shift + R` 在部分浏览器里是硬刷新快捷键;DSH 桌面端通过 `shortcuts` 先消费该组合,网页端如果仍被浏览器占用,可用标题栏的复位按钮。
|
|
177
186
|
- 图片只支持 PNG / JPEG / WebP / GIF,单张最大 20 MB、每段最多 20 张;其它附件类型暂不支持。
|
|
178
187
|
- AI 优化需要 DSH 里已配置默认模型;未配置时按钮会提示不可用,记笔记本身不受影响。优化缓存保存在宿主进程内,重启 DSH 或切换默认模型后,相同内容会再请求一次。
|
|
179
|
-
-
|
|
188
|
+
- 删除非空未归档分段不可撤销;确认后不会进入归档,只会在延迟窗口后清理未被其它分段引用的插件图片。空分段直接删除。
|
|
189
|
+
- 从 v4 笔记迁移到 v5 时会整体反转一次卡片顺序;如果你之前在 v4 里手动拖过顺序,这次反转会把它倒过来,之后按新顺序保存。
|
|
180
190
|
- 删除图片芯片会删除插件目录里的文件;如果同一路径仍被其它分段引用,则保留文件。选择的原路径图片只删引用,不删除原图。
|
|
181
191
|
- 网页版没有 `__DSH_HOST_PATHS__` 桥,选择 / 拖入的图片会像粘贴一样先复制到 `.dsh/notepad/images/`。
|
|
182
192
|
- 图片在浮窗里用缩略芯片表示,不提供富文本行内图文混排;插入 / 发送后由聊天记录按 Markdown 渲染。
|
package/client.js
CHANGED
|
@@ -50,6 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
const SAVE_DELAY = 500;
|
|
51
51
|
const MAX_SEGMENTS = 200;
|
|
52
52
|
const MAX_SEGMENT_CHARS = 160000;
|
|
53
|
+
const MAX_SEGMENT_HEIGHT = 240;
|
|
53
54
|
const MAX_TITLE_CHARS = 200;
|
|
54
55
|
const MAX_IMAGES = 20;
|
|
55
56
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
@@ -179,7 +180,7 @@ window.__ModuleLoader__.load({
|
|
|
179
180
|
hintNewline: 'Shift+Enter 分段内换行',
|
|
180
181
|
hintInsert: 'Ctrl+Enter 插入当前分段',
|
|
181
182
|
hintToggle: 'Ctrl+Shift+N 显示 / 隐藏浮窗',
|
|
182
|
-
hintReset: 'Ctrl+Shift+
|
|
183
|
+
hintReset: 'Ctrl+Shift+R 复位浮窗',
|
|
183
184
|
hintArrow: '↑ / ↓ 切换分段',
|
|
184
185
|
hintPaste: '粘贴图片自动保存到工作区',
|
|
185
186
|
resetShortcut: '复位记事本位置和尺寸',
|
|
@@ -312,7 +313,7 @@ window.__ModuleLoader__.load({
|
|
|
312
313
|
hintNewline: 'Shift+Enter adds a line break',
|
|
313
314
|
hintInsert: 'Ctrl+Enter inserts the current segment',
|
|
314
315
|
hintToggle: 'Ctrl+Shift+N shows / hides the notepad',
|
|
315
|
-
hintReset: 'Ctrl+Shift+
|
|
316
|
+
hintReset: 'Ctrl+Shift+R resets the floating window',
|
|
316
317
|
hintArrow: '↑ / ↓ switch segments',
|
|
317
318
|
hintPaste: 'Pasted images are saved in the workspace',
|
|
318
319
|
resetShortcut: 'Reset the notepad position and size',
|
|
@@ -784,12 +785,14 @@ window.__ModuleLoader__.load({
|
|
|
784
785
|
.dsh-np-segbtn[data-ai=true]{color:var(--np-accent);opacity:1}
|
|
785
786
|
.dsh-np-segbtn[data-danger=true]:hover:not(:disabled){color:var(--dsw-alias-state-error-primary,#e25555);background:var(--dsw-alias-interactive-bg-hover-danger,rgb(236 19 19/.05))}
|
|
786
787
|
.dsh-np-segbtn:disabled{opacity:.3;cursor:default}
|
|
787
|
-
.dsh-np-actiongroup{display:inline-flex;align-items:center;flex:none;padding:1px;border:1px solid transparent;border-radius:8px;transition:border-color .12s,background .12s}
|
|
788
|
-
.dsh-np-actiongroup:hover
|
|
789
|
-
.dsh-np-
|
|
790
|
-
.dsh-np-
|
|
788
|
+
.dsh-np-actiongroup{display:inline-flex;align-items:center;flex:none;padding:1px;border:1px solid transparent;border-radius:8px;transition:border-color .12s ease .12s,background .12s ease .12s}
|
|
789
|
+
.dsh-np-actiongroup:hover{border-color:var(--dsw-alias-border-l2,rgb(15 23 42/.14));background:color-mix(in srgb,var(--np-surface-2) 58%,transparent);transition-delay:.18s}
|
|
790
|
+
.dsh-np-actiongroup:focus-within{border-color:var(--dsw-alias-border-l2,rgb(15 23 42/.14));background:color-mix(in srgb,var(--np-surface-2) 58%,transparent);transition-delay:0s}
|
|
791
|
+
.dsh-np-group-extra{width:0!important;min-width:0;padding:0;border:0;opacity:0;visibility:hidden;pointer-events:none;overflow:hidden;transition:width .14s ease .12s,opacity .12s ease .12s,visibility 0s linear .26s}
|
|
792
|
+
.dsh-np-actiongroup:hover .dsh-np-group-extra{width:24px!important;opacity:1;visibility:visible;pointer-events:auto;transition:width .14s ease .2s,opacity .12s ease .2s,visibility 0s linear .2s}
|
|
793
|
+
.dsh-np-actiongroup:focus-within .dsh-np-group-extra{width:24px!important;opacity:1;visibility:visible;pointer-events:auto;transition-delay:0s}
|
|
791
794
|
.dsh-np-actiongroup[data-size=bar]:hover .dsh-np-group-extra,.dsh-np-actiongroup[data-size=bar]:focus-within .dsh-np-group-extra{width:27px!important}
|
|
792
|
-
.dsh-np-seg-text{display:block;width:100%;box-sizing:border-box;padding:2px 10px 8px;border:0;outline:0;resize:none;background:transparent;color:inherit;font-family:${FONT_STACK};font-size:13px;line-height:1.6;min-height:26px;max-height:240px;overflow-y:
|
|
795
|
+
.dsh-np-seg-text{display:block;width:100%;box-sizing:border-box;padding:2px 10px 8px;border:0;outline:0;resize:none;background:transparent;color:inherit;font-family:${FONT_STACK};font-size:13px;line-height:1.6;min-height:26px;max-height:240px;overflow-y:hidden;white-space:pre-wrap;overflow-wrap:anywhere;tab-size:2}
|
|
793
796
|
.dsh-np-seg-text::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2)}
|
|
794
797
|
.dsh-np-seg-text:disabled{opacity:.6}
|
|
795
798
|
.dsh-np-file{display:none}
|
|
@@ -1039,17 +1042,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1039
1042
|
return count;
|
|
1040
1043
|
}
|
|
1041
1044
|
|
|
1042
|
-
/**
|
|
1043
|
-
function
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1045
|
+
/** A segment with no title, text, or images can be deleted without a prompt. */
|
|
1046
|
+
function isEmptySegment(segment) {
|
|
1047
|
+
if (segment === null || segment === undefined) return false;
|
|
1048
|
+
const title = typeof segment.title === 'string' ? segment.title.trim() : '';
|
|
1049
|
+
const text = typeof segment.text === 'string' ? segment.text.trim() : '';
|
|
1050
|
+
const images = Array.isArray(segment.images) ? segment.images : [];
|
|
1051
|
+
return title === '' && text === '' && images.length === 0;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** Merge image references in order, dropping duplicates and respecting the cap. */
|
|
1055
|
+
function collectImages(segments) {
|
|
1056
|
+
const seen = new Set();
|
|
1057
|
+
const images = [];
|
|
1058
|
+
for (const segment of segments) {
|
|
1059
|
+
for (const image of Array.isArray(segment?.images) ? segment.images : []) {
|
|
1060
|
+
if (seen.has(image.path) || images.length >= MAX_IMAGES) continue;
|
|
1061
|
+
seen.add(image.path);
|
|
1062
|
+
images.push(image);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return images;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Decode a GET /note payload. Version 4 (0.4–0.6) stored newest-first;
|
|
1070
|
+
* version 5 stores oldest-first, so a v4 list is reversed once for display.
|
|
1071
|
+
*/
|
|
1072
|
+
function readServerSegments(data) {
|
|
1073
|
+
let value = normalizeServerSegments(data);
|
|
1074
|
+
if (data?.version === 4) value = value.slice().reverse();
|
|
1075
|
+
return value;
|
|
1048
1076
|
}
|
|
1049
1077
|
|
|
1050
1078
|
/**
|
|
1051
1079
|
* Adopt server content without moving cards the user is looking at.
|
|
1052
|
-
* Known ids keep the local order; cards created elsewhere land
|
|
1080
|
+
* Known ids keep the local order; cards created elsewhere land at the
|
|
1081
|
+
* bottom, matching the oldest-to-newest display direction.
|
|
1053
1082
|
*/
|
|
1054
1083
|
function mergeServerOrder(current, incoming) {
|
|
1055
1084
|
const byId = new Map();
|
|
@@ -1061,7 +1090,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1061
1090
|
}
|
|
1062
1091
|
const known = new Set(kept.map((segment) => segment.id));
|
|
1063
1092
|
const added = incoming.filter((segment) => !known.has(segment.id));
|
|
1064
|
-
return [...
|
|
1093
|
+
return [...kept, ...added];
|
|
1065
1094
|
}
|
|
1066
1095
|
|
|
1067
1096
|
function isAbsolutePath(value) {
|
|
@@ -1188,7 +1217,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1188
1217
|
element.style.height = 'auto';
|
|
1189
1218
|
const height = Number(element.scrollHeight);
|
|
1190
1219
|
if (Number.isFinite(height) && height > 0) {
|
|
1191
|
-
|
|
1220
|
+
const capped = height > MAX_SEGMENT_HEIGHT;
|
|
1221
|
+
element.style.height = `${Math.min(MAX_SEGMENT_HEIGHT, Math.max(26, height))}px`;
|
|
1222
|
+
// Keep the native scrollbar hidden until the card really reaches
|
|
1223
|
+
// its limit; otherwise CSS overflow:auto can reserve a gutter early.
|
|
1224
|
+
element.style.overflowY = capped ? 'auto' : 'hidden';
|
|
1192
1225
|
}
|
|
1193
1226
|
} catch {
|
|
1194
1227
|
/* A non-layout shim (tests) may not expose scrollHeight. */
|
|
@@ -1715,10 +1748,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1715
1748
|
fetchNote(root, binding.id)
|
|
1716
1749
|
.then((data) => {
|
|
1717
1750
|
if (cancelled || rootRef.current !== root || mySeq !== loadSeqRef.current) return;
|
|
1718
|
-
let value =
|
|
1719
|
-
// Notes written before 0.4 appended new cards at the bottom;
|
|
1720
|
-
// reverse that legacy order once so the newest card leads.
|
|
1721
|
-
if (isLegacyPayload(data)) value = value.slice().reverse();
|
|
1751
|
+
let value = readServerSegments(data);
|
|
1722
1752
|
if (value.length === 0) value = [newSegment('')];
|
|
1723
1753
|
segmentsRef.current = value;
|
|
1724
1754
|
setSegments(value);
|
|
@@ -1886,8 +1916,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1886
1916
|
return;
|
|
1887
1917
|
}
|
|
1888
1918
|
if (!resetShortcutHandledByHost
|
|
1889
|
-
&& (event.key === '
|
|
1890
|
-
|| event.code === 'Numpad0' || event.keyCode === 48)) {
|
|
1919
|
+
&& (event.key === 'R' || event.key === 'r' || event.code === 'KeyR')) {
|
|
1891
1920
|
event.preventDefault();
|
|
1892
1921
|
resetGeometry();
|
|
1893
1922
|
}
|
|
@@ -1965,8 +1994,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1965
1994
|
pendingScrollRef.current = null;
|
|
1966
1995
|
return;
|
|
1967
1996
|
}
|
|
1968
|
-
let incoming =
|
|
1969
|
-
if (isLegacyPayload(data)) incoming = incoming.slice().reverse();
|
|
1997
|
+
let incoming = readServerSegments(data);
|
|
1970
1998
|
if (incoming.length === 0) incoming = [newSegment('')];
|
|
1971
1999
|
const value = mergeServerOrder(segmentsRef.current, incoming);
|
|
1972
2000
|
segmentsRef.current = value;
|
|
@@ -2221,7 +2249,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2221
2249
|
return;
|
|
2222
2250
|
}
|
|
2223
2251
|
const segment = newSegment('');
|
|
2224
|
-
commitSegments([
|
|
2252
|
+
commitSegments([...segmentsRef.current, segment], { focus: { id: segment.id, caret: 0 } });
|
|
2225
2253
|
};
|
|
2226
2254
|
|
|
2227
2255
|
const splitSegment = (id, element) => {
|
|
@@ -2233,19 +2261,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2233
2261
|
const nextSegment = newSegment(tail);
|
|
2234
2262
|
const updated = segmentsRef.current.map((segment) =>
|
|
2235
2263
|
(segment.id === id ? { ...segment, text: head } : segment));
|
|
2236
|
-
//
|
|
2237
|
-
//
|
|
2238
|
-
|
|
2239
|
-
const
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
for (const segment of updated) {
|
|
2244
|
-
out.push(segment);
|
|
2245
|
-
if (segment.id === id) out.push(nextSegment);
|
|
2246
|
-
}
|
|
2247
|
-
return out;
|
|
2248
|
-
})();
|
|
2264
|
+
// Cards are displayed oldest-first, so the split tail always follows
|
|
2265
|
+
// its source card (also for an empty tail at the end of the note).
|
|
2266
|
+
const next = [];
|
|
2267
|
+
for (const segment of updated) {
|
|
2268
|
+
next.push(segment);
|
|
2269
|
+
if (segment.id === id) next.push(nextSegment);
|
|
2270
|
+
}
|
|
2249
2271
|
commitSegments(next, { focus: { id: nextSegment.id, caret: 0 } });
|
|
2250
2272
|
};
|
|
2251
2273
|
|
|
@@ -2363,15 +2385,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2363
2385
|
for (let index = 0; index < firstIndex; index += 1) {
|
|
2364
2386
|
if (!ids.has(segmentsRef.current[index].id)) insertAt += 1;
|
|
2365
2387
|
}
|
|
2366
|
-
const
|
|
2367
|
-
const images = [];
|
|
2368
|
-
for (const segment of chosen) {
|
|
2369
|
-
for (const image of Array.isArray(segment.images) ? segment.images : []) {
|
|
2370
|
-
if (seenPaths.has(image.path) || images.length >= MAX_IMAGES) continue;
|
|
2371
|
-
seenPaths.add(image.path);
|
|
2372
|
-
images.push(image);
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
2388
|
+
const images = collectImages(chosen);
|
|
2375
2389
|
const merged = {
|
|
2376
2390
|
id: newSegmentId(),
|
|
2377
2391
|
text: chosen.map((segment) => segment.text).filter((text) => text.trim() !== '').join('\n\n'),
|
|
@@ -2977,6 +2991,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2977
2991
|
return true;
|
|
2978
2992
|
};
|
|
2979
2993
|
|
|
2994
|
+
/** Remove a content-empty segment and keep a nearby typing surface. */
|
|
2995
|
+
const deleteEmptySegment = (id, previous, next) => {
|
|
2996
|
+
const remaining = segmentsRef.current.filter((segment) => segment.id !== id);
|
|
2997
|
+
const activeRemaining = remaining.filter((segment) => segment.archived !== true);
|
|
2998
|
+
if (activeRemaining.length === 0) {
|
|
2999
|
+
const fresh = newSegment('');
|
|
3000
|
+
commitSegments([fresh], { focus: { id: fresh.id, caret: 0 } });
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
const focus = previous ?? next;
|
|
3004
|
+
if (focus === undefined) {
|
|
3005
|
+
commitSegments(remaining);
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
const caret = previous !== undefined ? previous.text.length : 0;
|
|
3009
|
+
commitSegments(remaining, { focus: { id: focus.id, caret } });
|
|
3010
|
+
};
|
|
3011
|
+
|
|
3012
|
+
/** Backspace at position 0 joins this segment onto the previous card. */
|
|
3013
|
+
const mergeWithPrevious = (previous, current) => {
|
|
3014
|
+
const title = [previous.title, current.title]
|
|
3015
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
3016
|
+
.filter((value) => value !== '')
|
|
3017
|
+
.join(' · ')
|
|
3018
|
+
.slice(0, MAX_TITLE_CHARS);
|
|
3019
|
+
const merged = {
|
|
3020
|
+
...previous,
|
|
3021
|
+
text: `${previous.text}${current.text}`,
|
|
3022
|
+
title,
|
|
3023
|
+
images: collectImages([previous, current]),
|
|
3024
|
+
};
|
|
3025
|
+
const next = segmentsRef.current
|
|
3026
|
+
.filter((segment) => segment.id !== current.id)
|
|
3027
|
+
.map((segment) => (segment.id === previous.id ? merged : segment));
|
|
3028
|
+
commitSegments(next, { focus: { id: previous.id, caret: previous.text.length } });
|
|
3029
|
+
};
|
|
3030
|
+
|
|
2980
3031
|
const onSegmentKeyDown = (event, id) => {
|
|
2981
3032
|
if (event.isComposing === true || event.nativeEvent?.isComposing === true) return;
|
|
2982
3033
|
const chord = event.ctrlKey || event.metaKey;
|
|
@@ -3013,6 +3064,27 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
3013
3064
|
}
|
|
3014
3065
|
return;
|
|
3015
3066
|
}
|
|
3067
|
+
if (event.key === 'Backspace') {
|
|
3068
|
+
if (event.altKey || event.shiftKey || chord) return;
|
|
3069
|
+
const element = event.currentTarget ?? event.target;
|
|
3070
|
+
const start = typeof element?.selectionStart === 'number' ? element.selectionStart : null;
|
|
3071
|
+
const end = typeof element?.selectionEnd === 'number' ? element.selectionEnd : start;
|
|
3072
|
+
if (start !== 0 || end !== 0) return;
|
|
3073
|
+
const active = segmentsRef.current.filter((segment) => segment.archived !== true);
|
|
3074
|
+
const position = active.findIndex((segment) => segment.id === id);
|
|
3075
|
+
const current = position < 0 ? undefined : active[position];
|
|
3076
|
+
if (current === undefined) return;
|
|
3077
|
+
const previous = position > 0 ? active[position - 1] : undefined;
|
|
3078
|
+
if (isEmptySegment(current)) {
|
|
3079
|
+
event.preventDefault?.();
|
|
3080
|
+
deleteEmptySegment(current.id, previous, active[position + 1]);
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
if (previous === undefined) return;
|
|
3084
|
+
event.preventDefault?.();
|
|
3085
|
+
mergeWithPrevious(previous, current);
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3016
3088
|
if (event.key !== 'Enter' || event.shiftKey || event.altKey || chord) return;
|
|
3017
3089
|
event.preventDefault();
|
|
3018
3090
|
splitSegment(id, event.currentTarget ?? event.target);
|
|
@@ -3131,7 +3203,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
3131
3203
|
{
|
|
3132
3204
|
type: 'button',
|
|
3133
3205
|
className: 'dsh-np-btn',
|
|
3134
|
-
title: `${t('resetShortcut')} · Ctrl+Shift+
|
|
3206
|
+
title: `${t('resetShortcut')} · Ctrl+Shift+R`,
|
|
3135
3207
|
'aria-label': t('resetShortcut'),
|
|
3136
3208
|
onClick: resetGeometry,
|
|
3137
3209
|
},
|
|
@@ -3209,22 +3281,20 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
3209
3281
|
onClick: primary.onClick,
|
|
3210
3282
|
};
|
|
3211
3283
|
if (primary.accent === true) primaryProps['data-accent'] = 'true';
|
|
3284
|
+
const extraProps = {
|
|
3285
|
+
type: 'button',
|
|
3286
|
+
className: `${base} dsh-np-group-extra`,
|
|
3287
|
+
title: extra.title,
|
|
3288
|
+
'aria-label': extra.title,
|
|
3289
|
+
disabled,
|
|
3290
|
+
onClick: extra.onClick,
|
|
3291
|
+
};
|
|
3292
|
+
if (extra.danger === true) extraProps['data-danger'] = 'true';
|
|
3212
3293
|
return h(
|
|
3213
3294
|
'div',
|
|
3214
3295
|
{ className: 'dsh-np-actiongroup', 'data-size': size },
|
|
3215
3296
|
h('button', primaryProps, primary.icon),
|
|
3216
|
-
h(
|
|
3217
|
-
'button',
|
|
3218
|
-
{
|
|
3219
|
-
type: 'button',
|
|
3220
|
-
className: `${base} dsh-np-group-extra`,
|
|
3221
|
-
title: extra.title,
|
|
3222
|
-
'aria-label': extra.title,
|
|
3223
|
-
disabled,
|
|
3224
|
-
onClick: extra.onClick,
|
|
3225
|
-
},
|
|
3226
|
-
h(NewSessionIcon, { size: 14 }),
|
|
3227
|
-
),
|
|
3297
|
+
h('button', extraProps, extra.icon ?? h(NewSessionIcon, { size: 14 })),
|
|
3228
3298
|
);
|
|
3229
3299
|
};
|
|
3230
3300
|
|
|
@@ -3585,29 +3655,23 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
3585
3655
|
? h('span', { className: 'dsh-np-mini-spinner' })
|
|
3586
3656
|
: canUndoOptimize ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
|
|
3587
3657
|
),
|
|
3588
|
-
|
|
3589
|
-
'
|
|
3590
|
-
{
|
|
3591
|
-
type: 'button',
|
|
3592
|
-
className: 'dsh-np-segbtn',
|
|
3658
|
+
actionGroup({
|
|
3659
|
+
size: 'card',
|
|
3660
|
+
primary: {
|
|
3593
3661
|
title: t('archiveSegment'),
|
|
3594
|
-
'aria-label': t('archiveSegment'),
|
|
3595
3662
|
onClick: () => archiveSegment(segment.id),
|
|
3663
|
+
icon: h(ArchiveIcon, { size: 14 }),
|
|
3596
3664
|
},
|
|
3597
|
-
|
|
3598
|
-
),
|
|
3599
|
-
h(
|
|
3600
|
-
'button',
|
|
3601
|
-
{
|
|
3602
|
-
type: 'button',
|
|
3603
|
-
className: 'dsh-np-segbtn',
|
|
3604
|
-
'data-danger': 'true',
|
|
3665
|
+
extra: {
|
|
3605
3666
|
title: t('deleteActiveSegment'),
|
|
3606
|
-
|
|
3607
|
-
onClick: () =>
|
|
3667
|
+
danger: true,
|
|
3668
|
+
onClick: () => {
|
|
3669
|
+
if (isEmptySegment(segment)) deleteActiveSegment(segment.id);
|
|
3670
|
+
else setDeleteConfirm(segment.id);
|
|
3671
|
+
},
|
|
3672
|
+
icon: h(TrashIcon, { size: 14 }),
|
|
3608
3673
|
},
|
|
3609
|
-
|
|
3610
|
-
),
|
|
3674
|
+
}),
|
|
3611
3675
|
),
|
|
3612
3676
|
h(
|
|
3613
3677
|
'div',
|
|
@@ -3632,7 +3696,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
3632
3696
|
className: 'dsh-np-seg-text',
|
|
3633
3697
|
rows: 1,
|
|
3634
3698
|
value: segment.text,
|
|
3635
|
-
placeholder: index ===
|
|
3699
|
+
placeholder: index === activeSegments.length - 1 ? t('placeholder') : t('segmentPlaceholder'),
|
|
3636
3700
|
spellCheck: false,
|
|
3637
3701
|
maxLength: MAX_SEGMENT_CHARS,
|
|
3638
3702
|
'aria-label': t('editor'),
|
|
@@ -4055,12 +4119,12 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
4055
4119
|
label: () => translate('resetShortcut'),
|
|
4056
4120
|
aliases: ['reset notepad', 'reset notepad geometry'],
|
|
4057
4121
|
defaults: {
|
|
4058
|
-
'desktop:macos': { code: '
|
|
4059
|
-
'desktop:windows': { code: '
|
|
4060
|
-
'desktop:linux': { code: '
|
|
4061
|
-
'web:macos': { code: '
|
|
4062
|
-
'web:windows': { code: '
|
|
4063
|
-
'web:linux': { code: '
|
|
4122
|
+
'desktop:macos': { code: 'KeyR', modifiers: ['primary', 'shift'] },
|
|
4123
|
+
'desktop:windows': { code: 'KeyR', modifiers: ['primary', 'shift'] },
|
|
4124
|
+
'desktop:linux': { code: 'KeyR', modifiers: ['primary', 'shift'] },
|
|
4125
|
+
'web:macos': { code: 'KeyR', modifiers: ['primary', 'shift'] },
|
|
4126
|
+
'web:windows': { code: 'KeyR', modifiers: ['primary', 'shift'] },
|
|
4127
|
+
'web:linux': { code: 'KeyR', modifiers: ['primary', 'shift'] },
|
|
4064
4128
|
},
|
|
4065
4129
|
regions: ['page', 'editable'],
|
|
4066
4130
|
modals: [],
|
package/index.js
CHANGED
package/lib/note-store.js
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* One note per workspace, kept at `<workspace>/.dsh/notepad.json`. A note is an
|
|
5
5
|
* ordered list of text segments; each segment may be archived after it was
|
|
6
|
-
* sent, and
|
|
6
|
+
* sent, and the Client may delete either archived or active segments (active
|
|
7
|
+
* deletion is irreversible and confirmed in the UI).
|
|
7
8
|
*
|
|
8
9
|
* Version 4 adds an optional card title and creation timestamp to every
|
|
9
|
-
* segment. Version
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* segment. Version 5 stores cards oldest-first; the Client reverses a version 4
|
|
11
|
+
* payload once while reading it. Version 2 stores the segment list under
|
|
12
|
+
* `segments` and repeats the merged body under `text` so the file stays human
|
|
13
|
+
* readable and older builds can still read the content. Version 1 files (a
|
|
14
|
+
* single `text`) and hand-edited plain-text files are migrated on read as one
|
|
15
|
+
* segment.
|
|
13
16
|
*
|
|
14
17
|
* @module dsh-notepad/lib/note-store
|
|
15
18
|
*/
|
|
@@ -39,8 +42,8 @@ export const MAX_SEGMENT_TITLE_CHARS = 200;
|
|
|
39
42
|
/** Maximum characters kept for one creation timestamp. */
|
|
40
43
|
export const MAX_SEGMENT_TIME_CHARS = 64;
|
|
41
44
|
|
|
42
|
-
/** Storage envelope version. */
|
|
43
|
-
export const NOTE_VERSION =
|
|
45
|
+
/** Storage envelope version. Version 5 flips the default card order to oldest-first. */
|
|
46
|
+
export const NOTE_VERSION = 5;
|
|
44
47
|
|
|
45
48
|
/** Maximum image references accepted on one segment. */
|
|
46
49
|
export const MAX_IMAGES_PER_SEGMENT = 20;
|
|
@@ -358,9 +361,11 @@ export function mergeSegments(segments) {
|
|
|
358
361
|
export function parseNote(raw, mtimeMs) {
|
|
359
362
|
let segmentsInput;
|
|
360
363
|
let updatedAt;
|
|
364
|
+
let version = null;
|
|
361
365
|
try {
|
|
362
366
|
const parsed = JSON.parse(raw);
|
|
363
367
|
if (parsed !== null && typeof parsed === 'object') {
|
|
368
|
+
if (Number.isFinite(parsed.version)) version = parsed.version;
|
|
364
369
|
if (Array.isArray(parsed.segments)) segmentsInput = parsed.segments;
|
|
365
370
|
else if (typeof parsed.text === 'string') segmentsInput = parsed.text;
|
|
366
371
|
if (typeof parsed.updatedAt === 'string') updatedAt = parsed.updatedAt;
|
|
@@ -379,6 +384,7 @@ export function parseNote(raw, mtimeMs) {
|
|
|
379
384
|
if (segment !== null) segments.push(segment);
|
|
380
385
|
}
|
|
381
386
|
return {
|
|
387
|
+
version,
|
|
382
388
|
segments,
|
|
383
389
|
text: mergeSegments(segments),
|
|
384
390
|
updatedAt: updatedAt ?? (typeof mtimeMs === 'number' ? new Date(mtimeMs).toISOString() : null),
|
|
@@ -400,7 +406,7 @@ export async function readNote(root) {
|
|
|
400
406
|
raw = await fs.readFile(file, 'utf8');
|
|
401
407
|
} catch (error) {
|
|
402
408
|
if (error?.code === 'ENOENT') {
|
|
403
|
-
return { segments: [], text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
|
|
409
|
+
return { version: null, segments: [], text: '', updatedAt: null, mtimeMs: null, exists: false, size: 0 };
|
|
404
410
|
}
|
|
405
411
|
throw error;
|
|
406
412
|
}
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Notepad",
|
|
4
|
-
"description": "A per-workspace floating notepad in the session view.
|
|
4
|
+
"description": "A per-workspace floating notepad in the session view. Cards run oldest-to-newest, Enter splits after the source card, Backspace at the start of a card merges it into the previous one, and an empty card is deleted immediately (Backspace in it also removes it). An empty title is just a blank click target until clicked. Click an index to select, drag to reorder, merge a selection, move between cards with the arrow keys, and improve only the selected cards with AI — the button turns into undo and identical content reuses the previous answer. Insert, send and archive+delete groups wait briefly before expanding on hover; insert/send include current-session and new-session actions, while replace and archive stay single-click primaries. Image chips open an in-panel preview; non-empty delete asks for confirmation and warns the action cannot be undone. The footer shows one shortcut tip at a time and rotates on click; copy-all skips archived segments; sending follows the General Settings busy-send policy. Pending counts appear on the hidden launcher and the sidebar workspace row, auto refresh keeps the list scroll position, the textarea only shows a scrollbar at its height limit, Ctrl+Shift+R (or the header reset button) resets the panel geometry, and the panel never covers the host title bar."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "记事本",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "在会话界面显示一个按工作区共享的分段便签浮窗:分段从旧到新、从上到下排列,回车拆分后新卡片紧跟原卡片,在段首按 Backspace 会与上一段合并;空标题点击后才显示输入框;点击序号选中,可拖拽排序、合并多段、用上下方向键切换分段;AI 优化只作用于选中的分段,优化后按钮变为撤销,相同内容复用上次结果;插入、发送、归档+删除按钮 hover 稍候才横向展开为细圆角按钮组(插入/发送含当前会话与新会话,替换保持单键);图片支持面板内预览;空分段删除无需确认,空段内按 Backspace 自动删除,其它分段删除需二次确认并提示无法恢复;底部只显示一条快捷键提示,点击轮换;复制全部不包含归档分段;发送行为跟随「设置 > 通用设置」中的繁忙发送策略;隐藏按钮和侧边栏工作区显示未发送数量角标;自动刷新保持滚动位置,分段文本达到最大高度才出现滚动条;浮窗可调整尺寸,Ctrl+Shift+R 或标题栏复位按钮可复位位置和尺寸,且不会越过窗口标题栏。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@restregular/dsh-notepad",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view:
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: oldest-first segment cards with click-to-edit titles, Enter split after the source, Backspace merge into the previous card, drag to reorder, merge selected segments, arrow-key navigation, AI improve with undo and cached results, delayed hover groups for insert / send / archive-delete (including fresh-session variants), in-panel image previews, empty-segment deletion without a prompt, rotating shortcut tips, copy-all that skips archived segments, the General Settings busy-send policy, image chips with automatic file cleanup, pending-count badges on the hidden button and sidebar workspace, scroll-safe auto refresh, a textarea that only scrolls at its height limit, resizable title-bar-safe panel, Ctrl+Shift+R geometry reset, and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:分段按从旧到新、从上到下排列,回车拆分后新卡片紧跟原卡片,在段首按 Backspace 会与上一段合并;空标题只在点击后显示输入框;可拖拽排序、合并选中分段、上下方向键切换分段、只对选中分段做 AI 优化并可撤销 / 复用缓存;插入、发送、归档+删除按钮 hover 稍候才展开为细边框按钮组(插入/发送含新会话变体);图片支持面板内预览;空分段删除无需确认,空段内按 Backspace 自动删除;底部快捷键提示点击轮换;复制全部排除归档;发送跟随「设置 > 通用设置」的繁忙发送策略;图片芯片删除时自动清理磁盘文件;隐藏按钮与侧边栏工作区显示未发送数量角标;自动刷新保持滚动位置;分段文本到最大高度才出现滚动条;可调整尺寸,Ctrl+Shift+R 复位位置和尺寸,浮窗不会越过窗口标题栏,并适配深色模式。",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|