@restregular/dsh-notepad 0.4.0 → 0.5.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 +22 -15
- package/client.js +387 -82
- package/index.js +52 -3
- 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 的会话界面里放一张**属于当前工作区**的分段便签浮窗:一行一个问题、一段一个卡片,回车就能新建下一段;需要时把某一段、或勾选后的多段一起插入 / 替换 / 发送到对话框。除了文字,也可以直接**粘贴或选择图片**,输入框里会用缩略芯片显示,插入 / 发送时按路径引用。0.4
|
|
3
|
+
在 DeepSeek Harness 的会话界面里放一张**属于当前工作区**的分段便签浮窗:一行一个问题、一段一个卡片,回车就能新建下一段;需要时把某一段、或勾选后的多段一起插入 / 替换 / 发送到对话框。除了文字,也可以直接**粘贴或选择图片**,输入框里会用缩略芯片显示,插入 / 发送时按路径引用。0.4 起新分段置顶、点击序号选中、拖拽排序、合并多段;0.5 起空标题只在点击后显示输入框,上下方向键可切换分段,AI 优化只处理选中的分段且优化后按钮变为「撤销优化」,相同内容复用上次结果;自动刷新保持列表滚动位置,新增 `Ctrl/Cmd + Shift + 0` 复位浮窗位置和尺寸,浮窗也不会再越过窗口标题栏。
|
|
4
4
|
|
|
5
|
-
> A per-workspace floating segmented notepad for DeepSeek Harness sessions. New segment cards lead the list
|
|
5
|
+
> A per-workspace floating segmented notepad for DeepSeek Harness sessions. New segment cards lead the list; an empty title is a click-to-edit placeholder, 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. Pending counts appear on the hidden launcher and the sidebar workspace row. Paste or pick images as in-input thumbnail chips; insert, replace, or send one segment or a multi-selection; sending archives the segment automatically. Auto refresh preserves the list scroll position, `Ctrl/Cmd + Shift + 0` resets the window 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
|
|
|
@@ -31,9 +31,11 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
- **新建分段置顶**:标题栏 `+` 或分段末尾回车会在列表顶部创建新卡片;卡片从上到下保持最近在前,拖拽后按你的顺序保存。
|
|
34
|
-
-
|
|
34
|
+
- **标题**:空标题只显示「分段标题(可选)」占位,没有 hover 底色;点击后才出现输入框。已有标题的卡片直接显示输入框,边输入边自动保存;在输入框里清空后不会立刻消失,失焦才回到占位状态。
|
|
35
35
|
- **序号**:显示当前顺序(`01`、`02`…),点击可选中 / 取消选中这一段,拖动序号可调整卡片顺序。
|
|
36
|
-
- **AI
|
|
36
|
+
- **AI 优化**:勾选分段后,顶部工具栏的 ✦ 只优化选中的分段;卡片工具栏的 ✦ 优化此段。优化成功后,刚点过的按钮变成「撤销优化」,再点一次恢复原文;相同且未改动的标题 + 内容再次优化时直接复用上次结果,不再调用大模型。
|
|
37
|
+
- **方向键**:光标在分段首行按 `↑` 跳到上一张卡片末尾;在末行按 `↓` 跳到下一张卡片开头。多行分段内部仍按正常文本光标移动。
|
|
38
|
+
- **复位浮窗**:`Ctrl / Cmd + Shift + 0` 把位置和尺寸恢复为右下角的默认 `420 × 460`,最小化 / 归档展开等状态不变。
|
|
37
39
|
- **合并**:勾选 2 段以上后点顶部工具栏的合并按钮,合并结果留在最靠上的选中位置。
|
|
38
40
|
- **回车 / Enter**:把当前分段从光标处拆开,创建一张新的分段卡片(光标前的文字留在原卡片,光标后的文字进入新卡片)。在末尾回车就是新建一个空分段并置顶。
|
|
39
41
|
- **Shift + Enter**:分段内换行。
|
|
@@ -46,12 +48,14 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
46
48
|
| --- | --- | --- |
|
|
47
49
|
| 新建分段 | 标题栏 `+`,或分段末尾按回车 | 新卡片置顶并自动获得焦点 |
|
|
48
50
|
| 编辑分段 | 点击卡片文本区 | 停止输入约 0.5 秒后自动保存 |
|
|
49
|
-
| 分段标题 |
|
|
51
|
+
| 分段标题 | 空标题卡片的占位 / 已有标题的输入框 | 空标题点击后才出现输入框;停止输入约 0.5 秒后自动保存 |
|
|
50
52
|
| 选中分段 | 点击左上角序号,或左侧复选框 | 序号与复选框状态同步;点击序号即可参与批量操作 |
|
|
51
53
|
| 拖拽排序 | 按住左上角序号拖动到另一张卡片的上 / 下半区 | 松开后按新顺序保存,序号自动重排 |
|
|
52
54
|
| 合并分段 | 勾选 2 段以上 → 顶部工具栏合并按钮 | 按当前显示顺序合并文字、标题和图片,可撤销 |
|
|
53
|
-
| AI 优化此段 | 卡片工具栏的 ✦ 按钮 |
|
|
54
|
-
| AI
|
|
55
|
+
| AI 优化此段 | 卡片工具栏的 ✦ 按钮 | 用当前默认模型润色这一段,保留图片;成功后按钮变为「撤销优化」 |
|
|
56
|
+
| AI 优化选中 | 勾选分段 → 顶部工具栏的 ✦ 按钮 | 只优化勾选的非空未归档分段,显示进度;按钮变为「撤销优化」,可整体撤销 |
|
|
57
|
+
| 撤销优化 | 优化后的 ✦ 按钮(或顶部工具栏) | 恢复优化前的文字;再次优化相同内容时直接复用上次结果 |
|
|
58
|
+
| 方向键切换 | 分段文本内按 `↑` / `↓` | 首行 `↑` 到上一段末尾,末行 `↓` 到下一段开头 |
|
|
55
59
|
| 未发送角标 | 最小化后的右下角图标;左侧边栏工作区行左上角 | 有未发送内容时显示数量,超过 99 显示 `99+` |
|
|
56
60
|
| 添加图片(选择) | 卡片头部的图片按钮 | 桌面端直接用所选文件的宿主路径引入,不复制文件 |
|
|
57
61
|
| 添加图片(粘贴) | 在分段文本区粘贴截图 / 图片 | 写入 `.dsh/notepad/images/`,再以该路径引入 |
|
|
@@ -71,9 +75,10 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
71
75
|
| 清空归档 | 归档区标题「清空归档」 | 批量删除归档,可撤销 |
|
|
72
76
|
| 复制全部 | 标题栏复制按钮 | 复制所有分段(含归档),用空行合并 |
|
|
73
77
|
| 调整尺寸 | 拖动右下角斜纹手柄 | 宽高按浏览器记忆,最小 320 × 240 |
|
|
78
|
+
| 复位位置和尺寸 | `Ctrl/Cmd + Shift + 0` | 恢复默认 420 × 460 并停到右下角,其它状态不变 |
|
|
74
79
|
| 折叠 / 展开 | 标题栏上下折叠箭头 | 只保留标题栏,内容继续自动保存 |
|
|
75
80
|
| 最小化 / 恢复 | 标题栏最小化图标,或 `Ctrl/Cmd + Shift + N` | 收成右下角小图标,带平滑过渡 |
|
|
76
|
-
| 拖动位置 | 按住标题栏拖动 |
|
|
81
|
+
| 拖动位置 | 按住标题栏拖动 | 位置按浏览器记忆;不会越过 DSH 窗口最顶部的标题栏(最大化 / 最小化 / 关闭那一栏) |
|
|
77
82
|
|
|
78
83
|
同一个工作区里的不同会话共用同一份笔记:在 A 会话写的内容,切到同工作区的 B 会话仍然在。切换工作区时浮窗自动加载对应工作区的文件。
|
|
79
84
|
|
|
@@ -114,17 +119,19 @@ dsh plugin --profile web add @restregular/dsh-notepad
|
|
|
114
119
|
`text` 是 `segments`(含图片引用)用空行合并后的副本,方便人工查看,也兼容旧版本。旧版单段文件(只有 `text`)、v2/v3 分段文件和手动改成纯文本的文件会在读取时迁移;没有 `createdAt` 的旧笔记会按「最近在前」反转一次显示顺序,之后按卡片数组顺序保存(拖拽后的顺序不会被自动重排)。`images[].path` 工作区内用相对路径,工作区外保留绝对路径;`name` 仅用于显示和 Markdown 替代文本。`title` 最长 200 字符。
|
|
115
120
|
- **粘贴的图片**保存在 `<工作区>/.dsh/notepad/images/`,由宿主接口原子写入,只接受 PNG / JPEG / WebP / GIF,单张最大 20 MB;笔记只保存路径,不内嵌 base64。**选择的图片**直接引用原路径,不复制。每段最多 20 张。
|
|
116
121
|
- **删除图片芯片**会同时删除插件自己保存在 `.dsh/notepad/images/` 下的对应文件;引用工作区外原路径的图片只删引用,不碰原文件。删除已归档分段 / 清空归档时,未被其它分段引用的插件图片会在撤销窗口结束后自动清理。
|
|
117
|
-
- 浮窗位置、尺寸、折叠 / 最小化 / 隐藏状态、归档区是否展开保存在浏览器 `localStorage` 的 `dsh-notepad.ui.v1` 中,按浏览器 /
|
|
122
|
+
- 浮窗位置、尺寸、折叠 / 最小化 / 隐藏状态、归档区是否展开保存在浏览器 `localStorage` 的 `dsh-notepad.ui.v1` 中,按浏览器 / 设备生效,不写入工作区。位置会被限制在宿主标题栏下方(读取 DSH 的 `--dsh-frame-chrome-top` / `--dsh-frame-top-clearance`),`Ctrl/Cmd + Shift + 0` 可随时复位位置和尺寸。
|
|
118
123
|
- 每个工作区一份笔记,最多 **200 段 / 512 KB**(单段输入框限制 16 万字符,每段最多 20 张图片)。浮窗内是纯文本编辑,不做 Markdown 渲染;图片以缩略芯片展示。
|
|
119
124
|
- 浮窗只在**会话视图**出现:打开设置等非会话页面时自动隐藏,回到会话后恢复。
|
|
120
125
|
- 写入走宿主同源回环路由 `/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`,不会影响记事本的其它功能。
|
|
121
|
-
-
|
|
126
|
+
- **AI 优化缓存**:客户端按「工作区 + 标题 + 正文」缓存本页内的优化结果;宿主进程内也保留一份同样键的缓存(最多 64 条 / 约 200 万字符)。相同且未改动的标题 + 正文再次优化时直接复用上次结果,不再调用大模型;标题或正文变化后会重新请求。
|
|
127
|
+
- 窗口重新获得焦点(或页面重新可见)时,如果本地没有未保存的修改,会自动读取磁盘上的最新内容;刷新前会立即记录列表的 `scrollTop`,写入新内容后恢复,读取时也保留当前卡片顺序,不会让正在看的卡片或列表位置跳动。
|
|
122
128
|
|
|
123
129
|
## 实现
|
|
124
130
|
|
|
125
|
-
- **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`
|
|
126
|
-
- **Client**(`client.js`):整个 bundle 包在 IIFE 里;通过 `shell.overlay` 注册浮窗,读取 `uiSession.adapter.current`
|
|
127
|
-
- AI
|
|
131
|
+
- **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()` 调用当前默认模型,流式拼接文本后写入缓存并返回。
|
|
132
|
+
- **Client**(`client.js`):整个 bundle 包在 IIFE 里;通过 `shell.overlay` 注册浮窗,读取 `uiSession.adapter.current` 得到当前会话与工作区。标题、序号、拖拽排序和合并都在本地分段数组上完成,数组顺序就是保存顺序;空标题用占位按钮渲染,点击后才挂载输入框,`↑` / `↓` 在首 / 末行时把焦点和光标移到相邻卡片。侧边栏角标用 `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()`。图片在插入 / 发送时以 Markdown 图片语法追加,聊天记录里的 DSH 渲染器会按工作区 cwd 解析这些路径。
|
|
133
|
+
- AI 优化只改分段文字,不动标题和图片;单段优化后该卡片按钮变为「撤销优化」,批量优化后工具栏按钮变为「撤销优化」,撤销会恢复优化前的文字。优化结果按内容缓存在客户端与宿主两侧,相同内容再次优化不会重复请求;没有默认模型或模型调用失败时只提示错误,不影响笔记读写。
|
|
134
|
+
- 浮窗坐标同时受视口和宿主标题栏约束:拖动、调整尺寸、窗口缩放和快捷键复位都走同一个 `clampUi()`,最小 `top` 取 `--dsh-frame-chrome-top`(全屏时为 0)/ `--dsh-frame-top-clearance` 再加 8px。
|
|
128
135
|
- 文案通过宿主 `locale` 服务注册中英文;找不到 locale 时按浏览器语言回退。
|
|
129
136
|
|
|
130
137
|
## 开发
|
|
@@ -148,14 +155,14 @@ node test/package.test.mjs
|
|
|
148
155
|
npm test
|
|
149
156
|
```
|
|
150
157
|
|
|
151
|
-
`test/client-smoke.mjs` 会真正执行 `apply(ctx)`
|
|
158
|
+
`test/client-smoke.mjs` 会真正执行 `apply(ctx)` 并渲染浮窗,覆盖:分段加载、编辑自动保存、自动刷新保持列表滚动位置、新分段置顶、空标题占位与点击后出现输入框、标题自动保存、序号选中、方向键跨分段切换光标、合并、单段 / 选中分段 AI 优化、优化后按钮切换为撤销、相同内容命中缓存、拖拽排序、未发送角标、侧边栏工作区角标、回车拆分、单段/多段插入与替换、单段/多段发送与自动归档、归档显示隐藏、归档恢复与删除(含撤销)、全选、折叠、最小化 / 恢复、拖动调整尺寸、标题栏下方位置限制、全局快捷键与复位快捷键、深色模式与字体 CSS、重复执行 bundle 的兼容性,以及粘贴 / 选择图片、图片芯片、删除芯片时清理磁盘文件与带图插入发送。
|
|
152
159
|
|
|
153
160
|
## 已知限制
|
|
154
161
|
|
|
155
162
|
- 浮窗不会自动把内容推给 Agent;发送使用当前会话的排队发送,需要 Agent 可接收消息。
|
|
156
163
|
- 发送按钮依赖 DSH 的会话接口;接口不可用时降级为插入输入框并提示。
|
|
157
164
|
- 图片只支持 PNG / JPEG / WebP / GIF,单张最大 20 MB、每段最多 20 张;其它附件类型暂不支持。
|
|
158
|
-
- AI 优化需要 DSH
|
|
165
|
+
- AI 优化需要 DSH 里已配置默认模型;未配置时按钮会提示不可用,记笔记本身不受影响。优化缓存保存在宿主进程内,重启 DSH 或切换默认模型后,相同内容会再请求一次。
|
|
159
166
|
- 删除图片芯片会删除插件目录里的文件;如果同一路径仍被其它分段引用,则保留文件。选择的原路径图片只删引用,不删除原图。
|
|
160
167
|
- 网页版没有 `__DSH_HOST_PATHS__` 桥,选择 / 拖入的图片会像粘贴一样先复制到 `.dsh/notepad/images/`。
|
|
161
168
|
- 图片在浮窗里用缩略芯片表示,不提供富文本行内图文混排;插入 / 发送后由聊天记录按 Markdown 渲染。
|
package/client.js
CHANGED
|
@@ -37,7 +37,7 @@ window.__ModuleLoader__.load({
|
|
|
37
37
|
factory: (require) => {
|
|
38
38
|
const React = require('react');
|
|
39
39
|
const h = React.createElement;
|
|
40
|
-
const { useState, useEffect, useRef } = React;
|
|
40
|
+
const { useState, useEffect, useLayoutEffect, useRef } = React;
|
|
41
41
|
|
|
42
42
|
const NS = 'dsh-notepad';
|
|
43
43
|
const API = '/dsh-notepad/api';
|
|
@@ -56,6 +56,8 @@ window.__ModuleLoader__.load({
|
|
|
56
56
|
const MAX_OPTIMIZE_CHARS = 60000;
|
|
57
57
|
const SIDEBAR_REFRESH_MS = 20000;
|
|
58
58
|
const IMAGE_DELETE_DELAY = 5000;
|
|
59
|
+
const OPTIMIZE_CACHE_LIMIT = 100;
|
|
60
|
+
const EDGE_GAP = 8;
|
|
59
61
|
const IMAGE_MIME = {
|
|
60
62
|
'image/png': 'png',
|
|
61
63
|
'image/jpeg': 'jpg',
|
|
@@ -114,11 +116,14 @@ window.__ModuleLoader__.load({
|
|
|
114
116
|
mergeHint: '至少选择 2 段才能合并',
|
|
115
117
|
mergedToast: '已合并 {n} 段',
|
|
116
118
|
optimizeSegment: 'AI 优化此段',
|
|
117
|
-
|
|
119
|
+
optimizeSelected: 'AI 优化选中的 {n} 段',
|
|
120
|
+
optimizeSelectHint: '勾选分段后可批量优化',
|
|
118
121
|
optimizeAllProgress: 'AI 优化中 {done}/{total}',
|
|
119
122
|
optimizing: '优化中…',
|
|
123
|
+
undoOptimize: '撤销优化',
|
|
120
124
|
optimizedToast: '已优化此段',
|
|
121
125
|
optimizedCount: '已优化 {n} 段',
|
|
126
|
+
optimizeUndone: '已撤销优化',
|
|
122
127
|
optimizeEmpty: '没有可优化的文字',
|
|
123
128
|
optimizeTooLong: '内容太长,无法优化',
|
|
124
129
|
optimizeUnavailable: 'AI 服务不可用',
|
|
@@ -155,9 +160,10 @@ window.__ModuleLoader__.load({
|
|
|
155
160
|
show: '显示记事本(Ctrl+Shift+N)',
|
|
156
161
|
chars: '{n} 字符',
|
|
157
162
|
segments: '{n} 段',
|
|
158
|
-
hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入 · 支持粘贴图片',
|
|
163
|
+
hint: 'Enter 新建分段 · Shift+Enter 换行 · Ctrl+Enter 插入 · Ctrl+Shift+0 复位浮窗 · 支持粘贴图片',
|
|
159
164
|
workspace: '工作区',
|
|
160
165
|
resize: '拖动调整大小',
|
|
166
|
+
uiReset: '已复位浮窗位置和尺寸',
|
|
161
167
|
addImage: '添加图片',
|
|
162
168
|
removeImage: '移除图片',
|
|
163
169
|
openImage: '打开图片',
|
|
@@ -218,11 +224,14 @@ window.__ModuleLoader__.load({
|
|
|
218
224
|
mergeHint: 'Select at least 2 segments to merge',
|
|
219
225
|
mergedToast: 'Merged {n} segments',
|
|
220
226
|
optimizeSegment: 'Improve this segment with AI',
|
|
221
|
-
|
|
227
|
+
optimizeSelected: 'Improve {n} selected with AI',
|
|
228
|
+
optimizeSelectHint: 'Select segments to improve',
|
|
222
229
|
optimizeAllProgress: 'Improving {done}/{total}',
|
|
223
230
|
optimizing: 'Improving…',
|
|
231
|
+
undoOptimize: 'Undo improvement',
|
|
224
232
|
optimizedToast: 'Segment improved',
|
|
225
233
|
optimizedCount: 'Improved {n} segments',
|
|
234
|
+
optimizeUndone: 'Improvement undone',
|
|
226
235
|
optimizeEmpty: 'Nothing to improve',
|
|
227
236
|
optimizeTooLong: 'Too long to improve',
|
|
228
237
|
optimizeUnavailable: 'AI service unavailable',
|
|
@@ -259,9 +268,10 @@ window.__ModuleLoader__.load({
|
|
|
259
268
|
show: 'Show notepad (Ctrl+Shift+N)',
|
|
260
269
|
chars: '{n} chars',
|
|
261
270
|
segments: '{n} segments',
|
|
262
|
-
hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert · Paste images',
|
|
271
|
+
hint: 'Enter new segment · Shift+Enter newline · Ctrl+Enter insert · Ctrl+Shift+0 reset window · Paste images',
|
|
263
272
|
workspace: 'Workspace',
|
|
264
273
|
resize: 'Drag to resize',
|
|
274
|
+
uiReset: 'Window position and size reset',
|
|
265
275
|
addImage: 'Add image',
|
|
266
276
|
removeImage: 'Remove image',
|
|
267
277
|
openImage: 'Open image',
|
|
@@ -548,6 +558,24 @@ window.__ModuleLoader__.load({
|
|
|
548
558
|
);
|
|
549
559
|
}
|
|
550
560
|
|
|
561
|
+
/** Curved arrow: undo the last AI improvement. */
|
|
562
|
+
function UndoIcon({ size = 16 }) {
|
|
563
|
+
return h(
|
|
564
|
+
Svg,
|
|
565
|
+
{ size, viewBox: '0 0 16 16' },
|
|
566
|
+
h('path', {
|
|
567
|
+
d: 'M5.4 4.2 2.8 6.8l2.6 2.6',
|
|
568
|
+
stroke: 'currentColor', strokeWidth: 1.35,
|
|
569
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
570
|
+
}),
|
|
571
|
+
h('path', {
|
|
572
|
+
d: 'M3 6.8h5.3c2.7 0 4.9 2.2 4.9 4.9',
|
|
573
|
+
stroke: 'currentColor', strokeWidth: 1.35,
|
|
574
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
551
579
|
/** Two arrows converging: merge selected segments into one. */
|
|
552
580
|
function MergeIcon({ size = 16 }) {
|
|
553
581
|
return h(
|
|
@@ -608,7 +636,7 @@ window.__ModuleLoader__.load({
|
|
|
608
636
|
.dsh-np-btn[data-accent=true]{background:var(--np-accent);color:var(--np-accent-fg)}
|
|
609
637
|
.dsh-np-btn[data-accent=true]:hover:not(:disabled){background:color-mix(in srgb,var(--np-accent) 88%,#000);color:var(--np-accent-fg)}
|
|
610
638
|
.dsh-np-btn:disabled{opacity:.38;cursor:default;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280)}
|
|
611
|
-
.dsh-np-btn:focus-visible,.dsh-np-segbtn:focus-visible,.dsh-np-link:focus-visible,.dsh-np-empty-add:focus-visible,.dsh-np-toast-action:focus-visible,.dsh-np-selectall input:focus-visible,.dsh-np-check input:focus-visible,.dsh-np-restore:focus-visible,.dsh-np-img-open:focus-visible,.dsh-np-img-remove:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:-2px}
|
|
639
|
+
.dsh-np-btn:focus-visible,.dsh-np-segbtn:focus-visible,.dsh-np-link:focus-visible,.dsh-np-empty-add:focus-visible,.dsh-np-toast-action:focus-visible,.dsh-np-selectall input:focus-visible,.dsh-np-check input:focus-visible,.dsh-np-title-add:focus-visible,.dsh-np-restore:focus-visible,.dsh-np-img-open:focus-visible,.dsh-np-img-remove:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:-2px}
|
|
612
640
|
.dsh-np-body{display:flex;flex:1 1 auto;flex-direction:column;min-height:0;padding:8px 10px 0;opacity:1;transition:opacity .16s ease,visibility 0s linear 0s}
|
|
613
641
|
.dsh-np-card[data-minimized=true] .dsh-np-body{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .16s ease,visibility 0s linear .16s}
|
|
614
642
|
.dsh-np-tools{display:flex;align-items:center;flex-wrap:wrap;gap:4px;flex:none;padding:0 2px 7px;border-bottom:1px solid var(--dsw-alias-border-l1,rgb(15 23 42/.08))}
|
|
@@ -633,6 +661,7 @@ window.__ModuleLoader__.load({
|
|
|
633
661
|
.dsh-np-title-input::placeholder{color:var(--dsw-alias-label-caption,#9aa3b2);font-weight:400}
|
|
634
662
|
.dsh-np-title-input:hover{background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06))}
|
|
635
663
|
.dsh-np-title-input:focus{background:var(--np-surface);box-shadow:0 0 0 1px var(--np-accent) inset;color:var(--dsw-alias-label-primary,#1f2430)}
|
|
664
|
+
.dsh-np-title-add{flex:1 1 auto;min-width:0;width:0;height:20px;padding:0 4px;border:0;border-radius:5px;background:transparent;color:var(--dsw-alias-label-caption,#9aa3b2);font:inherit;font-size:11px;font-weight:400;line-height:20px;text-align:left;cursor:text;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.75}
|
|
636
665
|
.dsh-np-seg-title{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary,#4b5563);font-size:11px;font-weight:600}
|
|
637
666
|
.dsh-np-archived-chip{flex:none;padding:0 6px;border-radius:5px;background:var(--dsw-alias-interactive-bg-hover,rgb(38 49 72/.06));color:var(--dsw-alias-label-tertiary,#6b7280);font-size:10px;line-height:16px}
|
|
638
667
|
.dsh-np-segbtn{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary,#6b7280);cursor:pointer;transition:background .12s,color .12s,opacity .12s;opacity:.8}
|
|
@@ -1032,6 +1061,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1032
1061
|
return parts.length === 0 ? String(root ?? '') : parts[parts.length - 1];
|
|
1033
1062
|
}
|
|
1034
1063
|
|
|
1064
|
+
/**
|
|
1065
|
+
* Height of the chrome the host draws over the page (the Windows title bar
|
|
1066
|
+
* with maximize / minimize / close, or the macOS traffic lights). The
|
|
1067
|
+
* floating card must never move above that strip. The host exposes it as a
|
|
1068
|
+
* CSS variable on <html>; standalone browsers have none, so this is 0.
|
|
1069
|
+
* @returns {number}
|
|
1070
|
+
*/
|
|
1071
|
+
function hostChromeTop() {
|
|
1072
|
+
try {
|
|
1073
|
+
const root = typeof document !== 'undefined' ? document.documentElement : null;
|
|
1074
|
+
if (root === null || root === undefined) return 0;
|
|
1075
|
+
const hasAttribute = (name) => typeof root.hasAttribute === 'function' && root.hasAttribute(name);
|
|
1076
|
+
const readVar = (name) => {
|
|
1077
|
+
try {
|
|
1078
|
+
if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') return NaN;
|
|
1079
|
+
const style = window.getComputedStyle(root);
|
|
1080
|
+
return Number.parseFloat(style?.getPropertyValue(name) ?? '');
|
|
1081
|
+
} catch {
|
|
1082
|
+
return NaN;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
// `--dsh-frame-chrome-top` is 0 while the host is in fullscreen.
|
|
1086
|
+
const chrome = readVar('--dsh-frame-chrome-top');
|
|
1087
|
+
if (Number.isFinite(chrome)) return Math.max(0, chrome);
|
|
1088
|
+
const clearance = readVar('--dsh-frame-top-clearance');
|
|
1089
|
+
if (Number.isFinite(clearance) && clearance > 0) return clearance;
|
|
1090
|
+
if (hasAttribute('data-windows-titlebar')) {
|
|
1091
|
+
const titlebar = readVar('--dsh-windows-titlebar-height');
|
|
1092
|
+
return Number.isFinite(titlebar) && titlebar > 0 ? titlebar : 32;
|
|
1093
|
+
}
|
|
1094
|
+
const platform = typeof root.getAttribute === 'function' ? root.getAttribute('data-platform') : '';
|
|
1095
|
+
return platform === 'darwin' ? 48 : 0;
|
|
1096
|
+
} catch {
|
|
1097
|
+
return 0;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1035
1101
|
function readStoredUi() {
|
|
1036
1102
|
const base = {
|
|
1037
1103
|
x: null,
|
|
@@ -1064,17 +1130,18 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1064
1130
|
function clampUi(ui) {
|
|
1065
1131
|
const vw = (typeof window !== 'undefined' && window.innerWidth) || 1280;
|
|
1066
1132
|
const vh = (typeof window !== 'undefined' && window.innerHeight) || 800;
|
|
1067
|
-
const
|
|
1068
|
-
const
|
|
1133
|
+
const minTop = hostChromeTop() + EDGE_GAP;
|
|
1134
|
+
const widthLimit = Math.max(240, vw - EDGE_GAP * 2);
|
|
1135
|
+
const heightLimit = Math.max(180, vh - minTop - EDGE_GAP);
|
|
1069
1136
|
const width = Math.min(Math.max(MIN_W, Number(ui.width) || DEFAULT_W), widthLimit);
|
|
1070
1137
|
const height = Math.min(Math.max(MIN_H, Number(ui.height) || DEFAULT_H), heightLimit);
|
|
1071
1138
|
const displayHeight = ui.minimized ? HEAD_H : height;
|
|
1072
1139
|
const x = ui.x === null || ui.x === undefined
|
|
1073
|
-
? vw - width - 24
|
|
1074
|
-
: Math.min(Math.max(
|
|
1140
|
+
? Math.max(EDGE_GAP, vw - width - 24)
|
|
1141
|
+
: Math.min(Math.max(EDGE_GAP, ui.x), Math.max(EDGE_GAP, vw - width - EDGE_GAP));
|
|
1075
1142
|
const y = ui.y === null || ui.y === undefined
|
|
1076
|
-
? vh - displayHeight - 24
|
|
1077
|
-
: Math.min(Math.max(
|
|
1143
|
+
? Math.max(minTop, vh - displayHeight - 24)
|
|
1144
|
+
: Math.min(Math.max(minTop, ui.y), Math.max(minTop, vh - displayHeight - EDGE_GAP));
|
|
1078
1145
|
return { ...ui, x, y, width, height };
|
|
1079
1146
|
}
|
|
1080
1147
|
|
|
@@ -1201,7 +1268,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1201
1268
|
const [error, setError] = useState('');
|
|
1202
1269
|
const [savedAt, setSavedAt] = useState(null);
|
|
1203
1270
|
const [toast, setToast] = useState(null);
|
|
1204
|
-
const [ui, setUi] = useState(readStoredUi);
|
|
1271
|
+
const [ui, setUi] = useState(() => clampUi(readStoredUi()));
|
|
1205
1272
|
const [sending, setSending] = useState(false);
|
|
1206
1273
|
const [resizing, setResizing] = useState(false);
|
|
1207
1274
|
const [uploading, setUploading] = useState({});
|
|
@@ -1209,7 +1276,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1209
1276
|
const [reorderDrag, setReorderDrag] = useState('');
|
|
1210
1277
|
const [reorderOver, setReorderOver] = useState(null);
|
|
1211
1278
|
const [optimizing, setOptimizing] = useState(() => new Set());
|
|
1212
|
-
const [
|
|
1279
|
+
const [optimizeBatch, setOptimizeBatch] = useState(null);
|
|
1280
|
+
const [segmentUndo, setSegmentUndo] = useState(() => new Map());
|
|
1281
|
+
const [batchUndo, setBatchUndo] = useState(null);
|
|
1282
|
+
const [titleEditing, setTitleEditing] = useState('');
|
|
1213
1283
|
const [wsCounts, setWsCounts] = useState({});
|
|
1214
1284
|
const [currentWsId, setCurrentWsId] = useState('');
|
|
1215
1285
|
|
|
@@ -1235,9 +1305,13 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1235
1305
|
const fileInputRefs = useRef(new Map());
|
|
1236
1306
|
const listRef = useRef(null);
|
|
1237
1307
|
const pendingFocusRef = useRef(null);
|
|
1308
|
+
const pendingScrollRef = useRef(null);
|
|
1309
|
+
const refreshSeqRef = useRef(0);
|
|
1238
1310
|
const undoRef = useRef(null);
|
|
1239
1311
|
const optimizingRef = useRef(new Set());
|
|
1240
|
-
const
|
|
1312
|
+
const optimizeBatchRef = useRef(false);
|
|
1313
|
+
const optimizeCacheRef = useRef(new Map());
|
|
1314
|
+
const batchUndoRef = useRef(null);
|
|
1241
1315
|
const reorderDragRef = useRef('');
|
|
1242
1316
|
const reorderOverRef = useRef(null);
|
|
1243
1317
|
const imageDeleteTimersRef = useRef(new Map());
|
|
@@ -1259,6 +1333,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1259
1333
|
if (changed) setSelection(pruned);
|
|
1260
1334
|
};
|
|
1261
1335
|
|
|
1336
|
+
/** Keep the ref and the state in sync for the batch-optimize undo slot. */
|
|
1337
|
+
const setBatchUndoState = (value) => {
|
|
1338
|
+
batchUndoRef.current = value;
|
|
1339
|
+
setBatchUndo(value);
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
/** Any local edit invalidates the "undo the last batch" offer. */
|
|
1343
|
+
const invalidateBatchUndo = () => {
|
|
1344
|
+
if (batchUndoRef.current !== null) setBatchUndoState(null);
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1262
1347
|
// Session + workspace -------------------------------------------------
|
|
1263
1348
|
useEffect(() => {
|
|
1264
1349
|
let disposed = false;
|
|
@@ -1378,6 +1463,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1378
1463
|
saveNowRef.current = saveNow;
|
|
1379
1464
|
|
|
1380
1465
|
const commitSegments = (next, options = {}) => {
|
|
1466
|
+
invalidateBatchUndo();
|
|
1381
1467
|
segmentsRef.current = next;
|
|
1382
1468
|
setSegments(next);
|
|
1383
1469
|
pruneSelectionFor(next);
|
|
@@ -1396,6 +1482,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1396
1482
|
segmentsRef.current = [];
|
|
1397
1483
|
setSegments([]);
|
|
1398
1484
|
setSelection(new Set());
|
|
1485
|
+
setSegmentUndo(new Map());
|
|
1486
|
+
setBatchUndoState(null);
|
|
1487
|
+
setTitleEditing('');
|
|
1488
|
+
pendingScrollRef.current = null;
|
|
1399
1489
|
setStatus('idle');
|
|
1400
1490
|
setError('');
|
|
1401
1491
|
return undefined;
|
|
@@ -1412,6 +1502,10 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1412
1502
|
segmentsRef.current = [];
|
|
1413
1503
|
setSegments([]);
|
|
1414
1504
|
setSelection(new Set());
|
|
1505
|
+
setSegmentUndo(new Map());
|
|
1506
|
+
setBatchUndoState(null);
|
|
1507
|
+
setTitleEditing('');
|
|
1508
|
+
pendingScrollRef.current = null;
|
|
1415
1509
|
setStatus('loading');
|
|
1416
1510
|
setError('');
|
|
1417
1511
|
const load = () => {
|
|
@@ -1430,6 +1524,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1430
1524
|
segmentsRef.current = value;
|
|
1431
1525
|
setSegments(value);
|
|
1432
1526
|
setSelection(new Set());
|
|
1527
|
+
setSegmentUndo(new Map());
|
|
1528
|
+
setBatchUndoState(null);
|
|
1433
1529
|
versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
|
|
1434
1530
|
setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
|
|
1435
1531
|
dirtyRef.current = false;
|
|
@@ -1566,14 +1662,41 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1566
1662
|
autoGrow(element);
|
|
1567
1663
|
}, [segments]);
|
|
1568
1664
|
|
|
1665
|
+
// Restore a captured scroll offset right after a refresh commits ------
|
|
1666
|
+
useLayoutEffect(() => {
|
|
1667
|
+
const pending = pendingScrollRef.current;
|
|
1668
|
+
if (pending === null || pending === undefined) return;
|
|
1669
|
+
pendingScrollRef.current = null;
|
|
1670
|
+
const list = listRef.current;
|
|
1671
|
+
if (list === null || list === undefined) return;
|
|
1672
|
+
try {
|
|
1673
|
+
list.scrollTop = pending;
|
|
1674
|
+
} catch {
|
|
1675
|
+
/* A detached list cannot be scrolled. */
|
|
1676
|
+
}
|
|
1677
|
+
}, [segments]);
|
|
1678
|
+
|
|
1569
1679
|
// Keyboard ------------------------------------------------------------
|
|
1570
1680
|
useEffect(() => {
|
|
1571
1681
|
const onKeyDown = (event) => {
|
|
1572
1682
|
const chord = event.ctrlKey || event.metaKey;
|
|
1573
|
-
if (!chord || !event.shiftKey) return;
|
|
1574
|
-
if (event.key
|
|
1575
|
-
|
|
1576
|
-
|
|
1683
|
+
if (!chord || !event.shiftKey || event.altKey) return;
|
|
1684
|
+
if (event.key === 'N' || event.key === 'n') {
|
|
1685
|
+
event.preventDefault();
|
|
1686
|
+
setUi((previous) => clampUi({ ...previous, hidden: !previous.hidden }));
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
if (event.key === '0' || event.code === 'Digit0' || event.code === 'Numpad0') {
|
|
1690
|
+
event.preventDefault();
|
|
1691
|
+
setUi((previous) => clampUi({
|
|
1692
|
+
...previous,
|
|
1693
|
+
x: null,
|
|
1694
|
+
y: null,
|
|
1695
|
+
width: DEFAULT_W,
|
|
1696
|
+
height: DEFAULT_H,
|
|
1697
|
+
}));
|
|
1698
|
+
showToast(t('uiReset'));
|
|
1699
|
+
}
|
|
1577
1700
|
};
|
|
1578
1701
|
window.addEventListener('keydown', onKeyDown, true);
|
|
1579
1702
|
return () => {
|
|
@@ -1616,10 +1739,25 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1616
1739
|
const active = typeof document !== 'undefined' ? document.activeElement : null;
|
|
1617
1740
|
if (active !== null && active !== undefined && typeof active.closest === 'function'
|
|
1618
1741
|
&& active.closest('[data-dsh-notepad="true"]') !== null) return;
|
|
1742
|
+
// Snapshot the scroll offset right before the refresh; the list may
|
|
1743
|
+
// re-render (and jump) while the updated note is applied.
|
|
1744
|
+
const list = listRef.current;
|
|
1745
|
+
const scrollTop = list !== null && list !== undefined && Number.isFinite(list.scrollTop)
|
|
1746
|
+
? list.scrollTop
|
|
1747
|
+
: null;
|
|
1748
|
+
const mySeq = ++refreshSeqRef.current;
|
|
1749
|
+
pendingScrollRef.current = scrollTop;
|
|
1619
1750
|
fetchNote(root, sessionId)
|
|
1620
1751
|
.then((data) => {
|
|
1621
|
-
if (
|
|
1622
|
-
if (
|
|
1752
|
+
if (mySeq !== refreshSeqRef.current) return;
|
|
1753
|
+
if (rootRef.current !== root || dirtyRef.current) {
|
|
1754
|
+
pendingScrollRef.current = null;
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (typeof data.mtimeMs === 'number' && data.mtimeMs === versionRef.current) {
|
|
1758
|
+
pendingScrollRef.current = null;
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1623
1761
|
let incoming = normalizeServerSegments(data);
|
|
1624
1762
|
if (isLegacyPayload(data)) incoming = incoming.slice().reverse();
|
|
1625
1763
|
if (incoming.length === 0) incoming = [newSegment('')];
|
|
@@ -1627,11 +1765,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1627
1765
|
segmentsRef.current = value;
|
|
1628
1766
|
setSegments(value);
|
|
1629
1767
|
pruneSelectionFor(value);
|
|
1768
|
+
setSegmentUndo(new Map());
|
|
1769
|
+
setBatchUndoState(null);
|
|
1630
1770
|
versionRef.current = typeof data.mtimeMs === 'number' ? data.mtimeMs : null;
|
|
1631
1771
|
setSavedAt(typeof data.mtimeMs === 'number' ? data.mtimeMs : null);
|
|
1632
1772
|
setStatus('saved');
|
|
1633
1773
|
})
|
|
1634
|
-
.catch(() => {
|
|
1774
|
+
.catch(() => {
|
|
1775
|
+
if (mySeq === refreshSeqRef.current) pendingScrollRef.current = null;
|
|
1776
|
+
});
|
|
1635
1777
|
};
|
|
1636
1778
|
const onVisibility = () => {
|
|
1637
1779
|
if (typeof document !== 'undefined' && document.visibilityState === 'visible') refresh();
|
|
@@ -1650,9 +1792,17 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1650
1792
|
|
|
1651
1793
|
// Segment mutations ---------------------------------------------------
|
|
1652
1794
|
const updateSegmentText = (id, value) => {
|
|
1795
|
+
invalidateBatchUndo();
|
|
1653
1796
|
const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, text: value } : segment));
|
|
1654
1797
|
segmentsRef.current = next;
|
|
1655
1798
|
setSegments(next);
|
|
1799
|
+
setSegmentUndo((previous) => {
|
|
1800
|
+
const record = previous.get(id);
|
|
1801
|
+
if (record === undefined || record.after === value) return previous;
|
|
1802
|
+
const pruned = new Map(previous);
|
|
1803
|
+
pruned.delete(id);
|
|
1804
|
+
return pruned;
|
|
1805
|
+
});
|
|
1656
1806
|
dirtyRef.current = true;
|
|
1657
1807
|
setStatus('dirty');
|
|
1658
1808
|
setError('');
|
|
@@ -1663,6 +1813,7 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1663
1813
|
const title = String(value ?? '').slice(0, MAX_TITLE_CHARS);
|
|
1664
1814
|
const target = segmentsRef.current.find((segment) => segment.id === id);
|
|
1665
1815
|
if (target === undefined || target.title === title) return;
|
|
1816
|
+
invalidateBatchUndo();
|
|
1666
1817
|
const next = segmentsRef.current.map((segment) => (segment.id === id ? { ...segment, title } : segment));
|
|
1667
1818
|
segmentsRef.current = next;
|
|
1668
1819
|
setSegments(next);
|
|
@@ -1935,6 +2086,8 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
1935
2086
|
if (record === null || record === undefined) return;
|
|
1936
2087
|
undoRef.current = null;
|
|
1937
2088
|
clearPendingImageDeletes();
|
|
2089
|
+
setSegmentUndo(new Map());
|
|
2090
|
+
setBatchUndoState(null);
|
|
1938
2091
|
commitSegments(record.snapshot);
|
|
1939
2092
|
setToast(null);
|
|
1940
2093
|
};
|
|
@@ -2084,14 +2237,33 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2084
2237
|
setOptimizing(next);
|
|
2085
2238
|
};
|
|
2086
2239
|
|
|
2240
|
+
/** Cache key: the prompt depends on the workspace, title and card text. */
|
|
2241
|
+
const optimizationCacheKey = (root, segment) =>
|
|
2242
|
+
`${root}\u0000${segment.title ?? ''}\u0000${segment.text}`;
|
|
2243
|
+
|
|
2244
|
+
const rememberOptimization = (key, value) => {
|
|
2245
|
+
const cache = optimizeCacheRef.current;
|
|
2246
|
+
cache.delete(key);
|
|
2247
|
+
cache.set(key, value);
|
|
2248
|
+
while (cache.size > OPTIMIZE_CACHE_LIMIT) {
|
|
2249
|
+
const oldest = cache.keys().next();
|
|
2250
|
+
if (oldest.done === true) break;
|
|
2251
|
+
cache.delete(oldest.value);
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
|
|
2087
2255
|
const requestOptimization = async (segment) => {
|
|
2088
2256
|
const root = rootRef.current;
|
|
2089
2257
|
const sessionId = rootSessionRef.current;
|
|
2090
2258
|
if (root === '' || sessionId === '') throw new Error(t('optimizeUnavailable'));
|
|
2091
2259
|
if (segment.text.length > MAX_OPTIMIZE_CHARS) throw new Error(t('optimizeTooLong'));
|
|
2260
|
+
const cacheKey = optimizationCacheKey(root, segment);
|
|
2261
|
+
const cached = optimizeCacheRef.current.get(cacheKey);
|
|
2262
|
+
if (typeof cached === 'string' && cached !== '') return cached;
|
|
2092
2263
|
const data = await optimizeNoteText(root, sessionId, segment.text, segment.title ?? '');
|
|
2093
2264
|
const value = typeof data.text === 'string' ? data.text.trim() : '';
|
|
2094
2265
|
if (value === '') throw new Error(t('optimizeFailed'));
|
|
2266
|
+
rememberOptimization(cacheKey, value);
|
|
2095
2267
|
return value;
|
|
2096
2268
|
};
|
|
2097
2269
|
|
|
@@ -2102,18 +2274,21 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2102
2274
|
showToast(t('optimizeEmpty'));
|
|
2103
2275
|
return;
|
|
2104
2276
|
}
|
|
2105
|
-
const snapshot = segmentsRef.current;
|
|
2106
2277
|
const original = target.text;
|
|
2107
2278
|
markOptimizing(id, true);
|
|
2108
2279
|
try {
|
|
2109
2280
|
const value = await requestOptimization(target);
|
|
2110
2281
|
const current = segmentsRef.current.find((segment) => segment.id === id);
|
|
2111
2282
|
if (current === undefined || current.text !== original || value === original) return;
|
|
2112
|
-
undoRef.current = { snapshot };
|
|
2113
2283
|
const next = segmentsRef.current.map((segment) =>
|
|
2114
2284
|
(segment.id === id ? { ...segment, text: value } : segment));
|
|
2115
2285
|
commitSegments(next);
|
|
2116
|
-
|
|
2286
|
+
setSegmentUndo((previous) => {
|
|
2287
|
+
const updated = new Map(previous);
|
|
2288
|
+
updated.set(id, { before: original, after: value });
|
|
2289
|
+
return updated;
|
|
2290
|
+
});
|
|
2291
|
+
showToast(t('optimizedToast'));
|
|
2117
2292
|
} catch (reason) {
|
|
2118
2293
|
const message = String(reason?.message ?? reason);
|
|
2119
2294
|
showToast(message === t('optimizeTooLong') || message === t('optimizeUnavailable')
|
|
@@ -2124,20 +2299,46 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2124
2299
|
}
|
|
2125
2300
|
};
|
|
2126
2301
|
|
|
2127
|
-
const
|
|
2128
|
-
|
|
2302
|
+
const undoOptimizeSegment = (id) => {
|
|
2303
|
+
const record = segmentUndo.get(id);
|
|
2304
|
+
const current = segmentsRef.current.find((segment) => segment.id === id);
|
|
2305
|
+
if (record !== undefined && current !== undefined && current.text === record.after) {
|
|
2306
|
+
const next = segmentsRef.current.map((segment) =>
|
|
2307
|
+
(segment.id === id ? { ...segment, text: record.before } : segment));
|
|
2308
|
+
commitSegments(next);
|
|
2309
|
+
setSegmentUndo((previous) => {
|
|
2310
|
+
if (!previous.has(id)) return previous;
|
|
2311
|
+
const updated = new Map(previous);
|
|
2312
|
+
updated.delete(id);
|
|
2313
|
+
return updated;
|
|
2314
|
+
});
|
|
2315
|
+
showToast(t('optimizeUndone'));
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
setSegmentUndo((previous) => {
|
|
2319
|
+
if (!previous.has(id)) return previous;
|
|
2320
|
+
const updated = new Map(previous);
|
|
2321
|
+
updated.delete(id);
|
|
2322
|
+
return updated;
|
|
2323
|
+
});
|
|
2324
|
+
};
|
|
2325
|
+
|
|
2326
|
+
/** Improve only the checked segments; identical text is answered from cache. */
|
|
2327
|
+
const optimizeSelectedSegments = async () => {
|
|
2328
|
+
if (optimizeBatchRef.current) return;
|
|
2329
|
+
const ids = new Set(selectedRef.current);
|
|
2129
2330
|
const targets = segmentsRef.current.filter((segment) =>
|
|
2130
|
-
segment.archived !== true && segment.text.trim() !== '');
|
|
2331
|
+
segment.archived !== true && ids.has(segment.id) && segment.text.trim() !== '');
|
|
2131
2332
|
if (targets.length === 0) {
|
|
2132
2333
|
showToast(t('optimizeEmpty'));
|
|
2133
2334
|
return;
|
|
2134
2335
|
}
|
|
2135
|
-
|
|
2336
|
+
optimizeBatchRef.current = true;
|
|
2136
2337
|
const snapshot = segmentsRef.current;
|
|
2137
2338
|
let done = 0;
|
|
2138
2339
|
let changed = 0;
|
|
2139
2340
|
let failed = 0;
|
|
2140
|
-
|
|
2341
|
+
setOptimizeBatch({ done, total: targets.length });
|
|
2141
2342
|
try {
|
|
2142
2343
|
for (const target of targets) {
|
|
2143
2344
|
const current = segmentsRef.current.find((segment) =>
|
|
@@ -2152,6 +2353,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2152
2353
|
? { ...segment, text: value }
|
|
2153
2354
|
: segment));
|
|
2154
2355
|
commitSegments(next);
|
|
2356
|
+
setSegmentUndo((previous) => {
|
|
2357
|
+
const updated = new Map(previous);
|
|
2358
|
+
updated.set(target.id, { before: current.text, after: value });
|
|
2359
|
+
return updated;
|
|
2360
|
+
});
|
|
2155
2361
|
changed += 1;
|
|
2156
2362
|
}
|
|
2157
2363
|
} catch {
|
|
@@ -2161,20 +2367,29 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2161
2367
|
}
|
|
2162
2368
|
}
|
|
2163
2369
|
done += 1;
|
|
2164
|
-
|
|
2370
|
+
setOptimizeBatch({ done, total: targets.length });
|
|
2165
2371
|
}
|
|
2166
2372
|
if (changed > 0) {
|
|
2167
|
-
|
|
2168
|
-
showToast(t('optimizedCount', { n: changed })
|
|
2373
|
+
setBatchUndoState({ snapshot });
|
|
2374
|
+
showToast(t('optimizedCount', { n: changed }));
|
|
2169
2375
|
} else {
|
|
2170
2376
|
showToast(failed > 0 ? t('optimizeFailed') : t('optimizedToast'));
|
|
2171
2377
|
}
|
|
2172
2378
|
} finally {
|
|
2173
|
-
|
|
2174
|
-
|
|
2379
|
+
optimizeBatchRef.current = false;
|
|
2380
|
+
setOptimizeBatch(null);
|
|
2175
2381
|
}
|
|
2176
2382
|
};
|
|
2177
2383
|
|
|
2384
|
+
const undoBatchOptimization = () => {
|
|
2385
|
+
const record = batchUndoRef.current;
|
|
2386
|
+
if (record === null || record === undefined) return;
|
|
2387
|
+
setBatchUndoState(null);
|
|
2388
|
+
setSegmentUndo(new Map());
|
|
2389
|
+
commitSegments(record.snapshot);
|
|
2390
|
+
showToast(t('optimizeUndone'));
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2178
2393
|
// Selection -----------------------------------------------------------
|
|
2179
2394
|
const toggleSelected = (id) => {
|
|
2180
2395
|
const next = new Set(selectedRef.current);
|
|
@@ -2370,6 +2585,34 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2370
2585
|
updateSegmentText(id, event.target?.value ?? '');
|
|
2371
2586
|
};
|
|
2372
2587
|
|
|
2588
|
+
/** Focus a card's textarea and place the caret at an absolute offset. */
|
|
2589
|
+
const focusSegmentAt = (id, caret) => {
|
|
2590
|
+
const element = textareaRefs.current.get(id);
|
|
2591
|
+
if (element === null || element === undefined) return false;
|
|
2592
|
+
try {
|
|
2593
|
+
element.focus?.({ preventScroll: true });
|
|
2594
|
+
} catch {
|
|
2595
|
+
try {
|
|
2596
|
+
element.focus?.();
|
|
2597
|
+
} catch {
|
|
2598
|
+
/* ignore */
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
try {
|
|
2602
|
+
const value = typeof element.value === 'string' ? element.value : '';
|
|
2603
|
+
const position = Math.max(0, Math.min(Number(caret) || 0, value.length));
|
|
2604
|
+
if (typeof element.setSelectionRange === 'function') element.setSelectionRange(position, position);
|
|
2605
|
+
} catch {
|
|
2606
|
+
/* A non-layout shim may not support ranges. */
|
|
2607
|
+
}
|
|
2608
|
+
try {
|
|
2609
|
+
element.scrollIntoView?.({ block: 'nearest' });
|
|
2610
|
+
} catch {
|
|
2611
|
+
/* ignore */
|
|
2612
|
+
}
|
|
2613
|
+
return true;
|
|
2614
|
+
};
|
|
2615
|
+
|
|
2373
2616
|
const onSegmentKeyDown = (event, id) => {
|
|
2374
2617
|
if (event.isComposing === true || event.nativeEvent?.isComposing === true) return;
|
|
2375
2618
|
const chord = event.ctrlKey || event.metaKey;
|
|
@@ -2384,6 +2627,28 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2384
2627
|
insertSegments(list);
|
|
2385
2628
|
return;
|
|
2386
2629
|
}
|
|
2630
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
2631
|
+
if (event.altKey || event.shiftKey || chord) return;
|
|
2632
|
+
const element = event.currentTarget ?? event.target;
|
|
2633
|
+
const value = typeof element?.value === 'string' ? element.value : '';
|
|
2634
|
+
const start = typeof element?.selectionStart === 'number' ? element.selectionStart : null;
|
|
2635
|
+
const end = typeof element?.selectionEnd === 'number' ? element.selectionEnd : start;
|
|
2636
|
+
if (start === null || start !== end) return;
|
|
2637
|
+
const firstBreak = value.indexOf('\n');
|
|
2638
|
+
const lastBreak = value.lastIndexOf('\n');
|
|
2639
|
+
const up = event.key === 'ArrowUp';
|
|
2640
|
+
const atEdge = up
|
|
2641
|
+
? (firstBreak === -1 || start <= firstBreak)
|
|
2642
|
+
: (lastBreak === -1 || start > lastBreak);
|
|
2643
|
+
if (!atEdge) return;
|
|
2644
|
+
const active = segmentsRef.current.filter((segment) => segment.archived !== true);
|
|
2645
|
+
const position = active.findIndex((segment) => segment.id === id);
|
|
2646
|
+
const neighbor = position < 0 ? undefined : active[position + (up ? -1 : 1)];
|
|
2647
|
+
if (neighbor !== undefined && focusSegmentAt(neighbor.id, up ? neighbor.text.length : 0)) {
|
|
2648
|
+
event.preventDefault?.();
|
|
2649
|
+
}
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2387
2652
|
if (event.key !== 'Enter' || event.shiftKey || event.altKey || chord) return;
|
|
2388
2653
|
event.preventDefault();
|
|
2389
2654
|
splitSegment(id, event.currentTarget ?? event.target);
|
|
@@ -2400,8 +2665,11 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2400
2665
|
const vw = window.innerWidth || 1280;
|
|
2401
2666
|
const vh = window.innerHeight || 800;
|
|
2402
2667
|
const height = ui.minimized ? HEAD_H : ui.height;
|
|
2403
|
-
const
|
|
2404
|
-
const
|
|
2668
|
+
const minY = hostChromeTop() + EDGE_GAP;
|
|
2669
|
+
const x = Math.min(Math.max(EDGE_GAP, start.x + moveEvent.clientX - start.px),
|
|
2670
|
+
Math.max(EDGE_GAP, vw - ui.width - EDGE_GAP));
|
|
2671
|
+
const y = Math.min(Math.max(minY, start.y + moveEvent.clientY - start.py),
|
|
2672
|
+
Math.max(minY, vh - height - EDGE_GAP));
|
|
2405
2673
|
setUi((previous) => ({ ...previous, x, y }));
|
|
2406
2674
|
};
|
|
2407
2675
|
const stop = () => {
|
|
@@ -2427,17 +2695,20 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2427
2695
|
const move = (moveEvent) => {
|
|
2428
2696
|
const vw = window.innerWidth || 1280;
|
|
2429
2697
|
const vh = window.innerHeight || 800;
|
|
2430
|
-
const
|
|
2431
|
-
const
|
|
2698
|
+
const minY = hostChromeTop() + EDGE_GAP;
|
|
2699
|
+
const width = Math.min(Math.max(MIN_W, start.width + moveEvent.clientX - start.px),
|
|
2700
|
+
Math.max(MIN_W, vw - EDGE_GAP * 2));
|
|
2701
|
+
const height = Math.min(Math.max(MIN_H, start.height + moveEvent.clientY - start.py),
|
|
2702
|
+
Math.max(MIN_H, vh - minY - EDGE_GAP));
|
|
2432
2703
|
setUi((previous) => {
|
|
2433
|
-
const maxX = Math.max(
|
|
2434
|
-
const maxY = Math.max(
|
|
2704
|
+
const maxX = Math.max(EDGE_GAP, vw - width - EDGE_GAP);
|
|
2705
|
+
const maxY = Math.max(minY, vh - height - EDGE_GAP);
|
|
2435
2706
|
return {
|
|
2436
2707
|
...previous,
|
|
2437
2708
|
width,
|
|
2438
2709
|
height,
|
|
2439
|
-
x: typeof previous.x === 'number' ? Math.min(previous.x, maxX) : previous.x,
|
|
2440
|
-
y: typeof previous.y === 'number' ? Math.min(previous.y, maxY) : previous.y,
|
|
2710
|
+
x: typeof previous.x === 'number' ? Math.min(Math.max(EDGE_GAP, previous.x), maxX) : previous.x,
|
|
2711
|
+
y: typeof previous.y === 'number' ? Math.min(Math.max(minY, previous.y), maxY) : previous.y,
|
|
2441
2712
|
};
|
|
2442
2713
|
});
|
|
2443
2714
|
};
|
|
@@ -2465,8 +2736,9 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2465
2736
|
const totalChars = segments.reduce((sum, segment) => sum + segment.text.length, 0);
|
|
2466
2737
|
const displayWidth = ui.width;
|
|
2467
2738
|
const displayHeight = ui.minimized ? HEAD_H : ui.height;
|
|
2468
|
-
const fallbackX = Math.max(
|
|
2469
|
-
const fallbackY = Math.max(
|
|
2739
|
+
const fallbackX = Math.max(EDGE_GAP, ((typeof window !== 'undefined' && window.innerWidth) || 1280) - displayWidth - 24);
|
|
2740
|
+
const fallbackY = Math.max(hostChromeTop() + EDGE_GAP,
|
|
2741
|
+
((typeof window !== 'undefined' && window.innerHeight) || 800) - displayHeight - 24);
|
|
2470
2742
|
const left = typeof ui.x === 'number' ? ui.x : fallbackX;
|
|
2471
2743
|
const top = typeof ui.y === 'number' ? ui.y : fallbackY;
|
|
2472
2744
|
|
|
@@ -2622,17 +2894,24 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2622
2894
|
{
|
|
2623
2895
|
type: 'button',
|
|
2624
2896
|
className: 'dsh-np-btn',
|
|
2625
|
-
'data-active':
|
|
2626
|
-
title:
|
|
2627
|
-
? t('optimizeAllProgress', { done:
|
|
2628
|
-
:
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2897
|
+
'data-active': optimizeBatch !== null || batchUndo !== null ? 'true' : 'false',
|
|
2898
|
+
title: optimizeBatch !== null
|
|
2899
|
+
? t('optimizeAllProgress', { done: optimizeBatch.done, total: optimizeBatch.total })
|
|
2900
|
+
: batchUndo !== null
|
|
2901
|
+
? t('undoOptimize')
|
|
2902
|
+
: selectedCount > 0
|
|
2903
|
+
? t('optimizeSelected', { n: selectedCount })
|
|
2904
|
+
: t('optimizeSelectHint'),
|
|
2905
|
+
'aria-label': batchUndo !== null
|
|
2906
|
+
? t('undoOptimize')
|
|
2907
|
+
: selectedCount > 0 ? t('optimizeSelected', { n: selectedCount }) : t('optimizeSelectHint'),
|
|
2908
|
+
disabled: optimizeBatch !== null
|
|
2909
|
+
|| (batchUndo === null && selectedCount === 0),
|
|
2910
|
+
onClick: batchUndo !== null ? undoBatchOptimization : optimizeSelectedSegments,
|
|
2632
2911
|
},
|
|
2633
|
-
|
|
2912
|
+
optimizeBatch !== null
|
|
2634
2913
|
? h('span', { className: 'dsh-np-mini-spinner' })
|
|
2635
|
-
: h(SparkleIcon, { size: 14 }),
|
|
2914
|
+
: batchUndo !== null ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
|
|
2636
2915
|
),
|
|
2637
2916
|
h(
|
|
2638
2917
|
'button',
|
|
@@ -2720,6 +2999,15 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2720
2999
|
const activeCards = activeSegments.map((segment, index) => {
|
|
2721
3000
|
const images = Array.isArray(segment.images) ? segment.images : [];
|
|
2722
3001
|
const empty = segment.text.trim() === '' && images.length === 0;
|
|
3002
|
+
const titleValue = segment.title ?? '';
|
|
3003
|
+
const showTitleInput = titleValue !== '' || titleEditing === segment.id;
|
|
3004
|
+
const undoRecord = segmentUndo.get(segment.id);
|
|
3005
|
+
const canUndoOptimize = undoRecord !== undefined && undoRecord.after === segment.text;
|
|
3006
|
+
const optimizingNow = optimizing.has(segment.id);
|
|
3007
|
+
const aiBusy = optimizingNow || optimizeBatch !== null;
|
|
3008
|
+
const aiTitle = aiBusy
|
|
3009
|
+
? t('optimizing')
|
|
3010
|
+
: canUndoOptimize ? t('undoOptimize') : t('optimizeSegment');
|
|
2723
3011
|
return h(
|
|
2724
3012
|
'article',
|
|
2725
3013
|
{
|
|
@@ -2786,28 +3074,43 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2786
3074
|
},
|
|
2787
3075
|
String(index + 1).padStart(2, '0'),
|
|
2788
3076
|
),
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
3077
|
+
showTitleInput
|
|
3078
|
+
? h('input', {
|
|
3079
|
+
type: 'text',
|
|
3080
|
+
className: 'dsh-np-title-input',
|
|
3081
|
+
value: titleValue,
|
|
3082
|
+
placeholder: t('titlePlaceholder'),
|
|
3083
|
+
maxLength: MAX_TITLE_CHARS,
|
|
3084
|
+
autoFocus: titleValue === '',
|
|
3085
|
+
'aria-label': t('segmentTitle'),
|
|
3086
|
+
spellCheck: false,
|
|
3087
|
+
onFocus: () => setTitleEditing(segment.id),
|
|
3088
|
+
onChange: (event) => updateSegmentTitle(segment.id, event.target?.value ?? ''),
|
|
3089
|
+
onKeyDown: (event) => {
|
|
3090
|
+
if (event.key !== 'Enter') return;
|
|
3091
|
+
event.preventDefault();
|
|
3092
|
+
try {
|
|
3093
|
+
event.currentTarget?.blur?.();
|
|
3094
|
+
} catch {
|
|
3095
|
+
/* ignore */
|
|
3096
|
+
}
|
|
3097
|
+
},
|
|
3098
|
+
onBlur: () => {
|
|
3099
|
+
if (dirtyRef.current) saveNowRef.current?.();
|
|
3100
|
+
if ((segment.title ?? '') === '') setTitleEditing('');
|
|
3101
|
+
},
|
|
3102
|
+
})
|
|
3103
|
+
: h(
|
|
3104
|
+
'button',
|
|
3105
|
+
{
|
|
3106
|
+
type: 'button',
|
|
3107
|
+
className: 'dsh-np-title-add',
|
|
3108
|
+
title: t('segmentTitle'),
|
|
3109
|
+
'aria-label': t('segmentTitle'),
|
|
3110
|
+
onClick: () => setTitleEditing(segment.id),
|
|
3111
|
+
},
|
|
3112
|
+
t('titlePlaceholder'),
|
|
3113
|
+
),
|
|
2811
3114
|
h(
|
|
2812
3115
|
'button',
|
|
2813
3116
|
{
|
|
@@ -2861,14 +3164,16 @@ body[data-ds-dark-theme] .dsh-np-seg[data-archived=true]{opacity:.7}
|
|
|
2861
3164
|
type: 'button',
|
|
2862
3165
|
className: 'dsh-np-segbtn',
|
|
2863
3166
|
'data-ai': 'true',
|
|
2864
|
-
title:
|
|
2865
|
-
'aria-label':
|
|
2866
|
-
disabled:
|
|
2867
|
-
onClick:
|
|
3167
|
+
title: aiTitle,
|
|
3168
|
+
'aria-label': aiTitle,
|
|
3169
|
+
disabled: aiBusy || (canUndoOptimize ? false : empty),
|
|
3170
|
+
onClick: canUndoOptimize
|
|
3171
|
+
? () => undoOptimizeSegment(segment.id)
|
|
3172
|
+
: () => optimizeSegment(segment.id),
|
|
2868
3173
|
},
|
|
2869
|
-
|
|
3174
|
+
optimizingNow
|
|
2870
3175
|
? h('span', { className: 'dsh-np-mini-spinner' })
|
|
2871
|
-
: h(SparkleIcon, { size: 14 }),
|
|
3176
|
+
: canUndoOptimize ? h(UndoIcon, { size: 14 }) : h(SparkleIcon, { size: 14 }),
|
|
2872
3177
|
),
|
|
2873
3178
|
h(
|
|
2874
3179
|
'button',
|
package/index.js
CHANGED
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
const ROUTE = '/dsh-notepad/api';
|
|
39
39
|
|
|
40
40
|
/** Plugin version mirrored in package.json. */
|
|
41
|
-
const VERSION = '0.
|
|
41
|
+
const VERSION = '0.5.0';
|
|
42
42
|
|
|
43
43
|
/** Request body cap for note writes. */
|
|
44
44
|
const BODY_LIMIT = 1024 * 1024;
|
|
@@ -55,6 +55,12 @@ const OPTIMIZE_TIMEOUT_MS = 120_000;
|
|
|
55
55
|
/** Default output cap for one optimized segment. */
|
|
56
56
|
const OPTIMIZE_MAX_TOKENS = 4096;
|
|
57
57
|
|
|
58
|
+
/** Entries kept in the in-process optimizer cache. */
|
|
59
|
+
const OPTIMIZE_CACHE_LIMIT = 64;
|
|
60
|
+
|
|
61
|
+
/** Maximum key + value characters kept in the optimizer cache. */
|
|
62
|
+
const OPTIMIZE_CACHE_CHARS = 2_000_000;
|
|
63
|
+
|
|
58
64
|
/** Shared instruction for the optional writing assistant. */
|
|
59
65
|
const OPTIMIZE_SYSTEM = [
|
|
60
66
|
'You are a writing assistant for a developer notepad.',
|
|
@@ -238,6 +244,41 @@ function cleanOptimizedText(value) {
|
|
|
238
244
|
return text;
|
|
239
245
|
}
|
|
240
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Results of earlier optimizer calls. Re-optimizing content that has not
|
|
249
|
+
* changed returns the previous answer instead of billing another model call.
|
|
250
|
+
* The cache is per host process and bounded by entry count and characters.
|
|
251
|
+
* @type {Map<string, string>}
|
|
252
|
+
*/
|
|
253
|
+
const optimizeCache = new Map();
|
|
254
|
+
let optimizeCacheChars = 0;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {string} key
|
|
258
|
+
* @param {string} text
|
|
259
|
+
*/
|
|
260
|
+
function cacheOptimizedText(key, text) {
|
|
261
|
+
const previous = optimizeCache.get(key);
|
|
262
|
+
if (previous !== undefined) {
|
|
263
|
+
optimizeCacheChars -= key.length + previous.length;
|
|
264
|
+
optimizeCache.delete(key);
|
|
265
|
+
}
|
|
266
|
+
optimizeCache.set(key, text);
|
|
267
|
+
optimizeCacheChars += key.length + text.length;
|
|
268
|
+
while (optimizeCache.size > OPTIMIZE_CACHE_LIMIT || optimizeCacheChars > OPTIMIZE_CACHE_CHARS) {
|
|
269
|
+
const oldest = optimizeCache.keys().next();
|
|
270
|
+
if (oldest.done === true) break;
|
|
271
|
+
const value = optimizeCache.get(oldest.value) ?? '';
|
|
272
|
+
optimizeCacheChars -= oldest.value.length + value.length;
|
|
273
|
+
optimizeCache.delete(oldest.value);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Workspace + title + text: everything the optimizer prompt depends on. */
|
|
278
|
+
function optimizeCacheKey(root, title, text) {
|
|
279
|
+
return `${root}\u0000${title}\u0000${text}`;
|
|
280
|
+
}
|
|
281
|
+
|
|
241
282
|
/**
|
|
242
283
|
* Ask the currently selected default model to improve one note segment.
|
|
243
284
|
* The LLM service is optional: a deployment without one reports a stable
|
|
@@ -425,8 +466,16 @@ async function dispatchRequest(ctx, req, res) {
|
|
|
425
466
|
`Segment is too large to optimize (${text.length} characters; limit ${MAX_OPTIMIZE_CHARS}).`,
|
|
426
467
|
);
|
|
427
468
|
}
|
|
428
|
-
const
|
|
429
|
-
|
|
469
|
+
const title = typeof body.title === 'string' ? body.title : '';
|
|
470
|
+
const cacheKey = optimizeCacheKey(root, title, text);
|
|
471
|
+
const cached = optimizeCache.get(cacheKey);
|
|
472
|
+
if (typeof cached === 'string' && cached !== '') {
|
|
473
|
+
sendJson(res, { ok: true, root, text: cached, cached: true });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const optimized = await optimizeText(ctx, text, title);
|
|
477
|
+
cacheOptimizedText(cacheKey, optimized);
|
|
478
|
+
sendJson(res, { ok: true, root, text: optimized, cached: false });
|
|
430
479
|
return;
|
|
431
480
|
}
|
|
432
481
|
if (action !== 'note') {
|
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. New segment cards lead the list
|
|
4
|
+
"description": "A per-workspace floating notepad in the session view. New segment cards lead the list; an empty title is just a placeholder 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. Paste or pick images as path-based chips; removing a chip cleans up plugin-owned files. Pending counts appear on the hidden launcher and the sidebar workspace row. Insert, replace, or send one or many; sending archives automatically. Archived segments can be shown, hidden, restored, and deleted. Auto refresh keeps the list scroll position, Ctrl+Shift+0 resets the panel geometry, the panel never covers the host title bar, and it is resizable and dark-mode aware."
|
|
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": "在会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶,空标题点击后才显示输入框,点击序号选中,可拖拽排序、合并多段、用上下方向键切换分段;AI 优化只作用于选中的分段,优化后按钮变为撤销,相同内容直接复用上次结果;支持粘贴或选择图片并以路径附件形式引入,删除图片芯片会清理插件目录里的文件;隐藏按钮和侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送后自动归档,归档可显示、隐藏、恢复与删除;自动刷新保持滚动位置,浮窗可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,且不会越过窗口标题栏。"
|
|
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: newest segment cards first with
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Notepad — a per-workspace floating notepad in the DeepSeek Harness session view: newest segment cards first with titles that appear only after a click, drag to reorder, merge selected segments, arrow keys to move between cards, AI improve limited to the selected segments with a one-click undo and cached results, image chips with automatic file cleanup, pending-count badges on the hidden button and the sidebar workspace, batch insert / replace / send, archive management, scroll position kept across automatic refreshes, resizable panel, Ctrl+Shift+0 geometry reset, a title-bar-safe position, and dark-mode styling. · 记事本 — 在 DeepSeek Harness 会话界面显示一个按工作区共享的分段便签浮窗:新分段置顶且空标题只在点击后显示输入框,可拖拽排序、合并选中分段、上下方向键切换分段、只对选中分段做 AI 优化,优化后按钮变为撤销并复用相同内容的缓存结果;图片芯片删除时自动清理磁盘文件;隐藏按钮与侧边栏工作区显示未发送数量角标;支持单段/多段插入、替换与发送,发送自动归档,归档可显示隐藏和恢复删除;自动刷新保持列表滚动位置;可调整尺寸,Ctrl+Shift+0 复位位置和尺寸,浮窗不会越过窗口标题栏,并适配深色模式。",
|
|
5
5
|
"icon": "./icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|