@marver-design/marver 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/dist/{build-BrCl9hJS.mjs → build-BZaPa2DS.mjs} +7 -3
- package/dist/cli.mjs +2 -2
- package/dist/{dev-DdeU-Jst.mjs → dev-DaPQ9xA5.mjs} +1 -1
- package/dist/{plugin-BtSGAm2h.mjs → plugin-wMY9lNf3.mjs} +12 -1
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +107 -0
- package/src/client/content/index.tsx +31 -7
- package/src/client/frame-host/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +346 -0
- package/src/client/shell/App.tsx +39 -115
- package/src/client/shell/Comments.tsx +56 -11
- package/src/client/shell/Play.tsx +214 -155
- package/src/client/shell/Toolbar.tsx +189 -0
- package/src/client/shell/canvas/Canvas.tsx +4 -2
- package/src/client/shell/canvas/FrameNode.tsx +32 -2
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +32 -16
- package/src/client/shell/styles.css +63 -10
- package/src/client/stage/main.tsx +35 -11
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/shape.md +21 -19
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
2
|
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
4
3
|
import { Tip } from './Tip.tsx'
|
|
5
4
|
import { PKG, ROUTE } from '../const.ts'
|
|
@@ -7,9 +6,10 @@ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
|
7
6
|
import { frameByWindow } from './canvas/frame-registry.ts'
|
|
8
7
|
import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
9
8
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
10
|
-
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon,
|
|
9
|
+
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
11
10
|
import { CommentsController } from './Comments.tsx'
|
|
12
11
|
import { useComments } from './comments-store.ts'
|
|
12
|
+
import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
|
|
13
13
|
|
|
14
14
|
const commentsStore = () => useComments.getState()
|
|
15
15
|
|
|
@@ -48,41 +48,6 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Shared popover machinery: trigger position, outside-click close, portal to the app
|
|
52
|
-
* root (glass never nests - a nested backdrop-filter cannot sample the page). */
|
|
53
|
-
function usePopover() {
|
|
54
|
-
const [open, setOpen] = useState(false)
|
|
55
|
-
const [pos, setPos] = useState({ left: 0, top: 0 })
|
|
56
|
-
const boxRef = useRef<HTMLDivElement>(null)
|
|
57
|
-
const menuRef = useRef<HTMLDivElement>(null)
|
|
58
|
-
const toggle = () => {
|
|
59
|
-
if (!open && boxRef.current) {
|
|
60
|
-
const r = boxRef.current.getBoundingClientRect()
|
|
61
|
-
setPos({ left: r.left, top: r.bottom + 10 })
|
|
62
|
-
}
|
|
63
|
-
setOpen(!open)
|
|
64
|
-
}
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
if (!open) return
|
|
67
|
-
const close = (e: PointerEvent) => {
|
|
68
|
-
const t = e.target as globalThis.Node
|
|
69
|
-
if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false)
|
|
70
|
-
}
|
|
71
|
-
window.addEventListener('pointerdown', close)
|
|
72
|
-
return () => window.removeEventListener('pointerdown', close)
|
|
73
|
-
}, [open])
|
|
74
|
-
return { open, setOpen, pos, boxRef, menuRef, toggle }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function Popover({ pop, children }: { pop: ReturnType<typeof usePopover>; children: ReactNode }) {
|
|
78
|
-
const app = document.querySelector('.sh-app')
|
|
79
|
-
if (!pop.open || !app) return null
|
|
80
|
-
return createPortal(
|
|
81
|
-
<div className="sh-menu" ref={pop.menuRef} style={{ left: pop.pos.left, top: pop.pos.top }}>{children}</div>,
|
|
82
|
-
app,
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
51
|
/** Boards live flat in the sidebar - always visible, one click to switch. The list
|
|
87
52
|
* refreshes on mount, window focus, and a slow poll so agent-created board files
|
|
88
53
|
* appear without a reload. Active board = accent icon + wash, same language as scenes. */
|
|
@@ -213,12 +178,12 @@ function SelectionBar() {
|
|
|
213
178
|
}
|
|
214
179
|
|
|
215
180
|
/** Devices view: one click sizes frames to a device width, tidies, and fits. Scoped
|
|
216
|
-
* like the digit keys: the selection when one exists, the whole board otherwise.
|
|
181
|
+
* like the digit keys: the selection when one exists, the whole board otherwise. The
|
|
182
|
+
* dropdown chrome is the shared DevicePicker; this adapter owns the canvas wiring. */
|
|
217
183
|
function DeviceMenu() {
|
|
218
184
|
const deviceView = useStore((s) => s.deviceView)
|
|
219
185
|
const selection = useStore((s) => s.selection)
|
|
220
186
|
const nodes = useStore((s) => s.nodes)
|
|
221
|
-
const pop = usePopover()
|
|
222
187
|
const scoped = selection.length > 0
|
|
223
188
|
const pick = (name: string | null) => {
|
|
224
189
|
animateLayout()
|
|
@@ -230,7 +195,6 @@ function DeviceMenu() {
|
|
|
230
195
|
st.setDeviceView(name)
|
|
231
196
|
setTimeout(() => canvasCtl.fitAll(), 30)
|
|
232
197
|
}
|
|
233
|
-
pop.setOpen(false)
|
|
234
198
|
}
|
|
235
199
|
const entries = Object.entries(CONFIG.viewports)
|
|
236
200
|
// active check: board-wide it is the device view; scoped, the device every selected frame wears
|
|
@@ -238,31 +202,8 @@ function DeviceMenu() {
|
|
|
238
202
|
const active = scoped
|
|
239
203
|
? entries.find(([, vp]) => selNodes.length > 0 && selNodes.every((n) => n.w === vp.width))?.[0] ?? null
|
|
240
204
|
: deviceView
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
<Tip side="bottom" label={<><b>Device view</b><span>{scoped ? `${selection.length} selected` : deviceView ? `${cap(deviceView)} · 0 resets` : `keys 1-${entries.length}`}</span></>}>
|
|
244
|
-
<button className="sh-pill-btn" onClick={pop.toggle}>
|
|
245
|
-
{deviceIcon(active, 16)}
|
|
246
|
-
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
247
|
-
</button>
|
|
248
|
-
</Tip>
|
|
249
|
-
<Popover pop={pop}>
|
|
250
|
-
<Tip side="bottom" label={scoped ? 'Selected frames at their default sizes' : 'Every frame at its own default size'}>
|
|
251
|
-
<button onClick={() => pick(null)}>
|
|
252
|
-
<DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
|
|
253
|
-
{!scoped && deviceView === null && <CheckIcon size={13} className="chk" />}
|
|
254
|
-
</button>
|
|
255
|
-
</Tip>
|
|
256
|
-
<i className="div" />
|
|
257
|
-
{entries.map(([name, vp], i) => (
|
|
258
|
-
<button key={name} onClick={() => pick(name)} title={`${vp.width} × ${vp.height}`}>
|
|
259
|
-
{deviceIcon(name)}<span>{cap(name)}</span><kbd>{i < 9 ? i + 1 : ''}</kbd>
|
|
260
|
-
{active === name && <CheckIcon size={13} className="chk" />}
|
|
261
|
-
</button>
|
|
262
|
-
))}
|
|
263
|
-
</Popover>
|
|
264
|
-
</div>
|
|
265
|
-
)
|
|
205
|
+
const hint = scoped ? `${selection.length} selected` : deviceView ? cap(deviceView) : `keys 1-${entries.length}`
|
|
206
|
+
return <DevicePicker value={active} onSelect={pick} includeDefault hint={hint} />
|
|
266
207
|
}
|
|
267
208
|
|
|
268
209
|
/** Update pill (dev only): the daily registry check surfaces here - same glass, same
|
|
@@ -337,46 +278,25 @@ function ZoomMenu() {
|
|
|
337
278
|
/** Theme dropdown, scoped like the device digits: the selection when one exists, every
|
|
338
279
|
* frame otherwise. The trigger reflects the scope's MAJORITY (per-frame overrides never
|
|
339
280
|
* flip a board-level trigger - it reports the level it acts on, same rule as the shell).
|
|
340
|
-
* The
|
|
341
|
-
* so a nested backdrop-filter samples the pill's surface instead of the page - flat grey. */
|
|
281
|
+
* The dropdown chrome is the shared ThemePicker; this adapter owns the canvas wiring. */
|
|
342
282
|
function ThemeMenu() {
|
|
343
283
|
const nodes = useStore((s) => s.nodes)
|
|
344
284
|
const selection = useStore((s) => s.selection)
|
|
345
285
|
const viewTheme = useStore((s) => s.viewTheme)
|
|
346
|
-
const pop = usePopover()
|
|
347
286
|
const scoped = selection.length > 0
|
|
348
|
-
// scoped: the trigger + check reflect the selection; global: the VIEW preference
|
|
349
287
|
const scope = scoped ? nodes.filter((n) => selection.includes(n.key)) : []
|
|
350
|
-
const uniform = scoped
|
|
351
|
-
? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null)
|
|
352
|
-
: viewTheme
|
|
353
288
|
const majority = scoped
|
|
354
289
|
? (scope.length ? [...scope.reduce((m, n) => m.set(n.theme, (m.get(n.theme) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1])[0][0] : viewTheme)
|
|
355
290
|
: viewTheme
|
|
291
|
+
// the tick shows only a UNIFORM scope (mixed selection = no theme is "the" one), while
|
|
292
|
+
// the trigger reports the majority - the level the control acts on
|
|
293
|
+
const uniform = scoped ? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null) : viewTheme
|
|
356
294
|
const pick = (t: string) => {
|
|
357
295
|
const st = useStore.getState()
|
|
358
296
|
scoped ? st.setSelectedTheme(t) : st.setTheme(t)
|
|
359
|
-
pop.setOpen(false)
|
|
360
297
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
<Tip side="bottom" label={<><b>Theme</b><span>{scoped ? `${selection.length} selected · D` : 'all frames · D'}</span></>}>
|
|
364
|
-
<button className="sh-pill-btn" onClick={pop.toggle}>
|
|
365
|
-
{majority === 'dark' ? <MoonIcon size={16} /> : <SunIcon size={16} />}
|
|
366
|
-
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
367
|
-
</button>
|
|
368
|
-
</Tip>
|
|
369
|
-
<Popover pop={pop}>
|
|
370
|
-
{CONFIG.themes.map((t) => (
|
|
371
|
-
<button key={t} onClick={() => pick(t)}>
|
|
372
|
-
{t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
|
|
373
|
-
<span>{t}</span>
|
|
374
|
-
{uniform === t && <CheckIcon size={13} className="chk" />}
|
|
375
|
-
</button>
|
|
376
|
-
))}
|
|
377
|
-
</Popover>
|
|
378
|
-
</div>
|
|
379
|
-
)
|
|
298
|
+
const hint = scoped ? `${selection.length} selected · D` : 'all frames · D'
|
|
299
|
+
return <ThemePicker value={majority} checked={uniform} onSelect={pick} hint={hint} />
|
|
380
300
|
}
|
|
381
301
|
|
|
382
302
|
export function App() {
|
|
@@ -403,12 +323,21 @@ export function App() {
|
|
|
403
323
|
useEffect(() => {
|
|
404
324
|
if (booted) return
|
|
405
325
|
booted = true
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
326
|
+
const start = async () => {
|
|
327
|
+
if (bootHash.board) {
|
|
328
|
+
if (bootHash.board !== useStore.getState().board) // a deep link wins
|
|
329
|
+
useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
|
|
330
|
+
} else if (!PUBLISHED) {
|
|
331
|
+
// fresh open, no deep link: LAND on the first curated board (a tight, fast board = a good first
|
|
332
|
+
// impression) instead of the auto all-scenes everything-board, which renders every frame at once.
|
|
333
|
+
const first = (await fetchBoardNames().catch(() => [] as string[])).find((n) => n !== 'all-scenes')
|
|
334
|
+
if (first && first !== useStore.getState().board) useStore.setState({ board: first, boardAuto: false })
|
|
335
|
+
}
|
|
336
|
+
const ok = await boot()
|
|
409
337
|
urlReady.current = true
|
|
410
|
-
if (ok && bootHash.play) enterPlay(bootHash.play)
|
|
411
|
-
}
|
|
338
|
+
if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
|
|
339
|
+
}
|
|
340
|
+
void start()
|
|
412
341
|
}, [])
|
|
413
342
|
|
|
414
343
|
// the URL is a projection of state: design views replace in place; entering play and
|
|
@@ -540,6 +469,7 @@ export function App() {
|
|
|
540
469
|
const onMsg = (e: MessageEvent) => {
|
|
541
470
|
const data = e.data
|
|
542
471
|
if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
|
|
472
|
+
if (e.origin && e.origin !== location.origin) return // a cross-origin-navigated frame keeps its WindowProxy; reject it
|
|
543
473
|
// B0.3: O(1) registry lookup - also the security gate (unknown source = not a
|
|
544
474
|
// registered frame = dropped), replacing a per-message iframe scan + DOM walk.
|
|
545
475
|
const reg = frameByWindow(e.source)
|
|
@@ -640,9 +570,13 @@ export function App() {
|
|
|
640
570
|
if (e.key === 'Escape') {
|
|
641
571
|
const c = commentsStore()
|
|
642
572
|
if (c.commentMode || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null) }
|
|
573
|
+
else if (s.laser) s.setLaser(false) // Escape also exits laser mode (parity with comment)
|
|
643
574
|
else s.interact ? setInteract(null) : select(null)
|
|
644
575
|
}
|
|
645
576
|
if (e.key === 'p') enterPlay()
|
|
577
|
+
// H hides all chrome (shared binary Hide-UI); press again to reveal. Not persisted,
|
|
578
|
+
// so a refresh always restores it (the safety net for a forgotten toggle).
|
|
579
|
+
if (e.key === 'h') { toggleHideUI(); return }
|
|
646
580
|
if (e.key === 't') { animateLayout(); runTidy() }
|
|
647
581
|
// laser and comment mode are one-at-a-time: comment mode already highlights
|
|
648
582
|
// what you'd click, so stacking the full rainbow on top only adds noise
|
|
@@ -860,33 +794,23 @@ export function App() {
|
|
|
860
794
|
|
|
861
795
|
{/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
|
|
862
796
|
<nav className={`sh-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
|
|
863
|
-
{/* far-left section:
|
|
864
|
-
<
|
|
865
|
-
|
|
866
|
-
const c = commentsStore()
|
|
867
|
-
if (!c.commentMode) useStore.getState().setLaser(false)
|
|
868
|
-
c.setMode(!c.commentMode)
|
|
869
|
-
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
870
|
-
}}><CommentIcon size={16} /></button>
|
|
871
|
-
</Tip>
|
|
872
|
-
<Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
|
|
873
|
-
<button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
|
|
874
|
-
if (!laser) commentsStore().setMode(false)
|
|
875
|
-
useStore.getState().setLaser(!laser)
|
|
876
|
-
}}><LaserIcon size={16} /></button>
|
|
877
|
-
</Tip>
|
|
797
|
+
{/* far-left section: actions - comment mode, laser, tidy, prototype */}
|
|
798
|
+
<CommentButton />
|
|
799
|
+
<LaserButton />
|
|
878
800
|
<Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
|
|
879
801
|
<button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><ColumnsIcon size={16} /></button>
|
|
880
802
|
</Tip>
|
|
803
|
+
<Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
|
|
804
|
+
<button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
|
|
805
|
+
</Tip>
|
|
881
806
|
<i className="sep" />
|
|
882
807
|
<DeviceMenu />
|
|
883
808
|
<ThemeMenu />
|
|
884
809
|
<i className="sep" />
|
|
885
810
|
<ZoomMenu />
|
|
886
811
|
<i className="sep" />
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
</Tip>
|
|
812
|
+
{/* far right: UI management only - hide, collapse */}
|
|
813
|
+
<HideUIButton />
|
|
890
814
|
<Tip side="bottom" label={<><b>Collapse toolbar</b><span>⌘/</span></>}>
|
|
891
815
|
<button className="sh-pill-btn" onClick={() => setPillOpen(false)} tabIndex={pillOpen ? 0 : -1}>
|
|
892
816
|
<PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />
|
|
@@ -14,6 +14,27 @@ import { Tip } from './Tip.tsx'
|
|
|
14
14
|
import { ROUTE } from '../const.ts'
|
|
15
15
|
import type { Thread } from '../../shared/events.ts'
|
|
16
16
|
|
|
17
|
+
/** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
|
|
18
|
+
* element's own laser hue (captured at pick as anchor.el.hue) via --cm-line/--cm-line-ring.
|
|
19
|
+
* Deliberately NOT the button fills (--comment stays green: white text on a saturated hue
|
|
20
|
+
* fails at yellow/green/cyan) and NOT the avatar (keeps its per-user colour). No hue -> the
|
|
21
|
+
* CSS falls back to the green token. */
|
|
22
|
+
export function hueVars(hue?: number): React.CSSProperties {
|
|
23
|
+
// guard NaN / Infinity from a bad persisted anchor - an invalid custom property would
|
|
24
|
+
// suppress the CSS fallback and leave the edge uncoloured
|
|
25
|
+
if (!Number.isFinite(hue)) return {}
|
|
26
|
+
const h = hue as number
|
|
27
|
+
// --cm-solid is the FILLED-button colour (send / sign-in): a hue-aware lightness so the
|
|
28
|
+
// white glyph keeps contrast - yellow/green/cyan read light, so they go darker.
|
|
29
|
+
const l = (h >= 40 && h < 75) ? 34 : (h >= 75 && h < 165) ? 36 : (h >= 165 && h < 200) ? 38 : 45
|
|
30
|
+
return {
|
|
31
|
+
'--cm-line': `hsl(${h} 95% 45%)`,
|
|
32
|
+
'--cm-line-ring': `hsl(${h} 95% 50% / .30)`,
|
|
33
|
+
'--cm-solid': `hsl(${h} 85% ${l}%)`,
|
|
34
|
+
} as React.CSSProperties
|
|
35
|
+
}
|
|
36
|
+
const anchorHue = (a: unknown): number | undefined => (a as any)?.el?.hue
|
|
37
|
+
|
|
17
38
|
const rel = (ts: number) => {
|
|
18
39
|
const m = Math.round((Date.now() - ts) / 60_000)
|
|
19
40
|
if (m < 1) return 'now'
|
|
@@ -53,7 +74,7 @@ function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
|
|
|
53
74
|
|
|
54
75
|
/** The face of a marker: up to `max` overlapping avatars + the total count when >1.
|
|
55
76
|
* One shape for pins (one thread, anchored) and stacks (all threads on a frame). */
|
|
56
|
-
function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
|
|
77
|
+
export function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
|
|
57
78
|
const { faces, count } = markerDigest(threads)
|
|
58
79
|
return (
|
|
59
80
|
<>
|
|
@@ -78,6 +99,9 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
78
99
|
|
|
79
100
|
const open = threads.filter((t) => !t.resolved)
|
|
80
101
|
const anchored = open.filter((t) => (t.anchor as any)?.el)
|
|
102
|
+
// whether the ACTIVE thread is still open on this frame - a dep for the highlight effect so
|
|
103
|
+
// a remote resolve (thread leaves `open` while `active` is unchanged) still clears the lock
|
|
104
|
+
const activeShown = !!active && open.some((t) => t.id === active)
|
|
81
105
|
|
|
82
106
|
// resolve anchors against the live frame whenever threads or the document change
|
|
83
107
|
useEffect(() => {
|
|
@@ -85,7 +109,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
85
109
|
if (!win || node.status !== 'ready' || !anchored.length) return
|
|
86
110
|
const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: anchored.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
|
|
87
111
|
const onMsg = (e: MessageEvent) => {
|
|
88
|
-
if (e.source !== win || e.data?.type !== 'sh:anchor-rects') return
|
|
112
|
+
if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects') return
|
|
89
113
|
const next: typeof rects = {}
|
|
90
114
|
for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.rect
|
|
91
115
|
setRects(next)
|
|
@@ -96,6 +120,23 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
96
120
|
return () => { clearInterval(iv); window.removeEventListener('message', onMsg) }
|
|
97
121
|
}, [node.status, anchored.map((t) => t.id).join(','), iframe])
|
|
98
122
|
|
|
123
|
+
// #4/#5: drive the persistent element highlight into this frame - the composing draft on
|
|
124
|
+
// this node (lock on pick), or the open thread anchored here (highlight on open). null
|
|
125
|
+
// clears it, so the shell owns the release; on a failed/late resolve the frame re-applies
|
|
126
|
+
// on its next anchor-resolve tick. The self-lock on pick handles the instant case.
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const win = iframe.current?.contentWindow
|
|
129
|
+
if (!win || node.status !== 'ready') return
|
|
130
|
+
// ONLY light an element while its thread is the active one (or a draft is composing on
|
|
131
|
+
// it) AND pins are shown - closing the thread or hiding comments (⇧C) clears it, so a
|
|
132
|
+
// stray outline never lingers on the frame.
|
|
133
|
+
const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null
|
|
134
|
+
const activeHere = active ? open.find((t) => t.id === active && (t.anchor as any)?.el) : undefined
|
|
135
|
+
const anchor = show ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
|
|
136
|
+
win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: anchor ?? null }, location.origin)
|
|
137
|
+
// active/draft/show/activeShown are the triggers; `open` is read fresh from the closure
|
|
138
|
+
}, [active, draft, show, activeShown, node.status, node.key, frameId, iframe])
|
|
139
|
+
|
|
99
140
|
const drafting = draft?.nodeKey === node.key
|
|
100
141
|
if (!show || (!open.length && !drafting)) return null
|
|
101
142
|
|
|
@@ -124,7 +165,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
124
165
|
const { x, y, orphan } = pinPos(t)
|
|
125
166
|
return (
|
|
126
167
|
<div key={t.id} className={`cm-pin sh-no-pan${t.id === active ? ' on' : ''}${orphan ? ' orphan' : ''}`}
|
|
127
|
-
style={{ left: x, top: y }}
|
|
168
|
+
style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
|
|
128
169
|
onPointerDown={(e) => e.stopPropagation()}
|
|
129
170
|
onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
|
|
130
171
|
title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
|
|
@@ -133,14 +174,16 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
133
174
|
)
|
|
134
175
|
})}
|
|
135
176
|
{active && open.some((t) => t.id === active) && (
|
|
136
|
-
<ThreadCard key={active} thread={open.find((t) => t.id === active)!} at={pinPos(open.find((t) => t.id === active)!)}
|
|
177
|
+
<ThreadCard key={active} thread={open.find((t) => t.id === active)!} at={pinPos(open.find((t) => t.id === active)!)} bounds={{ w: node.w, h: node.h }} />
|
|
137
178
|
)}
|
|
138
|
-
{draft?.nodeKey === node.key && <DraftComposer at={{ x: ((draft.anchor as any)?.rect?.x ?? 0) + ((draft.anchor as any)?.pos?.fx ?? 0.5) * ((draft.anchor as any)?.rect?.w ?? 0), y: ((draft.anchor as any)?.rect?.y ?? 0) + ((draft.anchor as any)?.pos?.fy ?? 0.5) * ((draft.anchor as any)?.rect?.h ?? 0) }}
|
|
179
|
+
{draft?.nodeKey === node.key && <DraftComposer at={{ x: ((draft.anchor as any)?.rect?.x ?? 0) + ((draft.anchor as any)?.pos?.fx ?? 0.5) * ((draft.anchor as any)?.rect?.w ?? 0), y: ((draft.anchor as any)?.rect?.y ?? 0) + ((draft.anchor as any)?.pos?.fy ?? 0.5) * ((draft.anchor as any)?.rect?.h ?? 0) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />}
|
|
139
180
|
</>
|
|
140
181
|
)
|
|
141
182
|
}
|
|
142
183
|
|
|
143
|
-
|
|
184
|
+
/** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and
|
|
185
|
+
* prototype (bounds = the on-screen stage size, `at` in screen coords). Geometry only. */
|
|
186
|
+
export function ThreadCard({ thread, at, bounds }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number } }) {
|
|
144
187
|
const { resolve, setActive } = useComments.getState()
|
|
145
188
|
const me = useComments((s) => s.me)
|
|
146
189
|
const local = useComments((s) => s.local)
|
|
@@ -149,7 +192,7 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
|
|
|
149
192
|
const [copied, setCopied] = useState(false)
|
|
150
193
|
const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
151
194
|
useEffect(() => () => clearTimeout(copyTimer.current), [])
|
|
152
|
-
const flip = at.x >
|
|
195
|
+
const flip = at.x > bounds.w * 0.55
|
|
153
196
|
// clear only after the server took it - a failed send must not eat the words,
|
|
154
197
|
// and text typed WHILE the request was in flight must survive the clear
|
|
155
198
|
const submit = async () => {
|
|
@@ -157,7 +200,7 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
|
|
|
157
200
|
if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
|
|
158
201
|
}
|
|
159
202
|
return (
|
|
160
|
-
<div data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14,
|
|
203
|
+
<div data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, bounds.h - 40), ...hueVars(anchorHue(thread.anchor)) }}
|
|
161
204
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
|
|
162
205
|
{/* thread-level actions pin to the card corner, out of the header's flow -
|
|
163
206
|
the name row never has to share its line with them */}
|
|
@@ -220,14 +263,16 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
|
|
|
220
263
|
)
|
|
221
264
|
}
|
|
222
265
|
|
|
223
|
-
|
|
266
|
+
/** The draft composer - shared by canvas and prototype, geometry via `bounds`, tinted with
|
|
267
|
+
* the picked element's hue. */
|
|
268
|
+
export function DraftComposer({ at, bounds, hue }: { at: { x: number; y: number }; bounds: { w: number; h: number }; hue?: number }) {
|
|
224
269
|
const { create, setDraft } = useComments.getState()
|
|
225
270
|
const [text, setText] = useState('')
|
|
226
271
|
const ref = useRef<HTMLInputElement>(null)
|
|
227
272
|
useEffect(() => { ref.current?.focus() }, [])
|
|
228
|
-
const flip = at.x >
|
|
273
|
+
const flip = at.x > bounds.w * 0.55
|
|
229
274
|
return (
|
|
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,
|
|
275
|
+
<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, bounds.h - 40), ...hueVars(hue) }}
|
|
231
276
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
|
232
277
|
<div className="cm-compose">
|
|
233
278
|
<div className="cm-inputwrap">
|