@marver-design/marver 0.3.1 → 0.5.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 +151 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/dev-DdeU-Jst.mjs +162 -0
- package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +3 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/index.tsx +6 -2
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +269 -3
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +131 -17
- package/src/client/shell/Comments.tsx +464 -0
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +130 -10
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +101 -6
- package/src/client/shell/styles.css +196 -7
- package/src/client/stage/main.tsx +2 -0
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
- package/dist/dev-C2oTKuXe.mjs +0 -97
- package/dist/serve-OtA9Nlow.mjs +0 -207
|
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
|
|
2
2
|
import { TransformWrapper, TransformComponent, type ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch'
|
|
3
3
|
import { CONFIG, useStore } from '../store.ts'
|
|
4
4
|
import { bootHash } from '../hash.ts'
|
|
5
|
+
import { startPerf } from '../perf.ts'
|
|
5
6
|
import { FrameNode, HEADER } from './FrameNode.tsx'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -82,12 +83,20 @@ export function animateLayout(ms = 360) {
|
|
|
82
83
|
presetTimer = window.setTimeout(() => w.classList.remove('sh-preset'), ms)
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/** B0.2: one wheel event, whatever its origin - a shell-document wheel over the canvas, or
|
|
87
|
+
* a wheel forwarded from a passive frame's iframe. clientX/Y are shell-viewport pixels. */
|
|
88
|
+
export interface CanvasWheelInput {
|
|
89
|
+
deltaX: number; deltaY: number; deltaMode: number
|
|
90
|
+
ctrlKey: boolean; metaKey: boolean; clientX: number; clientY: number
|
|
91
|
+
}
|
|
92
|
+
|
|
85
93
|
export const canvasCtl = {
|
|
86
94
|
fitNode(_key: string) {},
|
|
87
95
|
fitNodes(_keys: string[]) {},
|
|
88
96
|
fitAll() {},
|
|
89
97
|
zoomTo(_scale: number) {},
|
|
90
98
|
zoom100() { canvasCtl.zoomTo(1) },
|
|
99
|
+
wheel(_input: CanvasWheelInput) {},
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
/** Variant-group captions (SPEC-023 §4): "Landing · 3 variants" above each group with
|
|
@@ -131,6 +140,9 @@ export function Canvas() {
|
|
|
131
140
|
const gesture = useStore((s) => s.gesture)
|
|
132
141
|
const setScale = useStore((s) => s.setScale)
|
|
133
142
|
const ref = useRef<ReactZoomPanPinchContentRef>(null)
|
|
143
|
+
const scaleTimer = useRef(0)
|
|
144
|
+
|
|
145
|
+
useEffect(() => { startPerf() }, []) // B0.4: frame-time sampler (window.__mvPerf)
|
|
134
146
|
|
|
135
147
|
useEffect(() => {
|
|
136
148
|
const wrap = () => document.querySelector('.sh-canvas') as HTMLElement | null
|
|
@@ -231,41 +243,55 @@ export function Canvas() {
|
|
|
231
243
|
return () => el.removeEventListener('pointerdown', down)
|
|
232
244
|
}, [])
|
|
233
245
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
246
|
+
// B0.2: the shell is the SINGLE wheel-camera owner. rzpp's wheel-pan is disabled (below);
|
|
247
|
+
// both entry paths - a shell-document wheel over the canvas, and a wheel forwarded from a
|
|
248
|
+
// passive frame's iframe (sh:wheel) - feed one applyCanvasWheel. Plain wheel pans (mirrors
|
|
249
|
+
// rzpp's old position-delta math); ctrl/meta wheel zooms about the cursor on an exponential
|
|
250
|
+
// curve (constant ratio per finger distance - uniform feel at every zoom, unlike rzpp's
|
|
251
|
+
// additive step). setTransform drives onTransformed, which repaints the grid.
|
|
240
252
|
useEffect(() => {
|
|
241
253
|
const app = document.querySelector('.sh-app') as HTMLElement | null
|
|
242
|
-
const
|
|
243
|
-
if (!app || !
|
|
254
|
+
const canvas = document.querySelector('.sh-canvas') as HTMLElement | null
|
|
255
|
+
if (!app || !canvas) return
|
|
244
256
|
let settle = 0
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
257
|
+
const beginGesture = (panning: boolean) => {
|
|
258
|
+
// sh-camera = a CANVAS pan/zoom (drives the snapshot cover); sh-gesturing also drops iframe
|
|
259
|
+
// pointer-events. A frame click/drag sets only sh-gesturing, so it never flashes a snapshot.
|
|
260
|
+
document.getElementById('sh-world')?.classList.add('sh-gesturing', 'sh-camera')
|
|
261
|
+
document.body.classList.toggle('sh-panning', panning)
|
|
262
|
+
clearTimeout(settle)
|
|
263
|
+
settle = window.setTimeout(() => {
|
|
264
|
+
document.getElementById('sh-world')?.classList.remove('sh-gesturing', 'sh-camera')
|
|
265
|
+
document.body.classList.remove('sh-panning')
|
|
266
|
+
}, 160)
|
|
267
|
+
}
|
|
268
|
+
const applyCanvasWheel = (w: CanvasWheelInput) => {
|
|
250
269
|
const inst = ref.current
|
|
251
|
-
if (!inst) return
|
|
270
|
+
if (!inst || useStore.getState().play) return
|
|
252
271
|
const { positionX, positionY, scale } = inst.instance.transformState
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
272
|
+
const zooming = w.ctrlKey || w.metaKey
|
|
273
|
+
beginGesture(!zooming)
|
|
274
|
+
if (!zooming) { inst.setTransform(positionX - w.deltaX, positionY - w.deltaY, scale, 0, 'linear'); return }
|
|
275
|
+
const f = Math.min(1.4, Math.max(1 / 1.4, Math.exp(-w.deltaY * 0.0075 * (CONFIG.zoomSpeed ?? 1))))
|
|
256
276
|
const next = Math.min(2, Math.max(0.05, scale * f))
|
|
257
277
|
if (next === scale) return
|
|
278
|
+
const r = canvas.getBoundingClientRect()
|
|
279
|
+
const cx = w.clientX - r.left, cy = w.clientY - r.top
|
|
258
280
|
const k = next / scale
|
|
259
|
-
const r = el.getBoundingClientRect()
|
|
260
|
-
const cx = e.clientX - r.left, cy = e.clientY - r.top
|
|
261
|
-
// rzpp's zoom callbacks never fire on setTransform - manage the gesture class here
|
|
262
|
-
document.getElementById('sh-world')?.classList.add('sh-gesturing')
|
|
263
|
-
clearTimeout(settle)
|
|
264
|
-
settle = window.setTimeout(() => document.getElementById('sh-world')?.classList.remove('sh-gesturing'), 160)
|
|
265
281
|
inst.setTransform(cx - (cx - positionX) * k, cy - (cy - positionY) * k, next, 0, 'linear')
|
|
266
282
|
}
|
|
283
|
+
canvasCtl.wheel = applyCanvasWheel
|
|
284
|
+
const onWheel = (e: WheelEvent) => {
|
|
285
|
+
const t = e.target as HTMLElement | null
|
|
286
|
+
if (!t?.closest?.('.sh-canvas')) return
|
|
287
|
+
if (t.closest('[data-sh-wheel-local]')) return // escape hatch for scrollable shell UI in-canvas
|
|
288
|
+
e.preventDefault()
|
|
289
|
+
e.stopImmediatePropagation()
|
|
290
|
+
applyCanvasWheel({ deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode,
|
|
291
|
+
ctrlKey: e.ctrlKey, metaKey: e.metaKey, clientX: e.clientX, clientY: e.clientY })
|
|
292
|
+
}
|
|
267
293
|
app.addEventListener('wheel', onWheel, { capture: true, passive: false })
|
|
268
|
-
return () => { app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
|
|
294
|
+
return () => { canvasCtl.wheel = () => {}; app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
|
|
269
295
|
}, [])
|
|
270
296
|
|
|
271
297
|
// space-pan: hold space, drag anywhere - nodes drop pointer-events so the canvas takes the drag
|
|
@@ -289,12 +315,14 @@ export function Canvas() {
|
|
|
289
315
|
}
|
|
290
316
|
}, [])
|
|
291
317
|
|
|
318
|
+
const camera = (on: boolean) => {
|
|
319
|
+
document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing', 'sh-camera')
|
|
320
|
+
}
|
|
292
321
|
const pan = (on: boolean) => () => {
|
|
293
|
-
|
|
322
|
+
camera(on)
|
|
294
323
|
document.body.classList[on ? 'add' : 'remove']('sh-panning')
|
|
295
324
|
}
|
|
296
|
-
const zoom = (on: boolean) => () =>
|
|
297
|
-
document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing')
|
|
325
|
+
const zoom = (on: boolean) => () => camera(on)
|
|
298
326
|
|
|
299
327
|
// no initialPosition props on the wrapper: rzpp applies them ASYNC after mount and
|
|
300
328
|
// stomps the boot fit when data is inlined (published builds) - the boot fit owns
|
|
@@ -312,13 +340,22 @@ export function Canvas() {
|
|
|
312
340
|
// ctrl/meta wheel zoom never reaches rzpp (exponential curve above); pinch.step
|
|
313
341
|
// only serves real touch-screen pinch.
|
|
314
342
|
wheel={{ wheelDisabled: true, step: 0.225 * (CONFIG.zoomSpeed ?? 1) }}
|
|
315
|
-
|
|
343
|
+
// B0.2: rzpp no longer owns wheel-pan (the shell's applyCanvasWheel does, so it works
|
|
344
|
+
// over passive frames and forwarded from iframes too). excluded still governs pointer-drag.
|
|
345
|
+
panning={{ wheelPanning: false, velocityDisabled: true, excluded: ['sh-no-pan'], disabled: gesture }}
|
|
316
346
|
pinch={{ step: 7.5 * (CONFIG.zoomSpeed ?? 1) }}
|
|
317
347
|
onPanningStart={pan(true)}
|
|
318
348
|
onPanningStop={pan(false)}
|
|
319
349
|
onZoomStart={zoom(true)}
|
|
320
350
|
onZoomStop={zoom(false)}
|
|
321
|
-
|
|
351
|
+
// B0.1: the grid repaints live via CSS vars (no React), but the store scale - which
|
|
352
|
+
// only the zoom-% badge reads - commits after the transform SETTLES, so no React
|
|
353
|
+
// re-render happens per tick during a pan/zoom.
|
|
354
|
+
onTransformed={(r) => {
|
|
355
|
+
paintGrid(r.state.positionX, r.state.positionY, r.state.scale)
|
|
356
|
+
clearTimeout(scaleTimer.current)
|
|
357
|
+
scaleTimer.current = window.setTimeout(() => setScale(r.state.scale), 120)
|
|
358
|
+
}}
|
|
322
359
|
onInit={(r) => paintGrid(r.state.positionX, r.state.positionY, r.state.scale)}
|
|
323
360
|
>
|
|
324
361
|
<TransformComponent wrapperClass="sh-canvas" contentClass="sh-content">
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { memo, useEffect, useRef } from 'react'
|
|
1
|
+
import { memo, useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
3
3
|
import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
|
|
4
|
+
import { CommentLayer } from '../Comments.tsx'
|
|
5
|
+
import { useComments } from '../comments-store.ts'
|
|
6
|
+
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
7
|
+
import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
|
|
4
8
|
|
|
5
9
|
export const HEADER = 28
|
|
6
10
|
const SNAP = 12
|
|
@@ -18,7 +22,9 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
18
22
|
const frame = useStore((s) => s.frameFor(node))
|
|
19
23
|
const selected = useStore((s) => s.selection.includes(node.key))
|
|
20
24
|
const interact = useStore((s) => s.interact === node.key)
|
|
21
|
-
|
|
25
|
+
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
|
|
26
|
+
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
|
|
27
|
+
// Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
|
|
22
28
|
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, setGesture, toast } = useStore.getState()
|
|
23
29
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
24
30
|
const themeRef = useRef(node.theme)
|
|
@@ -28,13 +34,98 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
28
34
|
if (frame && initialSrc.current === null) initialSrc.current = frameUrl(frame, node.theme)
|
|
29
35
|
const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
|
|
30
36
|
|
|
31
|
-
// theme switch without remount
|
|
37
|
+
// theme switch without remount: the live iframe flips via message (no navigation). The lean is
|
|
38
|
+
// INVALIDATED (not mutated) - a baked mermaid SVG can't be re-themed in place, so we drop it, show
|
|
39
|
+
// live while it re-renders in the new theme, and the capture effect (theme is a dep) rebuilds a
|
|
40
|
+
// fresh lean that is only shown once ready. No light-on-dark flash.
|
|
32
41
|
useEffect(() => {
|
|
33
42
|
if (themeRef.current !== node.theme) {
|
|
34
43
|
themeRef.current = node.theme
|
|
35
44
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
|
|
45
|
+
invalidateLean(node.key)
|
|
36
46
|
}
|
|
37
|
-
}, [node.theme])
|
|
47
|
+
}, [node.theme, node.key])
|
|
48
|
+
|
|
49
|
+
// B0.3: register this frame's WindowProxy so the shell routes its messages in O(1)
|
|
50
|
+
// (source window -> node), instead of rescanning every iframe + walking the DOM per
|
|
51
|
+
// message. Registration runs SYNCHRONOUSLY through the ref callback (P1): a fast static
|
|
52
|
+
// HTML frame or an immediate boot failure can post sh:ready/sh:error before a passive
|
|
53
|
+
// effect would run, and the registry is the security gate that would otherwise drop it
|
|
54
|
+
// as an unknown source (misleading 10s timeout). The WindowProxy is stable across
|
|
55
|
+
// navigations; onLoad re-asserts it (idempotent) after each navigation.
|
|
56
|
+
const regWin = useRef<WindowProxy | null>(null)
|
|
57
|
+
const registerWin = () => {
|
|
58
|
+
const iframe = iframeRef.current
|
|
59
|
+
const win = iframe?.contentWindow
|
|
60
|
+
if (!iframe || !win || regWin.current === win) return
|
|
61
|
+
regWin.current = win
|
|
62
|
+
registerFrame(win, { key: node.key, iframe })
|
|
63
|
+
}
|
|
64
|
+
const bindIframe = useCallback((el: HTMLIFrameElement | null) => {
|
|
65
|
+
if (regWin.current && (!el || el.contentWindow !== regWin.current)) { unregisterFrame(regWin.current); regWin.current = null }
|
|
66
|
+
iframeRef.current = el
|
|
67
|
+
registerWin()
|
|
68
|
+
}, [node.key])
|
|
69
|
+
|
|
70
|
+
// SPEC-M5: register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
|
|
71
|
+
const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
|
|
72
|
+
// capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
|
|
73
|
+
// (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
|
|
74
|
+
// so neither is a dep - keeping captures rare. Never during a gesture; the coordinator serialises.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
// capture reads the live iframe's same-origin document - true in dev AND publish (published frames
|
|
77
|
+
// are bundled same-origin and served by `marver serve`), so the lean tier works in both via this
|
|
78
|
+
// client-side capture. Fail-soft: a frame that can't serialise stays live (publish == today's
|
|
79
|
+
// behaviour in the worst case). No headless build step / heavy dependency needed.
|
|
80
|
+
if (node.status !== 'ready' || node.missing) return
|
|
81
|
+
const iframe = iframeRef.current
|
|
82
|
+
if (!iframe) return
|
|
83
|
+
const t = setTimeout(() => scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme }), 450)
|
|
84
|
+
return () => clearTimeout(t)
|
|
85
|
+
// node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
|
|
86
|
+
// flip, so a theme change re-captures after the live frame re-renders (key includes theme).
|
|
87
|
+
}, [node.status, node.nav, node.key, node.missing, node.theme])
|
|
88
|
+
useEffect(() => () => dropSnapshot(node.key), [node.key]) // drop the snapshot on unmount
|
|
89
|
+
// a reload / file-swap / error takes the frame out of 'ready': drop its cover so a stale picture
|
|
90
|
+
// never lingers (nav may not bump on a same-file reload). The next 'ready' re-captures.
|
|
91
|
+
useEffect(() => { if (node.status !== 'ready') dropSnapshot(node.key) }, [node.status, node.key])
|
|
92
|
+
// LEAN-PRIMARY focus handoff: entering interact shows the live app (drop the now-stale lean at
|
|
93
|
+
// once); leaving it recaptures the live frame's CURRENT state (the user may have typed/toggled)
|
|
94
|
+
// and only swaps back to lean once that fresh capture is admitted. force=true: same nav/theme.
|
|
95
|
+
const prevInteract = useRef(interact)
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (prevInteract.current === interact) return
|
|
98
|
+
const wasInteract = prevInteract.current
|
|
99
|
+
prevInteract.current = interact
|
|
100
|
+
if (interact) invalidateLean(node.key)
|
|
101
|
+
else if (wasInteract && node.status === 'ready' && iframeRef.current)
|
|
102
|
+
scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
|
|
103
|
+
}, [interact, node.key, node.nav, node.theme, node.status])
|
|
104
|
+
|
|
105
|
+
// laser mode (SPEC-M3 §7) rides the same rail; re-sent when a frame becomes ready
|
|
106
|
+
// so late loaders join an already-lasered board
|
|
107
|
+
const laser = useStore((s) => s.laser)
|
|
108
|
+
const commentMode = useComments((s) => s.commentMode)
|
|
109
|
+
// a node hosting the OPEN thread card (or a draft composer) rises above its
|
|
110
|
+
// neighbors - each node is a stacking context, so an overflowing card would
|
|
111
|
+
// otherwise paint under the next frame
|
|
112
|
+
const hostsCard = useComments((s) =>
|
|
113
|
+
(!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key)) || s.draft?.nodeKey === node.key)
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (node.status === 'ready' || !laser)
|
|
116
|
+
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
|
|
117
|
+
}, [laser, node.status])
|
|
118
|
+
// comment mode = pick mode in the frame (late loaders join like laser does)
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (node.status === 'ready' || !commentMode)
|
|
121
|
+
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
|
|
122
|
+
}, [commentMode, node.status])
|
|
123
|
+
// B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
|
|
124
|
+
// wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (node.status === 'ready' || !interact)
|
|
127
|
+
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
|
|
128
|
+
}, [interact, node.status])
|
|
38
129
|
|
|
39
130
|
// a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
|
|
40
131
|
useEffect(() => {
|
|
@@ -73,7 +164,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
73
164
|
const world = document.getElementById('sh-world')!
|
|
74
165
|
// Law G-5: measured scale, never stored zoom state. #sh-world is 1px wide by design,
|
|
75
166
|
// so its rendered rect width IS the scale (survives browser page-zoom too).
|
|
76
|
-
const gestureScale = world.getBoundingClientRect().width || scale || 1
|
|
167
|
+
const gestureScale = world.getBoundingClientRect().width || useStore.getState().scale || 1
|
|
77
168
|
const start = { x: e.clientX, y: e.clientY, nx: node.x, ny: node.y, nw: node.w, nh: node.h }
|
|
78
169
|
// group drag: moving any member moves the whole selection by the same delta
|
|
79
170
|
const st = useStore.getState()
|
|
@@ -84,10 +175,21 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
84
175
|
if (n) groupStarts[k] = { x: n.x, y: n.y }
|
|
85
176
|
}
|
|
86
177
|
}
|
|
87
|
-
|
|
88
|
-
|
|
178
|
+
// Defer sh-gesturing (and its `will-change: transform` on .sh-content) until an ACTUAL drag
|
|
179
|
+
// begins - a bare click otherwise promotes then demotes the compositor layer, re-rasterising
|
|
180
|
+
// the frame's text at a fractional zoom = the "jiggle". A pure click now never toggles it.
|
|
181
|
+
let gesturing = false
|
|
182
|
+
const begin = () => {
|
|
183
|
+
if (gesturing) return
|
|
184
|
+
gesturing = true
|
|
185
|
+
world.classList.add('sh-gesturing') // drops iframe pointer-events (sh-camera is NOT set, so no cover)
|
|
186
|
+
setGesture(true)
|
|
187
|
+
}
|
|
188
|
+
const MOVE_THRESHOLD = 3 // px in screen space before a press counts as a drag
|
|
89
189
|
|
|
90
190
|
const onMove = (ev: PointerEvent) => {
|
|
191
|
+
if (!gesturing && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < MOVE_THRESHOLD) return
|
|
192
|
+
begin()
|
|
91
193
|
const dx = (ev.clientX - start.x) / gestureScale
|
|
92
194
|
const dy = (ev.clientY - start.y) / gestureScale
|
|
93
195
|
if (mode === 'move') {
|
|
@@ -157,7 +259,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
157
259
|
<div
|
|
158
260
|
className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
|
|
159
261
|
data-theme={node.theme}
|
|
160
|
-
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER }}
|
|
262
|
+
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined }}
|
|
161
263
|
data-node={node.key}
|
|
162
264
|
>
|
|
163
265
|
{frame.variantGroup && (
|
|
@@ -192,12 +294,24 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
192
294
|
</div>
|
|
193
295
|
) : null}
|
|
194
296
|
<iframe
|
|
195
|
-
ref={
|
|
297
|
+
ref={bindIframe}
|
|
298
|
+
className="sh-live"
|
|
196
299
|
src={initialSrc.current ?? frameUrl(frame, node.theme)}
|
|
197
300
|
title={frame.id}
|
|
301
|
+
onLoad={registerWin}
|
|
198
302
|
style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
|
|
199
303
|
/>
|
|
200
|
-
{
|
|
304
|
+
{/* SPEC-M5 lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
|
|
305
|
+
the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
|
|
306
|
+
device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
|
|
307
|
+
so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
|
|
308
|
+
never messaged, pointer-events:none - it is NOT the live iframe (role: .sh-lean).
|
|
309
|
+
Rendered in dev AND publish (runtime client-side capture; frames are same-origin in both). */}
|
|
310
|
+
<iframe ref={bindLean} className="sh-lean" sandbox="allow-same-origin" title="" aria-hidden tabIndex={-1} />
|
|
311
|
+
{/* the overlay eats mouse events for drag-by-body; laser and comment mode both
|
|
312
|
+
need the mouse INSIDE the frame for hover highlights, so it steps aside
|
|
313
|
+
(drag still works via the header) */}
|
|
314
|
+
{!interact && !commentMode && !laser && (
|
|
201
315
|
<div
|
|
202
316
|
className="sh-overlay sh-no-pan"
|
|
203
317
|
onPointerDown={(e) => drag(e, 'move')}
|
|
@@ -206,6 +320,12 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
206
320
|
)}
|
|
207
321
|
</div>
|
|
208
322
|
|
|
323
|
+
{/* comments live OUTSIDE the clipped body: a card or pin near the frame edge
|
|
324
|
+
hangs over it (the vbadge precedent) instead of being cut off */}
|
|
325
|
+
<div className="cm-layer" style={{ top: HEADER, height: node.h }}>
|
|
326
|
+
<CommentLayer node={node} frameId={frame.id} iframe={iframeRef} />
|
|
327
|
+
</div>
|
|
328
|
+
|
|
209
329
|
{selected && (
|
|
210
330
|
<>
|
|
211
331
|
<div className="sh-handle e sh-no-pan" onPointerDown={(e) => drag(e, 'e')} />
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B0.3: O(1) source-window -> node lookup for the frame->shell message protocol.
|
|
3
|
+
*
|
|
4
|
+
* Every postMessage from a frame previously triggered a full `document.querySelectorAll
|
|
5
|
+
* ('iframe')` scan plus a `closest('[data-node]')` DOM walk to find the sender's node.
|
|
6
|
+
* A frame's WindowProxy is a stable object for the life of its iframe element (it survives
|
|
7
|
+
* same-element navigations - the iframe law keeps one element per node key), so one
|
|
8
|
+
* registration holds for the frame's whole life. The registry doubles as the security
|
|
9
|
+
* gate: an event whose source is not registered is not a known frame and is dropped.
|
|
10
|
+
*/
|
|
11
|
+
export interface FrameReg { key: string; iframe: HTMLIFrameElement }
|
|
12
|
+
|
|
13
|
+
const byWindow = new WeakMap<WindowProxy, FrameReg>()
|
|
14
|
+
|
|
15
|
+
export const registerFrame = (win: WindowProxy, reg: FrameReg): void => { byWindow.set(win, reg) }
|
|
16
|
+
export const unregisterFrame = (win: WindowProxy): void => { byWindow.delete(win) }
|
|
17
|
+
export const frameByWindow = (win: unknown): FrameReg | undefined =>
|
|
18
|
+
win ? byWindow.get(win as WindowProxy) : undefined
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPEC-M5 slice 1: the lean-frame facade coordinator. Imperative on purpose - the facade is a
|
|
3
|
+
* `<iframe class="sh-lean" sandbox="allow-same-origin">` driven by setting .srcdoc directly, so no
|
|
4
|
+
* FrameNode subscribes to snapshot state and a pan/zoom tick triggers zero React renders.
|
|
5
|
+
*
|
|
6
|
+
* The lean tier is a DOM SNAPSHOT, not a bitmap: the shell serialises the live frame's document
|
|
7
|
+
* (same origin) into self-contained static html (post-render DOM + full inlined CSS, JS stripped).
|
|
8
|
+
* LEAN-PRIMARY: this snapshot is what you SEE for a passive frame - at rest AND during pan/zoom - so
|
|
9
|
+
* there is NO per-gesture swap between the lean and the live iframe (two documents never render
|
|
10
|
+
* pixel-identically; the swap shifted text ~1-2px = jiggle, and flashed mermaid/theme). The live app
|
|
11
|
+
* shows underneath only when the frame is interacted, in laser/comment mode, or while the lean is
|
|
12
|
+
* being (re)built. Real DOM + real CSS: exact color, native reflow on resize. Theme change / focus
|
|
13
|
+
* INVALIDATE the lean (never mutate a displayed one - baked mermaid can't re-theme in place) and a
|
|
14
|
+
* fresh capture is admitted before it is shown again. Captures run bounded-parallel, viewport-first, at idle, never while busy.
|
|
15
|
+
*
|
|
16
|
+
* Correctness beats the flash-guard (codex): a frame the serialiser cannot render faithfully
|
|
17
|
+
* (canvas/video/shadow-dom/cross-origin-css, or an unrestorable scroller) is left DEGRADED - no
|
|
18
|
+
* `data-ready`, no cover, live pixels stay. Every install is guarded by a per-node GENERATION token
|
|
19
|
+
* so an in-flight capture can never paint a stale/wrong-node cover after a reload or unmount.
|
|
20
|
+
*/
|
|
21
|
+
import { serializeDoc, type SerializeResult } from '../../frame-host/serialize.ts'
|
|
22
|
+
|
|
23
|
+
export interface SnapMeta { sourceRevision: string; theme: string }
|
|
24
|
+
|
|
25
|
+
interface Entry { key: string; html: string; scrollMap: SerializeResult['scrollMap']; degraded: string[]; theme: string; gen: number; live: HTMLIFrameElement; meta: SnapMeta }
|
|
26
|
+
const byNode = new Map<string, Entry>() // nodeKey -> current lean snapshot
|
|
27
|
+
const frames = new Map<string, HTMLIFrameElement>() // nodeKey -> the facade <iframe> element
|
|
28
|
+
const gen = new Map<string, number>() // nodeKey -> generation (bumped on drop/reload)
|
|
29
|
+
const recheck = new Map<string, number>() // nodeKey -> a one-shot re-capture timer (slow async)
|
|
30
|
+
const rechecked = new Set<string>() // nodes whose one-shot recheck already fired (no re-arm loop)
|
|
31
|
+
const MAX_LEAN_BYTES = 4 * 1024 * 1024 // over this a frame is too heavy to inline - stay live
|
|
32
|
+
|
|
33
|
+
// content identity INCLUDES theme: content whose colors are baked into the DOM at render time
|
|
34
|
+
// (mermaid SVG) cannot be re-themed by the cover's attribute mutation, so a theme change must
|
|
35
|
+
// re-capture after the live frame re-renders. Size still needs no re-capture (the lean doc reflows).
|
|
36
|
+
const keyOf = (m: SnapMeta) => `${m.sourceRevision}|${m.theme}`
|
|
37
|
+
const genOf = (k: string) => gen.get(k) ?? 0
|
|
38
|
+
const bumpGen = (k: string) => gen.set(k, genOf(k) + 1) // invalidate any in-flight capture/onload for k
|
|
39
|
+
|
|
40
|
+
const inMotion = (): boolean => {
|
|
41
|
+
const w = document.getElementById('sh-world')
|
|
42
|
+
return !!w && (w.classList.contains('sh-gesturing') || w.classList.contains('sh-preset'))
|
|
43
|
+
}
|
|
44
|
+
// never serialise while laser/comment mode is on: those inject outline styles + hover chrome into the
|
|
45
|
+
// live doc, which the shell-side clone would bake into the lean (visible after the mode ends).
|
|
46
|
+
const modeActive = (): boolean => document.body.classList.contains('sh-laser') || document.body.classList.contains('sh-commenting')
|
|
47
|
+
const busy = (): boolean => inMotion() || modeActive()
|
|
48
|
+
|
|
49
|
+
/** Apply a node's current theme to its lean doc via attribute mutation (allow-same-origin lets the
|
|
50
|
+
* shell touch the doc; the full CSS is inlined so only which rules match changes - no re-capture). */
|
|
51
|
+
function applyTheme(doc: Document, theme: string): void {
|
|
52
|
+
doc.documentElement.dataset.theme = theme
|
|
53
|
+
doc.documentElement.classList.toggle('dark', theme === 'dark')
|
|
54
|
+
// pin the cover's color-scheme to the FRAME theme, not the viewer's OS. A srcdoc doc otherwise
|
|
55
|
+
// follows the OS: on a dark-mode Mac the UA canvas + any prefers-color-scheme rules go dark and
|
|
56
|
+
// bleed into the cover (dark mermaid boxes) while data-theme says light. This holds it to light.
|
|
57
|
+
doc.documentElement.style.colorScheme = theme
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Restore captured scroll offsets shell-side (the lean doc runs no JS). Returns false if any mapped
|
|
61
|
+
* scroller cannot be resolved OR the offset did not stick (clamped by a differently-reflowed lean
|
|
62
|
+
* doc) - the frame then degrades to live rather than showing a mis-scrolled cover. */
|
|
63
|
+
function restoreScroll(doc: Document, scrollMap: Entry['scrollMap']): boolean {
|
|
64
|
+
for (const s of scrollMap) {
|
|
65
|
+
const el = s.sel === ':root' ? doc.documentElement : doc.querySelector<HTMLElement>(s.sel)
|
|
66
|
+
if (!el) return false
|
|
67
|
+
el.scrollTop = s.top; el.scrollLeft = s.left
|
|
68
|
+
if (Math.abs(el.scrollTop - s.top) > 2 || Math.abs(el.scrollLeft - s.left) > 2) return false
|
|
69
|
+
}
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Install the stored snapshot into a node's lean iframe: parse srcdoc, then on load restore scroll,
|
|
74
|
+
* apply theme, and (only if nothing degraded AND fonts+paint have settled) mark ready. Every step is
|
|
75
|
+
* generation-guarded so a superseded capture or an about:blank reset never re-admits a cover. */
|
|
76
|
+
function install(nodeKey: string): void {
|
|
77
|
+
const iframe = frames.get(nodeKey)
|
|
78
|
+
if (!iframe) return
|
|
79
|
+
iframe.onload = null // detach any prior handler (about:blank reset can't re-admit)
|
|
80
|
+
delete iframe.dataset.ready
|
|
81
|
+
const e = byNode.get(nodeKey)
|
|
82
|
+
if (!e || e.degraded.length) { iframe.removeAttribute('srcdoc'); return } // degraded = no cover, keep live
|
|
83
|
+
const myGen = e.gen
|
|
84
|
+
iframe.onload = () => {
|
|
85
|
+
if (genOf(nodeKey) !== myGen || frames.get(nodeKey) !== iframe) return // superseded / remounted
|
|
86
|
+
const cur = byNode.get(nodeKey)
|
|
87
|
+
const doc = iframe.contentDocument
|
|
88
|
+
if (!cur || !doc) return
|
|
89
|
+
if (!restoreScroll(doc, cur.scrollMap)) { cur.degraded = [...cur.degraded, 'scroll']; iframe.removeAttribute('srcdoc'); return }
|
|
90
|
+
// CSP guard (codex): if a hardened host blocked the inline <style> (style-src 'self'), the lean is
|
|
91
|
+
// unstyled - the sentinel custom prop won't resolve. Stay live rather than show an unstyled cover.
|
|
92
|
+
if (getComputedStyle(doc.documentElement).getPropertyValue('--mv-lean-ok').trim() !== '1') { iframe.removeAttribute('srcdoc'); return }
|
|
93
|
+
applyTheme(doc, cur.theme)
|
|
94
|
+
// font+paint readiness gate (F3): the srcdoc doc reloads fonts independently, so mark ready only
|
|
95
|
+
// after its fonts settle + two paints, else a fallback-font seam shows on the swap.
|
|
96
|
+
const markReady = () => {
|
|
97
|
+
if (genOf(nodeKey) !== myGen || frames.get(nodeKey) !== iframe) return
|
|
98
|
+
iframe.dataset.ready = '1'
|
|
99
|
+
// slow-async guard: a data fetch / route change that lands AFTER the capture window would leave
|
|
100
|
+
// the lean frozen on a loading state. ONE bounded re-capture ~3s after admit catches it; later
|
|
101
|
+
// changes self-heal on focus. `rechecked` makes it truly one-shot - the recheck's own recapture
|
|
102
|
+
// must not re-arm the timer (that was an endless ~3s loop), so a long-poll can't thrash.
|
|
103
|
+
clearTimeout(recheck.get(nodeKey))
|
|
104
|
+
if (cur.live && !rechecked.has(nodeKey)) recheck.set(nodeKey, window.setTimeout(() => {
|
|
105
|
+
recheck.delete(nodeKey); rechecked.add(nodeKey)
|
|
106
|
+
if (genOf(nodeKey) === myGen) scheduleCapture(nodeKey, cur.live, cur.meta, true)
|
|
107
|
+
}, 3000))
|
|
108
|
+
}
|
|
109
|
+
void (doc.fonts?.ready ?? Promise.resolve()).then(() => requestAnimationFrame(() => requestAnimationFrame(markReady)))
|
|
110
|
+
}
|
|
111
|
+
iframe.srcdoc = e.html
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** FrameNode registers its facade <iframe> on mount so the coordinator can drive it imperatively. */
|
|
115
|
+
export function registerLeanFrame(nodeKey: string, iframe: HTMLIFrameElement | null): void {
|
|
116
|
+
if (!iframe) { frames.delete(nodeKey); return }
|
|
117
|
+
frames.set(nodeKey, iframe)
|
|
118
|
+
if (byNode.has(nodeKey)) install(nodeKey)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Hide the lean at once (show the live app underneath) and cancel in-flight work. Used when a frame
|
|
122
|
+
* is focused/interacted, or its theme changes: a baked mermaid SVG cannot be re-themed in place, so
|
|
123
|
+
* we never mutate a DISPLAYED snapshot across themes - we drop it and rebuild a fresh one, which is
|
|
124
|
+
* only shown once it passes admission. Live stays visible in the meantime (live-fallback). */
|
|
125
|
+
export function invalidateLean(nodeKey: string): void {
|
|
126
|
+
bumpGen(nodeKey)
|
|
127
|
+
pending.delete(nodeKey)
|
|
128
|
+
clearTimeout(recheck.get(nodeKey)); recheck.delete(nodeKey); rechecked.delete(nodeKey)
|
|
129
|
+
const iframe = frames.get(nodeKey)
|
|
130
|
+
if (iframe) delete iframe.dataset.ready
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Drop a node's snapshot (unmount, or content changed / frame reloaded). Cancels queued + in-flight
|
|
134
|
+
* work via the generation bump and clears the cover. */
|
|
135
|
+
export function dropSnapshot(nodeKey: string): void {
|
|
136
|
+
bumpGen(nodeKey)
|
|
137
|
+
byNode.delete(nodeKey)
|
|
138
|
+
pending.delete(nodeKey)
|
|
139
|
+
clearTimeout(recheck.get(nodeKey)); recheck.delete(nodeKey); rechecked.delete(nodeKey)
|
|
140
|
+
const iframe = frames.get(nodeKey)
|
|
141
|
+
if (iframe) { iframe.onload = null; delete iframe.dataset.ready; iframe.removeAttribute('srcdoc') }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- capture coordinator: bounded-parallel, viewport-first, idle-scheduled, never while busy --------
|
|
145
|
+
const pending = new Map<string, { live: HTMLIFrameElement; meta: SnapMeta }>() // nodeKey -> latest request
|
|
146
|
+
const inflightNodes = new Set<string>() // nodes currently being captured - never capture one twice at once
|
|
147
|
+
const MAX_CONCURRENT = 3 // overlap the per-frame settle waits (mostly timers) so a big board's leans
|
|
148
|
+
let inflight = 0 // land in ~1/3 the wall-clock of strictly-serial, without janking the loop
|
|
149
|
+
const idle = (fn: () => void) =>
|
|
150
|
+
(window as unknown as { requestIdleCallback?: (f: () => void, o?: object) => void }).requestIdleCallback?.(fn, { timeout: 600 }) ?? setTimeout(fn, 80)
|
|
151
|
+
const rafSettle = () => new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
|
152
|
+
const withDeadline = <T,>(p: Promise<T>, ms: number) => Promise.race([p, new Promise<void>((r) => setTimeout(r, ms))])
|
|
153
|
+
|
|
154
|
+
/** Wait until the frame's DOM stops mutating for `quietMs` (bounded by `maxMs`). Async content -
|
|
155
|
+
* lazily-imported mermaid renders its SVG well after the frame reports 'ready', late images/webfont
|
|
156
|
+
* swaps, entrance animations - all land here. Capturing before this quiet window yields a cover
|
|
157
|
+
* missing the diagram (the mermaid pop-in/out bug). Same-origin, so the shell can observe the doc. */
|
|
158
|
+
function domQuiet(doc: Document, quietMs: number, maxMs: number): Promise<void> {
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
let timer = 0, done = false
|
|
161
|
+
const finish = () => { if (done) return; done = true; clearTimeout(timer); clearTimeout(hard); mo.disconnect(); resolve() }
|
|
162
|
+
const mo = new MutationObserver(() => { clearTimeout(timer); timer = window.setTimeout(finish, quietMs) })
|
|
163
|
+
try { mo.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }) }
|
|
164
|
+
catch { return resolve() }
|
|
165
|
+
timer = window.setTimeout(finish, quietMs)
|
|
166
|
+
const hard = window.setTimeout(finish, maxMs)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Request a fresh lean snapshot for a ready/quiet frame. Coalesces to the latest per node. `force`
|
|
171
|
+
* recaptures even when the key is unchanged - used on blur, where the live state changed under the
|
|
172
|
+
* same revision/theme (typed input, toggled UI) and the old lean is now wrong. */
|
|
173
|
+
export function scheduleCapture(nodeKey: string, live: HTMLIFrameElement, meta: SnapMeta, force = false): void {
|
|
174
|
+
if (!force && byNode.get(nodeKey)?.key === keyOf(meta)) return // already have this content revision
|
|
175
|
+
pending.set(nodeKey, { live, meta })
|
|
176
|
+
pump()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The next node to capture: the pending frame nearest the viewport centre, on-screen before off. So
|
|
180
|
+
* the frames the user is actually looking at get their lean first (perceived-instant), and the rest
|
|
181
|
+
* fill in behind - instead of registration order, which pops in arbitrary corners of a big board. */
|
|
182
|
+
function pickNext(): string | null {
|
|
183
|
+
let best: string | null = null, bestScore = Infinity
|
|
184
|
+
const vw = window.innerWidth, vh = window.innerHeight
|
|
185
|
+
for (const [k, req] of pending) {
|
|
186
|
+
if (inflightNodes.has(k)) continue // that node is mid-capture; its re-request waits
|
|
187
|
+
const r = req.live.getBoundingClientRect()
|
|
188
|
+
const off = r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw
|
|
189
|
+
const dx = r.left + r.width / 2 - vw / 2, dy = r.top + r.height / 2 - vh / 2
|
|
190
|
+
const score = (off ? 1e7 : 0) + Math.hypot(dx, dy) // on-screen first, then by distance to centre
|
|
191
|
+
if (score < bestScore) { bestScore = score; best = k }
|
|
192
|
+
}
|
|
193
|
+
return best
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pump(): void {
|
|
197
|
+
if (busy()) { if (pending.size) idle(pump); return } // never serialise mid-gesture or during laser/comment
|
|
198
|
+
while (inflight < MAX_CONCURRENT) {
|
|
199
|
+
const nodeKey = pickNext()
|
|
200
|
+
if (!nodeKey) break
|
|
201
|
+
const req = pending.get(nodeKey)!
|
|
202
|
+
pending.delete(nodeKey)
|
|
203
|
+
inflightNodes.add(nodeKey); inflight++
|
|
204
|
+
const done = () => { inflightNodes.delete(nodeKey); inflight--; idle(pump) }
|
|
205
|
+
void capture(nodeKey, req.live, req.meta).then(done, done)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function capture(nodeKey: string, live: HTMLIFrameElement, meta: SnapMeta): Promise<void> {
|
|
210
|
+
const myGen = genOf(nodeKey)
|
|
211
|
+
const doc = live.contentDocument
|
|
212
|
+
if (!doc) return
|
|
213
|
+
// settle: fonts, two stable paints, THEN a DOM-quiet window so async content (mermaid renders its
|
|
214
|
+
// SVG after 'ready', late images) is captured - not a diagram-less frame. All bounded.
|
|
215
|
+
await withDeadline(doc.fonts?.ready ?? Promise.resolve(), 400).catch(() => {})
|
|
216
|
+
await rafSettle()
|
|
217
|
+
await domQuiet(doc, 180, 2500) // still-loading frames wait longer so async data lands in-capture
|
|
218
|
+
// bail if the world changed under us during settle: superseded (drop/reload), a newer request
|
|
219
|
+
// landed, the frame renavigated, or a gesture/preset started (serialising+parsing now would jank).
|
|
220
|
+
if (genOf(nodeKey) !== myGen || pending.has(nodeKey) || live.contentDocument !== doc) return
|
|
221
|
+
if (busy()) { pending.set(nodeKey, { live, meta }); return } // requeue for the next idle tick
|
|
222
|
+
let result: SerializeResult
|
|
223
|
+
try { result = serializeDoc(doc, doc.URL) } // <base> = the frame's own URL, so relative url()/img/font resolve
|
|
224
|
+
catch { return } // fail soft: keep live pixels
|
|
225
|
+
// budget: a pathologically heavy frame (huge inlined CSS/DOM) would multiply memory across the board
|
|
226
|
+
// and jank the main thread parsing it - over the cap, degrade (stay live) AND drop the html so the
|
|
227
|
+
// giant string is not retained in byNode (keeping it would defeat the memory bound).
|
|
228
|
+
const oversized = result.html.length > MAX_LEAN_BYTES
|
|
229
|
+
const degraded = oversized ? [...result.degraded, 'oversized'] : result.degraded
|
|
230
|
+
const theme = doc.documentElement.dataset.theme || meta.theme // theme AT capture, not a stale closure
|
|
231
|
+
byNode.set(nodeKey, { key: keyOf(meta), html: oversized ? '' : result.html, scrollMap: oversized ? [] : result.scrollMap, degraded, theme, gen: myGen, live, meta })
|
|
232
|
+
install(nodeKey)
|
|
233
|
+
}
|