dsh-plugin-look-up 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/dsh-lookup-dict +0 -0
- package/client.js +658 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +18 -0
- package/index.js +267 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/native/dsh-lookup-dict.m +79 -0
- package/package.json +66 -4
- package/scripts/build-helper.sh +28 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 huanglianqi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-plugin-look-up
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
macOS-style **Look Up** inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) chat: select a word in any conversation, click the small **Look Up / 查词** chip, and read the definition in a card anchored to the selection.
|
|
4
|
+
|
|
5
|
+
The definitions come from the **dictionaries you already have in Dictionary.app** — the same local, offline data macOS Look Up reads. No network request, no API key, no accessibility permission.
|
|
6
|
+
|
|
7
|
+
 
|
|
8
|
+
|
|
9
|
+
## What it looks like
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
选中 “serendipity” ──► [ 🔍 查词 ] ──► ┌─────────────────────────────┐
|
|
13
|
+
│ serendipity ✕ │
|
|
14
|
+
│ ˌsɛr(ə)nˈdɪpɪti │
|
|
15
|
+
│ noun formal │
|
|
16
|
+
│ ① uncountable (luck) … │
|
|
17
|
+
│ ② countable … │
|
|
18
|
+
├─────────────────────────────┤
|
|
19
|
+
│ macOS 系统词典 · 本地查询 │
|
|
20
|
+
│ 复制 在词典中打开 │
|
|
21
|
+
└─────────────────────────────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- macOS 11 or newer.
|
|
27
|
+
- DSH Desktop (or the DSH Web GUI served on the same Mac).
|
|
28
|
+
- At least one dictionary installed in **Dictionary.app** (Oxford, 现代汉语词典, 新华字典, 牛津英汉汉英词典 … all work). Check with Dictionary.app → Settings → Dictionaries.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
From the DSH plugin CLI:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dsh plugin --profile <profile> add dsh-plugin-look-up
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or install the bundle manually in DSH: **Settings → Plugins → add** and give the package name `dsh-plugin-look-up`, then enable the **Look Up** row.
|
|
39
|
+
|
|
40
|
+
The package ships a small signed universal helper (`bin/dsh-lookup-dict`, arm64 + x86_64). Nothing is compiled on install.
|
|
41
|
+
|
|
42
|
+
## Use
|
|
43
|
+
|
|
44
|
+
1. Select a word or a short phrase in a chat message.
|
|
45
|
+
2. Click the **查词 / Look Up** chip that appears next to the selection.
|
|
46
|
+
3. Read the definition; **Escape**, an outside click, or scrolling closes the card.
|
|
47
|
+
4. `复制` copies the word; `在词典中打开` opens the full entry in Dictionary.app through the `dict:` URL scheme.
|
|
48
|
+
|
|
49
|
+
The card follows the DSH theme (light/dark, Dream Skin tokens) and the active UI language (中文 / English).
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
| Layer | Responsibility |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `client.js` | Reads the current selection, places the chip/card on `shell.overlay`, renders definitions. |
|
|
56
|
+
| `index.js` | Host routes under `/plugin/look-up/*`: loopback-only, bounded JSON body, in-memory cache. |
|
|
57
|
+
| `bin/dsh-lookup-dict` | Objective-C helper calling `DCSCopyTextDefinition` from DictionaryServices — the same API behind Look Up. |
|
|
58
|
+
|
|
59
|
+
Only the selected word is passed to the helper. The helper reads local dictionary data and prints JSON. There is no telemetry, no network call, and the route rejects non-loopback hosts.
|
|
60
|
+
|
|
61
|
+
### Why not the native Look Up panel?
|
|
62
|
+
|
|
63
|
+
The system definition panel belongs to the Electron shell: DSH runs its Host in an Electron `utilityProcess` without `webContents`, and the renderer preload exposes a fixed action whitelist, so a plugin cannot call `webContents.showDefinitionForSelection()`. A native helper can *display* the panel, but it steals keyboard focus and dismisses itself when the user clicks back into DSH — a poor fit for a chat overlay. This plugin instead reproduces the content and the interaction inside the conversation, using the same dictionaries.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm run build:helper # rebuild + re-sign bin/dsh-lookup-dict
|
|
69
|
+
npm run check # syntax-check the two entry points
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Source of the helper: [`native/dsh-lookup-dict.m`](native/dsh-lookup-dict.m). The ad-hoc signature is applied by the build script; no Developer ID is required for local execution.
|
|
73
|
+
|
|
74
|
+
## 中文说明
|
|
75
|
+
|
|
76
|
+
在 DSH 对话里选中一个单词,点击旁边出现的「查词」小胶囊,就在原位弹出释义卡片。词条来自本机「词典」App 里已安装的词典(牛津、现代汉语词典、新华字典等),与 macOS 的 Look Up 同源,**完全离线**,不联网、不需要 API key、不需要辅助功能权限。
|
|
77
|
+
|
|
78
|
+
- 系统要求:macOS 11+,DSH Desktop 或同一台 Mac 上的 DSH Web GUI。
|
|
79
|
+
- 安装:`dsh plugin --profile <profile> add dsh-plugin-look-up`。
|
|
80
|
+
- 关闭:Esc、点击卡片外、滚动页面。
|
|
81
|
+
- 复制 / 在词典中打开(走 `dict:` URL scheme)。
|
|
82
|
+
|
|
83
|
+
已知边界:Electron 的系统释义面板无法被第三方插件直接调用(Host 在 utilityProcess 里,渲染层 preload 只暴露固定动作白名单),所以这里用同一个系统词典数据在对话内复刻了交互。
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT © 2026 huanglianqi
|
|
Binary file
|
package/client.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-look-up — browser half.
|
|
3
|
+
*
|
|
4
|
+
* A selection pill and a definition card on `shell.overlay`. The card asks the
|
|
5
|
+
* Host for a macOS system-dictionary definition (the same local data Look Up
|
|
6
|
+
* reads); nothing leaves the machine. The plugin is inert outside macOS and
|
|
7
|
+
* says so instead of failing silently.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
id: 'dsh-plugin-look-up',
|
|
12
|
+
factory(require) {
|
|
13
|
+
const React = require('react')
|
|
14
|
+
const h = React.createElement
|
|
15
|
+
const { useCallback, useEffect, useRef, useState } = React
|
|
16
|
+
|
|
17
|
+
const NS = 'look-up'
|
|
18
|
+
const LOOKUP_URL = '/plugin/look-up/lookup'
|
|
19
|
+
const OPEN_URL = '/plugin/look-up/open'
|
|
20
|
+
const MAX_WORD_LENGTH = 64
|
|
21
|
+
const CARD_WIDTH = 380
|
|
22
|
+
|
|
23
|
+
/** Runtime copy; the package locale files carry only the plugin metadata. */
|
|
24
|
+
const STRINGS = {
|
|
25
|
+
en: {
|
|
26
|
+
lookUp: 'Look Up',
|
|
27
|
+
loading: 'Looking up in the system dictionary…',
|
|
28
|
+
noEntry: 'No system-dictionary entry for “{word}”.',
|
|
29
|
+
unsupported: 'System-dictionary lookup is available on macOS only.',
|
|
30
|
+
helperMissing: 'The dictionary helper is missing. Reinstall the plugin.',
|
|
31
|
+
timeout: 'The dictionary took too long. Try again.',
|
|
32
|
+
failed: 'Lookup failed. Try again.',
|
|
33
|
+
copyWord: 'Copy word',
|
|
34
|
+
copied: 'Copied',
|
|
35
|
+
openInDictionary: 'Open in Dictionary',
|
|
36
|
+
source: 'macOS system dictionaries · offline',
|
|
37
|
+
close: 'Close',
|
|
38
|
+
lookUpFor: 'Look up “{word}”'
|
|
39
|
+
},
|
|
40
|
+
zh: {
|
|
41
|
+
lookUp: '查词',
|
|
42
|
+
loading: '正在查询系统词典…',
|
|
43
|
+
noEntry: '系统词典中没有找到“{word}”。',
|
|
44
|
+
unsupported: '系统词典查询仅支持 macOS。',
|
|
45
|
+
helperMissing: '词典组件缺失,请重新安装插件。',
|
|
46
|
+
timeout: '查询超时,请重试。',
|
|
47
|
+
failed: '查询失败,请重试。',
|
|
48
|
+
copyWord: '复制单词',
|
|
49
|
+
copied: '已复制',
|
|
50
|
+
openInDictionary: '在词典中打开',
|
|
51
|
+
source: 'macOS 系统词典 · 本地查询',
|
|
52
|
+
close: '关闭',
|
|
53
|
+
lookUpFor: '查询“{word}”'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CSS = `
|
|
58
|
+
.dlu-root {
|
|
59
|
+
position: fixed;
|
|
60
|
+
inset: 0;
|
|
61
|
+
z-index: 2147483000;
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Segoe UI", sans-serif;
|
|
64
|
+
-webkit-font-smoothing: antialiased;
|
|
65
|
+
}
|
|
66
|
+
.dlu-pill {
|
|
67
|
+
position: fixed;
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 6px;
|
|
71
|
+
height: 30px;
|
|
72
|
+
padding: 0 11px;
|
|
73
|
+
border-radius: 8px;
|
|
74
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, .25));
|
|
75
|
+
background: var(--dsw-alias-bg-overlay, Canvas);
|
|
76
|
+
color: var(--dsw-alias-label-primary, CanvasText);
|
|
77
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, .16), 0 1px 3px rgba(0, 0, 0, .10);
|
|
78
|
+
backdrop-filter: blur(18px) saturate(1.3);
|
|
79
|
+
-webkit-backdrop-filter: blur(18px) saturate(1.3);
|
|
80
|
+
font-size: 12px;
|
|
81
|
+
font-weight: 560;
|
|
82
|
+
line-height: 1;
|
|
83
|
+
letter-spacing: .01em;
|
|
84
|
+
white-space: nowrap;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
pointer-events: auto;
|
|
87
|
+
user-select: none;
|
|
88
|
+
animation: dlu-in 120ms cubic-bezier(.2, .8, .3, 1);
|
|
89
|
+
}
|
|
90
|
+
.dlu-pill:hover {
|
|
91
|
+
background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2, Canvas));
|
|
92
|
+
}
|
|
93
|
+
.dlu-pill:active {
|
|
94
|
+
background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-bg-layer-2, Canvas));
|
|
95
|
+
}
|
|
96
|
+
.dlu-pill:focus-visible {
|
|
97
|
+
outline: 2px solid var(--dsw-alias-brand-primary, Highlight);
|
|
98
|
+
outline-offset: 2px;
|
|
99
|
+
}
|
|
100
|
+
.dlu-pill svg { width: 13px; height: 13px; flex: none; opacity: .82; }
|
|
101
|
+
|
|
102
|
+
.dlu-card {
|
|
103
|
+
position: fixed;
|
|
104
|
+
display: flex;
|
|
105
|
+
flex-direction: column;
|
|
106
|
+
width: ${CARD_WIDTH}px;
|
|
107
|
+
max-width: calc(100vw - 24px);
|
|
108
|
+
overflow: hidden;
|
|
109
|
+
border-radius: 14px;
|
|
110
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, .25));
|
|
111
|
+
background: var(--dsw-alias-bg-overlay, Canvas);
|
|
112
|
+
color: var(--dsw-alias-label-primary, CanvasText);
|
|
113
|
+
box-shadow:
|
|
114
|
+
0 24px 64px rgba(0, 0, 0, .24),
|
|
115
|
+
0 8px 24px rgba(0, 0, 0, .14),
|
|
116
|
+
0 1px 0 rgba(255, 255, 255, .04) inset;
|
|
117
|
+
backdrop-filter: blur(26px) saturate(1.4);
|
|
118
|
+
-webkit-backdrop-filter: blur(26px) saturate(1.4);
|
|
119
|
+
pointer-events: auto;
|
|
120
|
+
animation: dlu-in 140ms cubic-bezier(.2, .8, .3, 1);
|
|
121
|
+
}
|
|
122
|
+
.dlu-head {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: flex-start;
|
|
125
|
+
gap: 10px;
|
|
126
|
+
padding: 13px 12px 9px 16px;
|
|
127
|
+
}
|
|
128
|
+
.dlu-titles { min-width: 0; }
|
|
129
|
+
.dlu-word {
|
|
130
|
+
font-size: 16px;
|
|
131
|
+
font-weight: 680;
|
|
132
|
+
line-height: 1.25;
|
|
133
|
+
letter-spacing: -.012em;
|
|
134
|
+
overflow-wrap: anywhere;
|
|
135
|
+
}
|
|
136
|
+
.dlu-phonetic {
|
|
137
|
+
margin-top: 3px;
|
|
138
|
+
font-size: 11.5px;
|
|
139
|
+
line-height: 1.35;
|
|
140
|
+
color: var(--dsw-alias-label-secondary, GrayText);
|
|
141
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
|
142
|
+
overflow-wrap: anywhere;
|
|
143
|
+
}
|
|
144
|
+
.dlu-close {
|
|
145
|
+
margin-left: auto;
|
|
146
|
+
flex: none;
|
|
147
|
+
display: grid;
|
|
148
|
+
place-items: center;
|
|
149
|
+
width: 26px;
|
|
150
|
+
height: 26px;
|
|
151
|
+
padding: 0;
|
|
152
|
+
border: 0;
|
|
153
|
+
border-radius: 7px;
|
|
154
|
+
background: transparent;
|
|
155
|
+
color: var(--dsw-alias-label-secondary, GrayText);
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
}
|
|
158
|
+
.dlu-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.14)); color: var(--dsw-alias-label-primary, CanvasText); }
|
|
159
|
+
.dlu-close:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, Highlight); outline-offset: 1px; }
|
|
160
|
+
.dlu-close svg { width: 14px; height: 14px; }
|
|
161
|
+
|
|
162
|
+
.dlu-body {
|
|
163
|
+
padding: 1px 16px 14px;
|
|
164
|
+
overflow-y: auto;
|
|
165
|
+
overscroll-behavior: contain;
|
|
166
|
+
font-size: 13.5px;
|
|
167
|
+
line-height: 1.64;
|
|
168
|
+
color: var(--dsw-alias-label-primary, CanvasText);
|
|
169
|
+
scrollbar-width: thin;
|
|
170
|
+
}
|
|
171
|
+
.dlu-body p { margin: 0 0 9px; }
|
|
172
|
+
.dlu-body p:last-child { margin-bottom: 0; }
|
|
173
|
+
.dlu-body b, .dlu-body strong { font-weight: 640; }
|
|
174
|
+
.dlu-empty {
|
|
175
|
+
color: var(--dsw-alias-label-secondary, GrayText);
|
|
176
|
+
font-size: 13px;
|
|
177
|
+
line-height: 1.6;
|
|
178
|
+
}
|
|
179
|
+
.dlu-empty.is-error { color: var(--dsw-alias-state-error-primary, #d9534f); }
|
|
180
|
+
.dlu-loading { display: flex; align-items: center; gap: 8px; color: var(--dsw-alias-label-secondary, GrayText); font-size: 13px; }
|
|
181
|
+
.dlu-dots { display: inline-flex; gap: 3px; }
|
|
182
|
+
.dlu-dot {
|
|
183
|
+
width: 4px;
|
|
184
|
+
height: 4px;
|
|
185
|
+
border-radius: 50%;
|
|
186
|
+
background: var(--dsw-alias-brand-primary, #4c8dff);
|
|
187
|
+
animation: dlu-pulse 900ms ease-in-out infinite;
|
|
188
|
+
}
|
|
189
|
+
.dlu-dot:nth-child(2) { animation-delay: 120ms; }
|
|
190
|
+
.dlu-dot:nth-child(3) { animation-delay: 240ms; }
|
|
191
|
+
|
|
192
|
+
.dlu-foot {
|
|
193
|
+
display: flex;
|
|
194
|
+
align-items: center;
|
|
195
|
+
gap: 6px;
|
|
196
|
+
padding: 9px 10px 9px 14px;
|
|
197
|
+
border-top: 1px solid var(--dsw-alias-border-l1, rgba(127, 127, 127, .2));
|
|
198
|
+
background: color-mix(in srgb, var(--dsw-alias-bg-layer-2, Canvas) 55%, transparent);
|
|
199
|
+
}
|
|
200
|
+
.dlu-source {
|
|
201
|
+
margin-right: auto;
|
|
202
|
+
font-size: 10.5px;
|
|
203
|
+
line-height: 1.3;
|
|
204
|
+
color: var(--dsw-alias-label-tertiary, GrayText);
|
|
205
|
+
white-space: nowrap;
|
|
206
|
+
overflow: hidden;
|
|
207
|
+
text-overflow: ellipsis;
|
|
208
|
+
}
|
|
209
|
+
.dlu-btn {
|
|
210
|
+
display: inline-flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
gap: 4px;
|
|
213
|
+
height: 24px;
|
|
214
|
+
padding: 0 8px;
|
|
215
|
+
border-radius: 6px;
|
|
216
|
+
border: 0;
|
|
217
|
+
background: transparent;
|
|
218
|
+
color: var(--dsw-alias-label-secondary, GrayText);
|
|
219
|
+
font-size: 11px;
|
|
220
|
+
font-weight: 550;
|
|
221
|
+
line-height: 1;
|
|
222
|
+
white-space: nowrap;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
}
|
|
225
|
+
.dlu-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.14)); color: var(--dsw-alias-label-primary, CanvasText); }
|
|
226
|
+
.dlu-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary, Highlight); outline-offset: 1px; }
|
|
227
|
+
.dlu-btn[data-copied="true"] { color: var(--dsw-alias-state-success-primary, #2e9e5b); }
|
|
228
|
+
|
|
229
|
+
@keyframes dlu-in {
|
|
230
|
+
from { opacity: 0; transform: translateY(3px) scale(.985); }
|
|
231
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
232
|
+
}
|
|
233
|
+
@keyframes dlu-pulse {
|
|
234
|
+
0%, 100% { opacity: .25; transform: translateY(0); }
|
|
235
|
+
40% { opacity: 1; transform: translateY(-2px); }
|
|
236
|
+
}
|
|
237
|
+
@media (prefers-reduced-motion: reduce) {
|
|
238
|
+
.dlu-pill, .dlu-card { animation: none; }
|
|
239
|
+
.dlu-dot { animation: none; opacity: .7; }
|
|
240
|
+
}
|
|
241
|
+
`
|
|
242
|
+
|
|
243
|
+
const ICON_LOOKUP = h('svg', {
|
|
244
|
+
viewBox: '0 0 16 16',
|
|
245
|
+
fill: 'none',
|
|
246
|
+
stroke: 'currentColor',
|
|
247
|
+
strokeWidth: 1.6,
|
|
248
|
+
strokeLinecap: 'round',
|
|
249
|
+
'aria-hidden': true
|
|
250
|
+
}, h('circle', { cx: 7, cy: 7, r: 4.2 }), h('path', { d: 'M10.2 10.2 13.5 13.5' }))
|
|
251
|
+
|
|
252
|
+
const ICON_CLOSE = h('svg', {
|
|
253
|
+
viewBox: '0 0 16 16',
|
|
254
|
+
fill: 'none',
|
|
255
|
+
stroke: 'currentColor',
|
|
256
|
+
strokeWidth: 1.7,
|
|
257
|
+
strokeLinecap: 'round',
|
|
258
|
+
'aria-hidden': true
|
|
259
|
+
}, h('path', { d: 'M4 4l8 8M12 4l-8 8' }))
|
|
260
|
+
|
|
261
|
+
function clamp(value, min, max) {
|
|
262
|
+
return Math.min(Math.max(value, min), max)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The element carrying a DOM node, or null. */
|
|
266
|
+
function elementOf(node) {
|
|
267
|
+
if (node === null || node === undefined) return null
|
|
268
|
+
return node.nodeType === 1 ? node : node.parentElement ?? null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Read the current non-collapsed selection when it is worth looking up. */
|
|
272
|
+
function readSelection() {
|
|
273
|
+
const selection = window.getSelection()
|
|
274
|
+
if (selection === null || selection === undefined || selection.isCollapsed || selection.rangeCount === 0) return null
|
|
275
|
+
const word = selection.toString().replace(/\s+/gu, ' ').trim()
|
|
276
|
+
if (word.length === 0 || word.length > MAX_WORD_LENGTH) return null
|
|
277
|
+
if (!/[0-9A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u04FF\u3040-\u30FF\u3400-\u9FFF\uAC00-\uD7AF]/u.test(word)) return null
|
|
278
|
+
|
|
279
|
+
const range = selection.getRangeAt(0)
|
|
280
|
+
const holder = elementOf(range.commonAncestorContainer)
|
|
281
|
+
if (holder === null) return null
|
|
282
|
+
if (holder.closest('.dlu-root, input, textarea, pre, .cm-editor, [contenteditable="true"]') !== null) return null
|
|
283
|
+
|
|
284
|
+
const rect = range.getBoundingClientRect()
|
|
285
|
+
if (rect.width === 0 && rect.height === 0) return null
|
|
286
|
+
return {
|
|
287
|
+
word,
|
|
288
|
+
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Place the small trigger above the selection, or below when there is no room. */
|
|
293
|
+
function pillPosition(rect) {
|
|
294
|
+
const width = 88
|
|
295
|
+
const height = 30
|
|
296
|
+
const left = clamp(rect.right - width / 2, 8, Math.max(8, window.innerWidth - width - 8))
|
|
297
|
+
const above = rect.top - height - 7
|
|
298
|
+
const top = above >= 8 ? above : rect.bottom + 7
|
|
299
|
+
return { left, top }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Place the card next to the selection, preferring the roomier side. */
|
|
303
|
+
function cardPosition(rect) {
|
|
304
|
+
const width = Math.min(CARD_WIDTH, window.innerWidth - 24)
|
|
305
|
+
const left = clamp(rect.left, 12, Math.max(12, window.innerWidth - width - 12))
|
|
306
|
+
const below = window.innerHeight - rect.bottom - 12
|
|
307
|
+
const above = rect.top - 12
|
|
308
|
+
if (below >= 240 || below >= above) {
|
|
309
|
+
return { left, top: rect.bottom + 10, width, maxHeight: Math.max(120, below - 104) }
|
|
310
|
+
}
|
|
311
|
+
return { left, bottom: window.innerHeight - rect.top + 10, width, maxHeight: Math.max(120, above - 104) }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Split a raw DictionaryServices entry into a phonetic line and readable
|
|
316
|
+
* paragraphs. The raw form is `headword | pronunciation | sense ...` for
|
|
317
|
+
* English, and `headword pinyin ①sense ...` for Chinese.
|
|
318
|
+
*/
|
|
319
|
+
function parseDefinition(text, word) {
|
|
320
|
+
const source = String(text ?? '').trim()
|
|
321
|
+
if (source.length === 0) return { phonetic: '', paragraphs: [] }
|
|
322
|
+
|
|
323
|
+
let segments = source.split(/\s*\|\s*/u).map((part) => part.trim()).filter(Boolean)
|
|
324
|
+
let phonetic = ''
|
|
325
|
+
let bodySegments = [source]
|
|
326
|
+
if (segments.length > 1 && (/[ˈˌəʃʒθðŋæɑɔɪʊɛɜːɒʌ]/u.test(segments[1]) || /^(?:BrE|AmE|US|UK)\b/u.test(segments[1]))) {
|
|
327
|
+
phonetic = segments[1]
|
|
328
|
+
bodySegments = segments.slice(2)
|
|
329
|
+
} else if (segments.length > 1 && /^(?:noun|verb|adjective|adverb|pronoun|preposition|conjunction|determiner|exclamation|interjection|numeral|auxiliary|phrasal verb)\b/iu.test(segments[1])) {
|
|
330
|
+
bodySegments = segments.slice(1)
|
|
331
|
+
}
|
|
332
|
+
if (bodySegments.length === 0) bodySegments = [source]
|
|
333
|
+
|
|
334
|
+
// Chinese entries use "|" between example phrases, not between the
|
|
335
|
+
// headword and the senses, so only the phonetic/POS split above applies.
|
|
336
|
+
let body = bodySegments.join('\n\n').trim()
|
|
337
|
+
if (body.toLowerCase().startsWith(word.toLowerCase())) body = body.slice(word.length).trim()
|
|
338
|
+
if (body.length === 0) body = source
|
|
339
|
+
|
|
340
|
+
body = body
|
|
341
|
+
.replace(/\s*([①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳])/gu, '\n$1')
|
|
342
|
+
.replace(/\n{3,}/gu, '\n\n')
|
|
343
|
+
.trim()
|
|
344
|
+
|
|
345
|
+
const paragraphs = body.split(/\n+/u).map((part) => part.trim()).filter(Boolean)
|
|
346
|
+
return { phonetic, paragraphs: paragraphs.length > 0 ? paragraphs : [body] }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Re-render whenever the locale dictionary or active locale changes. */
|
|
350
|
+
function useLocaleVersion(locale) {
|
|
351
|
+
const [, setVersion] = useState(0)
|
|
352
|
+
useEffect(() => locale.subscribe(() => setVersion((value) => value + 1)), [locale])
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function LookUpLayer({ locale }) {
|
|
356
|
+
useLocaleVersion(locale)
|
|
357
|
+
const t = locale.bind(NS)
|
|
358
|
+
|
|
359
|
+
const [pill, setPill] = useState(null)
|
|
360
|
+
const [card, setCard] = useState(null)
|
|
361
|
+
const [copied, setCopied] = useState(false)
|
|
362
|
+
const [opening, setOpening] = useState(false)
|
|
363
|
+
const cardRef = useRef(null)
|
|
364
|
+
const requestRef = useRef(0)
|
|
365
|
+
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
cardRef.current = card
|
|
368
|
+
}, [card])
|
|
369
|
+
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
setCopied(false)
|
|
372
|
+
setOpening(false)
|
|
373
|
+
}, [card ? card.requestId : 0])
|
|
374
|
+
|
|
375
|
+
const closeAll = useCallback(() => {
|
|
376
|
+
setPill(null)
|
|
377
|
+
setCard(null)
|
|
378
|
+
}, [])
|
|
379
|
+
|
|
380
|
+
const openCard = useCallback((selection) => {
|
|
381
|
+
setPill(null)
|
|
382
|
+
requestRef.current += 1
|
|
383
|
+
const requestId = requestRef.current
|
|
384
|
+
setCard({
|
|
385
|
+
word: selection.word,
|
|
386
|
+
requestId,
|
|
387
|
+
status: 'loading',
|
|
388
|
+
result: null,
|
|
389
|
+
code: null,
|
|
390
|
+
position: cardPosition(selection.rect)
|
|
391
|
+
})
|
|
392
|
+
}, [])
|
|
393
|
+
|
|
394
|
+
// Ask the Host once per opened card.
|
|
395
|
+
useEffect(() => {
|
|
396
|
+
if (card === null || card.status !== 'loading') return undefined
|
|
397
|
+
const requestId = card.requestId
|
|
398
|
+
const controller = new AbortController()
|
|
399
|
+
const timer = window.setTimeout(() => controller.abort(), 8000)
|
|
400
|
+
let live = true
|
|
401
|
+
|
|
402
|
+
const settle = (patch) => {
|
|
403
|
+
if (!live) return
|
|
404
|
+
setCard((current) => current !== null && current.requestId === requestId ? { ...current, ...patch } : current)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
fetch(LOOKUP_URL, {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
credentials: 'same-origin',
|
|
410
|
+
headers: { 'content-type': 'application/json' },
|
|
411
|
+
body: JSON.stringify({ word: card.word }),
|
|
412
|
+
signal: controller.signal
|
|
413
|
+
})
|
|
414
|
+
.then((response) => response.json())
|
|
415
|
+
.then((data) => {
|
|
416
|
+
if (data !== null && typeof data === 'object' && data.ok === true) {
|
|
417
|
+
settle({ status: 'ready', result: data })
|
|
418
|
+
} else {
|
|
419
|
+
settle({ status: 'error', code: data !== null && typeof data === 'object' ? data.code ?? 'failed' : 'failed' })
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
.catch((error) => {
|
|
423
|
+
settle({ status: 'error', code: error !== null && error !== undefined && error.name === 'AbortError' ? 'timeout' : 'failed' })
|
|
424
|
+
})
|
|
425
|
+
.finally(() => window.clearTimeout(timer))
|
|
426
|
+
|
|
427
|
+
return () => {
|
|
428
|
+
live = false
|
|
429
|
+
window.clearTimeout(timer)
|
|
430
|
+
controller.abort()
|
|
431
|
+
}
|
|
432
|
+
}, [card === null ? null : card.requestId])
|
|
433
|
+
|
|
434
|
+
// Selection trigger.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
let timer = 0
|
|
437
|
+
|
|
438
|
+
const inspect = () => {
|
|
439
|
+
if (cardRef.current !== null) return
|
|
440
|
+
const selection = readSelection()
|
|
441
|
+
if (selection === null) {
|
|
442
|
+
setPill(null)
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
setPill({ ...selection, position: pillPosition(selection.rect) })
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const onMouseUp = (event) => {
|
|
449
|
+
const target = elementOf(event.target)
|
|
450
|
+
if (target !== null && target.closest('.dlu-root') !== null) return
|
|
451
|
+
window.clearTimeout(timer)
|
|
452
|
+
timer = window.setTimeout(inspect, 0)
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const onKeyUp = (event) => {
|
|
456
|
+
if (event.key !== 'Shift' && event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
|
|
457
|
+
window.clearTimeout(timer)
|
|
458
|
+
timer = window.setTimeout(inspect, 0)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
window.addEventListener('mouseup', onMouseUp)
|
|
462
|
+
window.addEventListener('keyup', onKeyUp)
|
|
463
|
+
return () => {
|
|
464
|
+
window.clearTimeout(timer)
|
|
465
|
+
window.removeEventListener('mouseup', onMouseUp)
|
|
466
|
+
window.removeEventListener('keyup', onKeyUp)
|
|
467
|
+
}
|
|
468
|
+
}, [])
|
|
469
|
+
|
|
470
|
+
// Dismissal: outside click, Escape, page scroll, viewport resize.
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
const onMouseDown = (event) => {
|
|
473
|
+
const target = elementOf(event.target)
|
|
474
|
+
if (target !== null && target.closest('.dlu-root') !== null) return
|
|
475
|
+
if (cardRef.current !== null) setCard(null)
|
|
476
|
+
setPill(null)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const onKeyDown = (event) => {
|
|
480
|
+
if (event.key === 'Escape') {
|
|
481
|
+
setPill(null)
|
|
482
|
+
setCard(null)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const onScroll = (event) => {
|
|
487
|
+
const target = elementOf(event.target)
|
|
488
|
+
if (target !== null && target.closest('.dlu-card') !== null) return
|
|
489
|
+
setPill(null)
|
|
490
|
+
if (cardRef.current !== null) setCard(null)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const onResize = () => {
|
|
494
|
+
setPill(null)
|
|
495
|
+
if (cardRef.current !== null) setCard(null)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const onSelectionChange = () => {
|
|
499
|
+
if (cardRef.current !== null) return
|
|
500
|
+
const selection = window.getSelection()
|
|
501
|
+
if (selection === null || selection === undefined || selection.isCollapsed) setPill(null)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
window.addEventListener('mousedown', onMouseDown)
|
|
505
|
+
window.addEventListener('keydown', onKeyDown)
|
|
506
|
+
window.addEventListener('resize', onResize)
|
|
507
|
+
document.addEventListener('scroll', onScroll, true)
|
|
508
|
+
document.addEventListener('selectionchange', onSelectionChange)
|
|
509
|
+
return () => {
|
|
510
|
+
window.removeEventListener('mousedown', onMouseDown)
|
|
511
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
512
|
+
window.removeEventListener('resize', onResize)
|
|
513
|
+
document.removeEventListener('scroll', onScroll, true)
|
|
514
|
+
document.removeEventListener('selectionchange', onSelectionChange)
|
|
515
|
+
}
|
|
516
|
+
}, [])
|
|
517
|
+
|
|
518
|
+
const copyWord = useCallback((word) => {
|
|
519
|
+
const finish = () => {
|
|
520
|
+
setCopied(true)
|
|
521
|
+
window.setTimeout(() => setCopied(false), 1400)
|
|
522
|
+
}
|
|
523
|
+
if (navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === 'function') {
|
|
524
|
+
navigator.clipboard.writeText(word).then(finish, finish)
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
finish()
|
|
528
|
+
}, [])
|
|
529
|
+
|
|
530
|
+
const openInDictionary = useCallback((word) => {
|
|
531
|
+
setOpening(true)
|
|
532
|
+
fetch(OPEN_URL, {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
credentials: 'same-origin',
|
|
535
|
+
headers: { 'content-type': 'application/json' },
|
|
536
|
+
body: JSON.stringify({ word })
|
|
537
|
+
})
|
|
538
|
+
.catch(() => undefined)
|
|
539
|
+
.finally(() => setOpening(false))
|
|
540
|
+
}, [])
|
|
541
|
+
|
|
542
|
+
const result = card !== null && card.status === 'ready' ? card.result : null
|
|
543
|
+
const parsed = result !== null ? parseDefinition(result.definition, result.word ?? card.word) : null
|
|
544
|
+
|
|
545
|
+
const errorText = (() => {
|
|
546
|
+
if (card === null || card.status !== 'error') return ''
|
|
547
|
+
if (card.code === 'unsupported') return t('unsupported')
|
|
548
|
+
if (card.code === 'helper-missing') return t('helperMissing')
|
|
549
|
+
if (card.code === 'timeout') return t('timeout')
|
|
550
|
+
if (card.code === 'no-entry') return t('noEntry', { word: card.word })
|
|
551
|
+
return t('failed')
|
|
552
|
+
})()
|
|
553
|
+
|
|
554
|
+
return h('div', { className: 'dlu-root', style: { pointerEvents: 'none' } },
|
|
555
|
+
h('style', null, CSS),
|
|
556
|
+
|
|
557
|
+
pill !== null
|
|
558
|
+
? h('button', {
|
|
559
|
+
type: 'button',
|
|
560
|
+
className: 'dlu-pill',
|
|
561
|
+
style: { left: `${pill.position.left}px`, top: `${pill.position.top}px` },
|
|
562
|
+
title: t('lookUpFor', { word: pill.word }),
|
|
563
|
+
'aria-label': t('lookUpFor', { word: pill.word }),
|
|
564
|
+
onMouseDown: (event) => {
|
|
565
|
+
event.preventDefault()
|
|
566
|
+
event.stopPropagation()
|
|
567
|
+
},
|
|
568
|
+
onClick: (event) => {
|
|
569
|
+
event.preventDefault()
|
|
570
|
+
event.stopPropagation()
|
|
571
|
+
openCard(pill)
|
|
572
|
+
}
|
|
573
|
+
}, ICON_LOOKUP, h('span', null, t('lookUp')))
|
|
574
|
+
: null,
|
|
575
|
+
|
|
576
|
+
card !== null
|
|
577
|
+
? h('div', {
|
|
578
|
+
className: 'dlu-card',
|
|
579
|
+
role: 'dialog',
|
|
580
|
+
'aria-label': card.word,
|
|
581
|
+
style: {
|
|
582
|
+
left: `${card.position.left}px`,
|
|
583
|
+
width: `${card.position.width}px`,
|
|
584
|
+
...(card.position.top !== undefined ? { top: `${card.position.top}px` } : {}),
|
|
585
|
+
...(card.position.bottom !== undefined ? { bottom: `${card.position.bottom}px` } : {})
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
h('div', { className: 'dlu-head' },
|
|
589
|
+
h('div', { className: 'dlu-titles' },
|
|
590
|
+
h('div', { className: 'dlu-word' }, result !== null ? result.word ?? card.word : card.word),
|
|
591
|
+
parsed !== null && parsed.phonetic !== '' ? h('div', { className: 'dlu-phonetic' }, parsed.phonetic) : null
|
|
592
|
+
),
|
|
593
|
+
h('button', {
|
|
594
|
+
type: 'button',
|
|
595
|
+
className: 'dlu-close',
|
|
596
|
+
'aria-label': t('close'),
|
|
597
|
+
title: t('close'),
|
|
598
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
599
|
+
onClick: () => setCard(null)
|
|
600
|
+
}, ICON_CLOSE)
|
|
601
|
+
),
|
|
602
|
+
h('div', { className: 'dlu-body', style: { maxHeight: `${card.position.maxHeight}px` } },
|
|
603
|
+
card.status === 'loading'
|
|
604
|
+
? h('div', { className: 'dlu-loading' },
|
|
605
|
+
h('span', null, t('loading')),
|
|
606
|
+
h('span', { className: 'dlu-dots' },
|
|
607
|
+
h('span', { className: 'dlu-dot' }),
|
|
608
|
+
h('span', { className: 'dlu-dot' }),
|
|
609
|
+
h('span', { className: 'dlu-dot' })
|
|
610
|
+
)
|
|
611
|
+
)
|
|
612
|
+
: null,
|
|
613
|
+
card.status === 'error'
|
|
614
|
+
? h('div', { className: 'dlu-empty is-error' }, errorText)
|
|
615
|
+
: null,
|
|
616
|
+
card.status === 'ready' && parsed !== null
|
|
617
|
+
? parsed.paragraphs.map((paragraph, index) => h('p', { key: index }, paragraph))
|
|
618
|
+
: null
|
|
619
|
+
),
|
|
620
|
+
h('div', { className: 'dlu-foot' },
|
|
621
|
+
h('span', { className: 'dlu-source' }, t('source')),
|
|
622
|
+
card.status === 'ready'
|
|
623
|
+
? h('button', {
|
|
624
|
+
type: 'button',
|
|
625
|
+
className: 'dlu-btn',
|
|
626
|
+
'data-copied': copied ? 'true' : undefined,
|
|
627
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
628
|
+
onClick: () => copyWord(result.word ?? card.word)
|
|
629
|
+
}, copied ? t('copied') : t('copyWord'))
|
|
630
|
+
: null,
|
|
631
|
+
card.status === 'ready' && navigator.platform !== undefined && /Mac/u.test(navigator.platform)
|
|
632
|
+
? h('button', {
|
|
633
|
+
type: 'button',
|
|
634
|
+
className: 'dlu-btn',
|
|
635
|
+
disabled: opening,
|
|
636
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
637
|
+
onClick: () => openInDictionary(result.word ?? card.word)
|
|
638
|
+
}, t('openInDictionary'))
|
|
639
|
+
: null
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
: null
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
inject: ['slots', 'locale'],
|
|
648
|
+
apply(ctx) {
|
|
649
|
+
ctx.effect(() => ctx.locale.register(NS, STRINGS), 'look-up: copy')
|
|
650
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
651
|
+
name: 'shell.overlay',
|
|
652
|
+
id: 'look-up',
|
|
653
|
+
order: 55
|
|
654
|
+
}, () => h(LookUpLayer, { locale: ctx.locale })))
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Look Up">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="dlu-bg" x1="8" y1="4" x2="58" y2="62" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop stop-color="#4f8df7"/>
|
|
5
|
+
<stop offset="1" stop-color="#1e3a8a"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="dlu-page" x1="20" y1="14" x2="44" y2="50" gradientUnits="userSpaceOnUse">
|
|
8
|
+
<stop stop-color="#ffffff" stop-opacity=".98"/>
|
|
9
|
+
<stop offset="1" stop-color="#dbeafe" stop-opacity=".9"/>
|
|
10
|
+
</linearGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#dlu-bg)"/>
|
|
13
|
+
<rect x="2" y="2" width="60" height="60" rx="15" fill="none" stroke="#ffffff" stroke-opacity=".18"/>
|
|
14
|
+
<path d="M20 15h16.5a7 7 0 0 1 7 7v20.5a7 7 0 0 1-7 7H20z" fill="url(#dlu-page)"/>
|
|
15
|
+
<path d="M26 23h11M26 29h11M26 35h7" stroke="#1e3a8a" stroke-opacity=".42" stroke-width="2.6" stroke-linecap="round"/>
|
|
16
|
+
<circle cx="34.5" cy="37.5" r="9.2" fill="#1e3a8a" fill-opacity=".22" stroke="#ffffff" stroke-width="3.6"/>
|
|
17
|
+
<path d="M41.2 44.2 49.6 52.6" stroke="#ffffff" stroke-width="4.4" stroke-linecap="round"/>
|
|
18
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-look-up — Host half.
|
|
3
|
+
*
|
|
4
|
+
* The browser half owns the interaction; this half owns the only privileged
|
|
5
|
+
* step: asking macOS for a definition. `bin/dsh-lookup-dict` is a small signed
|
|
6
|
+
* universal helper that calls DictionaryServices' `DCSCopyTextDefinition`, the
|
|
7
|
+
* same local dictionary data macOS Look Up reads. The Host never needs network
|
|
8
|
+
* access, accessibility permission, or an Apple Events grant.
|
|
9
|
+
*
|
|
10
|
+
* Routes (loopback only, same-origin JSON):
|
|
11
|
+
* GET /plugin/look-up/health → mount, platform and helper status
|
|
12
|
+
* POST /plugin/look-up/lookup {word} → { ok, word, definition } | { ok:false, code }
|
|
13
|
+
* POST /plugin/look-up/open {word} → open the word in Dictionary.app
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFile } from 'node:child_process'
|
|
17
|
+
import { accessSync, constants, readFileSync } from 'node:fs'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { promisify } from 'node:util'
|
|
20
|
+
|
|
21
|
+
/** Cordis plugin name. */
|
|
22
|
+
export const name = 'look-up'
|
|
23
|
+
|
|
24
|
+
/** Required Host service: the route registry. */
|
|
25
|
+
export const inject = ['webServer']
|
|
26
|
+
|
|
27
|
+
/** Package name; also the Client bundle row id. */
|
|
28
|
+
export const PLUGIN_ID = 'dsh-plugin-look-up'
|
|
29
|
+
|
|
30
|
+
/** Route prefix owned by this plugin. */
|
|
31
|
+
export const ROUTE_PREFIX = '/plugin/look-up'
|
|
32
|
+
|
|
33
|
+
const execFileAsync = promisify(execFile)
|
|
34
|
+
const MANIFEST = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
|
|
35
|
+
const HELPER_PATH = fileURLToPath(new URL('./bin/dsh-lookup-dict', import.meta.url))
|
|
36
|
+
const MAX_WORD_LENGTH = 64
|
|
37
|
+
const MAX_BODY_BYTES = 8 * 1024
|
|
38
|
+
const MAX_CACHE_ENTRIES = 256
|
|
39
|
+
const HELPER_TIMEOUT_MS = 5000
|
|
40
|
+
|
|
41
|
+
/** In-memory definition cache; definitions are immutable for a given word. */
|
|
42
|
+
const cache = new Map()
|
|
43
|
+
|
|
44
|
+
/** Whether the packaged helper exists and is executable. */
|
|
45
|
+
function helperStatus() {
|
|
46
|
+
if (process.platform !== 'darwin') return 'unsupported'
|
|
47
|
+
try {
|
|
48
|
+
accessSync(HELPER_PATH, constants.X_OK)
|
|
49
|
+
return 'ok'
|
|
50
|
+
} catch {
|
|
51
|
+
return 'missing'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether the request arrived on a loopback Host.
|
|
57
|
+
* @param req - the incoming request.
|
|
58
|
+
* @returns true for 127.0.0.1, localhost, or [::1].
|
|
59
|
+
*/
|
|
60
|
+
export function loopbackHost(req) {
|
|
61
|
+
const host = req.headers.host
|
|
62
|
+
if (typeof host !== 'string' || host === '') return false
|
|
63
|
+
if (host.startsWith('[')) return host.startsWith('[::1]')
|
|
64
|
+
const name = host.split(':')[0]
|
|
65
|
+
return name === '127.0.0.1' || name === 'localhost'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize one user-supplied word.
|
|
70
|
+
* @param input - untrusted JSON value.
|
|
71
|
+
* @returns the normalized word, or null when it is not usable.
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeWord(input) {
|
|
74
|
+
if (typeof input !== 'string') return null
|
|
75
|
+
const word = input.replace(/\s+/gu, ' ').trim()
|
|
76
|
+
if (word.length === 0 || word.length > MAX_WORD_LENGTH) return null
|
|
77
|
+
// Control characters are never part of a dictionary headword and would only
|
|
78
|
+
// make it into process arguments or the Dictionary URL.
|
|
79
|
+
if (/[\u0000-\u001f\u007f]/u.test(word)) return null
|
|
80
|
+
return word
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Write one JSON response. */
|
|
84
|
+
function sendJson(res, status, payload) {
|
|
85
|
+
const body = Buffer.from(`${JSON.stringify(payload)}\n`)
|
|
86
|
+
res.writeHead(status, {
|
|
87
|
+
'content-type': 'application/json; charset=utf-8',
|
|
88
|
+
'content-length': String(body.byteLength),
|
|
89
|
+
'cache-control': 'no-store',
|
|
90
|
+
'x-content-type-options': 'nosniff'
|
|
91
|
+
})
|
|
92
|
+
res.end(body)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Read a bounded JSON request body.
|
|
97
|
+
* @param req - the incoming request.
|
|
98
|
+
* @returns the parsed JSON object (an empty object for an empty body).
|
|
99
|
+
*/
|
|
100
|
+
async function readJson(req) {
|
|
101
|
+
const chunks = []
|
|
102
|
+
let size = 0
|
|
103
|
+
for await (const chunk of req) {
|
|
104
|
+
size += chunk.length
|
|
105
|
+
if (size > MAX_BODY_BYTES) throw new Error('body-too-large')
|
|
106
|
+
chunks.push(chunk)
|
|
107
|
+
}
|
|
108
|
+
if (size === 0) return {}
|
|
109
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
110
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('body-invalid')
|
|
111
|
+
return parsed
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Store one result in the definition cache, evicting the oldest entry. */
|
|
115
|
+
function remember(word, value) {
|
|
116
|
+
cache.set(word, value)
|
|
117
|
+
if (cache.size > MAX_CACHE_ENTRIES) {
|
|
118
|
+
const oldest = cache.keys().next().value
|
|
119
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Ask the macOS helper for one definition.
|
|
125
|
+
* @param word - a normalized word.
|
|
126
|
+
* @returns a client-facing result object.
|
|
127
|
+
*/
|
|
128
|
+
async function lookupWord(word) {
|
|
129
|
+
const cached = cache.get(word)
|
|
130
|
+
if (cached !== undefined) return cached
|
|
131
|
+
|
|
132
|
+
if (process.platform !== 'darwin') return { ok: false, code: 'unsupported', word }
|
|
133
|
+
if (helperStatus() !== 'ok') return { ok: false, code: 'helper-missing', word }
|
|
134
|
+
|
|
135
|
+
let stdout
|
|
136
|
+
try {
|
|
137
|
+
const result = await execFileAsync(HELPER_PATH, [word], {
|
|
138
|
+
timeout: HELPER_TIMEOUT_MS,
|
|
139
|
+
maxBuffer: 1024 * 1024,
|
|
140
|
+
encoding: 'utf8',
|
|
141
|
+
windowsHide: true
|
|
142
|
+
})
|
|
143
|
+
stdout = result.stdout
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error?.code === 'ETIMEDOUT' || error?.killed === true) return { ok: false, code: 'timeout', word }
|
|
146
|
+
// The helper reports "no entry" as JSON with exit code 0, so a thrown
|
|
147
|
+
// error here means the process itself failed. Some spawn errors still
|
|
148
|
+
// carry usable stdout, but we do not trust partial output.
|
|
149
|
+
return { ok: false, code: 'helper-failed', word }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let parsed
|
|
153
|
+
try {
|
|
154
|
+
parsed = JSON.parse(stdout.trim())
|
|
155
|
+
} catch {
|
|
156
|
+
return { ok: false, code: 'helper-failed', word }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = parsed.ok === true
|
|
160
|
+
? { ok: true, word, definition: String(parsed.definition ?? ''), source: 'DictionaryServices' }
|
|
161
|
+
: { ok: false, code: typeof parsed.error === 'string' ? parsed.error : 'no-entry', word }
|
|
162
|
+
if (result.ok) remember(word, result)
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Open one word in Dictionary.app through its registered `dict:` URL scheme.
|
|
168
|
+
* @param word - a normalized word.
|
|
169
|
+
* @returns a client-facing result object.
|
|
170
|
+
*/
|
|
171
|
+
async function openInDictionary(word) {
|
|
172
|
+
if (process.platform !== 'darwin') return { ok: false, code: 'unsupported' }
|
|
173
|
+
try {
|
|
174
|
+
await execFileAsync('/usr/bin/open', [`dict://${encodeURIComponent(word)}`], {
|
|
175
|
+
timeout: HELPER_TIMEOUT_MS,
|
|
176
|
+
encoding: 'utf8'
|
|
177
|
+
})
|
|
178
|
+
return { ok: true }
|
|
179
|
+
} catch {
|
|
180
|
+
return { ok: false, code: 'open-failed' }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Dispatch one request under the plugin's route prefix.
|
|
186
|
+
* @param ctx - the Host plugin context.
|
|
187
|
+
* @param req - the incoming request.
|
|
188
|
+
* @param res - the response to write.
|
|
189
|
+
*/
|
|
190
|
+
async function serve(ctx, req, res) {
|
|
191
|
+
if (!loopbackHost(req)) {
|
|
192
|
+
sendJson(res, 403, { ok: false, code: 'loopback-only' })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname
|
|
197
|
+
|
|
198
|
+
if (pathname === `${ROUTE_PREFIX}/health`) {
|
|
199
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
200
|
+
sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
const clientModules = typeof ctx.get === 'function' ? ctx.get('clientModules') : undefined
|
|
204
|
+
const clientBundle = clientModules !== undefined && typeof clientModules.clientPath === 'function'
|
|
205
|
+
? clientModules.clientPath(PLUGIN_ID)
|
|
206
|
+
: undefined
|
|
207
|
+
sendJson(res, 200, {
|
|
208
|
+
ok: true,
|
|
209
|
+
plugin: PLUGIN_ID,
|
|
210
|
+
version: MANIFEST.version,
|
|
211
|
+
platform: process.platform,
|
|
212
|
+
helper: helperStatus(),
|
|
213
|
+
cacheEntries: cache.size,
|
|
214
|
+
clientRegistered: clientBundle !== undefined,
|
|
215
|
+
clientBundle: clientBundle ?? null
|
|
216
|
+
})
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (pathname !== `${ROUTE_PREFIX}/lookup` && pathname !== `${ROUTE_PREFIX}/open`) {
|
|
221
|
+
sendJson(res, 404, { ok: false, code: 'not-found' })
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (req.method !== 'POST') {
|
|
225
|
+
sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let body
|
|
230
|
+
try {
|
|
231
|
+
body = await readJson(req)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
sendJson(res, error instanceof Error && error.message === 'body-too-large' ? 413 : 400, {
|
|
234
|
+
ok: false,
|
|
235
|
+
code: error instanceof Error ? error.message : 'body-invalid'
|
|
236
|
+
})
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const word = normalizeWord(body.word)
|
|
241
|
+
if (word === null) {
|
|
242
|
+
sendJson(res, 400, { ok: false, code: 'invalid-word' })
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const result = pathname === `${ROUTE_PREFIX}/lookup`
|
|
247
|
+
? await lookupWord(word)
|
|
248
|
+
: await openInDictionary(word)
|
|
249
|
+
sendJson(res, 200, result)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Register the plugin's routes for the lifetime of its Loader row.
|
|
254
|
+
* @param ctx - the Host plugin context carrying `webServer`.
|
|
255
|
+
*/
|
|
256
|
+
export function apply(ctx) {
|
|
257
|
+
ctx.effect(
|
|
258
|
+
() => ctx.webServer.register({
|
|
259
|
+
kind: 'prefix',
|
|
260
|
+
path: ROUTE_PREFIX,
|
|
261
|
+
handler: (req, res) => {
|
|
262
|
+
void serve(ctx, req, res)
|
|
263
|
+
}
|
|
264
|
+
}),
|
|
265
|
+
'look-up: routes'
|
|
266
|
+
)
|
|
267
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-lookup-dict — tiny macOS system-dictionary bridge for dsh-plugin-look-up.
|
|
3
|
+
*
|
|
4
|
+
* Reads the definition of one word from the dictionaries the user configured in
|
|
5
|
+
* Dictionary.app by calling DictionaryServices' public `DCSCopyTextDefinition`.
|
|
6
|
+
* That is the same local, offline data source macOS Look Up uses; no network,
|
|
7
|
+
* no accessibility permission, no Apple Events.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* dsh-lookup-dict <word> -> one JSON object on stdout
|
|
11
|
+
* dsh-lookup-dict --version-> "1"
|
|
12
|
+
*
|
|
13
|
+
* Exit codes: 0 when a JSON object was written (including `ok:false`),
|
|
14
|
+
* 2 on usage errors. Output is always valid JSON otherwise.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
#import <Foundation/Foundation.h>
|
|
18
|
+
#import <CoreServices/CoreServices.h>
|
|
19
|
+
|
|
20
|
+
static void write_json(NSDictionary *payload) {
|
|
21
|
+
NSError *error = nil;
|
|
22
|
+
NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
|
|
23
|
+
if (data == nil) {
|
|
24
|
+
fputs("{\"ok\":false,\"error\":\"encode\"}\n", stdout);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
fwrite(data.bytes, 1, data.length, stdout);
|
|
28
|
+
fputc('\n', stdout);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
int main(int argc, const char *argv[]) {
|
|
32
|
+
@autoreleasepool {
|
|
33
|
+
if (argc > 1 && strcmp(argv[1], "--version") == 0) {
|
|
34
|
+
fputs("1\n", stdout);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
if (argc < 2) {
|
|
38
|
+
write_json(@{@"ok": @NO, @"error": @"usage"});
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
NSString *raw = [[NSString alloc] initWithUTF8String:argv[1]];
|
|
43
|
+
if (raw == nil) {
|
|
44
|
+
write_json(@{@"ok": @NO, @"error": @"encoding"});
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
NSString *word = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
48
|
+
if (word.length == 0) {
|
|
49
|
+
write_json(@{@"ok": @NO, @"error": @"empty"});
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
if (word.length > 128) {
|
|
53
|
+
write_json(@{@"ok": @NO, @"error": @"too-long"});
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
CFRange range = CFRangeMake(0, (CFIndex)word.length);
|
|
58
|
+
CFStringRef copied = DCSCopyTextDefinition(NULL, (__bridge CFStringRef)word, range);
|
|
59
|
+
if (copied == NULL) {
|
|
60
|
+
write_json(@{@"ok": @NO, @"error": @"no-entry", @"word": word});
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
NSString *definition = (__bridge_transfer NSString *)copied;
|
|
65
|
+
NSString *trimmed = [definition stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
|
66
|
+
if (trimmed.length == 0) {
|
|
67
|
+
write_json(@{@"ok": @NO, @"error": @"no-entry", @"word": word});
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
write_json(@{
|
|
72
|
+
@"ok": @YES,
|
|
73
|
+
@"word": word,
|
|
74
|
+
@"definition": trimmed,
|
|
75
|
+
@"source": @"DictionaryServices"
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-look-up",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "macOS Look Up inside DSH chat: select a word and read system-dictionary definitions in place, offline.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "huanglianqi",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/huanglianqi/dsh-plugin-look-up.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/huanglianqi/dsh-plugin-look-up#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/huanglianqi/dsh-plugin-look-up/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dsh",
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"deepseek-harness-plugin",
|
|
21
|
+
"dictionary",
|
|
22
|
+
"look-up",
|
|
23
|
+
"macos",
|
|
24
|
+
"define"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./index.js",
|
|
31
|
+
"./client": "./client.js",
|
|
32
|
+
"./locale/*.json": "./locale/*.json",
|
|
33
|
+
"./icon": "./icon.svg"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"index.js",
|
|
37
|
+
"client.js",
|
|
38
|
+
"cordis.patch.yml",
|
|
39
|
+
"locale/*.json",
|
|
40
|
+
"icon.svg",
|
|
41
|
+
"bin/dsh-lookup-dict",
|
|
42
|
+
"native/dsh-lookup-dict.m",
|
|
43
|
+
"scripts/build-helper.sh",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build:helper": "bash scripts/build-helper.sh",
|
|
49
|
+
"check": "node --check index.js && node --check client.js",
|
|
50
|
+
"test": "node --test test/*.test.mjs"
|
|
51
|
+
},
|
|
52
|
+
"dsh": {
|
|
53
|
+
"bundle": {
|
|
54
|
+
"patch": "./cordis.patch.yml"
|
|
55
|
+
},
|
|
56
|
+
"client": {
|
|
57
|
+
"platform": "web",
|
|
58
|
+
"immediately": true,
|
|
59
|
+
"inject": [
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-slots",
|
|
61
|
+
"@deepseek-ai/dsh-client-locale"
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Rebuild bin/dsh-lookup-dict as a signed universal (arm64 + x86_64) helper.
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
cd "$(dirname "$0")/.."
|
|
5
|
+
|
|
6
|
+
if [[ "$(uname -s)" != "Darwin" ]]; then
|
|
7
|
+
echo "build-helper.sh: macOS only" >&2
|
|
8
|
+
exit 1
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
mkdir -p bin
|
|
12
|
+
xcrun clang \
|
|
13
|
+
-O2 \
|
|
14
|
+
-fobjc-arc \
|
|
15
|
+
-arch arm64 \
|
|
16
|
+
-arch x86_64 \
|
|
17
|
+
-mmacosx-version-min=11.0 \
|
|
18
|
+
-Wno-deprecated-declarations \
|
|
19
|
+
-framework Foundation \
|
|
20
|
+
-framework CoreServices \
|
|
21
|
+
-o bin/dsh-lookup-dict \
|
|
22
|
+
native/dsh-lookup-dict.m
|
|
23
|
+
|
|
24
|
+
# ad-hoc signature keeps the arm64 slice runnable after lipo merges it.
|
|
25
|
+
codesign --force --sign - bin/dsh-lookup-dict
|
|
26
|
+
|
|
27
|
+
echo "built bin/dsh-lookup-dict ($(lipo -archs bin/dsh-lookup-dict))"
|
|
28
|
+
./bin/dsh-lookup-dict --version
|