@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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -2
  3. package/README.zh-CN.md +152 -0
  4. package/THIRD_PARTY_NOTICES.md +22 -0
  5. package/client.js +1070 -0
  6. package/cordis.patch.yml +4 -0
  7. package/icon.svg +1 -0
  8. package/index.js +41 -0
  9. package/locale/en.json +6 -0
  10. package/locale/zh.json +6 -0
  11. package/package.json +90 -4
  12. package/src/host/files.mjs +29 -0
  13. package/src/host/http.mjs +153 -0
  14. package/src/host/llm.mjs +194 -0
  15. package/src/host/log.mjs +40 -0
  16. package/src/host/service.mjs +334 -0
  17. package/src/host/settings.mjs +282 -0
  18. package/src/host/skills.mjs +88 -0
  19. package/src/host/supervisor.mjs +126 -0
  20. package/src/host/usage.mjs +128 -0
  21. package/src/shared/icons.mjs +66 -0
  22. package/src/shared/open-with-system.mjs +15 -0
  23. package/src/shared/paths.mjs +7 -0
  24. package/src/shared/protocol.mjs +4 -0
  25. package/src/worker/controller.mjs +274 -0
  26. package/src/worker/main.mjs +147 -0
  27. package/src/worker/native-window.mjs +319 -0
  28. package/src/worker/open-url.mjs +20 -0
  29. package/src/worker/overlay.mjs +266 -0
  30. package/src/worker/page.mjs +14 -0
  31. package/src/worker/placement.mjs +58 -0
  32. package/src/worker/speech.mjs +136 -0
  33. package/ui/markdown.js +122 -0
  34. package/ui/toolbar.css +162 -0
  35. package/ui/toolbar.html +54 -0
  36. package/ui/toolbar.js +552 -0
  37. package/vendor/selection-hook/LICENSE +21 -0
  38. package/vendor/selection-hook/index.d.ts +517 -0
  39. package/vendor/selection-hook/index.js +662 -0
  40. package/vendor/selection-hook/package.json +10 -0
  41. package/vendor/selection-hook/prebuilds/darwin-arm64/selection-hook.node +0 -0
  42. package/vendor/selection-hook/prebuilds/darwin-x64/selection-hook.node +0 -0
  43. package/vendor/selection-hook/prebuilds/win32-arm64/selection-hook.node +0 -0
  44. 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.