@marver-design/marver 0.4.0 → 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 +62 -0
- package/dist/{build-p3xmXU3b.mjs → build-BrCl9hJS.mjs} +5 -3
- package/dist/cli.mjs +3 -3
- package/dist/{dev-DZi1yRhn.mjs → dev-DdeU-Jst.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-BtSGAm2h.mjs} +55 -19
- package/package.json +2 -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 +40 -5
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +61 -13
- package/src/client/shell/Comments.tsx +5 -4
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +98 -8
- 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 +98 -6
- package/src/client/shell/styles.css +37 -7
- package/src/client/stage/main.tsx +2 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// SPEC-M5 slice 1: the DOM-snapshot serializer. Turns a live, same-origin frame Document into a
|
|
2
|
+
// self-contained STATIC html string: post-render DOM + full inlined CSS (styleSheets +
|
|
3
|
+
// adoptedStyleSheets), JS stripped. Rendered in a `sandbox="allow-same-origin"` (NO allow-scripts)
|
|
4
|
+
// iframe srcdoc, it reflows / theme-flips / device-sweeps with the browser's own layout engine and
|
|
5
|
+
// the app's own stylesheet - so color is identical (same tokens, no raster) and layout is real CSS.
|
|
6
|
+
//
|
|
7
|
+
// Runs in the SHELL (same origin as every frame), reading the live iframe's contentDocument directly
|
|
8
|
+
// - no bridge round-trip. Fail soft: any throw returns a `degraded` result the coordinator refuses to
|
|
9
|
+
// show. Correctness beats the flash-guard: a frame we cannot serialise faithfully stays live.
|
|
10
|
+
|
|
11
|
+
export interface ScrollEntry { sel: string; top: number; left: number }
|
|
12
|
+
export interface SerializeResult {
|
|
13
|
+
html: string
|
|
14
|
+
scrollMap: ScrollEntry[] // native scrollers to restore shell-side after load
|
|
15
|
+
degraded: string[] // 'canvas' | 'video' | 'shadow-dom' | 'cross-origin-css' | 'js-layout'
|
|
16
|
+
notes: string[]
|
|
17
|
+
cssBytes: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STRIP_TAGS = new Set(['SCRIPT', 'NOSCRIPT'])
|
|
21
|
+
|
|
22
|
+
/** Absolutize relative url() in a sheet's cssText against that sheet's href (codex P1: consolidating
|
|
23
|
+
* rules into one <style> moves the url() base from each sheet to the srcdoc base). */
|
|
24
|
+
function absolutizeUrls(css: string, sheetHref: string | null): string {
|
|
25
|
+
if (!sheetHref) return css
|
|
26
|
+
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, ref: string) => {
|
|
27
|
+
if (/^(data:|https?:|blob:|#|\/)/i.test(ref) || !ref.trim()) return m
|
|
28
|
+
try { return `url(${q}${new URL(ref, sheetHref).href}${q})` } catch { return m }
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Serialise one sheet's rules, recursing into same-origin @import (so imported CSS is inlined, not
|
|
33
|
+
* left as an invalid mid-list @import pointing at an un-inlined sheet). Bumps `xo` on any unreadable
|
|
34
|
+
* (cross-origin) sheet or import so the caller can degrade. */
|
|
35
|
+
function rulesText(rules: CSSRuleList, href: string | null, xo: { n: number }): string {
|
|
36
|
+
let text = ''
|
|
37
|
+
for (const r of Array.from(rules)) {
|
|
38
|
+
if (r.type === 3 /* CSSRule.IMPORT_RULE */) {
|
|
39
|
+
const imp = r as CSSImportRule & { supportsText?: string; layerName?: string | null }
|
|
40
|
+
const sheet = imp.styleSheet
|
|
41
|
+
if (!sheet) { xo.n++; continue }
|
|
42
|
+
let inner: string
|
|
43
|
+
try { inner = rulesText(sheet.cssRules, sheet.href ?? href, xo) } catch { xo.n++; continue }
|
|
44
|
+
// preserve the import's conditions - flattening them makes a conditional (e.g. desktop-only,
|
|
45
|
+
// layered, feature-gated) sheet globally active and mis-render the lean.
|
|
46
|
+
if (imp.layerName != null) inner = `@layer${imp.layerName ? ' ' + imp.layerName : ''}{\n${inner}\n}`
|
|
47
|
+
if (imp.supportsText) inner = `@supports (${imp.supportsText}){\n${inner}\n}`
|
|
48
|
+
const m = imp.media?.mediaText
|
|
49
|
+
if (m && m !== 'all') inner = `@media ${m}{\n${inner}\n}`
|
|
50
|
+
text += inner
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
text += r.cssText + '\n'
|
|
54
|
+
}
|
|
55
|
+
return absolutizeUrls(text, href)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Collect every CSS rule the document renders with: <style>/<link> sheets (recursing @import) AND
|
|
59
|
+
* constructable adoptedStyleSheets. Cross-origin sheets throw on .cssRules - record + degrade (never
|
|
60
|
+
* claim fidelity). Honours per-sheet media (`<link media=print>`) and skips disabled sheets. */
|
|
61
|
+
function collectCss(doc: Document, degraded: string[], notes: string[]): string {
|
|
62
|
+
const chunks: string[] = []
|
|
63
|
+
const xo = { n: 0 }
|
|
64
|
+
const dump = (sheet: CSSStyleSheet, href: string | null, label: string) => {
|
|
65
|
+
if (sheet.disabled) return
|
|
66
|
+
let rules: CSSRuleList | null = null
|
|
67
|
+
try { rules = sheet.cssRules } catch { xo.n++; return }
|
|
68
|
+
if (!rules) return
|
|
69
|
+
const text = rulesText(rules, href, xo)
|
|
70
|
+
if (!text) return
|
|
71
|
+
const media = sheet.media?.mediaText
|
|
72
|
+
chunks.push(`/* ${label} */\n${media && media !== 'all' ? `@media ${media}{\n${text}\n}` : text}`)
|
|
73
|
+
}
|
|
74
|
+
for (const s of Array.from(doc.styleSheets)) dump(s as CSSStyleSheet, s.href, s.href ?? 'inline')
|
|
75
|
+
const adopted = (doc as Document & { adoptedStyleSheets?: CSSStyleSheet[] }).adoptedStyleSheets ?? []
|
|
76
|
+
adopted.forEach((s, i) => dump(s, null, `adopted[${i}]`))
|
|
77
|
+
if (xo.n) { degraded.push('cross-origin-css'); notes.push(`${xo.n} cross-origin stylesheet(s)/import(s) unreadable`) }
|
|
78
|
+
if (adopted.length) notes.push(`${adopted.length} adoptedStyleSheet(s) inlined`)
|
|
79
|
+
return chunks.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A stable-ish selector for restoring scroll in the identical-structure lean doc. */
|
|
83
|
+
function selectorFor(el: Element): string {
|
|
84
|
+
const seg: string[] = []
|
|
85
|
+
for (let cur: Element | null = el; cur && cur !== el.ownerDocument.documentElement; cur = cur.parentElement) {
|
|
86
|
+
if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
|
|
87
|
+
const tag = cur.tagName.toLowerCase()
|
|
88
|
+
let n = 1
|
|
89
|
+
for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling) if (sib.tagName === cur.tagName) n++
|
|
90
|
+
seg.unshift(`${tag}:nth-of-type(${n})`)
|
|
91
|
+
}
|
|
92
|
+
return seg.join('>')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Strip execution + inline handlers from the clone; flag content that cannot reflow as DOM. */
|
|
96
|
+
function scrub(root: Element, doc: Document, degraded: string[]): void {
|
|
97
|
+
root.querySelectorAll('script, noscript, link[rel~="modulepreload"], link[rel~="preload"], link[rel~="stylesheet"]').forEach((n) => n.remove())
|
|
98
|
+
const walk = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
|
|
99
|
+
const flag = { canvas: false, video: false, nested: false }
|
|
100
|
+
for (let el = walk.currentNode as Element | null; el; el = walk.nextNode() as Element | null) {
|
|
101
|
+
for (const attr of Array.from(el.attributes)) {
|
|
102
|
+
if (/^on/i.test(attr.name)) el.removeAttribute(attr.name)
|
|
103
|
+
else if (/^\s*javascript:/i.test(attr.value)) el.removeAttribute(attr.name)
|
|
104
|
+
}
|
|
105
|
+
const tag = el.tagName
|
|
106
|
+
if (tag === 'CANVAS') flag.canvas = true
|
|
107
|
+
if (tag === 'VIDEO') flag.video = true
|
|
108
|
+
// a nested iframe/object/embed can't reflow in a scriptless clone (blank/stale, and would
|
|
109
|
+
// re-fetch third-party content) - degrade to live rather than ship a broken lean.
|
|
110
|
+
if (tag === 'IFRAME' || tag === 'OBJECT' || tag === 'EMBED') flag.nested = true
|
|
111
|
+
if (STRIP_TAGS.has(tag)) el.remove()
|
|
112
|
+
}
|
|
113
|
+
if (flag.canvas) degraded.push('canvas')
|
|
114
|
+
if (flag.video) degraded.push('video')
|
|
115
|
+
if (flag.nested) degraded.push('nested-frame')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Open shadow roots must be detected on the ORIGINAL tree - cloneNode(true) drops them, so the clone
|
|
119
|
+
* can never reveal them. A frame with any shadow DOM (open here, or closed via the boot-time flag)
|
|
120
|
+
* degrades to live rather than shipping a lean copy missing its shadow content. */
|
|
121
|
+
function hasOpenShadow(doc: Document): boolean {
|
|
122
|
+
const walk = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_ELEMENT)
|
|
123
|
+
for (let el = walk.currentNode as Element | null; el; el = walk.nextNode() as Element | null)
|
|
124
|
+
if ((el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot) return true
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Copy live form PROPERTIES (value/checked/selected) into the clone. They are runtime state, not
|
|
129
|
+
* attributes, so cloneNode never carries them - without this a re-captured frame shows empty inputs
|
|
130
|
+
* and unchecked boxes. Original and clone share structure, so we pair them in document order.
|
|
131
|
+
* tagName/type checks (not instanceof) because the clone nodes are owned by the frame's realm. */
|
|
132
|
+
function syncFormState(doc: Document, clone: HTMLElement): void {
|
|
133
|
+
const live = doc.querySelectorAll('input, textarea, option')
|
|
134
|
+
const dst = clone.querySelectorAll('input, textarea, option')
|
|
135
|
+
live.forEach((src, i) => {
|
|
136
|
+
const d = dst[i]
|
|
137
|
+
if (!d || d.tagName !== src.tagName) return
|
|
138
|
+
if (src.tagName === 'INPUT') {
|
|
139
|
+
const s = src as HTMLInputElement
|
|
140
|
+
// never bake a password into the retained snapshot html; a file input can't be reconstructed
|
|
141
|
+
if (s.type === 'password' || s.type === 'file') { d.removeAttribute('value'); return }
|
|
142
|
+
if (s.type === 'checkbox' || s.type === 'radio') s.checked ? d.setAttribute('checked', '') : d.removeAttribute('checked')
|
|
143
|
+
else d.setAttribute('value', s.value)
|
|
144
|
+
} else if (src.tagName === 'TEXTAREA') { d.textContent = (src as HTMLTextAreaElement).value }
|
|
145
|
+
else if (src.tagName === 'OPTION') { (src as HTMLOptionElement).selected ? d.setAttribute('selected', '') : d.removeAttribute('selected') }
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Serialize a live same-origin Document into a static, self-contained html string.
|
|
151
|
+
* @param doc the live frame document (same origin - we read its cssRules directly)
|
|
152
|
+
* @param baseHref the frame's real URL, injected as <base> so relative url()/img/font resolve
|
|
153
|
+
*/
|
|
154
|
+
export function serializeDoc(doc: Document, baseHref: string): SerializeResult {
|
|
155
|
+
const notes: string[] = []
|
|
156
|
+
const degraded: string[] = []
|
|
157
|
+
|
|
158
|
+
// shadow DOM (open detected on the original tree, closed via the boot-time attachShadow flag):
|
|
159
|
+
// either kind degrades the frame to live - cloneNode cannot carry a shadow root.
|
|
160
|
+
const win = doc.defaultView as (Window & { __mvClosedShadow?: boolean }) | null
|
|
161
|
+
if (win?.__mvClosedShadow || hasOpenShadow(doc)) { degraded.push('shadow-dom'); notes.push('shadow root(s) present') }
|
|
162
|
+
|
|
163
|
+
const css = collectCss(doc, degraded, notes)
|
|
164
|
+
const cssBytes = new Blob([css]).size
|
|
165
|
+
|
|
166
|
+
// scroll offsets to restore shell-side (native scrollers; a virtualised one that fails to resolve
|
|
167
|
+
// OR whose offset does not stick re-degrades the frame in the coordinator). != 0 catches RTL/negative.
|
|
168
|
+
const scrollMap: ScrollEntry[] = []
|
|
169
|
+
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('*'))) {
|
|
170
|
+
if (el.scrollTop !== 0 || el.scrollLeft !== 0) scrollMap.push({ sel: selectorFor(el), top: el.scrollTop, left: el.scrollLeft })
|
|
171
|
+
}
|
|
172
|
+
const de = doc.documentElement
|
|
173
|
+
if (de.scrollTop !== 0 || de.scrollLeft !== 0) scrollMap.push({ sel: ':root', top: de.scrollTop, left: de.scrollLeft })
|
|
174
|
+
|
|
175
|
+
// clone the RENDERED dom (React's committed output = authored markup that reflows under CSS)
|
|
176
|
+
const html = doc.documentElement.cloneNode(true) as HTMLElement
|
|
177
|
+
scrub(html, doc, degraded)
|
|
178
|
+
syncFormState(doc, html)
|
|
179
|
+
|
|
180
|
+
// head: <meta charset> + <base> (relative url()/img/font resolve against the real frame URL) +
|
|
181
|
+
// ONE inlined stylesheet (both themes, all media queries). Drop the clone's own style nodes.
|
|
182
|
+
const head = html.querySelector('head') ?? html.insertBefore(doc.createElement('head'), html.firstChild)
|
|
183
|
+
head.querySelectorAll('style').forEach((n) => n.remove())
|
|
184
|
+
// sentinel first rule: the coordinator checks --mv-lean-ok resolves before showing the lean. If a
|
|
185
|
+
// hardened host's CSP (style-src 'self') blocked this inline <style>, the sentinel is absent and the
|
|
186
|
+
// frame stays live rather than showing an unstyled cover.
|
|
187
|
+
const style = doc.createElement('style'); style.textContent = ':root{--mv-lean-ok:1}\n' + css
|
|
188
|
+
const base = doc.createElement('base'); base.setAttribute('href', baseHref)
|
|
189
|
+
const meta = doc.createElement('meta'); meta.setAttribute('charset', 'utf-8')
|
|
190
|
+
head.insertBefore(style, head.firstChild)
|
|
191
|
+
head.insertBefore(base, head.firstChild)
|
|
192
|
+
head.insertBefore(meta, head.firstChild)
|
|
193
|
+
|
|
194
|
+
return { html: '<!doctype html>\n' + html.outerHTML, scrollMap, degraded: Array.from(new Set(degraded)), notes, cssBytes }
|
|
195
|
+
}
|
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
|
|
|
@@ -501,6 +513,13 @@ export function App() {
|
|
|
501
513
|
useEffect(() => {
|
|
502
514
|
if (!import.meta.hot) return
|
|
503
515
|
import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
|
|
516
|
+
// A7 controlled HMR: a frame file changed. Reload exactly the affected frames through the
|
|
517
|
+
// lease-aware path (idle frames now, leased ones deferred to a safe point) - never a shell
|
|
518
|
+
// reload, never a React Fast Refresh yanking a frame the user is in.
|
|
519
|
+
import.meta.hot.on('sh:frame-invalidated', (m: any) => {
|
|
520
|
+
const frameIds = Array.isArray(m?.frameIds) ? m.frameIds.filter((x: unknown) => typeof x === 'string') : []
|
|
521
|
+
if (frameIds.length && typeof m?.revision === 'string') useStore.getState().invalidateFrames(frameIds, m.revision)
|
|
522
|
+
})
|
|
504
523
|
// multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
|
|
505
524
|
// re-boots silently, keeping whatever selection survives; a dirty one keeps its
|
|
506
525
|
// edits and converges through the 409 path on its next save (disk wins, spec §8).
|
|
@@ -521,11 +540,11 @@ export function App() {
|
|
|
521
540
|
const onMsg = (e: MessageEvent) => {
|
|
522
541
|
const data = e.data
|
|
523
542
|
if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
543
|
+
// B0.3: O(1) registry lookup - also the security gate (unknown source = not a
|
|
544
|
+
// registered frame = dropped), replacing a per-message iframe scan + DOM walk.
|
|
545
|
+
const reg = frameByWindow(e.source)
|
|
546
|
+
if (!reg) return
|
|
547
|
+
const { key: nodeKey, iframe: el } = reg
|
|
529
548
|
const s = useStore.getState()
|
|
530
549
|
|
|
531
550
|
if (data.type === 'sh:ready') {
|
|
@@ -579,6 +598,29 @@ export function App() {
|
|
|
579
598
|
// walking a flow must not eject you to design mode at every hop
|
|
580
599
|
if (s.interact === nodeKey) setInteract(node.key)
|
|
581
600
|
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
601
|
+
} else if (data.type === 'sh:wheel') {
|
|
602
|
+
// B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
|
|
603
|
+
// interact target or play (the app scrolls itself - the parent stays authoritative).
|
|
604
|
+
if (s.play || s.interact === nodeKey) return
|
|
605
|
+
const nums = [data.deltaX, data.deltaY, data.clientX, data.clientY].map(Number)
|
|
606
|
+
if (!nums.every(Number.isFinite)) return
|
|
607
|
+
const [deltaX, deltaY, localX, localY] = nums
|
|
608
|
+
const rect = el.getBoundingClientRect()
|
|
609
|
+
// iframe-local (untransformed CSS px) -> shell-screen px: rect is the frame's
|
|
610
|
+
// transformed size, so rect.width/clientWidth is its effective on-screen scale.
|
|
611
|
+
// Plain rect.left+localX would put the zoom origin in the wrong place when zoomed.
|
|
612
|
+
const sx = el.clientWidth ? rect.width / el.clientWidth : 1
|
|
613
|
+
const sy = el.clientHeight ? rect.height / el.clientHeight : 1
|
|
614
|
+
canvasCtl.wheel({
|
|
615
|
+
deltaX, deltaY, deltaMode: Number(data.deltaMode) || 0,
|
|
616
|
+
ctrlKey: !!data.ctrlKey, metaKey: !!data.metaKey,
|
|
617
|
+
clientX: rect.left + localX * sx, clientY: rect.top + localY * sy,
|
|
618
|
+
})
|
|
619
|
+
} else if (data.type === 'sh:interaction') {
|
|
620
|
+
// A6: the frame reports transient laser/comment engagement (pointer inside + mode on).
|
|
621
|
+
// While engaged the frame is leased, so a hot update to it defers until disengage.
|
|
622
|
+
s.setExternalLease(nodeKey, 'laser', !!data.laser)
|
|
623
|
+
s.setExternalLease(nodeKey, 'comment', !!data.comment)
|
|
582
624
|
}
|
|
583
625
|
}
|
|
584
626
|
window.addEventListener('message', onMsg)
|
|
@@ -609,7 +651,7 @@ export function App() {
|
|
|
609
651
|
s.setLaser(!s.laser)
|
|
610
652
|
}
|
|
611
653
|
// C = comment mode (SPEC-M3 §6, the Figma/Miro convention) · Shift+C = hide/show
|
|
612
|
-
// pins · copy
|
|
654
|
+
// pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
|
|
613
655
|
if (e.key === 'c' && !e.shiftKey) {
|
|
614
656
|
const c = commentsStore()
|
|
615
657
|
if (!c.commentMode) s.setLaser(false)
|
|
@@ -617,7 +659,7 @@ export function App() {
|
|
|
617
659
|
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
618
660
|
}
|
|
619
661
|
if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
|
|
620
|
-
if (e.key === '
|
|
662
|
+
if (e.key === 'P' && e.shiftKey && s.selection.length) {
|
|
621
663
|
const files = s.selection
|
|
622
664
|
.map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
|
|
623
665
|
.filter((f): f is string => !!f)
|
|
@@ -702,8 +744,14 @@ export function App() {
|
|
|
702
744
|
mounted so collapse/expand can crossfade-morph between them */}
|
|
703
745
|
<aside className={`sh-panel${panelOpen ? '' : ' closed'}`} aria-hidden={!panelOpen}>
|
|
704
746
|
<div className="sh-panel-top">
|
|
705
|
-
|
|
706
|
-
|
|
747
|
+
{/* the mark links to the marver site; the title is the humanized repo name (C2: names
|
|
748
|
+
the project so two concurrent canvases are never confused), ellipsed if long */}
|
|
749
|
+
<Tip side="bottom" label="marver.design">
|
|
750
|
+
<a className="mark-link" href="https://marver.design" target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
|
|
751
|
+
<ParallelogramDuoIcon size={21} className="mark" />
|
|
752
|
+
</a>
|
|
753
|
+
</Tip>
|
|
754
|
+
<span className="name" title={CONFIG.projectName || 'Marver'}>{CONFIG.projectName ? humanize(CONFIG.projectName) : 'Marver'}</span>
|
|
707
755
|
<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
756
|
</div>
|
|
709
757
|
<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,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">
|