@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.
- package/ASSETS.md +20 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/cordis.patch.yml +13 -0
- package/lib/client.js +33 -0
- package/package.json +38 -4
- package/pet/app/card/card.css +365 -0
- package/pet/app/card/card.html +68 -0
- package/pet/app/card/card.js +896 -0
- package/pet/app/card/markdown.js +64 -0
- package/pet/app/card/mascot/blink.png +0 -0
- package/pet/app/card/mascot/done.png +0 -0
- package/pet/app/card/mascot/error.png +0 -0
- package/pet/app/card/mascot/idle.png +0 -0
- package/pet/app/card/mascot/peek.png +0 -0
- package/pet/app/card/mascot/think.png +0 -0
- package/pet/app/card/preload.js +21 -0
- package/pet/app/fullscreen.js +131 -0
- package/pet/app/main.js +1284 -0
- package/pet/app/native/uia-selection.ps1 +158 -0
- package/pet/app/notice/notice.html +37 -0
- package/pet/app/notice/notice.js +31 -0
- package/pet/app/notice/preload.js +7 -0
- package/pet/app/overlay/overlay.html +30 -0
- package/pet/app/overlay/overlay.js +42 -0
- package/pet/app/overlay/preload.js +7 -0
- package/pet/app/pill/pill.css +73 -0
- package/pet/app/pill/pill.html +11 -0
- package/pet/app/pill/pill.js +35 -0
- package/pet/app/pill/preload.js +8 -0
- package/pet/app/preload.js +16 -0
- package/pet/app/renderer/index.html +24 -0
- package/pet/app/renderer/lines.js +25 -0
- package/pet/app/renderer/pet.js +292 -0
- package/pet/app/renderer/renderer2d.js +71 -0
- package/pet/app/renderer/style.css +157 -0
- package/pet/app/selection.js +246 -0
- package/pet/app/shake.js +76 -0
- package/pet/app/uipick.js +314 -0
- package/pet/assets/cursor/cursor-32.cur +0 -0
- package/pet/assets/cursor/cursor-32.png +0 -0
- package/pet/assets/cursor/cursor-48.cur +0 -0
- package/pet/assets/cursor/cursor-48.png +0 -0
- package/pet/assets/cursor/cursor-64.cur +0 -0
- package/pet/assets/cursor/cursor-64.png +0 -0
- package/pet/assets/cursor/cursor-clean.png +0 -0
- package/pet/assets/cursor/make_cursor.py +85 -0
- package/pet/assets/cursor/source.png +0 -0
- package/pet/assets/pointy.ico +0 -0
- package/pet/assets/tray.png +0 -0
- package/pet/assets/tray@2x.png +0 -0
- package/pet/boot.js +6 -0
- package/pet/extension/background.js +252 -0
- package/pet/extension/cursor-32.png +0 -0
- package/pet/extension/cursor-48.png +0 -0
- package/pet/extension/cursor-64.png +0 -0
- package/pet/extension/icon.png +0 -0
- package/pet/extension/manifest.json +12 -0
- package/pet/extension/pick.js +391 -0
- package/pet/extension/popup.html +29 -0
- package/pet/extension/popup.js +30 -0
- package/pet/package.json +11 -0
- package/pet/vendor/koffi/LICENSE.txt +22 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.exp +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.lib +0 -0
- package/pet/vendor/koffi/build/koffi/win32_x64/koffi.node +0 -0
- package/pet/vendor/koffi/index.js +634 -0
- package/pet/vendor/koffi/indirect.js +533 -0
- package/pet/vendor/koffi/package.json +35 -0
- package/pet/vendor/ws/LICENSE +20 -0
- package/pet/vendor/ws/index.js +22 -0
- package/pet/vendor/ws/lib/buffer-util.js +142 -0
- package/pet/vendor/ws/lib/constants.js +19 -0
- package/pet/vendor/ws/lib/event-target.js +292 -0
- package/pet/vendor/ws/lib/extension.js +203 -0
- package/pet/vendor/ws/lib/limiter.js +55 -0
- package/pet/vendor/ws/lib/permessage-deflate.js +530 -0
- package/pet/vendor/ws/lib/receiver.js +743 -0
- package/pet/vendor/ws/lib/sender.js +610 -0
- package/pet/vendor/ws/lib/stream.js +161 -0
- package/pet/vendor/ws/lib/subprotocol.js +62 -0
- package/pet/vendor/ws/lib/validation.js +152 -0
- package/pet/vendor/ws/lib/websocket-server.js +562 -0
- package/pet/vendor/ws/lib/websocket.js +1426 -0
- package/pet/vendor/ws/package.json +74 -0
- package/pet/vendor/ws/wrapper.mjs +21 -0
- package/src/node.js +732 -0
- 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()
|
package/pet/package.json
ADDED
|
@@ -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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|