@marver-design/marver 0.18.0 → 0.19.1
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 +41 -0
- package/README.md +2 -1
- package/dist/{bake-jr38C_pX.mjs → bake-kaf5kGZ7.mjs} +1 -1
- package/dist/{build-ER_7T6Cw.mjs → build-7ed5H2vT.mjs} +40 -7
- package/dist/cli.mjs +3 -3
- package/dist/{daemon-CKhg0zuT.mjs → daemon-DbHvLQUL.mjs} +1 -1
- package/dist/{dev-BvLbY98O.mjs → dev-D3mP2x27.mjs} +6 -5
- package/dist/{init-BWbHqVng.mjs → init-BQYCS3EU.mjs} +1 -1
- package/dist/{manifest-DAnEL8_a.mjs → manifest-B01PSyDc.mjs} +33 -6
- package/dist/{plugin-Cai5C1WV.mjs → plugin-DI-7NAnx.mjs} +9 -9
- package/dist/{poster-CIuz_PwH.mjs → poster-DNh6N27C.mjs} +1 -1
- package/dist/{publish-bakes-D0LhbQQ3.mjs → publish-bakes-Dp-ZFk3d.mjs} +2 -2
- package/dist/{shot-iicees2e.mjs → shot-DMDvDbeP.mjs} +2 -2
- package/docs/sticky-notes.md +49 -0
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +27 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +48 -0
- package/src/client/shell/App.tsx +7 -63
- package/src/client/shell/Comments.tsx +75 -12
- package/src/client/shell/Play.tsx +6 -3
- package/src/client/shell/canvas/Canvas.tsx +10 -1
- package/src/client/shell/canvas/FrameNode.tsx +17 -0
- package/src/client/shell/canvas/Sticky.tsx +284 -0
- package/src/client/shell/goto.ts +72 -0
- package/src/client/shell/notes.ts +181 -0
- package/src/client/shell/store.ts +52 -25
- package/src/client/shell/styles.css +83 -0
- package/src/client/shell/tidy.ts +20 -2
- package/templates/AGENTS-embedded.md +18 -0
- package/templates/AGENTS-studio.md +18 -0
- package/templates/instructions/shape.md +69 -0
package/src/client/shell/App.tsx
CHANGED
|
@@ -13,6 +13,8 @@ import { humanize as slugLabel, readTitle } from '../../shared/board-tree.ts'
|
|
|
13
13
|
import { CommentsController, revealThread } from './Comments.tsx'
|
|
14
14
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
15
15
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
16
|
+
import { goTo } from './goto.ts'
|
|
17
|
+
import { useNotes } from './notes.ts'
|
|
16
18
|
import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
|
|
17
19
|
|
|
18
20
|
const commentsStore = () => useComments.getState()
|
|
@@ -599,17 +601,9 @@ export function App() {
|
|
|
599
601
|
if (c.active) c.setActive(null)
|
|
600
602
|
if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
|
|
601
603
|
} else if (data.type === 'sh:go') {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// design mode at every hop (and must survive a board switch)
|
|
606
|
-
const existing = s.nodes.find((n) => n.frame === target && !n.missing)
|
|
607
|
-
if (existing) {
|
|
608
|
-
gotoSeq++ // a local goto supersedes any cross-board one in flight
|
|
609
|
-
select(existing.key)
|
|
610
|
-
if (carry) setInteract(existing.key)
|
|
611
|
-
setTimeout(() => canvasCtl.fitNode(existing.key), 50)
|
|
612
|
-
} else void gotoAcrossBoards(target, carry)
|
|
604
|
+
// a goto from inside an interacting frame CARRIES interact mode to the target - walking
|
|
605
|
+
// a flow must not eject you to design mode at every hop (and must survive a board switch)
|
|
606
|
+
goTo(String(data.target ?? ''), s.interact === nodeKey)
|
|
613
607
|
} else if (data.type === 'sh:wheel') {
|
|
614
608
|
// B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
|
|
615
609
|
// interact target or play (the app scrolls itself - the parent stays authoritative).
|
|
@@ -681,6 +675,8 @@ export function App() {
|
|
|
681
675
|
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
682
676
|
}
|
|
683
677
|
if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
|
|
678
|
+
// sticky notes (spec 18): any note visible -> hide all; none -> show all
|
|
679
|
+
if (e.key === 'n' && !e.shiftKey) { const visible = document.querySelectorAll('.sh-notes:not(.off)').length > 0; useNotes.getState().toggleAll(visible); toast(visible ? 'notes hidden' : 'notes shown'); return }
|
|
684
680
|
// Shift+L = laser comment: the laser-sharp lighting on the element a comment tags
|
|
685
681
|
// (pick hover, compose lock, open-thread highlight). Pins and cards stay - this
|
|
686
682
|
// only dims the lighting inside the artwork.
|
|
@@ -1017,58 +1013,6 @@ function JamAvatar({ author }: { author: { name?: string; avatar?: string; id?:
|
|
|
1017
1013
|
: <span className="sh-jam-ava init" style={{ background: `hsl(${hue} 55% 45%)` }}>{initials}</span>
|
|
1018
1014
|
}
|
|
1019
1015
|
|
|
1020
|
-
/** A data-goto whose target frame is not on the current board follows the frame HOME:
|
|
1021
|
-
* the first curated board (switcher rank) that pins it is switched to and the frame
|
|
1022
|
-
* focused there - a link is navigation, and navigation never edits a board. Only a
|
|
1023
|
-
* frame NO board pins spawns onto the current board (the original prototype behavior
|
|
1024
|
-
* for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
|
|
1025
|
-
* bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
|
|
1026
|
-
let gotoSeq = 0
|
|
1027
|
-
async function gotoAcrossBoards(target: string, carry: boolean) {
|
|
1028
|
-
const s = useStore.getState()
|
|
1029
|
-
// an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
|
|
1030
|
-
// must not send us on a trip that ends in a silent timeout
|
|
1031
|
-
if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
|
|
1032
|
-
const seq = ++gotoSeq
|
|
1033
|
-
let home: string | null = null
|
|
1034
|
-
try {
|
|
1035
|
-
const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
|
|
1036
|
-
for (const name of names) {
|
|
1037
|
-
if ((await boardFrames(name)).includes(target)) { home = name; break }
|
|
1038
|
-
}
|
|
1039
|
-
} catch {
|
|
1040
|
-
// a transport failure is NOT proof the frame is unpinned - spawning here would
|
|
1041
|
-
// recreate the board mutation this function exists to prevent
|
|
1042
|
-
return useStore.getState().toast(`goto: could not read the boards - try again`)
|
|
1043
|
-
}
|
|
1044
|
-
if (seq !== gotoSeq) return // superseded by newer navigation
|
|
1045
|
-
if (!home) {
|
|
1046
|
-
// no curated board pins it - the original prototype behavior: spawn beside you
|
|
1047
|
-
const st = useStore.getState()
|
|
1048
|
-
const node = st.spawn(target)
|
|
1049
|
-
if (!node) return st.toast(`unknown goto target "${target}"`)
|
|
1050
|
-
st.select(node.key)
|
|
1051
|
-
if (carry) st.setInteract(node.key)
|
|
1052
|
-
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
1053
|
-
return
|
|
1054
|
-
}
|
|
1055
|
-
await s.switchBoard(home)
|
|
1056
|
-
for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
|
|
1057
|
-
if (seq !== gotoSeq) return
|
|
1058
|
-
const st = useStore.getState()
|
|
1059
|
-
if (st.board === home) { // a cancelled/failed switch must not select here
|
|
1060
|
-
const node = st.nodes.find((n) => n.frame === target && !n.missing)
|
|
1061
|
-
if (node) {
|
|
1062
|
-
st.select(node.key)
|
|
1063
|
-
if (carry) st.setInteract(node.key)
|
|
1064
|
-
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
1065
|
-
return
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
await new Promise((r) => setTimeout(r, 250))
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
1016
|
/** View from a notification: threads are frame-scoped, so first try to reveal RIGHT HERE
|
|
1073
1017
|
* (the current board may show the thread's frame); only when it doesn't, switch to the
|
|
1074
1018
|
* note's origin board and retry while its comments load. Dismiss only on success: a
|
|
@@ -9,6 +9,7 @@ import { createPortal } from 'react-dom'
|
|
|
9
9
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
10
10
|
import { useStore, type Node } from './store.ts'
|
|
11
11
|
import { threadHostKey } from './keys.ts'
|
|
12
|
+
import { isNoteAnchor, resolveNoteAnchor, useNotes } from './notes.ts'
|
|
12
13
|
import { canvasCtl } from './canvas/ctl.ts'
|
|
13
14
|
import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
|
|
14
15
|
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
|
|
@@ -138,11 +139,15 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
138
139
|
// a remote resolve (thread leaves `open` while `active` is unchanged) still clears the lock
|
|
139
140
|
const activeShown = !!active && open.some((t) => t.id === active)
|
|
140
141
|
|
|
142
|
+
// anchors split by where they live: in the frame document (inspect.js resolves them over
|
|
143
|
+
// postMessage) or on a sticky note (spec 18: shell DOM, resolved right here)
|
|
144
|
+
const inFrame = anchored.filter((t) => !isNoteAnchor(t.anchor))
|
|
145
|
+
const onNote = anchored.filter((t) => isNoteAnchor(t.anchor))
|
|
141
146
|
// resolve anchors against the live frame whenever threads or the document change
|
|
142
147
|
useEffect(() => {
|
|
143
148
|
const win = iframe.current?.contentWindow
|
|
144
|
-
if (!win || node.status !== 'ready' || !
|
|
145
|
-
const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors:
|
|
149
|
+
if (!win || node.status !== 'ready' || !inFrame.length) return
|
|
150
|
+
const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: inFrame.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
|
|
146
151
|
const onMsg = (e: MessageEvent) => {
|
|
147
152
|
if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects') return
|
|
148
153
|
const next: typeof rects = {}
|
|
@@ -153,7 +158,44 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
153
158
|
ask()
|
|
154
159
|
const iv = setInterval(ask, 4000) // re-renders, scroll, hot reloads - cheap to re-ask
|
|
155
160
|
return () => { clearInterval(iv); window.removeEventListener('message', onMsg) }
|
|
156
|
-
}, [node.status,
|
|
161
|
+
}, [node.status, inFrame.map((t) => t.id).join(','), iframe])
|
|
162
|
+
// note anchors: their own map, measured in the shell, in node-body coordinates (negative x -
|
|
163
|
+
// the note is left of the frame). The composing draft is measured too (key 'draft'), so it
|
|
164
|
+
// follows a fold like a saved pin. A folded column parks pins on the fold; a note whose file
|
|
165
|
+
// is gone, or whose element the markdown no longer has, parks like an orphan. Re-measured on
|
|
166
|
+
// every fold/unfold (twice: at once and past the transition) and on the same 4 s beat.
|
|
167
|
+
const [noteRects, setNoteRects] = useState<typeof rects>({})
|
|
168
|
+
const notesState = useNotes()
|
|
169
|
+
const draftOnNote = draft?.nodeKey === node.key && isNoteAnchor(draft.anchor) ? draft.anchor : null
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!onNote.length && !draftOnNote) { setNoteRects((r) => (Object.keys(r).length ? {} : r)); return }
|
|
172
|
+
const measure = () => {
|
|
173
|
+
const host = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`) as HTMLElement | null
|
|
174
|
+
const origin = host?.querySelector('.cm-layer')?.getBoundingClientRect()
|
|
175
|
+
if (!host || !origin) return
|
|
176
|
+
const s = origin.width ? origin.width / host.offsetWidth : 1
|
|
177
|
+
const toBody = (r: DOMRect) => ({ x: (r.left - origin.left) / s, y: (r.top - origin.top) / s, w: r.width / s, h: r.height / s })
|
|
178
|
+
const column = host.querySelector('.sh-notes')
|
|
179
|
+
const fold = column?.querySelector('.sh-notes-fold')
|
|
180
|
+
const place = (a: any) => {
|
|
181
|
+
const body = column?.querySelector(`[data-sticky="${a.el.note}"] .sh-sticky-body`)
|
|
182
|
+
if (!body) return null
|
|
183
|
+
if (column!.classList.contains('off')) return fold ? toBody(fold.getBoundingClientRect()) : null
|
|
184
|
+
const el = resolveNoteAnchor(a, body)
|
|
185
|
+
return el ? toBody(el.getBoundingClientRect()) : null
|
|
186
|
+
}
|
|
187
|
+
const next: typeof rects = {}
|
|
188
|
+
for (const t of onNote) next[t.id] = place(t.anchor)
|
|
189
|
+
if (draftOnNote) next.draft = place(draftOnNote)
|
|
190
|
+
setNoteRects(next)
|
|
191
|
+
}
|
|
192
|
+
measure()
|
|
193
|
+
const late = setTimeout(measure, 260)
|
|
194
|
+
const iv = setInterval(measure, 4000)
|
|
195
|
+
return () => { clearTimeout(late); clearInterval(iv) }
|
|
196
|
+
}, [onNote.map((t) => t.id).join(','), draftOnNote, notesState.all, notesState.hidden.join(','), node.key, node.w, node.h])
|
|
197
|
+
/** This node's sticky column when it is showing: the docked card must clear it (world px). */
|
|
198
|
+
const noteColumn = (): HTMLElement | null => document.querySelector(`[data-node-notes="${CSS.escape(node.key)}"]:not(.off)`)
|
|
157
199
|
|
|
158
200
|
// #4/#5: drive the persistent element highlight into this frame - the composing draft on
|
|
159
201
|
// this node (lock on pick), or the open thread anchored here (highlight on open). null
|
|
@@ -168,7 +210,15 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
168
210
|
const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null
|
|
169
211
|
const activeHere = active ? open.find((t) => t.id === active && (t.anchor as any)?.el) : undefined
|
|
170
212
|
const anchor = show && showAnchor ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
|
|
171
|
-
win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: anchor ?? null }, location.origin)
|
|
213
|
+
win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: isNoteAnchor(anchor) ? null : anchor ?? null }, location.origin)
|
|
214
|
+
// a note anchor lights its element in the shell the way the frame lights its own
|
|
215
|
+
const host = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)
|
|
216
|
+
for (const el of host?.querySelectorAll('[data-sh-lock]') ?? []) el.removeAttribute('data-sh-lock')
|
|
217
|
+
if (isNoteAnchor(anchor)) {
|
|
218
|
+
const body = host?.querySelector(`[data-sticky="${anchor.el.note}"] .sh-sticky-body`)
|
|
219
|
+
const el = body && resolveNoteAnchor(anchor, body)
|
|
220
|
+
if (el) el.setAttribute('data-sh-lock', '')
|
|
221
|
+
}
|
|
172
222
|
// active/draft/show/showAnchor/activeShown are the triggers; `open` is read fresh from the closure
|
|
173
223
|
}, [active, draft, show, showAnchor, activeShown, node.status, node.key, frameId, iframe])
|
|
174
224
|
|
|
@@ -181,6 +231,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
181
231
|
// badge is widest, the working shimmer next. Reactive, so a job starting mid-read adjusts.
|
|
182
232
|
const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
|
|
183
233
|
const flankShim = useStore((s) => s.working.includes(frameId))
|
|
234
|
+
// (a sticky column is read from the DOM at render: it is per viewer and per host node)
|
|
184
235
|
const drafting = draft?.nodeKey === node.key
|
|
185
236
|
const alertIds = useMentionAlerts()
|
|
186
237
|
if (!show || (!open.length && !drafting)) return null
|
|
@@ -198,7 +249,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
198
249
|
|
|
199
250
|
const pinPos = (t: Thread) => {
|
|
200
251
|
const a = t.anchor as any
|
|
201
|
-
const r = rects[t.id]
|
|
252
|
+
const r = isNoteAnchor(a) ? noteRects[t.id] : rects[t.id]
|
|
202
253
|
if (a?.el && r) return { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h, orphan: false }
|
|
203
254
|
if (a?.el && r === null) return { x: node.w - 16, y: 16, orphan: true } // orphan parks top-right
|
|
204
255
|
const p = a?.pos // frame-level: stored fraction of the frame
|
|
@@ -214,15 +265,23 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
214
265
|
const W = 320, GAP = 30
|
|
215
266
|
const rect = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)?.getBoundingClientRect()
|
|
216
267
|
if (!rect) return 'r'
|
|
268
|
+
// a showing sticky column sits in the left gutter in WORLD px (its width + 36, the CSS
|
|
269
|
+
// margin): the card lands beyond it, so both room and collision use that screen offset
|
|
270
|
+
const scale = rect.width / Math.max(1, node.w)
|
|
271
|
+
const col = noteColumn()
|
|
272
|
+
const gutter = col ? col.getBoundingClientRect().width + 36 * scale : 0
|
|
217
273
|
const occupied = (s: 'l' | 'r') => {
|
|
218
|
-
const rx = s === 'r' ? rect.right + 10 : rect.left - 10 - W
|
|
219
|
-
return [...document.querySelectorAll('.sh-node')].some((n) => {
|
|
220
|
-
if (n.getAttribute('data-node') === node.key) return false
|
|
274
|
+
const rx = s === 'r' ? rect.right + 10 : rect.left - gutter - 10 - W
|
|
275
|
+
return [...document.querySelectorAll('.sh-node, .sh-notes:not(.off)')].some((n) => {
|
|
276
|
+
if (n.getAttribute('data-node') === node.key || n.getAttribute('data-node-notes') === node.key) return false
|
|
221
277
|
const r = n.getBoundingClientRect()
|
|
222
278
|
return r.left < rx + W && r.right > rx && r.top < rect.bottom && r.bottom > rect.top
|
|
223
279
|
})
|
|
224
280
|
}
|
|
225
|
-
|
|
281
|
+
// the left room starts at the sidebar's edge, not the window's: a card parked under the
|
|
282
|
+
// panel is a card nobody can read (a note's gutter makes that reach likelier)
|
|
283
|
+
const panelRight = document.querySelector('.sh-panel:not(.closed)')?.getBoundingClientRect().right ?? 0
|
|
284
|
+
const room = (s: 'l' | 'r') => (s === 'r' ? window.innerWidth - rect.right : rect.left - gutter - panelRight) >= W + GAP
|
|
226
285
|
const prefer: ('l' | 'r')[] = pinPos(activeThread2).x < node.w / 2 ? ['l', 'r'] : ['r', 'l']
|
|
227
286
|
return prefer.find((s) => room(s) && !occupied(s)) ?? prefer.find(room) ?? prefer[0]
|
|
228
287
|
}
|
|
@@ -247,9 +306,13 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
247
306
|
})}
|
|
248
307
|
{activeThread2 && (
|
|
249
308
|
<ThreadCard key={active} thread={activeThread2} at={pinPos(activeThread2)} bounds={{ w: node.w, h: node.h }} nodeKey={node.key} side={cardSide}
|
|
250
|
-
flank={cardSide === 'l' ? (flankBadge ? 'badge' : flankShim ? 'shim' : null) : null} />
|
|
309
|
+
flank={cardSide === 'l' ? (noteColumn() ? 'note' : flankBadge ? 'badge' : flankShim ? 'shim' : null) : null} />
|
|
251
310
|
)}
|
|
252
|
-
{draft?.nodeKey === node.key &&
|
|
311
|
+
{draft?.nodeKey === node.key && (() => {
|
|
312
|
+
const a = draft.anchor as any
|
|
313
|
+
const r = (draftOnNote && noteRects.draft) || a?.rect
|
|
314
|
+
return <DraftComposer at={{ x: (r?.x ?? 0) + (a?.pos?.fx ?? 0.5) * (r?.w ?? 0), y: (r?.y ?? 0) + (a?.pos?.fy ?? 0.5) * (r?.h ?? 0) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />
|
|
315
|
+
})()}
|
|
253
316
|
</>
|
|
254
317
|
)
|
|
255
318
|
}
|
|
@@ -538,7 +601,7 @@ function ComposeAvatar() {
|
|
|
538
601
|
return <EditableAvatar author={me ?? undefined} size={24} />
|
|
539
602
|
}
|
|
540
603
|
|
|
541
|
-
export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, stage }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number }; nodeKey?: string; side?: 'l' | 'r'; flank?: 'badge' | 'shim' | null; stage?: boolean }) {
|
|
604
|
+
export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, stage }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number }; nodeKey?: string; side?: 'l' | 'r'; flank?: 'badge' | 'shim' | 'note' | null; stage?: boolean }) {
|
|
542
605
|
const { resolve, setActive } = useComments.getState()
|
|
543
606
|
const me = useComments((s) => s.me)
|
|
544
607
|
const local = useComments((s) => s.local)
|
|
@@ -14,6 +14,7 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
14
14
|
import { useStore, BOARD_POLICY, BRANDING, CONFIG, PUBLISHED, SOURCE_REVEALED, boardLabel, boardLocked, cap, fetchBoardNames, modeAllowed, playsAsSlides, SLIDE_INTRINSIC, type Node } from './store.ts'
|
|
15
15
|
import { deckOrder } from './play-order.ts'
|
|
16
16
|
import { useComments } from './comments-store.ts'
|
|
17
|
+
import { isNoteAnchor } from './notes.ts'
|
|
17
18
|
import { ROUTE } from '../const.ts'
|
|
18
19
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
19
20
|
import { canvasCtl } from './canvas/ctl.ts'
|
|
@@ -207,7 +208,9 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
|
|
|
207
208
|
const draft = useComments((s) => s.draft)
|
|
208
209
|
const allThreads = useComments((s) => s.threads)
|
|
209
210
|
const { setActive } = useComments.getState()
|
|
210
|
-
|
|
211
|
+
// a thread on a sticky note (spec 18) belongs to the canvas: the stage has no notes, and its
|
|
212
|
+
// anchor must never be handed to inspect.js (a same-named frame element would match)
|
|
213
|
+
const threads = allThreads.filter((t) => t.frame === frameId && !t.resolved && !isNoteAnchor(t.anchor))
|
|
211
214
|
const anchored = threads.filter((t) => (t.anchor as any)?.el)
|
|
212
215
|
const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
|
|
213
216
|
const sx = vp.width ? dw / vp.width : 1
|
|
@@ -245,7 +248,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
|
|
|
245
248
|
if (!win) return
|
|
246
249
|
// only while the thread is active (or composing) AND pins are shown AND element focus is
|
|
247
250
|
// on (⇧L) - closing, ⇧C, or dimming focus clears it
|
|
248
|
-
const at = !show || !showAnchor ? null : (draft?.frame === frameId ? draft.anchor
|
|
251
|
+
const at = !show || !showAnchor ? null : (draft?.frame === frameId && !isNoteAnchor(draft.anchor) ? draft.anchor
|
|
249
252
|
: (active ? (threads.find((t) => t.id === active && (t.anchor as any)?.el)?.anchor ?? null) : null))
|
|
250
253
|
win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: at ?? null }, location.origin)
|
|
251
254
|
}, [active, draft, show, showAnchor, frameId, iframe, rects, ready])
|
|
@@ -261,7 +264,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
|
|
|
261
264
|
return { x: (p?.fx ?? .94) * dw, y: (p?.fy ?? .06) * dh, orphan: false }
|
|
262
265
|
}
|
|
263
266
|
const activeThread = threads.find((t) => t.id === active)
|
|
264
|
-
const draftAt = draft?.frame === frameId ? (() => {
|
|
267
|
+
const draftAt = draft?.frame === frameId && !isNoteAnchor(draft.anchor) ? (() => {
|
|
265
268
|
const a = draft.anchor as any
|
|
266
269
|
return { x: ((a?.rect?.x ?? 0) + (a?.pos?.fx ?? .5) * (a?.rect?.w ?? 0)) * sx, y: ((a?.rect?.y ?? 0) + (a?.pos?.fy ?? .5) * (a?.rect?.h ?? 0)) * sy }
|
|
267
270
|
})() : null
|
|
@@ -4,6 +4,7 @@ import { CONFIG, useStore } from '../store.ts'
|
|
|
4
4
|
import { bootHash } from '../hash.ts'
|
|
5
5
|
import { startPerf } from '../perf.ts'
|
|
6
6
|
import { FrameNode, HEADER } from './FrameNode.tsx'
|
|
7
|
+
import { NOTE_GAP } from '../notes.ts'
|
|
7
8
|
import { startCameraBroadcast, setCameraScale } from './camera-broadcast.ts'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -177,7 +178,15 @@ export function Canvas() {
|
|
|
177
178
|
y0 -= Math.max(64, 36 / s1) // caption line above the group
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
|
-
|
|
181
|
+
// sticky notes (spec 18): a visible column hangs left of its frame, and may run below it
|
|
182
|
+
let y1n = y1
|
|
183
|
+
for (const n of ns) {
|
|
184
|
+
const col = document.querySelector(`[data-node-notes="${CSS.escape(n.key)}"]:not(.off)`) as HTMLElement | null
|
|
185
|
+
if (!col) continue
|
|
186
|
+
x0 = Math.min(x0, n.x - col.offsetWidth - NOTE_GAP)
|
|
187
|
+
y1n = Math.max(y1n, n.y + col.offsetTop + col.offsetHeight)
|
|
188
|
+
}
|
|
189
|
+
fitRect(x0, y0, x1 - x0, y1n - y0, focus)
|
|
181
190
|
}
|
|
182
191
|
canvasCtl.fitNode = (key: string) => fitKeys([key], true)
|
|
183
192
|
canvasCtl.fitNodes = (keys: string[]) => fitKeys(keys, true)
|
|
@@ -9,6 +9,8 @@ import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
|
9
9
|
import { primeCameraFor } from './camera-broadcast.ts'
|
|
10
10
|
import { sleep, wake } from './sleep.ts'
|
|
11
11
|
import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
|
|
12
|
+
import { Stickies, type NoteSpec } from './Sticky.tsx'
|
|
13
|
+
import { noteId, sceneNoteHost } from '../notes.ts'
|
|
12
14
|
|
|
13
15
|
export const HEADER = 28
|
|
14
16
|
const SNAP = 12
|
|
@@ -51,6 +53,18 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
51
53
|
const selected = useStore((s) => s.selection.includes(node.key))
|
|
52
54
|
const interact = useStore((s) => s.interact === node.key)
|
|
53
55
|
const working = useStore((s) => s.working.includes(node.frame)) // Live Jam: Marver is editing this frame
|
|
56
|
+
// sticky notes (spec 18): the scene's note when this node is the scene's first on the board
|
|
57
|
+
// (reading order), then the frame's own. Text is compared by value - a note edit re-renders
|
|
58
|
+
// only its column; a pan never re-renders any node (no position subscription here).
|
|
59
|
+
const sceneNote = useStore((s) => {
|
|
60
|
+
const scene = frame?.scene
|
|
61
|
+
const text = scene ? s.manifest?.scenes.find((sc) => sc.name === scene)?.note : undefined
|
|
62
|
+
if (!text) return undefined
|
|
63
|
+
return sceneNoteHost(s.nodes, (id) => s.manifest?.frames.find((f) => f.id === id)?.scene, scene!) === node.key ? text : undefined
|
|
64
|
+
})
|
|
65
|
+
const notes: NoteSpec[] = []
|
|
66
|
+
if (frame && sceneNote) notes.push({ kind: 'scene', id: noteId('scene', frame.scene), text: sceneNote })
|
|
67
|
+
if (frame?.note) notes.push({ kind: 'frame', id: noteId('frame', frame.id), text: frame.note })
|
|
54
68
|
const workingSince = useStore((s) => s.workingSince[node.frame])
|
|
55
69
|
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
|
|
56
70
|
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
|
|
@@ -346,12 +360,15 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
346
360
|
className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}${working ? ' working' : ''}`}
|
|
347
361
|
data-theme={node.theme}
|
|
348
362
|
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined,
|
|
363
|
+
// the sticky column's width (spec 18): the docked comment card clears it on the left
|
|
364
|
+
...(notes.length ? { ['--sh-note-w' as string]: `${Math.max(...notes.map((n) => (n.kind === 'scene' ? 380 : 260)))}px` } : {}),
|
|
349
365
|
// phase every working animation by when THIS frame's job started - parallel
|
|
350
366
|
// frames pulsing in sync would read as one fake choreography
|
|
351
367
|
...(working ? { ['--mv-w0' as string]: `${-(Date.now() - (workingSince ?? Date.now()))}ms` } : {}) }}
|
|
352
368
|
data-node={node.key}
|
|
353
369
|
>
|
|
354
370
|
{working && <WorkShimmer belowBadge={!!frame.variantGroup} />}
|
|
371
|
+
<Stickies nodeKey={node.key} frameId={frame.id} notes={notes} underBadge={!!frame.variantGroup} />
|
|
355
372
|
{frame.variantGroup && (
|
|
356
373
|
<div className="sh-vbadge sh-no-pan" title={`${frame.variantGroup} · variant ${frame.variant?.toUpperCase()} - click to select`}
|
|
357
374
|
onPointerDown={(e) => e.stopPropagation()}
|