@followbin/dsh-selection 0.1.0 → 0.2.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 +16 -5
- package/README.zh-CN.md +15 -4
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/client.js +19 -4
- package/icon.svg +1 -1
- package/package.json +1 -1
- package/src/host/service.mjs +10 -0
- package/src/host/settings.mjs +3 -0
- package/src/shared/icons.mjs +23 -0
- package/src/worker/controller.mjs +14 -1
- package/src/worker/overlay.mjs +4 -1
- package/src/worker/page.mjs +2 -2
- package/ui/toolbar.css +11 -1
- package/ui/toolbar.html +2 -0
- package/ui/toolbar.js +67 -7
package/README.md
CHANGED
|
@@ -8,12 +8,20 @@ Select text in any app and a small toolbar appears next to it: translate, summar
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
You need DSH Desktop 0.2.2 or later on macOS 14 or later. Windows support is written but not tested yet; see [Known limitations](#known-limitations).
|
|
11
|
+
You need DSH Desktop 0.2.2 or later on macOS 14 or later. Windows support is written but not tested yet; see [Known limitations](#known-limitations). The screenshots below show DSH in Chinese; the English interface is laid out the same way.
|
|
12
12
|
|
|
13
13
|
1. In DSH, open **Plugins** in the sidebar and click **Add plugin**.
|
|
14
14
|
2. Enter `@followbin/dsh-selection` and click **Install**.
|
|
15
|
+
|
|
16
|
+
<img src="docs/images/zh/install-add.webp" alt="The Add plugin dialog with @followbin/dsh-selection entered" width="430">
|
|
17
|
+
|
|
15
18
|
3. When the dialog says **Installed**, click **Enable now**.
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
<img src="docs/images/zh/install-enable.webp" alt="The Installed dialog with its Enable now button" width="430">
|
|
21
|
+
|
|
22
|
+
4. macOS asks whether "DeepSeek Harness" may control your computer using accessibility features. Choose *Open System Settings* (the highlighted default button is *Deny*) and switch DeepSeek Harness on ([why](#permissions-macos)).
|
|
23
|
+
|
|
24
|
+
<img src="docs/images/zh/install-permission.webp" alt="macOS asking whether DeepSeek Harness may use accessibility features, with Selection assistant now in DSH's sidebar" width="700">
|
|
17
25
|
|
|
18
26
|
Now select some text in any app. Settings are under **Selection assistant** in DSH's sidebar.
|
|
19
27
|
|
|
@@ -24,6 +32,7 @@ To install the newest code from GitHub instead, enter `https://github.com/follow
|
|
|
24
32
|
## Features
|
|
25
33
|
|
|
26
34
|
- **Toolbar**: the first few skills sit on the toolbar, the rest live in the ⋯ menu together with "Disable in this app" and "More settings…".
|
|
35
|
+
- **Mini toolbar**: a single icon instead; rest the pointer on it and every skill drops down. Switch between the two from the toolbar's menu or the settings page.
|
|
27
36
|
- **Built-in skills**: Translate, Summarize, Explain, Ask DSH, Read aloud, Copy, Polish, Search.
|
|
28
37
|
- **Custom skills**: a name, an icon and an instruction. Try them on sample text before saving.
|
|
29
38
|
- **Answers stream in** with Markdown, can be copied, regenerated, translated into another language or pinned on screen. When an answer is complete, a box below it takes follow-up questions, for every skill.
|
|
@@ -44,11 +53,12 @@ Windows needs no extra permission.
|
|
|
44
53
|

|
|
45
54
|
|
|
46
55
|
- Select text and the toolbar appears below the selection. Press **Esc**, click elsewhere or start typing to dismiss it.
|
|
56
|
+
- With the mini toolbar, rest the pointer on the icon (or click it) to open the skills; they close once the pointer has left.
|
|
47
57
|
- **Ask DSH** opens a small chat about the selection. Type a question and press Enter; follow-up questions keep the context.
|
|
48
58
|
- Drag the toolbar, or a panel by its header, to move it; it stays where you put it.
|
|
49
59
|
- In a result panel, the pin keeps it open while you click elsewhere (a new selection still replaces it); the language menu re-translates into another language. Click into the answer to select part of it and copy with ⌘C.
|
|
50
60
|
- Once an answer is complete, type a follow-up in the box below it, for example "more formal" after a translation. Regenerating, or choosing another language, starts the conversation over.
|
|
51
|
-
- **⋯ → Disable in this app** stops the toolbar in the current app. **⋯ → More settings…** opens the settings page in DSH.
|
|
61
|
+
- **⋯ → Use the mini toolbar** turns the toolbar into a single icon, and **Use the full toolbar** in its menu turns it back. **⋯ → Disable in this app** stops the toolbar in the current app. **⋯ → More settings…** opens the settings page in DSH.
|
|
52
62
|
|
|
53
63
|

|
|
54
64
|
|
|
@@ -65,8 +75,9 @@ Open **Selection assistant** in DSH's sidebar. The page has tabs: General, Skill
|
|
|
65
75
|
| --- | --- | --- |
|
|
66
76
|
| Show the toolbar when text is selected | on | |
|
|
67
77
|
| Skills, their order, on/off | 8 built-ins | Drag, or focus the handle and press ↑ ↓. Custom skills can be edited and deleted. |
|
|
78
|
+
| Toolbar style | full | Mini is a single icon whose menu holds every skill. Also switchable from the toolbar's menu. |
|
|
68
79
|
| Show the toolbar | automatically | Or only while holding ⌥ / Alt during the selection. |
|
|
69
|
-
| Skills on the toolbar | 5 | The rest go into the ⋯ menu. |
|
|
80
|
+
| Skills on the toolbar | 5 | The rest go into the ⋯ menu. Full toolbar only. |
|
|
70
81
|
| My language | 简体中文 | Auto translation goes into this language, or into English if the text is already in it. |
|
|
71
82
|
| Translate into | auto | |
|
|
72
83
|
| Summaries, explanations and answers in | my language | Or the language of the selected text, or a fixed language. |
|
|
@@ -131,7 +142,7 @@ After changing `src/worker/` or `ui/`, click **Restart the assistant** on the se
|
|
|
131
142
|
| `pnpm test` | Unit tests for the Host (settings, prompts, model client, HTTP guards, worker messages), the worker's rules, the toolbar page and the settings page (the pages run in jsdom). Plain Node 20 or later is fine. |
|
|
132
143
|
| `pnpm selftest` | Opens the toolbar, the ⋯ menu and both panel types, then checks native window state, layout and that the frontmost app never changes. No permission needed. Add `--interactive` to click around for 20 seconds. |
|
|
133
144
|
| `pnpm dev` | Runs the real toolbar outside DSH with a demo model. Settings go to `~/.dsh/selection-dev`. On macOS the accessibility prompt is for your terminal app. |
|
|
134
|
-
| `pnpm screenshots` | Redraws the images in `docs/images/` from the real
|
|
145
|
+
| `pnpm screenshots` | Redraws the toolbar, follow-up, settings and walkthrough images in `docs/images/` from the real pages with sample text; the install screenshots are taken by hand. Add `en` or `zh` for one language. Needs Google Chrome and ffmpeg. |
|
|
135
146
|
| `pnpm vendor:selection-hook [version]` | Refreshes `vendor/selection-hook` from npm. |
|
|
136
147
|
|
|
137
148
|
How the pieces fit together, and why some unusual choices were made, is in [docs/architecture.md](docs/architecture.md).
|
package/README.zh-CN.md
CHANGED
|
@@ -12,8 +12,16 @@
|
|
|
12
12
|
|
|
13
13
|
1. 在 DSH 侧边栏打开「插件」,点「添加插件」。
|
|
14
14
|
2. 输入 `@followbin/dsh-selection`,点「安装」。
|
|
15
|
+
|
|
16
|
+
<img src="docs/images/zh/install-add.webp" alt="「添加插件」里填好 @followbin/dsh-selection" width="430">
|
|
17
|
+
|
|
15
18
|
3. 显示「已安装」后,点「立即启用」。
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
<img src="docs/images/zh/install-enable.webp" alt="安装完成,点「立即启用」" width="430">
|
|
21
|
+
|
|
22
|
+
4. macOS 会询问是否允许「DeepSeek Harness」使用辅助功能控制这台电脑。点「打开系统设置」(高亮的默认按钮是「拒绝」,别直接按回车),把 DeepSeek Harness 打开([为什么需要](#权限macos))。
|
|
23
|
+
|
|
24
|
+
<img src="docs/images/zh/install-permission.webp" alt="macOS 请求辅助功能权限,DSH 侧边栏已出现「划词助手」" width="700">
|
|
17
25
|
|
|
18
26
|
然后在任意应用里选中一段文字试试。设置在 DSH 侧边栏的「划词助手」里。
|
|
19
27
|
|
|
@@ -24,6 +32,7 @@
|
|
|
24
32
|
## 功能
|
|
25
33
|
|
|
26
34
|
- **工具栏**:前几个技能直接显示,其余收进「⋯」菜单;菜单里还有「在此应用中禁用」和「更多设置…」。
|
|
35
|
+
- **迷你模式**:工具栏换成一个小图标,鼠标停在上面就展开全部技能。在工具栏菜单或设置页里随时切换。
|
|
27
36
|
- **内置技能**:翻译、总结、解释、问问 DSH、朗读、复制、润色、搜索。
|
|
28
37
|
- **自定义技能**:起个名字、选个图标、写一句指令,保存前可以拿示例文字试一试。
|
|
29
38
|
- **结果面板**:流式显示,支持 Markdown,可以复制、重新生成、切换翻译语言、固定在屏幕上。每个技能回答完后,下面都有输入框可以接着问。
|
|
@@ -44,11 +53,12 @@ Windows 不需要额外权限。
|
|
|
44
53
|

|
|
45
54
|
|
|
46
55
|
- 选中文字后,工具栏出现在选区下方。按 **Esc**、点别处或开始打字都会收起它。
|
|
56
|
+
- 迷你模式下,鼠标停在图标上(或点一下)就展开技能,移开后自动收起。
|
|
47
57
|
- **问问 DSH** 会打开一个针对这段文字的小对话框。输入问题后按回车,追问会保留上下文。
|
|
48
58
|
- 按住工具栏,或按住面板的标题栏拖动,可以把它移开;放下后就停在那里。
|
|
49
59
|
- 结果面板上的图钉可以把面板固定住,点别处也不会收起(再次划词仍会替换它);翻译面板的语言下拉框可以换一种语言重新翻译。在回答里点一下,就能选中其中一部分并用 ⌘C 复制。
|
|
50
60
|
- 回答完成后,可以在下方输入框里接着问,比如翻译完问「再正式一点」。点重新生成或换一种语言时,对话会从头开始。
|
|
51
|
-
- **⋯ → 在此应用中禁用** 会让当前应用里不再弹出;**⋯ → 更多设置…** 会打开 DSH 里的设置页。
|
|
61
|
+
- **⋯ → 切换为迷你模式** 把工具栏换成一个图标,图标菜单里的 **切换为常规模式** 换回来;**⋯ → 在此应用中禁用** 会让当前应用里不再弹出;**⋯ → 更多设置…** 会打开 DSH 里的设置页。
|
|
52
62
|
|
|
53
63
|

|
|
54
64
|
|
|
@@ -65,8 +75,9 @@ Windows 不需要额外权限。
|
|
|
65
75
|
| --- | --- | --- |
|
|
66
76
|
| 选中文字时显示工具栏 | 开 | |
|
|
67
77
|
| 技能、顺序与开关 | 8 个内置技能 | 拖动排序,也可以聚焦拖动把手后按 ↑ ↓。自定义技能可以编辑、删除。 |
|
|
78
|
+
| 工具栏样式 | 常规 | 迷你模式只有一个图标,菜单里是全部技能;也可以在工具栏菜单里切换。 |
|
|
68
79
|
| 弹出方式 | 选中后自动弹出 | 也可以设为按住 ⌥ / Alt 选中时才弹出。 |
|
|
69
|
-
| 工具栏上的技能数 | 5 |
|
|
80
|
+
| 工具栏上的技能数 | 5 | 其余收进「⋯」菜单。只在常规模式下有效。 |
|
|
70
81
|
| 我的母语 | 简体中文 | 自动翻译会译成母语;原文已经是母语时译成英文。 |
|
|
71
82
|
| 翻译成 | 自动 | |
|
|
72
83
|
| 总结、解释和回答使用 | 我的母语 | 也可以跟随原文的语言,或固定某种语言。 |
|
|
@@ -131,7 +142,7 @@ env ELECTRON_RUN_AS_NODE=1 "/Applications/DeepSeek Harness.app/Contents/MacOS/De
|
|
|
131
142
|
| `pnpm test` | 单元测试:Host(设置、提示词、模型调用、HTTP 防护、worker 消息)、worker 的行为规则、工具栏页面和设置页(页面跑在 jsdom 里)。普通 Node 20 及以上就能跑。 |
|
|
132
143
|
| `pnpm selftest` | 依次打开工具栏、「⋯」菜单和两种结果面板,检查原生窗口状态、布局,以及前台应用始终没变。不需要权限。加 `--interactive` 可以手动点 20 秒。 |
|
|
133
144
|
| `pnpm dev` | 不经过 DSH、用演示模型运行真实的工具栏,设置存在 `~/.dsh/selection-dev`。在 macOS 上,授权弹窗针对的是你的终端应用。 |
|
|
134
|
-
| `pnpm screenshots` | 用真实的工具栏页面和设置页、配上示例文字,重新生成 `docs/images/`
|
|
145
|
+
| `pnpm screenshots` | 用真实的工具栏页面和设置页、配上示例文字,重新生成 `docs/images/` 里的工具栏、追问、设置页截图和动图;安装步骤的截图是手工截的。加 `en` 或 `zh` 只生成一种语言。需要 Google Chrome 和 ffmpeg。 |
|
|
135
146
|
| `pnpm vendor:selection-hook [版本]` | 从 npm 更新 `vendor/selection-hook`。 |
|
|
136
147
|
|
|
137
148
|
各部分怎么协作、为什么有些做法不寻常,见 [docs/architecture.md](docs/architecture.md)。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -7,7 +7,7 @@ dsh-selection ships or depends on the following software. Each is used under its
|
|
|
7
7
|
| [selection-hook](https://github.com/0xfullex/selection-hook) 2.1.2 | Vendored in `vendor/selection-hook` (JavaScript loader and N-API prebuilds for macOS and Windows): global input hooks and reading the selected text | MIT | © 2025 0xfullex |
|
|
8
8
|
| [@webviewjs/webview](https://github.com/webviewjs/webview) 0.6.0 | npm dependency: system web view windows (built on tao and wry) | MIT | © 2024 Twilight |
|
|
9
9
|
| [koffi](https://koffi.dev/) 3.3.6 (`@koromix/koffi-*` platform packages) | npm dependency: calling macOS and Windows system APIs | MIT | © 2026 Niels Martignène |
|
|
10
|
-
| [Lucide](https://lucide.dev/) icons | Path data copied into `src/shared/icons.mjs
|
|
10
|
+
| [Lucide](https://lucide.dev/) icons | Path data copied into `src/shared/icons.mjs` | ISC (portions MIT, from Feather) | © Lucide Contributors 2022; portions © Cole Bemis 2013–2022 |
|
|
11
11
|
|
|
12
12
|
The full MIT text for selection-hook is in `vendor/selection-hook/LICENSE`; the other packages include their license files in `node_modules`.
|
|
13
13
|
|
package/client.js
CHANGED
|
@@ -36,6 +36,7 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
'enabled.hint': '关掉后划词助手不会再弹出,设置保留。',
|
|
37
37
|
'skills.title': '技能',
|
|
38
38
|
'skills.hint': '前 {count} 个启用的技能显示在工具栏上,其余收进 ⋯ 菜单。拖动或用方向键调整顺序。',
|
|
39
|
+
'skills.hintMini': '迷你模式下,所有启用的技能都在图标展开的菜单里。拖动或用方向键调整顺序。',
|
|
39
40
|
'skills.overflow': '以下收进 ⋯ 菜单',
|
|
40
41
|
'skills.custom': '自定义',
|
|
41
42
|
'skills.add': '新建技能',
|
|
@@ -63,6 +64,10 @@ window.__ModuleLoader__.load({
|
|
|
63
64
|
'toolbar.trigger.auto': '选中文字后自动弹出',
|
|
64
65
|
'toolbar.trigger.modifier': '按住 ⌥ / Alt 选中时才弹出',
|
|
65
66
|
'toolbar.size': '工具栏上的技能数',
|
|
67
|
+
'toolbar.style': '样式',
|
|
68
|
+
'toolbar.style.full': '常规',
|
|
69
|
+
'toolbar.style.mini': '迷你',
|
|
70
|
+
'toolbar.style.hint': '迷你模式只显示一个图标,鼠标停在上面会展开全部技能。在工具栏的菜单里也能切换。',
|
|
66
71
|
'language.title': '语言',
|
|
67
72
|
'language.native': '我的母语',
|
|
68
73
|
'language.translate': '翻译成',
|
|
@@ -158,6 +163,7 @@ window.__ModuleLoader__.load({
|
|
|
158
163
|
'enabled.hint': 'Turning this off hides the assistant and keeps your settings.',
|
|
159
164
|
'skills.title': 'Skills',
|
|
160
165
|
'skills.hint': 'The first {count} enabled skills sit on the toolbar; the rest go into the ⋯ menu. Drag or use the arrow keys to reorder.',
|
|
166
|
+
'skills.hintMini': 'With the mini toolbar, every enabled skill is in the icon’s menu. Drag or use the arrow keys to reorder.',
|
|
161
167
|
'skills.overflow': 'In the ⋯ menu',
|
|
162
168
|
'skills.custom': 'Custom',
|
|
163
169
|
'skills.add': 'New skill',
|
|
@@ -185,6 +191,10 @@ window.__ModuleLoader__.load({
|
|
|
185
191
|
'toolbar.trigger.auto': 'Automatically after selecting text',
|
|
186
192
|
'toolbar.trigger.modifier': 'Only while holding ⌥ / Alt during selection',
|
|
187
193
|
'toolbar.size': 'Skills on the toolbar',
|
|
194
|
+
'toolbar.style': 'Style',
|
|
195
|
+
'toolbar.style.full': 'Full',
|
|
196
|
+
'toolbar.style.mini': 'Mini',
|
|
197
|
+
'toolbar.style.hint': 'The mini toolbar is a single icon; rest the pointer on it to see every skill. You can also switch from the toolbar’s menu.',
|
|
188
198
|
'language.title': 'Language',
|
|
189
199
|
'language.native': 'My language',
|
|
190
200
|
'language.translate': 'Translate into',
|
|
@@ -576,7 +586,7 @@ window.__ModuleLoader__.load({
|
|
|
576
586
|
checked: entry.enabled, label: nameOf(entry),
|
|
577
587
|
onChange: (enabled) => update({ skills: settings.skills.map((skill) => (skill.id === entry.id ? { ...skill, enabled } : skill)) }),
|
|
578
588
|
})))
|
|
579
|
-
if (entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
|
|
589
|
+
if (settings.toolbarStyle !== 'mini' && entry.enabled && enabledSeen === settings.toolbarSize && index < settings.skills.length - 1) {
|
|
580
590
|
rows.push(h('div', { key: '__overflow', className: 'dss-overflow' }, t('skills.overflow')))
|
|
581
591
|
}
|
|
582
592
|
})
|
|
@@ -868,11 +878,16 @@ window.__ModuleLoader__.load({
|
|
|
868
878
|
h(Row, { label: t('enabled.label'), hint: t('enabled.hint') }, h(Switch, { checked: settings.enabled, label: t('enabled.label'), onChange: (enabled) => update({ enabled }) }))),
|
|
869
879
|
h(Section, { title: t('toolbar.title') },
|
|
870
880
|
h('div', { className: 'dss-card' },
|
|
881
|
+
h(Row, { label: t('toolbar.style'), hint: t('toolbar.style.hint') },
|
|
882
|
+
h('div', { className: 'dss-segmented', role: 'group', 'aria-label': t('toolbar.style'), style: { marginTop: 0 } },
|
|
883
|
+
['full', 'mini'].map((style) => h('button', {
|
|
884
|
+
key: style, type: 'button', 'aria-pressed': String(settings.toolbarStyle === style), onClick: () => update({ toolbarStyle: style }),
|
|
885
|
+
}, t(`toolbar.style.${style}`))))),
|
|
871
886
|
h(Row, { label: t('toolbar.trigger') },
|
|
872
887
|
h('select', { className: 'dss-select', value: settings.trigger, onChange: (event) => update({ trigger: event.target.value }) },
|
|
873
888
|
h('option', { value: 'auto' }, t('toolbar.trigger.auto')),
|
|
874
889
|
h('option', { value: 'modifier' }, t('toolbar.trigger.modifier')))),
|
|
875
|
-
h(Row, { label: t('toolbar.size') },
|
|
890
|
+
settings.toolbarStyle === 'mini' ? null : h(Row, { label: t('toolbar.size') },
|
|
876
891
|
h(NumberInput, { value: settings.toolbarSize, min: limits.toolbarSize.min, max: limits.toolbarSize.max, label: t('toolbar.size'), onChange: (toolbarSize) => update({ toolbarSize }) })))),
|
|
877
892
|
h(Section, { title: t('language.title') },
|
|
878
893
|
h('div', { className: 'dss-card' },
|
|
@@ -890,7 +905,7 @@ window.__ModuleLoader__.load({
|
|
|
890
905
|
catalog.languages.map((language) => h('option', { key: language.code, value: language.code }, language.name))))))),
|
|
891
906
|
|
|
892
907
|
panel('skills',
|
|
893
|
-
h(Section, { title: t('skills.title'), hint: fill(t('skills.hint'), { count: settings.toolbarSize }) },
|
|
908
|
+
h(Section, { title: t('skills.title'), hint: settings.toolbarStyle === 'mini' ? t('skills.hintMini') : fill(t('skills.hint'), { count: settings.toolbarSize }) },
|
|
894
909
|
h(SkillList, { t, settings, catalog, update, onEdit: (entry) => setEditing(entry) }),
|
|
895
910
|
editing
|
|
896
911
|
? h(SkillEditor, { key: editing.id || 'new', t, catalog, limits, initial: editing.id ? editing : null, onSave: saveSkill, onCancel: () => setEditing(null), prepare: saveNow })
|
|
@@ -1013,7 +1028,7 @@ window.__ModuleLoader__.load({
|
|
|
1013
1028
|
}
|
|
1014
1029
|
|
|
1015
1030
|
// Dashed selection frame around lines of text (Lucide "text-select", ISC).
|
|
1016
|
-
const SIDEBAR_ICON = '
|
|
1031
|
+
const SIDEBAR_ICON = 'M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M12 6.8c.47 2.87 2.33 4.73 5.2 5.2-2.87.47-4.73 2.33-5.2 5.2-.47-2.87-2.33-4.73-5.2-5.2 2.87-.47 4.73-2.33 5.2-5.2Z'
|
|
1017
1032
|
|
|
1018
1033
|
function SidebarIcon({ size = 18, active }) {
|
|
1019
1034
|
return h('svg', {
|
package/icon.svg
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36" fill="none"><path d="M7.5 12.5V10C7.5 8.62 8.62 7.5 10 7.5H12.5M23.5 7.5H26C27.38 7.5 28.5 8.62 28.5 10V12.5M28.5 23.5V26C28.5 27.38 27.38 28.5 26 28.5H23.5M12.5 28.5H10C8.62 28.5 7.5 27.38 7.5 26V23.5" stroke="url(#dsh-selection-frame)" stroke-width="2.4" stroke-linecap="round"/><path d="M18 10.8C18.655 14.77 21.23 17.345 25.2 18C21.23 18.655 18.655 21.23 18 25.2C17.345 21.23 14.77 18.655 10.8 18C14.77 17.345 17.345 14.77 18 10.8Z" fill="url(#dsh-selection-spark)" stroke="none"/><defs><linearGradient id="dsh-selection-frame" x1="7.5" y1="7.5" x2="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop stop-color="#6AECB6"/><stop offset="1" stop-color="#37A6B1"/></linearGradient><linearGradient id="dsh-selection-spark" x1="10.8" y1="10.8" x2="25.2" y2="25.2" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient></defs></svg>
|
package/package.json
CHANGED
package/src/host/service.mjs
CHANGED
|
@@ -101,6 +101,7 @@ export function createSelectionService({
|
|
|
101
101
|
enabled: current.enabled,
|
|
102
102
|
trigger: current.trigger,
|
|
103
103
|
toolbarSize: current.toolbarSize,
|
|
104
|
+
toolbarStyle: current.toolbarStyle,
|
|
104
105
|
skills: enabledSkills(current),
|
|
105
106
|
disabledApps: current.disabledApps,
|
|
106
107
|
clipboardFallback: current.clipboardFallback,
|
|
@@ -131,6 +132,15 @@ export function createSelectionService({
|
|
|
131
132
|
case 'cancel':
|
|
132
133
|
runs.get(message.requestId)?.abort()
|
|
133
134
|
break
|
|
135
|
+
case 'toolbar-style':
|
|
136
|
+
if (message.style === 'full' || message.style === 'mini') {
|
|
137
|
+
try {
|
|
138
|
+
settings.update((current) => ({ ...current, toolbarStyle: message.style }))
|
|
139
|
+
} catch (error) {
|
|
140
|
+
log('error', 'saving settings failed', { error: String(error?.message ?? error) })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
break
|
|
134
144
|
case 'disable-app':
|
|
135
145
|
if (typeof message.app === 'string' && message.app) {
|
|
136
146
|
try {
|
package/src/host/settings.mjs
CHANGED
|
@@ -57,6 +57,7 @@ export function defaultSettings() {
|
|
|
57
57
|
enabled: true,
|
|
58
58
|
trigger: 'auto',
|
|
59
59
|
toolbarSize: 5,
|
|
60
|
+
toolbarStyle: 'full',
|
|
60
61
|
skills: BUILTIN_SKILLS.map((skill) => ({ id: skill.id, enabled: true })),
|
|
61
62
|
disabledApps: [],
|
|
62
63
|
nativeLanguage: 'zh-CN',
|
|
@@ -83,6 +84,7 @@ export function normalizeSettings(input) {
|
|
|
83
84
|
enabled: typeof value.enabled === 'boolean' ? value.enabled : defaults.enabled,
|
|
84
85
|
trigger: value.trigger === 'modifier' ? 'modifier' : 'auto',
|
|
85
86
|
toolbarSize: clampInt(value.toolbarSize, LIMITS.toolbarSize, defaults.toolbarSize),
|
|
87
|
+
toolbarStyle: value.toolbarStyle === 'mini' ? 'mini' : 'full',
|
|
86
88
|
skills: normalizeSkills(value.skills),
|
|
87
89
|
disabledApps: normalizeApps(value.disabledApps),
|
|
88
90
|
nativeLanguage: LANGUAGE_CODES.has(value.nativeLanguage) ? value.nativeLanguage : defaults.nativeLanguage,
|
|
@@ -266,6 +268,7 @@ function isObject(value) {
|
|
|
266
268
|
* @property {boolean} enabled Show the toolbar when text is selected.
|
|
267
269
|
* @property {'auto' | 'modifier'} trigger `modifier` shows it only while ⌥ / Alt is held.
|
|
268
270
|
* @property {number} toolbarSize How many enabled skills sit on the toolbar; the rest go to the ⋯ menu.
|
|
271
|
+
* @property {'full' | 'mini'} toolbarStyle Full: skill buttons on the toolbar. Mini: one icon whose menu holds every skill.
|
|
269
272
|
* @property {Array<{ id: string, enabled: boolean, custom?: true, name?: string, icon?: string, prompt?: string }>} skills
|
|
270
273
|
* @property {string[]} disabledApps Program names (as reported by the OS) where the toolbar never shows.
|
|
271
274
|
* @property {string} nativeLanguage Language the user reads best; auto translation flips around it.
|
package/src/shared/icons.mjs
CHANGED
|
@@ -35,8 +35,31 @@ export const ICONS = Object.freeze({
|
|
|
35
35
|
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
|
36
36
|
edit: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>',
|
|
37
37
|
cursor: '<path d="M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1"/><path d="M7 22h1a4 4 0 0 0 4-4v-1"/><path d="M7 2h1a4 4 0 0 1 4 4v1"/>',
|
|
38
|
+
minimize: '<path d="m14 10 7-7"/><path d="M20 10h-6V4"/><path d="m3 21 7-7"/><path d="M4 14h6v6"/>',
|
|
39
|
+
maximize: '<path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/><path d="M9 21H3v-6"/>',
|
|
38
40
|
})
|
|
39
41
|
|
|
42
|
+
const BRAND_FRAME = 'M7.5 12.5V10C7.5 8.62 8.62 7.5 10 7.5H12.5M23.5 7.5H26C27.38 7.5 28.5 8.62 28.5 10V12.5M28.5 23.5V26C28.5 27.38 27.38 28.5 26 28.5H23.5M12.5 28.5H10C8.62 28.5 7.5 27.38 7.5 26V23.5'
|
|
43
|
+
const BRAND_SPARK = 'M18 10.8C18.655 14.77 21.23 17.345 25.2 18C21.23 18.655 18.655 21.23 18 25.2C17.345 21.23 14.77 18.655 10.8 18C14.77 17.345 17.345 14.77 18 10.8Z'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The plugin's own mark, drawn for it rather than taken from Lucide: a selection frame around a spark,
|
|
47
|
+
* in the colours of DSH's plugin icons. `id` prefixes the gradient ids, which a document needs unique.
|
|
48
|
+
* The full view box keeps the margin DSH's own plugin icons have; a cropped one fits a small button.
|
|
49
|
+
*/
|
|
50
|
+
export function brandIcon(id, viewBox = '0 0 36 36') {
|
|
51
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="${viewBox}" fill="none">`
|
|
52
|
+
+ `<path d="${BRAND_FRAME}" stroke="url(#${id}-frame)" stroke-width="2.4" stroke-linecap="round"/>`
|
|
53
|
+
+ `<path d="${BRAND_SPARK}" fill="url(#${id}-spark)" stroke="none"/>`
|
|
54
|
+
+ '<defs>'
|
|
55
|
+
+ `<linearGradient id="${id}-frame" x1="7.5" y1="7.5" x2="28.5" y2="28.5" gradientUnits="userSpaceOnUse"><stop stop-color="#6AECB6"/><stop offset="1" stop-color="#37A6B1"/></linearGradient>`
|
|
56
|
+
+ `<linearGradient id="${id}-spark" x1="10.8" y1="10.8" x2="25.2" y2="25.2" gradientUnits="userSpaceOnUse"><stop stop-color="#7CB7FF"/><stop offset="1" stop-color="#145AF3"/></linearGradient>`
|
|
57
|
+
+ '</defs></svg>'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The same mark as a one-colour line drawing on a 24×24 grid, for DSH's sidebar. */
|
|
61
|
+
export const BRAND_LINE = 'M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2M12 6.8c.47 2.87 2.33 4.73 5.2 5.2-2.87.47-4.73 2.33-5.2 5.2-.47-2.87-2.33-4.73-5.2-5.2 2.87-.47 4.73-2.33 5.2-5.2Z'
|
|
62
|
+
|
|
40
63
|
/** Icons a user may pick for a custom skill. */
|
|
41
64
|
export const CUSTOM_SKILL_ICONS = Object.freeze([
|
|
42
65
|
'sparkles', 'star', 'heart', 'zap', 'lightbulb', 'list', 'quote', 'globe', 'link', 'code', 'polish', 'summarize', 'explain', 'ask',
|
|
@@ -49,7 +49,11 @@ export function createController({
|
|
|
49
49
|
|
|
50
50
|
function configure(next) {
|
|
51
51
|
config = next
|
|
52
|
-
overlay.configure({
|
|
52
|
+
overlay.configure({
|
|
53
|
+
bar: next.skills.slice(0, next.toolbarSize),
|
|
54
|
+
more: next.skills.slice(next.toolbarSize),
|
|
55
|
+
style: next.toolbarStyle === 'mini' ? 'mini' : 'full',
|
|
56
|
+
})
|
|
53
57
|
if (!next.enabled) dismiss({ force: true, reason: 'disabled' })
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -119,6 +123,7 @@ export function createController({
|
|
|
119
123
|
case 'skill':
|
|
120
124
|
return runSkill(message.id)
|
|
121
125
|
case 'menu':
|
|
126
|
+
if (message.action === 'toolbar-style') return switchStyle()
|
|
122
127
|
if (message.action !== 'disable-app' && message.action !== 'settings') return
|
|
123
128
|
if (message.action === 'disable-app' && selection) send({ type: 'disable-app', app: selection.app })
|
|
124
129
|
if (message.action === 'settings') send({ type: 'open-settings' })
|
|
@@ -165,6 +170,14 @@ export function createController({
|
|
|
165
170
|
}
|
|
166
171
|
}
|
|
167
172
|
|
|
173
|
+
/** Swaps the full toolbar and the mini icon where they are, and has the Host save the choice. */
|
|
174
|
+
function switchStyle() {
|
|
175
|
+
if (!config) return
|
|
176
|
+
const style = config.toolbarStyle === 'mini' ? 'full' : 'mini'
|
|
177
|
+
configure({ ...config, toolbarStyle: style })
|
|
178
|
+
send({ type: 'toolbar-style', style })
|
|
179
|
+
}
|
|
180
|
+
|
|
168
181
|
function runSkill(id) {
|
|
169
182
|
const skill = config?.skills.find((candidate) => candidate.id === id)
|
|
170
183
|
if (!skill || !selection) return
|
package/src/worker/overlay.mjs
CHANGED
|
@@ -57,6 +57,9 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
57
57
|
focused: false,
|
|
58
58
|
alwaysOnTop: true,
|
|
59
59
|
windowsSkipTaskbar: true,
|
|
60
|
+
// tao keeps a system shadow on undecorated windows unless told not to; on Windows it outlines the
|
|
61
|
+
// whole transparent window with a 1px border and adds hidden insets around the client area.
|
|
62
|
+
windowsUndecoratedShadow: false,
|
|
60
63
|
macosHasShadow: false,
|
|
61
64
|
})
|
|
62
65
|
// WebView2 defaults its data folder next to the executable, i.e. inside the DSH install directory.
|
|
@@ -179,7 +182,7 @@ export async function createOverlay({ onMessage, log }) {
|
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
return {
|
|
182
|
-
/** @param {{ bar: object[], more: object[] }} toolbar Skills on the toolbar and in the
|
|
185
|
+
/** @param {{ bar: object[], more: object[], style: 'full' | 'mini' }} toolbar Skills on the toolbar and in its menu, and how the toolbar is drawn. */
|
|
183
186
|
configure: (toolbar) => call('configure', toolbar),
|
|
184
187
|
/** @param {Anchor} point In selection-hook coordinates. */
|
|
185
188
|
showBar(point) {
|
package/src/worker/page.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// The toolbar page as one HTML string: the web view loads it with no file or network access.
|
|
2
2
|
// Kept apart from overlay.mjs, which loads the native web view, so tests can load the same page.
|
|
3
3
|
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { ICONS } from '../shared/icons.mjs'
|
|
4
|
+
import { ICONS, brandIcon } from '../shared/icons.mjs'
|
|
5
5
|
|
|
6
6
|
/** Inlines the page's stylesheet, scripts and icon table. */
|
|
7
7
|
export function pageHtml() {
|
|
8
8
|
const read = (name) => readFileSync(new URL(`../../ui/${name}`, import.meta.url), 'utf8')
|
|
9
9
|
return read('toolbar.html')
|
|
10
10
|
.replace('<link rel="stylesheet" href="toolbar.css">', () => `<style>${read('toolbar.css')}</style>`)
|
|
11
|
-
.replace('<script src="icons.js"></script>', () => `<script>window.DSH_ICONS = ${JSON.stringify(ICONS)}</script>`)
|
|
11
|
+
.replace('<script src="icons.js"></script>', () => `<script>window.DSH_ICONS = ${JSON.stringify(ICONS)}; window.DSH_BRAND = ${JSON.stringify(brandIcon('brand', '5 5 26 26'))}</script>`)
|
|
12
12
|
.replace('<script src="markdown.js"></script>', () => `<script>${read('markdown.js')}</script>`)
|
|
13
13
|
.replace('<script src="toolbar.js"></script>', () => `<script>${read('toolbar.js')}</script>`)
|
|
14
14
|
}
|
package/ui/toolbar.css
CHANGED
|
@@ -63,7 +63,13 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
63
63
|
.skill { height: 32px; padding: 0 10px 0 9px; font-weight: 500; letter-spacing: 0.1px; }
|
|
64
64
|
.skill.done svg { color: #1f9d55; }
|
|
65
65
|
.divider { width: 1px; height: 16px; margin: 0 4px; background: var(--hairline); }
|
|
66
|
-
.more-button[aria-expanded="true"] { background: var(--press); }
|
|
66
|
+
.more-button[aria-expanded="true"], .mini-button[aria-expanded="true"] { background: var(--press); }
|
|
67
|
+
/* Mini toolbar: one icon, and every skill in its menu. */
|
|
68
|
+
.mini-button { display: none; color: var(--accent); }
|
|
69
|
+
.mini-button svg { width: 20px; height: 20px; }
|
|
70
|
+
[data-style="mini"] .bar { padding: 3px; border-radius: 10px; }
|
|
71
|
+
[data-style="mini"] .bar-skills, [data-style="mini"] .divider, [data-style="mini"] .more-button { display: none; }
|
|
72
|
+
[data-style="mini"] .mini-button { display: inline-flex; }
|
|
67
73
|
|
|
68
74
|
/* ⋯ menu, opened below the more button */
|
|
69
75
|
.menu { display: none; position: absolute; min-width: 190px; padding: 6px; border-radius: 12px; z-index: 1; }
|
|
@@ -101,6 +107,7 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
101
107
|
.panel-body { flex: 0 1 auto; min-height: 0; padding: 0 16px 14px; max-height: 340px; overflow-y: auto; overscroll-behavior: contain; }
|
|
102
108
|
[data-fill] .panel { height: var(--room); }
|
|
103
109
|
[data-fill] .panel-body { flex-grow: 1; max-height: none; }
|
|
110
|
+
/* A styled scrollbar, so Windows draws a thin bar without its classic arrow buttons. */
|
|
104
111
|
.panel-body::-webkit-scrollbar { width: 8px; }
|
|
105
112
|
.panel-body::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 4px; border: 2px solid var(--surface); }
|
|
106
113
|
.source {
|
|
@@ -127,8 +134,11 @@ button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); o
|
|
|
127
134
|
[data-kind="ask"] .composer, [data-composer="shown"] .composer { display: flex; }
|
|
128
135
|
.composer textarea {
|
|
129
136
|
flex: 1; min-height: 22px; max-height: 96px; padding: 4px 0; border: 0; outline: 0; resize: none; background: transparent;
|
|
137
|
+
overflow-y: hidden; scrollbar-width: none;
|
|
130
138
|
line-height: 1.5; user-select: text; -webkit-user-select: text; cursor: text;
|
|
131
139
|
}
|
|
140
|
+
/* Past its tallest size it scrolls with the wheel and the caret, without a bar. */
|
|
141
|
+
.composer textarea::-webkit-scrollbar { display: none; }
|
|
132
142
|
.composer textarea::placeholder { color: var(--muted); }
|
|
133
143
|
.send { width: 28px; height: 28px; border-radius: 50%; background: var(--accent); color: #fff; }
|
|
134
144
|
[data-pointer] .send:hover { background: var(--accent); filter: brightness(1.08); }
|
package/ui/toolbar.html
CHANGED
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
<div class="bar-skills"></div>
|
|
14
14
|
<span class="divider" aria-hidden="true"></span>
|
|
15
15
|
<button type="button" class="icon-button more-button" data-i18n-label="more" aria-haspopup="menu" aria-expanded="false"></button>
|
|
16
|
+
<button type="button" class="icon-button mini-button" data-i18n-label="assistant" aria-haspopup="menu" aria-expanded="false"></button>
|
|
16
17
|
</div>
|
|
17
18
|
|
|
18
19
|
<div class="card menu" role="menu">
|
|
19
20
|
<div class="menu-skills"></div>
|
|
20
21
|
<div class="menu-divider" aria-hidden="true"></div>
|
|
22
|
+
<button type="button" class="menu-item plain" role="menuitem" data-menu-action="toolbar-style"></button>
|
|
21
23
|
<button type="button" class="menu-item plain" role="menuitem" data-menu-action="disable-app" data-i18n="disableApp"></button>
|
|
22
24
|
<button type="button" class="menu-item plain" role="menuitem" data-menu-action="settings" data-i18n="settings"></button>
|
|
23
25
|
</div>
|
package/ui/toolbar.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const STRINGS = {
|
|
4
4
|
zh: {
|
|
5
5
|
more: '更多', disableApp: '在此应用中禁用', settings: '更多设置…', copied: '已复制', stop: '停止',
|
|
6
|
+
assistant: '划词助手', miniStyle: '切换为迷你模式', fullStyle: '切换为常规模式',
|
|
6
7
|
close: '关闭', copyResult: '复制结果', retry: '重新生成', pin: '固定窗口', translateTo: '译为', auto: '自动',
|
|
7
8
|
askPlaceholder: '就这段内容问点什么…', followPlaceholder: '接着问…', send: '发送', waiting: '正在生成', retryShort: '重试', from: '来自 {app}',
|
|
8
9
|
errors: {
|
|
@@ -15,6 +16,7 @@
|
|
|
15
16
|
},
|
|
16
17
|
en: {
|
|
17
18
|
more: 'More', disableApp: 'Disable in this app', settings: 'More settings…', copied: 'Copied', stop: 'Stop',
|
|
19
|
+
assistant: 'Selection assistant', miniStyle: 'Use the mini toolbar', fullStyle: 'Use the full toolbar',
|
|
18
20
|
close: 'Close', copyResult: 'Copy result', retry: 'Regenerate', pin: 'Keep open', translateTo: 'Translate to', auto: 'Auto',
|
|
19
21
|
askPlaceholder: 'Ask something about this text…', followPlaceholder: 'Ask a follow-up…', send: 'Send', waiting: 'Generating', retryShort: 'Retry', from: 'from {app}',
|
|
20
22
|
errors: {
|
|
@@ -38,8 +40,10 @@
|
|
|
38
40
|
const els = {
|
|
39
41
|
barSkills: $('.bar-skills'),
|
|
40
42
|
more: $('.more-button'),
|
|
43
|
+
mini: $('.mini-button'),
|
|
41
44
|
menu: $('.menu'),
|
|
42
45
|
menuSkills: $('.menu-skills'),
|
|
46
|
+
styleItem: $('[data-menu-action="toolbar-style"]'),
|
|
43
47
|
panel: $('.panel'),
|
|
44
48
|
panelIcon: $('.panel-icon'),
|
|
45
49
|
panelTitle: $('.panel-title'),
|
|
@@ -64,6 +68,7 @@
|
|
|
64
68
|
})
|
|
65
69
|
document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t[node.dataset.i18nPlaceholder] })
|
|
66
70
|
els.more.innerHTML = icon('more')
|
|
71
|
+
els.mini.innerHTML = window.DSH_BRAND || icon('sparkles')
|
|
67
72
|
$('[data-menu-action="disable-app"]').insertAdjacentHTML('afterbegin', icon('ban'))
|
|
68
73
|
$('[data-menu-action="settings"]').insertAdjacentHTML('afterbegin', icon('settings'))
|
|
69
74
|
for (const [tool, name] of [['pin', 'pin'], ['copy', 'copy'], ['retry', 'retry'], ['close', 'close']]) {
|
|
@@ -73,6 +78,15 @@
|
|
|
73
78
|
|
|
74
79
|
const emptyState = (requestId = null, pending = false) => ({ requestId, text: '', turn: null, pending, failure: null })
|
|
75
80
|
let skills = { bar: [], more: [] }
|
|
81
|
+
let style = 'full'
|
|
82
|
+
let hoverTimer = null
|
|
83
|
+
|
|
84
|
+
// The menu offers whichever style is not in use.
|
|
85
|
+
function drawStyleItem() {
|
|
86
|
+
els.styleItem.innerHTML = `${icon(style === 'mini' ? 'maximize' : 'minimize')}<span></span>`
|
|
87
|
+
els.styleItem.querySelector('span').textContent = style === 'mini' ? t.fullStyle : t.miniStyle
|
|
88
|
+
}
|
|
89
|
+
drawStyleItem()
|
|
76
90
|
let state = emptyState()
|
|
77
91
|
let renderTimer = null
|
|
78
92
|
// Timers rather than requestAnimationFrame: the view can be occluded or the screen locked, and then
|
|
@@ -92,11 +106,13 @@
|
|
|
92
106
|
function setMenu(open) {
|
|
93
107
|
if (open) {
|
|
94
108
|
const bar = $('.bar')
|
|
95
|
-
|
|
109
|
+
const button = style === 'mini' ? els.mini : els.more
|
|
110
|
+
els.menu.style.left = `${bar.offsetLeft + button.offsetLeft - 6}px`
|
|
96
111
|
els.menu.style.top = `${bar.offsetTop + bar.offsetHeight + 6}px`
|
|
97
112
|
}
|
|
98
113
|
body.dataset.menu = open ? 'open' : 'closed'
|
|
99
|
-
els.more.setAttribute('aria-expanded', String(open))
|
|
114
|
+
els.more.setAttribute('aria-expanded', String(open && style === 'full'))
|
|
115
|
+
els.mini.setAttribute('aria-expanded', String(open && style === 'mini'))
|
|
100
116
|
queueSize()
|
|
101
117
|
}
|
|
102
118
|
|
|
@@ -151,11 +167,22 @@
|
|
|
151
167
|
window.bar = {
|
|
152
168
|
configure(next) {
|
|
153
169
|
skills = next
|
|
154
|
-
|
|
155
|
-
|
|
170
|
+
const nextStyle = next.style === 'mini' ? 'mini' : 'full'
|
|
171
|
+
if (nextStyle !== style) {
|
|
172
|
+
setMenu(false)
|
|
173
|
+
style = nextStyle
|
|
174
|
+
}
|
|
175
|
+
body.dataset.style = style
|
|
176
|
+
// The mini toolbar has no skill buttons of its own: every skill is in its menu.
|
|
177
|
+
const onBar = style === 'mini' ? [] : next.bar
|
|
178
|
+
const inMenu = style === 'mini' ? [...next.bar, ...next.more] : next.more
|
|
179
|
+
els.barSkills.replaceChildren(...onBar.map((skill) => skillButton(skill, 'skill')))
|
|
180
|
+
els.menuSkills.replaceChildren(...inMenu.map((skill) => skillButton(skill, 'menu-item')))
|
|
181
|
+
drawStyleItem()
|
|
156
182
|
queueSize()
|
|
157
183
|
},
|
|
158
184
|
showBar() {
|
|
185
|
+
clearTimeout(hoverTimer)
|
|
159
186
|
delete body.dataset.pointer
|
|
160
187
|
body.dataset.mode = 'bar'
|
|
161
188
|
setMenu(false)
|
|
@@ -250,8 +277,8 @@
|
|
|
250
277
|
queueSize()
|
|
251
278
|
},
|
|
252
279
|
flash(skillId) {
|
|
253
|
-
// A skill run from the
|
|
254
|
-
flashDone(els.barSkills.querySelector(`[data-skill="${skillId}"]`) || els.more, t.copied)
|
|
280
|
+
// A skill run from the menu confirms on the button that opens the menu: the menu has closed by then.
|
|
281
|
+
flashDone(els.barSkills.querySelector(`[data-skill="${skillId}"]`) || (style === 'mini' ? els.mini : els.more), t.copied)
|
|
255
282
|
},
|
|
256
283
|
/**
|
|
257
284
|
* How tall the panel may grow before it scrolls inside, from the worker's placement. With `fill`
|
|
@@ -265,6 +292,7 @@
|
|
|
265
292
|
queueSize()
|
|
266
293
|
},
|
|
267
294
|
reset() {
|
|
295
|
+
clearTimeout(hoverTimer)
|
|
268
296
|
delete body.dataset.pointer
|
|
269
297
|
body.dataset.mode = 'hidden'
|
|
270
298
|
body.dataset.menu = 'closed'
|
|
@@ -339,7 +367,11 @@
|
|
|
339
367
|
|
|
340
368
|
function resizeInput() {
|
|
341
369
|
els.input.style.height = 'auto'
|
|
342
|
-
|
|
370
|
+
const height = els.input.scrollHeight
|
|
371
|
+
els.input.style.height = `${Math.min(height, 96)}px`
|
|
372
|
+
// Scrollable only past its tallest size: at fractional display scaling one line can overflow by a
|
|
373
|
+
// fraction of a pixel, which would otherwise let it scroll.
|
|
374
|
+
els.input.style.overflowY = height > 96 ? 'auto' : 'hidden'
|
|
343
375
|
els.send.disabled = !els.input.value.trim()
|
|
344
376
|
}
|
|
345
377
|
|
|
@@ -358,6 +390,11 @@
|
|
|
358
390
|
post({ type: 'skill', id: skill.dataset.skill })
|
|
359
391
|
return
|
|
360
392
|
}
|
|
393
|
+
if (event.target.closest('.mini-button')) {
|
|
394
|
+
clearTimeout(hoverTimer)
|
|
395
|
+
setMenu(true)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
361
398
|
if (event.target.closest('.more-button')) {
|
|
362
399
|
setMenu(body.dataset.menu !== 'open')
|
|
363
400
|
return
|
|
@@ -478,6 +515,29 @@
|
|
|
478
515
|
if (body.dataset.mode === 'panel') post({ type: 'keyboard', needed: true })
|
|
479
516
|
})
|
|
480
517
|
|
|
518
|
+
// The mini toolbar opens its menu once the pointer rests on the icon, and closes it once the pointer
|
|
519
|
+
// has left the window for a moment. Leaving the icon alone is not enough: a menu that does not fit
|
|
520
|
+
// below makes the worker move the whole window, which can take the icon from under the pointer.
|
|
521
|
+
const HOVER_OPEN_MS = 120
|
|
522
|
+
const HOVER_CLOSE_MS = 300
|
|
523
|
+
els.mini.addEventListener('pointerenter', () => {
|
|
524
|
+
clearTimeout(hoverTimer)
|
|
525
|
+
if (style !== 'mini' || body.dataset.menu === 'open' || press) return
|
|
526
|
+
hoverTimer = setTimeout(() => setMenu(true), HOVER_OPEN_MS)
|
|
527
|
+
})
|
|
528
|
+
els.mini.addEventListener('pointerleave', () => {
|
|
529
|
+
if (body.dataset.menu !== 'open') clearTimeout(hoverTimer)
|
|
530
|
+
})
|
|
531
|
+
els.mini.addEventListener('pointerdown', () => clearTimeout(hoverTimer))
|
|
532
|
+
document.addEventListener('pointerout', (event) => {
|
|
533
|
+
if (event.relatedTarget || style !== 'mini' || body.dataset.menu !== 'open') return
|
|
534
|
+
clearTimeout(hoverTimer)
|
|
535
|
+
hoverTimer = setTimeout(() => setMenu(false), HOVER_CLOSE_MS)
|
|
536
|
+
})
|
|
537
|
+
document.addEventListener('pointerover', () => {
|
|
538
|
+
if (body.dataset.menu === 'open') clearTimeout(hoverTimer)
|
|
539
|
+
})
|
|
540
|
+
|
|
481
541
|
// WebKit keeps :hover on whatever was under the pointer when it last saw one, through hiding and
|
|
482
542
|
// showing the window, and this process is never the active app, so a pointer that does not move
|
|
483
543
|
// over the page is not tracked. Hover styles therefore wait for a real movement after each show.
|