@pointylab/dsh 0.0.0-stage → 0.4.4

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 (88) hide show
  1. package/ASSETS.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +85 -2
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +33 -0
  6. package/package.json +38 -4
  7. package/pet/app/card/card.css +365 -0
  8. package/pet/app/card/card.html +68 -0
  9. package/pet/app/card/card.js +896 -0
  10. package/pet/app/card/markdown.js +64 -0
  11. package/pet/app/card/mascot/blink.png +0 -0
  12. package/pet/app/card/mascot/done.png +0 -0
  13. package/pet/app/card/mascot/error.png +0 -0
  14. package/pet/app/card/mascot/idle.png +0 -0
  15. package/pet/app/card/mascot/peek.png +0 -0
  16. package/pet/app/card/mascot/think.png +0 -0
  17. package/pet/app/card/preload.js +21 -0
  18. package/pet/app/fullscreen.js +131 -0
  19. package/pet/app/main.js +1284 -0
  20. package/pet/app/native/uia-selection.ps1 +158 -0
  21. package/pet/app/notice/notice.html +37 -0
  22. package/pet/app/notice/notice.js +31 -0
  23. package/pet/app/notice/preload.js +7 -0
  24. package/pet/app/overlay/overlay.html +30 -0
  25. package/pet/app/overlay/overlay.js +42 -0
  26. package/pet/app/overlay/preload.js +7 -0
  27. package/pet/app/pill/pill.css +73 -0
  28. package/pet/app/pill/pill.html +11 -0
  29. package/pet/app/pill/pill.js +35 -0
  30. package/pet/app/pill/preload.js +8 -0
  31. package/pet/app/preload.js +16 -0
  32. package/pet/app/renderer/index.html +24 -0
  33. package/pet/app/renderer/lines.js +25 -0
  34. package/pet/app/renderer/pet.js +292 -0
  35. package/pet/app/renderer/renderer2d.js +71 -0
  36. package/pet/app/renderer/style.css +157 -0
  37. package/pet/app/selection.js +246 -0
  38. package/pet/app/shake.js +76 -0
  39. package/pet/app/uipick.js +314 -0
  40. package/pet/assets/cursor/cursor-32.cur +0 -0
  41. package/pet/assets/cursor/cursor-32.png +0 -0
  42. package/pet/assets/cursor/cursor-48.cur +0 -0
  43. package/pet/assets/cursor/cursor-48.png +0 -0
  44. package/pet/assets/cursor/cursor-64.cur +0 -0
  45. package/pet/assets/cursor/cursor-64.png +0 -0
  46. package/pet/assets/cursor/cursor-clean.png +0 -0
  47. package/pet/assets/cursor/make_cursor.py +85 -0
  48. package/pet/assets/cursor/source.png +0 -0
  49. package/pet/assets/pointy.ico +0 -0
  50. package/pet/assets/tray.png +0 -0
  51. package/pet/assets/tray@2x.png +0 -0
  52. package/pet/boot.js +6 -0
  53. package/pet/extension/background.js +252 -0
  54. package/pet/extension/cursor-32.png +0 -0
  55. package/pet/extension/cursor-48.png +0 -0
  56. package/pet/extension/cursor-64.png +0 -0
  57. package/pet/extension/icon.png +0 -0
  58. package/pet/extension/manifest.json +12 -0
  59. package/pet/extension/pick.js +391 -0
  60. package/pet/extension/popup.html +29 -0
  61. package/pet/extension/popup.js +30 -0
  62. package/pet/package.json +11 -0
  63. package/pet/vendor/koffi/LICENSE.txt +22 -0
  64. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
  65. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
  66. package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
  67. package/pet/vendor/koffi/index.js +634 -0
  68. package/pet/vendor/koffi/indirect.js +533 -0
  69. package/pet/vendor/koffi/package.json +35 -0
  70. package/pet/vendor/ws/LICENSE +20 -0
  71. package/pet/vendor/ws/index.js +22 -0
  72. package/pet/vendor/ws/lib/buffer-util.js +142 -0
  73. package/pet/vendor/ws/lib/constants.js +19 -0
  74. package/pet/vendor/ws/lib/event-target.js +292 -0
  75. package/pet/vendor/ws/lib/extension.js +203 -0
  76. package/pet/vendor/ws/lib/limiter.js +55 -0
  77. package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
  78. package/pet/vendor/ws/lib/receiver.js +743 -0
  79. package/pet/vendor/ws/lib/sender.js +610 -0
  80. package/pet/vendor/ws/lib/stream.js +161 -0
  81. package/pet/vendor/ws/lib/subprotocol.js +62 -0
  82. package/pet/vendor/ws/lib/validation.js +152 -0
  83. package/pet/vendor/ws/lib/websocket-server.js +562 -0
  84. package/pet/vendor/ws/lib/websocket.js +1426 -0
  85. package/pet/vendor/ws/package.json +74 -0
  86. package/pet/vendor/ws/wrapper.mjs +21 -0
  87. package/src/node.js +732 -0
  88. package/src/runtime.js +178 -0
@@ -0,0 +1,391 @@
1
+ // 指认模式:只在Pointy叫的时候注入一次,选完、Esc 或右键就把自己拆掉。
2
+ // 高亮光标下"有意义的一块"(一条评论、一张卡片、一段话、一张图……),点一下把它的内容交给Pointy。
3
+ (() => {
4
+ // 上一次指认要是没收尾(Pointy那边超时、扩展重载过),先把它拆掉再开始,免得这一页从此晃不出来
5
+ window.__pointyPickCleanup?.()
6
+ window.__pointyPick = true
7
+
8
+ const MAX_TEXT = 4000
9
+
10
+ // ---------- shadow DOM 工具 ----------
11
+ const parentOf = (el) => el.parentElement ?? (el.getRootNode() instanceof ShadowRoot ? el.getRootNode().host : null)
12
+
13
+ // 登录墙、半透明蒙层这类盖在内容上面的东西,指认时要穿过去
14
+ const OVERLAY = /mask|overlay|wall|backdrop|curtain|scrim/i
15
+ const isOverlay = (el) => OVERLAY.test(el.id ?? '') || (typeof el.className === 'string' && OVERLAY.test(el.className))
16
+
17
+ /** 光标下最上面的一个"不是蒙层"的元素,穿过 shadow DOM */
18
+ function topAt(root, x, y, exclude) {
19
+ return root.elementsFromPoint(x, y).find(el => el !== exclude && !isOverlay(el))
20
+ }
21
+
22
+ function deepElementFromPoint(x, y) {
23
+ let el = topAt(document, x, y)
24
+ while (el?.shadowRoot) {
25
+ const inner = topAt(el.shadowRoot, x, y, el)
26
+ if (!inner || inner === el) break
27
+ el = inner
28
+ }
29
+ return el
30
+ }
31
+
32
+ function* deepWalk(root) {
33
+ const stack = [root]
34
+ while (stack.length) {
35
+ const node = stack.pop()
36
+ yield node
37
+ if (node.shadowRoot) stack.push(node.shadowRoot)
38
+ const kids = node.children ?? []
39
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i])
40
+ }
41
+ }
42
+
43
+ const deepQuery = (root, selector) => { for (const n of deepWalk(root)) if (n.matches?.(selector)) return n; return null }
44
+ const deepQueryAll = (root, selector, limit = 20) => { const out = []; for (const n of deepWalk(root)) { if (n.matches?.(selector)) out.push(n); if (out.length >= limit) break } return out }
45
+
46
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG'])
47
+ const BLOCKY = /^(P|DIV|LI|TR|BR|H[1-6]|ARTICLE|SECTION|BLOCKQUOTE|PRE|TABLE|UL|OL)$/
48
+
49
+ /** innerText 进不了 shadow DOM(B 站评论全在里面),自己拼 */
50
+ function deepText(root) {
51
+ let out = ''
52
+ const visit = (node) => {
53
+ if (out.length > MAX_TEXT) return
54
+ if (node.nodeType === Node.TEXT_NODE) { out += node.textContent; return }
55
+ if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) return
56
+ if (SKIP.has(node.nodeName)) return
57
+ if (node.nodeType === Node.ELEMENT_NODE) {
58
+ const style = getComputedStyle(node)
59
+ if (style.display === 'none' || style.visibility === 'hidden') return
60
+ if (node.tagName === 'IMG' && node.alt) out += `[图:${node.alt}]`
61
+ }
62
+ const children = node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes
63
+ for (const c of children) visit(c)
64
+ // 有 shadowRoot 时 slot 进来的 light DOM 也要
65
+ if (node.shadowRoot) for (const c of node.childNodes) visit(c)
66
+ if (BLOCKY.test(node.nodeName)) out += '\n'
67
+ }
68
+ visit(root)
69
+ return out.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim().slice(0, MAX_TEXT)
70
+ }
71
+
72
+ // ---------- 找"有意义的一块" ----------
73
+ const SEMANTIC_TAG = /^(ARTICLE|LI|TR|PRE|BLOCKQUOTE|FIGURE|IMG|VIDEO|P|H[1-6]|TABLE|DETAILS|DIALOG)$/
74
+ const SEMANTIC_CLASS = /comment|reply|card|item|post|feed|message|msg|tweet|issue|timeline|entry|thread|bubble|answer|review/i
75
+
76
+ function isSemantic(el) {
77
+ if (SEMANTIC_TAG.test(el.tagName)) return true
78
+ if (el.tagName.includes('-') && /comment|reply|card|item|post|thread/i.test(el.tagName)) return true // web component
79
+ const role = el.getAttribute?.('role')
80
+ if (role && /article|listitem|row|comment|dialog/.test(role)) return true
81
+ if (typeof el.className === 'string' && SEMANTIC_CLASS.test(el.className)) return true
82
+ return [...(el.attributes ?? [])].some(a => /^data-.*(id|key)$/i.test(a.name))
83
+ }
84
+
85
+ /** 从光标下的元素往上,收集一串候选块:最小的有意义块在前,整页在后 */
86
+ function chainAt(x, y) {
87
+ const chain = []
88
+ for (let el = deepElementFromPoint(x, y); el && el !== document.documentElement; el = parentOf(el)) {
89
+ if (el === document.body) break
90
+ const r = el.getBoundingClientRect()
91
+ if (r.width < 16 || r.height < 12) continue
92
+ if (r.width >= innerWidth * 0.98 && r.height >= innerHeight * 0.9) break // 快到整页了
93
+ if (isSemantic(el) || el.tagName === 'IMG' || el.tagName === 'VIDEO') chain.push(el)
94
+ }
95
+ if (!chain.length) {
96
+ const el = deepElementFromPoint(x, y)
97
+ if (el && el !== document.body) chain.push(el)
98
+ }
99
+ return chain
100
+ }
101
+
102
+ // ---------- 站点适配器 ----------
103
+ const pickText = (root, selector) => { const n = root && deepQuery(root, selector); return n ? deepText(n).slice(0, 300) : '' }
104
+
105
+ function biliAdapter(el) {
106
+ const info = { site: 'bilibili' }
107
+ const bv = location.pathname.match(/\/video\/(BV\w+)/)?.[1]
108
+ if (bv) {
109
+ info.page = { 类型: '视频页', BV号: bv, 标题: document.querySelector('h1')?.textContent.trim() ?? document.title, UP主: document.querySelector('.up-name, .up-detail-top a')?.textContent.trim() ?? '' }
110
+ const video = document.querySelector('video')
111
+ if (video) info.page.播放到 = `${Math.floor(video.currentTime / 60)}:${String(Math.floor(video.currentTime % 60)).padStart(2, '0')}`
112
+ }
113
+ for (let n = el; n; n = parentOf(n)) {
114
+ if (/^BILI-COMMENT-(REPLY-)?RENDERER$|^BILI-COMMENT-THREAD-RENDERER$/.test(n.tagName) || (typeof n.className === 'string' && /reply-item|sub-reply-item/.test(n.className))) {
115
+ const userLink = deepQuery(n, 'a[href*="space.bilibili.com"]')
116
+ info.kind = 'comment'
117
+ info.fields = {
118
+ 用户: pickText(n, '#user-name, .user-name, .sub-user-name') || userLink?.textContent.trim() || '',
119
+ uid: userLink?.href.match(/space\.bilibili\.com\/(\d+)/)?.[1] ?? '',
120
+ 时间: pickText(n, '#pubdate, .reply-time, .sub-reply-time'),
121
+ 点赞: pickText(n, '#like #count, .reply-like span, .sub-reply-like span'),
122
+ 正文: pickText(n, '#contents, bili-rich-text, .reply-content, .sub-reply-content'),
123
+ }
124
+ return info
125
+ }
126
+ const videoLink = n.matches?.('a[href*="/video/BV"]') ? n : null
127
+ if (videoLink || (typeof n.className === 'string' && /video-card|bili-video-card|feed-card/.test(n.className))) {
128
+ const a = videoLink ?? deepQuery(n, 'a[href*="/video/BV"]')
129
+ if (a) {
130
+ info.kind = 'video-card'
131
+ info.fields = {
132
+ BV号: a.href.match(/BV\w+/)?.[0] ?? '',
133
+ 标题: a.title || pickText(n, '[title], .bili-video-card__info--tit, h3') || a.textContent.trim(),
134
+ UP主: pickText(n, '.bili-video-card__info--author, .up-name, .name'),
135
+ 链接: a.href,
136
+ }
137
+ return info
138
+ }
139
+ }
140
+ }
141
+ return info
142
+ }
143
+
144
+ function githubAdapter(el) {
145
+ const [, owner, repo, type, num] = location.pathname.split('/')
146
+ const info = { site: 'github', page: { 仓库: owner && repo ? `${owner}/${repo}` : '' } }
147
+ if ((type === 'issues' || type === 'pull') && num) { info.page.类型 = type === 'pull' ? 'PR' : 'issue'; info.page.编号 = `#${num}`; info.page.标题 = document.querySelector('.js-issue-title, bdi.markdown-title, [data-testid="issue-title"]')?.textContent.trim() ?? '' }
148
+ const line = el.closest?.('[data-line-number], td.blob-code, .react-code-line-contents, [id^="LC"]')
149
+ if (line) {
150
+ const no = line.getAttribute('data-line-number') ?? line.id?.replace(/^LC/, '') ?? ''
151
+ info.kind = 'code-line'
152
+ info.fields = { 文件: location.pathname.split('/blob/')[1]?.split('/').slice(1).join('/') ?? '', 行号: no, 链接: `${location.origin}${location.pathname}#L${no}` }
153
+ return info
154
+ }
155
+ // 新版 React 页面:正文是 .react-issue-body,评论是 .react-issue-comment;旧版页面的选择器留着兜底
156
+ const comment = el.closest?.('.react-issue-body, .react-issue-comment, .timeline-comment, .js-comment-container, .TimelineItem')
157
+ if (comment) {
158
+ const permalink = comment.querySelector('[data-testid="issue-body-header-link"], a[href*="#issuecomment-"], a[href*="#issue-"], a[href*="#discussion_r"], a.js-timestamp')
159
+ const author = comment.querySelector('[data-testid="issue-body-header-author"], [data-testid="avatar-link"], a.author, .author')
160
+ info.kind = comment.matches('.react-issue-body') ? 'issue-body' : 'comment'
161
+ info.fields = {
162
+ 作者: author?.getAttribute('href')?.split('/').filter(Boolean).pop() ?? author?.textContent.trim() ?? '',
163
+ 时间: comment.querySelector('relative-time')?.getAttribute('datetime') ?? '',
164
+ 链接: permalink?.href ?? location.href,
165
+ 正文: deepText(comment.querySelector('.markdown-body, [data-testid="markdown-body"]') ?? comment).slice(0, 3000),
166
+ }
167
+ }
168
+ return info
169
+ }
170
+
171
+ function genericInfo(el) {
172
+ const kind =
173
+ el.tagName === 'IMG' ? 'image' :
174
+ el.tagName === 'VIDEO' ? 'video' :
175
+ el.tagName === 'PRE' || el.querySelector?.('pre, code') && el.tagName !== 'ARTICLE' ? 'code' :
176
+ el.tagName === 'TR' ? 'table-row' :
177
+ /^H[1-6]$/.test(el.tagName) ? 'heading' :
178
+ el.tagName === 'A' ? 'link' :
179
+ el.tagName === 'P' ? 'paragraph' : 'block'
180
+ const links = deepQueryAll(el, 'a[href]', 8).map(a => ({ text: a.textContent.trim().slice(0, 60), href: a.href })).filter(l => l.href.startsWith('http'))
181
+ if (el.tagName === 'A' && el.href) links.unshift({ text: el.textContent.trim().slice(0, 60), href: el.href })
182
+ const images = (el.tagName === 'IMG' ? [el] : deepQueryAll(el, 'img', 6)).map(i => ({ src: i.currentSrc || i.src, alt: i.alt })).filter(i => i.src && !i.src.startsWith('data:'))
183
+ return { kind, links, images }
184
+ }
185
+
186
+ function describe(el) {
187
+ const host = location.hostname
188
+ const adapter = /bilibili\.com$/.test(host) ? biliAdapter(el) : /github\.com$/.test(host) ? githubAdapter(el) : {}
189
+ const generic = genericInfo(el)
190
+ const r = el.getBoundingClientRect()
191
+ return {
192
+ kind: adapter.kind ?? generic.kind,
193
+ site: adapter.site ?? host,
194
+ fields: adapter.fields ?? {},
195
+ page: adapter.page ?? {},
196
+ text: el.tagName === 'IMG' ? (el.alt || '') : deepText(el),
197
+ links: generic.links,
198
+ images: generic.images,
199
+ tag: el.tagName.toLowerCase(),
200
+ rect: { x: r.left, y: r.top, width: r.width, height: r.height },
201
+ viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
202
+ }
203
+ }
204
+
205
+ // ---------- 已有选区就直接用 ----------
206
+ const existing = getSelection()?.toString().trim()
207
+ if (existing) {
208
+ const range = getSelection().getRangeAt(0)
209
+ const r = range.getBoundingClientRect()
210
+ let block = range.commonAncestorContainer
211
+ if (block.nodeType !== 1) block = block.parentElement
212
+ chrome.runtime.sendMessage({ type: 'pick-result', result: { ...describe(block), kind: 'selection', text: existing.slice(0, 20000), rect: { x: r.left, y: r.top, width: r.width, height: r.height } } })
213
+ window.__pointyPick = false
214
+ return
215
+ }
216
+
217
+ // 上一次指认留着的高亮(卡片还开着)先撤掉
218
+ window.__pointyHeld?.()
219
+
220
+ // ---------- 高亮层:浅蓝圆角描边 + 一圈淡光,跟着光标平滑移动 ----------
221
+ // 用 transform 移动(走合成层,不触发页面重排),缓动是先快后慢,跟手
222
+ const box = document.createElement('div')
223
+ const label = document.createElement('div')
224
+ // 170ms,落位时轻微过冲回弹(弹性调弱过一次),像被吸过去
225
+ const EASE = '170ms cubic-bezier(0.34, 1.2, 0.64, 1)'
226
+ Object.assign(box.style, {
227
+ position: 'fixed', left: '0', top: '0', zIndex: 2147483647, pointerEvents: 'none', boxSizing: 'border-box',
228
+ border: '1.5px solid rgba(138, 180, 248, 0.95)', background: 'rgba(138, 180, 248, 0.08)', borderRadius: '8px',
229
+ boxShadow: '0 0 0 3px rgba(138, 180, 248, 0.16), 0 0 16px 4px rgba(138, 180, 248, 0.28)',
230
+ opacity: '0', willChange: 'transform, width, height',
231
+ transition: `transform ${EASE}, width ${EASE}, height ${EASE}, opacity 120ms ease-out`,
232
+ })
233
+ // 页面正下方一个"右键或 Esc 退出"的小胶囊;放在 shadow DOM 里,不受网页样式影响
234
+ Object.assign(label.style, { position: 'fixed', left: '0', top: '0', zIndex: 2147483647, pointerEvents: 'none' })
235
+ label.attachShadow({ mode: 'open' }).innerHTML = `
236
+ <style>
237
+ .pill { position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; padding: 10px 22px; border-radius: 999px;
238
+ background: rgba(32, 33, 36, 0.92); color: #fff; font: 16px "Segoe UI", "Microsoft YaHei UI", sans-serif; white-space: nowrap;
239
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); pointer-events: none; }
240
+ kbd { font: inherit; font-size: 14px; padding: 1px 9px; border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 7px; }
241
+ .pill svg { width: 15px; height: 20px; }
242
+ </style>
243
+ <div class="pill"><svg viewBox="-1 -1 14 22"><path d="M0 0V17L4 13L7 20L10 18.7L7 12H12Z" fill="#fff"/></svg>右键 或 <kbd>Esc</kbd> 退出</div>`
244
+ // 指认期间光标换成鲸鱼娘扒着箭头的样子,热点在箭头尖;网页自己设的光标(链接的小手等)一律压住
245
+ const cursorStyle = document.createElement('style')
246
+ const cur = (n) => chrome.runtime.getURL(`cursor-${n}.png`)
247
+ cursorStyle.textContent = `* { cursor: image-set(url("${cur(32)}") 1x, url("${cur(48)}") 1.5x, url("${cur(64)}") 2x) 0 8, default !important; }`
248
+ document.documentElement.append(box, label, cursorStyle)
249
+
250
+ let chain = []
251
+ let level = 0
252
+ let levelUp = 0 // 用户用 [ ] 往外扩了几级;鼠标移动时保持这个相对层级(滚轮留给页面滚动)
253
+ // Pointy告诉的光标屏幕坐标(物理像素),换算成页面坐标;算不准也没关系,鼠标一动就更新
254
+ const start = window.__pointyStart
255
+ let lastPoint = start
256
+ ? { x: start.x / devicePixelRatio - screenX - (outerWidth - innerWidth) / 2, y: start.y / devicePixelRatio - screenY - (outerHeight - innerHeight) }
257
+ : { x: innerWidth / 2, y: innerHeight / 2 }
258
+
259
+ const current = () => chain[Math.min(level, chain.length - 1)]
260
+ let shown = false
261
+ function render() {
262
+ const el = current()
263
+ if (!el) { box.style.opacity = '0'; shown = false; return }
264
+ const r = el.getBoundingClientRect()
265
+ // 第一次出现直接放到位再淡入,不从左上角飞过来
266
+ if (!shown) box.style.transition = 'opacity 120ms ease-out'
267
+ Object.assign(box.style, { transform: `translate(${r.left - 3}px, ${r.top - 3}px)`, width: `${r.width + 6}px`, height: `${r.height + 6}px`, opacity: '1' })
268
+ if (!shown) {
269
+ shown = true
270
+ box.getBoundingClientRect()
271
+ box.style.transition = `transform ${EASE}, width ${EASE}, height ${EASE}, opacity 120ms ease-out`
272
+ }
273
+ }
274
+ function retarget(x, y) {
275
+ chain = chainAt(x, y)
276
+ level = Math.min(levelUp, chain.length - 1)
277
+ render()
278
+ }
279
+ const setLevel = (n) => { level = Math.max(0, Math.min(chain.length - 1, n)); levelUp = level; render() }
280
+
281
+ function cleanup(keepBox) {
282
+ clearTimeout(giveUp)
283
+ chrome.runtime.onMessage?.removeListener(onAbort)
284
+ removeEventListener('mousemove', onMove, true)
285
+ removeEventListener('click', onClick, true)
286
+ removeEventListener('mousedown', onMouseDown, true)
287
+ removeEventListener('mouseup', swallow, true)
288
+ removeEventListener('keydown', onKey, true)
289
+ removeEventListener('contextmenu', onContextMenu, true)
290
+ removeEventListener('scroll', onScroll, true)
291
+ cancelAnimationFrame(moveFrame)
292
+ if (!keepBox) box.remove()
293
+ label.remove()
294
+ cursorStyle.remove()
295
+ window.__pointyPick = false
296
+ if (window.__pointyPickCleanup === stale) window.__pointyPickCleanup = undefined
297
+ }
298
+ const stale = () => cleanup()
299
+ window.__pointyPickCleanup = stale
300
+
301
+ /**
302
+ * 选中之后高亮框留在那个元素上,直到卡片关掉(Pointy发 pick-release 过来)。
303
+ * 先藏起来等扩展截完图,扩展发 pick-hold 再显示;页面滚动时跟着元素走。
304
+ */
305
+ function hold(el) {
306
+ const follow = () => {
307
+ const r = el.getBoundingClientRect()
308
+ Object.assign(box.style, { transform: `translate(${r.left - 3}px, ${r.top - 3}px)`, width: `${r.width + 6}px`, height: `${r.height + 6}px` })
309
+ }
310
+ let holdId // 扩展在 pick-hold 里告诉这次指认的 id,只认同一个 id 的 pick-release
311
+ const onMsg = (msg) => {
312
+ if (msg?.type === 'pick-hold') { holdId = msg.reqId; follow(); box.style.transition = 'opacity 120ms ease-out'; box.style.opacity = '1' }
313
+ else if (msg?.type === 'pick-release' && msg.reqId === holdId) release()
314
+ }
315
+ const release = () => {
316
+ removeEventListener('scroll', follow, true)
317
+ removeEventListener('resize', follow)
318
+ chrome.runtime.onMessage?.removeListener(onMsg)
319
+ clearTimeout(expire)
320
+ box.remove()
321
+ if (window.__pointyHeld === release) window.__pointyHeld = undefined
322
+ }
323
+ // 万一Pointy那边没通知,5 分钟后自己撤掉
324
+ const expire = setTimeout(release, 5 * 60 * 1000)
325
+ addEventListener('scroll', follow, true)
326
+ addEventListener('resize', follow)
327
+ chrome.runtime.onMessage?.addListener(onMsg)
328
+ window.__pointyHeld = release
329
+ }
330
+
331
+ // 一帧最多算一次,鼠标甩得快也不卡
332
+ let moveFrame = 0
333
+ const onMove = (e) => {
334
+ lastPoint = { x: e.clientX, y: e.clientY }
335
+ if (moveFrame) return
336
+ moveFrame = requestAnimationFrame(() => {
337
+ moveFrame = 0
338
+ retarget(lastPoint.x, lastPoint.y)
339
+ })
340
+ }
341
+ const swallow = (e) => { e.preventDefault(); e.stopPropagation() }
342
+ // 右键按下就取消;紧跟着的那一次右键菜单吃掉(之后右键照常)
343
+ function onMouseDown(e) {
344
+ swallow(e)
345
+ if (e.button !== 2) return
346
+ const eat = (ev) => { ev.preventDefault(); ev.stopPropagation(); removeEventListener('contextmenu', eat, true) }
347
+ addEventListener('contextmenu', eat, true)
348
+ setTimeout(() => removeEventListener('contextmenu', eat, true), 1500)
349
+ cancel(e)
350
+ }
351
+ function onClick(e) {
352
+ swallow(e)
353
+ const el = current()
354
+ cleanup(!!el)
355
+ if (!el) return chrome.runtime.sendMessage({ type: 'pick-result', cancelled: true })
356
+ const result = describe(el)
357
+ // 高亮框先藏起来,等两帧再让扩展截图,免得截进去;截完扩展会发 pick-hold 让它回来
358
+ box.style.transition = 'none'
359
+ box.style.opacity = '0'
360
+ hold(el)
361
+ requestAnimationFrame(() => requestAnimationFrame(() => chrome.runtime.sendMessage({ type: 'pick-result', result })))
362
+ }
363
+ function cancel(e) {
364
+ swallow(e)
365
+ cleanup()
366
+ chrome.runtime.sendMessage({ type: 'pick-result', cancelled: true })
367
+ }
368
+ // 右键 = 取消,同时不让网页弹右键菜单
369
+ const onContextMenu = (e) => cancel(e)
370
+ function onKey(e) {
371
+ if (e.key === 'Escape') cancel(e)
372
+ else if (e.key === '[') { swallow(e); setLevel(level - 1) }
373
+ else if (e.key === ']') { swallow(e); setLevel(level + 1) }
374
+ }
375
+ // 页面滚动时光标下的东西变了,重新算一下高亮
376
+ const onScroll = () => retarget(lastPoint.x, lastPoint.y)
377
+
378
+ // 没人理就自己撤掉,免得一直拦着这个页面的滚轮和点击;Pointy那边超时也会发 pick-abort 过来
379
+ const giveUp = setTimeout(() => { cleanup(); chrome.runtime.sendMessage({ type: 'pick-result', cancelled: true }) }, 45_000)
380
+ const onAbort = (msg) => { if (msg?.type === 'pick-abort') cleanup() }
381
+ chrome.runtime.onMessage?.addListener(onAbort)
382
+
383
+ addEventListener('mousemove', onMove, true)
384
+ addEventListener('click', onClick, true)
385
+ addEventListener('mousedown', onMouseDown, true)
386
+ addEventListener('mouseup', swallow, true)
387
+ addEventListener('keydown', onKey, true)
388
+ addEventListener('contextmenu', onContextMenu, true)
389
+ addEventListener('scroll', onScroll, true)
390
+ retarget(lastPoint.x, lastPoint.y)
391
+ })()
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>
6
+ body { width: 240px; margin: 12px; font: 13px "Microsoft YaHei UI", sans-serif; color: #2b2b2b; }
7
+ input { width: 100%; box-sizing: border-box; padding: 6px; font-size: 18px; letter-spacing: 4px; text-align: center; }
8
+ button { margin-top: 8px; width: 100%; padding: 6px; cursor: pointer; }
9
+ .muted { color: #8a7a62; font-size: 12px; }
10
+ #msg { margin-top: 6px; min-height: 1em; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <div id="status">检查中……</div>
15
+ <div id="pair-box" hidden>
16
+ <p class="muted">点下面的按钮,屏幕右下角 Pointy 会问要不要允许连接,点「允许」。</p>
17
+ <button id="pair" type="button">连接 Pointy</button>
18
+ </div>
19
+ <button id="unpair" type="button" hidden>取消配对</button>
20
+ <div id="msg"></div>
21
+ <details>
22
+ <summary class="muted">高级</summary>
23
+ <label class="muted">Pointy 端口(设了 POINTY_PORT 才需要改)
24
+ <input id="port" inputmode="numeric" style="font-size:13px;letter-spacing:0;text-align:left">
25
+ </label>
26
+ </details>
27
+ <script src="popup.js"></script>
28
+ </body>
29
+ </html>
@@ -0,0 +1,30 @@
1
+ const $ = (id) => document.getElementById(id)
2
+
3
+ async function refresh() {
4
+ const s = await chrome.runtime.sendMessage({ type: 'status' })
5
+ $('status').textContent = !s.paired ? '还没配对' : s.connected ? '已连接 Pointy' : '已配对,Pointy 没在运行'
6
+ $('pair-box').hidden = s.paired
7
+ $('unpair').hidden = !s.paired
8
+ }
9
+
10
+ $('pair').addEventListener('click', async () => {
11
+ $('msg').textContent = '去 Pointy 那边点「允许」……'
12
+ const r = await chrome.runtime.sendMessage({ type: 'pair' })
13
+ $('msg').textContent = r.ok ? '已连接' : r.error
14
+ setTimeout(refresh, 800)
15
+ })
16
+
17
+ $('unpair').addEventListener('click', async () => {
18
+ await chrome.runtime.sendMessage({ type: 'unpair' })
19
+ refresh()
20
+ })
21
+
22
+ chrome.storage.local.get('port').then(({ port }) => { $('port').value = port ?? 47321 })
23
+ $('port').addEventListener('change', async () => {
24
+ const port = Number($('port').value)
25
+ if (Number.isInteger(port) && port > 0 && port < 65536) await chrome.storage.local.set({ port })
26
+ else await chrome.storage.local.remove('port')
27
+ $('msg').textContent = '端口已保存'
28
+ })
29
+
30
+ refresh()
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "pointy",
3
+ "private": true,
4
+ "main": "boot.js",
5
+ "pointy": {
6
+ "electron": {
7
+ "version": "44.10.1",
8
+ "sha256": "5099bdc11c6a78edff35851491c1f8228a1c2e844e1b376eee219402c42c6011"
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (C) 2025 Niels Martignène <niels.martignene@protonmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the “Software”), to deal in
7
+ the Software without restriction, including without limitation the rights to use,
8
+ copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
9
+ Software, and to permit persons to whom the Software is furnished to do so,
10
+ 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,
16
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.