@marver-design/marver 0.17.0 → 0.19.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 +96 -0
- package/README.md +3 -1
- package/dist/bake-kaf5kGZ7.mjs +747 -0
- package/dist/{build-D_g53Bp2.mjs → build-7ed5H2vT.mjs} +215 -13
- package/dist/cli.mjs +19 -11
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-DbHvLQUL.mjs} +1 -1
- package/dist/{dev-LnIISva5.mjs → dev-D3mP2x27.mjs} +205 -10
- package/dist/{init-B7YhcN2o.mjs → init-BQYCS3EU.mjs} +2 -2
- package/dist/{manifest-CaslQIAO.mjs → manifest-B01PSyDc.mjs} +34 -7
- package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
- package/dist/{plugin-D2msH1cj.mjs → plugin-DI-7NAnx.mjs} +108 -29
- package/dist/{poster-BEjUcQP3.mjs → poster-DNh6N27C.mjs} +1 -1
- package/dist/publish-bakes-Dp-ZFk3d.mjs +216 -0
- package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
- package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
- package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
- package/dist/{shot-DlmTO8AF.mjs → shot-DMDvDbeP.mjs} +14 -7
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/publish.md +27 -5
- package/docs/sticky-notes.md +43 -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/frame-host/bridge.js +4 -9
- package/src/client/frame-host/main.tsx +20 -6
- package/src/client/shell/App.tsx +14 -63
- package/src/client/shell/Comments.tsx +77 -14
- package/src/client/shell/Play.tsx +6 -3
- package/src/client/shell/canvas/Canvas.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +99 -89
- package/src/client/shell/canvas/Sticky.tsx +284 -0
- package/src/client/shell/canvas/admission.ts +70 -0
- package/src/client/shell/canvas/sleep.ts +194 -0
- package/src/client/shell/goto.ts +72 -0
- package/src/client/shell/notes.ts +151 -0
- package/src/client/shell/store.ts +36 -18
- package/src/client/shell/styles.css +92 -25
- package/src/client/shell/tidy.ts +20 -2
- package/src/shared/sleep-rule.ts +41 -0
- package/templates/AGENTS-embedded.md +15 -0
- package/templates/AGENTS-studio.md +15 -0
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/publish.md +62 -9
- package/templates/instructions/shape.md +62 -0
- package/src/client/frame-host/serialize.ts +0 -195
- package/src/client/shell/canvas/snapshots.ts +0 -233
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import { memo, useCallback, useEffect, useRef } from 'react'
|
|
1
|
+
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
3
|
+
import { admit, release } from './admission.ts'
|
|
3
4
|
import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, SlideFrameIcon, XIcon } from '../icons.tsx'
|
|
4
5
|
import { CommentLayer } from '../Comments.tsx'
|
|
5
6
|
import { useComments } from '../comments-store.ts'
|
|
6
7
|
import { threadHostKey } from '../keys.ts'
|
|
7
8
|
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
8
9
|
import { primeCameraFor } from './camera-broadcast.ts'
|
|
9
|
-
import {
|
|
10
|
+
import { sleep, wake } from './sleep.ts'
|
|
10
11
|
import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
|
|
12
|
+
import { Stickies, type NoteSpec } from './Sticky.tsx'
|
|
13
|
+
import { noteId, sceneNoteHost } from '../notes.ts'
|
|
11
14
|
|
|
12
15
|
export const HEADER = 28
|
|
13
16
|
const SNAP = 12
|
|
@@ -34,6 +37,12 @@ function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
|
|
|
34
37
|
* One frame on the canvas. Iframe laws: the iframe element is created once per node key
|
|
35
38
|
* and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
|
|
36
39
|
*
|
|
40
|
+
* At rest the frame SLEEPS in place (sleep.ts, spec 16): the same live document, its animations
|
|
41
|
+
* paused and its backdrop-filters replaced by certified textures. Interact mode wakes it; laser,
|
|
42
|
+
* comment pins and selection act on the sleeping document as it is. A frame the human has
|
|
43
|
+
* interacted with is a state the compiler cannot reproduce from its URL: it stays awake until
|
|
44
|
+
* it is reloaded. Any change of theme, size or source wakes first and sleeps again once settled.
|
|
45
|
+
*
|
|
37
46
|
* Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
|
|
38
47
|
* TARGET's classList, nothing else), and drags additionally raise the store gesture flag,
|
|
39
48
|
* which hard-disables canvas panning for the duration. Both are needed: the class stops the
|
|
@@ -44,6 +53,18 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
44
53
|
const selected = useStore((s) => s.selection.includes(node.key))
|
|
45
54
|
const interact = useStore((s) => s.interact === node.key)
|
|
46
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 })
|
|
47
68
|
const workingSince = useStore((s) => s.workingSince[node.frame])
|
|
48
69
|
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
|
|
49
70
|
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
|
|
@@ -51,21 +72,46 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
51
72
|
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, reloadFrame, setGesture, toast } = useStore.getState()
|
|
52
73
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
53
74
|
const themeRef = useRef(node.theme)
|
|
54
|
-
// src is
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
75
|
+
// The JSX src is set ONCE, at ADMISSION, with the theme and revision of that moment, and never
|
|
76
|
+
// changes again (React would write it back after any later navigation and load the frame twice):
|
|
77
|
+
// theme changes ride sh:set-theme (never navigation), so frame state (forms, scroll, dialogs)
|
|
78
|
+
// survives a theme flip; file changes and reloads navigate imperatively below, only once
|
|
79
|
+
// admitted - a queued frame takes the fresh revision when its turn comes.
|
|
80
|
+
const [src, setSrc] = useState<string>()
|
|
81
|
+
const admitted = src !== undefined
|
|
82
|
+
const admittedRef = useRef(false)
|
|
58
83
|
const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
|
|
59
84
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
85
|
+
// admission (admission.ts): the iframe gets its src when a boot slot is free, nearest the centre
|
|
86
|
+
// of the view first; the slot goes back when the frame is ready, errored, gone, or its watchdog acts
|
|
87
|
+
const navigate = (url: string) => { if (admittedRef.current && iframeRef.current) iframeRef.current.src = url }
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!frame || node.missing) { admittedRef.current = false; setSrc(undefined); return } // gone: the card replaces the iframe; a return is a new document, admitted anew
|
|
90
|
+
if (admittedRef.current) return // the document exists (the frame's id changed under it): no slot
|
|
91
|
+
const rank = () => { // visible first, then by distance to the centre of the canvas (the panel offsets the window's)
|
|
92
|
+
const r = iframeRef.current?.getBoundingClientRect(), c = document.querySelector('.sh-canvas')?.getBoundingClientRect()
|
|
93
|
+
if (!r || !c) return Infinity
|
|
94
|
+
const visible = r.right > c.left && r.left < c.right && r.bottom > c.top && r.top < c.bottom
|
|
95
|
+
return (visible ? 0 : 1e7) + Math.hypot(r.x + r.width / 2 - c.x - c.width / 2, r.y + r.height / 2 - c.y - c.height / 2)
|
|
96
|
+
}
|
|
97
|
+
const start = () => {
|
|
98
|
+
const s = useStore.getState(), n = s.nodes.find((x) => x.key === node.key), f = n && s.frameFor(n)
|
|
99
|
+
if (!n || !f) { release(node.key); return }
|
|
100
|
+
if (n.status !== 'loading') setStatus(node.key, 'loading') // a returning frame boots under the watchdog like any other
|
|
101
|
+
admittedRef.current = true
|
|
102
|
+
setSrc(frameUrl(f, n.theme))
|
|
103
|
+
}
|
|
104
|
+
admit({ key: node.key, rank, start })
|
|
105
|
+
return () => release(node.key)
|
|
106
|
+
}, [frame?.id, node.key, node.missing])
|
|
107
|
+
useEffect(() => { if (node.status !== 'loading') release(node.key) }, [node.status, node.key])
|
|
108
|
+
|
|
109
|
+
// theme switch without remount: the live iframe flips via message (no navigation); the frame
|
|
110
|
+
// reports sh:theme-applied and only then sleeps again under the new theme (node.themeOn)
|
|
64
111
|
useEffect(() => {
|
|
65
112
|
if (themeRef.current !== node.theme) {
|
|
66
113
|
themeRef.current = node.theme
|
|
67
114
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
|
|
68
|
-
invalidateLean(node.key)
|
|
69
115
|
}
|
|
70
116
|
}, [node.theme, node.key])
|
|
71
117
|
|
|
@@ -90,53 +136,6 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
90
136
|
registerWin()
|
|
91
137
|
}, [node.key])
|
|
92
138
|
|
|
93
|
-
// Register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
|
|
94
|
-
const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
|
|
95
|
-
// capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
|
|
96
|
-
// (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
|
|
97
|
-
// so neither is a dep - keeping captures rare. Never during a gesture; the coordinator serialises.
|
|
98
|
-
useEffect(() => {
|
|
99
|
-
// capture reads the live iframe's same-origin document - true in dev AND publish (published frames
|
|
100
|
-
// are bundled same-origin and served by `marver serve`), so the lean tier works in both via this
|
|
101
|
-
// client-side capture. Fail-soft: a frame that can't serialise stays live (publish == today's
|
|
102
|
-
// behaviour in the worst case). No headless build step / heavy dependency needed.
|
|
103
|
-
if (node.status !== 'ready' || node.missing) return
|
|
104
|
-
const iframe = iframeRef.current
|
|
105
|
-
if (!iframe) return
|
|
106
|
-
const t = setTimeout(() => {
|
|
107
|
-
// never re-admit a cover while this frame hosts an open thread / draft: the live app
|
|
108
|
-
// must stay visible (its highlight updates in real time). A status/theme change would
|
|
109
|
-
// otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
|
|
110
|
-
// hostsCard rail recaptures a clean lean once the card closes.
|
|
111
|
-
const c = useComments.getState()
|
|
112
|
-
// hosting goes through the resolver (keys.ts): an adopted thread's card renders
|
|
113
|
-
// here even though its stored nodeKey names a node that no longer exists
|
|
114
|
-
const hosting = (!!c.active && c.threads.some((th) =>
|
|
115
|
-
th.id === c.active && !th.resolved && threadHostKey(th, useStore.getState().nodes) === node.key)) || c.draft?.nodeKey === node.key
|
|
116
|
-
if (hosting) return
|
|
117
|
-
scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
|
|
118
|
-
}, 450)
|
|
119
|
-
return () => clearTimeout(t)
|
|
120
|
-
// node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
|
|
121
|
-
// flip, so a theme change re-captures after the live frame re-renders (key includes theme).
|
|
122
|
-
}, [node.status, node.nav, node.key, node.missing, node.theme])
|
|
123
|
-
useEffect(() => () => dropSnapshot(node.key), [node.key]) // drop the snapshot on unmount
|
|
124
|
-
// a reload / file-swap / error takes the frame out of 'ready': drop its cover so a stale picture
|
|
125
|
-
// never lingers (nav may not bump on a same-file reload). The next 'ready' re-captures.
|
|
126
|
-
useEffect(() => { if (node.status !== 'ready') dropSnapshot(node.key) }, [node.status, node.key])
|
|
127
|
-
// LEAN-PRIMARY focus handoff: entering interact shows the live app (drop the now-stale lean at
|
|
128
|
-
// once); leaving it recaptures the live frame's CURRENT state (the user may have typed/toggled)
|
|
129
|
-
// and only swaps back to lean once that fresh capture is admitted. force=true: same nav/theme.
|
|
130
|
-
const prevInteract = useRef(interact)
|
|
131
|
-
useEffect(() => {
|
|
132
|
-
if (prevInteract.current === interact) return
|
|
133
|
-
const wasInteract = prevInteract.current
|
|
134
|
-
prevInteract.current = interact
|
|
135
|
-
if (interact) invalidateLean(node.key)
|
|
136
|
-
else if (wasInteract && node.status === 'ready' && iframeRef.current)
|
|
137
|
-
scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
|
|
138
|
-
}, [interact, node.key, node.nav, node.theme, node.status])
|
|
139
|
-
|
|
140
139
|
// laser mode rides the same rail; re-sent when a frame becomes ready
|
|
141
140
|
// so late loaders join an already-lasered board
|
|
142
141
|
const laser = useStore((s) => s.laser)
|
|
@@ -147,21 +146,6 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
147
146
|
const hostsCard = useComments((s) =>
|
|
148
147
|
(!!s.active && s.threads.some((t) =>
|
|
149
148
|
t.id === s.active && !t.resolved && threadHostKey(t, useStore.getState().nodes) === node.key)) || s.draft?.nodeKey === node.key)
|
|
150
|
-
// a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
|
|
151
|
-
// cover: the active-element highlight lives in the live DOM and updates in real time
|
|
152
|
-
// (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
|
|
153
|
-
// mode ends and either bakes a stale highlight or hides the live one. Mirror the interact
|
|
154
|
-
// rail: drop the cover while hosting, rebuild a fresh lean once the card closes (the
|
|
155
|
-
// highlight is cleared by then, so the recapture is clean).
|
|
156
|
-
const prevHostsCard = useRef(hostsCard)
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
if (prevHostsCard.current === hostsCard) return
|
|
159
|
-
const wasHosting = prevHostsCard.current
|
|
160
|
-
prevHostsCard.current = hostsCard
|
|
161
|
-
if (hostsCard) invalidateLean(node.key)
|
|
162
|
-
else if (wasHosting && node.status === 'ready' && iframeRef.current)
|
|
163
|
-
scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
|
|
164
|
-
}, [hostsCard, node.key, node.nav, node.theme, node.status])
|
|
165
149
|
useEffect(() => {
|
|
166
150
|
if (node.status === 'ready' || !laser)
|
|
167
151
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
|
|
@@ -179,6 +163,32 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
179
163
|
if (node.status === 'ready' || !interact)
|
|
180
164
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
|
|
181
165
|
}, [interact, node.status])
|
|
166
|
+
// SLEEP lifecycle. Interact = awake and DIRTY (the app's state is now its own); a resize drag =
|
|
167
|
+
// awake for the whole drag; any other change of the key (theme once applied, size, source revision,
|
|
168
|
+
// navigation) wakes first - the old override describes another state - and sleeps again once the
|
|
169
|
+
// new one has settled. Laser and comment mode need nothing: the sleeping document IS the live one.
|
|
170
|
+
const dirty = useRef(false)
|
|
171
|
+
const lastDoc = useRef<Document | null>(null) // a new document (reload, self-reload) is pristine again
|
|
172
|
+
const resizing = useRef(false)
|
|
173
|
+
const [resizeTick, setResizeTick] = useState(0)
|
|
174
|
+
useEffect(() => { dirty.current = false }, [node.nav]) // a fresh document is pristine again
|
|
175
|
+
const w = Math.round(node.w), h = Math.round(node.h)
|
|
176
|
+
// a layout effect: the wake lands BEFORE the first paint of the new state (a stretched texture
|
|
177
|
+
// must never be painted at a new size)
|
|
178
|
+
useLayoutEffect(() => {
|
|
179
|
+
const iframe = iframeRef.current
|
|
180
|
+
if (!iframe || !frame || node.missing) { wake(node.key, null); return } // nothing to keep (a deleted frame's card must not retain its old document)
|
|
181
|
+
const doc = iframe.contentDocument
|
|
182
|
+
if (doc && doc !== lastDoc.current) { lastDoc.current = doc; dirty.current = false }
|
|
183
|
+
if (interact) dirty.current = true
|
|
184
|
+
wake(node.key, iframe)
|
|
185
|
+
if (interact || dirty.current || resizing.current || node.status !== 'ready') return
|
|
186
|
+
if (node.themeOn !== undefined && node.themeOn !== node.theme) return // the frame has not painted the new theme yet
|
|
187
|
+
const t = setTimeout(() => { void sleep(node.key, iframe, { frame: frame.id, theme: node.theme, w, h }) }, 250)
|
|
188
|
+
return () => clearTimeout(t)
|
|
189
|
+
}, [interact, node.status, node.theme, node.themeOn, node.rev, node.nav, w, h, node.missing, frame?.id, node.key, resizeTick])
|
|
190
|
+
useEffect(() => () => wake(node.key, iframeRef.current), [node.key])
|
|
191
|
+
|
|
182
192
|
// image-LOD: once the frame is ready its content listener is live, so send the settled zoom - a static
|
|
183
193
|
// board that never gets a gesture then still sharpens its images from the cheap low-res first paint.
|
|
184
194
|
useEffect(() => {
|
|
@@ -191,7 +201,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
191
201
|
const sig = `${frame.kind}:${frame.file}`
|
|
192
202
|
if (fileRef.current !== null && fileRef.current !== sig && iframeRef.current) {
|
|
193
203
|
setStatus(node.key, 'loading')
|
|
194
|
-
|
|
204
|
+
navigate(frameUrl(frame, node.theme))
|
|
195
205
|
}
|
|
196
206
|
fileRef.current = sig
|
|
197
207
|
}, [frame?.kind, frame?.file])
|
|
@@ -202,7 +212,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
202
212
|
useEffect(() => {
|
|
203
213
|
if ((node.nav ?? 0) === navRef.current) return
|
|
204
214
|
navRef.current = node.nav ?? 0
|
|
205
|
-
if (frame
|
|
215
|
+
if (frame) navigate(frameUrl(frame, node.theme))
|
|
206
216
|
}, [node.nav])
|
|
207
217
|
|
|
208
218
|
// Reload the frame, assigning the fresh URL SYNCHRONOUSLY so the live iframe's src is current the
|
|
@@ -215,8 +225,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
215
225
|
reloadFrame(node.key, automatic)
|
|
216
226
|
const after = useStore.getState().nodes.find((x) => x.key === node.key)
|
|
217
227
|
const f = useStore.getState().frameFor(node)
|
|
218
|
-
if (after && f
|
|
219
|
-
|
|
228
|
+
if (after && f) {
|
|
229
|
+
navigate(frameUrl(f, node.theme))
|
|
220
230
|
navRef.current = after.nav ?? 0
|
|
221
231
|
}
|
|
222
232
|
}
|
|
@@ -231,14 +241,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
231
241
|
// auto-renavigates ONCE on a fresh rev; a second silence stays 'loading' (never a red error card).
|
|
232
242
|
// node.nav is a dep so a fresh navigation restarts the full budget; readyRetried flips true on the
|
|
233
243
|
// retry and bounds it to exactly one.
|
|
244
|
+
// The budget counts from ADMISSION (a queued frame is not silent). The retried document keeps its
|
|
245
|
+
// boot slot - its boot is still running - and gives it back after a second silence.
|
|
234
246
|
useEffect(() => {
|
|
235
|
-
if (!
|
|
236
|
-
const t = setTimeout(() => reloadRef.current(true), 10_000)
|
|
247
|
+
if (!admitted || node.status !== 'loading' || !frame || node.missing) return
|
|
248
|
+
const t = setTimeout(() => { if (shouldArmReadyWatch(node, true)) reloadRef.current(true); else release(node.key) }, 10_000)
|
|
237
249
|
return () => clearTimeout(t)
|
|
238
250
|
// depend on the frame's stable SIGNATURE, not the manifest object - that object is replaced on
|
|
239
251
|
// every manifest reconcile, so depending on it would reset the budget on unrelated frames during
|
|
240
252
|
// heavy Live Jam churn and starve the retry. kind/file still re-arm on a real file swap.
|
|
241
|
-
}, [node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
|
|
253
|
+
}, [admitted, node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
|
|
242
254
|
|
|
243
255
|
const drag = (e: React.PointerEvent, mode: 'move' | 'e' | 's' | 'se') => {
|
|
244
256
|
e.stopPropagation()
|
|
@@ -268,8 +280,9 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
268
280
|
const begin = () => {
|
|
269
281
|
if (gesturing) return
|
|
270
282
|
gesturing = true
|
|
271
|
-
world.classList.add('sh-gesturing') // drops iframe pointer-events
|
|
283
|
+
world.classList.add('sh-gesturing') // drops iframe pointer-events
|
|
272
284
|
setGesture(true)
|
|
285
|
+
if (mode !== 'move') { resizing.current = true; wake(node.key, iframeRef.current); setResizeTick((t) => t + 1) } // awake before the first resized paint, for the whole resize
|
|
273
286
|
}
|
|
274
287
|
const MOVE_THRESHOLD = 3 // px in screen space before a press counts as a drag
|
|
275
288
|
|
|
@@ -297,6 +310,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
297
310
|
try { el.releasePointerCapture(e.pointerId) } catch { /* already released */ }
|
|
298
311
|
world.classList.remove('sh-gesturing')
|
|
299
312
|
setGesture(false)
|
|
313
|
+
if (resizing.current) { resizing.current = false; setResizeTick((t) => t + 1) } // settle, then sleep again
|
|
300
314
|
el.removeEventListener('pointermove', onMove)
|
|
301
315
|
el.removeEventListener('pointerup', done)
|
|
302
316
|
el.removeEventListener('pointercancel', done)
|
|
@@ -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()}
|
|
@@ -398,18 +415,11 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
398
415
|
<iframe
|
|
399
416
|
ref={bindIframe}
|
|
400
417
|
className="sh-live"
|
|
401
|
-
src={
|
|
418
|
+
src={src}
|
|
402
419
|
title={frame.id}
|
|
403
420
|
onLoad={registerWin}
|
|
404
421
|
style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
|
|
405
422
|
/>
|
|
406
|
-
{/* Lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
|
|
407
|
-
the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
|
|
408
|
-
device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
|
|
409
|
-
so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
|
|
410
|
-
never messaged, pointer-events:none - it is NOT the live iframe (role: .sh-lean).
|
|
411
|
-
Rendered in dev AND publish (runtime client-side capture; frames are same-origin in both). */}
|
|
412
|
-
<iframe ref={bindLean} className="sh-lean" sandbox="allow-same-origin" title="" aria-hidden tabIndex={-1} />
|
|
413
423
|
{/* the overlay eats mouse events for drag-by-body; laser and comment mode both
|
|
414
424
|
need the mouse INSIDE the frame for hover highlights, so it steps aside
|
|
415
425
|
(drag still works via the header) */}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sticky notes (spec 18): the yellow column left of a frame - the scene's note (when this node
|
|
3
|
+
* hosts it) above the frame's own. Shell DOM, never inside the iframe: sleep, bakes and the frame
|
|
4
|
+
* document are untouched. Markdown through the content package's renderer (the Md block's link
|
|
5
|
+
* and image policy, raw HTML inert); a ```mermaid fence renders hand-drawn on demand.
|
|
6
|
+
*
|
|
7
|
+
* Fold: the dog-ear at the column's top-right corner folds the whole column down to that corner
|
|
8
|
+
* (scale + fade toward it); the corner stays as the tab that unfolds it. Per viewer, in
|
|
9
|
+
* localStorage - never in the board file.
|
|
10
|
+
*
|
|
11
|
+
* Comments: in comment mode a click on a note element stages a draft on the node, anchored like
|
|
12
|
+
* a frame element (notes.ts builds the bundle; the CommentLayer resolves it here in the shell).
|
|
13
|
+
* Links: `goto:` navigates through the one goto path; in comment mode a click picks, never
|
|
14
|
+
* navigates (the bridge rule).
|
|
15
|
+
*/
|
|
16
|
+
import { memo, useEffect, useMemo, useRef } from 'react'
|
|
17
|
+
import { renderMarkdown, sanitizeMarkdownHtml } from '../../content/md.ts'
|
|
18
|
+
import { cleanSource, guardDiagramSource, sanitizeSvg } from '../../content/diagram.tsx'
|
|
19
|
+
import { useComments } from '../comments-store.ts'
|
|
20
|
+
import { goTo } from '../goto.ts'
|
|
21
|
+
import { NOTE_W, SCENE_NOTE_W, noteAnchor, noteVisible, useNotes, type NoteKind } from '../notes.ts'
|
|
22
|
+
|
|
23
|
+
export interface NoteSpec { kind: NoteKind; id: string; text: string }
|
|
24
|
+
|
|
25
|
+
/** Diagram text only: the sketched look is the diagram's, prose stays in the shell font. */
|
|
26
|
+
const HAND_FONT = `"Segoe Print", "Bradley Hand", "Chalkboard SE", "Comic Sans MS", "Comic Neue", cursive`
|
|
27
|
+
/** Yellow paper for the hand-drawn look - the same values the sheet paints the sticky with. */
|
|
28
|
+
const PAPER = { bg: '#fff3a3', ink: '#2b2500', line: '#6b5a00', soft: '#ffe680', pale: '#fffbdc', hatch: '#f0cc4e' }
|
|
29
|
+
const THEME_VARS = {
|
|
30
|
+
background: PAPER.bg, fontFamily: HAND_FONT, fontSize: '17px',
|
|
31
|
+
primaryColor: PAPER.soft, primaryTextColor: PAPER.ink, primaryBorderColor: PAPER.line,
|
|
32
|
+
secondaryColor: PAPER.pale, secondaryTextColor: PAPER.ink, secondaryBorderColor: PAPER.line,
|
|
33
|
+
tertiaryColor: PAPER.pale, tertiaryTextColor: PAPER.ink, tertiaryBorderColor: PAPER.line,
|
|
34
|
+
lineColor: PAPER.line, textColor: PAPER.ink, mainBkg: PAPER.soft, nodeBorder: PAPER.line,
|
|
35
|
+
clusterBkg: PAPER.pale, clusterBorder: PAPER.line, titleColor: PAPER.ink,
|
|
36
|
+
edgeLabelBackground: PAPER.bg, noteBkgColor: PAPER.soft, noteTextColor: PAPER.ink, noteBorderColor: PAPER.line,
|
|
37
|
+
actorBkg: PAPER.soft, actorBorder: PAPER.line, actorTextColor: PAPER.ink, signalColor: PAPER.line, signalTextColor: PAPER.ink,
|
|
38
|
+
labelBoxBkgColor: PAPER.pale, labelBoxBorderColor: PAPER.line, labelTextColor: PAPER.ink, loopTextColor: PAPER.ink,
|
|
39
|
+
activationBkgColor: PAPER.pale, activationBorderColor: PAPER.line, sequenceNumberColor: PAPER.ink,
|
|
40
|
+
pie1: PAPER.soft, pie2: PAPER.pale, pie3: '#f3d96a', pie4: '#e9c94f', pie5: '#fff0b8', pie6: '#e0c04a', pieTitleTextColor: PAPER.ink, pieSectionTextColor: PAPER.ink, pieLegendTextColor: PAPER.ink, pieStrokeColor: PAPER.line, pieOuterStrokeColor: PAPER.line,
|
|
41
|
+
// every family that would otherwise bring its own rainbow: gantt sections, journey fills,
|
|
42
|
+
// git branches, the cScale mindmap and timeline use - all the paper's yellows, ink for text
|
|
43
|
+
sectionBkgColor: PAPER.pale, altSectionBkgColor: PAPER.bg, sectionBkgColor2: PAPER.soft,
|
|
44
|
+
taskBkgColor: PAPER.soft, taskBorderColor: PAPER.line, taskTextColor: PAPER.ink, taskTextLightColor: PAPER.ink, taskTextOutsideColor: PAPER.ink, taskTextDarkColor: PAPER.ink,
|
|
45
|
+
activeTaskBkgColor: '#f3d96a', activeTaskBorderColor: PAPER.line, doneTaskBkgColor: PAPER.pale, doneTaskBorderColor: PAPER.line,
|
|
46
|
+
critBkgColor: '#e9c94f', critBorderColor: PAPER.ink, gridColor: PAPER.line, todayLineColor: PAPER.ink,
|
|
47
|
+
fillType0: PAPER.soft, fillType1: PAPER.pale, fillType2: '#f3d96a', fillType3: PAPER.soft, fillType4: PAPER.pale, fillType5: '#f3d96a', fillType6: PAPER.soft, fillType7: PAPER.pale,
|
|
48
|
+
git0: '#e9c94f', git1: '#f3d96a', git2: PAPER.soft, git3: '#e0c04a', git4: PAPER.pale, git5: '#e9c94f', git6: '#f3d96a', git7: PAPER.soft,
|
|
49
|
+
gitBranchLabel0: PAPER.ink, gitBranchLabel1: PAPER.ink, gitBranchLabel2: PAPER.ink, gitBranchLabel3: PAPER.ink, gitBranchLabel4: PAPER.ink, gitBranchLabel5: PAPER.ink, gitBranchLabel6: PAPER.ink, gitBranchLabel7: PAPER.ink,
|
|
50
|
+
gitInv0: PAPER.ink, gitInv1: PAPER.ink, gitInv2: PAPER.ink, gitInv3: PAPER.ink, gitInv4: PAPER.ink, gitInv5: PAPER.ink, gitInv6: PAPER.ink, gitInv7: PAPER.ink,
|
|
51
|
+
commitLabelColor: PAPER.ink, commitLabelBackground: PAPER.bg, tagLabelColor: PAPER.ink, tagLabelBackground: PAPER.soft, tagLabelBorder: PAPER.line,
|
|
52
|
+
...Object.fromEntries([...Array(12)].flatMap((_, i) => [[`cScale${i}`, [PAPER.soft, '#f3d96a', PAPER.pale, '#e9c94f'][i % 4]], [`cScaleLabel${i}`, PAPER.ink], [`cScalePeer${i}`, PAPER.line]])),
|
|
53
|
+
quadrant1Fill: PAPER.soft, quadrant2Fill: PAPER.pale, quadrant3Fill: PAPER.pale, quadrant4Fill: PAPER.soft,
|
|
54
|
+
quadrant1TextFill: PAPER.ink, quadrant2TextFill: PAPER.ink, quadrant3TextFill: PAPER.ink, quadrant4TextFill: PAPER.ink,
|
|
55
|
+
quadrantPointFill: PAPER.ink, quadrantPointTextFill: PAPER.ink, quadrantXAxisTextFill: PAPER.ink, quadrantYAxisTextFill: PAPER.ink,
|
|
56
|
+
quadrantInternalBorderStrokeFill: PAPER.line, quadrantExternalBorderStrokeFill: PAPER.line, quadrantTitleFill: PAPER.ink,
|
|
57
|
+
attributeBackgroundColorOdd: PAPER.pale, attributeBackgroundColorEven: PAPER.bg,
|
|
58
|
+
}
|
|
59
|
+
let diagramSeq = 0
|
|
60
|
+
|
|
61
|
+
/** A sticky is one colour of paper. Whatever a diagram family brings that the theme variables
|
|
62
|
+
* did not reach (journey actor dots, ER marker circles, a white attribute row, block fills) is
|
|
63
|
+
* mapped onto the palette AFTER render, by computed colour: white and light fills become paper,
|
|
64
|
+
* dark fills become ink, strokes likewise. Text is never touched (its fill is set above). */
|
|
65
|
+
const PALETTE = new Set([PAPER.bg, PAPER.ink, PAPER.line, PAPER.soft, PAPER.pale, PAPER.hatch, '#f3d96a', '#e9c94f', '#e0c04a', '#fff0b8', '#a88f2a'].map((c) => c.toLowerCase()))
|
|
66
|
+
const hex = (rgb: string): string | null => {
|
|
67
|
+
const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec(rgb)
|
|
68
|
+
if (!m || (m[4] !== undefined && Number(m[4]) === 0)) return null
|
|
69
|
+
return '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')
|
|
70
|
+
}
|
|
71
|
+
const luminance = (h: string) => { const n = parseInt(h.slice(1), 16); return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255 }
|
|
72
|
+
function inkPalette(svg: SVGSVGElement) {
|
|
73
|
+
for (const el of svg.querySelectorAll('path, rect, circle, ellipse, polygon, polyline, line') as NodeListOf<SVGElement>) {
|
|
74
|
+
const cs = getComputedStyle(el)
|
|
75
|
+
const fill = hex(cs.fill), stroke = hex(cs.stroke)
|
|
76
|
+
if (fill && !PALETTE.has(fill)) el.style.setProperty('fill', luminance(fill) > 0.55 ? (fill === '#ffffff' ? PAPER.pale : PAPER.soft) : PAPER.line, 'important')
|
|
77
|
+
// a LIGHT stroke is a hachure line or a faint grid (rough draws a white fill as white
|
|
78
|
+
// hatching): it becomes the hatch yellow; a mid stroke the line brown; a dark one the ink
|
|
79
|
+
if (stroke && !PALETTE.has(stroke)) el.style.setProperty('stroke', luminance(stroke) > 0.7 ? PAPER.hatch : luminance(stroke) > 0.35 ? PAPER.line : PAPER.ink, 'important')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One look for every diagram type. mermaid's hand-drawn look (rough.js) reaches flowchart,
|
|
84
|
+
* state, class, ER and block natively; sequence actors, journey and timeline boxes, mindmap
|
|
85
|
+
* nodes, quadrants, git commits, pie slices and legends come out as plain rects, circles and
|
|
86
|
+
* paths. Every visible one of those that mermaid did not already sketch is redrawn here with the
|
|
87
|
+
* same rough.js recipe the flowchart nodes got - hachured fill, sketched border - in the paper's
|
|
88
|
+
* colours, the plain shape kept underneath as a flat backing. Seeded per shape, so a re-render
|
|
89
|
+
* of the same note draws the same lines. */
|
|
90
|
+
const FILLS = ['hachure', 'cross-hatch', 'dots', 'zigzag-line', 'dashed'] as const
|
|
91
|
+
function sketchShapes(svg: SVGSVGElement, rough: any) {
|
|
92
|
+
const rc = rough.svg(svg)
|
|
93
|
+
let seed = 7, slice = 0
|
|
94
|
+
const visible = (el: Element) => { const f = getComputedStyle(el).fill; return f !== 'none' && !/rgba\(\d+, \d+, \d+, 0\)/.test(f) }
|
|
95
|
+
const skip = (el: Element) =>
|
|
96
|
+
(el as any).__sketched
|
|
97
|
+
|| !!el.closest('.label, foreignObject, marker, defs, .face, .legend-text')
|
|
98
|
+
|| sketchedByMermaid(el)
|
|
99
|
+
|| (el.nextElementSibling?.tagName === 'g' && !!el.nextElementSibling.querySelector('path')) // mermaid's own rough
|
|
100
|
+
|| !visible(el)
|
|
101
|
+
// the unified renderer's nodes carry `g.node` / `g.rough-node`, but the class is a promise, not
|
|
102
|
+
// a fact: a mindmap node wears `rough-node` around a plain path. Trust the evidence - rough
|
|
103
|
+
// output is classless paths, direct or inside the label container
|
|
104
|
+
const sketchedByMermaid = (el: Element) => {
|
|
105
|
+
const node = el.parentElement?.closest('g.node, g.rough-node, g.cluster')
|
|
106
|
+
if (!node) return false
|
|
107
|
+
const plain = (e: Element) => !(e.getAttribute('class') ?? '').trim() // an EMPTY class attribute is no class
|
|
108
|
+
for (const c of node.children) {
|
|
109
|
+
if (c.tagName === 'path' && plain(c)) return true
|
|
110
|
+
if (c.tagName === 'g' && (plain(c) || /\b(basic|label-container)\b/.test(c.getAttribute('class') ?? '')) && [...c.children].some((cc) => cc.tagName === 'path' && plain(cc))) return true
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
const place = (el: Element, g: SVGGElement, backing: string) => {
|
|
115
|
+
// rough draws with presentation attributes; inside a mermaid node group the diagram's own
|
|
116
|
+
// stylesheet (`.node path { fill; stroke }`) would beat them - pin them as inline styles
|
|
117
|
+
for (const path of g.querySelectorAll('path')) {
|
|
118
|
+
path.style.fill = path.getAttribute('fill') ?? 'none'
|
|
119
|
+
path.style.stroke = path.getAttribute('stroke') ?? 'none'
|
|
120
|
+
path.style.strokeWidth = path.getAttribute('stroke-width') ?? '1'
|
|
121
|
+
}
|
|
122
|
+
el.setAttribute('style', `fill: ${backing}; stroke: none;`)
|
|
123
|
+
;(el as any).__sketched = true
|
|
124
|
+
el.after(g)
|
|
125
|
+
}
|
|
126
|
+
const recipe = (extra: Record<string, unknown> = {}) => ({
|
|
127
|
+
seed: seed++, roughness: 0.7, bowing: 0.6, stroke: PAPER.line, strokeWidth: 1.6,
|
|
128
|
+
fill: PAPER.hatch, fillStyle: 'hachure', hachureAngle: 120, hachureGap: 4, fillWeight: 1.2, ...extra,
|
|
129
|
+
})
|
|
130
|
+
for (const r of [...svg.querySelectorAll('rect')]) {
|
|
131
|
+
if (skip(r)) continue
|
|
132
|
+
const w = Number(r.getAttribute('width') ?? 0), h = Number(r.getAttribute('height') ?? 0)
|
|
133
|
+
if (!(w > 6 && h > 6)) continue
|
|
134
|
+
const x = Number(r.getAttribute('x') ?? 0), y = Number(r.getAttribute('y') ?? 0)
|
|
135
|
+
// a quadrant is a field, not a box: the sketched border only, a flat pale backing
|
|
136
|
+
const field = !!r.closest('.quadrants') || w * h > 40_000
|
|
137
|
+
place(r, rc.rectangle(x, y, w, h, recipe(field ? { fill: undefined, fillStyle: 'solid' } : {})), field ? PAPER.pale : PAPER.soft)
|
|
138
|
+
}
|
|
139
|
+
for (const c of [...svg.querySelectorAll('circle')]) {
|
|
140
|
+
if (skip(c)) continue
|
|
141
|
+
const d = 2 * Number(c.getAttribute('r') ?? 0)
|
|
142
|
+
if (d < 6) continue
|
|
143
|
+
place(c, rc.circle(Number(c.getAttribute('cx') ?? 0), Number(c.getAttribute('cy') ?? 0), d, recipe(d < 30 ? { fillStyle: 'solid', fill: PAPER.hatch } : {})), PAPER.soft)
|
|
144
|
+
}
|
|
145
|
+
for (const e of [...svg.querySelectorAll('ellipse')]) {
|
|
146
|
+
if (skip(e)) continue
|
|
147
|
+
place(e, rc.ellipse(Number(e.getAttribute('cx') ?? 0), Number(e.getAttribute('cy') ?? 0), 2 * Number(e.getAttribute('rx') ?? 0), 2 * Number(e.getAttribute('ry') ?? 0), recipe()), PAPER.soft)
|
|
148
|
+
}
|
|
149
|
+
// pie slices: one fill style per slice, so the wedges read apart in one colour of ink;
|
|
150
|
+
// mindmap and timeline draw their boxes as paths (node-bkg) - the same box recipe
|
|
151
|
+
for (const p of [...svg.querySelectorAll('path.pieCircle, path.node-bkg')]) {
|
|
152
|
+
if (skip(p)) continue
|
|
153
|
+
const d = p.getAttribute('d')
|
|
154
|
+
if (!d) continue
|
|
155
|
+
const pie = p.classList.contains('pieCircle')
|
|
156
|
+
place(p, rc.path(d, recipe(pie ? { fillStyle: FILLS[slice++ % FILLS.length], hachureGap: 5, fillWeight: 1 } : {})), PAPER.soft)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Render every ```mermaid fence in a sticky body: hand-drawn, strict, sanitized. The pre stays
|
|
161
|
+
* mounted through an error (the message replaces the code), so a healed edit heals in place. */
|
|
162
|
+
async function renderDiagrams(body: HTMLElement, alive: () => boolean) {
|
|
163
|
+
const fences = [...body.querySelectorAll('pre > code.language-mermaid')] as HTMLElement[]
|
|
164
|
+
if (!fences.length) return
|
|
165
|
+
const [{ default: mermaid }, { default: rough }] = await Promise.all([import('mermaid'), import('roughjs')])
|
|
166
|
+
if (!alive()) return
|
|
167
|
+
mermaid.initialize({
|
|
168
|
+
startOnLoad: false, securityLevel: 'strict', theme: 'base', look: 'handDrawn', themeVariables: THEME_VARS, fontFamily: HAND_FONT,
|
|
169
|
+
// legible at sticky size. Flowcharts get rough.js boxes and HTML labels; every other type
|
|
170
|
+
// draws plain SVG text in a handwriting face that has no bold - so SVG text is inked with a
|
|
171
|
+
// thin stroke (paint-order keeps the fill on top), lines and boxes are darker and thicker.
|
|
172
|
+
themeCSS: [
|
|
173
|
+
`.nodeLabel, .edgeLabel, .label { font-weight: 700 !important; letter-spacing: .01em }`,
|
|
174
|
+
`text, tspan { font-weight: 700 !important; fill: ${PAPER.ink}; stroke: ${PAPER.ink}; stroke-width: .45px; paint-order: stroke fill; letter-spacing: .01em }`,
|
|
175
|
+
`.edgePath path, .flowchart-link { stroke-width: 2px } .node path, .node rect { stroke-width: 1.6px }`,
|
|
176
|
+
// sequence: actor boxes, lifelines, messages and arrowheads in the paper's ink
|
|
177
|
+
`.actor-line { stroke: ${PAPER.line} !important; stroke-width: 1.4px !important }`,
|
|
178
|
+
`.messageLine0, .messageLine1 { stroke: ${PAPER.ink} !important; stroke-width: 1.8px !important } #arrowhead path, .arrowheadPath { fill: ${PAPER.ink} !important; stroke: ${PAPER.ink} !important }`,
|
|
179
|
+
`.messageText, .actor > tspan, text.actor { fill: ${PAPER.ink} !important }`,
|
|
180
|
+
`.loopLine { stroke: ${PAPER.line} !important } .labelBox, .loopText, .note { stroke: ${PAPER.line} }`,
|
|
181
|
+
`.er.attributeBoxOdd { fill: ${PAPER.pale} !important; stroke: ${PAPER.line} } .er.attributeBoxEven { fill: ${PAPER.bg} !important; stroke: ${PAPER.line} } .er.relationshipLine { stroke: ${PAPER.ink} !important }`,
|
|
182
|
+
`.section0, .section2, .section4 { fill: ${PAPER.pale} } .section1, .section3 { fill: ${PAPER.bg} } .grid .tick line { stroke: ${PAPER.line} } .today { stroke: ${PAPER.ink} }`,
|
|
183
|
+
`.commit-label, .branchLabel text { fill: ${PAPER.ink} !important } .commit-label-bkg { fill: ${PAPER.bg} !important } .commit-arrow, .arrow { stroke: ${PAPER.ink} !important }`,
|
|
184
|
+
`.mindmap-node > path, .mindmap-node rect { stroke: ${PAPER.line}; stroke-width: 1.4px } .edge { stroke: ${PAPER.line}; stroke-width: 2px }`,
|
|
185
|
+
// state / class / er: the same ink
|
|
186
|
+
`.statediagram-state rect, .classGroup rect, .er.entityBox { stroke: ${PAPER.line}; stroke-width: 1.6px; fill: ${PAPER.soft} } .transition, .relation, .er.relationshipLine { stroke: ${PAPER.ink} !important; stroke-width: 1.8px !important }`,
|
|
187
|
+
].join(' '),
|
|
188
|
+
flowchart: { padding: 10, nodeSpacing: 32, rankSpacing: 36 },
|
|
189
|
+
// one actor row (the mirrored bottom row is noise at note size), roomier boxes, bigger text
|
|
190
|
+
sequence: { mirrorActors: false, actorFontSize: 18, actorFontWeight: 700, messageFontSize: 17, messageFontWeight: 700, noteFontSize: 16, width: 96, height: 50, actorMargin: 26, boxMargin: 8, messageMargin: 30, diagramMarginX: 6, diagramMarginY: 6, wrap: true, bottomMarginAdj: 4 },
|
|
191
|
+
// the wide-by-nature types drawn AT the note's width instead of shrunk into it
|
|
192
|
+
gantt: { useWidth: 380, fontSize: 13, sectionFontSize: 13, barHeight: 22, barGap: 6, topPadding: 44, leftPadding: 64, rightPadding: 12, gridLineStartPadding: 20, numberSectionStyles: 2 },
|
|
193
|
+
journey: { width: 118, height: 40, leftMargin: 56, taskMargin: 12, taskFontSize: 13, diagramMarginX: 8, diagramMarginY: 8, boxMargin: 6, actorColours: [PAPER.line, PAPER.ink, '#a88f2a', '#e9c94f'] },
|
|
194
|
+
timeline: { disableMulticolor: true, padding: 10, width: 84, height: 40, leftMargin: 40, taskMargin: 8, diagramMarginX: 8, diagramMarginY: 8 },
|
|
195
|
+
quadrantChart: { chartWidth: 360, chartHeight: 360, titleFontSize: 16, quadrantLabelFontSize: 14, pointLabelFontSize: 13, xAxisLabelFontSize: 13, yAxisLabelFontSize: 13, pointRadius: 5 },
|
|
196
|
+
mindmap: { padding: 10, maxNodeWidth: 150 },
|
|
197
|
+
er: { minEntityWidth: 90, minEntityHeight: 50, entityPadding: 12, fontSize: 14, diagramPadding: 10 },
|
|
198
|
+
gitGraph: { showBranches: true, rotateCommitLabel: false, nodeLabel: { width: 60 } } as any,
|
|
199
|
+
})
|
|
200
|
+
for (const code of fences) {
|
|
201
|
+
const pre = code.parentElement!
|
|
202
|
+
const src = cleanSource(code.textContent ?? '') // front matter and %%{init}%% directives never reach mermaid (the Diagram rule)
|
|
203
|
+
const host = document.createElement('div')
|
|
204
|
+
host.className = 'sh-sticky-diagram'
|
|
205
|
+
try {
|
|
206
|
+
guardDiagramSource(src) // the zero-external-request boundary (the Diagram block's)
|
|
207
|
+
const { svg } = await mermaid.render(`sh-note-d${++diagramSeq}`, src)
|
|
208
|
+
if (!alive()) return
|
|
209
|
+
host.innerHTML = sanitizeSvg(svg)
|
|
210
|
+
const el = host.querySelector('svg')
|
|
211
|
+
// never larger than drawn: a family without mermaid's max-width (block) would upscale to
|
|
212
|
+
// the note's width and its hatching with it - the viewBox width is the ceiling
|
|
213
|
+
const vb = el?.viewBox?.baseVal
|
|
214
|
+
if (el && vb && vb.width > 0 && !el.style.maxWidth) el.style.maxWidth = `${Math.ceil(vb.width)}px`
|
|
215
|
+
pre.replaceWith(host)
|
|
216
|
+
if (el) { sketchShapes(el, rough); inkPalette(el) } // computed colours need the svg in the document
|
|
217
|
+
} catch (e) {
|
|
218
|
+
if (!alive()) return
|
|
219
|
+
pre.classList.add('err')
|
|
220
|
+
code.textContent = `diagram: ${String((e as Error)?.message ?? e).split('\n')[0]}`
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function StickyBody({ text, kind, nodeKey, frameId }: { text: string; kind: NoteKind; nodeKey: string; frameId: string }) {
|
|
226
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
227
|
+
const html = useMemo(() => sanitizeMarkdownHtml(renderMarkdown(text)), [text])
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const el = ref.current
|
|
230
|
+
if (!el) return
|
|
231
|
+
let live = true
|
|
232
|
+
el.innerHTML = html
|
|
233
|
+
void renderDiagrams(el, () => live && ref.current === el)
|
|
234
|
+
return () => { live = false }
|
|
235
|
+
}, [html])
|
|
236
|
+
|
|
237
|
+
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
238
|
+
const root = ref.current
|
|
239
|
+
const target = e.target instanceof Element ? e.target : null
|
|
240
|
+
if (!root || !target) return
|
|
241
|
+
const c = useComments.getState()
|
|
242
|
+
if (c.commentMode) {
|
|
243
|
+
// comment mode owns every click: pick the element, exactly like a frame pick
|
|
244
|
+
e.preventDefault(); e.stopPropagation()
|
|
245
|
+
// the picked element is the innermost element under the pointer; a click in the body's
|
|
246
|
+
// own padding picks the first block instead of the whole note
|
|
247
|
+
const pick = target === root ? root.firstElementChild : target
|
|
248
|
+
if (!pick) return
|
|
249
|
+
const nodeEl = root.closest('.sh-node') as HTMLElement | null
|
|
250
|
+
const bodyEl = nodeEl?.querySelector('.cm-layer') as HTMLElement | null
|
|
251
|
+
if (!nodeEl || !bodyEl) return
|
|
252
|
+
const origin = bodyEl.getBoundingClientRect()
|
|
253
|
+
const s = origin.width ? origin.width / nodeEl.offsetWidth : 1 // on-screen px per world px
|
|
254
|
+
const toBody = (r: DOMRect) => ({ x: (r.left - origin.left) / s, y: (r.top - origin.top) / s, w: r.width / s, h: r.height / s })
|
|
255
|
+
if (c.active) c.setActive(null)
|
|
256
|
+
c.setDraft({ nodeKey, frame: frameId, anchor: noteAnchor(pick, root, kind, e, toBody) })
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
const link = target.closest('a[data-goto]')
|
|
260
|
+
if (link) { e.preventDefault(); e.stopPropagation(); goTo(link.getAttribute('data-goto') ?? '') }
|
|
261
|
+
}
|
|
262
|
+
return <div ref={ref} className="sh-sticky-body" onClick={onClick} />
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The column for one node. `underBadge`: a variant badge owns the top of the gutter. */
|
|
266
|
+
export const Stickies = memo(function Stickies({ nodeKey, frameId, notes, underBadge }: { nodeKey: string; frameId: string; notes: NoteSpec[]; underBadge: boolean }) {
|
|
267
|
+
const ids = notes.map((n) => n.id)
|
|
268
|
+
const on = useNotes((s) => noteVisible(s, ids))
|
|
269
|
+
if (!notes.length) return null
|
|
270
|
+
const width = Math.max(...notes.map((n) => (n.kind === 'scene' ? SCENE_NOTE_W : NOTE_W)))
|
|
271
|
+
return (
|
|
272
|
+
<div className={`sh-notes${on ? '' : ' off'}${underBadge ? ' below-vbadge' : ''}`} data-node-notes={nodeKey} style={{ width }}>
|
|
273
|
+
<button className="sh-notes-fold sh-no-pan" type="button" aria-label={on ? 'hide notes' : 'show notes'} title={on ? 'hide notes (N: all)' : 'show notes (N: all)'}
|
|
274
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
275
|
+
onClick={(e) => { e.stopPropagation(); useNotes.getState().toggle(ids) }} />
|
|
276
|
+
{notes.map((n) => (
|
|
277
|
+
<div key={n.id} className="sh-sticky sh-no-pan" data-sticky={n.kind} data-note={n.id} style={{ width: n.kind === 'scene' ? SCENE_NOTE_W : NOTE_W }}
|
|
278
|
+
onPointerDown={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}>
|
|
279
|
+
<StickyBody text={n.text} kind={n.kind} nodeKey={nodeKey} frameId={frameId} />
|
|
280
|
+
</div>
|
|
281
|
+
))}
|
|
282
|
+
</div>
|
|
283
|
+
)
|
|
284
|
+
})
|