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