@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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `goto:` navigation, one path for every caller: a frame's data-goto (sh:go from the bridge)
|
|
3
|
+
* and a sticky note's link (shell DOM, spec 18). On this board: select and fit. Elsewhere: follow
|
|
4
|
+
* the frame home (below). Never edits a board unless no board pins the target.
|
|
5
|
+
*/
|
|
6
|
+
import { boardFrames, fetchBoardNames, useStore } from './store.ts'
|
|
7
|
+
import { canvasCtl } from './canvas/ctl.ts'
|
|
8
|
+
|
|
9
|
+
/** Navigate to a frame id. `carry` keeps interact mode across the hop (a link inside an
|
|
10
|
+
* interacting frame walks a flow - it must not eject you to design mode). */
|
|
11
|
+
export function goTo(target: string, carry = false): void {
|
|
12
|
+
const s = useStore.getState()
|
|
13
|
+
const existing = s.nodes.find((n) => n.frame === target && !n.missing)
|
|
14
|
+
if (existing) {
|
|
15
|
+
gotoSeq++ // a local goto supersedes any cross-board one in flight
|
|
16
|
+
s.select(existing.key)
|
|
17
|
+
if (carry) s.setInteract(existing.key)
|
|
18
|
+
setTimeout(() => canvasCtl.fitNode(existing.key), 50)
|
|
19
|
+
} else void gotoAcrossBoards(target, carry)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A data-goto whose target frame is not on the current board follows the frame HOME:
|
|
23
|
+
* the first curated board (switcher rank) that pins it is switched to and the frame
|
|
24
|
+
* focused there - a link is navigation, and navigation never edits a board. Only a
|
|
25
|
+
* frame NO board pins spawns onto the current board (the original prototype behavior
|
|
26
|
+
* for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
|
|
27
|
+
* bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
|
|
28
|
+
let gotoSeq = 0
|
|
29
|
+
async function gotoAcrossBoards(target: string, carry: boolean) {
|
|
30
|
+
const s = useStore.getState()
|
|
31
|
+
// an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
|
|
32
|
+
// must not send us on a trip that ends in a silent timeout
|
|
33
|
+
if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
|
|
34
|
+
const seq = ++gotoSeq
|
|
35
|
+
let home: string | null = null
|
|
36
|
+
try {
|
|
37
|
+
const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
if ((await boardFrames(name)).includes(target)) { home = name; break }
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// a transport failure is NOT proof the frame is unpinned - spawning here would
|
|
43
|
+
// recreate the board mutation this function exists to prevent
|
|
44
|
+
return useStore.getState().toast(`goto: could not read the boards - try again`)
|
|
45
|
+
}
|
|
46
|
+
if (seq !== gotoSeq) return // superseded by newer navigation
|
|
47
|
+
if (!home) {
|
|
48
|
+
// no curated board pins it - the original prototype behavior: spawn beside you
|
|
49
|
+
const st = useStore.getState()
|
|
50
|
+
const node = st.spawn(target)
|
|
51
|
+
if (!node) return st.toast(`unknown goto target "${target}"`)
|
|
52
|
+
st.select(node.key)
|
|
53
|
+
if (carry) st.setInteract(node.key)
|
|
54
|
+
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
await s.switchBoard(home)
|
|
58
|
+
for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
|
|
59
|
+
if (seq !== gotoSeq) return
|
|
60
|
+
const st = useStore.getState()
|
|
61
|
+
if (st.board === home) { // a cancelled/failed switch must not select here
|
|
62
|
+
const node = st.nodes.find((n) => n.frame === target && !n.missing)
|
|
63
|
+
if (node) {
|
|
64
|
+
st.select(node.key)
|
|
65
|
+
if (carry) st.setInteract(node.key)
|
|
66
|
+
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sticky notes (spec 18): the pure half. Widths the layout reserves, which node hosts a
|
|
3
|
+
* scene's note, the per-viewer hide state, and the comment anchor a note builds and resolves
|
|
4
|
+
* in the shell (a note is shell DOM - inspect.js never sees it, so the shell plays its part).
|
|
5
|
+
* No React, no store import: unit tests read this straight.
|
|
6
|
+
*/
|
|
7
|
+
import { create } from 'zustand'
|
|
8
|
+
|
|
9
|
+
/** World px. A frame note is a column beside one frame; a scene note is wider, an intro. */
|
|
10
|
+
export const NOTE_W = 260
|
|
11
|
+
export const SCENE_NOTE_W = 380
|
|
12
|
+
/** The gutter between a note and its frame (the variant badge lives in the same gutter). */
|
|
13
|
+
export const NOTE_GAP = 24
|
|
14
|
+
/** Pin and card tint for anchors on a note - the paper's own hue. */
|
|
15
|
+
export const NOTE_HUE = 48
|
|
16
|
+
|
|
17
|
+
export type NoteKind = 'frame' | 'scene'
|
|
18
|
+
export const noteId = (kind: NoteKind, name: string): string => `${kind}:${name}`
|
|
19
|
+
|
|
20
|
+
/** The width tidy reserves in front of a node: its own note, and the scene note when this node
|
|
21
|
+
* hosts it - one column, so the wider of the two, never the sum. */
|
|
22
|
+
export function noteReserve(frameNote: boolean, sceneNote: boolean): number {
|
|
23
|
+
const w = Math.max(frameNote ? NOTE_W : 0, sceneNote ? SCENE_NOTE_W : 0)
|
|
24
|
+
return w ? w + NOTE_GAP : 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The node that shows a scene's note: the scene's first node on the board in reading order -
|
|
28
|
+
* smallest y, then x, then original index (the deck rule), missing nodes skipped. Null when the
|
|
29
|
+
* scene has no node here. */
|
|
30
|
+
export function sceneNoteHost(
|
|
31
|
+
nodes: readonly { key: string; frame: string; x: number; y: number; missing?: boolean }[],
|
|
32
|
+
sceneOf: (frame: string) => string | undefined,
|
|
33
|
+
scene: string,
|
|
34
|
+
): string | null {
|
|
35
|
+
let best: { key: string; x: number; y: number; i: number } | null = null
|
|
36
|
+
nodes.forEach((n, i) => {
|
|
37
|
+
if (n.missing || sceneOf(n.frame) !== scene) return
|
|
38
|
+
if (!best || n.y < best.y || (n.y === best.y && (n.x < best.x || (n.x === best.x && i < best.i)))) best = { key: n.key, x: n.x, y: n.y, i }
|
|
39
|
+
})
|
|
40
|
+
return best ? (best as { key: string }).key : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The node header the canvas draws above a frame body (FrameNode's HEADER) - a node's card is
|
|
44
|
+
* `h + NODE_HEADER` tall, and that is the height a note beside it has to clear. */
|
|
45
|
+
export const NODE_HEADER = 28
|
|
46
|
+
|
|
47
|
+
/** True when a note has no room: another node's card stands inside the reserve in front of a
|
|
48
|
+
* noted node (its own note, or the scene's note it hosts). Room is the layout's job, never the
|
|
49
|
+
* author's - a board the shell composes re-applies its layout when this is true, so a note file
|
|
50
|
+
* can land on a saved board and the frames make way. Missing nodes (a deleted frame's card, still
|
|
51
|
+
* drawn full size) block room but never host a note. */
|
|
52
|
+
export function notesCramped(
|
|
53
|
+
nodes: readonly { key: string; frame: string; x: number; y: number; w: number; h: number; missing?: boolean }[],
|
|
54
|
+
manifest: { frames: { id: string; scene: string; note?: string }[]; scenes: { name: string; note?: string }[] } | null,
|
|
55
|
+
): boolean {
|
|
56
|
+
if (!manifest) return false
|
|
57
|
+
const entry = (id: string) => manifest.frames.find((f) => f.id === id)
|
|
58
|
+
const live = nodes.filter((n) => !n.missing)
|
|
59
|
+
const hosts = new Set<string>()
|
|
60
|
+
for (const s of manifest.scenes) {
|
|
61
|
+
if (!s.note) continue
|
|
62
|
+
const h = sceneNoteHost(live, (id) => entry(id)?.scene, s.name)
|
|
63
|
+
if (h) hosts.add(h)
|
|
64
|
+
}
|
|
65
|
+
return live.some((n) => {
|
|
66
|
+
const r = noteReserve(!!entry(n.frame)?.note, hosts.has(n.key))
|
|
67
|
+
if (!r) return false
|
|
68
|
+
const x0 = n.x - r, y1 = n.y + n.h + NODE_HEADER
|
|
69
|
+
return nodes.some((o) => o !== n && o.x < n.x && o.x + o.w > x0 && o.y < y1 && o.y + o.h + NODE_HEADER > n.y)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- per-viewer visibility -------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const STORAGE = 'mv-notes'
|
|
76
|
+
interface NotesState {
|
|
77
|
+
/** the N toggle: false hides every note on the canvas */
|
|
78
|
+
all: boolean
|
|
79
|
+
/** note ids folded by their own corner */
|
|
80
|
+
hidden: string[]
|
|
81
|
+
/** fold or unfold one column (its ids together) */
|
|
82
|
+
toggle(ids: string[]): void
|
|
83
|
+
/** N: any note visible -> hide all; none -> show all (and unfold every column) */
|
|
84
|
+
toggleAll(anyVisible: boolean): void
|
|
85
|
+
}
|
|
86
|
+
const load = (): { all: boolean; hidden: string[] } => {
|
|
87
|
+
try {
|
|
88
|
+
const raw = JSON.parse(localStorage.getItem(STORAGE) ?? 'null')
|
|
89
|
+
if (raw && typeof raw === 'object') return { all: raw.all !== false, hidden: Array.isArray(raw.hidden) ? raw.hidden.filter((x: unknown) => typeof x === 'string') : [] }
|
|
90
|
+
} catch { /* storage unavailable or junk: shown by default */ }
|
|
91
|
+
return { all: true, hidden: [] }
|
|
92
|
+
}
|
|
93
|
+
const save = (s: { all: boolean; hidden: string[] }) => { try { localStorage.setItem(STORAGE, JSON.stringify(s)) } catch { /* private mode */ } }
|
|
94
|
+
|
|
95
|
+
export const useNotes = create<NotesState>((set, get) => ({
|
|
96
|
+
...load(),
|
|
97
|
+
toggle(ids) {
|
|
98
|
+
const { all, hidden } = get()
|
|
99
|
+
const off = !all || ids.some((id) => hidden.includes(id))
|
|
100
|
+
// unfolding a column while N hid everything shows that column alone: N flips back on and
|
|
101
|
+
// every OTHER column is folded, so the one asked for is the one that appears
|
|
102
|
+
const next = off
|
|
103
|
+
? { all: true, hidden: all ? hidden.filter((id) => !ids.includes(id)) : [...new Set([...allIds(), ...hidden].filter((id) => !ids.includes(id)))] }
|
|
104
|
+
: { all, hidden: [...new Set([...hidden, ...ids])] }
|
|
105
|
+
set(next); save(next)
|
|
106
|
+
},
|
|
107
|
+
toggleAll(anyVisible) {
|
|
108
|
+
const next = anyVisible ? { all: false, hidden: get().hidden } : { all: true, hidden: [] }
|
|
109
|
+
set(next); save(next)
|
|
110
|
+
},
|
|
111
|
+
}))
|
|
112
|
+
/** Every note id currently on the canvas - read from the DOM, the one place that knows. */
|
|
113
|
+
const allIds = (): string[] => (typeof document === 'undefined' ? [] : [...document.querySelectorAll('[data-note]')].map((el) => el.getAttribute('data-note')!))
|
|
114
|
+
|
|
115
|
+
export const noteVisible = (s: { all: boolean; hidden: string[] }, ids: string[]): boolean => s.all && !ids.some((id) => s.hidden.includes(id))
|
|
116
|
+
|
|
117
|
+
// ---- comment anchors on a note ------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/** The same bundle inspect.js posts for a frame element, plus `note` so the shell resolves it
|
|
120
|
+
* itself. `rect` and `pos` are in node-body coordinates (the CommentLayer's), so a pin on a note
|
|
121
|
+
* sits at a negative x - left of the frame. */
|
|
122
|
+
export interface NoteAnchor {
|
|
123
|
+
el: { semantics: { tag: string; quote?: string }; cssPath: string; note: NoteKind; hue: number }
|
|
124
|
+
pos: { fx: number; fy: number }
|
|
125
|
+
rect: { x: number; y: number; w: number; h: number }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** An nth-of-type chain from the sticky body to the element - stable across re-renders of the
|
|
129
|
+
* same markdown, honest when it changes (the quote fallback then decides). */
|
|
130
|
+
export function cssPathWithin(el: Element, root: Element): string {
|
|
131
|
+
const parts: string[] = []
|
|
132
|
+
let cur: Element | null = el
|
|
133
|
+
while (cur && cur !== root) {
|
|
134
|
+
const tag = cur.tagName.toLowerCase()
|
|
135
|
+
const parent: Element | null = cur.parentElement
|
|
136
|
+
if (!parent) break
|
|
137
|
+
const same = [...parent.children].filter((c) => c.tagName === cur!.tagName)
|
|
138
|
+
parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(cur) + 1})` : tag)
|
|
139
|
+
cur = parent
|
|
140
|
+
}
|
|
141
|
+
return parts.join(' > ')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const quoteOf = (el: Element) => (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
145
|
+
|
|
146
|
+
/** Build the anchor for a click at (clientX, clientY) on `el` inside a sticky whose body is
|
|
147
|
+
* `root`; `toBody` maps a screen rect to node-body coordinates. */
|
|
148
|
+
export function noteAnchor(el: Element, root: Element, kind: NoteKind, at: { clientX: number; clientY: number }, toBody: (r: DOMRect) => { x: number; y: number; w: number; h: number }): NoteAnchor {
|
|
149
|
+
const r = el.getBoundingClientRect()
|
|
150
|
+
const quote = quoteOf(el).slice(0, 200)
|
|
151
|
+
return {
|
|
152
|
+
el: { semantics: { tag: el.tagName.toLowerCase(), ...(quote ? { quote } : {}) }, cssPath: cssPathWithin(el, root), note: kind, hue: NOTE_HUE },
|
|
153
|
+
pos: {
|
|
154
|
+
fx: r.width ? Math.min(1, Math.max(0, (at.clientX - r.left) / r.width)) : .5,
|
|
155
|
+
fy: r.height ? Math.min(1, Math.max(0, (at.clientY - r.top) / r.height)) : .5,
|
|
156
|
+
},
|
|
157
|
+
rect: toBody(r),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** inspect.js's resolve, for a note: the path when it still matches, else tag + quote. */
|
|
162
|
+
export function resolveNoteAnchor(anchor: unknown, root: Element): Element | null {
|
|
163
|
+
const a = anchor as Partial<NoteAnchor> | null
|
|
164
|
+
const want = a?.el?.semantics ?? { tag: '' }
|
|
165
|
+
const match = (el: Element) => {
|
|
166
|
+
if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
|
|
167
|
+
if (want.quote) { const t = quoteOf(el); if (!(t.startsWith(want.quote.slice(0, 60)) || t.includes(want.quote.slice(0, 40)))) return false }
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const byPath = a?.el?.cssPath ? root.querySelector(a.el.cssPath) : null
|
|
172
|
+
if (byPath && match(byPath)) return byPath
|
|
173
|
+
} catch { /* stale selector */ }
|
|
174
|
+
if (want.quote && /^[a-z][a-z0-9-]*$/.test(want.tag)) for (const el of root.querySelectorAll(want.tag)) if (match(el)) return el
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const isNoteAnchor = (anchor: unknown): anchor is NoteAnchor => {
|
|
179
|
+
const n = (anchor as any)?.el?.note
|
|
180
|
+
return n === 'frame' || n === 'scene'
|
|
181
|
+
}
|