dsh-spectacles 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,392 @@
1
+ /**
2
+ * dsh-spectacles(砚明的金丝眼镜)—— 浏览器半端:一枚「眼镜」按钮 + 一个框选小按钮。
3
+ *
4
+ * 没戴 = 裸眼:看不清、容易读错。
5
+ * 点一下 → 弹提示「请点击要指定的窗口」→ 人点窗口 → 变绿「拿下眼镜」,副行写「金丝 · 微信 · 1296×828」
6
+ * 再点一下 → 摘下 → 提示「眼镜摘下来了」
7
+ * 旁边 ✥ → 屏幕上拖一个框,只读这一块 → 副行写「金丝 · 微信 · 320×180」
8
+ * 拖动 → 挪到任意位置,松手记住(localStorage),下次还在那儿。
9
+ *
10
+ * 样式照桌宠的气泡来:圆角、半透明白、柔和阴影。
11
+ * 不依赖 slots,直接挂 DOM,免得 slot 名字变了就整块消失。
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: 'dsh-spectacles',
15
+ factory: () => {
16
+ const API_STATE = '/dsh-look/state'
17
+ const API_PICK = '/dsh-look/pick'
18
+ const API_REGION = '/dsh-look/region'
19
+ const API_PING = '/dsh-look/ping'
20
+ const POS_KEY = 'ym-look-pos-v1'
21
+
22
+ // 探针:把「渲染层跑没跑、点了没有」写进插件日志,出问题一眼能看出来
23
+ function ping(what) {
24
+ try {
25
+ fetch(API_PING, { method: 'POST', body: String(what || ''), keepalive: true }).catch(() => {})
26
+ } catch {
27
+ /* 探针失败不影响正事 */
28
+ }
29
+ }
30
+
31
+ const CSS = `
32
+ .ym-look-wrap {
33
+ position: fixed; right: 22px; bottom: 118px; z-index: 2147483000;
34
+ display: inline-flex; align-items: center; gap: 8px;
35
+ pointer-events: auto;
36
+ cursor: grab; user-select: none; touch-action: none;
37
+ }
38
+ .ym-look-wrap.dragging { cursor: grabbing; }
39
+ .ym-look-btn {
40
+ display: inline-flex; align-items: center; gap: 9px;
41
+ max-width: 300px;
42
+ padding: 9px 17px 10px 14px; border-radius: 999px;
43
+ font: 600 15px/1 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
44
+ letter-spacing: .2px; text-align: left;
45
+ color: #2b3440; background: rgba(255,255,255,.93);
46
+ border: 1px solid rgba(30,40,55,.10);
47
+ box-shadow: 0 8px 22px rgba(20,30,45,.16), 0 1px 0 rgba(255,255,255,.7) inset;
48
+ backdrop-filter: blur(10px);
49
+ cursor: pointer;
50
+ transition: background .15s ease, color .15s ease, box-shadow .15s ease;
51
+ }
52
+ .ym-look-btn:hover { box-shadow: 0 10px 26px rgba(20,30,45,.20), 0 1px 0 rgba(255,255,255,.7) inset; }
53
+ .ym-look-btn.on {
54
+ color: #0d5b3f; background: rgba(226,248,236,.96);
55
+ border-color: rgba(22,120,80,.22);
56
+ box-shadow: 0 8px 22px rgba(16,90,60,.18), 0 1px 0 rgba(255,255,255,.7) inset;
57
+ }
58
+ .ym-look-dot {
59
+ width: 9px; height: 9px; border-radius: 50%;
60
+ background: #b3bcc8; box-shadow: 0 0 0 3px rgba(179,188,200,.18);
61
+ flex: none;
62
+ }
63
+ .ym-look-btn.on .ym-look-dot { background: #17a56a; box-shadow: 0 0 0 3px rgba(23,165,106,.18); }
64
+ .ym-look-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
65
+ .ym-look-label {
66
+ display: block; max-width: 240px;
67
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
68
+ }
69
+ .ym-look-sub {
70
+ display: none; max-width: 240px;
71
+ font: 500 11.5px/1 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
72
+ letter-spacing: 0; color: #4d7f68;
73
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
74
+ }
75
+ .ym-look-btn.on .ym-look-sub { display: block; }
76
+
77
+ .ym-look-region {
78
+ width: 30px; height: 30px; flex: none;
79
+ display: inline-flex; align-items: center; justify-content: center;
80
+ border-radius: 50%; padding: 0;
81
+ font: 600 15px/1 -apple-system, "Segoe UI", sans-serif;
82
+ color: #4d5a68; background: rgba(255,255,255,.93);
83
+ border: 1px solid rgba(30,40,55,.10);
84
+ box-shadow: 0 6px 16px rgba(20,30,45,.14);
85
+ cursor: pointer;
86
+ transition: background .15s ease, color .15s ease;
87
+ }
88
+ .ym-look-region:hover { color: #0d5b3f; background: rgba(226,248,236,.96); }
89
+
90
+ .ym-look-toast {
91
+ position: fixed; left: 50%; bottom: 152px; transform: translateX(-50%) translateY(6px);
92
+ z-index: 2147483001; max-width: 62vw;
93
+ padding: 10px 18px 11px; border-radius: 16px;
94
+ font: 500 13.5px/1.35 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
95
+ color: #26303b; background: rgba(255,255,255,.96);
96
+ border: 1px solid rgba(30,40,55,.10);
97
+ box-shadow: 0 10px 28px rgba(18,28,42,.18);
98
+ opacity: 0; pointer-events: none;
99
+ transition: opacity .18s ease, transform .18s ease;
100
+ }
101
+ .ym-look-toast::after {
102
+ content: ""; position: absolute; left: 50%; bottom: -6px; width: 12px; height: 12px;
103
+ margin-left: -6px; background: rgba(255,255,255,.96);
104
+ border-right: 1px solid rgba(30,40,55,.10); border-bottom: 1px solid rgba(30,40,55,.10);
105
+ transform: rotate(45deg); border-radius: 0 0 3px 0;
106
+ }
107
+ .ym-look-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
108
+ `
109
+
110
+ let styleEl = null
111
+ let wrap = null
112
+ let btn = null
113
+ let regionBtn = null
114
+ let subEl = null
115
+ let toastEl = null
116
+ let toastTimer = null
117
+ let state = { on: false, title: null, rect: null, crop: null, size: null }
118
+ let busy = false
119
+ let suppressClick = false
120
+
121
+ function toast(text, ms) {
122
+ if (!toastEl) return
123
+ toastEl.textContent = text
124
+ toastEl.classList.add('show')
125
+ if (toastTimer) clearTimeout(toastTimer)
126
+ toastTimer = setTimeout(() => toastEl.classList.remove('show'), ms || 2600)
127
+ }
128
+
129
+ function shortTitle(t) {
130
+ const s = String(t || '').replace(/\s+/g, ' ').trim()
131
+ if (!s) return '已指定'
132
+ return s.length > 14 ? s.slice(0, 14) + '…' : s
133
+ }
134
+
135
+ /** 这副眼镜现在读多大一块:框过就是框的大小,没框就是整个窗口。 */
136
+ function sizeText(from) {
137
+ const s = from || state
138
+ const sz = Array.isArray(s.size) ? s.size : s.crop ? [s.crop[2], s.crop[3]] : s.rect ? [s.rect[2], s.rect[3]] : null
139
+ if (!sz || !Number(sz[0]) || !Number(sz[1])) return ''
140
+ return Math.round(sz[0]) + '×' + Math.round(sz[1])
141
+ }
142
+
143
+ function paint() {
144
+ if (!btn) return
145
+ btn.classList.toggle('on', !!state.on)
146
+ btn.querySelector('.ym-look-label').textContent = state.on ? '拿下眼镜' : '戴上眼镜'
147
+ if (subEl) {
148
+ const sz = sizeText()
149
+ subEl.textContent = '金丝 · ' + shortTitle(state.title) + (sz ? ' · ' + sz : '')
150
+ }
151
+ const full = state.title || '已指定'
152
+ const sz = sizeText()
153
+ btn.title = state.on
154
+ ? '正戴着:' + full + (sz ? '(' + sz + ')' : '') + (state.crop ? ' · 只读框住的这一块' : '') + '\n点一下拿下 · 拖到任意位置'
155
+ : '点一下,再点你要 AI 看的窗口;点 ✥ 可以只框一块\n拖到任意位置'
156
+ }
157
+
158
+ // ---- 位置:拖到哪记哪 ----
159
+ function clampPos(x, y) {
160
+ const r = wrap.getBoundingClientRect()
161
+ const maxX = Math.max(0, window.innerWidth - r.width - 2)
162
+ const maxY = Math.max(0, window.innerHeight - r.height - 2)
163
+ return { x: Math.min(Math.max(0, x), maxX), y: Math.min(Math.max(0, y), maxY) }
164
+ }
165
+
166
+ function place(x, y, save) {
167
+ if (!wrap) return
168
+ const p = clampPos(x, y)
169
+ wrap.style.left = p.x + 'px'
170
+ wrap.style.top = p.y + 'px'
171
+ wrap.style.right = 'auto'
172
+ wrap.style.bottom = 'auto'
173
+ if (save) {
174
+ try {
175
+ localStorage.setItem(POS_KEY, JSON.stringify(p))
176
+ } catch {
177
+ /* 存不上就算了,下次回默认位 */
178
+ }
179
+ }
180
+ }
181
+
182
+ function restorePos() {
183
+ let p = null
184
+ try {
185
+ p = JSON.parse(localStorage.getItem(POS_KEY) || 'null')
186
+ } catch {
187
+ p = null
188
+ }
189
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) place(p.x, p.y, false)
190
+ else place(window.innerWidth - 22 - 140, window.innerHeight - 118 - 42, false)
191
+ }
192
+
193
+ function makeDraggable() {
194
+ let down = null
195
+ wrap.addEventListener('pointerdown', (e) => {
196
+ if (e.button !== 0) return
197
+ const r = wrap.getBoundingClientRect()
198
+ // 按钮上按下也允许拖:只是这一刻先不抓指针。
199
+ // 抓了指针,浏览器就把后面的 click 记到 wrap 头上,按钮永远收不到点击
200
+ // (之前「点了没反应」就是这么来的)。
201
+ down = { px: e.clientX, py: e.clientY, x: r.left, y: r.top, moved: false, id: e.pointerId }
202
+ })
203
+ wrap.addEventListener('pointermove', (e) => {
204
+ if (!down || e.pointerId !== down.id) return
205
+ const dx = e.clientX - down.px
206
+ const dy = e.clientY - down.py
207
+ if (!down.moved && Math.abs(dx) + Math.abs(dy) < 4) return
208
+ if (!down.moved) {
209
+ down.moved = true
210
+ // 真的在拖了才抓指针,普通点击不受影响
211
+ try {
212
+ wrap.setPointerCapture(e.pointerId)
213
+ } catch {
214
+ /* 拿不到也能拖 */
215
+ }
216
+ }
217
+ wrap.classList.add('dragging')
218
+ place(down.x + dx, down.y + dy, false)
219
+ })
220
+ const end = (e) => {
221
+ if (!down || e.pointerId !== down.id) return
222
+ const moved = down.moved
223
+ down = null
224
+ wrap.classList.remove('dragging')
225
+ try {
226
+ wrap.releasePointerCapture(e.pointerId)
227
+ } catch {
228
+ /* 已释放 */
229
+ }
230
+ if (moved) {
231
+ const r = wrap.getBoundingClientRect()
232
+ place(r.left, r.top, true)
233
+ suppressClick = true
234
+ setTimeout(() => {
235
+ suppressClick = false
236
+ }, 0)
237
+ }
238
+ }
239
+ wrap.addEventListener('pointerup', end)
240
+ wrap.addEventListener('pointercancel', end)
241
+ window.addEventListener('resize', () => {
242
+ const r = wrap.getBoundingClientRect()
243
+ place(r.left, r.top, false)
244
+ })
245
+ }
246
+
247
+ function adopt(j) {
248
+ state = {
249
+ on: !!j.on,
250
+ title: j.title || null,
251
+ rect: Array.isArray(j.rect) ? j.rect : null,
252
+ crop: Array.isArray(j.crop) ? j.crop : null,
253
+ size: Array.isArray(j.size) ? j.size : null,
254
+ }
255
+ paint()
256
+ }
257
+
258
+ async function refresh() {
259
+ try {
260
+ const r = await fetch(API_STATE, { cache: 'no-store' })
261
+ adopt(await r.json())
262
+ } catch {
263
+ /* 宿主还没起来就下次再刷 */
264
+ }
265
+ }
266
+
267
+ async function toggle() {
268
+ ping('toggle busy=' + busy + ' on=' + state.on)
269
+ if (busy) return
270
+ busy = true
271
+ if (!state.on) toast('请点击要戴上看清的那个窗口(30 秒内)', 3200)
272
+ try {
273
+ const r = await fetch(API_PICK, { method: 'POST' })
274
+ const j = await r.json()
275
+ if (j.on) {
276
+ adopt(j)
277
+ toast('眼镜戴上了:' + (j.title || '已指定') + (sizeText(j) ? ' · ' + sizeText(j) : ''))
278
+ } else {
279
+ adopt({ on: false })
280
+ toast(j.error ? '没戴上(超时或没点窗口)' : '眼镜摘下来了')
281
+ }
282
+ } catch (e) {
283
+ toast('眼镜切换失败:' + (e && e.message ? e.message : e))
284
+ await refresh()
285
+ } finally {
286
+ busy = false
287
+ }
288
+ }
289
+
290
+ async function pickRegion() {
291
+ if (busy) return
292
+ busy = true
293
+ toast('在屏幕上拖一个框(Esc 取消)', 3200)
294
+ try {
295
+ const r = await fetch(API_REGION, { method: 'POST' })
296
+ const j = await r.json()
297
+ if (j.on) {
298
+ adopt(j)
299
+ toast('已框选 ' + sizeText(j) + ':' + (j.title || '已指定'))
300
+ } else {
301
+ toast('没框成:' + (j.error || '取消了'))
302
+ await refresh()
303
+ }
304
+ } catch (e) {
305
+ toast('框选失败:' + (e && e.message ? e.message : e))
306
+ await refresh()
307
+ } finally {
308
+ busy = false
309
+ }
310
+ }
311
+
312
+ return {
313
+ inject: [],
314
+ apply(ctx) {
315
+ if (document.querySelector('.ym-look-wrap')) return
316
+ styleEl = document.createElement('style')
317
+ styleEl.setAttribute('data-plugin-css', 'dsh-look')
318
+ styleEl.textContent = CSS
319
+ document.head.appendChild(styleEl)
320
+
321
+ wrap = document.createElement('div')
322
+ wrap.className = 'ym-look-wrap'
323
+
324
+ btn = document.createElement('button')
325
+ btn.className = 'ym-look-btn'
326
+ btn.innerHTML =
327
+ '<span class="ym-look-dot"></span>' +
328
+ '<span class="ym-look-text">' +
329
+ '<span class="ym-look-label">戴上眼镜</span>' +
330
+ '<span class="ym-look-sub"></span>' +
331
+ '</span>'
332
+ subEl = btn.querySelector('.ym-look-sub')
333
+ btn.addEventListener('click', () => {
334
+ if (suppressClick) return
335
+ toggle()
336
+ })
337
+
338
+ regionBtn = document.createElement('button')
339
+ regionBtn.className = 'ym-look-region'
340
+ regionBtn.textContent = '✥'
341
+ regionBtn.title = '框一块:只读你框住的那一块'
342
+ regionBtn.addEventListener('click', (e) => {
343
+ e.stopPropagation()
344
+ if (suppressClick) return
345
+ pickRegion()
346
+ })
347
+
348
+ wrap.appendChild(btn)
349
+ wrap.appendChild(regionBtn)
350
+ document.body.appendChild(wrap)
351
+ try {
352
+ restorePos()
353
+ } catch {
354
+ /* 布局没好就下一帧再来 */
355
+ requestAnimationFrame(() => {
356
+ try {
357
+ restorePos()
358
+ } catch {
359
+ /* 放弃,用默认位 */
360
+ }
361
+ })
362
+ }
363
+ makeDraggable()
364
+
365
+ // 按钮万一被宿主别的东西盖住,快捷键照样能切:Ctrl+Shift+L
366
+ const onKey = (e) => {
367
+ if (!e.ctrlKey || !e.shiftKey) return
368
+ if (String(e.key || '').toLowerCase() !== 'l') return
369
+ e.preventDefault()
370
+ toggle()
371
+ }
372
+ window.addEventListener('keydown', onKey)
373
+
374
+ toastEl = document.createElement('div')
375
+ toastEl.className = 'ym-look-toast'
376
+ document.body.appendChild(toastEl)
377
+
378
+ refresh()
379
+ ping('client loaded v1.1.1')
380
+ const t = setInterval(refresh, 5000)
381
+
382
+ ctx.effect(() => () => {
383
+ clearInterval(t)
384
+ window.removeEventListener('keydown', onKey)
385
+ if (styleEl) styleEl.remove()
386
+ if (wrap) wrap.remove()
387
+ if (toastEl) toastEl.remove()
388
+ }, 'dsh-look: 读屏按钮')
389
+ },
390
+ }
391
+ },
392
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: spectacles
3
+ name: dsh-spectacles