@marver-design/marver 0.4.0 → 0.6.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 +107 -0
- package/dist/{build-p3xmXU3b.mjs → build-BZaPa2DS.mjs} +11 -5
- package/dist/cli.mjs +3 -3
- package/dist/{dev-DZi1yRhn.mjs → dev-DaPQ9xA5.mjs} +57 -8
- package/dist/{init-Ck8z-HiD.mjs → init-DsCUmlCW.mjs} +1 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-CtQqO5ZZ.mjs → plugin-wMY9lNf3.mjs} +67 -20
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/img-lod.ts +107 -0
- package/src/client/content/index.tsx +37 -9
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +40 -5
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +75 -18
- package/src/client/shell/Comments.tsx +5 -4
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +69 -30
- package/src/client/shell/canvas/FrameNode.tsx +104 -8
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +130 -22
- package/src/client/shell/styles.css +37 -7
- package/src/client/stage/main.tsx +2 -0
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +44 -35
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
3
|
+
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
4
4
|
import { Tip } from './Tip.tsx'
|
|
5
5
|
import { PKG, ROUTE } from '../const.ts'
|
|
6
6
|
import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
7
|
+
import { frameByWindow } from './canvas/frame-registry.ts'
|
|
7
8
|
import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
8
9
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
9
10
|
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, CommentIcon, DevicesIcon, FrameRectIcon, IntentGlyph, LaserIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
@@ -23,7 +24,7 @@ function SceneGroup({ name, count, held, onPick, children }: { name: string; cou
|
|
|
23
24
|
<button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)}>
|
|
24
25
|
<CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
|
|
25
26
|
{/* the NAME selects every frame in the scene; the caret/row still collapses */}
|
|
26
|
-
<span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{
|
|
27
|
+
<span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{humanize(name) || '(root)'}</span>
|
|
27
28
|
<small>{count}</small>
|
|
28
29
|
</button>
|
|
29
30
|
{open && children}
|
|
@@ -200,7 +201,7 @@ function SelectionBar() {
|
|
|
200
201
|
</Tip>
|
|
201
202
|
))}
|
|
202
203
|
<i className="sep" />
|
|
203
|
-
<Tip label={<><b>{multi ? `Copy ${selection.length} file paths` : 'Copy file path'}</b><span className="k"
|
|
204
|
+
<Tip label={<><b>{multi ? `Copy ${selection.length} file paths` : 'Copy file path'}</b><span className="k">⇧P</span></>}>
|
|
204
205
|
<button className="icon"
|
|
205
206
|
onClick={() => { navigator.clipboard.writeText(selectedFrames().map((f) => f.file).join('\n')); toast(multi ? `${selection.length} file paths copied` : 'file path copied') }}><SignpostIcon size={15} /></button>
|
|
206
207
|
</Tip>
|
|
@@ -379,8 +380,19 @@ function ThemeMenu() {
|
|
|
379
380
|
}
|
|
380
381
|
|
|
381
382
|
export function App() {
|
|
382
|
-
|
|
383
|
+
// B0.1: per-field selectors, NOT `useStore()` (which subscribes to the whole store and
|
|
384
|
+
// re-rendered the entire shell on every setScale tick during a pan/zoom).
|
|
385
|
+
const manifest = useStore((s) => s.manifest)
|
|
386
|
+
const nodes = useStore((s) => s.nodes)
|
|
387
|
+
const panelOpen = useStore((s) => s.panelOpen)
|
|
388
|
+
const toasts = useStore((s) => s.toasts)
|
|
389
|
+
const selection = useStore((s) => s.selection)
|
|
390
|
+
const laser = useStore((s) => s.laser)
|
|
383
391
|
const commentMode = useComments((s) => s.commentMode)
|
|
392
|
+
// SPEC-M5: laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
|
|
393
|
+
// lean cover would hide them, so a body class suppresses the cover while either mode is active.
|
|
394
|
+
useEffect(() => { document.body.classList.toggle('sh-laser', laser) }, [laser])
|
|
395
|
+
useEffect(() => { document.body.classList.toggle('sh-commenting', commentMode) }, [commentMode])
|
|
384
396
|
const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
|
|
385
397
|
const [pillOpen, setPillOpen] = useState(true)
|
|
386
398
|
|
|
@@ -391,12 +403,21 @@ export function App() {
|
|
|
391
403
|
useEffect(() => {
|
|
392
404
|
if (booted) return
|
|
393
405
|
booted = true
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
406
|
+
const start = async () => {
|
|
407
|
+
if (bootHash.board) {
|
|
408
|
+
if (bootHash.board !== useStore.getState().board) // a deep link wins
|
|
409
|
+
useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
|
|
410
|
+
} else if (!PUBLISHED) {
|
|
411
|
+
// fresh open, no deep link: LAND on the first curated board (a tight, fast board = a good first
|
|
412
|
+
// impression) instead of the auto all-scenes everything-board, which renders every frame at once.
|
|
413
|
+
const first = (await fetchBoardNames().catch(() => [] as string[])).find((n) => n !== 'all-scenes')
|
|
414
|
+
if (first && first !== useStore.getState().board) useStore.setState({ board: first, boardAuto: false })
|
|
415
|
+
}
|
|
416
|
+
const ok = await boot()
|
|
397
417
|
urlReady.current = true
|
|
398
|
-
if (ok && bootHash.play) enterPlay(bootHash.play)
|
|
399
|
-
}
|
|
418
|
+
if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
|
|
419
|
+
}
|
|
420
|
+
void start()
|
|
400
421
|
}, [])
|
|
401
422
|
|
|
402
423
|
// the URL is a projection of state: design views replace in place; entering play and
|
|
@@ -501,6 +522,13 @@ export function App() {
|
|
|
501
522
|
useEffect(() => {
|
|
502
523
|
if (!import.meta.hot) return
|
|
503
524
|
import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
|
|
525
|
+
// A7 controlled HMR: a frame file changed. Reload exactly the affected frames through the
|
|
526
|
+
// lease-aware path (idle frames now, leased ones deferred to a safe point) - never a shell
|
|
527
|
+
// reload, never a React Fast Refresh yanking a frame the user is in.
|
|
528
|
+
import.meta.hot.on('sh:frame-invalidated', (m: any) => {
|
|
529
|
+
const frameIds = Array.isArray(m?.frameIds) ? m.frameIds.filter((x: unknown) => typeof x === 'string') : []
|
|
530
|
+
if (frameIds.length && typeof m?.revision === 'string') useStore.getState().invalidateFrames(frameIds, m.revision)
|
|
531
|
+
})
|
|
504
532
|
// multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
|
|
505
533
|
// re-boots silently, keeping whatever selection survives; a dirty one keeps its
|
|
506
534
|
// edits and converges through the 409 path on its next save (disk wins, spec §8).
|
|
@@ -521,11 +549,11 @@ export function App() {
|
|
|
521
549
|
const onMsg = (e: MessageEvent) => {
|
|
522
550
|
const data = e.data
|
|
523
551
|
if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
552
|
+
// B0.3: O(1) registry lookup - also the security gate (unknown source = not a
|
|
553
|
+
// registered frame = dropped), replacing a per-message iframe scan + DOM walk.
|
|
554
|
+
const reg = frameByWindow(e.source)
|
|
555
|
+
if (!reg) return
|
|
556
|
+
const { key: nodeKey, iframe: el } = reg
|
|
529
557
|
const s = useStore.getState()
|
|
530
558
|
|
|
531
559
|
if (data.type === 'sh:ready') {
|
|
@@ -579,6 +607,29 @@ export function App() {
|
|
|
579
607
|
// walking a flow must not eject you to design mode at every hop
|
|
580
608
|
if (s.interact === nodeKey) setInteract(node.key)
|
|
581
609
|
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
610
|
+
} else if (data.type === 'sh:wheel') {
|
|
611
|
+
// B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
|
|
612
|
+
// interact target or play (the app scrolls itself - the parent stays authoritative).
|
|
613
|
+
if (s.play || s.interact === nodeKey) return
|
|
614
|
+
const nums = [data.deltaX, data.deltaY, data.clientX, data.clientY].map(Number)
|
|
615
|
+
if (!nums.every(Number.isFinite)) return
|
|
616
|
+
const [deltaX, deltaY, localX, localY] = nums
|
|
617
|
+
const rect = el.getBoundingClientRect()
|
|
618
|
+
// iframe-local (untransformed CSS px) -> shell-screen px: rect is the frame's
|
|
619
|
+
// transformed size, so rect.width/clientWidth is its effective on-screen scale.
|
|
620
|
+
// Plain rect.left+localX would put the zoom origin in the wrong place when zoomed.
|
|
621
|
+
const sx = el.clientWidth ? rect.width / el.clientWidth : 1
|
|
622
|
+
const sy = el.clientHeight ? rect.height / el.clientHeight : 1
|
|
623
|
+
canvasCtl.wheel({
|
|
624
|
+
deltaX, deltaY, deltaMode: Number(data.deltaMode) || 0,
|
|
625
|
+
ctrlKey: !!data.ctrlKey, metaKey: !!data.metaKey,
|
|
626
|
+
clientX: rect.left + localX * sx, clientY: rect.top + localY * sy,
|
|
627
|
+
})
|
|
628
|
+
} else if (data.type === 'sh:interaction') {
|
|
629
|
+
// A6: the frame reports transient laser/comment engagement (pointer inside + mode on).
|
|
630
|
+
// While engaged the frame is leased, so a hot update to it defers until disengage.
|
|
631
|
+
s.setExternalLease(nodeKey, 'laser', !!data.laser)
|
|
632
|
+
s.setExternalLease(nodeKey, 'comment', !!data.comment)
|
|
582
633
|
}
|
|
583
634
|
}
|
|
584
635
|
window.addEventListener('message', onMsg)
|
|
@@ -609,7 +660,7 @@ export function App() {
|
|
|
609
660
|
s.setLaser(!s.laser)
|
|
610
661
|
}
|
|
611
662
|
// C = comment mode (SPEC-M3 §6, the Figma/Miro convention) · Shift+C = hide/show
|
|
612
|
-
// pins · copy
|
|
663
|
+
// pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
|
|
613
664
|
if (e.key === 'c' && !e.shiftKey) {
|
|
614
665
|
const c = commentsStore()
|
|
615
666
|
if (!c.commentMode) s.setLaser(false)
|
|
@@ -617,7 +668,7 @@ export function App() {
|
|
|
617
668
|
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
618
669
|
}
|
|
619
670
|
if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
|
|
620
|
-
if (e.key === '
|
|
671
|
+
if (e.key === 'P' && e.shiftKey && s.selection.length) {
|
|
621
672
|
const files = s.selection
|
|
622
673
|
.map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
|
|
623
674
|
.filter((f): f is string => !!f)
|
|
@@ -702,8 +753,14 @@ export function App() {
|
|
|
702
753
|
mounted so collapse/expand can crossfade-morph between them */}
|
|
703
754
|
<aside className={`sh-panel${panelOpen ? '' : ' closed'}`} aria-hidden={!panelOpen}>
|
|
704
755
|
<div className="sh-panel-top">
|
|
705
|
-
|
|
706
|
-
|
|
756
|
+
{/* the mark links to the marver site; the title is the humanized repo name (C2: names
|
|
757
|
+
the project so two concurrent canvases are never confused), ellipsed if long */}
|
|
758
|
+
<Tip side="bottom" label="marver.design">
|
|
759
|
+
<a className="mark-link" href="https://marver.design" target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
|
|
760
|
+
<ParallelogramDuoIcon size={21} className="mark" />
|
|
761
|
+
</a>
|
|
762
|
+
</Tip>
|
|
763
|
+
<span className="name" title={CONFIG.projectName || 'Marver'}>{CONFIG.projectName ? humanize(CONFIG.projectName) : 'Marver'}</span>
|
|
707
764
|
<Tip side="bottom" label={<><b>Collapse panel</b><span>⌘\</span></>}><button className="sh-ibtn" onClick={togglePanel} tabIndex={panelOpen ? 0 : -1}><PanelFilledIcon size={17} /></button></Tip>
|
|
708
765
|
</div>
|
|
709
766
|
<div className="sh-panel-scroll">
|
|
@@ -157,7 +157,7 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
|
|
|
157
157
|
if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
|
|
158
158
|
}
|
|
159
159
|
return (
|
|
160
|
-
<div className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
|
|
160
|
+
<div data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
|
|
161
161
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
|
|
162
162
|
{/* thread-level actions pin to the card corner, out of the header's flow -
|
|
163
163
|
the name row never has to share its line with them */}
|
|
@@ -227,7 +227,7 @@ function DraftComposer({ at, node }: { at: { x: number; y: number }; node: Node
|
|
|
227
227
|
useEffect(() => { ref.current?.focus() }, [])
|
|
228
228
|
const flip = at.x > node.w * 0.55
|
|
229
229
|
return (
|
|
230
|
-
<div className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
|
|
230
|
+
<div data-sh-wheel-local className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
|
|
231
231
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
|
232
232
|
<div className="cm-compose">
|
|
233
233
|
<div className="cm-inputwrap">
|
|
@@ -453,9 +453,10 @@ export function CommentsController() {
|
|
|
453
453
|
return () => window.removeEventListener('hashchange', onHash)
|
|
454
454
|
}, [])
|
|
455
455
|
|
|
456
|
-
// broadcast pick mode to every frame (laser rides along inside the bridge)
|
|
456
|
+
// broadcast pick mode to every LIVE frame (laser rides along inside the bridge). Scoped to
|
|
457
|
+
// .sh-live so the SPEC-M5 lean cover (.sh-lean, a scriptless snapshot) is never messaged.
|
|
457
458
|
useEffect(() => {
|
|
458
|
-
for (const f of document.querySelectorAll('iframe'))
|
|
459
|
+
for (const f of document.querySelectorAll('iframe.sh-live'))
|
|
459
460
|
(f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
|
|
460
461
|
}, [commentMode])
|
|
461
462
|
|
|
@@ -120,6 +120,12 @@ function BoardMenu({ current }: { current: string }) {
|
|
|
120
120
|
function PlayInner() {
|
|
121
121
|
const play = useStore((s) => s.play)
|
|
122
122
|
const board = useStore((s) => s.board)
|
|
123
|
+
// A6/A7: a controlled frame edit while play is open never auto-reloads the live stage
|
|
124
|
+
// (that would destroy the user's session mid-prototype). It records playUpdateRevision;
|
|
125
|
+
// we surface an "Update ready" affordance, and applyPlayUpdate bumps playNav to reload
|
|
126
|
+
// the stage at its current position on an explicit click.
|
|
127
|
+
const playUpdateRevision = useStore((s) => s.playUpdateRevision)
|
|
128
|
+
const playNav = useStore((s) => s.playNav)
|
|
123
129
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
124
130
|
// the src is frozen at mount - navigation happens INSIDE the stage; device and theme
|
|
125
131
|
// changes must never reload it (a phone does not remount when you flip dark mode)
|
|
@@ -143,6 +149,16 @@ function PlayInner() {
|
|
|
143
149
|
|
|
144
150
|
const postStage = (msg: Record<string, unknown>) => iframeRef.current?.contentWindow?.postMessage(msg, '*')
|
|
145
151
|
|
|
152
|
+
// apply a deferred update: reload the stage iframe at its CURRENT frame + theme on a fresh
|
|
153
|
+
// rev-stamped URL. sh:stage-ready replays position; device size is shell-owned.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!playNav || !iframeRef.current) return
|
|
156
|
+
const p = useStore.getState().play
|
|
157
|
+
if (!p) return
|
|
158
|
+
iframeRef.current.src = `${ROUTE}/stage/?at=${encodeURIComponent(p.at)}&theme=${encodeURIComponent(p.theme)}&r=${playNav}`
|
|
159
|
+
}, [playNav])
|
|
160
|
+
const applyUpdate = () => useStore.getState().applyPlayUpdate()
|
|
161
|
+
|
|
146
162
|
const exit = () => {
|
|
147
163
|
const { at } = useStore.getState().play ?? {}
|
|
148
164
|
useStore.getState().setPlay(null)
|
|
@@ -378,6 +394,14 @@ function PlayInner() {
|
|
|
378
394
|
</button>
|
|
379
395
|
</Tip>
|
|
380
396
|
))}
|
|
397
|
+
{playUpdateRevision && (
|
|
398
|
+
<>
|
|
399
|
+
<i className="sep" />
|
|
400
|
+
<Tip inv side="bottom" label={<><b>Update ready</b><span>an edit landed - reload this prototype</span></>}>
|
|
401
|
+
<button className="sh-play-update" onClick={applyUpdate}><ReloadIcon size={13} /><span>Update</span></button>
|
|
402
|
+
</Tip>
|
|
403
|
+
</>
|
|
404
|
+
)}
|
|
381
405
|
<i className="sep" />
|
|
382
406
|
<Tip inv side="bottom" label={<><b>Collapse toolbar</b><span>H hides everything</span><span className="k">⌘/</span></>}>
|
|
383
407
|
<button onClick={() => setChrome('collapsed')}>
|
|
@@ -2,7 +2,9 @@ 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'
|
|
7
|
+
import { startCameraBroadcast, setCameraScale } from './camera-broadcast.ts'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* The world. rzpp owns pan/zoom; nodes are absolutely positioned children of #sh-world.
|
|
@@ -82,12 +84,20 @@ export function animateLayout(ms = 360) {
|
|
|
82
84
|
presetTimer = window.setTimeout(() => w.classList.remove('sh-preset'), ms)
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
/** B0.2: one wheel event, whatever its origin - a shell-document wheel over the canvas, or
|
|
88
|
+
* a wheel forwarded from a passive frame's iframe. clientX/Y are shell-viewport pixels. */
|
|
89
|
+
export interface CanvasWheelInput {
|
|
90
|
+
deltaX: number; deltaY: number; deltaMode: number
|
|
91
|
+
ctrlKey: boolean; metaKey: boolean; clientX: number; clientY: number
|
|
92
|
+
}
|
|
93
|
+
|
|
85
94
|
export const canvasCtl = {
|
|
86
95
|
fitNode(_key: string) {},
|
|
87
96
|
fitNodes(_keys: string[]) {},
|
|
88
97
|
fitAll() {},
|
|
89
98
|
zoomTo(_scale: number) {},
|
|
90
99
|
zoom100() { canvasCtl.zoomTo(1) },
|
|
100
|
+
wheel(_input: CanvasWheelInput) {},
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
/** Variant-group captions (SPEC-023 §4): "Landing · 3 variants" above each group with
|
|
@@ -131,6 +141,9 @@ export function Canvas() {
|
|
|
131
141
|
const gesture = useStore((s) => s.gesture)
|
|
132
142
|
const setScale = useStore((s) => s.setScale)
|
|
133
143
|
const ref = useRef<ReactZoomPanPinchContentRef>(null)
|
|
144
|
+
const scaleTimer = useRef(0)
|
|
145
|
+
|
|
146
|
+
useEffect(() => { startPerf(); startCameraBroadcast() }, []) // B0.4: frame-time sampler + image-LOD camera signal
|
|
134
147
|
|
|
135
148
|
useEffect(() => {
|
|
136
149
|
const wrap = () => document.querySelector('.sh-canvas') as HTMLElement | null
|
|
@@ -231,41 +244,55 @@ export function Canvas() {
|
|
|
231
244
|
return () => el.removeEventListener('pointerdown', down)
|
|
232
245
|
}, [])
|
|
233
246
|
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
247
|
+
// B0.2: the shell is the SINGLE wheel-camera owner. rzpp's wheel-pan is disabled (below);
|
|
248
|
+
// both entry paths - a shell-document wheel over the canvas, and a wheel forwarded from a
|
|
249
|
+
// passive frame's iframe (sh:wheel) - feed one applyCanvasWheel. Plain wheel pans (mirrors
|
|
250
|
+
// rzpp's old position-delta math); ctrl/meta wheel zooms about the cursor on an exponential
|
|
251
|
+
// curve (constant ratio per finger distance - uniform feel at every zoom, unlike rzpp's
|
|
252
|
+
// additive step). setTransform drives onTransformed, which repaints the grid.
|
|
240
253
|
useEffect(() => {
|
|
241
254
|
const app = document.querySelector('.sh-app') as HTMLElement | null
|
|
242
|
-
const
|
|
243
|
-
if (!app || !
|
|
255
|
+
const canvas = document.querySelector('.sh-canvas') as HTMLElement | null
|
|
256
|
+
if (!app || !canvas) return
|
|
244
257
|
let settle = 0
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
258
|
+
const beginGesture = (panning: boolean) => {
|
|
259
|
+
// sh-camera = a CANVAS pan/zoom (drives the snapshot cover); sh-gesturing also drops iframe
|
|
260
|
+
// pointer-events. A frame click/drag sets only sh-gesturing, so it never flashes a snapshot.
|
|
261
|
+
document.getElementById('sh-world')?.classList.add('sh-gesturing', 'sh-camera')
|
|
262
|
+
document.body.classList.toggle('sh-panning', panning)
|
|
263
|
+
clearTimeout(settle)
|
|
264
|
+
settle = window.setTimeout(() => {
|
|
265
|
+
document.getElementById('sh-world')?.classList.remove('sh-gesturing', 'sh-camera')
|
|
266
|
+
document.body.classList.remove('sh-panning')
|
|
267
|
+
}, 160)
|
|
268
|
+
}
|
|
269
|
+
const applyCanvasWheel = (w: CanvasWheelInput) => {
|
|
250
270
|
const inst = ref.current
|
|
251
|
-
if (!inst) return
|
|
271
|
+
if (!inst || useStore.getState().play) return
|
|
252
272
|
const { positionX, positionY, scale } = inst.instance.transformState
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
273
|
+
const zooming = w.ctrlKey || w.metaKey
|
|
274
|
+
beginGesture(!zooming)
|
|
275
|
+
if (!zooming) { inst.setTransform(positionX - w.deltaX, positionY - w.deltaY, scale, 0, 'linear'); return }
|
|
276
|
+
const f = Math.min(1.4, Math.max(1 / 1.4, Math.exp(-w.deltaY * 0.0075 * (CONFIG.zoomSpeed ?? 1))))
|
|
256
277
|
const next = Math.min(2, Math.max(0.05, scale * f))
|
|
257
278
|
if (next === scale) return
|
|
279
|
+
const r = canvas.getBoundingClientRect()
|
|
280
|
+
const cx = w.clientX - r.left, cy = w.clientY - r.top
|
|
258
281
|
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
282
|
inst.setTransform(cx - (cx - positionX) * k, cy - (cy - positionY) * k, next, 0, 'linear')
|
|
266
283
|
}
|
|
284
|
+
canvasCtl.wheel = applyCanvasWheel
|
|
285
|
+
const onWheel = (e: WheelEvent) => {
|
|
286
|
+
const t = e.target as HTMLElement | null
|
|
287
|
+
if (!t?.closest?.('.sh-canvas')) return
|
|
288
|
+
if (t.closest('[data-sh-wheel-local]')) return // escape hatch for scrollable shell UI in-canvas
|
|
289
|
+
e.preventDefault()
|
|
290
|
+
e.stopImmediatePropagation()
|
|
291
|
+
applyCanvasWheel({ deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode,
|
|
292
|
+
ctrlKey: e.ctrlKey, metaKey: e.metaKey, clientX: e.clientX, clientY: e.clientY })
|
|
293
|
+
}
|
|
267
294
|
app.addEventListener('wheel', onWheel, { capture: true, passive: false })
|
|
268
|
-
return () => { app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
|
|
295
|
+
return () => { canvasCtl.wheel = () => {}; app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
|
|
269
296
|
}, [])
|
|
270
297
|
|
|
271
298
|
// space-pan: hold space, drag anywhere - nodes drop pointer-events so the canvas takes the drag
|
|
@@ -289,12 +316,14 @@ export function Canvas() {
|
|
|
289
316
|
}
|
|
290
317
|
}, [])
|
|
291
318
|
|
|
319
|
+
const camera = (on: boolean) => {
|
|
320
|
+
document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing', 'sh-camera')
|
|
321
|
+
}
|
|
292
322
|
const pan = (on: boolean) => () => {
|
|
293
|
-
|
|
323
|
+
camera(on)
|
|
294
324
|
document.body.classList[on ? 'add' : 'remove']('sh-panning')
|
|
295
325
|
}
|
|
296
|
-
const zoom = (on: boolean) => () =>
|
|
297
|
-
document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing')
|
|
326
|
+
const zoom = (on: boolean) => () => camera(on)
|
|
298
327
|
|
|
299
328
|
// no initialPosition props on the wrapper: rzpp applies them ASYNC after mount and
|
|
300
329
|
// stomps the boot fit when data is inlined (published builds) - the boot fit owns
|
|
@@ -303,7 +332,7 @@ export function Canvas() {
|
|
|
303
332
|
<TransformWrapper
|
|
304
333
|
ref={ref}
|
|
305
334
|
minScale={0.05}
|
|
306
|
-
maxScale={
|
|
335
|
+
maxScale={5}
|
|
307
336
|
limitToBounds={false}
|
|
308
337
|
doubleClick={{ disabled: true }}
|
|
309
338
|
// wheelDisabled is load-bearing: rzpp's onWheelPanning is a no-op without it, and
|
|
@@ -312,13 +341,23 @@ export function Canvas() {
|
|
|
312
341
|
// ctrl/meta wheel zoom never reaches rzpp (exponential curve above); pinch.step
|
|
313
342
|
// only serves real touch-screen pinch.
|
|
314
343
|
wheel={{ wheelDisabled: true, step: 0.225 * (CONFIG.zoomSpeed ?? 1) }}
|
|
315
|
-
|
|
344
|
+
// B0.2: rzpp no longer owns wheel-pan (the shell's applyCanvasWheel does, so it works
|
|
345
|
+
// over passive frames and forwarded from iframes too). excluded still governs pointer-drag.
|
|
346
|
+
panning={{ wheelPanning: false, velocityDisabled: true, excluded: ['sh-no-pan'], disabled: gesture }}
|
|
316
347
|
pinch={{ step: 7.5 * (CONFIG.zoomSpeed ?? 1) }}
|
|
317
348
|
onPanningStart={pan(true)}
|
|
318
349
|
onPanningStop={pan(false)}
|
|
319
350
|
onZoomStart={zoom(true)}
|
|
320
351
|
onZoomStop={zoom(false)}
|
|
321
|
-
|
|
352
|
+
// B0.1: the grid repaints live via CSS vars (no React), but the store scale - which
|
|
353
|
+
// only the zoom-% badge reads - commits after the transform SETTLES, so no React
|
|
354
|
+
// re-render happens per tick during a pan/zoom.
|
|
355
|
+
onTransformed={(r) => {
|
|
356
|
+
paintGrid(r.state.positionX, r.state.positionY, r.state.scale)
|
|
357
|
+
setCameraScale(r.state.scale) // keep the LOD's settle-scale current (cheap number write, no React)
|
|
358
|
+
clearTimeout(scaleTimer.current)
|
|
359
|
+
scaleTimer.current = window.setTimeout(() => setScale(r.state.scale), 120)
|
|
360
|
+
}}
|
|
322
361
|
onInit={(r) => paintGrid(r.state.positionX, r.state.positionY, r.state.scale)}
|
|
323
362
|
>
|
|
324
363
|
<TransformComponent wrapperClass="sh-canvas" contentClass="sh-content">
|
|
@@ -1,8 +1,11 @@
|
|
|
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
4
|
import { CommentLayer } from '../Comments.tsx'
|
|
5
5
|
import { useComments } from '../comments-store.ts'
|
|
6
|
+
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
7
|
+
import { primeCameraFor } from './camera-broadcast.ts'
|
|
8
|
+
import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
|
|
6
9
|
|
|
7
10
|
export const HEADER = 28
|
|
8
11
|
const SNAP = 12
|
|
@@ -20,7 +23,9 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
20
23
|
const frame = useStore((s) => s.frameFor(node))
|
|
21
24
|
const selected = useStore((s) => s.selection.includes(node.key))
|
|
22
25
|
const interact = useStore((s) => s.interact === node.key)
|
|
23
|
-
|
|
26
|
+
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
|
|
27
|
+
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
|
|
28
|
+
// Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
|
|
24
29
|
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, setGesture, toast } = useStore.getState()
|
|
25
30
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
26
31
|
const themeRef = useRef(node.theme)
|
|
@@ -30,13 +35,73 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
30
35
|
if (frame && initialSrc.current === null) initialSrc.current = frameUrl(frame, node.theme)
|
|
31
36
|
const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
|
|
32
37
|
|
|
33
|
-
// theme switch without remount
|
|
38
|
+
// theme switch without remount: the live iframe flips via message (no navigation). The lean is
|
|
39
|
+
// INVALIDATED (not mutated) - a baked mermaid SVG can't be re-themed in place, so we drop it, show
|
|
40
|
+
// live while it re-renders in the new theme, and the capture effect (theme is a dep) rebuilds a
|
|
41
|
+
// fresh lean that is only shown once ready. No light-on-dark flash.
|
|
34
42
|
useEffect(() => {
|
|
35
43
|
if (themeRef.current !== node.theme) {
|
|
36
44
|
themeRef.current = node.theme
|
|
37
45
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
|
|
46
|
+
invalidateLean(node.key)
|
|
38
47
|
}
|
|
39
|
-
}, [node.theme])
|
|
48
|
+
}, [node.theme, node.key])
|
|
49
|
+
|
|
50
|
+
// B0.3: register this frame's WindowProxy so the shell routes its messages in O(1)
|
|
51
|
+
// (source window -> node), instead of rescanning every iframe + walking the DOM per
|
|
52
|
+
// message. Registration runs SYNCHRONOUSLY through the ref callback (P1): a fast static
|
|
53
|
+
// HTML frame or an immediate boot failure can post sh:ready/sh:error before a passive
|
|
54
|
+
// effect would run, and the registry is the security gate that would otherwise drop it
|
|
55
|
+
// as an unknown source (misleading 10s timeout). The WindowProxy is stable across
|
|
56
|
+
// navigations; onLoad re-asserts it (idempotent) after each navigation.
|
|
57
|
+
const regWin = useRef<WindowProxy | null>(null)
|
|
58
|
+
const registerWin = () => {
|
|
59
|
+
const iframe = iframeRef.current
|
|
60
|
+
const win = iframe?.contentWindow
|
|
61
|
+
if (!iframe || !win || regWin.current === win) return
|
|
62
|
+
regWin.current = win
|
|
63
|
+
registerFrame(win, { key: node.key, iframe })
|
|
64
|
+
}
|
|
65
|
+
const bindIframe = useCallback((el: HTMLIFrameElement | null) => {
|
|
66
|
+
if (regWin.current && (!el || el.contentWindow !== regWin.current)) { unregisterFrame(regWin.current); regWin.current = null }
|
|
67
|
+
iframeRef.current = el
|
|
68
|
+
registerWin()
|
|
69
|
+
}, [node.key])
|
|
70
|
+
|
|
71
|
+
// SPEC-M5: register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
|
|
72
|
+
const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
|
|
73
|
+
// capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
|
|
74
|
+
// (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
|
|
75
|
+
// so neither is a dep - keeping captures rare. Never during a gesture; the coordinator serialises.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
// capture reads the live iframe's same-origin document - true in dev AND publish (published frames
|
|
78
|
+
// are bundled same-origin and served by `marver serve`), so the lean tier works in both via this
|
|
79
|
+
// client-side capture. Fail-soft: a frame that can't serialise stays live (publish == today's
|
|
80
|
+
// behaviour in the worst case). No headless build step / heavy dependency needed.
|
|
81
|
+
if (node.status !== 'ready' || node.missing) return
|
|
82
|
+
const iframe = iframeRef.current
|
|
83
|
+
if (!iframe) return
|
|
84
|
+
const t = setTimeout(() => scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme }), 450)
|
|
85
|
+
return () => clearTimeout(t)
|
|
86
|
+
// node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
|
|
87
|
+
// flip, so a theme change re-captures after the live frame re-renders (key includes theme).
|
|
88
|
+
}, [node.status, node.nav, node.key, node.missing, node.theme])
|
|
89
|
+
useEffect(() => () => dropSnapshot(node.key), [node.key]) // drop the snapshot on unmount
|
|
90
|
+
// a reload / file-swap / error takes the frame out of 'ready': drop its cover so a stale picture
|
|
91
|
+
// never lingers (nav may not bump on a same-file reload). The next 'ready' re-captures.
|
|
92
|
+
useEffect(() => { if (node.status !== 'ready') dropSnapshot(node.key) }, [node.status, node.key])
|
|
93
|
+
// LEAN-PRIMARY focus handoff: entering interact shows the live app (drop the now-stale lean at
|
|
94
|
+
// once); leaving it recaptures the live frame's CURRENT state (the user may have typed/toggled)
|
|
95
|
+
// and only swaps back to lean once that fresh capture is admitted. force=true: same nav/theme.
|
|
96
|
+
const prevInteract = useRef(interact)
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (prevInteract.current === interact) return
|
|
99
|
+
const wasInteract = prevInteract.current
|
|
100
|
+
prevInteract.current = interact
|
|
101
|
+
if (interact) invalidateLean(node.key)
|
|
102
|
+
else if (wasInteract && node.status === 'ready' && iframeRef.current)
|
|
103
|
+
scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
|
|
104
|
+
}, [interact, node.key, node.nav, node.theme, node.status])
|
|
40
105
|
|
|
41
106
|
// laser mode (SPEC-M3 §7) rides the same rail; re-sent when a frame becomes ready
|
|
42
107
|
// so late loaders join an already-lasered board
|
|
@@ -56,6 +121,17 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
56
121
|
if (node.status === 'ready' || !commentMode)
|
|
57
122
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
|
|
58
123
|
}, [commentMode, node.status])
|
|
124
|
+
// B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
|
|
125
|
+
// wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (node.status === 'ready' || !interact)
|
|
128
|
+
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
|
|
129
|
+
}, [interact, node.status])
|
|
130
|
+
// image-LOD: once the frame is ready its content listener is live, so send the settled zoom - a static
|
|
131
|
+
// board that never gets a gesture then still sharpens its images from the cheap low-res first paint.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (node.status === 'ready') primeCameraFor(iframeRef.current?.contentWindow)
|
|
134
|
+
}, [node.status])
|
|
59
135
|
|
|
60
136
|
// a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
|
|
61
137
|
useEffect(() => {
|
|
@@ -94,7 +170,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
94
170
|
const world = document.getElementById('sh-world')!
|
|
95
171
|
// Law G-5: measured scale, never stored zoom state. #sh-world is 1px wide by design,
|
|
96
172
|
// so its rendered rect width IS the scale (survives browser page-zoom too).
|
|
97
|
-
const gestureScale = world.getBoundingClientRect().width || scale || 1
|
|
173
|
+
const gestureScale = world.getBoundingClientRect().width || useStore.getState().scale || 1
|
|
98
174
|
const start = { x: e.clientX, y: e.clientY, nx: node.x, ny: node.y, nw: node.w, nh: node.h }
|
|
99
175
|
// group drag: moving any member moves the whole selection by the same delta
|
|
100
176
|
const st = useStore.getState()
|
|
@@ -105,10 +181,21 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
105
181
|
if (n) groupStarts[k] = { x: n.x, y: n.y }
|
|
106
182
|
}
|
|
107
183
|
}
|
|
108
|
-
|
|
109
|
-
|
|
184
|
+
// Defer sh-gesturing (and its `will-change: transform` on .sh-content) until an ACTUAL drag
|
|
185
|
+
// begins - a bare click otherwise promotes then demotes the compositor layer, re-rasterising
|
|
186
|
+
// the frame's text at a fractional zoom = the "jiggle". A pure click now never toggles it.
|
|
187
|
+
let gesturing = false
|
|
188
|
+
const begin = () => {
|
|
189
|
+
if (gesturing) return
|
|
190
|
+
gesturing = true
|
|
191
|
+
world.classList.add('sh-gesturing') // drops iframe pointer-events (sh-camera is NOT set, so no cover)
|
|
192
|
+
setGesture(true)
|
|
193
|
+
}
|
|
194
|
+
const MOVE_THRESHOLD = 3 // px in screen space before a press counts as a drag
|
|
110
195
|
|
|
111
196
|
const onMove = (ev: PointerEvent) => {
|
|
197
|
+
if (!gesturing && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < MOVE_THRESHOLD) return
|
|
198
|
+
begin()
|
|
112
199
|
const dx = (ev.clientX - start.x) / gestureScale
|
|
113
200
|
const dy = (ev.clientY - start.y) / gestureScale
|
|
114
201
|
if (mode === 'move') {
|
|
@@ -213,11 +300,20 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
213
300
|
</div>
|
|
214
301
|
) : null}
|
|
215
302
|
<iframe
|
|
216
|
-
ref={
|
|
303
|
+
ref={bindIframe}
|
|
304
|
+
className="sh-live"
|
|
217
305
|
src={initialSrc.current ?? frameUrl(frame, node.theme)}
|
|
218
306
|
title={frame.id}
|
|
307
|
+
onLoad={registerWin}
|
|
219
308
|
style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
|
|
220
309
|
/>
|
|
310
|
+
{/* SPEC-M5 lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
|
|
311
|
+
the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
|
|
312
|
+
device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
|
|
313
|
+
so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
|
|
314
|
+
never messaged, pointer-events:none - it is NOT the live iframe (role: .sh-lean).
|
|
315
|
+
Rendered in dev AND publish (runtime client-side capture; frames are same-origin in both). */}
|
|
316
|
+
<iframe ref={bindLean} className="sh-lean" sandbox="allow-same-origin" title="" aria-hidden tabIndex={-1} />
|
|
221
317
|
{/* the overlay eats mouse events for drag-by-body; laser and comment mode both
|
|
222
318
|
need the mouse INSIDE the frame for hover highlights, so it steps aside
|
|
223
319
|
(drag still works via the header) */}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Tells every content frame when the canvas camera starts and stops moving, so each frame's image-LOD
|
|
2
|
+
// (content/img-lod.ts) can freeze its bitmaps during motion and sharpen to the settled zoom afterwards.
|
|
3
|
+
//
|
|
4
|
+
// Fires ONCE per gesture, never per animation frame - it watches the single #sh-world.sh-camera class
|
|
5
|
+
// that EVERY camera path (rzpp zoom/pan, wheel, pinch, programmatic setTransform) toggles, so one hook
|
|
6
|
+
// covers them all. Posting a message to 150 frames every tick would be its own jank; two posts per
|
|
7
|
+
// gesture (start, settle) is free.
|
|
8
|
+
|
|
9
|
+
let lastScale = 1
|
|
10
|
+
/** Fed live from onTransformed so `settle` always broadcasts the final zoom. Cheap (a number write). */
|
|
11
|
+
export function setCameraScale(s: number): void { if (s > 0) lastScale = s }
|
|
12
|
+
|
|
13
|
+
function frameWindows(): Window[] {
|
|
14
|
+
const out: Window[] = []
|
|
15
|
+
for (const f of document.querySelectorAll<HTMLIFrameElement>('#sh-world iframe'))
|
|
16
|
+
if (f.contentWindow) out.push(f.contentWindow)
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function broadcast(moving: boolean): void {
|
|
21
|
+
const msg = moving ? { type: 'sh:camera', moving: true } : { type: 'sh:camera', moving: false, scale: lastScale }
|
|
22
|
+
for (const w of frameWindows()) { try { w.postMessage(msg, location.origin) } catch { /* cross-doc timing */ } }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let started = false
|
|
26
|
+
export function startCameraBroadcast(): void {
|
|
27
|
+
if (started) return
|
|
28
|
+
const world = document.getElementById('sh-world'); if (!world) return
|
|
29
|
+
started = true
|
|
30
|
+
let moving = false
|
|
31
|
+
new MutationObserver(() => {
|
|
32
|
+
const now = world.classList.contains('sh-camera')
|
|
33
|
+
if (now === moving) return
|
|
34
|
+
moving = now
|
|
35
|
+
broadcast(now) // +sh-camera -> freeze; -sh-camera -> sharpen to the settled scale
|
|
36
|
+
}).observe(world, { attributes: true, attributeFilter: ['class'] })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Prime ONE freshly-ready frame with the current settled scale, so a static board that is never zoomed
|
|
40
|
+
* still sharpens from its cheap low-res first paint to the right resolution. */
|
|
41
|
+
export function primeCameraFor(win: Window | null | undefined): void {
|
|
42
|
+
if (!win) return
|
|
43
|
+
try { win.postMessage({ type: 'sh:camera', moving: false, scale: lastScale }, location.origin) } catch { /* timing */ }
|
|
44
|
+
}
|