@followbin/dsh-selection 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +151 -2
- package/README.zh-CN.md +152 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/client.js +1070 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +1 -0
- package/index.js +41 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +90 -4
- package/src/host/files.mjs +29 -0
- package/src/host/http.mjs +153 -0
- package/src/host/llm.mjs +194 -0
- package/src/host/log.mjs +40 -0
- package/src/host/service.mjs +334 -0
- package/src/host/settings.mjs +282 -0
- package/src/host/skills.mjs +88 -0
- package/src/host/supervisor.mjs +126 -0
- package/src/host/usage.mjs +128 -0
- package/src/shared/icons.mjs +66 -0
- package/src/shared/open-with-system.mjs +15 -0
- package/src/shared/paths.mjs +7 -0
- package/src/shared/protocol.mjs +4 -0
- package/src/worker/controller.mjs +274 -0
- package/src/worker/main.mjs +147 -0
- package/src/worker/native-window.mjs +319 -0
- package/src/worker/open-url.mjs +20 -0
- package/src/worker/overlay.mjs +266 -0
- package/src/worker/page.mjs +14 -0
- package/src/worker/placement.mjs +58 -0
- package/src/worker/speech.mjs +136 -0
- package/ui/markdown.js +122 -0
- package/ui/toolbar.css +162 -0
- package/ui/toolbar.html +54 -0
- package/ui/toolbar.js +552 -0
- package/vendor/selection-hook/LICENSE +21 -0
- package/vendor/selection-hook/index.d.ts +517 -0
- package/vendor/selection-hook/index.js +662 -0
- package/vendor/selection-hook/package.json +10 -0
- package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
- package/vendor/selection-hook/prebuilds/win32-x64/selection-hook.node +0 -0
package/ui/toolbar.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// Toolbar page logic. Node drives it through window.bar.*; it answers with window.ipc.postMessage(JSON).
|
|
2
|
+
(function () {
|
|
3
|
+
const STRINGS = {
|
|
4
|
+
zh: {
|
|
5
|
+
more: '更多', disableApp: '在此应用中禁用', settings: '更多设置…', copied: '已复制', stop: '停止',
|
|
6
|
+
assistant: '划词助手', miniStyle: '切换为迷你模式', fullStyle: '切换为常规模式',
|
|
7
|
+
close: '关闭', copyResult: '复制结果', retry: '重新生成', pin: '固定窗口', translateTo: '译为', auto: '自动',
|
|
8
|
+
askPlaceholder: '就这段内容问点什么…', followPlaceholder: '接着问…', send: '发送', waiting: '正在生成', retryShort: '重试', from: '来自 {app}',
|
|
9
|
+
errors: {
|
|
10
|
+
NO_LLM: 'DSH 的模型服务还没就绪,稍后再试',
|
|
11
|
+
NO_MODEL: 'DSH 里还没有可用的模型,请先在 DSH 设置中登录或填写 API Key',
|
|
12
|
+
MODEL_FAILED: '模型调用失败',
|
|
13
|
+
EMPTY: '模型没有返回内容',
|
|
14
|
+
TIMEOUT: '等待模型超时',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
en: {
|
|
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',
|
|
20
|
+
close: 'Close', copyResult: 'Copy result', retry: 'Regenerate', pin: 'Keep open', translateTo: 'Translate to', auto: 'Auto',
|
|
21
|
+
askPlaceholder: 'Ask something about this text…', followPlaceholder: 'Ask a follow-up…', send: 'Send', waiting: 'Generating', retryShort: 'Retry', from: 'from {app}',
|
|
22
|
+
errors: {
|
|
23
|
+
NO_LLM: 'DSH’s model service is not ready yet, try again shortly',
|
|
24
|
+
NO_MODEL: 'No model is set up in DSH yet. Sign in or add an API key in DSH settings',
|
|
25
|
+
MODEL_FAILED: 'The model request failed',
|
|
26
|
+
EMPTY: 'The model returned nothing',
|
|
27
|
+
TIMEOUT: 'The model took too long to answer',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
const locale = (navigator.language || 'en').toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
32
|
+
const t = STRINGS[locale]
|
|
33
|
+
const icons = window.DSH_ICONS || {}
|
|
34
|
+
const body = document.body
|
|
35
|
+
const $ = (selector) => document.querySelector(selector)
|
|
36
|
+
const post = (message) => window.ipc.postMessage(JSON.stringify(message))
|
|
37
|
+
const icon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name] || ''}</svg>`
|
|
38
|
+
const nameOf = (skill) => (typeof skill.name === 'string' ? skill.name : skill.name[locale] || skill.name.en)
|
|
39
|
+
|
|
40
|
+
const els = {
|
|
41
|
+
barSkills: $('.bar-skills'),
|
|
42
|
+
more: $('.more-button'),
|
|
43
|
+
mini: $('.mini-button'),
|
|
44
|
+
menu: $('.menu'),
|
|
45
|
+
menuSkills: $('.menu-skills'),
|
|
46
|
+
styleItem: $('[data-menu-action="toolbar-style"]'),
|
|
47
|
+
panel: $('.panel'),
|
|
48
|
+
panelIcon: $('.panel-icon'),
|
|
49
|
+
panelTitle: $('.panel-title'),
|
|
50
|
+
panelApp: $('.panel-app'),
|
|
51
|
+
language: $('.panel-language'),
|
|
52
|
+
pin: $('[data-tool="pin"]'),
|
|
53
|
+
copy: $('[data-tool="copy"]'),
|
|
54
|
+
source: $('.source'),
|
|
55
|
+
result: $('.result'),
|
|
56
|
+
thread: $('.thread'),
|
|
57
|
+
composer: $('.composer'),
|
|
58
|
+
input: $('.composer textarea'),
|
|
59
|
+
send: $('.composer .send'),
|
|
60
|
+
panelBody: $('.panel-body'),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Static labels and icons.
|
|
64
|
+
document.querySelectorAll('[data-i18n]').forEach((node) => { node.textContent = t[node.dataset.i18n] })
|
|
65
|
+
document.querySelectorAll('[data-i18n-label]').forEach((node) => {
|
|
66
|
+
node.setAttribute('aria-label', t[node.dataset.i18nLabel])
|
|
67
|
+
node.title = t[node.dataset.i18nLabel]
|
|
68
|
+
})
|
|
69
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach((node) => { node.placeholder = t[node.dataset.i18nPlaceholder] })
|
|
70
|
+
els.more.innerHTML = icon('more')
|
|
71
|
+
els.mini.innerHTML = window.DSH_BRAND || icon('sparkles')
|
|
72
|
+
$('[data-menu-action="disable-app"]').insertAdjacentHTML('afterbegin', icon('ban'))
|
|
73
|
+
$('[data-menu-action="settings"]').insertAdjacentHTML('afterbegin', icon('settings'))
|
|
74
|
+
for (const [tool, name] of [['pin', 'pin'], ['copy', 'copy'], ['retry', 'retry'], ['close', 'close']]) {
|
|
75
|
+
$(`[data-tool="${tool}"]`).innerHTML = icon(name)
|
|
76
|
+
}
|
|
77
|
+
els.send.innerHTML = icon('send')
|
|
78
|
+
|
|
79
|
+
const emptyState = (requestId = null, pending = false) => ({ requestId, text: '', turn: null, pending, failure: null })
|
|
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()
|
|
90
|
+
let state = emptyState()
|
|
91
|
+
let renderTimer = null
|
|
92
|
+
// Timers rather than requestAnimationFrame: the view can be occluded or the screen locked, and then
|
|
93
|
+
// frame callbacks stop while the window still has to be resized and the answer kept up to date.
|
|
94
|
+
const RENDER_INTERVAL_MS = 16
|
|
95
|
+
|
|
96
|
+
function skillButton(skill, className) {
|
|
97
|
+
const button = document.createElement('button')
|
|
98
|
+
button.type = 'button'
|
|
99
|
+
button.className = className
|
|
100
|
+
button.dataset.skill = skill.id
|
|
101
|
+
button.innerHTML = `${icon(skill.icon)}<span></span>`
|
|
102
|
+
button.querySelector('span').textContent = nameOf(skill)
|
|
103
|
+
return button
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function setMenu(open) {
|
|
107
|
+
if (open) {
|
|
108
|
+
const bar = $('.bar')
|
|
109
|
+
const button = style === 'mini' ? els.mini : els.more
|
|
110
|
+
els.menu.style.left = `${bar.offsetLeft + button.offsetLeft - 6}px`
|
|
111
|
+
els.menu.style.top = `${bar.offsetTop + bar.offsetHeight + 6}px`
|
|
112
|
+
}
|
|
113
|
+
body.dataset.menu = open ? 'open' : 'closed'
|
|
114
|
+
els.more.setAttribute('aria-expanded', String(open && style === 'full'))
|
|
115
|
+
els.mini.setAttribute('aria-expanded', String(open && style === 'mini'))
|
|
116
|
+
queueSize()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Whether the reader is at the end of the conversation. New text keeps the end in view until they
|
|
120
|
+
// scroll up; scrolling back down, or asking something, follows it again.
|
|
121
|
+
let following = true
|
|
122
|
+
const scrollToEnd = () => { els.panelBody.scrollTop = els.panelBody.scrollHeight }
|
|
123
|
+
|
|
124
|
+
// Draws the answer and any failure from state, so a late batched render can never erase an error.
|
|
125
|
+
function render() {
|
|
126
|
+
clearTimeout(renderTimer)
|
|
127
|
+
renderTimer = null
|
|
128
|
+
const node = state.turn ? state.turn.querySelector('.answer') : els.result
|
|
129
|
+
node.classList.toggle('pending', state.pending)
|
|
130
|
+
node.dataset.pending = t.waiting
|
|
131
|
+
// Copy takes the answer from here: the node itself also holds the failure notice.
|
|
132
|
+
node.dataset.text = state.text
|
|
133
|
+
node.innerHTML = window.DshMarkdown.render(state.text) + (state.failure ? failureHtml(state.failure) : '')
|
|
134
|
+
if (following) scrollToEnd()
|
|
135
|
+
queueSize()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Re-rendering the whole answer is cheap at this size; batching keeps streaming smooth.
|
|
139
|
+
function scheduleRender() {
|
|
140
|
+
if (renderTimer === null) renderTimer = setTimeout(render, RENDER_INTERVAL_MS)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function failureHtml(failure) {
|
|
144
|
+
const message = t.errors[failure.code] || t.errors.MODEL_FAILED
|
|
145
|
+
const detail = failure.detail ? `<small>${window.DshMarkdown.escapeHtml(failure.detail)}</small>` : ''
|
|
146
|
+
const request = window.DshMarkdown.escapeHtml(state.requestId || '')
|
|
147
|
+
return `<div class="failure" data-request="${request}">${window.DshMarkdown.escapeHtml(message)}${detail}<button type="button" data-tool="retry">${t.retryShort}</button></div>`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A second flash before the first one ends must still restore the original content, not the check mark.
|
|
151
|
+
const flashes = new WeakMap()
|
|
152
|
+
function flashDone(button, label) {
|
|
153
|
+
const running = flashes.get(button)
|
|
154
|
+
if (running) clearTimeout(running.timer)
|
|
155
|
+
const original = running ? running.original : button.innerHTML
|
|
156
|
+
const labelled = running ? running.labelled : button.querySelector('span') !== null
|
|
157
|
+
button.classList.add('done')
|
|
158
|
+
button.innerHTML = `${icon('check')}${labelled ? `<span>${label}</span>` : ''}`
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
flashes.delete(button)
|
|
161
|
+
button.classList.remove('done')
|
|
162
|
+
button.innerHTML = original
|
|
163
|
+
}, 1200)
|
|
164
|
+
flashes.set(button, { original, labelled, timer })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
window.bar = {
|
|
168
|
+
configure(next) {
|
|
169
|
+
skills = next
|
|
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()
|
|
182
|
+
queueSize()
|
|
183
|
+
},
|
|
184
|
+
showBar() {
|
|
185
|
+
clearTimeout(hoverTimer)
|
|
186
|
+
delete body.dataset.pointer
|
|
187
|
+
body.dataset.mode = 'bar'
|
|
188
|
+
setMenu(false)
|
|
189
|
+
},
|
|
190
|
+
setMenu(open) {
|
|
191
|
+
if (body.dataset.mode === 'bar') setMenu(Boolean(open))
|
|
192
|
+
},
|
|
193
|
+
showPanel({ skill, text, app, languages, translateTarget }) {
|
|
194
|
+
delete body.dataset.pointer
|
|
195
|
+
body.dataset.mode = 'panel'
|
|
196
|
+
body.dataset.menu = 'closed'
|
|
197
|
+
body.dataset.kind = skill.kind === 'ask' ? 'ask' : skill.id === 'translate' ? 'translate' : 'prompt'
|
|
198
|
+
els.panelIcon.innerHTML = icon(skill.icon)
|
|
199
|
+
els.panelTitle.textContent = nameOf(skill)
|
|
200
|
+
els.panelApp.textContent = app ? t.from.replace('{app}', app) : ''
|
|
201
|
+
els.source.textContent = text
|
|
202
|
+
els.source.classList.remove('expanded')
|
|
203
|
+
els.result.replaceChildren()
|
|
204
|
+
delete els.result.dataset.text
|
|
205
|
+
els.thread.replaceChildren()
|
|
206
|
+
// Other skills offer the follow-up box once their answer is complete.
|
|
207
|
+
delete body.dataset.composer
|
|
208
|
+
els.input.placeholder = skill.kind === 'ask' ? t.askPlaceholder : t.followPlaceholder
|
|
209
|
+
els.pin.setAttribute('aria-pressed', 'false')
|
|
210
|
+
els.language.replaceChildren(
|
|
211
|
+
new Option(t.auto, 'auto'),
|
|
212
|
+
...(languages || []).map((language) => new Option(language.name, language.code)),
|
|
213
|
+
)
|
|
214
|
+
els.language.value = translateTarget || 'auto'
|
|
215
|
+
clearTimeout(renderTimer)
|
|
216
|
+
renderTimer = null
|
|
217
|
+
state = emptyState()
|
|
218
|
+
following = true
|
|
219
|
+
els.panelBody.scrollTop = 0
|
|
220
|
+
// A question typed but not sent belongs to the earlier selection.
|
|
221
|
+
els.input.value = ''
|
|
222
|
+
resizeInput()
|
|
223
|
+
if (skill.kind === 'ask') setTimeout(() => els.input.focus(), 0)
|
|
224
|
+
queueSize()
|
|
225
|
+
},
|
|
226
|
+
begin(requestId, info) {
|
|
227
|
+
// The previous answer is drawn before its state goes: its last text may still wait for the
|
|
228
|
+
// render timer, and a turn that was still streaming ends where it is.
|
|
229
|
+
if (state.requestId !== null && (renderTimer !== null || state.pending)) {
|
|
230
|
+
state.pending = false
|
|
231
|
+
render()
|
|
232
|
+
}
|
|
233
|
+
// Retry buttons left in earlier failures would run whatever is on screen now.
|
|
234
|
+
els.panel.querySelectorAll('.failure [data-tool="retry"]').forEach((button) => button.remove())
|
|
235
|
+
state = emptyState(requestId, true)
|
|
236
|
+
if (typeof info?.question === 'string') {
|
|
237
|
+
const turn = document.createElement('div')
|
|
238
|
+
turn.className = 'turn'
|
|
239
|
+
turn.innerHTML = '<div class="question"></div><div class="answer markdown"></div>'
|
|
240
|
+
turn.querySelector('.question').textContent = info.question
|
|
241
|
+
els.thread.append(turn)
|
|
242
|
+
state.turn = turn
|
|
243
|
+
following = true
|
|
244
|
+
scrollToEnd()
|
|
245
|
+
} else if (body.dataset.kind !== 'ask') {
|
|
246
|
+
// The panel's own skill runs again (retry, another language), so its conversation starts over.
|
|
247
|
+
els.thread.replaceChildren()
|
|
248
|
+
delete body.dataset.composer
|
|
249
|
+
}
|
|
250
|
+
scheduleRender()
|
|
251
|
+
queueSize()
|
|
252
|
+
},
|
|
253
|
+
append(requestId, chunk) {
|
|
254
|
+
if (requestId !== state.requestId) return
|
|
255
|
+
state.text += chunk
|
|
256
|
+
state.pending = true
|
|
257
|
+
scheduleRender()
|
|
258
|
+
},
|
|
259
|
+
done(requestId) {
|
|
260
|
+
if (requestId !== state.requestId) return
|
|
261
|
+
state.pending = false
|
|
262
|
+
body.dataset.composer = 'shown'
|
|
263
|
+
scheduleRender()
|
|
264
|
+
},
|
|
265
|
+
fail(requestId, failure) {
|
|
266
|
+
if (requestId !== state.requestId) return
|
|
267
|
+
state.pending = false
|
|
268
|
+
state.failure = failure
|
|
269
|
+
render()
|
|
270
|
+
},
|
|
271
|
+
setSpeaking(speaking) {
|
|
272
|
+
const button = els.barSkills.querySelector('[data-skill="speak"]') || els.menuSkills.querySelector('[data-skill="speak"]')
|
|
273
|
+
if (!button) return
|
|
274
|
+
const skill = [...skills.bar, ...skills.more].find((candidate) => candidate.id === 'speak')
|
|
275
|
+
button.innerHTML = `${icon(speaking ? 'stop' : 'speak')}<span></span>`
|
|
276
|
+
button.querySelector('span').textContent = speaking ? t.stop : nameOf(skill)
|
|
277
|
+
queueSize()
|
|
278
|
+
},
|
|
279
|
+
flash(skillId) {
|
|
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)
|
|
282
|
+
},
|
|
283
|
+
/**
|
|
284
|
+
* How tall the panel may grow before it scrolls inside, from the worker's placement. With `fill`
|
|
285
|
+
* it takes that height at once: it sits above the selection, its bottom edge at the selection.
|
|
286
|
+
*/
|
|
287
|
+
setRoom(px, fill) {
|
|
288
|
+
const limited = Number.isFinite(px) && px > 0
|
|
289
|
+
body.style.setProperty('--room', limited ? `${px}px` : 'none')
|
|
290
|
+
if (limited && fill) body.dataset.fill = 'true'
|
|
291
|
+
else delete body.dataset.fill
|
|
292
|
+
queueSize()
|
|
293
|
+
},
|
|
294
|
+
reset() {
|
|
295
|
+
clearTimeout(hoverTimer)
|
|
296
|
+
delete body.dataset.pointer
|
|
297
|
+
body.dataset.mode = 'hidden'
|
|
298
|
+
body.dataset.menu = 'closed'
|
|
299
|
+
if (document.activeElement) document.activeElement.blur()
|
|
300
|
+
},
|
|
301
|
+
/** Clicks an element through the page's own click handling (self tests). */
|
|
302
|
+
click(selector) {
|
|
303
|
+
document.querySelector(selector)?.click()
|
|
304
|
+
},
|
|
305
|
+
probe() {
|
|
306
|
+
const card = body.dataset.mode === 'panel' ? els.panel : $('.bar')
|
|
307
|
+
const box = card.getBoundingClientRect()
|
|
308
|
+
const buttons = [...document.querySelectorAll('button')].filter((button) => button.offsetParent !== null)
|
|
309
|
+
post({
|
|
310
|
+
type: 'probe',
|
|
311
|
+
mode: body.dataset.mode,
|
|
312
|
+
menu: body.dataset.menu,
|
|
313
|
+
viewport: { width: innerWidth, height: innerHeight },
|
|
314
|
+
card: { width: Math.round(box.width), height: Math.round(box.height) },
|
|
315
|
+
buttons: buttons.map((button) => button.getAttribute('aria-label') || button.textContent.trim()),
|
|
316
|
+
overflow: buttons.filter((button) => {
|
|
317
|
+
const rect = button.getBoundingClientRect()
|
|
318
|
+
const owner = button.closest('.card').getBoundingClientRect()
|
|
319
|
+
return rect.right > owner.right + 0.5 || rect.bottom > owner.bottom + 0.5
|
|
320
|
+
}).map((button) => button.getAttribute('aria-label') || button.textContent.trim()),
|
|
321
|
+
darkMode: matchMedia('(prefers-color-scheme: dark)').matches,
|
|
322
|
+
// Visible area of each card: anything a card draws outside it is clipped from the user's view.
|
|
323
|
+
clipped: [...document.querySelectorAll('.card')].filter((node) => node.offsetParent !== null).some((node) => {
|
|
324
|
+
const rect = node.getBoundingClientRect()
|
|
325
|
+
return rect.bottom > innerHeight + 0.5 || rect.right > innerWidth + 0.5
|
|
326
|
+
|| document.elementFromPoint(rect.left + rect.width / 2, rect.bottom - 2)?.closest('.card') !== node
|
|
327
|
+
}),
|
|
328
|
+
controls: Object.fromEntries([...document.querySelectorAll('[data-tool]')].filter((node) => node.offsetParent !== null).map((node) => {
|
|
329
|
+
const rect = node.getBoundingClientRect()
|
|
330
|
+
return [node.dataset.tool, { x: rect.left, y: rect.top, width: rect.width, height: rect.height }]
|
|
331
|
+
})),
|
|
332
|
+
body: (({ left, right }) => ({ left, right }))(els.panelBody.getBoundingClientRect()),
|
|
333
|
+
questions: [...els.thread.querySelectorAll('.question')].map((node) => (({ left, right }) => ({ left, right }))(node.getBoundingClientRect())),
|
|
334
|
+
})
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Report the window size the visible cards need, which card the window is anchored on, and every
|
|
339
|
+
// clickable card so clicks on the transparent margin fall through to the app underneath.
|
|
340
|
+
const padding = (() => {
|
|
341
|
+
const style = getComputedStyle(body)
|
|
342
|
+
return { right: parseFloat(style.paddingRight), bottom: parseFloat(style.paddingBottom) }
|
|
343
|
+
})()
|
|
344
|
+
let sizeQueued = false
|
|
345
|
+
// Batched per task; reading layout synchronously forces it even when nothing is being painted.
|
|
346
|
+
function queueSize() {
|
|
347
|
+
if (sizeQueued) return
|
|
348
|
+
sizeQueued = true
|
|
349
|
+
queueMicrotask(() => {
|
|
350
|
+
sizeQueued = false
|
|
351
|
+
const mode = body.dataset.mode
|
|
352
|
+
if (mode !== 'bar' && mode !== 'panel') return
|
|
353
|
+
const cards = mode === 'bar' ? [$('.bar'), ...(body.dataset.menu === 'open' ? [els.menu] : [])] : [els.panel]
|
|
354
|
+
const rects = cards.map((card) => card.getBoundingClientRect())
|
|
355
|
+
const asRect = (rect) => ({ x: rect.left, y: rect.top, width: rect.width, height: rect.height })
|
|
356
|
+
post({
|
|
357
|
+
type: 'size',
|
|
358
|
+
mode,
|
|
359
|
+
width: Math.ceil(Math.max(...rects.map((rect) => rect.right)) + padding.right),
|
|
360
|
+
height: Math.ceil(Math.max(...rects.map((rect) => rect.bottom)) + padding.bottom),
|
|
361
|
+
primary: asRect(rects[0]),
|
|
362
|
+
hits: rects.map(asRect),
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
new ResizeObserver(queueSize).observe($('.stage'))
|
|
367
|
+
|
|
368
|
+
function resizeInput() {
|
|
369
|
+
els.input.style.height = 'auto'
|
|
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'
|
|
375
|
+
els.send.disabled = !els.input.value.trim()
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function submitQuestion() {
|
|
379
|
+
const question = els.input.value.trim()
|
|
380
|
+
if (!question) return
|
|
381
|
+
post({ type: 'ask', question })
|
|
382
|
+
els.input.value = ''
|
|
383
|
+
resizeInput()
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
document.addEventListener('click', (event) => {
|
|
387
|
+
const skill = event.target.closest('[data-skill]')
|
|
388
|
+
if (skill) {
|
|
389
|
+
setMenu(false)
|
|
390
|
+
post({ type: 'skill', id: skill.dataset.skill })
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
if (event.target.closest('.mini-button')) {
|
|
394
|
+
clearTimeout(hoverTimer)
|
|
395
|
+
setMenu(true)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
if (event.target.closest('.more-button')) {
|
|
399
|
+
setMenu(body.dataset.menu !== 'open')
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
const menu = event.target.closest('[data-menu-action]')
|
|
403
|
+
if (menu) {
|
|
404
|
+
post({ type: 'menu', action: menu.dataset.menuAction })
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
const link = event.target.closest('a[data-href]')
|
|
408
|
+
if (link) {
|
|
409
|
+
event.preventDefault()
|
|
410
|
+
post({ type: 'open-url', url: link.dataset.href })
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
const tool = event.target.closest('[data-tool]')
|
|
414
|
+
if (tool) {
|
|
415
|
+
const action = tool.dataset.tool
|
|
416
|
+
if (action === 'pin') {
|
|
417
|
+
const pinned = tool.getAttribute('aria-pressed') !== 'true'
|
|
418
|
+
tool.setAttribute('aria-pressed', String(pinned))
|
|
419
|
+
post({ type: 'pin', pinned })
|
|
420
|
+
} else if (action === 'copy') {
|
|
421
|
+
const answers = [els.result, ...els.thread.querySelectorAll('.answer')]
|
|
422
|
+
const text = answers.map((node) => node.dataset.text || '').filter(Boolean).join('\n\n')
|
|
423
|
+
if (text) {
|
|
424
|
+
post({ type: 'copy-result', text })
|
|
425
|
+
flashDone(tool, '')
|
|
426
|
+
}
|
|
427
|
+
} else if (action === 'retry') {
|
|
428
|
+
const failure = tool.closest('.failure')
|
|
429
|
+
if (failure) {
|
|
430
|
+
// A failed turn is asked again in place of itself.
|
|
431
|
+
failure.closest('.turn')?.remove()
|
|
432
|
+
post({ type: 'retry', requestId: failure.dataset.request })
|
|
433
|
+
} else {
|
|
434
|
+
post({ type: 'retry' })
|
|
435
|
+
}
|
|
436
|
+
} else if (action === 'close') {
|
|
437
|
+
post({ type: 'close' })
|
|
438
|
+
}
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
if (event.target.closest('.source')) {
|
|
442
|
+
els.source.classList.toggle('expanded')
|
|
443
|
+
queueSize()
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
els.language.addEventListener('change', () => post({ type: 'language', code: els.language.value }))
|
|
448
|
+
els.input.addEventListener('input', resizeInput)
|
|
449
|
+
els.input.addEventListener('focus', () => post({ type: 'keyboard', needed: true }))
|
|
450
|
+
els.input.addEventListener('keydown', (event) => {
|
|
451
|
+
// WebKit ends the composition before this keydown, so only keyCode 229 marks the IME's own Enter.
|
|
452
|
+
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) {
|
|
453
|
+
event.preventDefault()
|
|
454
|
+
submitQuestion()
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
// Escape closes the panel, unless an input method is using it to cancel a composition.
|
|
458
|
+
document.addEventListener('keydown', (event) => {
|
|
459
|
+
if (event.key === 'Escape' && !event.isComposing && event.keyCode !== 229 && body.dataset.mode !== 'hidden') post({ type: 'close' })
|
|
460
|
+
})
|
|
461
|
+
els.composer.addEventListener('submit', (event) => {
|
|
462
|
+
event.preventDefault()
|
|
463
|
+
submitQuestion()
|
|
464
|
+
})
|
|
465
|
+
// Pressing on the toolbar or the panel header and moving moves the window. A press that travels less
|
|
466
|
+
// than a few points stays an ordinary click; once it has become a drag, its click is swallowed.
|
|
467
|
+
const DRAG_THRESHOLD = 4
|
|
468
|
+
let press = null
|
|
469
|
+
let swallowClick = false
|
|
470
|
+
document.addEventListener('pointerdown', (event) => {
|
|
471
|
+
if (event.button !== 0 || event.target.closest('select, textarea')) return
|
|
472
|
+
if (!event.target.closest('.bar, .panel-head')) return
|
|
473
|
+
press = { id: event.pointerId, x: event.screenX, y: event.screenY, target: event.target, dragging: false }
|
|
474
|
+
})
|
|
475
|
+
document.addEventListener('pointermove', (event) => {
|
|
476
|
+
if (!press || event.pointerId !== press.id) return
|
|
477
|
+
if (!press.dragging) {
|
|
478
|
+
if (Math.hypot(event.screenX - press.x, event.screenY - press.y) < DRAG_THRESHOLD) return
|
|
479
|
+
press.dragging = true
|
|
480
|
+
body.dataset.dragging = 'true'
|
|
481
|
+
try {
|
|
482
|
+
press.target.setPointerCapture(event.pointerId)
|
|
483
|
+
} catch {
|
|
484
|
+
// Without capture the drag still works while the pointer stays over the window.
|
|
485
|
+
}
|
|
486
|
+
post({ type: 'drag', phase: 'start', x: press.x, y: press.y })
|
|
487
|
+
}
|
|
488
|
+
post({ type: 'drag', phase: 'move', x: event.screenX, y: event.screenY })
|
|
489
|
+
})
|
|
490
|
+
const endPress = (event) => {
|
|
491
|
+
if (!press || event.pointerId !== press.id) return
|
|
492
|
+
if (press.dragging) {
|
|
493
|
+
post({ type: 'drag', phase: 'end', x: event.screenX, y: event.screenY })
|
|
494
|
+
delete body.dataset.dragging
|
|
495
|
+
swallowClick = true
|
|
496
|
+
setTimeout(() => { swallowClick = false }, 0)
|
|
497
|
+
}
|
|
498
|
+
press = null
|
|
499
|
+
}
|
|
500
|
+
document.addEventListener('pointerup', endPress)
|
|
501
|
+
document.addEventListener('pointercancel', endPress)
|
|
502
|
+
document.addEventListener('click', (event) => {
|
|
503
|
+
if (!swallowClick) return
|
|
504
|
+
swallowClick = false
|
|
505
|
+
event.stopImmediatePropagation()
|
|
506
|
+
event.preventDefault()
|
|
507
|
+
}, true)
|
|
508
|
+
|
|
509
|
+
els.panelBody.addEventListener('scroll', () => {
|
|
510
|
+
following = els.panelBody.scrollHeight - els.panelBody.scrollTop - els.panelBody.clientHeight < 24
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
// Clicking into the answer lets the panel take the keyboard, so ⌘C copies a selected part of it.
|
|
514
|
+
els.panelBody.addEventListener('pointerdown', () => {
|
|
515
|
+
if (body.dataset.mode === 'panel') post({ type: 'keyboard', needed: true })
|
|
516
|
+
})
|
|
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
|
+
|
|
541
|
+
// WebKit keeps :hover on whatever was under the pointer when it last saw one, through hiding and
|
|
542
|
+
// showing the window, and this process is never the active app, so a pointer that does not move
|
|
543
|
+
// over the page is not tracked. Hover styles therefore wait for a real movement after each show.
|
|
544
|
+
document.addEventListener('pointermove', () => {
|
|
545
|
+
if (!('pointer' in body.dataset)) body.dataset.pointer = ''
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// Dropping a link or file would navigate the view away from this page.
|
|
549
|
+
for (const type of ['dragover', 'drop']) document.addEventListener(type, (event) => event.preventDefault())
|
|
550
|
+
window.addEventListener('error', (event) => post({ type: 'page-error', message: String(event.message) }))
|
|
551
|
+
post({ type: 'page-ready' })
|
|
552
|
+
})()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 0xfullex (https://github.com/0xfullex/selection-hook)
|
|
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.
|