@1agh/maude 1.4.6 → 1.5.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/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +534 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops-merge.ts +15 -0
- package/apps/studio/annotations/ops.ts +499 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +65 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +249 -69
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-lib.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +6 -0
- package/apps/studio/client/app.jsx +98 -35
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +49 -5
- package/apps/studio/collab/persistence.ts +31 -10
- package/apps/studio/collab/registry.ts +64 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/cursors-overlay.tsx +158 -4
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +12 -4
- package/apps/studio/sync/agent.ts +8 -4
- package/apps/studio/sync/codec.ts +96 -40
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +1 -1
- package/apps/studio/sync/index.ts +38 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +8 -5
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +76 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- package/apps/studio/commands/annotation-strokes-command.ts +0 -137
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations/ui/element-node.tsx — one DOM node per annotation element
|
|
3
|
+
* @scope apps/studio/annotations/ui/element-node.tsx
|
|
4
|
+
* @purpose DDR-242 AD7 + AD8 (Tasks 17 + 23). Every element is its own
|
|
5
|
+
* absolutely positioned node in the world, in paint order:
|
|
6
|
+
* geometry in a small SVG drawn in WORLD coordinates (so the shared
|
|
7
|
+
* geometry helpers apply unchanged), text as HTML. Keeping each
|
|
8
|
+
* element's text inside its own node keeps z-order exact — a pen
|
|
9
|
+
* stroke drawn over a sticky still covers its text — which one
|
|
10
|
+
* text layer over all the ink could not.
|
|
11
|
+
*
|
|
12
|
+
* `ElementNode` is memoized on its record and resolved geometry, so
|
|
13
|
+
* a change re-renders only the element that changed. The editor
|
|
14
|
+
* (text-editor.tsx) replaces an element's text block in place, with
|
|
15
|
+
* the same class and style.
|
|
16
|
+
*
|
|
17
|
+
* DOM contract kept for hit-testing, comments and tooling:
|
|
18
|
+
* `[data-id][data-tool]` on the node (`data-tool` = the v1 tool
|
|
19
|
+
* name; `data-type` = the v2 type). Painted parts claim pointer
|
|
20
|
+
* events only while the layer is interactive.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Component, type CSSProperties, memo, type ReactNode } from 'react';
|
|
24
|
+
import {
|
|
25
|
+
clampLinkTitle,
|
|
26
|
+
LINK_CARD_FILL,
|
|
27
|
+
LINK_CARD_STROKE,
|
|
28
|
+
LINK_DOMAIN_FILL,
|
|
29
|
+
LINK_GLYPH_D1,
|
|
30
|
+
LINK_GLYPH_D2,
|
|
31
|
+
LINK_GLYPH_STROKE,
|
|
32
|
+
LINK_TITLE_FILL,
|
|
33
|
+
linkCardLayout,
|
|
34
|
+
MEDIAREF_AUDIO_GLYPH,
|
|
35
|
+
MEDIAREF_VIDEO_GLYPH,
|
|
36
|
+
penPathD,
|
|
37
|
+
polygonPoints,
|
|
38
|
+
SECTION_CORNER_RADIUS,
|
|
39
|
+
SECTION_LABEL_FONT,
|
|
40
|
+
SECTION_LABEL_H,
|
|
41
|
+
STICKY_CORNER_RADIUS,
|
|
42
|
+
stickyCornerPath,
|
|
43
|
+
type WorldPoint,
|
|
44
|
+
} from '../../annotations-model.ts';
|
|
45
|
+
import { arrowPrimitives, type SvgPrimitive } from '../../canvas-arrowheads.ts';
|
|
46
|
+
import { countRender, useLiveViewport } from '../../canvas-lib.tsx';
|
|
47
|
+
import { colorForName } from '../../use-collab.tsx';
|
|
48
|
+
import type { AnnotationElement } from '../types.ts';
|
|
49
|
+
import type { ResolvedEnds } from './render-model.ts';
|
|
50
|
+
import { type CommitInfo, type SizeMode, TextEditor } from './text-editor.tsx';
|
|
51
|
+
import {
|
|
52
|
+
anchorShift,
|
|
53
|
+
type ListStyle,
|
|
54
|
+
slotTextStyle,
|
|
55
|
+
TEXT_FONT,
|
|
56
|
+
type TextAlign,
|
|
57
|
+
type TextSlot,
|
|
58
|
+
withListMarkers,
|
|
59
|
+
} from './text-style.ts';
|
|
60
|
+
|
|
61
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// Props
|
|
63
|
+
|
|
64
|
+
/** An open edit session on this element's text slot (only the edited node gets one). */
|
|
65
|
+
export interface EditRequest {
|
|
66
|
+
caretPoint: { x: number; y: number } | null;
|
|
67
|
+
/** A collaborator changed (merged on commit) or deleted (commit restores) the element. */
|
|
68
|
+
notice?: 'edited' | 'deleted' | null;
|
|
69
|
+
onCommit: (info: CommitInfo) => void;
|
|
70
|
+
onCancel: () => void;
|
|
71
|
+
onDraft?: (text: string) => void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ElementNodeProps {
|
|
75
|
+
el: AnnotationElement;
|
|
76
|
+
/** Resolved arrow endpoints (arrows only). */
|
|
77
|
+
ends?: ResolvedEnds;
|
|
78
|
+
interactive: boolean;
|
|
79
|
+
edit: EditRequest | null;
|
|
80
|
+
/** Maps a stored `assets/…` path to a URL the canvas can load. */
|
|
81
|
+
resolveAsset: (href: string) => string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
85
|
+
// Field readers (records are validated or come from the v1 adapter; be lenient)
|
|
86
|
+
|
|
87
|
+
function num(v: unknown, def = 0): number {
|
|
88
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : def;
|
|
89
|
+
}
|
|
90
|
+
function str(v: unknown, def = ''): string {
|
|
91
|
+
return typeof v === 'string' ? v : def;
|
|
92
|
+
}
|
|
93
|
+
function align(v: unknown, def: TextAlign): TextAlign {
|
|
94
|
+
return v === 'left' || v === 'center' || v === 'right' ? v : def;
|
|
95
|
+
}
|
|
96
|
+
function list(v: unknown): ListStyle | undefined {
|
|
97
|
+
return v === 'bullet' || v === 'number' ? v : undefined;
|
|
98
|
+
}
|
|
99
|
+
function textFmt(r: Record<string, unknown>, defAlign: TextAlign, defColor?: string) {
|
|
100
|
+
return {
|
|
101
|
+
fontSize: num(r.fontSize, 14),
|
|
102
|
+
color: typeof r.color === 'string' ? r.color : defColor,
|
|
103
|
+
bold: r.bold === true,
|
|
104
|
+
italic: r.italic === true,
|
|
105
|
+
strike: r.strike === true,
|
|
106
|
+
underline: r.underline === true,
|
|
107
|
+
align: align(r.align, defAlign),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The v1 tool name hit-testing and tooling key on (`data-tool`). */
|
|
112
|
+
export function v1ToolOf(el: AnnotationElement): string {
|
|
113
|
+
if (el.type === 'shape') {
|
|
114
|
+
const k = str(el.kind, 'rect');
|
|
115
|
+
return k === 'rect' || k === 'ellipse' ? k : 'polygon';
|
|
116
|
+
}
|
|
117
|
+
return el.type;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
121
|
+
// Shared pieces
|
|
122
|
+
|
|
123
|
+
function Node({
|
|
124
|
+
el,
|
|
125
|
+
x,
|
|
126
|
+
y,
|
|
127
|
+
w,
|
|
128
|
+
h,
|
|
129
|
+
rot = 0,
|
|
130
|
+
raised = false,
|
|
131
|
+
extraStyle,
|
|
132
|
+
children,
|
|
133
|
+
}: {
|
|
134
|
+
el: AnnotationElement;
|
|
135
|
+
x: number;
|
|
136
|
+
y: number;
|
|
137
|
+
w: number | 'auto';
|
|
138
|
+
h: number | 'auto';
|
|
139
|
+
rot?: number;
|
|
140
|
+
/** An open editor floats above the elements painted after it. */
|
|
141
|
+
raised?: boolean;
|
|
142
|
+
extraStyle?: CSSProperties;
|
|
143
|
+
children: ReactNode;
|
|
144
|
+
}) {
|
|
145
|
+
const tool = v1ToolOf(el);
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
className="dc-annot-el"
|
|
149
|
+
data-id={el.id}
|
|
150
|
+
data-tool={tool}
|
|
151
|
+
data-type={el.type}
|
|
152
|
+
{...(tool === 'polygon' ? { 'data-shape': str(el.kind) } : {})}
|
|
153
|
+
style={{
|
|
154
|
+
left: x,
|
|
155
|
+
top: y,
|
|
156
|
+
width: w === 'auto' ? undefined : w,
|
|
157
|
+
height: h === 'auto' ? undefined : h,
|
|
158
|
+
...(rot ? { transform: `rotate(${rot}deg)` } : {}),
|
|
159
|
+
...(raised ? { zIndex: 3 } : {}),
|
|
160
|
+
...extraStyle,
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Geometry drawn in WORLD coords, mapped onto the node's box. */
|
|
169
|
+
function Geo({
|
|
170
|
+
x,
|
|
171
|
+
y,
|
|
172
|
+
w,
|
|
173
|
+
h,
|
|
174
|
+
children,
|
|
175
|
+
}: {
|
|
176
|
+
x: number;
|
|
177
|
+
y: number;
|
|
178
|
+
w: number;
|
|
179
|
+
h: number;
|
|
180
|
+
children: ReactNode;
|
|
181
|
+
}) {
|
|
182
|
+
const vw = Math.max(w, 1);
|
|
183
|
+
const vh = Math.max(h, 1);
|
|
184
|
+
return (
|
|
185
|
+
<svg
|
|
186
|
+
className="dc-annot-geo"
|
|
187
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
188
|
+
width={vw}
|
|
189
|
+
height={vh}
|
|
190
|
+
viewBox={`${x} ${y} ${vw} ${vh}`}
|
|
191
|
+
aria-hidden="true"
|
|
192
|
+
>
|
|
193
|
+
{children}
|
|
194
|
+
</svg>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function hit(interactive: boolean, mode: 'visiblePainted' | 'stroke' = 'visiblePainted') {
|
|
199
|
+
return interactive ? mode : ('none' as const);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Marker above an open editor when a collaborator changed the element under it. */
|
|
203
|
+
function EditNotice({ notice }: { notice: EditRequest['notice'] }) {
|
|
204
|
+
if (!notice) return null;
|
|
205
|
+
const text =
|
|
206
|
+
notice === 'deleted'
|
|
207
|
+
? 'Deleted by a collaborator — Enter keeps your text, Esc discards it'
|
|
208
|
+
: 'Edited by a collaborator — both changes are kept';
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
className="dc-annot-edit-notice"
|
|
212
|
+
data-edit-notice={notice}
|
|
213
|
+
role="status"
|
|
214
|
+
style={{
|
|
215
|
+
position: 'absolute',
|
|
216
|
+
left: 0,
|
|
217
|
+
bottom: '100%',
|
|
218
|
+
marginBottom: 6,
|
|
219
|
+
whiteSpace: 'nowrap',
|
|
220
|
+
font: `11px ${TEXT_FONT}`,
|
|
221
|
+
color: '#1f1f1f',
|
|
222
|
+
background: notice === 'deleted' ? '#fde2e1' : '#fff3c4',
|
|
223
|
+
border: '1px solid rgba(0,0,0,0.12)',
|
|
224
|
+
borderRadius: 6,
|
|
225
|
+
padding: '2px 8px',
|
|
226
|
+
zIndex: 4,
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
{text}
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
// Per-type views
|
|
236
|
+
|
|
237
|
+
function StickyView({ el, interactive, edit }: ElementNodeProps) {
|
|
238
|
+
const x = num(el.x);
|
|
239
|
+
const y = num(el.y);
|
|
240
|
+
const w = num(el.w);
|
|
241
|
+
const h = num(el.h);
|
|
242
|
+
const r = num(el.radius, STICKY_CORNER_RADIUS);
|
|
243
|
+
const fill = str(el.fill, '#fce8a6');
|
|
244
|
+
const style = {
|
|
245
|
+
...slotTextStyle('sticky', textFmt(el, 'left')),
|
|
246
|
+
color: '#2a2a28',
|
|
247
|
+
padding: '14px 16px',
|
|
248
|
+
};
|
|
249
|
+
const text = str(el.text);
|
|
250
|
+
const lst = list(el.list);
|
|
251
|
+
const authorName =
|
|
252
|
+
el.author && typeof el.author === 'object' ? str((el.author as { name?: unknown }).name) : '';
|
|
253
|
+
return (
|
|
254
|
+
<Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)} raised={!!edit}>
|
|
255
|
+
<Geo x={x} y={y} w={w} h={h}>
|
|
256
|
+
<path
|
|
257
|
+
d={stickyCornerPath(x, y, w, h, r)}
|
|
258
|
+
fill={fill}
|
|
259
|
+
stroke="rgba(0,0,0,0.05)"
|
|
260
|
+
strokeWidth={1}
|
|
261
|
+
vectorEffect="non-scaling-stroke"
|
|
262
|
+
filter="url(#dc-sticky-shadow)"
|
|
263
|
+
pointerEvents={hit(interactive)}
|
|
264
|
+
/>
|
|
265
|
+
</Geo>
|
|
266
|
+
{edit ? (
|
|
267
|
+
<div
|
|
268
|
+
style={{
|
|
269
|
+
position: 'absolute',
|
|
270
|
+
left: 0,
|
|
271
|
+
top: 0,
|
|
272
|
+
width: '100%',
|
|
273
|
+
minHeight: h,
|
|
274
|
+
// Grows downward while typing past the card; paints its own paper
|
|
275
|
+
// so the overflow still reads as the note. `backgroundColor`, never
|
|
276
|
+
// the `background` shorthand: the fill is peer-controlled and the
|
|
277
|
+
// shorthand would take a `url(…)`.
|
|
278
|
+
backgroundColor: fill,
|
|
279
|
+
borderRadius: `${r}px ${r}px 0 ${r}px`,
|
|
280
|
+
zIndex: 1,
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<TextEditor
|
|
284
|
+
initialText={text}
|
|
285
|
+
list={lst}
|
|
286
|
+
className="dc-annot-text"
|
|
287
|
+
style={{ ...style, width: '100%' }}
|
|
288
|
+
size={{ kind: 'grow-height', minHeight: h }}
|
|
289
|
+
fmt={textFmt(el, 'left')}
|
|
290
|
+
caretPoint={edit.caretPoint}
|
|
291
|
+
ariaLabel="Edit sticky note text"
|
|
292
|
+
onCommit={edit.onCommit}
|
|
293
|
+
onCancel={edit.onCancel}
|
|
294
|
+
onDraft={edit.onDraft}
|
|
295
|
+
/>
|
|
296
|
+
</div>
|
|
297
|
+
) : (
|
|
298
|
+
<div
|
|
299
|
+
className="dc-annot-text"
|
|
300
|
+
style={{ ...style, position: 'absolute', inset: 0, overflow: 'hidden' }}
|
|
301
|
+
>
|
|
302
|
+
{withListMarkers(text, lst)}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
{edit ? <EditNotice notice={edit.notice} /> : null}
|
|
306
|
+
{authorName ? (
|
|
307
|
+
<span
|
|
308
|
+
title={authorName}
|
|
309
|
+
style={{
|
|
310
|
+
position: 'absolute',
|
|
311
|
+
right: 4,
|
|
312
|
+
bottom: 3,
|
|
313
|
+
maxWidth: 156,
|
|
314
|
+
overflow: 'hidden',
|
|
315
|
+
textOverflow: 'ellipsis',
|
|
316
|
+
whiteSpace: 'nowrap',
|
|
317
|
+
fontSize: 9,
|
|
318
|
+
fontWeight: 600,
|
|
319
|
+
fontFamily: TEXT_FONT,
|
|
320
|
+
color: colorForName(authorName),
|
|
321
|
+
background: 'rgba(255,255,255,0.78)',
|
|
322
|
+
padding: '1px 5px',
|
|
323
|
+
borderRadius: 8,
|
|
324
|
+
lineHeight: 1.4,
|
|
325
|
+
}}
|
|
326
|
+
>
|
|
327
|
+
{authorName}
|
|
328
|
+
</span>
|
|
329
|
+
) : null}
|
|
330
|
+
</Node>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function TextView({ el, interactive, edit }: ElementNodeProps) {
|
|
335
|
+
const f = textFmt(el, 'left', '#1a1a1a');
|
|
336
|
+
const style: CSSProperties = { ...slotTextStyle('text', f), color: f.color, padding: '0 2px' };
|
|
337
|
+
const text = str(el.text);
|
|
338
|
+
const lst = list(el.list);
|
|
339
|
+
return (
|
|
340
|
+
<Node
|
|
341
|
+
el={el}
|
|
342
|
+
x={num(el.x)}
|
|
343
|
+
y={num(el.y)}
|
|
344
|
+
w="auto"
|
|
345
|
+
h="auto"
|
|
346
|
+
rot={num(el.rot)}
|
|
347
|
+
raised={!!edit}
|
|
348
|
+
extraStyle={{
|
|
349
|
+
transform:
|
|
350
|
+
[anchorShift(f.align), el.rot ? `rotate(${num(el.rot)}deg)` : undefined]
|
|
351
|
+
.filter(Boolean)
|
|
352
|
+
.join(' ') || undefined,
|
|
353
|
+
}}
|
|
354
|
+
>
|
|
355
|
+
{edit ? (
|
|
356
|
+
<TextEditor
|
|
357
|
+
initialText={text}
|
|
358
|
+
list={lst}
|
|
359
|
+
className="dc-annot-text dc-annot-text--nowrap"
|
|
360
|
+
style={style}
|
|
361
|
+
size={{ kind: 'fit-both' }}
|
|
362
|
+
fmt={f}
|
|
363
|
+
caretPoint={edit.caretPoint}
|
|
364
|
+
ariaLabel="Edit text"
|
|
365
|
+
onCommit={edit.onCommit}
|
|
366
|
+
onCancel={edit.onCancel}
|
|
367
|
+
onDraft={edit.onDraft}
|
|
368
|
+
/>
|
|
369
|
+
) : (
|
|
370
|
+
<div
|
|
371
|
+
className="dc-annot-text dc-annot-text--nowrap"
|
|
372
|
+
style={{ ...style, pointerEvents: interactive ? 'auto' : 'none', cursor: 'text' }}
|
|
373
|
+
>
|
|
374
|
+
{withListMarkers(text, lst)}
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
{edit ? <EditNotice notice={edit.notice} /> : null}
|
|
378
|
+
</Node>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function ShapeView({ el, interactive, edit }: ElementNodeProps) {
|
|
383
|
+
const x = num(el.x);
|
|
384
|
+
const y = num(el.y);
|
|
385
|
+
const w = num(el.w);
|
|
386
|
+
const h = num(el.h);
|
|
387
|
+
const kind = str(el.kind, 'rect');
|
|
388
|
+
const common = {
|
|
389
|
+
stroke: str(el.color, '#1a1a1a'),
|
|
390
|
+
strokeWidth: num(el.width, 2),
|
|
391
|
+
strokeLinecap: 'round' as const,
|
|
392
|
+
strokeLinejoin: 'round' as const,
|
|
393
|
+
fill: typeof el.fill === 'string' ? el.fill : 'none',
|
|
394
|
+
strokeDasharray: el.dashed === true ? '6 4' : undefined,
|
|
395
|
+
pointerEvents: hit(interactive),
|
|
396
|
+
};
|
|
397
|
+
let geo: ReactNode;
|
|
398
|
+
if (kind === 'rect') {
|
|
399
|
+
const r = num(el.radius);
|
|
400
|
+
geo = <rect {...common} x={x} y={y} width={w} height={h} rx={r} ry={r} />;
|
|
401
|
+
} else if (kind === 'ellipse') {
|
|
402
|
+
geo = <ellipse {...common} cx={x + w / 2} cy={y + h / 2} rx={w / 2} ry={h / 2} />;
|
|
403
|
+
} else {
|
|
404
|
+
geo = (
|
|
405
|
+
<polygon
|
|
406
|
+
{...common}
|
|
407
|
+
points={polygonPoints(kind as Parameters<typeof polygonPoints>[0], x, y, w, h)}
|
|
408
|
+
/>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
const label = (el.label ?? {}) as Record<string, unknown>;
|
|
412
|
+
const hasLabel = typeof label.text === 'string';
|
|
413
|
+
const f = textFmt(label, 'center', '#1a1a1a');
|
|
414
|
+
const style: CSSProperties = { ...slotTextStyle('label', f), color: f.color };
|
|
415
|
+
return (
|
|
416
|
+
<Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)} raised={!!edit}>
|
|
417
|
+
<Geo x={x} y={y} w={w} h={h}>
|
|
418
|
+
{geo}
|
|
419
|
+
</Geo>
|
|
420
|
+
{edit ? (
|
|
421
|
+
<div className="dc-annot-label" style={{ zIndex: 1 }}>
|
|
422
|
+
<TextEditor
|
|
423
|
+
initialText={str(label.text)}
|
|
424
|
+
list={list(label.list)}
|
|
425
|
+
className="dc-annot-text"
|
|
426
|
+
style={style}
|
|
427
|
+
size={{ kind: 'fit-height' }}
|
|
428
|
+
fmt={f}
|
|
429
|
+
caretPoint={edit.caretPoint}
|
|
430
|
+
ariaLabel="Edit annotation text"
|
|
431
|
+
onCommit={edit.onCommit}
|
|
432
|
+
onCancel={edit.onCancel}
|
|
433
|
+
onDraft={edit.onDraft}
|
|
434
|
+
/>
|
|
435
|
+
</div>
|
|
436
|
+
) : hasLabel ? (
|
|
437
|
+
<div className="dc-annot-label">
|
|
438
|
+
<div
|
|
439
|
+
className="dc-annot-text"
|
|
440
|
+
style={{ ...style, pointerEvents: interactive ? 'auto' : 'none' }}
|
|
441
|
+
>
|
|
442
|
+
{withListMarkers(str(label.text), list(label.list))}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
) : null}
|
|
446
|
+
{edit ? <EditNotice notice={edit.notice} /> : null}
|
|
447
|
+
</Node>
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function renderArrowPrimitive(p: SvgPrimitive, key: number): ReactNode {
|
|
452
|
+
switch (p.el) {
|
|
453
|
+
case 'line':
|
|
454
|
+
return (
|
|
455
|
+
<line
|
|
456
|
+
key={key}
|
|
457
|
+
x1={p.x1}
|
|
458
|
+
y1={p.y1}
|
|
459
|
+
x2={p.x2}
|
|
460
|
+
y2={p.y2}
|
|
461
|
+
strokeDasharray={p.dash ? '6 4' : undefined}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
464
|
+
case 'path':
|
|
465
|
+
return <path key={key} d={p.d} strokeDasharray={p.dash ? '6 4' : undefined} />;
|
|
466
|
+
case 'polyline':
|
|
467
|
+
return <polyline key={key} points={p.points} fill={p.fill} />;
|
|
468
|
+
case 'polygon':
|
|
469
|
+
return <polygon key={key} points={p.points} fill={p.fill} />;
|
|
470
|
+
case 'circle':
|
|
471
|
+
return <circle key={key} cx={p.cx} cy={p.cy} r={p.r} fill={p.fill} />;
|
|
472
|
+
default:
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function arrowGeom(el: AnnotationElement, ends: ResolvedEnds) {
|
|
478
|
+
return {
|
|
479
|
+
x1: ends.x1,
|
|
480
|
+
y1: ends.y1,
|
|
481
|
+
x2: ends.x2,
|
|
482
|
+
y2: ends.y2,
|
|
483
|
+
width: num(el.width, 2),
|
|
484
|
+
color: str(el.color, '#1a1a1a'),
|
|
485
|
+
...(typeof el.startHead === 'string' ? { startHead: el.startHead } : {}),
|
|
486
|
+
...(typeof el.endHead === 'string' ? { endHead: el.endHead } : {}),
|
|
487
|
+
...(el.dashed === true ? { dashed: true } : {}),
|
|
488
|
+
...(typeof el.line === 'string' ? { lineType: el.line } : {}),
|
|
489
|
+
...(ends.startAnchor
|
|
490
|
+
? {
|
|
491
|
+
startBind: {
|
|
492
|
+
hostId: ends.startAnchor.el,
|
|
493
|
+
nx: ends.startAnchor.nx,
|
|
494
|
+
ny: ends.startAnchor.ny,
|
|
495
|
+
},
|
|
496
|
+
}
|
|
497
|
+
: {}),
|
|
498
|
+
...(ends.endAnchor
|
|
499
|
+
? { endBind: { hostId: ends.endAnchor.el, nx: ends.endAnchor.nx, ny: ends.endAnchor.ny } }
|
|
500
|
+
: {}),
|
|
501
|
+
} as Parameters<typeof arrowPrimitives>[0];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function ArrowView({ el, ends, interactive }: ElementNodeProps) {
|
|
505
|
+
if (!ends) return null;
|
|
506
|
+
const width = num(el.width, 2);
|
|
507
|
+
const pad = width * 4 + 24; // heads + curve bulge stay inside the node's box
|
|
508
|
+
const x = Math.min(ends.x1, ends.x2) - pad;
|
|
509
|
+
const y = Math.min(ends.y1, ends.y2) - pad;
|
|
510
|
+
const w = Math.abs(ends.x2 - ends.x1) + pad * 2;
|
|
511
|
+
const h = Math.abs(ends.y2 - ends.y1) + pad * 2;
|
|
512
|
+
return (
|
|
513
|
+
<Node el={el} x={x} y={y} w={w} h={h}>
|
|
514
|
+
<Geo x={x} y={y} w={w} h={h}>
|
|
515
|
+
<g
|
|
516
|
+
stroke={str(el.color, '#1a1a1a')}
|
|
517
|
+
strokeWidth={width}
|
|
518
|
+
strokeLinecap="round"
|
|
519
|
+
strokeLinejoin="round"
|
|
520
|
+
fill="none"
|
|
521
|
+
pointerEvents={hit(interactive)}
|
|
522
|
+
>
|
|
523
|
+
{arrowPrimitives(arrowGeom(el, ends)).map((p, i) => renderArrowPrimitive(p, i))}
|
|
524
|
+
</g>
|
|
525
|
+
</Geo>
|
|
526
|
+
</Node>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function PenView({ el, interactive }: ElementNodeProps) {
|
|
531
|
+
const flat = Array.isArray(el.points) ? (el.points as number[]) : [];
|
|
532
|
+
const pts: WorldPoint[] = [];
|
|
533
|
+
let x0 = Number.POSITIVE_INFINITY;
|
|
534
|
+
let y0 = Number.POSITIVE_INFINITY;
|
|
535
|
+
let x1 = Number.NEGATIVE_INFINITY;
|
|
536
|
+
let y1 = Number.NEGATIVE_INFINITY;
|
|
537
|
+
for (let i = 0; i + 1 < flat.length; i += 2) {
|
|
538
|
+
const px = flat[i] as number;
|
|
539
|
+
const py = flat[i + 1] as number;
|
|
540
|
+
pts.push([px, py]);
|
|
541
|
+
if (px < x0) x0 = px;
|
|
542
|
+
if (py < y0) y0 = py;
|
|
543
|
+
if (px > x1) x1 = px;
|
|
544
|
+
if (py > y1) y1 = py;
|
|
545
|
+
}
|
|
546
|
+
if (!pts.length) return null;
|
|
547
|
+
const width = num(el.width, 2);
|
|
548
|
+
const pad = width;
|
|
549
|
+
const highlighter = el.highlighter === true;
|
|
550
|
+
return (
|
|
551
|
+
<Node
|
|
552
|
+
el={el}
|
|
553
|
+
x={x0 - pad}
|
|
554
|
+
y={y0 - pad}
|
|
555
|
+
w={x1 - x0 + pad * 2}
|
|
556
|
+
h={y1 - y0 + pad * 2}
|
|
557
|
+
// Overlapping highlighter ink darkens (multiply) against everything below.
|
|
558
|
+
extraStyle={highlighter ? { mixBlendMode: 'multiply' } : undefined}
|
|
559
|
+
>
|
|
560
|
+
<Geo x={x0 - pad} y={y0 - pad} w={x1 - x0 + pad * 2} h={y1 - y0 + pad * 2}>
|
|
561
|
+
{/* No vector-effect: drawn ink is world content and scales with zoom. */}
|
|
562
|
+
<path
|
|
563
|
+
d={penPathD(pts)}
|
|
564
|
+
fill="none"
|
|
565
|
+
stroke={str(el.color, '#1a1a1a')}
|
|
566
|
+
strokeWidth={width}
|
|
567
|
+
strokeLinecap="round"
|
|
568
|
+
strokeLinejoin="round"
|
|
569
|
+
pointerEvents={hit(interactive, 'stroke')}
|
|
570
|
+
/>
|
|
571
|
+
</Geo>
|
|
572
|
+
</Node>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function ImageView({ el, interactive, resolveAsset }: ElementNodeProps) {
|
|
577
|
+
const x = num(el.x);
|
|
578
|
+
const y = num(el.y);
|
|
579
|
+
const w = num(el.w);
|
|
580
|
+
const h = num(el.h);
|
|
581
|
+
return (
|
|
582
|
+
<Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)}>
|
|
583
|
+
<Geo x={x} y={y} w={w} h={h}>
|
|
584
|
+
<image
|
|
585
|
+
x={x}
|
|
586
|
+
y={y}
|
|
587
|
+
width={w}
|
|
588
|
+
height={h}
|
|
589
|
+
href={resolveAsset(str(el.href))}
|
|
590
|
+
preserveAspectRatio="xMidYMid meet"
|
|
591
|
+
aria-label={str(el.alt) || undefined}
|
|
592
|
+
pointerEvents={hit(interactive)}
|
|
593
|
+
/>
|
|
594
|
+
</Geo>
|
|
595
|
+
</Node>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function LinkView({ el, interactive }: ElementNodeProps) {
|
|
600
|
+
const x = num(el.x);
|
|
601
|
+
const y = num(el.y);
|
|
602
|
+
const w = num(el.w);
|
|
603
|
+
const h = num(el.h);
|
|
604
|
+
const lay = linkCardLayout(x, y, w, h);
|
|
605
|
+
const font = { fontFamily: TEXT_FONT } as const;
|
|
606
|
+
return (
|
|
607
|
+
<Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)}>
|
|
608
|
+
<Geo x={x} y={y} w={w} h={h}>
|
|
609
|
+
<g
|
|
610
|
+
data-url={str(el.url)}
|
|
611
|
+
data-title={str(el.title)}
|
|
612
|
+
data-domain={str(el.domain)}
|
|
613
|
+
pointerEvents={hit(interactive)}
|
|
614
|
+
>
|
|
615
|
+
<rect
|
|
616
|
+
x={x}
|
|
617
|
+
y={y}
|
|
618
|
+
width={w}
|
|
619
|
+
height={h}
|
|
620
|
+
rx={8}
|
|
621
|
+
ry={8}
|
|
622
|
+
fill={LINK_CARD_FILL}
|
|
623
|
+
stroke={LINK_CARD_STROKE}
|
|
624
|
+
strokeWidth={1}
|
|
625
|
+
vectorEffect="non-scaling-stroke"
|
|
626
|
+
filter="url(#dc-sticky-shadow)"
|
|
627
|
+
/>
|
|
628
|
+
<svg
|
|
629
|
+
x={lay.glyph.x}
|
|
630
|
+
y={lay.glyph.y}
|
|
631
|
+
width={lay.glyph.size}
|
|
632
|
+
height={lay.glyph.size}
|
|
633
|
+
viewBox="0 0 24 24"
|
|
634
|
+
fill="none"
|
|
635
|
+
stroke={LINK_GLYPH_STROKE}
|
|
636
|
+
strokeWidth={2}
|
|
637
|
+
strokeLinecap="round"
|
|
638
|
+
strokeLinejoin="round"
|
|
639
|
+
aria-hidden="true"
|
|
640
|
+
>
|
|
641
|
+
<path d={LINK_GLYPH_D1} />
|
|
642
|
+
<path d={LINK_GLYPH_D2} />
|
|
643
|
+
</svg>
|
|
644
|
+
<text
|
|
645
|
+
x={lay.textX}
|
|
646
|
+
y={lay.domain.y}
|
|
647
|
+
fontSize={lay.domain.fontSize}
|
|
648
|
+
fill={LINK_DOMAIN_FILL}
|
|
649
|
+
dominantBaseline="hanging"
|
|
650
|
+
style={font}
|
|
651
|
+
>
|
|
652
|
+
{str(el.domain)}
|
|
653
|
+
</text>
|
|
654
|
+
<text
|
|
655
|
+
x={lay.textX}
|
|
656
|
+
y={lay.title.y}
|
|
657
|
+
fontSize={lay.title.fontSize}
|
|
658
|
+
fill={LINK_TITLE_FILL}
|
|
659
|
+
fontWeight={600}
|
|
660
|
+
dominantBaseline="hanging"
|
|
661
|
+
style={font}
|
|
662
|
+
>
|
|
663
|
+
{clampLinkTitle(str(el.title), lay.textMaxChars)}
|
|
664
|
+
</text>
|
|
665
|
+
</g>
|
|
666
|
+
</Geo>
|
|
667
|
+
</Node>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function MediaRefView({ el, interactive }: ElementNodeProps) {
|
|
672
|
+
const x = num(el.x);
|
|
673
|
+
const y = num(el.y);
|
|
674
|
+
const w = num(el.w);
|
|
675
|
+
const h = num(el.h);
|
|
676
|
+
const HEADER = 26;
|
|
677
|
+
const isAudio = el.media === 'audio';
|
|
678
|
+
const src = str(el.src);
|
|
679
|
+
const font = { fontFamily: TEXT_FONT } as const;
|
|
680
|
+
return (
|
|
681
|
+
<Node el={el} x={x} y={y} w={w} h={h} rot={num(el.rot)}>
|
|
682
|
+
<Geo x={x} y={y} w={w} h={h}>
|
|
683
|
+
<g
|
|
684
|
+
data-src={src}
|
|
685
|
+
data-media-kind={isAudio ? 'audio' : 'video'}
|
|
686
|
+
data-title={str(el.title)}
|
|
687
|
+
pointerEvents={hit(interactive)}
|
|
688
|
+
>
|
|
689
|
+
<rect
|
|
690
|
+
x={x}
|
|
691
|
+
y={y}
|
|
692
|
+
width={w}
|
|
693
|
+
height={h}
|
|
694
|
+
rx={8}
|
|
695
|
+
ry={8}
|
|
696
|
+
fill={LINK_CARD_FILL}
|
|
697
|
+
stroke={LINK_CARD_STROKE}
|
|
698
|
+
strokeWidth={1}
|
|
699
|
+
vectorEffect="non-scaling-stroke"
|
|
700
|
+
filter="url(#dc-sticky-shadow)"
|
|
701
|
+
/>
|
|
702
|
+
<svg
|
|
703
|
+
x={x + 8}
|
|
704
|
+
y={y + 5}
|
|
705
|
+
width={16}
|
|
706
|
+
height={16}
|
|
707
|
+
viewBox="0 0 24 24"
|
|
708
|
+
fill={LINK_GLYPH_STROKE}
|
|
709
|
+
stroke="none"
|
|
710
|
+
aria-hidden="true"
|
|
711
|
+
>
|
|
712
|
+
<path d={isAudio ? MEDIAREF_AUDIO_GLYPH : MEDIAREF_VIDEO_GLYPH} />
|
|
713
|
+
</svg>
|
|
714
|
+
<text
|
|
715
|
+
x={x + 30}
|
|
716
|
+
y={y + 9}
|
|
717
|
+
fontSize={11}
|
|
718
|
+
fill={LINK_TITLE_FILL}
|
|
719
|
+
fontWeight={600}
|
|
720
|
+
dominantBaseline="hanging"
|
|
721
|
+
style={font}
|
|
722
|
+
>
|
|
723
|
+
{clampLinkTitle(str(el.title), Math.max(8, Math.floor((w - 40) / 7)))}
|
|
724
|
+
</text>
|
|
725
|
+
{src ? null : (
|
|
726
|
+
<text
|
|
727
|
+
x={x + 30}
|
|
728
|
+
y={y + HEADER + 10}
|
|
729
|
+
fontSize={10}
|
|
730
|
+
fill={LINK_DOMAIN_FILL}
|
|
731
|
+
dominantBaseline="hanging"
|
|
732
|
+
style={font}
|
|
733
|
+
>
|
|
734
|
+
(missing media reference)
|
|
735
|
+
</text>
|
|
736
|
+
)}
|
|
737
|
+
</g>
|
|
738
|
+
</Geo>
|
|
739
|
+
</Node>
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Section title chip geometry — screen-size-constant (counter-scaled by zoom). */
|
|
744
|
+
export function sectionChip(label: string, zoom: number) {
|
|
745
|
+
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
746
|
+
const chipH = SECTION_LABEL_H / zoom;
|
|
747
|
+
const gap = 4 / zoom;
|
|
748
|
+
const padX = 9 / zoom;
|
|
749
|
+
const chipW = Math.max(56 / zoom, label.length * fontSize * 0.62 + 18 / zoom);
|
|
750
|
+
return { fontSize, chipH, gap, padX, chipW };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function SectionChip({
|
|
754
|
+
el,
|
|
755
|
+
interactive,
|
|
756
|
+
edit,
|
|
757
|
+
}: {
|
|
758
|
+
el: AnnotationElement;
|
|
759
|
+
interactive: boolean;
|
|
760
|
+
edit: EditRequest | null;
|
|
761
|
+
}) {
|
|
762
|
+
// Counter-scaled chrome needs the live zoom, not the settle-cadence one.
|
|
763
|
+
const zoom = useLiveViewport().zoom || 1;
|
|
764
|
+
const label = str(el.label, 'Section');
|
|
765
|
+
const color = str(el.color, '#8a8a8a');
|
|
766
|
+
const g = sectionChip(label, zoom);
|
|
767
|
+
const style: CSSProperties = {
|
|
768
|
+
fontFamily: TEXT_FONT,
|
|
769
|
+
fontSize: `${g.fontSize}px`,
|
|
770
|
+
lineHeight: `${g.chipH}px`,
|
|
771
|
+
color,
|
|
772
|
+
padding: `0 ${g.padX}px`,
|
|
773
|
+
textAlign: 'left',
|
|
774
|
+
};
|
|
775
|
+
const box: CSSProperties = {
|
|
776
|
+
top: -(g.chipH + g.gap),
|
|
777
|
+
minWidth: g.chipW,
|
|
778
|
+
height: g.chipH,
|
|
779
|
+
borderRadius: 5 / zoom,
|
|
780
|
+
// Same wash the SVG chip had (fill-opacity 0.16).
|
|
781
|
+
backgroundColor: `color-mix(in oklab, ${color} 16%, transparent)`,
|
|
782
|
+
pointerEvents: interactive ? 'auto' : 'none',
|
|
783
|
+
};
|
|
784
|
+
if (edit) {
|
|
785
|
+
return (
|
|
786
|
+
<div className="dc-annot-chip" style={{ ...box, zIndex: 1 }}>
|
|
787
|
+
<TextEditor
|
|
788
|
+
initialText={label}
|
|
789
|
+
singleLine
|
|
790
|
+
className="dc-annot-text dc-annot-text--nowrap"
|
|
791
|
+
style={style}
|
|
792
|
+
size={{ kind: 'fit-both', minWidth: g.chipW }}
|
|
793
|
+
fmt={{ fontSize: g.fontSize }}
|
|
794
|
+
caretPoint={edit.caretPoint}
|
|
795
|
+
ariaLabel="Rename section"
|
|
796
|
+
onCommit={edit.onCommit}
|
|
797
|
+
onCancel={edit.onCancel}
|
|
798
|
+
onDraft={edit.onDraft}
|
|
799
|
+
/>
|
|
800
|
+
</div>
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
return (
|
|
804
|
+
<div className="dc-annot-chip" data-section-chip="1" style={{ ...box, width: g.chipW }}>
|
|
805
|
+
<div className="dc-annot-text dc-annot-text--nowrap" style={style}>
|
|
806
|
+
{label}
|
|
807
|
+
</div>
|
|
808
|
+
</div>
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function SectionView({ el, interactive, edit }: ElementNodeProps) {
|
|
813
|
+
const x = num(el.x);
|
|
814
|
+
const y = num(el.y);
|
|
815
|
+
const w = num(el.w);
|
|
816
|
+
const h = num(el.h);
|
|
817
|
+
const color = str(el.color, '#8a8a8a');
|
|
818
|
+
return (
|
|
819
|
+
<Node el={el} x={x} y={y} w={w} h={h} raised={!!edit}>
|
|
820
|
+
<Geo x={x} y={y} w={w} h={h}>
|
|
821
|
+
{/* Body: pure backdrop, click-through (content on a section selects normally). */}
|
|
822
|
+
<rect
|
|
823
|
+
x={x}
|
|
824
|
+
y={y}
|
|
825
|
+
width={w}
|
|
826
|
+
height={h}
|
|
827
|
+
rx={SECTION_CORNER_RADIUS}
|
|
828
|
+
ry={SECTION_CORNER_RADIUS}
|
|
829
|
+
fill={color}
|
|
830
|
+
fillOpacity={0.07}
|
|
831
|
+
stroke={color}
|
|
832
|
+
strokeOpacity={0.45}
|
|
833
|
+
strokeWidth={1.5}
|
|
834
|
+
vectorEffect="non-scaling-stroke"
|
|
835
|
+
pointerEvents="none"
|
|
836
|
+
/>
|
|
837
|
+
{/* Invisible border ring — the grabbable edge. */}
|
|
838
|
+
{interactive ? (
|
|
839
|
+
<rect
|
|
840
|
+
x={x}
|
|
841
|
+
y={y}
|
|
842
|
+
width={w}
|
|
843
|
+
height={h}
|
|
844
|
+
rx={SECTION_CORNER_RADIUS}
|
|
845
|
+
ry={SECTION_CORNER_RADIUS}
|
|
846
|
+
fill="none"
|
|
847
|
+
stroke="transparent"
|
|
848
|
+
strokeWidth={12}
|
|
849
|
+
vectorEffect="non-scaling-stroke"
|
|
850
|
+
pointerEvents="stroke"
|
|
851
|
+
/>
|
|
852
|
+
) : null}
|
|
853
|
+
</Geo>
|
|
854
|
+
<SectionChip el={el} interactive={interactive} edit={edit} />
|
|
855
|
+
{edit ? <EditNotice notice={edit.notice} /> : null}
|
|
856
|
+
</Node>
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** A placeholder never grows past this (world units) — a hostile record can't blanket the board. */
|
|
861
|
+
const PLACEHOLDER_MAX = 2000;
|
|
862
|
+
const clampPx = (v: number, lo: number) => Math.min(PLACEHOLDER_MAX, Math.max(lo, v));
|
|
863
|
+
|
|
864
|
+
function PlaceholderView({ el }: ElementNodeProps) {
|
|
865
|
+
const x = num(el.x);
|
|
866
|
+
const y = num(el.y);
|
|
867
|
+
const w = clampPx(num(el.w), 40);
|
|
868
|
+
const h = clampPx(num(el.h), 24);
|
|
869
|
+
return (
|
|
870
|
+
<Node el={el} x={x} y={y} w={w} h={h}>
|
|
871
|
+
<div
|
|
872
|
+
className="dc-annot-placeholder"
|
|
873
|
+
title="Made with a newer version of Maude — kept as is"
|
|
874
|
+
style={{ pointerEvents: 'none' }}
|
|
875
|
+
>
|
|
876
|
+
{el.type}
|
|
877
|
+
</div>
|
|
878
|
+
</Node>
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// A Map, never a plain object: `type` is peer data, and a lookup on an object
|
|
883
|
+
// would resolve `constructor` & co. to inherited members (security review A1).
|
|
884
|
+
const VIEWS = new Map<string, (p: ElementNodeProps) => ReactNode>([
|
|
885
|
+
['sticky', StickyView],
|
|
886
|
+
['text', TextView],
|
|
887
|
+
['shape', ShapeView],
|
|
888
|
+
['arrow', ArrowView],
|
|
889
|
+
['pen', PenView],
|
|
890
|
+
['image', ImageView],
|
|
891
|
+
['link', LinkView],
|
|
892
|
+
['mediaref', MediaRefView],
|
|
893
|
+
['section', SectionView],
|
|
894
|
+
]);
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Register how a runtime-registered element type draws (see
|
|
898
|
+
* `registerElementType`). Without one it draws as a labelled placeholder.
|
|
899
|
+
* `Node` is the positioned wrapper every view uses.
|
|
900
|
+
*/
|
|
901
|
+
export function registerElementView(type: string, view: (p: ElementNodeProps) => ReactNode): void {
|
|
902
|
+
if (VIEWS.has(type)) throw new Error(`"${type}" already has a view`);
|
|
903
|
+
VIEWS.set(type, view);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
export { Node as ElementBox };
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* One element that fails to render (a malformed peer record, a bug in a view)
|
|
910
|
+
* draws as a placeholder instead of unmounting the whole canvas.
|
|
911
|
+
*/
|
|
912
|
+
class ElementBoundary extends Component<ElementNodeProps, { failed: boolean }> {
|
|
913
|
+
override state = { failed: false };
|
|
914
|
+
static getDerivedStateFromError(): { failed: boolean } {
|
|
915
|
+
return { failed: true };
|
|
916
|
+
}
|
|
917
|
+
override componentDidUpdate(prev: ElementNodeProps): void {
|
|
918
|
+
if (this.state.failed && prev.el !== this.props.el) this.setState({ failed: false });
|
|
919
|
+
}
|
|
920
|
+
override render(): ReactNode {
|
|
921
|
+
if (this.state.failed) return <PlaceholderView {...this.props} />;
|
|
922
|
+
const View = VIEWS.get(this.props.el.type) ?? PlaceholderView;
|
|
923
|
+
return <View {...this.props} />;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function ElementNodeImpl(props: ElementNodeProps) {
|
|
928
|
+
countRender('annotationNodeRenders');
|
|
929
|
+
return <ElementBoundary {...props} />;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function sameEnds(a?: ResolvedEnds, b?: ResolvedEnds): boolean {
|
|
933
|
+
if (a === b) return true;
|
|
934
|
+
if (!a || !b) return false;
|
|
935
|
+
return (
|
|
936
|
+
a.x1 === b.x1 &&
|
|
937
|
+
a.y1 === b.y1 &&
|
|
938
|
+
a.x2 === b.x2 &&
|
|
939
|
+
a.y2 === b.y2 &&
|
|
940
|
+
a.startAnchor?.nx === b.startAnchor?.nx &&
|
|
941
|
+
a.startAnchor?.ny === b.startAnchor?.ny &&
|
|
942
|
+
a.endAnchor?.nx === b.endAnchor?.nx &&
|
|
943
|
+
a.endAnchor?.ny === b.endAnchor?.ny
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
export const ElementNode = memo(
|
|
948
|
+
ElementNodeImpl,
|
|
949
|
+
(a, b) =>
|
|
950
|
+
a.el === b.el &&
|
|
951
|
+
a.interactive === b.interactive &&
|
|
952
|
+
a.edit === b.edit &&
|
|
953
|
+
a.resolveAsset === b.resolveAsset &&
|
|
954
|
+
sameEnds(a.ends, b.ends)
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
/** Slot geometry for a pending (not yet born) standalone text at a world point. */
|
|
958
|
+
export function PendingTextNode({
|
|
959
|
+
x,
|
|
960
|
+
y,
|
|
961
|
+
color,
|
|
962
|
+
fontSize,
|
|
963
|
+
edit,
|
|
964
|
+
}: {
|
|
965
|
+
x: number;
|
|
966
|
+
y: number;
|
|
967
|
+
color: string;
|
|
968
|
+
fontSize: number;
|
|
969
|
+
edit: EditRequest;
|
|
970
|
+
}) {
|
|
971
|
+
const style: CSSProperties = {
|
|
972
|
+
...slotTextStyle('text', { fontSize, color }),
|
|
973
|
+
color,
|
|
974
|
+
padding: '0 2px',
|
|
975
|
+
};
|
|
976
|
+
return (
|
|
977
|
+
<div className="dc-annot-el" data-pending-text="1" style={{ left: x, top: y }}>
|
|
978
|
+
<TextEditor
|
|
979
|
+
initialText=""
|
|
980
|
+
className="dc-annot-text dc-annot-text--nowrap"
|
|
981
|
+
style={style}
|
|
982
|
+
size={{ kind: 'fit-both' }}
|
|
983
|
+
fmt={{ fontSize }}
|
|
984
|
+
caretPoint={null}
|
|
985
|
+
ariaLabel="Edit text"
|
|
986
|
+
onCommit={edit.onCommit}
|
|
987
|
+
onCancel={edit.onCancel}
|
|
988
|
+
/>
|
|
989
|
+
</div>
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
export type { SizeMode, TextSlot };
|