@marver-design/marver 0.5.0 → 0.7.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/CHANGELOG.md +94 -0
- package/dist/{build-BrCl9hJS.mjs → build-BZaPa2DS.mjs} +7 -3
- package/dist/cli.mjs +2 -2
- package/dist/{dev-DdeU-Jst.mjs → dev-DaPQ9xA5.mjs} +1 -1
- package/dist/{plugin-BtSGAm2h.mjs → plugin-wMY9lNf3.mjs} +12 -1
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +107 -0
- package/src/client/content/index.tsx +31 -7
- package/src/client/frame-host/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +346 -0
- package/src/client/shell/App.tsx +39 -115
- package/src/client/shell/Comments.tsx +56 -11
- package/src/client/shell/Play.tsx +214 -155
- package/src/client/shell/Toolbar.tsx +189 -0
- package/src/client/shell/canvas/Canvas.tsx +4 -2
- package/src/client/shell/canvas/FrameNode.tsx +32 -2
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +32 -16
- package/src/client/shell/styles.css +63 -10
- package/src/client/stage/main.tsx +35 -11
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/shape.md +21 -19
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// Shared frame bridge. TSX frames import it plainly; the plugin injects it into HTML frames
|
|
2
2
|
// with ?html=1, which turns on theme-from-query and auto-ready (TSX posts its own ready after boot).
|
|
3
|
+
//
|
|
4
|
+
// The laser / comment-pick / anchor / highlight machinery lives in ./inspect.js and is
|
|
5
|
+
// shared with the prototype stage; this bridge owns the canvas-frame concerns around it:
|
|
6
|
+
// theme, wheel ownership, the interaction lease, data-goto, and error reporting.
|
|
7
|
+
import { createInspect } from './inspect.js'
|
|
8
|
+
|
|
3
9
|
const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
|
|
4
|
-
|
|
10
|
+
// same-origin parent (the shell): a fixed target origin keeps posts (incl. picked anchor
|
|
11
|
+
// bundles) from leaking if this frame is ever navigated cross-origin
|
|
12
|
+
const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) }
|
|
5
13
|
const id = new URLSearchParams(location.search).get('id') ?? location.pathname
|
|
6
14
|
|
|
7
15
|
// SPEC-M5: the shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
|
|
@@ -24,10 +32,25 @@ if (isHtmlFrame) {
|
|
|
24
32
|
if (theme) setTheme(theme)
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
// A6: report transient laser/comment engagement (pointer inside this frame AND a mode on) so the
|
|
36
|
+
// shell leases the frame - a hot update to it then defers until the pointer leaves or the mode
|
|
37
|
+
// ends, instead of yanking the user mid-inspect/mid-comment.
|
|
38
|
+
let pointerInside = false
|
|
39
|
+
let interactiveOn = false // B0.2: set by sh:interactive - the play/interact target owns its own wheel
|
|
40
|
+
const reportInteraction = () => post({ type: 'sh:interaction', id, laser: pointerInside && inspect.isLaser(), comment: pointerInside && inspect.isPick() })
|
|
41
|
+
|
|
42
|
+
// laser / pick / anchor / highlight - one shared controller (see inspect.js). The id is
|
|
43
|
+
// static here (one iframe per frame); onModeChange re-reports the lease when a mode flips.
|
|
44
|
+
const inspect = createInspect({ post, getId: () => id, onModeChange: () => reportInteraction() })
|
|
45
|
+
|
|
46
|
+
document.addEventListener('mouseover', () => { if (!pointerInside) { pointerInside = true; reportInteraction() } })
|
|
47
|
+
document.addEventListener('mouseout', (e) => { if (!e.relatedTarget) { pointerInside = false; reportInteraction() } })
|
|
48
|
+
window.addEventListener('blur', () => { if (pointerInside) { pointerInside = false; reportInteraction() } })
|
|
49
|
+
|
|
27
50
|
document.addEventListener('click', (e) => {
|
|
28
51
|
// laser/comment mode owns every click - a goto link must not ALSO navigate
|
|
29
52
|
// (both handlers capture on document, so stopPropagation can't referee)
|
|
30
|
-
if (modeActive()) return
|
|
53
|
+
if (inspect.modeActive()) return
|
|
31
54
|
const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
|
|
32
55
|
if (!el) return
|
|
33
56
|
e.preventDefault()
|
|
@@ -43,7 +66,10 @@ window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:error', id
|
|
|
43
66
|
window.addEventListener('message', (e) => {
|
|
44
67
|
// commands come from the SHELL (parent) only - embedded app content must not flip the theme
|
|
45
68
|
if (e.source !== window.parent || window.parent === window) return
|
|
69
|
+
if (e.origin && e.origin !== location.origin) return // a navigated frame must not spoof commands
|
|
46
70
|
if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
|
|
71
|
+
// B0.2: interact/play target owns its own wheel; passive frames forward it to the canvas
|
|
72
|
+
if (e?.data?.type === 'sh:interactive') { interactiveOn = !!e.data.on }
|
|
47
73
|
})
|
|
48
74
|
|
|
49
75
|
if (isHtmlFrame) {
|
|
@@ -57,7 +83,6 @@ if (isHtmlFrame) {
|
|
|
57
83
|
// forward them to the shell, when interactive we leave them for the app (only blocking
|
|
58
84
|
// the browser's own ctrl/meta page pinch-zoom). preventing here is mandatory - the parent
|
|
59
85
|
// gets the forwarded message too late to cancel the iframe's own scroll.
|
|
60
|
-
let interactiveOn = false
|
|
61
86
|
window.addEventListener('wheel', (e) => {
|
|
62
87
|
if (interactiveOn) { if (e.ctrlKey || e.metaKey) e.preventDefault(); return }
|
|
63
88
|
e.preventDefault()
|
|
@@ -68,242 +93,3 @@ window.addEventListener('wheel', (e) => {
|
|
|
68
93
|
document.addEventListener('gesturestart', (e) => e.preventDefault())
|
|
69
94
|
// a nested scroll container hitting its boundary must not chain into the shell page
|
|
70
95
|
document.documentElement.style.overscrollBehavior = 'contain'
|
|
71
|
-
|
|
72
|
-
// ---- laser mode + element picking (SPEC-M3 §5, §7) ----------------------------------
|
|
73
|
-
// Laser: one injected stylesheet, outline only (zero layout shift), depth-based hue -
|
|
74
|
-
// each nesting level steps 60° around the wheel, cycling at 6. Picking: laser plus a
|
|
75
|
-
// click interception that captures the anchor bundle and posts it to the shell.
|
|
76
|
-
|
|
77
|
-
const LASER_ID = 'mv-laser-style'
|
|
78
|
-
// comment-mode cursor: the pin's teardrop in the comment green, duotone (dark rim,
|
|
79
|
-
// lighter inner) with a white halo ring so it pops on any content; hotspot at the tail
|
|
80
|
-
const PICK_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 256 256'%3E%3Cpath d='M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Z' fill='none' stroke='%23fff' stroke-width='40'/%3E%3Cpath d='M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Z' fill='%2334c759' stroke='%231f8a3d' stroke-width='12'/%3E%3Ccircle cx='138' cy='118' r='46' fill='%23fff' opacity='.32'/%3E%3C/svg%3E") 4 21, crosshair`
|
|
81
|
-
// laser-mode cursor: the crosshair reticle in accent blue with a white halo ring,
|
|
82
|
-
// hotspot dead center
|
|
83
|
-
const LASER_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 256 256'%3E%3Cg stroke='%23fff' stroke-width='46' fill='none'%3E%3Ccircle cx='128' cy='128' r='56'/%3E%3Cpath d='M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232' stroke-linecap='round'/%3E%3C/g%3E%3Cg stroke='%230088ff' stroke-width='20' fill='none'%3E%3Ccircle cx='128' cy='128' r='56'/%3E%3Cpath d='M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232' stroke-linecap='round'/%3E%3C/g%3E%3Ccircle cx='128' cy='128' r='16' fill='%230088ff' stroke='%23fff' stroke-width='8'/%3E%3C/svg%3E") 12 12, crosshair`
|
|
84
|
-
const laserCss = () => {
|
|
85
|
-
// depth via unrolled descendant combinators - CSS custom properties cannot cycle
|
|
86
|
-
let rules = 'body { --mv-hue: 0 }\n'
|
|
87
|
-
for (let d = 1; d <= 12; d++)
|
|
88
|
-
rules += `body ${'> * '.repeat(d)}{ --mv-hue: ${(d % 6) * 60} }\n`
|
|
89
|
-
// full rainbow only in laser mode; comment mode keeps just the hover highlight
|
|
90
|
-
// (understand what you'd click without the whole board shouting). The label and
|
|
91
|
-
// its children are chrome, never subjects - no outlines on them.
|
|
92
|
-
if (laserOn)
|
|
93
|
-
rules += 'body *:not(script):not(style):not(#mv-laser-label):not(#mv-laser-label *) { outline: 1px solid hsl(var(--mv-hue) 85% 55% / .75); outline-offset: -1px }\n'
|
|
94
|
-
// comment cursor wins when both modes are on (comment owns the click)
|
|
95
|
-
if (pickOn || laserOn)
|
|
96
|
-
rules += `body, body * { cursor: ${pickOn ? PICK_CURSOR : LASER_CURSOR} !important }\n`
|
|
97
|
-
return rules + `
|
|
98
|
-
body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-offset: -2px;
|
|
99
|
-
background-image: linear-gradient(hsl(var(--mv-hue) 95% 50% / .08), hsl(var(--mv-hue) 95% 50% / .08)) }
|
|
100
|
-
#mv-laser-label { position: fixed; z-index: 2147483647; pointer-events: none; outline: none !important;
|
|
101
|
-
display: flex; align-items: center; gap: 5px; width: max-content;
|
|
102
|
-
font: 600 10px -apple-system, system-ui, sans-serif; color: #fff; background: rgba(20, 20, 24, .92);
|
|
103
|
-
padding: 3px 7px; border-radius: 5px; max-width: 340px; white-space: nowrap; overflow: hidden;
|
|
104
|
-
text-overflow: ellipsis; letter-spacing: .01em }
|
|
105
|
-
#mv-laser-label svg { flex: none; display: block; outline: none !important }
|
|
106
|
-
#mv-laser-label.mv-copied { animation: mv-pop .22s cubic-bezier(.32, .72, .35, 1) }
|
|
107
|
-
@keyframes mv-pop { from { transform: scale(.85); opacity: .5 } }
|
|
108
|
-
`
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// filled clipboard with a check inside (Phosphor clipboard-fill + check): the label
|
|
112
|
-
// wears this while confirming a copy
|
|
113
|
-
const COPIED_HTML = `<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor"><path d="M200,32H163.74a47.92,47.92,0,0,0-71.48,0H56A16,16,0,0,0,40,48V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Zm-72,0a32,32,0,0,1,32,32H96A32,32,0,0,1,128,32Z"/><path d="M92 150l26 26 48-54" fill="none" stroke="rgba(20,20,24,.92)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Element path copied</span>`
|
|
114
|
-
|
|
115
|
-
let laserOn = false, pickOn = false, hoverEl = null, labelEl = null
|
|
116
|
-
const modeActive = () => laserOn || pickOn
|
|
117
|
-
|
|
118
|
-
// A6: report transient laser/comment engagement (pointer inside this frame AND a mode on) so the
|
|
119
|
-
// shell leases the frame - a hot update to it then defers until the pointer leaves or the mode
|
|
120
|
-
// ends, instead of yanking the user mid-inspect/mid-comment.
|
|
121
|
-
let pointerInside = false
|
|
122
|
-
const reportInteraction = () => post({ type: 'sh:interaction', id, laser: pointerInside && laserOn, comment: pointerInside && pickOn })
|
|
123
|
-
document.addEventListener('mouseover', () => { if (!pointerInside) { pointerInside = true; reportInteraction() } })
|
|
124
|
-
document.addEventListener('mouseout', (e) => { if (!e.relatedTarget) { pointerInside = false; reportInteraction() } })
|
|
125
|
-
window.addEventListener('blur', () => { if (pointerInside) { pointerInside = false; reportInteraction() } })
|
|
126
|
-
|
|
127
|
-
// laser and pick are independent looks over shared hover machinery: the stylesheet
|
|
128
|
-
// is regenerated on every flip so each mode contributes exactly its own rules
|
|
129
|
-
const applyModes = () => {
|
|
130
|
-
const cur = document.getElementById(LASER_ID)
|
|
131
|
-
if (!modeActive()) {
|
|
132
|
-
if (cur) cur.remove()
|
|
133
|
-
clearHover()
|
|
134
|
-
return
|
|
135
|
-
}
|
|
136
|
-
const s = cur ?? document.createElement('style')
|
|
137
|
-
s.id = LASER_ID
|
|
138
|
-
s.textContent = laserCss()
|
|
139
|
-
if (!cur) document.head.appendChild(s)
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
let copiedTimer = null
|
|
143
|
-
|
|
144
|
-
const clearHover = () => {
|
|
145
|
-
if (hoverEl) { delete hoverEl.dataset.mvHover; hoverEl = null }
|
|
146
|
-
if (labelEl) { labelEl.remove(); labelEl = null }
|
|
147
|
-
clearTimeout(copiedTimer)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// the shell confirmed the clipboard write - the hover label ITSELF says so (a
|
|
151
|
-
// corner toast is too far from where the eyes are), then reverts after 2s.
|
|
152
|
-
// The seq + element check keeps a slow ack from decorating a DIFFERENT label
|
|
153
|
-
// (pointer moved on, or laser was retoggled, before writeText resolved).
|
|
154
|
-
const showCopied = (seq) => {
|
|
155
|
-
if (seq !== copySeq || hoverEl !== copyEl) return
|
|
156
|
-
if (!labelEl) return
|
|
157
|
-
labelEl.classList.add('mv-copied')
|
|
158
|
-
labelEl.innerHTML = COPIED_HTML
|
|
159
|
-
clearTimeout(copiedTimer)
|
|
160
|
-
copiedTimer = setTimeout(() => {
|
|
161
|
-
if (!labelEl) return
|
|
162
|
-
labelEl.classList.remove('mv-copied')
|
|
163
|
-
if (hoverEl) labelEl.textContent = describe(hoverEl)
|
|
164
|
-
else { labelEl.remove(); labelEl = null }
|
|
165
|
-
}, 2000)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const describe = (el) => {
|
|
169
|
-
const tag = el.tagName.toLowerCase()
|
|
170
|
-
const loc = el.dataset.mvLoc
|
|
171
|
-
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
|
|
172
|
-
return loc ? `${tag} · ${loc}` : text ? `${tag} · “${text}”` : tag
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
document.addEventListener('mousemove', (e) => {
|
|
176
|
-
if (!modeActive()) return
|
|
177
|
-
const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
|
|
178
|
-
if (!el || el === hoverEl) { if (labelEl && hoverEl) place(labelEl, e); return }
|
|
179
|
-
clearHover()
|
|
180
|
-
hoverEl = el
|
|
181
|
-
el.dataset.mvHover = '1'
|
|
182
|
-
labelEl = document.createElement('div')
|
|
183
|
-
labelEl.id = 'mv-laser-label'
|
|
184
|
-
labelEl.textContent = describe(el)
|
|
185
|
-
document.body.appendChild(labelEl)
|
|
186
|
-
place(labelEl, e)
|
|
187
|
-
}, true)
|
|
188
|
-
// label rides to the RIGHT of the cursor, vertically centered on it - below-right
|
|
189
|
-
// hid the pointer's target; flips to the left when the right edge runs out
|
|
190
|
-
const place = (label, e) => {
|
|
191
|
-
const pad = 18
|
|
192
|
-
const w = label.offsetWidth, h = label.offsetHeight
|
|
193
|
-
let x = e.clientX + pad
|
|
194
|
-
if (x + w > innerWidth - 4) x = Math.max(4, e.clientX - pad - w)
|
|
195
|
-
label.style.left = x + 'px'
|
|
196
|
-
label.style.top = Math.min(Math.max(4, e.clientY - h / 2), innerHeight - h - 4) + 'px'
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// the anchor bundle (SPEC-M3 §5): every rung captured at pick time
|
|
200
|
-
const cssPath = (el) => {
|
|
201
|
-
const seg = []
|
|
202
|
-
for (let cur = el; cur && cur !== document.body; cur = cur.parentElement) {
|
|
203
|
-
if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
|
|
204
|
-
const tag = cur.tagName.toLowerCase()
|
|
205
|
-
let n = 1
|
|
206
|
-
for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
|
|
207
|
-
if (sib.tagName === cur.tagName) n++
|
|
208
|
-
seg.unshift(n > 1 ? `${tag}:nth-of-type(${n})` : tag)
|
|
209
|
-
}
|
|
210
|
-
return seg.join(' > ')
|
|
211
|
-
}
|
|
212
|
-
const anchorBundle = (el, e) => {
|
|
213
|
-
const r = el.getBoundingClientRect()
|
|
214
|
-
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
215
|
-
return {
|
|
216
|
-
el: {
|
|
217
|
-
semantics: {
|
|
218
|
-
tag: el.tagName.toLowerCase(),
|
|
219
|
-
role: el.getAttribute('role') ?? undefined,
|
|
220
|
-
ariaLabel: el.getAttribute('aria-label') ?? undefined,
|
|
221
|
-
testId: el.getAttribute('data-testid') ?? undefined,
|
|
222
|
-
quote: text.slice(0, 200) || undefined,
|
|
223
|
-
},
|
|
224
|
-
cssPath: cssPath(el),
|
|
225
|
-
source: el.dataset.mvLoc ?? undefined,
|
|
226
|
-
},
|
|
227
|
-
pos: {
|
|
228
|
-
fx: r.width ? Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) : .5,
|
|
229
|
-
fy: r.height ? Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)) : .5,
|
|
230
|
-
},
|
|
231
|
-
rect: { x: r.left, y: r.top, w: r.width, h: r.height }, // frame-viewport coords for the shell's pin math
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// every press is reported (tiny message, capture, nothing prevented): the shell
|
|
236
|
-
// uses it to close an open thread card when the click lands INSIDE a frame -
|
|
237
|
-
// shell-document listeners can never see those
|
|
238
|
-
document.addEventListener('pointerdown', () => post({ type: 'sh:frame-down', id }), true)
|
|
239
|
-
|
|
240
|
-
let copySeq = 0, copyEl = null
|
|
241
|
-
document.addEventListener('click', (e) => {
|
|
242
|
-
if (!modeActive()) return
|
|
243
|
-
e.preventDefault()
|
|
244
|
-
e.stopPropagation()
|
|
245
|
-
const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
|
|
246
|
-
if (!el) return
|
|
247
|
-
// comment mode wins when both are on; plain laser click hands the agent an
|
|
248
|
-
// exact address - frame file + css path (+ source loc when the build stamps one)
|
|
249
|
-
if (pickOn) post({ type: 'sh:picked', id, anchor: anchorBundle(el, e) })
|
|
250
|
-
else {
|
|
251
|
-
copyEl = el
|
|
252
|
-
post({ type: 'sh:laser-copy', id, seq: ++copySeq, path: cssPath(el), source: el.dataset.mvLoc ?? null })
|
|
253
|
-
}
|
|
254
|
-
}, true)
|
|
255
|
-
|
|
256
|
-
/** Resolve a stored anchor back to a rect (the ladder, §5): semantics-verified CSS
|
|
257
|
-
* path first, then testId, then a quote scan; null = orphan. Every check compares
|
|
258
|
-
* the FULL captured semantics - two textless buttons must not swap silently. */
|
|
259
|
-
const resolveAnchor = (anchor) => {
|
|
260
|
-
const want = anchor?.el?.semantics ?? {}
|
|
261
|
-
const match = (el) => {
|
|
262
|
-
if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
|
|
263
|
-
if (want.role && el.getAttribute('role') !== want.role) return false
|
|
264
|
-
if (want.ariaLabel && el.getAttribute('aria-label') !== want.ariaLabel) return false
|
|
265
|
-
if (want.testId && el.getAttribute('data-testid') !== want.testId) return false
|
|
266
|
-
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
267
|
-
if (want.quote && !(text.startsWith(want.quote.slice(0, 60)) || text.includes(want.quote.slice(0, 40)))) return false
|
|
268
|
-
return true
|
|
269
|
-
}
|
|
270
|
-
try {
|
|
271
|
-
const byPath = anchor?.el?.cssPath && document.querySelector(anchor.el.cssPath)
|
|
272
|
-
if (byPath && match(byPath)) return byPath
|
|
273
|
-
} catch { /* stale selector */ }
|
|
274
|
-
try {
|
|
275
|
-
if (want.testId) {
|
|
276
|
-
const el = document.querySelector(`[data-testid="${CSS.escape(want.testId)}"]`)
|
|
277
|
-
if (el && match(el)) return el
|
|
278
|
-
}
|
|
279
|
-
if (want.quote && typeof want.tag === 'string' && /^[a-z][a-z0-9-]*$/.test(want.tag)) {
|
|
280
|
-
for (const el of document.querySelectorAll(want.tag))
|
|
281
|
-
if (match(el)) return el
|
|
282
|
-
}
|
|
283
|
-
} catch { /* malformed semantics must not sink the whole batch */ }
|
|
284
|
-
return null
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
window.addEventListener('message', (e) => {
|
|
288
|
-
// commands come from the SHELL only - the parent window. Anything else (nested
|
|
289
|
-
// third-party iframes, a hijacked opener) is ignored.
|
|
290
|
-
if (e.source !== window.parent || window.parent === window) return
|
|
291
|
-
const m = e?.data
|
|
292
|
-
if (!m || typeof m !== 'object') return
|
|
293
|
-
// independent toggles - each mode contributes its own rules, applyModes composes
|
|
294
|
-
if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes(); reportInteraction() }
|
|
295
|
-
if (m.type === 'sh:pick') { pickOn = !!m.on; applyModes(); reportInteraction() }
|
|
296
|
-
// B0.2: interact/play target owns its own wheel; passive frames forward it to the canvas
|
|
297
|
-
if (m.type === 'sh:interactive') { interactiveOn = !!m.on }
|
|
298
|
-
if (m.type === 'sh:copy-ok') showCopied(m.seq)
|
|
299
|
-
if (m.type === 'sh:resolve-anchors' && Array.isArray(m.anchors)) {
|
|
300
|
-
const rects = m.anchors.slice(0, 200).map((a) => {
|
|
301
|
-
let el = null
|
|
302
|
-
try { el = resolveAnchor(a.anchor) } catch { /* one bad anchor, not the batch */ }
|
|
303
|
-
if (!el) return { key: a.key, orphan: true }
|
|
304
|
-
const r = el.getBoundingClientRect()
|
|
305
|
-
return { key: a.key, rect: { x: r.left, y: r.top, w: r.width, h: r.height } }
|
|
306
|
-
})
|
|
307
|
-
post({ type: 'sh:anchor-rects', id, rects })
|
|
308
|
-
}
|
|
309
|
-
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Types for inspect.js (kept as plain JS so it serves into frames like bridge.js).
|
|
2
|
+
export function createInspect(opts: {
|
|
3
|
+
post: (msg: Record<string, unknown>) => void
|
|
4
|
+
getId: () => string
|
|
5
|
+
onModeChange?: (laser: boolean, pick: boolean) => void
|
|
6
|
+
}): { modeActive: () => boolean; isLaser: () => boolean; isPick: () => boolean; dispose: () => void }
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Shared frame-inspect controller (SPEC-M3 §5/§7 + prototype-review #4/#5).
|
|
2
|
+
//
|
|
3
|
+
// Laser outlines, comment-pick, anchor capture/resolve, and a persistent element
|
|
4
|
+
// lock ("highlight") - the review machinery shared by the canvas frame bridge
|
|
5
|
+
// (bridge.js, one iframe per frame) AND the prototype stage (stage/main.tsx, one
|
|
6
|
+
// persistent tree that swaps frames in place). Keeping ONE source means the two
|
|
7
|
+
// surfaces never drift.
|
|
8
|
+
//
|
|
9
|
+
// The HOST owns wheel + navigation ownership, theme, and error wiring; inspect only
|
|
10
|
+
// touches laser/pick/anchor/highlight. It self-installs the pointer + message
|
|
11
|
+
// listeners and returns { modeActive } so the host can guard its own nav clicks.
|
|
12
|
+
//
|
|
13
|
+
// createInspect({ post, getId, onModeChange? })
|
|
14
|
+
// post(msg) send a message up to the shell (parent)
|
|
15
|
+
// getId() the frame id to stamp on posts (static on canvas, the
|
|
16
|
+
// current stage frame in play - it swaps without reload)
|
|
17
|
+
// onModeChange?() called after any laser/pick flip (the canvas bridge uses it
|
|
18
|
+
// to report its interaction lease; the stage ignores it)
|
|
19
|
+
//
|
|
20
|
+
// shell -> frame : sh:laser · sh:pick · sh:copy-ok · sh:resolve-anchors · sh:highlight-anchor
|
|
21
|
+
// frame -> shell : sh:frame-down · sh:picked · sh:laser-copy · sh:anchor-rects
|
|
22
|
+
|
|
23
|
+
// comment-mode cursor: the pin's teardrop in the comment green, duotone (dark rim,
|
|
24
|
+
// lighter inner) with a white halo ring so it pops on any content; hotspot at the tail
|
|
25
|
+
const PICK_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 256 256'%3E%3Cpath d='M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Z' fill='none' stroke='%23fff' stroke-width='40'/%3E%3Cpath d='M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Z' fill='%2334c759' stroke='%231f8a3d' stroke-width='12'/%3E%3Ccircle cx='138' cy='118' r='46' fill='%23fff' opacity='.32'/%3E%3C/svg%3E") 4 21, crosshair`
|
|
26
|
+
// laser-mode cursor: the crosshair reticle in accent blue with a white halo ring,
|
|
27
|
+
// hotspot dead center
|
|
28
|
+
const LASER_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 256 256'%3E%3Cg stroke='%23fff' stroke-width='46' fill='none'%3E%3Ccircle cx='128' cy='128' r='56'/%3E%3Cpath d='M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232' stroke-linecap='round'/%3E%3C/g%3E%3Cg stroke='%230088ff' stroke-width='20' fill='none'%3E%3Ccircle cx='128' cy='128' r='56'/%3E%3Cpath d='M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232' stroke-linecap='round'/%3E%3C/g%3E%3Ccircle cx='128' cy='128' r='16' fill='%230088ff' stroke='%23fff' stroke-width='8'/%3E%3C/svg%3E") 12 12, crosshair`
|
|
29
|
+
|
|
30
|
+
// filled clipboard with a check inside (Phosphor clipboard-fill + check): the label
|
|
31
|
+
// wears this while confirming a copy
|
|
32
|
+
const COPIED_HTML = `<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor"><path d="M200,32H163.74a47.92,47.92,0,0,0-71.48,0H56A16,16,0,0,0,40,48V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Zm-72,0a32,32,0,0,1,32,32H96A32,32,0,0,1,128,32Z"/><path d="M92 150l26 26 48-54" fill="none" stroke="rgba(20,20,24,.92)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Element path copied</span>`
|
|
33
|
+
|
|
34
|
+
const LASER_ID = 'mv-laser-style'
|
|
35
|
+
const BASE_ID = 'mv-inspect-base'
|
|
36
|
+
|
|
37
|
+
// hue-tinted highlight (an element's OWN depth hue - the same rainbow laser uses):
|
|
38
|
+
// 2px outline + soft fill + ring, driven by whatever hue CSS var the caller names. The
|
|
39
|
+
// comment-mode hover reads the LIVE depth hue (--mv-hue, mode-only); the persistent lock
|
|
40
|
+
// reads a STORED hue (--mv-lock-hue, set inline per element) so the frame outline never
|
|
41
|
+
// diverges from the pin/card colour if the DOM reparents.
|
|
42
|
+
const HUE_HL = (sel, hueVar, imp = '') => `
|
|
43
|
+
body ${sel} { outline: 2px solid hsl(var(${hueVar}) 95% 45%)${imp}; outline-offset: -2px;
|
|
44
|
+
background-image: linear-gradient(hsl(var(${hueVar}) 95% 50% / .10), hsl(var(${hueVar}) 95% 50% / .10));
|
|
45
|
+
box-shadow: 0 0 0 3px hsl(var(${hueVar}) 95% 50% / .32) }`
|
|
46
|
+
// depth hue for every element (role/nesting colour) - only injected while a mode is active
|
|
47
|
+
const DEPTH_HUE = (() => {
|
|
48
|
+
let r = 'body { --mv-hue: 0 }\n'
|
|
49
|
+
for (let d = 1; d <= 12; d++) r += `body ${'> * '.repeat(d)}{ --mv-hue: ${(d % 6) * 60} }\n`
|
|
50
|
+
return r
|
|
51
|
+
})()
|
|
52
|
+
|
|
53
|
+
export function createInspect({ post, getId, onModeChange }) {
|
|
54
|
+
let laserOn = false, pickOn = false
|
|
55
|
+
let hoverEl = null, labelEl = null
|
|
56
|
+
let lockedEl = null, highlightAnchor = null // the persistent outline (#4 lock / #5 open)
|
|
57
|
+
let copiedTimer = null, copySeq = 0, copyEl = null
|
|
58
|
+
|
|
59
|
+
// every listener goes through `on` so dispose() can remove them all - a stage remount
|
|
60
|
+
// (Fast Refresh) would otherwise stack duplicate controllers posting duplicate picks
|
|
61
|
+
const removers = []
|
|
62
|
+
const on = (target, type, fn, opts) => { target.addEventListener(type, fn, opts); removers.push(() => target.removeEventListener(type, fn, opts)) }
|
|
63
|
+
|
|
64
|
+
const modeActive = () => laserOn || pickOn
|
|
65
|
+
|
|
66
|
+
// the mode stylesheet: laser's full-board rainbow, or comment's single-element hover.
|
|
67
|
+
// Both colour by the element's depth hue (--mv-hue lives in the always-on base sheet).
|
|
68
|
+
const laserCss = () => {
|
|
69
|
+
let rules = DEPTH_HUE // depth hues live only while a mode is active
|
|
70
|
+
if (laserOn)
|
|
71
|
+
rules += 'body *:not(script):not(style):not(#mv-laser-label):not(#mv-laser-label *) { outline: 1px solid hsl(var(--mv-hue) 85% 55% / .75); outline-offset: -1px }\n'
|
|
72
|
+
if (pickOn || laserOn)
|
|
73
|
+
rules += `body, body * { cursor: ${pickOn ? PICK_CURSOR : LASER_CURSOR} !important }\n`
|
|
74
|
+
// comment mode: the hovered element pops in its OWN hue (outline + fill + ring), so it
|
|
75
|
+
// reads as "the thing you'll comment on" in the same colour it wears in laser. Laser
|
|
76
|
+
// keeps its lighter single outline so the whole board stays legible.
|
|
77
|
+
const hover = pickOn ? HUE_HL('[data-mv-hover]', '--mv-hue') : `
|
|
78
|
+
body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-offset: -2px;
|
|
79
|
+
background-image: linear-gradient(hsl(var(--mv-hue) 95% 50% / .08), hsl(var(--mv-hue) 95% 50% / .08)) }`
|
|
80
|
+
return rules + hover + `
|
|
81
|
+
#mv-laser-label { position: fixed; z-index: 2147483647; pointer-events: none; outline: none !important;
|
|
82
|
+
display: flex; align-items: center; gap: 5px; width: max-content;
|
|
83
|
+
font: 600 10px -apple-system, system-ui, sans-serif; color: #fff; background: rgba(20, 20, 24, .92);
|
|
84
|
+
padding: 3px 7px; border-radius: 5px; max-width: 340px; white-space: nowrap; overflow: hidden;
|
|
85
|
+
text-overflow: ellipsis; letter-spacing: .01em }
|
|
86
|
+
#mv-laser-label svg { flex: none; display: block; outline: none !important }
|
|
87
|
+
#mv-laser-label.mv-copied { animation: mv-pop .22s cubic-bezier(.32, .72, .35, 1) }
|
|
88
|
+
@keyframes mv-pop { from { transform: scale(.85); opacity: .5 } }
|
|
89
|
+
`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// the lock outline is always available (a thread can be opened while NOT in a mode),
|
|
93
|
+
// so its rule lives in a tiny base sheet injected once - comment green, distinct from
|
|
94
|
+
// the mode hover, and it wins by being 2px with an offset.
|
|
95
|
+
const injectBase = () => {
|
|
96
|
+
if (document.getElementById(BASE_ID)) return
|
|
97
|
+
const s = document.createElement('style')
|
|
98
|
+
s.id = BASE_ID
|
|
99
|
+
// the persistent lock, tinted in the anchored element's stored hue (set inline as
|
|
100
|
+
// --mv-lock-hue by applyLock; fallback to a calm blue for any pre-hue anchor)
|
|
101
|
+
s.textContent = HUE_HL('[data-mv-locked]', '--mv-lock-hue, 210', ' !important')
|
|
102
|
+
document.head.appendChild(s)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const applyModes = () => {
|
|
106
|
+
const cur = document.getElementById(LASER_ID)
|
|
107
|
+
if (!modeActive()) { if (cur) cur.remove(); clearHover(); return }
|
|
108
|
+
const s = cur ?? document.createElement('style')
|
|
109
|
+
s.id = LASER_ID
|
|
110
|
+
s.textContent = laserCss()
|
|
111
|
+
if (!cur) document.head.appendChild(s)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const describe = (el) => {
|
|
115
|
+
const tag = el.tagName.toLowerCase()
|
|
116
|
+
const loc = el.dataset.mvLoc
|
|
117
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
|
|
118
|
+
return loc ? `${tag} · ${loc}` : text ? `${tag} · “${text}”` : tag
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const clearHover = () => {
|
|
122
|
+
if (hoverEl) { delete hoverEl.dataset.mvHover; hoverEl = null }
|
|
123
|
+
if (labelEl) { labelEl.remove(); labelEl = null }
|
|
124
|
+
clearTimeout(copiedTimer)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// the persistent lock (#4/#5): one element at a time, its own attribute so a
|
|
128
|
+
// hover clear never touches it and the mode-off teardown leaves it alone.
|
|
129
|
+
const applyLock = (el, hue) => {
|
|
130
|
+
// stamp the stored hue first, so re-confirming the SAME element still refreshes its
|
|
131
|
+
// colour; a missing/invalid hue REMOVES the var so the CSS fallback (210) applies
|
|
132
|
+
if (el) {
|
|
133
|
+
if (Number.isFinite(hue)) el.style.setProperty('--mv-lock-hue', String(hue))
|
|
134
|
+
else el.style.removeProperty('--mv-lock-hue')
|
|
135
|
+
}
|
|
136
|
+
if (lockedEl === el) return
|
|
137
|
+
if (lockedEl) { delete lockedEl.dataset.mvLocked; lockedEl.style.removeProperty('--mv-lock-hue') }
|
|
138
|
+
lockedEl = el
|
|
139
|
+
if (el) el.dataset.mvLocked = '1'
|
|
140
|
+
}
|
|
141
|
+
const clearLock = () => {
|
|
142
|
+
if (lockedEl) { delete lockedEl.dataset.mvLocked; lockedEl.style.removeProperty('--mv-lock-hue') }
|
|
143
|
+
lockedEl = null; highlightAnchor = null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const showCopied = (seq) => {
|
|
147
|
+
if (seq !== copySeq || hoverEl !== copyEl || !labelEl) return
|
|
148
|
+
labelEl.classList.add('mv-copied')
|
|
149
|
+
labelEl.innerHTML = COPIED_HTML
|
|
150
|
+
clearTimeout(copiedTimer)
|
|
151
|
+
copiedTimer = setTimeout(() => {
|
|
152
|
+
if (!labelEl) return
|
|
153
|
+
labelEl.classList.remove('mv-copied')
|
|
154
|
+
if (hoverEl) labelEl.textContent = describe(hoverEl)
|
|
155
|
+
else { labelEl.remove(); labelEl = null }
|
|
156
|
+
}, 2000)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// the label hangs BELOW the cursor with a gap, left-aligned under it - never over the
|
|
160
|
+
// element you are trying to click. It only shifts left when it would run off the right
|
|
161
|
+
// edge (so the text is never clipped), and flips ABOVE the cursor near the bottom edge.
|
|
162
|
+
const place = (label, e) => {
|
|
163
|
+
const gap = 18
|
|
164
|
+
const w = label.offsetWidth, h = label.offsetHeight
|
|
165
|
+
let x = e.clientX // left edge under the cursor
|
|
166
|
+
if (x + w > innerWidth - 4) x = innerWidth - 4 - w // keep fully in view instead of clipping
|
|
167
|
+
x = Math.max(4, x)
|
|
168
|
+
let y = e.clientY + gap // below, clear of the cursor
|
|
169
|
+
if (y + h > innerHeight - 4) y = e.clientY - gap - h // no room below -> flip above
|
|
170
|
+
y = Math.max(4, y)
|
|
171
|
+
label.style.left = x + 'px'
|
|
172
|
+
label.style.top = y + 'px'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// hover-follow: the label chases the cursor while a mode is on - BUT a locked
|
|
176
|
+
// element (#4 composing / #5 thread open) freezes it: the lit element is the one
|
|
177
|
+
// being discussed, not whatever the pointer grazes.
|
|
178
|
+
on(document, 'mousemove', (e) => {
|
|
179
|
+
if (!modeActive() || lockedEl) return
|
|
180
|
+
const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
|
|
181
|
+
if (!el || el === hoverEl) { if (labelEl && hoverEl) place(labelEl, e); return }
|
|
182
|
+
clearHover()
|
|
183
|
+
hoverEl = el
|
|
184
|
+
el.dataset.mvHover = '1'
|
|
185
|
+
labelEl = document.createElement('div')
|
|
186
|
+
labelEl.id = 'mv-laser-label'
|
|
187
|
+
labelEl.textContent = describe(el)
|
|
188
|
+
document.body.appendChild(labelEl)
|
|
189
|
+
place(labelEl, e)
|
|
190
|
+
}, true)
|
|
191
|
+
|
|
192
|
+
const cssPath = (el) => {
|
|
193
|
+
const seg = []
|
|
194
|
+
for (let cur = el; cur && cur !== document.body; cur = cur.parentElement) {
|
|
195
|
+
if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
|
|
196
|
+
const tag = cur.tagName.toLowerCase()
|
|
197
|
+
let n = 1
|
|
198
|
+
for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
|
|
199
|
+
if (sib.tagName === cur.tagName) n++
|
|
200
|
+
seg.unshift(n > 1 ? `${tag}:nth-of-type(${n})` : tag)
|
|
201
|
+
}
|
|
202
|
+
return seg.join(' > ')
|
|
203
|
+
}
|
|
204
|
+
// the element's live depth hue (0..300) - so the shell can tint the pin / composer /
|
|
205
|
+
// thread card in the same colour the element wears in laser, and keep it after a reload
|
|
206
|
+
const hueOf = (el) => {
|
|
207
|
+
const raw = getComputedStyle(el).getPropertyValue('--mv-hue').trim() // set by the mode sheet
|
|
208
|
+
const n = raw === '' ? NaN : Number(raw)
|
|
209
|
+
return Number.isFinite(n) ? ((n % 360) + 360) % 360 : 210 // fallback: a calm blue
|
|
210
|
+
}
|
|
211
|
+
const anchorBundle = (el, e) => {
|
|
212
|
+
const r = el.getBoundingClientRect()
|
|
213
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
214
|
+
return {
|
|
215
|
+
el: {
|
|
216
|
+
semantics: {
|
|
217
|
+
tag: el.tagName.toLowerCase(),
|
|
218
|
+
role: el.getAttribute('role') ?? undefined,
|
|
219
|
+
ariaLabel: el.getAttribute('aria-label') ?? undefined,
|
|
220
|
+
testId: el.getAttribute('data-testid') ?? undefined,
|
|
221
|
+
quote: text.slice(0, 200) || undefined,
|
|
222
|
+
},
|
|
223
|
+
cssPath: cssPath(el),
|
|
224
|
+
source: el.dataset.mvLoc ?? undefined,
|
|
225
|
+
hue: hueOf(el),
|
|
226
|
+
},
|
|
227
|
+
pos: {
|
|
228
|
+
fx: r.width ? Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) : .5,
|
|
229
|
+
fy: r.height ? Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)) : .5,
|
|
230
|
+
},
|
|
231
|
+
rect: { x: r.left, y: r.top, w: r.width, h: r.height },
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// every press is reported so the shell can close an open thread card when the click
|
|
236
|
+
// lands INSIDE a frame (shell-document listeners never see those)
|
|
237
|
+
on(document, 'pointerdown', () => post({ type: 'sh:frame-down', id: getId() }), true)
|
|
238
|
+
|
|
239
|
+
on(document, 'click', (e) => {
|
|
240
|
+
if (!modeActive()) return
|
|
241
|
+
e.preventDefault()
|
|
242
|
+
e.stopPropagation()
|
|
243
|
+
const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
|
|
244
|
+
if (!el) return
|
|
245
|
+
if (pickOn) {
|
|
246
|
+
// #4: lock the chosen element the instant it is picked - no round-trip flicker.
|
|
247
|
+
// The shell re-confirms via sh:highlight-anchor once the draft is staged.
|
|
248
|
+
applyLock(el, hueOf(el))
|
|
249
|
+
clearHover()
|
|
250
|
+
post({ type: 'sh:picked', id: getId(), anchor: anchorBundle(el, e) })
|
|
251
|
+
} else {
|
|
252
|
+
copyEl = el
|
|
253
|
+
post({ type: 'sh:laser-copy', id: getId(), seq: ++copySeq, path: cssPath(el), source: el.dataset.mvLoc ?? null })
|
|
254
|
+
}
|
|
255
|
+
}, true)
|
|
256
|
+
|
|
257
|
+
const resolveAnchor = (anchor) => {
|
|
258
|
+
const want = anchor?.el?.semantics ?? {}
|
|
259
|
+
const match = (el) => {
|
|
260
|
+
if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
|
|
261
|
+
if (want.role && el.getAttribute('role') !== want.role) return false
|
|
262
|
+
if (want.ariaLabel && el.getAttribute('aria-label') !== want.ariaLabel) return false
|
|
263
|
+
if (want.testId && el.getAttribute('data-testid') !== want.testId) return false
|
|
264
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
265
|
+
if (want.quote && !(text.startsWith(want.quote.slice(0, 60)) || text.includes(want.quote.slice(0, 40)))) return false
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const byPath = anchor?.el?.cssPath && document.querySelector(anchor.el.cssPath)
|
|
270
|
+
if (byPath && match(byPath)) return byPath
|
|
271
|
+
} catch { /* stale selector */ }
|
|
272
|
+
try {
|
|
273
|
+
if (want.testId) {
|
|
274
|
+
const el = document.querySelector(`[data-testid="${CSS.escape(want.testId)}"]`)
|
|
275
|
+
if (el && match(el)) return el
|
|
276
|
+
}
|
|
277
|
+
if (want.quote && typeof want.tag === 'string' && /^[a-z][a-z0-9-]*$/.test(want.tag)) {
|
|
278
|
+
for (const el of document.querySelectorAll(want.tag))
|
|
279
|
+
if (match(el)) return el
|
|
280
|
+
}
|
|
281
|
+
} catch { /* malformed semantics must not sink the whole batch */ }
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// re-resolve + re-apply the shell-driven highlight. Called on set and on every
|
|
286
|
+
// resolve-anchors pass, so a stage frame swapped in AFTER the highlight arrived
|
|
287
|
+
// (the DOM had not committed yet) still lights up on the next cycle - and, just as
|
|
288
|
+
// important, a target that has SINCE detached clears the lock (applyLock(null))
|
|
289
|
+
// instead of stranding a stale outline / a stuck hover-suppression.
|
|
290
|
+
const reapplyHighlight = () => {
|
|
291
|
+
if (!highlightAnchor) return
|
|
292
|
+
applyLock(resolveAnchor(highlightAnchor), highlightAnchor?.el?.hue)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const onMessage = (e) => {
|
|
296
|
+
if (e.source !== window.parent || window.parent === window) return
|
|
297
|
+
if (e.origin && e.origin !== location.origin) return // a navigated frame must not spoof shell commands
|
|
298
|
+
const m = e?.data
|
|
299
|
+
if (!m || typeof m !== 'object') return
|
|
300
|
+
if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes(); onModeChange?.(laserOn, pickOn) }
|
|
301
|
+
else if (m.type === 'sh:pick') {
|
|
302
|
+
pickOn = !!m.on; applyModes()
|
|
303
|
+
// leaving comment mode drops a provisional self-lock the shell never confirmed;
|
|
304
|
+
// a shell-owned highlight (highlightAnchor set, e.g. #5) survives
|
|
305
|
+
if (!pickOn && !highlightAnchor) clearLock()
|
|
306
|
+
onModeChange?.(laserOn, pickOn)
|
|
307
|
+
}
|
|
308
|
+
else if (m.type === 'sh:copy-ok') showCopied(m.seq)
|
|
309
|
+
else if (m.type === 'sh:highlight-anchor') {
|
|
310
|
+
// null clears; an anchor locks its element (the pick draft #4, or the opened thread
|
|
311
|
+
// #5). No frame-id guard: a canvas frame only ever receives its OWN highlights (the
|
|
312
|
+
// shell posts to that one iframe), and an HTML frame reports id=pathname != frame.id
|
|
313
|
+
// so a guard would wrongly drop every one. Stage-swap staleness is handled instead by
|
|
314
|
+
// resolveAnchor failing on the wrong DOM (-> clear) and reapplyHighlight relighting on
|
|
315
|
+
// the next resolve tick once the new frame has committed.
|
|
316
|
+
if (!m.anchor) { clearLock(); return }
|
|
317
|
+
highlightAnchor = m.anchor
|
|
318
|
+
applyLock(resolveAnchor(m.anchor), m.anchor?.el?.hue)
|
|
319
|
+
}
|
|
320
|
+
else if (m.type === 'sh:resolve-anchors' && Array.isArray(m.anchors)) {
|
|
321
|
+
const rects = m.anchors.slice(0, 200).map((a) => {
|
|
322
|
+
let el = null
|
|
323
|
+
try { el = resolveAnchor(a.anchor) } catch { /* one bad anchor, not the batch */ }
|
|
324
|
+
if (!el) return { key: a.key, orphan: true }
|
|
325
|
+
const r = el.getBoundingClientRect()
|
|
326
|
+
return { key: a.key, rect: { x: r.left, y: r.top, w: r.width, h: r.height } }
|
|
327
|
+
})
|
|
328
|
+
reapplyHighlight()
|
|
329
|
+
post({ type: 'sh:anchor-rects', id: getId(), rects })
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
on(window, 'message', onMessage)
|
|
333
|
+
|
|
334
|
+
injectBase()
|
|
335
|
+
return {
|
|
336
|
+
modeActive, isLaser: () => laserOn, isPick: () => pickOn,
|
|
337
|
+
// remove every listener + injected style + clear timers/locks (stage remount safety)
|
|
338
|
+
dispose() {
|
|
339
|
+
removers.forEach((f) => f())
|
|
340
|
+
clearTimeout(copiedTimer)
|
|
341
|
+
clearHover(); clearLock()
|
|
342
|
+
document.getElementById(LASER_ID)?.remove()
|
|
343
|
+
document.getElementById(BASE_ID)?.remove()
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
}
|