@1agh/maude 0.29.0 → 0.30.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/README.md +1 -1
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1708 -1660
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +208 -116
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-shell.tsx +305 -8
- package/apps/studio/client/app.jsx +2812 -238
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/styles/3-shell-maude.css +713 -11
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +16 -2
- package/apps/studio/client/tour/overlay.jsx +105 -17
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +20 -20
- package/apps/studio/dist/client.bundle.js +3322 -602
- package/apps/studio/dist/comment-mount.js +95 -9
- package/apps/studio/dist/styles.css +2490 -167
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/history.ts +20 -3
- package/apps/studio/http.ts +124 -5
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +1 -7
- package/apps/studio/server.ts +5 -2
- package/apps/studio/sync/agent.ts +231 -57
- package/apps/studio/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-start.test.ts +244 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +12 -2
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-collab.tsx +20 -15
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +135 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +2 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +28 -4
|
@@ -34,18 +34,121 @@ import {
|
|
|
34
34
|
useState,
|
|
35
35
|
} from 'react';
|
|
36
36
|
import { createPortal } from 'react-dom';
|
|
37
|
-
|
|
37
|
+
import {
|
|
38
|
+
type AlignEdge,
|
|
39
|
+
alignStrokes,
|
|
40
|
+
type DistributeAxis,
|
|
41
|
+
distributeStrokes,
|
|
42
|
+
} from './annotations-align.ts';
|
|
43
|
+
import {
|
|
44
|
+
anchorPoint,
|
|
45
|
+
BIND_THRESHOLD_PX,
|
|
46
|
+
bindCandidate,
|
|
47
|
+
isBindable,
|
|
48
|
+
recomputeBoundArrows,
|
|
49
|
+
} from './annotations-bindings.ts';
|
|
38
50
|
import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
|
|
39
51
|
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
duplicateStrokes,
|
|
53
|
+
expandIdsToGroups,
|
|
54
|
+
groupStrokes,
|
|
55
|
+
normalizeGroups,
|
|
56
|
+
outermostGroupOf,
|
|
57
|
+
reorderStrokes,
|
|
58
|
+
ungroupStrokes,
|
|
59
|
+
type ZOrderOp,
|
|
60
|
+
} from './annotations-groups.ts';
|
|
61
|
+
import {
|
|
62
|
+
type AnchorHost,
|
|
63
|
+
type ArrowStroke,
|
|
64
|
+
applyDrawModifiers,
|
|
65
|
+
clampLinkTitle,
|
|
66
|
+
DEFAULT_FONT_SIZE,
|
|
67
|
+
DEFAULT_HIGHLIGHTER_COLOR,
|
|
68
|
+
DEFAULT_HIGHLIGHTER_WIDTH,
|
|
69
|
+
DEFAULT_SECTION_COLOR,
|
|
70
|
+
DEFAULT_STICKY_COLOR,
|
|
71
|
+
type DrawMods,
|
|
72
|
+
defaultFillFor,
|
|
73
|
+
type EditorFmt,
|
|
74
|
+
FILL_PALETTE,
|
|
75
|
+
fmtEqual,
|
|
76
|
+
HALO_PAD_PX,
|
|
77
|
+
HIGHLIGHTER_PALETTE,
|
|
78
|
+
HIGHLIGHTER_WIDTHS,
|
|
79
|
+
IMAGE_MAX_DROP_SIDE,
|
|
80
|
+
IMAGE_MIN_SIZE,
|
|
81
|
+
type ImageStroke,
|
|
82
|
+
isStrokeMeaningful,
|
|
83
|
+
LINK_CARD_FILL,
|
|
84
|
+
LINK_CARD_STROKE,
|
|
85
|
+
LINK_DEFAULT_H,
|
|
86
|
+
LINK_DEFAULT_W,
|
|
87
|
+
LINK_DOMAIN_FILL,
|
|
88
|
+
LINK_GLYPH_D1,
|
|
89
|
+
LINK_GLYPH_D2,
|
|
90
|
+
LINK_GLYPH_STROKE,
|
|
91
|
+
LINK_TITLE_FILL,
|
|
92
|
+
type ListType,
|
|
93
|
+
linkCardLayout,
|
|
94
|
+
listPrefixedBody,
|
|
95
|
+
listPrefixedLine,
|
|
96
|
+
normalizeBox,
|
|
97
|
+
normalizeRect,
|
|
98
|
+
normalizeSticky,
|
|
99
|
+
normFmt,
|
|
100
|
+
penPathD,
|
|
101
|
+
polygonPoints,
|
|
102
|
+
resolveDefaultInk,
|
|
103
|
+
rid,
|
|
104
|
+
SECTION_CORNER_RADIUS,
|
|
105
|
+
SECTION_DEFAULT_H,
|
|
106
|
+
SECTION_DEFAULT_W,
|
|
107
|
+
SECTION_LABEL_FONT,
|
|
108
|
+
SECTION_LABEL_H,
|
|
109
|
+
SECTION_MIN_SIZE,
|
|
110
|
+
type SectionStroke,
|
|
111
|
+
SHAPE_DEFAULT_SIZE,
|
|
112
|
+
STICKY_CORNER_RADIUS,
|
|
113
|
+
STICKY_DEFAULT_H,
|
|
114
|
+
STICKY_DEFAULT_W,
|
|
115
|
+
STICKY_MIN_SIZE,
|
|
116
|
+
STICKY_PALETTE,
|
|
117
|
+
STROKE_PALETTE,
|
|
118
|
+
STROKE_WIDTH_THICK,
|
|
119
|
+
STROKE_WIDTH_THIN,
|
|
120
|
+
type StickyStroke,
|
|
121
|
+
type Stroke,
|
|
122
|
+
splitTextLines,
|
|
123
|
+
stickyCornerPath,
|
|
124
|
+
stripEditorMarkers,
|
|
125
|
+
strokeBBox,
|
|
126
|
+
strokeCenter,
|
|
127
|
+
strokeHitTest,
|
|
128
|
+
strokeRotation,
|
|
129
|
+
strokesShallowEqual,
|
|
130
|
+
strokesToSvg,
|
|
131
|
+
svgToStrokes,
|
|
132
|
+
TEXT_LINE_HEIGHT,
|
|
133
|
+
type TextAlign,
|
|
134
|
+
type TextStroke,
|
|
135
|
+
type Thickness,
|
|
136
|
+
textDecoCss,
|
|
137
|
+
textLineDy,
|
|
138
|
+
translateOne,
|
|
139
|
+
type WorldPoint,
|
|
140
|
+
} from './annotations-model.ts';
|
|
141
|
+
import {
|
|
142
|
+
computeSnap,
|
|
143
|
+
GRID_PITCH_PX,
|
|
144
|
+
SNAP_THRESHOLD_PX,
|
|
145
|
+
type SnapGuide,
|
|
146
|
+
} from './annotations-snap.ts';
|
|
147
|
+
import { arrowPrimitives, type SvgPrimitive } from './canvas-arrowheads.ts';
|
|
46
148
|
import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
|
|
47
149
|
import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
|
|
48
150
|
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
151
|
+
import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
|
|
49
152
|
import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
50
153
|
import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
51
154
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
@@ -63,19 +166,20 @@ import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
|
63
166
|
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
64
167
|
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
65
168
|
|
|
169
|
+
// FigJam v3 — the pure data model (Stroke types, palettes, serialize/parse,
|
|
170
|
+
// geometry) lives in annotations-model.ts: React-free, importable headlessly
|
|
171
|
+
// by bun tests and the `maude design annotate` write verb. The layer
|
|
172
|
+
// re-exports the whole model so every existing
|
|
173
|
+
// `from './annotations-layer.tsx'` import keeps working unchanged.
|
|
174
|
+
export * from './annotations-model.ts';
|
|
175
|
+
|
|
66
176
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
177
|
// Types
|
|
68
178
|
|
|
69
|
-
type WorldPoint = readonly [number, number];
|
|
70
|
-
|
|
71
179
|
// Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
|
|
72
180
|
// module imports nothing back from here — no cycle, see DDR-067) and re-exported
|
|
73
181
|
// here for back-compat (context-toolbar etc. import them from this module).
|
|
74
182
|
export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
|
|
75
|
-
/** Phase 24 — polygon shape primitives (diamond + the two triangle pointings). */
|
|
76
|
-
export type PolygonShape = 'diamond' | 'triangle' | 'triangle-down';
|
|
77
|
-
/** Phase 24 — horizontal alignment for text + sticky bodies. */
|
|
78
|
-
export type TextAlign = 'left' | 'center' | 'right';
|
|
79
183
|
|
|
80
184
|
/** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
|
|
81
185
|
type GhostDescriptor =
|
|
@@ -83,199 +187,6 @@ type GhostDescriptor =
|
|
|
83
187
|
| { kind: 'sticky'; x: number; y: number; color: string }
|
|
84
188
|
| { kind: 'shape'; x: number; y: number; shapeKind: ShapeKind; color: string };
|
|
85
189
|
|
|
86
|
-
export interface PenStroke {
|
|
87
|
-
id: string;
|
|
88
|
-
tool: 'pen';
|
|
89
|
-
color: string;
|
|
90
|
-
width: number;
|
|
91
|
-
points: WorldPoint[];
|
|
92
|
-
/**
|
|
93
|
-
* Highlighter (item 8). A `highlighter:true` pen reuses ALL pen draw / erase /
|
|
94
|
-
* hit-test / translate logic; it just renders wide + translucent with
|
|
95
|
-
* `mix-blend-mode:multiply` (overlaps darken) and carries a translucent
|
|
96
|
-
* marker colour. Absent / false = a normal solid pen (back-compat).
|
|
97
|
-
*/
|
|
98
|
-
highlighter?: boolean;
|
|
99
|
-
}
|
|
100
|
-
export interface RectStroke {
|
|
101
|
-
id: string;
|
|
102
|
-
tool: 'rect';
|
|
103
|
-
color: string;
|
|
104
|
-
width: number;
|
|
105
|
-
x: number;
|
|
106
|
-
y: number;
|
|
107
|
-
w: number;
|
|
108
|
-
h: number;
|
|
109
|
-
fill?: string | null;
|
|
110
|
-
/** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
|
|
111
|
-
cornerRadius?: number;
|
|
112
|
-
/** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
|
|
113
|
-
dashed?: boolean;
|
|
114
|
-
}
|
|
115
|
-
export interface EllipseStroke {
|
|
116
|
-
id: string;
|
|
117
|
-
tool: 'ellipse';
|
|
118
|
-
color: string;
|
|
119
|
-
width: number;
|
|
120
|
-
cx: number;
|
|
121
|
-
cy: number;
|
|
122
|
-
rx: number;
|
|
123
|
-
ry: number;
|
|
124
|
-
fill?: string | null;
|
|
125
|
-
/** Dashed outline (stroke-dasharray). Absent / false = solid (back-compat). */
|
|
126
|
-
dashed?: boolean;
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Phase 24 — diamond / triangle / triangle-down primitives. Stored as a bbox
|
|
130
|
-
* (x/y/w/h, exactly like a rect) + a `shape` discriminant; the actual SVG
|
|
131
|
-
* points are derived from the bbox at serialize + render time. Brand-new on
|
|
132
|
-
* disk (`<polygon data-tool="polygon" data-shape="…">`), so no back-compat
|
|
133
|
-
* constraint — only idempotent round-trip.
|
|
134
|
-
*/
|
|
135
|
-
export interface PolygonStroke {
|
|
136
|
-
id: string;
|
|
137
|
-
tool: 'polygon';
|
|
138
|
-
shape: PolygonShape;
|
|
139
|
-
color: string;
|
|
140
|
-
width: number;
|
|
141
|
-
x: number;
|
|
142
|
-
y: number;
|
|
143
|
-
w: number;
|
|
144
|
-
h: number;
|
|
145
|
-
fill?: string | null;
|
|
146
|
-
/** Dashed outline (stroke-dasharray). Absent / false = solid. */
|
|
147
|
-
dashed?: boolean;
|
|
148
|
-
}
|
|
149
|
-
export interface ArrowStroke {
|
|
150
|
-
id: string;
|
|
151
|
-
tool: 'arrow';
|
|
152
|
-
color: string;
|
|
153
|
-
width: number;
|
|
154
|
-
x1: number;
|
|
155
|
-
y1: number;
|
|
156
|
-
x2: number;
|
|
157
|
-
y2: number;
|
|
158
|
-
/** Head on the (x1,y1) start. Absent = 'none' (back-compat). Phase 24 widened the enum. */
|
|
159
|
-
startHead?: ArrowHead;
|
|
160
|
-
/** Head on the (x2,y2) end. Absent = 'triangle' (back-compat). Phase 24 widened the enum. */
|
|
161
|
-
endHead?: ArrowHead;
|
|
162
|
-
/** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */
|
|
163
|
-
dashed?: boolean;
|
|
164
|
-
/** Phase 24 — shaft routing. Absent = 'straight' (back-compat). */
|
|
165
|
-
lineType?: ArrowLineType;
|
|
166
|
-
}
|
|
167
|
-
export interface TextStroke {
|
|
168
|
-
id: string;
|
|
169
|
-
tool: 'text';
|
|
170
|
-
color: string;
|
|
171
|
-
fontSize: number;
|
|
172
|
-
text: string;
|
|
173
|
-
/**
|
|
174
|
-
* Host shape id for anchored text (double-click a rect/ellipse). Phase 21
|
|
175
|
-
* relaxed this to optional: standalone text (the `text` tool) carries no
|
|
176
|
-
* anchor and renders at its own world `(x, y)` instead.
|
|
177
|
-
*/
|
|
178
|
-
anchorId?: string;
|
|
179
|
-
/** Phase 21 — world coords for standalone (unanchored) text. */
|
|
180
|
-
x?: number;
|
|
181
|
-
y?: number;
|
|
182
|
-
/** Phase 24 — bold weight. Absent / false = normal (back-compat). */
|
|
183
|
-
bold?: boolean;
|
|
184
|
-
/** Phase 24 — strikethrough. Absent / false = none (back-compat). */
|
|
185
|
-
strike?: boolean;
|
|
186
|
-
/** Italic style (item 4b). Absent / false = upright (back-compat). */
|
|
187
|
-
italic?: boolean;
|
|
188
|
-
/** Underline (item 4b). Combined with strike into one text-decoration. */
|
|
189
|
-
underline?: boolean;
|
|
190
|
-
/** List style (item 4c). Markers are render-only — never stored in `text`. */
|
|
191
|
-
listType?: ListType;
|
|
192
|
-
/**
|
|
193
|
-
* Phase 24 — horizontal alignment. Absent default differs by kind: anchored
|
|
194
|
-
* text = 'center' (legacy, byte-identical), standalone = 'left'.
|
|
195
|
-
*/
|
|
196
|
-
align?: TextAlign;
|
|
197
|
-
}
|
|
198
|
-
/** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */
|
|
199
|
-
export interface StickyStroke {
|
|
200
|
-
id: string;
|
|
201
|
-
tool: 'sticky';
|
|
202
|
-
color: string;
|
|
203
|
-
x: number;
|
|
204
|
-
y: number;
|
|
205
|
-
w: number;
|
|
206
|
-
h: number;
|
|
207
|
-
text: string;
|
|
208
|
-
fontSize: number;
|
|
209
|
-
/** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */
|
|
210
|
-
cornerRadius?: number;
|
|
211
|
-
/** Phase 24 — bold body weight. Absent / false = normal. */
|
|
212
|
-
bold?: boolean;
|
|
213
|
-
/** Phase 24 — strikethrough body. Absent / false = none. */
|
|
214
|
-
strike?: boolean;
|
|
215
|
-
/** Italic body (item 4b). Absent / false = upright. */
|
|
216
|
-
italic?: boolean;
|
|
217
|
-
/** Underline body (item 4b). Combined with strike into one text-decoration. */
|
|
218
|
-
underline?: boolean;
|
|
219
|
-
/** List style (item 4c). Markers are render-only — never stored in `text`. */
|
|
220
|
-
listType?: ListType;
|
|
221
|
-
/** Phase 24 — body alignment. Absent = 'left' (FigJam sticky default). */
|
|
222
|
-
align?: TextAlign;
|
|
223
|
-
}
|
|
224
|
-
/**
|
|
225
|
-
* Phase 23 — dropped / pasted raster image. Free-floating, rect-shaped, moves
|
|
226
|
-
* and resizes like any annotation. `href` is ALWAYS a relative
|
|
227
|
-
* `assets/<sha8>.<ext>` path (never a data: URL — keeps the persisted SVG under
|
|
228
|
-
* its 1 MB cap and matches the sanitizer's `<image>` href allowlist). The live
|
|
229
|
-
* canvas may briefly render an optimistic `blob:` href before the upload swaps
|
|
230
|
-
* it to the content-addressed path; only the `assets/…` form is ever persisted.
|
|
231
|
-
*/
|
|
232
|
-
export interface ImageStroke {
|
|
233
|
-
id: string;
|
|
234
|
-
tool: 'image';
|
|
235
|
-
x: number;
|
|
236
|
-
y: number;
|
|
237
|
-
w: number;
|
|
238
|
-
h: number;
|
|
239
|
-
href: string;
|
|
240
|
-
/**
|
|
241
|
-
* Alt text. Persisted in `data-alt` and emitted as `aria-label` on the
|
|
242
|
-
* `<image>`, so it travels with the exported / saved SVG (where AT reads it).
|
|
243
|
-
* NOTE: in the LIVE canvas the whole annotation SVG root is `aria-hidden`
|
|
244
|
-
* (editor chrome — AT shouldn't be flooded by decorative strokes), so the live
|
|
245
|
-
* in-canvas `aria-label` is pruned; the alt's audience is the export. Absent ⇒ ''.
|
|
246
|
-
*/
|
|
247
|
-
alt?: string;
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* Phase 23 — pasted / dropped URL rendered as a client-only preview chip. NO
|
|
251
|
-
* server fetch and NO external favicon (the dev-server stays zero-egress —
|
|
252
|
-
* DDR-054/060). `title` comes from the clipboard/DnD `text/html` anchor text
|
|
253
|
-
* when present, else the prettified URL; `domain` is `new URL(url).hostname`.
|
|
254
|
-
* Persists as an allowlisted `<g>` (rect + vector glyph + two `<text>` runs) —
|
|
255
|
-
* the click-to-open handler reads `data-url`, no `<a href>` is ever stored.
|
|
256
|
-
*/
|
|
257
|
-
export interface LinkStroke {
|
|
258
|
-
id: string;
|
|
259
|
-
tool: 'link';
|
|
260
|
-
x: number;
|
|
261
|
-
y: number;
|
|
262
|
-
w: number;
|
|
263
|
-
h: number;
|
|
264
|
-
url: string;
|
|
265
|
-
title: string;
|
|
266
|
-
domain: string;
|
|
267
|
-
}
|
|
268
|
-
export type Stroke =
|
|
269
|
-
| PenStroke
|
|
270
|
-
| RectStroke
|
|
271
|
-
| EllipseStroke
|
|
272
|
-
| PolygonStroke
|
|
273
|
-
| ArrowStroke
|
|
274
|
-
| TextStroke
|
|
275
|
-
| StickyStroke
|
|
276
|
-
| ImageStroke
|
|
277
|
-
| LinkStroke;
|
|
278
|
-
|
|
279
190
|
/**
|
|
280
191
|
* Phase 21 — what the inline editor is currently bound to. `anchored` edits
|
|
281
192
|
* the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
|
|
@@ -283,48 +194,14 @@ export type Stroke =
|
|
|
283
194
|
* exists until real text is committed).
|
|
284
195
|
*/
|
|
285
196
|
type EditingTarget =
|
|
286
|
-
| { kind: 'anchored'; anchorId: string; host:
|
|
197
|
+
| { kind: 'anchored'; anchorId: string; host: AnchorHost }
|
|
287
198
|
| { kind: 'sticky'; sticky: StickyStroke }
|
|
288
199
|
| { kind: 'standalone'; text: TextStroke }
|
|
200
|
+
/** FigJam v3 — renaming a section's label chip. */
|
|
201
|
+
| { kind: 'section'; section: SectionStroke }
|
|
289
202
|
| { kind: 'pending'; x: number; y: number }
|
|
290
203
|
| null;
|
|
291
204
|
|
|
292
|
-
// Phase 21 colour system — a single coherent hue family used everywhere.
|
|
293
|
-
// FigJam model: stroke (saturated ink) is INDEPENDENT of fill, and fills are
|
|
294
|
-
// light TINTS of the same hue (index-paired with STROKE_PALETTE). Stickies use
|
|
295
|
-
// their own lightened paper set (STICKY_PALETTE). Exported so the draw-time
|
|
296
|
-
// chrome AND the per-selection context toolbar share ONE palette instead of
|
|
297
|
-
// drifting apart.
|
|
298
|
-
export const STROKE_PALETTE = [
|
|
299
|
-
'#e5484d', // red (default — markup ink)
|
|
300
|
-
'#f2762a', // orange
|
|
301
|
-
'#e0a500', // amber
|
|
302
|
-
'#30a46c', // green
|
|
303
|
-
'#3b82f6', // blue
|
|
304
|
-
'#8b5cf6', // purple
|
|
305
|
-
'#e93d82', // pink
|
|
306
|
-
'#7c7c7c', // gray
|
|
307
|
-
'#1f1f1f', // ink
|
|
308
|
-
] as const;
|
|
309
|
-
type PaletteColor = (typeof STROKE_PALETTE)[number];
|
|
310
|
-
// Phase 24 — default markup ink is BLACK (the `#1f1f1f` ink swatch, slot 8) for
|
|
311
|
-
// EVERY ink tool (pen / shape / arrow / text). It's a palette member so the
|
|
312
|
-
// draw chrome + per-selection toolbar highlight it as the active swatch; the
|
|
313
|
-
// other hues stay one click away. (Stickies keep their warm-paper default —
|
|
314
|
-
// DEFAULT_STICKY_COLOR — they're paper, not ink.)
|
|
315
|
-
const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[8];
|
|
316
|
-
// Annotation polish — the LIVE default ink follows the canvas theme so a
|
|
317
|
-
// freshly-armed pen/shape/arrow/text reads true on dark canvases (the
|
|
318
|
-
// `#1f1f1f` ink is near-invisible on a dark mock). Light → the `#1f1f1f`
|
|
319
|
-
// ink slot; dark → a light ink that reads on dark. This is the live draw
|
|
320
|
-
// default ONLY — `DEFAULT_COLOR` stays the parse fallback (round-trip
|
|
321
|
-
// determinism + back-compat), and stored strokes keep their literal hex
|
|
322
|
-
// (FigJam parity — no retroactive recolour).
|
|
323
|
-
const DEFAULT_INK_DARK = '#ededed';
|
|
324
|
-
export function resolveDefaultInk(theme: string): string {
|
|
325
|
-
return theme === 'dark' ? DEFAULT_INK_DARK : DEFAULT_COLOR;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
205
|
/**
|
|
329
206
|
* The canvas-shell CHROME theme — `data-maude-theme` on `<html>`, default
|
|
330
207
|
* 'dark'. This (NOT the DS `data-theme`, which is deliberately separate and
|
|
@@ -353,266 +230,6 @@ function useCanvasChromeTheme(): 'light' | 'dark' {
|
|
|
353
230
|
return theme;
|
|
354
231
|
}
|
|
355
232
|
|
|
356
|
-
// Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
|
|
357
|
-
// pale blue wash under a saturated stroke, exactly like FigJam shapes.
|
|
358
|
-
export const FILL_PALETTE = [
|
|
359
|
-
'#fbe0e1', // red tint
|
|
360
|
-
'#fce6d6', // orange tint
|
|
361
|
-
'#fbeec2', // amber tint
|
|
362
|
-
'#d9f1e2', // green tint
|
|
363
|
-
'#e0ebfd', // blue tint
|
|
364
|
-
'#ebe3fc', // purple tint
|
|
365
|
-
'#fbdfeb', // pink tint
|
|
366
|
-
'#ededed', // gray tint
|
|
367
|
-
'#e7e7e7', // ink tint
|
|
368
|
-
] as const;
|
|
369
|
-
|
|
370
|
-
// Neutral fill wash for the ink slot (no paired hue) — light vs dark canvas.
|
|
371
|
-
const NEUTRAL_FILL_LIGHT = FILL_PALETTE[8]; // '#e7e7e7'
|
|
372
|
-
const NEUTRAL_FILL_DARK = '#2a2a2a';
|
|
373
|
-
/**
|
|
374
|
-
* Annotation polish (item 2) — the LIVE default fill for a freshly-armed Shape
|
|
375
|
-
* tool. A coloured ink maps to its index-paired light tint (FigJam: a saturated
|
|
376
|
-
* outline over a pale wash of the same hue); the ink slot / themed-dark ink /
|
|
377
|
-
* any unknown hex maps to a neutral wash. "No fill" stays one click away (the
|
|
378
|
-
* chrome's None swatch) and, once picked, sticks (fillTouchedRef). Stored
|
|
379
|
-
* shapes keep their literal fill — only NEW shapes pick up this default.
|
|
380
|
-
*/
|
|
381
|
-
export function defaultFillFor(color: string, theme: string): string {
|
|
382
|
-
const idx = STROKE_PALETTE.indexOf(color as PaletteColor);
|
|
383
|
-
// Coloured ink (slots 0–7) → its paired tint; ink slot (8) / unknown → neutral.
|
|
384
|
-
if (idx >= 0 && idx < FILL_PALETTE.length - 1) return FILL_PALETTE[idx];
|
|
385
|
-
return theme === 'dark' ? NEUTRAL_FILL_DARK : NEUTRAL_FILL_LIGHT;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const STROKE_WIDTH_THIN = 3;
|
|
389
|
-
const STROKE_WIDTH_THICK = 6;
|
|
390
|
-
type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
391
|
-
|
|
392
|
-
const FONT_SIZE_MEDIUM = 14;
|
|
393
|
-
const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
|
|
394
|
-
|
|
395
|
-
// Phase 24 — sticky-note paper tints. A muted/desaturated FigJam-style set
|
|
396
|
-
// (Image #2): a warm paper yellow default, then white/grey + soft pastels.
|
|
397
|
-
// Wholly separate from the stroke ink PALETTE and the translucent FILL_PALETTE
|
|
398
|
-
// so stickies read as "paper", not "ink". Slot 0 (yellow) is the default.
|
|
399
|
-
// Existing stickies keep their stored hex; only NEW stickies pick up the new
|
|
400
|
-
// default tint.
|
|
401
|
-
export const STICKY_PALETTE = [
|
|
402
|
-
'#fce8a6', // muted yellow (default — warm paper)
|
|
403
|
-
'#ffffff', // white
|
|
404
|
-
'#e6e4e0', // light grey
|
|
405
|
-
'#f7c5c0', // salmon
|
|
406
|
-
'#f8d2a6', // peach
|
|
407
|
-
'#bfe3c0', // mint
|
|
408
|
-
'#a9dbdb', // aqua
|
|
409
|
-
'#bcd2f0', // light blue
|
|
410
|
-
'#cfc4ec', // lavender
|
|
411
|
-
'#f3c4dd', // light pink
|
|
412
|
-
] as const;
|
|
413
|
-
const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
|
|
414
|
-
const STICKY_CORNER_RADIUS = 8;
|
|
415
|
-
|
|
416
|
-
// Annotation polish (item 8) — highlighter marker hues. Translucent 8-digit hex
|
|
417
|
-
// (RRGGBBAA, ~50% alpha) so overlaps darken under `mix-blend-mode:multiply`.
|
|
418
|
-
// Yellow is the default; green / pink / blue follow. Wholly separate from the
|
|
419
|
-
// ink PALETTE — the highlighter draws a soft wash, not a saturated line.
|
|
420
|
-
export const HIGHLIGHTER_PALETTE = [
|
|
421
|
-
'#ffe24d80', // yellow (default)
|
|
422
|
-
'#7ce8a080', // green
|
|
423
|
-
'#ff9ed180', // pink
|
|
424
|
-
'#7ec5ff80', // blue
|
|
425
|
-
] as const;
|
|
426
|
-
const DEFAULT_HIGHLIGHTER_COLOR = HIGHLIGHTER_PALETTE[0];
|
|
427
|
-
// Highlighter marker nib widths (item 8) — three sizes (thin / medium / thick),
|
|
428
|
-
// all wider than the pen. Default medium.
|
|
429
|
-
const HIGHLIGHTER_WIDTHS = [10, 18, 28] as const;
|
|
430
|
-
const DEFAULT_HIGHLIGHTER_WIDTH = HIGHLIGHTER_WIDTHS[1];
|
|
431
|
-
// Phase 24 — stickies are 1:1; the default tap size is a square.
|
|
432
|
-
const STICKY_DEFAULT_W = 200;
|
|
433
|
-
const STICKY_DEFAULT_H = 200;
|
|
434
|
-
const STICKY_MIN_SIZE = 40;
|
|
435
|
-
// Phase 24 — a bare tap with the Shape tool drops a default-sized shape at the
|
|
436
|
-
// tap point (FigJam parity: click commits, drag sizes). Square aspect.
|
|
437
|
-
const SHAPE_DEFAULT_SIZE = 120;
|
|
438
|
-
|
|
439
|
-
// Phase 23 — image + link media strokes.
|
|
440
|
-
/** Below this side an image stroke is discarded as an accidental micro-drop. */
|
|
441
|
-
const IMAGE_MIN_SIZE = 16;
|
|
442
|
-
/** Longest side a freshly dropped/pasted image is scaled down to (world px). */
|
|
443
|
-
export const IMAGE_MAX_DROP_SIDE = 480;
|
|
444
|
-
const LINK_DEFAULT_W = 260;
|
|
445
|
-
const LINK_DEFAULT_H = 76;
|
|
446
|
-
const LINK_CARD_FILL = '#ffffff';
|
|
447
|
-
const LINK_CARD_STROKE = '#d4d4d8';
|
|
448
|
-
const LINK_DOMAIN_FILL = '#71717a';
|
|
449
|
-
const LINK_TITLE_FILL = '#18181b';
|
|
450
|
-
const LINK_GLYPH_STROKE = '#52525b';
|
|
451
|
-
// Lucide "link" icon (24×24 viewBox) — two interlocked loops. ONE source for the
|
|
452
|
-
// serialized nested-<svg> glyph AND the StrokeNode render so re-serialize stays
|
|
453
|
-
// byte-stable. The parser ignores the glyph entirely (it reads data-* + the
|
|
454
|
-
// <rect> geometry), so render/serialize only need to agree visually.
|
|
455
|
-
const LINK_GLYPH_D1 = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71';
|
|
456
|
-
const LINK_GLYPH_D2 = 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
|
|
457
|
-
|
|
458
|
-
/** Card text positions, derived purely from the bbox (idempotent round-trip). */
|
|
459
|
-
function linkCardLayout(x: number, y: number, w: number, h: number) {
|
|
460
|
-
const textX = x + 48;
|
|
461
|
-
return {
|
|
462
|
-
glyph: { x: x + 16, y: y + h / 2 - 10, size: 20 },
|
|
463
|
-
textX,
|
|
464
|
-
domain: { y: y + h / 2 - 14, fontSize: 11 },
|
|
465
|
-
title: { y: y + h / 2, fontSize: 13 },
|
|
466
|
-
textMaxChars: Math.max(8, Math.floor((w - 60) / 7)),
|
|
467
|
-
};
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/** Clamp a link title to the card's character budget (pure → byte-stable). */
|
|
471
|
-
function clampLinkTitle(title: string, maxChars: number): string {
|
|
472
|
-
return title.length > maxChars ? `${title.slice(0, Math.max(1, maxChars - 1))}…` : title;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
476
|
-
// Pure helpers — exported for unit tests.
|
|
477
|
-
|
|
478
|
-
export function rid(): string {
|
|
479
|
-
return `s_${Math.random().toString(36).slice(2, 10)}`;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
function esc(s: string): string {
|
|
483
|
-
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Phase 23 — attribute-safe escape: `esc()` plus `>`. The legacy text/sticky
|
|
488
|
-
* paths only put user text in element CONTENT (where a bare `>` is harmless),
|
|
489
|
-
* so `esc()` never escaped it; but the media strokes carry user text (pasted
|
|
490
|
-
* link title/url, image alt) inside ATTRIBUTES (data-title / data-url / data-alt
|
|
491
|
-
* / href). A bare `>` there would prematurely close the tag and confuse the
|
|
492
|
-
* `[^>]*>` element scan in `sanitizeAnnotationSvg`. Use this for every media
|
|
493
|
-
* attribute value; element CONTENT keeps plain `esc()`.
|
|
494
|
-
*/
|
|
495
|
-
function escAttr(s: string): string {
|
|
496
|
-
return esc(s).replace(/>/g, '>');
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
export function penPathD(points: readonly WorldPoint[]): string {
|
|
500
|
-
if (points.length === 0) return '';
|
|
501
|
-
const [first, ...rest] = points as readonly WorldPoint[];
|
|
502
|
-
if (!first) return '';
|
|
503
|
-
let d = `M${first[0]} ${first[1]}`;
|
|
504
|
-
for (const p of rest) d += ` L${p[0]} ${p[1]}`;
|
|
505
|
-
return d;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// ── Multi-line text (item 4a) ────────────────────────────────────────────────
|
|
509
|
-
// SVG <text> ignores `\n`, so multi-line annotation text must render as one
|
|
510
|
-
// <tspan> per line. The serialized + live forms share this geometry; single-
|
|
511
|
-
// line text keeps the legacy single-run form (no tspan) so the canary holds.
|
|
512
|
-
|
|
513
|
-
/** Line-height multiplier for multi-line annotation text. */
|
|
514
|
-
const TEXT_LINE_HEIGHT = 1.25;
|
|
515
|
-
|
|
516
|
-
/** Split a text body into its display lines. */
|
|
517
|
-
export function splitTextLines(text: string): string[] {
|
|
518
|
-
return text.split('\n');
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/** Annotation polish (item 4c) — list style for text + sticky bodies. */
|
|
522
|
-
export type ListType = 'bullet' | 'number';
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Render-time list marker prefix for one line. Markers are PRESENTATION ONLY —
|
|
526
|
-
* never stored in `text` (DDR) — so the stored string stays clean and
|
|
527
|
-
* contentEditable editing is sane. Bullet → `• `; number → `${i + 1}. `.
|
|
528
|
-
*/
|
|
529
|
-
function listPrefixedLine(line: string, index: number, list?: ListType): string {
|
|
530
|
-
if (!list) return line;
|
|
531
|
-
return list === 'bullet' ? `• ${line}` : `${index + 1}. ${line}`;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/** Inverse of {@link listPrefixedLine} — strip a render-time marker on parse. */
|
|
535
|
-
function stripListPrefix(line: string, index: number, list?: ListType): string {
|
|
536
|
-
if (!list) return line;
|
|
537
|
-
const marker = list === 'bullet' ? '• ' : `${index + 1}. `;
|
|
538
|
-
return line.startsWith(marker) ? line.slice(marker.length) : line;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/** Prefix every line of a body with its list marker (for the editor display). */
|
|
542
|
-
export function listPrefixedBody(text: string, list?: ListType): string {
|
|
543
|
-
if (!list) return text;
|
|
544
|
-
return splitTextLines(text)
|
|
545
|
-
.map((line, i) => listPrefixedLine(line, i, list))
|
|
546
|
-
.join('\n');
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Strip list markers off editor `innerText` on commit (item 4c). Generic — a
|
|
551
|
-
* `•` bullet OR any leading `N. ` number is removed once per line, regardless of
|
|
552
|
-
* the index the user actually typed, so re-numbering while editing round-trips
|
|
553
|
-
* cleanly (the stored text stays marker-free; the read view re-derives markers).
|
|
554
|
-
*/
|
|
555
|
-
export function stripEditorMarkers(text: string, list?: ListType): string {
|
|
556
|
-
if (!list) return text;
|
|
557
|
-
const re = list === 'bullet' ? /^• / : /^\d+\.\s/;
|
|
558
|
-
return splitTextLines(text)
|
|
559
|
-
.map((line) => line.replace(re, ''))
|
|
560
|
-
.join('\n');
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* Combined `text-decoration` SVG attribute for strike + underline (item 4b).
|
|
565
|
-
* Strike-only stays `text-decoration="line-through"` (byte-identical to the
|
|
566
|
-
* legacy Phase-24 form); both → `line-through underline`; neither → empty.
|
|
567
|
-
*/
|
|
568
|
-
function textDecoAttr(strike?: boolean, underline?: boolean): string {
|
|
569
|
-
const vals: string[] = [];
|
|
570
|
-
if (strike) vals.push('line-through');
|
|
571
|
-
if (underline) vals.push('underline');
|
|
572
|
-
return vals.length ? ` text-decoration="${vals.join(' ')}"` : '';
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/** CSS `text-decoration` value for the live render (strike + underline). */
|
|
576
|
-
function textDecoCss(strike?: boolean, underline?: boolean): string | undefined {
|
|
577
|
-
const vals: string[] = [];
|
|
578
|
-
if (strike) vals.push('line-through');
|
|
579
|
-
if (underline) vals.push('underline');
|
|
580
|
-
return vals.length ? vals.join(' ') : undefined;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Inline text formatting carried by an editor through commit (item 4b/4d
|
|
585
|
-
* unification) — so Cmd+B / Cmd+I / Cmd+U toggled WHILE editing land on the
|
|
586
|
-
* stroke. `strike` rides along unchanged (no shortcut; toolbar-only).
|
|
587
|
-
*/
|
|
588
|
-
export interface EditorFmt {
|
|
589
|
-
bold?: boolean;
|
|
590
|
-
italic?: boolean;
|
|
591
|
-
underline?: boolean;
|
|
592
|
-
strike?: boolean;
|
|
593
|
-
}
|
|
594
|
-
/** Normalize an EditorFmt → only-true keys kept; false becomes undefined so the
|
|
595
|
-
* serialize-only-when-set invariant + byte-identical canary hold. */
|
|
596
|
-
function normFmt(fmt?: EditorFmt): EditorFmt {
|
|
597
|
-
return {
|
|
598
|
-
bold: fmt?.bold || undefined,
|
|
599
|
-
italic: fmt?.italic || undefined,
|
|
600
|
-
underline: fmt?.underline || undefined,
|
|
601
|
-
strike: fmt?.strike || undefined,
|
|
602
|
-
};
|
|
603
|
-
}
|
|
604
|
-
/** True when a stroke's existing formatting already matches `fmt` (so a pure
|
|
605
|
-
* identity edit can short-circuit without a redundant undo record). */
|
|
606
|
-
function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
|
|
607
|
-
if (!fmt) return true;
|
|
608
|
-
return (
|
|
609
|
-
!!s.bold === !!fmt.bold &&
|
|
610
|
-
!!s.italic === !!fmt.italic &&
|
|
611
|
-
!!s.underline === !!fmt.underline &&
|
|
612
|
-
!!s.strike === !!fmt.strike
|
|
613
|
-
);
|
|
614
|
-
}
|
|
615
|
-
|
|
616
233
|
/**
|
|
617
234
|
* Shared inline-formatting state for the three text editors (sticky / anchored /
|
|
618
235
|
* standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
|
|
@@ -625,1012 +242,86 @@ function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
|
|
|
625
242
|
function useEditorFormat(initial: EditorFmt): {
|
|
626
243
|
fmtRef: { current: EditorFmt };
|
|
627
244
|
style: CSSProperties;
|
|
628
|
-
onFormatKey: (e: ReactKeyboardEvent) => boolean;
|
|
629
|
-
} {
|
|
630
|
-
const [bold, setBold] = useState(!!initial.bold);
|
|
631
|
-
const [italic, setItalic] = useState(!!initial.italic);
|
|
632
|
-
const [underline, setUnderline] = useState(!!initial.underline);
|
|
633
|
-
const strike = !!initial.strike;
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* Per-line baseline offset (`dy`). Line 0 sits at the anchor when top-anchored
|
|
666
|
-
* (hanging) or is lifted half the block height when vertically centred
|
|
667
|
-
* (anchored-in-host); every later line advances one line-height.
|
|
668
|
-
*/
|
|
669
|
-
function textLineDy(i: number, fontSize: number, lineCount: number, centered: boolean): number {
|
|
670
|
-
const lh = fontSize * TEXT_LINE_HEIGHT;
|
|
671
|
-
if (i > 0) return lh;
|
|
672
|
-
return centered ? (-(lineCount - 1) / 2) * lh : 0;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
/**
|
|
676
|
-
* Inner content for a serialized `<text>` stroke: the legacy single esc'd run
|
|
677
|
-
* when there's no newline (byte-identical, canary-safe), else one `<tspan>` per
|
|
678
|
-
* line. `x` is set on each tspan for standalone text (resets the line origin);
|
|
679
|
-
* anchored text omits it (the persisted form carries no absolute position —
|
|
680
|
-
* geometry is resolved against the host at render time). A `list` prefix
|
|
681
|
-
* (bullet / number) is prepended per line at render time only (DDR — never
|
|
682
|
-
* stored in `text`).
|
|
683
|
-
*/
|
|
684
|
-
function textInnerSvg(
|
|
685
|
-
text: string,
|
|
686
|
-
fontSize: number,
|
|
687
|
-
centered: boolean,
|
|
688
|
-
x: number | undefined,
|
|
689
|
-
list?: ListType
|
|
690
|
-
): string {
|
|
691
|
-
if (!list && !text.includes('\n')) return esc(listPrefixedLine(text, 0, list));
|
|
692
|
-
const lines = splitTextLines(text);
|
|
693
|
-
const xAttr = x != null ? ` x="${x}"` : '';
|
|
694
|
-
return lines
|
|
695
|
-
.map(
|
|
696
|
-
(line, i) =>
|
|
697
|
-
`<tspan${xAttr} dy="${textLineDy(i, fontSize, lines.length, centered)}">${esc(
|
|
698
|
-
listPrefixedLine(line, i, list)
|
|
699
|
-
)}</tspan>`
|
|
700
|
-
)
|
|
701
|
-
.join('');
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
// Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
|
|
705
|
-
// Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
|
|
706
|
-
// and the byte-identical canary keep working.
|
|
707
|
-
export { arrowHeadPoints } from './canvas-arrowheads.ts';
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Phase 24 — polygon vertices derived from the bbox. `diamond` = the four
|
|
711
|
-
* edge-midpoints; `triangle` = apex-up; `triangle-down` = apex-down. Every
|
|
712
|
-
* shape's vertices span the FULL bbox, so a parse-back via the points' min/max
|
|
713
|
-
* recovers x/y/w/h exactly (idempotent round-trip).
|
|
714
|
-
*/
|
|
715
|
-
export function polygonVertices(
|
|
716
|
-
shape: PolygonShape,
|
|
717
|
-
x: number,
|
|
718
|
-
y: number,
|
|
719
|
-
w: number,
|
|
720
|
-
h: number
|
|
721
|
-
): Array<[number, number]> {
|
|
722
|
-
if (shape === 'diamond') {
|
|
723
|
-
return [
|
|
724
|
-
[x + w / 2, y],
|
|
725
|
-
[x + w, y + h / 2],
|
|
726
|
-
[x + w / 2, y + h],
|
|
727
|
-
[x, y + h / 2],
|
|
728
|
-
];
|
|
729
|
-
}
|
|
730
|
-
if (shape === 'triangle') {
|
|
731
|
-
return [
|
|
732
|
-
[x + w / 2, y],
|
|
733
|
-
[x + w, y + h],
|
|
734
|
-
[x, y + h],
|
|
735
|
-
];
|
|
736
|
-
}
|
|
737
|
-
// triangle-down — apex at the bottom.
|
|
738
|
-
return [
|
|
739
|
-
[x, y],
|
|
740
|
-
[x + w, y],
|
|
741
|
-
[x + w / 2, y + h],
|
|
742
|
-
];
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
/** Vertices as an SVG `points` string. */
|
|
746
|
-
export function polygonPoints(
|
|
747
|
-
shape: PolygonShape,
|
|
748
|
-
x: number,
|
|
749
|
-
y: number,
|
|
750
|
-
w: number,
|
|
751
|
-
h: number
|
|
752
|
-
): string {
|
|
753
|
-
return polygonVertices(shape, x, y, w, h)
|
|
754
|
-
.map(([px, py]) => `${px},${py}`)
|
|
755
|
-
.join(' ');
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* Annotation polish (item 1) — a rounded-rect `d` with TL/TR/BL rounded at `r`
|
|
760
|
-
* and the **bottom-right corner SHARP** (the FigJam sticky-note silhouette). The
|
|
761
|
-
* radius is clamped to half the smaller side so it never self-overlaps. Used by
|
|
762
|
-
* `StrokeNode`'s LIVE sticky render only; the persisted form (`strokeToSvgEl`)
|
|
763
|
-
* stays a plain `<rect>` (DDR — zero canary / sanitizer / parse impact).
|
|
764
|
-
*/
|
|
765
|
-
export function stickyCornerPath(x: number, y: number, w: number, h: number, r: number): string {
|
|
766
|
-
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
|
|
767
|
-
return [
|
|
768
|
-
`M${x + rr} ${y}`,
|
|
769
|
-
`L${x + w - rr} ${y}`,
|
|
770
|
-
`Q${x + w} ${y} ${x + w} ${y + rr}`,
|
|
771
|
-
`L${x + w} ${y + h}`, // sharp bottom-right
|
|
772
|
-
`L${x + rr} ${y + h}`,
|
|
773
|
-
`Q${x} ${y + h} ${x} ${y + h - rr}`,
|
|
774
|
-
`L${x} ${y + rr}`,
|
|
775
|
-
`Q${x} ${y} ${x + rr} ${y}`,
|
|
776
|
-
'Z',
|
|
777
|
-
].join(' ');
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
/** Even-odd ray-cast point-in-polygon test. */
|
|
781
|
-
function pointInPolygon(px: number, py: number, pts: ReadonlyArray<[number, number]>): boolean {
|
|
782
|
-
let inside = false;
|
|
783
|
-
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
|
784
|
-
const a = pts[i];
|
|
785
|
-
const b = pts[j];
|
|
786
|
-
if (!a || !b) continue;
|
|
787
|
-
const [xi, yi] = a;
|
|
788
|
-
const [xj, yj] = b;
|
|
789
|
-
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) {
|
|
790
|
-
inside = !inside;
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
return inside;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
/** Parse a polygon `points` string back into its bounding box. */
|
|
797
|
-
function polygonBBox(points: string): { x: number; y: number; w: number; h: number } | null {
|
|
798
|
-
let xMin = Number.POSITIVE_INFINITY;
|
|
799
|
-
let yMin = Number.POSITIVE_INFINITY;
|
|
800
|
-
let xMax = Number.NEGATIVE_INFINITY;
|
|
801
|
-
let yMax = Number.NEGATIVE_INFINITY;
|
|
802
|
-
for (const pair of points.trim().split(/\s+/)) {
|
|
803
|
-
const [px, py] = pair.split(',').map((n) => Number.parseFloat(n));
|
|
804
|
-
if (px == null || py == null || Number.isNaN(px) || Number.isNaN(py)) continue;
|
|
805
|
-
if (px < xMin) xMin = px;
|
|
806
|
-
if (px > xMax) xMax = px;
|
|
807
|
-
if (py < yMin) yMin = py;
|
|
808
|
-
if (py > yMax) yMax = py;
|
|
809
|
-
}
|
|
810
|
-
if (!Number.isFinite(xMin)) return null;
|
|
811
|
-
return { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin };
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
function strokeToSvgEl(s: Stroke): string {
|
|
815
|
-
if (s.tool === 'text') {
|
|
816
|
-
// Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
|
|
817
|
-
// standalone text (no anchorId) writes its own world x/y and omits
|
|
818
|
-
// data-anchor-id (so the parser routes it back to the standalone branch).
|
|
819
|
-
// bold/italic/strike/underline/align/list serialize ONLY for non-default
|
|
820
|
-
// values, so a legacy text node stays byte-identical (every added fragment
|
|
821
|
-
// is empty). Multi-line text emits one <tspan> per line (item 4a); a
|
|
822
|
-
// single-line unstyled run stays the legacy single esc'd text.
|
|
823
|
-
const weight = s.bold ? ' font-weight="700"' : '';
|
|
824
|
-
const italic = s.italic ? ' font-style="italic"' : '';
|
|
825
|
-
const deco = textDecoAttr(s.strike, s.underline);
|
|
826
|
-
const listAttr = s.listType ? ` data-list="${s.listType}"` : '';
|
|
827
|
-
if (s.anchorId != null && s.anchorId !== '') {
|
|
828
|
-
const align = s.align ?? 'center'; // anchored default = centre (legacy)
|
|
829
|
-
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
830
|
-
const alignAttr = align !== 'center' ? ` data-align="${align}"` : '';
|
|
831
|
-
return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
|
|
832
|
-
s.anchorId
|
|
833
|
-
)}" data-font-size="${s.fontSize}" fill="${esc(
|
|
834
|
-
s.color
|
|
835
|
-
)}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="middle"${alignAttr}>${textInnerSvg(
|
|
836
|
-
s.text,
|
|
837
|
-
s.fontSize,
|
|
838
|
-
true,
|
|
839
|
-
undefined,
|
|
840
|
-
s.listType
|
|
841
|
-
)}</text>`;
|
|
842
|
-
}
|
|
843
|
-
const tx = s.x ?? 0;
|
|
844
|
-
const ty = s.y ?? 0;
|
|
845
|
-
const align = s.align ?? 'left'; // standalone default = left
|
|
846
|
-
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
847
|
-
const alignAttr = align !== 'left' ? ` data-align="${align}"` : '';
|
|
848
|
-
return `<text data-id="${esc(s.id)}" data-tool="text" x="${tx}" y="${ty}" data-font-size="${
|
|
849
|
-
s.fontSize
|
|
850
|
-
}" fill="${esc(
|
|
851
|
-
s.color
|
|
852
|
-
)}"${weight}${italic}${deco}${listAttr} text-anchor="${anchor}" dominant-baseline="hanging"${alignAttr}>${textInnerSvg(
|
|
853
|
-
s.text,
|
|
854
|
-
s.fontSize,
|
|
855
|
-
false,
|
|
856
|
-
tx,
|
|
857
|
-
s.listType
|
|
858
|
-
)}</text>`;
|
|
859
|
-
}
|
|
860
|
-
if (s.tool === 'sticky') {
|
|
861
|
-
// Phase 21 — sticky body lives in an allowlisted <text> child so it
|
|
862
|
-
// survives sanitizeAnnotationSvg (which strips <foreignObject>, DDR-060
|
|
863
|
-
// F1). The live canvas re-renders this stroke with a foreignObject so the
|
|
864
|
-
// text word-wraps; the persisted <text> is the inert, sanitizer-safe form.
|
|
865
|
-
const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
866
|
-
const w = Math.max(0, s.w);
|
|
867
|
-
const h = Math.max(0, s.h);
|
|
868
|
-
// bold/italic/strike/underline/align/list on the <g> data-attrs, emitted
|
|
869
|
-
// ONLY for non-default values (sticky default align = left) so Phase-21
|
|
870
|
-
// stickies serialize byte-identically. The body <text> stays raw text —
|
|
871
|
-
// list markers are render-only (item 4c), never persisted.
|
|
872
|
-
const align = s.align ?? 'left';
|
|
873
|
-
const styleAttrs =
|
|
874
|
-
(s.bold ? ' data-bold="1"' : '') +
|
|
875
|
-
(s.italic ? ' data-italic="1"' : '') +
|
|
876
|
-
(s.strike ? ' data-strike="1"' : '') +
|
|
877
|
-
(s.underline ? ' data-underline="1"' : '') +
|
|
878
|
-
(align !== 'left' ? ` data-align="${align}"` : '') +
|
|
879
|
-
(s.listType ? ` data-list="${s.listType}"` : '');
|
|
880
|
-
return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
|
|
881
|
-
s.fontSize
|
|
882
|
-
}" fill="${esc(s.color)}"${styleAttrs}><rect x="${s.x}" y="${
|
|
883
|
-
s.y
|
|
884
|
-
}" width="${w}" height="${h}" rx="${r}" ry="${r}"/><text data-sticky-body="1" x="${
|
|
885
|
-
s.x + 12
|
|
886
|
-
}" y="${s.y + 12}" font-size="${
|
|
887
|
-
s.fontSize
|
|
888
|
-
}" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
|
|
889
|
-
}
|
|
890
|
-
if (s.tool === 'image') {
|
|
891
|
-
// Phase 23 — `href` is ALWAYS a relative assets/<sha8>.<ext> path (asserted
|
|
892
|
-
// on create + re-validated by the sanitizer's <image> href allowlist). Alt
|
|
893
|
-
// text persists in `data-alt` + is emitted as `aria-label` for the exported
|
|
894
|
-
// SVG (the live annotation root is aria-hidden — see ImageStroke.alt).
|
|
895
|
-
const nx = Math.min(s.x, s.x + s.w);
|
|
896
|
-
const ny = Math.min(s.y, s.y + s.h);
|
|
897
|
-
const nw = Math.abs(s.w);
|
|
898
|
-
const nh = Math.abs(s.h);
|
|
899
|
-
const altAttr = s.alt ? ` data-alt="${escAttr(s.alt)}"` : '';
|
|
900
|
-
return `<image data-id="${esc(s.id)}" data-tool="image" x="${nx}" y="${ny}" width="${nw}" height="${nh}" href="${escAttr(
|
|
901
|
-
s.href
|
|
902
|
-
)}" preserveAspectRatio="xMidYMid meet"${altAttr}/>`;
|
|
903
|
-
}
|
|
904
|
-
if (s.tool === 'link') {
|
|
905
|
-
// Phase 23 — client-only preview chip. data-url/title/domain are the
|
|
906
|
-
// round-trip source of truth; the inner rect/glyph/text are the inert,
|
|
907
|
-
// sanitizer-safe visual (no <a href> persisted — click-to-open reads
|
|
908
|
-
// data-url client-side and validates http(s) before window.open).
|
|
909
|
-
const nx = Math.min(s.x, s.x + s.w);
|
|
910
|
-
const ny = Math.min(s.y, s.y + s.h);
|
|
911
|
-
const nw = Math.abs(s.w);
|
|
912
|
-
const nh = Math.abs(s.h);
|
|
913
|
-
const lay = linkCardLayout(nx, ny, nw, nh);
|
|
914
|
-
const shownTitle = clampLinkTitle(s.title, lay.textMaxChars);
|
|
915
|
-
return (
|
|
916
|
-
`<g data-id="${esc(s.id)}" data-tool="link" data-url="${escAttr(s.url)}" data-title="${escAttr(
|
|
917
|
-
s.title
|
|
918
|
-
)}" data-domain="${escAttr(s.domain)}">` +
|
|
919
|
-
`<rect x="${nx}" y="${ny}" width="${nw}" height="${nh}" rx="8" ry="8" fill="${LINK_CARD_FILL}" stroke="${LINK_CARD_STROKE}" stroke-width="1"/>` +
|
|
920
|
-
`<svg x="${lay.glyph.x}" y="${lay.glyph.y}" width="${lay.glyph.size}" height="${lay.glyph.size}" viewBox="0 0 24 24" fill="none" stroke="${LINK_GLYPH_STROKE}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${LINK_GLYPH_D1}"/><path d="${LINK_GLYPH_D2}"/></svg>` +
|
|
921
|
-
`<text x="${lay.textX}" y="${lay.domain.y}" font-size="${lay.domain.fontSize}" fill="${LINK_DOMAIN_FILL}" dominant-baseline="hanging">${esc(
|
|
922
|
-
s.domain
|
|
923
|
-
)}</text>` +
|
|
924
|
-
`<text x="${lay.textX}" y="${lay.title.y}" font-size="${lay.title.fontSize}" fill="${LINK_TITLE_FILL}" font-weight="600" dominant-baseline="hanging">${esc(
|
|
925
|
-
shownTitle
|
|
926
|
-
)}</text>` +
|
|
927
|
-
`</g>`
|
|
928
|
-
);
|
|
929
|
-
}
|
|
930
|
-
const common = `data-id="${esc(s.id)}" data-tool="${s.tool}" stroke="${esc(s.color)}" stroke-width="${s.width}" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"`;
|
|
931
|
-
if (s.tool === 'pen') {
|
|
932
|
-
// Highlighter (item 8) — data-highlighter ONLY when true so a normal pen
|
|
933
|
-
// stays byte-identical (canary).
|
|
934
|
-
const hl = s.highlighter ? ' data-highlighter="1"' : '';
|
|
935
|
-
return `<path ${common} fill="none" d="${penPathD(s.points)}"${hl} pointer-events="stroke"/>`;
|
|
936
|
-
}
|
|
937
|
-
if (s.tool === 'rect') {
|
|
938
|
-
const fill = s.fill ? esc(s.fill) : 'none';
|
|
939
|
-
// Phase 21 — corner radius: append rx/ry/data-r ONLY when > 0 so legacy
|
|
940
|
-
// sharp-corner rects serialize byte-identically (Task 10 canary).
|
|
941
|
-
const r = s.cornerRadius ?? 0;
|
|
942
|
-
const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
|
|
943
|
-
// Dashed outline (item 7) — emitted ONLY when true (mirror polygon/arrow)
|
|
944
|
-
// so a legacy solid rect stays byte-identical.
|
|
945
|
-
const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
|
|
946
|
-
const dashAttr = s.dashed ? ' data-dash="1"' : '';
|
|
947
|
-
return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
|
|
948
|
-
0,
|
|
949
|
-
s.w
|
|
950
|
-
)}" height="${Math.max(0, s.h)}"${round}${dash}${dashAttr}/>`;
|
|
951
|
-
}
|
|
952
|
-
if (s.tool === 'ellipse') {
|
|
953
|
-
const fill = s.fill ? esc(s.fill) : 'none';
|
|
954
|
-
const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
|
|
955
|
-
const dashAttr = s.dashed ? ' data-dash="1"' : '';
|
|
956
|
-
return `<ellipse ${common} fill="${fill}" cx="${s.cx}" cy="${s.cy}" rx="${Math.max(
|
|
957
|
-
0,
|
|
958
|
-
s.rx
|
|
959
|
-
)}" ry="${Math.max(0, s.ry)}"${dash}${dashAttr}/>`;
|
|
960
|
-
}
|
|
961
|
-
if (s.tool === 'polygon') {
|
|
962
|
-
// Phase 24 — bbox-derived points + data-shape. Normalize the bbox so a
|
|
963
|
-
// negative-extent (mid-flip) stroke serializes idempotently.
|
|
964
|
-
const nx = Math.min(s.x, s.x + s.w);
|
|
965
|
-
const ny = Math.min(s.y, s.y + s.h);
|
|
966
|
-
const nw = Math.abs(s.w);
|
|
967
|
-
const nh = Math.abs(s.h);
|
|
968
|
-
const fill = s.fill ? esc(s.fill) : 'none';
|
|
969
|
-
const dash = s.dashed ? ' stroke-dasharray="6 4"' : '';
|
|
970
|
-
const dashAttr = s.dashed ? ' data-dash="1"' : '';
|
|
971
|
-
return `<polygon ${common} fill="${fill}" data-shape="${s.shape}" points="${polygonPoints(
|
|
972
|
-
s.shape,
|
|
973
|
-
nx,
|
|
974
|
-
ny,
|
|
975
|
-
nw,
|
|
976
|
-
nh
|
|
977
|
-
)}"${dash}${dashAttr}/>`;
|
|
978
|
-
}
|
|
979
|
-
// arrow — Phase 24 reduces to ordered SVG primitives (canvas-arrowheads), the
|
|
980
|
-
// same primitives StrokeNode renders. Defaults (startHead 'none', endHead
|
|
981
|
-
// 'triangle', lineType 'straight', solid) reduce to exactly
|
|
982
|
-
// [<line>, <polyline fill=color>] → the byte-identical Phase 5.1 form. data-*
|
|
983
|
-
// attrs appear only for non-default values.
|
|
984
|
-
const startHead = s.startHead ?? 'none';
|
|
985
|
-
const endHead = s.endHead ?? 'triangle';
|
|
986
|
-
const lineType = s.lineType ?? 'straight';
|
|
987
|
-
const dashed = s.dashed ?? false;
|
|
988
|
-
// esc() every interpolated value (defence-in-depth, Phase 24 security review
|
|
989
|
-
// DDR-067) — heads are clamped on parse, but a value reaching serialize must
|
|
990
|
-
// never be able to break out of the attribute.
|
|
991
|
-
const dataAttrs =
|
|
992
|
-
(startHead !== 'none' ? ` data-start-head="${esc(startHead)}"` : '') +
|
|
993
|
-
(endHead !== 'triangle' ? ` data-end-head="${esc(endHead)}"` : '') +
|
|
994
|
-
(lineType !== 'straight' ? ` data-line-type="${esc(lineType)}"` : '') +
|
|
995
|
-
(dashed ? ' data-dash="1"' : '');
|
|
996
|
-
const body = arrowPrimitives(s).map(svgPrimitiveToString).join('');
|
|
997
|
-
return `<g ${common} fill="none"${dataAttrs}>${body}</g>`;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
/** Format one arrow SVG primitive for the persisted string (byte-identical to
|
|
1001
|
-
* the Phase-5.1 `<line>`/`<polyline>` forms for the legacy default arrow). */
|
|
1002
|
-
function svgPrimitiveToString(p: SvgPrimitive): string {
|
|
1003
|
-
const dash = 'dash' in p && p.dash ? ' stroke-dasharray="6 4"' : '';
|
|
1004
|
-
switch (p.el) {
|
|
1005
|
-
case 'line':
|
|
1006
|
-
return `<line x1="${p.x1}" y1="${p.y1}" x2="${p.x2}" y2="${p.y2}"${dash}/>`;
|
|
1007
|
-
case 'path':
|
|
1008
|
-
return `<path d="${p.d}"${dash}/>`;
|
|
1009
|
-
case 'polyline':
|
|
1010
|
-
return `<polyline points="${p.points}" fill="${esc(p.fill)}"/>`;
|
|
1011
|
-
case 'polygon':
|
|
1012
|
-
return `<polygon points="${p.points}" fill="${esc(p.fill)}"/>`;
|
|
1013
|
-
case 'circle':
|
|
1014
|
-
return `<circle cx="${p.cx}" cy="${p.cy}" r="${p.r}" fill="${esc(p.fill)}"/>`;
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
export function strokesToSvg(strokes: readonly Stroke[]): string {
|
|
1019
|
-
const header = '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">';
|
|
1020
|
-
if (strokes.length === 0) return `${header}</svg>`;
|
|
1021
|
-
const body = strokes.map(strokeToSvgEl).join('');
|
|
1022
|
-
return `${header}${body}</svg>`;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
function parsePathD(d: string): WorldPoint[] {
|
|
1026
|
-
const out: WorldPoint[] = [];
|
|
1027
|
-
const re = /[ML]\s*(-?\d+(?:\.\d+)?)\s*[\s,]\s*(-?\d+(?:\.\d+)?)/g;
|
|
1028
|
-
let m: RegExpExecArray | null;
|
|
1029
|
-
// biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex loop
|
|
1030
|
-
while ((m = re.exec(d)) !== null) {
|
|
1031
|
-
const [, x = '0', y = '0'] = m;
|
|
1032
|
-
out.push([Number.parseFloat(x), Number.parseFloat(y)]);
|
|
1033
|
-
}
|
|
1034
|
-
return out;
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
function parseFill(raw: string | null): string | null {
|
|
1038
|
-
if (!raw) return null;
|
|
1039
|
-
const v = raw.trim().toLowerCase();
|
|
1040
|
-
if (!v || v === 'none' || v === 'transparent') return null;
|
|
1041
|
-
return raw;
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
/**
|
|
1045
|
-
* Phase 24 — recover an arrow's two endpoints from its shaft. A straight arrow
|
|
1046
|
-
* persists a `<line>`; a curved/elbow arrow persists a `<path>` whose first and
|
|
1047
|
-
* last coordinate pairs are the endpoints (the bow control / elbow corner sit
|
|
1048
|
-
* between them, so first-pair = start, last-pair = end recovers the ends
|
|
1049
|
-
* exactly → idempotent re-serialize).
|
|
1050
|
-
*/
|
|
1051
|
-
function arrowEndpoints(el: Element): { x1: number; y1: number; x2: number; y2: number } | null {
|
|
1052
|
-
const line = el.querySelector('line');
|
|
1053
|
-
if (line) {
|
|
1054
|
-
return {
|
|
1055
|
-
x1: Number.parseFloat(line.getAttribute('x1') || '0'),
|
|
1056
|
-
y1: Number.parseFloat(line.getAttribute('y1') || '0'),
|
|
1057
|
-
x2: Number.parseFloat(line.getAttribute('x2') || '0'),
|
|
1058
|
-
y2: Number.parseFloat(line.getAttribute('y2') || '0'),
|
|
1059
|
-
};
|
|
1060
|
-
}
|
|
1061
|
-
const path = el.querySelector('path');
|
|
1062
|
-
if (path) {
|
|
1063
|
-
const nums = (path.getAttribute('d') || '').match(/-?\d+(?:\.\d+)?/g);
|
|
1064
|
-
if (nums && nums.length >= 4) {
|
|
1065
|
-
return {
|
|
1066
|
-
x1: Number.parseFloat(nums[0] as string),
|
|
1067
|
-
y1: Number.parseFloat(nums[1] as string),
|
|
1068
|
-
x2: Number.parseFloat(nums[nums.length - 2] as string),
|
|
1069
|
-
y2: Number.parseFloat(nums[nums.length - 1] as string),
|
|
1070
|
-
};
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
return null;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
export function svgToStrokes(svgText: string): Stroke[] {
|
|
1077
|
-
const text = (svgText ?? '').trim();
|
|
1078
|
-
if (!text) return [];
|
|
1079
|
-
if (typeof DOMParser === 'undefined') return [];
|
|
1080
|
-
try {
|
|
1081
|
-
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
|
|
1082
|
-
if (doc.querySelector('parsererror')) return [];
|
|
1083
|
-
const out: Stroke[] = [];
|
|
1084
|
-
for (const el of Array.from(doc.querySelectorAll('[data-tool]'))) {
|
|
1085
|
-
const tool = el.getAttribute('data-tool');
|
|
1086
|
-
const id = el.getAttribute('data-id') || rid();
|
|
1087
|
-
const color = el.getAttribute('stroke') || el.getAttribute('fill') || DEFAULT_COLOR;
|
|
1088
|
-
const width = Number.parseFloat(el.getAttribute('stroke-width') || '2') || 2;
|
|
1089
|
-
if (tool === 'pen') {
|
|
1090
|
-
const d = el.getAttribute('d') || '';
|
|
1091
|
-
const points = parsePathD(d);
|
|
1092
|
-
if (points.length) {
|
|
1093
|
-
const pen: PenStroke = { id, tool: 'pen', color, width, points };
|
|
1094
|
-
// Highlighter flag; absent ⇒ undefined so a normal pen round-trips.
|
|
1095
|
-
if (el.getAttribute('data-highlighter') === '1') pen.highlighter = true;
|
|
1096
|
-
out.push(pen);
|
|
1097
|
-
}
|
|
1098
|
-
continue;
|
|
1099
|
-
}
|
|
1100
|
-
if (tool === 'sticky') {
|
|
1101
|
-
// Phase 21 — sticky reads geometry off its <rect> child, paper tint
|
|
1102
|
-
// off the group fill, body text off the inner <text>.
|
|
1103
|
-
const rectEl = el.querySelector('rect');
|
|
1104
|
-
const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
|
|
1105
|
-
const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
|
|
1106
|
-
const w = Number.parseFloat(rectEl?.getAttribute('width') || '0');
|
|
1107
|
-
const h = Number.parseFloat(rectEl?.getAttribute('height') || '0');
|
|
1108
|
-
const cornerRadius =
|
|
1109
|
-
Number.parseFloat(el.getAttribute('data-r') || String(STICKY_CORNER_RADIUS)) || 0;
|
|
1110
|
-
const fontSize =
|
|
1111
|
-
Number.parseFloat(el.getAttribute('data-fs') || String(DEFAULT_FONT_SIZE)) ||
|
|
1112
|
-
DEFAULT_FONT_SIZE;
|
|
1113
|
-
const stickyColor = el.getAttribute('fill') || DEFAULT_STICKY_COLOR;
|
|
1114
|
-
const body = el.querySelector('text');
|
|
1115
|
-
const sticky: StickyStroke = {
|
|
1116
|
-
id,
|
|
1117
|
-
tool: 'sticky',
|
|
1118
|
-
color: stickyColor,
|
|
1119
|
-
x,
|
|
1120
|
-
y,
|
|
1121
|
-
w,
|
|
1122
|
-
h,
|
|
1123
|
-
text: body?.textContent ?? '',
|
|
1124
|
-
fontSize,
|
|
1125
|
-
cornerRadius,
|
|
1126
|
-
};
|
|
1127
|
-
// Style attrs; absent ⇒ defaults (normal / left), left unset.
|
|
1128
|
-
if (el.getAttribute('data-bold') === '1') sticky.bold = true;
|
|
1129
|
-
if (el.getAttribute('data-italic') === '1') sticky.italic = true;
|
|
1130
|
-
if (el.getAttribute('data-strike') === '1') sticky.strike = true;
|
|
1131
|
-
if (el.getAttribute('data-underline') === '1') sticky.underline = true;
|
|
1132
|
-
const sticAlign = el.getAttribute('data-align');
|
|
1133
|
-
if (sticAlign === 'left' || sticAlign === 'center' || sticAlign === 'right') {
|
|
1134
|
-
sticky.align = sticAlign;
|
|
1135
|
-
}
|
|
1136
|
-
const sticList = el.getAttribute('data-list');
|
|
1137
|
-
if (sticList === 'bullet' || sticList === 'number') sticky.listType = sticList;
|
|
1138
|
-
out.push(sticky);
|
|
1139
|
-
continue;
|
|
1140
|
-
}
|
|
1141
|
-
if (tool === 'rect') {
|
|
1142
|
-
const x = Number.parseFloat(el.getAttribute('x') || '0');
|
|
1143
|
-
const y = Number.parseFloat(el.getAttribute('y') || '0');
|
|
1144
|
-
const w = Number.parseFloat(el.getAttribute('width') || '0');
|
|
1145
|
-
const h = Number.parseFloat(el.getAttribute('height') || '0');
|
|
1146
|
-
const fill = parseFill(el.getAttribute('fill'));
|
|
1147
|
-
// Phase 21 — corner radius; absent ⇒ 0 (sharp, back-compat).
|
|
1148
|
-
const cornerRadius = Number.parseFloat(el.getAttribute('data-r') || '0') || 0;
|
|
1149
|
-
const rect: RectStroke = { id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius };
|
|
1150
|
-
// Dashed (item 7); absent ⇒ undefined so a solid rect round-trips.
|
|
1151
|
-
const rectDash = el.getAttribute('data-dash');
|
|
1152
|
-
if (rectDash === '1' || rectDash === 'true') rect.dashed = true;
|
|
1153
|
-
out.push(rect);
|
|
1154
|
-
continue;
|
|
1155
|
-
}
|
|
1156
|
-
if (tool === 'ellipse') {
|
|
1157
|
-
const cx = Number.parseFloat(el.getAttribute('cx') || '0');
|
|
1158
|
-
const cy = Number.parseFloat(el.getAttribute('cy') || '0');
|
|
1159
|
-
const rx = Number.parseFloat(el.getAttribute('rx') || '0');
|
|
1160
|
-
const ry = Number.parseFloat(el.getAttribute('ry') || '0');
|
|
1161
|
-
const fill = parseFill(el.getAttribute('fill'));
|
|
1162
|
-
const ell: EllipseStroke = { id, tool: 'ellipse', color, width, cx, cy, rx, ry, fill };
|
|
1163
|
-
const ellDash = el.getAttribute('data-dash');
|
|
1164
|
-
if (ellDash === '1' || ellDash === 'true') ell.dashed = true;
|
|
1165
|
-
out.push(ell);
|
|
1166
|
-
continue;
|
|
1167
|
-
}
|
|
1168
|
-
if (tool === 'polygon') {
|
|
1169
|
-
// Phase 24 — recover the bbox from the points; shape from data-shape.
|
|
1170
|
-
const shapeRaw = el.getAttribute('data-shape');
|
|
1171
|
-
const shape: PolygonShape =
|
|
1172
|
-
shapeRaw === 'triangle' || shapeRaw === 'triangle-down' ? shapeRaw : 'diamond';
|
|
1173
|
-
const bb = polygonBBox(el.getAttribute('points') || '');
|
|
1174
|
-
if (bb) {
|
|
1175
|
-
const fill = parseFill(el.getAttribute('fill'));
|
|
1176
|
-
const poly: PolygonStroke = {
|
|
1177
|
-
id,
|
|
1178
|
-
tool: 'polygon',
|
|
1179
|
-
shape,
|
|
1180
|
-
color,
|
|
1181
|
-
width,
|
|
1182
|
-
x: bb.x,
|
|
1183
|
-
y: bb.y,
|
|
1184
|
-
w: bb.w,
|
|
1185
|
-
h: bb.h,
|
|
1186
|
-
fill,
|
|
1187
|
-
};
|
|
1188
|
-
const dashRaw = el.getAttribute('data-dash');
|
|
1189
|
-
if (dashRaw === '1' || dashRaw === 'true') poly.dashed = true;
|
|
1190
|
-
out.push(poly);
|
|
1191
|
-
}
|
|
1192
|
-
continue;
|
|
1193
|
-
}
|
|
1194
|
-
if (tool === 'arrow') {
|
|
1195
|
-
// Phase 24 — shaft is a <line> (straight) OR a <path> (curved/elbow).
|
|
1196
|
-
// Recover the two endpoints from whichever is present.
|
|
1197
|
-
const ends = arrowEndpoints(el);
|
|
1198
|
-
if (ends) {
|
|
1199
|
-
const arrow: ArrowStroke = {
|
|
1200
|
-
id,
|
|
1201
|
-
tool: 'arrow',
|
|
1202
|
-
color,
|
|
1203
|
-
width,
|
|
1204
|
-
x1: ends.x1,
|
|
1205
|
-
y1: ends.y1,
|
|
1206
|
-
x2: ends.x2,
|
|
1207
|
-
y2: ends.y2,
|
|
1208
|
-
};
|
|
1209
|
-
// Heads + dash + line-type. The serializer writes a data-* attribute
|
|
1210
|
-
// only for a NON-default value, so a legacy arrow carries none of
|
|
1211
|
-
// these and stays { startHead/endHead/dashed/lineType: undefined } →
|
|
1212
|
-
// defaults on re-serialize (byte-identical, canary). Phase 24 widened
|
|
1213
|
-
// the head enum, so read the literal value rather than match a single
|
|
1214
|
-
// string.
|
|
1215
|
-
// Clamp to the known head vocabulary — an out-of-vocab / poisoned
|
|
1216
|
-
// value (hub-pushed SVG) is rejected, never cast through unchecked
|
|
1217
|
-
// (Phase 24 security review, DDR-067).
|
|
1218
|
-
const sh = el.getAttribute('data-start-head');
|
|
1219
|
-
if (sh && ARROW_HEADS.has(sh)) arrow.startHead = sh as ArrowHead;
|
|
1220
|
-
const eh = el.getAttribute('data-end-head');
|
|
1221
|
-
if (eh && ARROW_HEADS.has(eh)) arrow.endHead = eh as ArrowHead;
|
|
1222
|
-
const lt = el.getAttribute('data-line-type');
|
|
1223
|
-
if (lt === 'curved' || lt === 'elbow' || lt === 'straight') arrow.lineType = lt;
|
|
1224
|
-
const dashRaw = el.getAttribute('data-dash');
|
|
1225
|
-
if (dashRaw === '1' || dashRaw === 'true') arrow.dashed = true;
|
|
1226
|
-
out.push(arrow);
|
|
1227
|
-
}
|
|
1228
|
-
continue;
|
|
1229
|
-
}
|
|
1230
|
-
if (tool === 'image') {
|
|
1231
|
-
// Phase 23 — geometry off the element; href is whatever survived the
|
|
1232
|
-
// sanitizer (a valid assets/<sha8>.<ext> path, or '' if it was stripped
|
|
1233
|
-
// — an external/data:/`..` href is dropped server-side, so a poisoned
|
|
1234
|
-
// SVG round-trips to an inert empty-href stroke that fetches nothing).
|
|
1235
|
-
const x = Number.parseFloat(el.getAttribute('x') || '0');
|
|
1236
|
-
const y = Number.parseFloat(el.getAttribute('y') || '0');
|
|
1237
|
-
const w = Number.parseFloat(el.getAttribute('width') || '0');
|
|
1238
|
-
const h = Number.parseFloat(el.getAttribute('height') || '0');
|
|
1239
|
-
const href = el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
1240
|
-
const img: ImageStroke = { id, tool: 'image', x, y, w, h, href };
|
|
1241
|
-
const alt = el.getAttribute('data-alt');
|
|
1242
|
-
if (alt) img.alt = alt;
|
|
1243
|
-
out.push(img);
|
|
1244
|
-
continue;
|
|
1245
|
-
}
|
|
1246
|
-
if (tool === 'link') {
|
|
1247
|
-
// Phase 23 — data-* are the source of truth; geometry off the <rect>
|
|
1248
|
-
// child (mirrors sticky). Defensive: missing title ⇒ domain.
|
|
1249
|
-
const rectEl = el.querySelector('rect');
|
|
1250
|
-
const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
|
|
1251
|
-
const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
|
|
1252
|
-
const w = Number.parseFloat(rectEl?.getAttribute('width') || String(LINK_DEFAULT_W));
|
|
1253
|
-
const h = Number.parseFloat(rectEl?.getAttribute('height') || String(LINK_DEFAULT_H));
|
|
1254
|
-
const url = el.getAttribute('data-url') || '';
|
|
1255
|
-
const domain = el.getAttribute('data-domain') || '';
|
|
1256
|
-
const title = el.getAttribute('data-title') || domain || url;
|
|
1257
|
-
out.push({ id, tool: 'link', x, y, w, h, url, title, domain });
|
|
1258
|
-
continue;
|
|
1259
|
-
}
|
|
1260
|
-
if (tool === 'text') {
|
|
1261
|
-
const rawAnchor = el.getAttribute('data-anchor-id');
|
|
1262
|
-
const fontSize =
|
|
1263
|
-
Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
|
|
1264
|
-
DEFAULT_FONT_SIZE;
|
|
1265
|
-
const inkColor = el.getAttribute('fill') || color;
|
|
1266
|
-
// List style (item 4c) — read FIRST so per-line markers can be stripped
|
|
1267
|
-
// off the parsed text (markers are render-only; never stored).
|
|
1268
|
-
const listRaw = el.getAttribute('data-list');
|
|
1269
|
-
const listType: ListType | undefined =
|
|
1270
|
-
listRaw === 'bullet' || listRaw === 'number' ? listRaw : undefined;
|
|
1271
|
-
// Multi-line text (item 4a) — one <tspan> per line. Recover `\n` by
|
|
1272
|
-
// joining tspan text content (markers stripped); a legacy single-run
|
|
1273
|
-
// <text> has no tspans → read its trimmed textContent.
|
|
1274
|
-
const tspans = el.querySelectorAll('tspan');
|
|
1275
|
-
const body =
|
|
1276
|
-
tspans.length > 0
|
|
1277
|
-
? Array.from(tspans)
|
|
1278
|
-
.map((t, i) => stripListPrefix(t.textContent ?? '', i, listType))
|
|
1279
|
-
.join('\n')
|
|
1280
|
-
: stripListPrefix((el.textContent || '').trim(), 0, listType);
|
|
1281
|
-
// bold / italic / strike / underline / align. `data-align` is the
|
|
1282
|
-
// round-trip source of truth (text-anchor is derived from it). Absent ⇒
|
|
1283
|
-
// default (normal / per-kind align), left unset so legacy nodes
|
|
1284
|
-
// round-trip.
|
|
1285
|
-
const isBold = el.getAttribute('font-weight') === '700';
|
|
1286
|
-
const isItalic = el.getAttribute('font-style') === 'italic';
|
|
1287
|
-
const decoAttr = el.getAttribute('text-decoration') || '';
|
|
1288
|
-
const isStrike = decoAttr.includes('line-through');
|
|
1289
|
-
const isUnderline = decoAttr.includes('underline');
|
|
1290
|
-
const da = el.getAttribute('data-align');
|
|
1291
|
-
const align: TextAlign | undefined =
|
|
1292
|
-
da === 'left' || da === 'center' || da === 'right' ? da : undefined;
|
|
1293
|
-
// Phase 21 — standalone text (no data-anchor-id) carries world x/y
|
|
1294
|
-
// instead of a host id.
|
|
1295
|
-
if (!rawAnchor) {
|
|
1296
|
-
const t: TextStroke = {
|
|
1297
|
-
id,
|
|
1298
|
-
tool: 'text',
|
|
1299
|
-
color: inkColor,
|
|
1300
|
-
fontSize,
|
|
1301
|
-
text: body,
|
|
1302
|
-
x: Number.parseFloat(el.getAttribute('x') || '0'),
|
|
1303
|
-
y: Number.parseFloat(el.getAttribute('y') || '0'),
|
|
1304
|
-
};
|
|
1305
|
-
if (isBold) t.bold = true;
|
|
1306
|
-
if (isItalic) t.italic = true;
|
|
1307
|
-
if (isStrike) t.strike = true;
|
|
1308
|
-
if (isUnderline) t.underline = true;
|
|
1309
|
-
if (listType) t.listType = listType;
|
|
1310
|
-
if (align) t.align = align;
|
|
1311
|
-
out.push(t);
|
|
1312
|
-
continue;
|
|
1313
|
-
}
|
|
1314
|
-
const t: TextStroke = {
|
|
1315
|
-
id,
|
|
1316
|
-
tool: 'text',
|
|
1317
|
-
color: inkColor,
|
|
1318
|
-
fontSize,
|
|
1319
|
-
text: body,
|
|
1320
|
-
anchorId: rawAnchor,
|
|
1321
|
-
};
|
|
1322
|
-
if (isBold) t.bold = true;
|
|
1323
|
-
if (isItalic) t.italic = true;
|
|
1324
|
-
if (isStrike) t.strike = true;
|
|
1325
|
-
if (isUnderline) t.underline = true;
|
|
1326
|
-
if (listType) t.listType = listType;
|
|
1327
|
-
if (align) t.align = align;
|
|
1328
|
-
out.push(t);
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
return out;
|
|
1332
|
-
} catch {
|
|
1333
|
-
return [];
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
function pointSegmentDist(
|
|
1338
|
-
px: number,
|
|
1339
|
-
py: number,
|
|
1340
|
-
ax: number,
|
|
1341
|
-
ay: number,
|
|
1342
|
-
bx: number,
|
|
1343
|
-
by: number
|
|
1344
|
-
): number {
|
|
1345
|
-
const dx = bx - ax;
|
|
1346
|
-
const dy = by - ay;
|
|
1347
|
-
const len2 = dx * dx + dy * dy;
|
|
1348
|
-
if (len2 === 0) return Math.hypot(px - ax, py - ay);
|
|
1349
|
-
let t = ((px - ax) * dx + (py - ay) * dy) / len2;
|
|
1350
|
-
t = Math.max(0, Math.min(1, t));
|
|
1351
|
-
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): boolean {
|
|
1355
|
-
if (s.tool === 'text') {
|
|
1356
|
-
// Anchored text isn't independently hit-testable (it inherits its host).
|
|
1357
|
-
// Standalone text (Phase 21) uses its synthetic bbox so the eraser can
|
|
1358
|
-
// reach it.
|
|
1359
|
-
if (s.anchorId != null && s.anchorId !== '') return false;
|
|
1360
|
-
const bb = strokeBBox(s);
|
|
1361
|
-
if (!bb) return false;
|
|
1362
|
-
return (
|
|
1363
|
-
wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
|
|
1364
|
-
);
|
|
1365
|
-
}
|
|
1366
|
-
const t = Math.max(tol, 'width' in s ? s.width : 2);
|
|
1367
|
-
if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
|
|
1368
|
-
// Sticky / image / link are solid cards — filled-rect hit anywhere inside.
|
|
1369
|
-
const xMin = Math.min(s.x, s.x + s.w);
|
|
1370
|
-
const xMax = Math.max(s.x, s.x + s.w);
|
|
1371
|
-
const yMin = Math.min(s.y, s.y + s.h);
|
|
1372
|
-
const yMax = Math.max(s.y, s.y + s.h);
|
|
1373
|
-
return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
|
|
1374
|
-
}
|
|
1375
|
-
if (s.tool === 'pen') {
|
|
1376
|
-
if (s.points.length === 1) {
|
|
1377
|
-
const p = s.points[0] as WorldPoint;
|
|
1378
|
-
return Math.hypot(wx - p[0], wy - p[1]) <= t;
|
|
1379
|
-
}
|
|
1380
|
-
for (let i = 1; i < s.points.length; i++) {
|
|
1381
|
-
const a = s.points[i - 1] as WorldPoint;
|
|
1382
|
-
const b = s.points[i] as WorldPoint;
|
|
1383
|
-
if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
|
|
1384
|
-
}
|
|
1385
|
-
return false;
|
|
1386
|
-
}
|
|
1387
|
-
if (s.tool === 'arrow') {
|
|
1388
|
-
return pointSegmentDist(wx, wy, s.x1, s.y1, s.x2, s.y2) <= t;
|
|
1389
|
-
}
|
|
1390
|
-
if (s.tool === 'ellipse') {
|
|
1391
|
-
// Inside-ellipse hit when filled; on the perimeter otherwise.
|
|
1392
|
-
if (s.rx <= 0 || s.ry <= 0) return false;
|
|
1393
|
-
const nx = (wx - s.cx) / s.rx;
|
|
1394
|
-
const ny = (wy - s.cy) / s.ry;
|
|
1395
|
-
const d = nx * nx + ny * ny;
|
|
1396
|
-
if (s.fill) return d <= 1.0 + t / Math.max(s.rx, s.ry);
|
|
1397
|
-
// Stroke-only: hit if normalized distance is within a band around 1.
|
|
1398
|
-
const band = t / Math.max(s.rx, s.ry);
|
|
1399
|
-
const dist = Math.abs(Math.sqrt(d) - 1);
|
|
1400
|
-
return dist <= band;
|
|
1401
|
-
}
|
|
1402
|
-
if (s.tool === 'polygon') {
|
|
1403
|
-
const nx = Math.min(s.x, s.x + s.w);
|
|
1404
|
-
const ny = Math.min(s.y, s.y + s.h);
|
|
1405
|
-
const pts = polygonVertices(s.shape, nx, ny, Math.abs(s.w), Math.abs(s.h));
|
|
1406
|
-
// Filled → inside-hit; always allow an edge-proximity hit (covers the
|
|
1407
|
-
// stroke-only outline + a tolerance band on a filled shape).
|
|
1408
|
-
if (s.fill && pointInPolygon(wx, wy, pts)) return true;
|
|
1409
|
-
for (let i = 0; i < pts.length; i++) {
|
|
1410
|
-
const a = pts[i] as [number, number];
|
|
1411
|
-
const b = pts[(i + 1) % pts.length] as [number, number];
|
|
1412
|
-
if (pointSegmentDist(wx, wy, a[0], a[1], b[0], b[1]) <= t) return true;
|
|
1413
|
-
}
|
|
1414
|
-
return false;
|
|
1415
|
-
}
|
|
1416
|
-
// rect — inside when filled, edge-only otherwise.
|
|
1417
|
-
const x = s.x;
|
|
1418
|
-
const y = s.y;
|
|
1419
|
-
const x2 = x + s.w;
|
|
1420
|
-
const y2 = y + s.h;
|
|
1421
|
-
const xMin = Math.min(x, x2);
|
|
1422
|
-
const xMax = Math.max(x, x2);
|
|
1423
|
-
const yMin = Math.min(y, y2);
|
|
1424
|
-
const yMax = Math.max(y, y2);
|
|
1425
|
-
if (s.fill) {
|
|
1426
|
-
return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
|
|
1427
|
-
}
|
|
1428
|
-
if (wx < xMin - t || wx > xMax + t) return false;
|
|
1429
|
-
if (wy < yMin - t || wy > yMax + t) return false;
|
|
1430
|
-
const onLeft = Math.abs(wx - x) <= t;
|
|
1431
|
-
const onRight = Math.abs(wx - x2) <= t;
|
|
1432
|
-
const onTop = Math.abs(wy - y) <= t;
|
|
1433
|
-
const onBottom = Math.abs(wy - y2) <= t;
|
|
1434
|
-
return onLeft || onRight || onTop || onBottom;
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
|
-
/** Flip a negative-extent box so x/y is the top-left and w/h are positive. */
|
|
1438
|
-
function normalizeBox<T extends { x: number; y: number; w: number; h: number }>(r: T): T {
|
|
1439
|
-
if (r.w >= 0 && r.h >= 0) return r;
|
|
1440
|
-
return {
|
|
1441
|
-
...r,
|
|
1442
|
-
x: Math.min(r.x, r.x + r.w),
|
|
1443
|
-
y: Math.min(r.y, r.y + r.h),
|
|
1444
|
-
w: Math.abs(r.w),
|
|
1445
|
-
h: Math.abs(r.h),
|
|
1446
|
-
};
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
/**
|
|
1450
|
-
* Phase 24 — draw-time resize modifiers (FigJam parity, mirror of the
|
|
1451
|
-
* `use-annotation-resize.tsx` set so create + resize feel identical):
|
|
1452
|
-
* • `shift` — lock to 1:1 (square / circle); the larger drag axis sets the
|
|
1453
|
-
* side, each axis keeps its own sign so the drag direction holds.
|
|
1454
|
-
* • `alt` — grow from the pointer-down point as CENTER (symmetric).
|
|
1455
|
-
* With neither held the box is `{ x: down, w: cursor − down }` — byte-identical
|
|
1456
|
-
* to the previous corner-drag math.
|
|
1457
|
-
*/
|
|
1458
|
-
export interface DrawMods {
|
|
1459
|
-
shift: boolean;
|
|
1460
|
-
alt: boolean;
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
/** Constrain a draw drag (`ax,ay` = pointer-down anchor; `wx,wy` = cursor). */
|
|
1464
|
-
export function constrainDrawBox(
|
|
1465
|
-
ax: number,
|
|
1466
|
-
ay: number,
|
|
1467
|
-
wx: number,
|
|
1468
|
-
wy: number,
|
|
1469
|
-
mods: DrawMods
|
|
1470
|
-
): { x: number; y: number; w: number; h: number } {
|
|
1471
|
-
let dx = wx - ax;
|
|
1472
|
-
let dy = wy - ay;
|
|
1473
|
-
if (mods.shift) {
|
|
1474
|
-
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
|
1475
|
-
dx = (dx < 0 ? -1 : 1) * side;
|
|
1476
|
-
dy = (dy < 0 ? -1 : 1) * side;
|
|
1477
|
-
}
|
|
1478
|
-
if (mods.alt) {
|
|
1479
|
-
// Anchor is the center → span ±|d| on each axis around it.
|
|
1480
|
-
return { x: ax - dx, y: ay - dy, w: 2 * dx, h: 2 * dy };
|
|
1481
|
-
}
|
|
1482
|
-
return { x: ax, y: ay, w: dx, h: dy };
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
/**
|
|
1486
|
-
* Apply the draw-time modifiers to the in-progress stroke. Shared by the
|
|
1487
|
-
* pointer-move handler and the live keydown/keyup re-apply, so holding Shift /
|
|
1488
|
-
* Alt updates the draft even without moving the cursor. `anchor` is the
|
|
1489
|
-
* pointer-down point; pen / text carry no box so they pass through unchanged.
|
|
1490
|
-
*/
|
|
1491
|
-
export function applyDrawModifiers(
|
|
1492
|
-
cur: Stroke,
|
|
1493
|
-
anchor: { x: number; y: number },
|
|
1494
|
-
wx: number,
|
|
1495
|
-
wy: number,
|
|
1496
|
-
mods: DrawMods
|
|
1497
|
-
): Stroke {
|
|
1498
|
-
if (cur.tool === 'rect' || cur.tool === 'polygon') {
|
|
1499
|
-
const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
|
|
1500
|
-
return { ...cur, x: b.x, y: b.y, w: b.w, h: b.h };
|
|
1501
|
-
}
|
|
1502
|
-
if (cur.tool === 'ellipse') {
|
|
1503
|
-
const b = constrainDrawBox(anchor.x, anchor.y, wx, wy, mods);
|
|
1504
|
-
return {
|
|
1505
|
-
...cur,
|
|
1506
|
-
cx: b.x + b.w / 2,
|
|
1507
|
-
cy: b.y + b.h / 2,
|
|
1508
|
-
rx: Math.abs(b.w) / 2,
|
|
1509
|
-
ry: Math.abs(b.h) / 2,
|
|
245
|
+
onFormatKey: (e: ReactKeyboardEvent) => boolean;
|
|
246
|
+
} {
|
|
247
|
+
const [bold, setBold] = useState(!!initial.bold);
|
|
248
|
+
const [italic, setItalic] = useState(!!initial.italic);
|
|
249
|
+
const [underline, setUnderline] = useState(!!initial.underline);
|
|
250
|
+
const [strike, setStrike] = useState(!!initial.strike);
|
|
251
|
+
// FigJam v3 — edit-mode toolbar extensions: size + alignment preview live in
|
|
252
|
+
// the editor and commit with the text (normFmt carries them through).
|
|
253
|
+
const [fontSize, setFontSize] = useState<number | undefined>(initial.fontSize);
|
|
254
|
+
const [align, setAlign] = useState<TextAlign | undefined>(initial.align);
|
|
255
|
+
const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike, fontSize, align });
|
|
256
|
+
fmtRef.current = { bold, italic, underline, strike, fontSize, align };
|
|
257
|
+
const style: CSSProperties = {
|
|
258
|
+
fontWeight: bold ? 700 : undefined,
|
|
259
|
+
fontStyle: italic ? 'italic' : undefined,
|
|
260
|
+
textDecoration: textDecoCss(strike, underline),
|
|
261
|
+
...(fontSize != null && fontSize !== initial.fontSize ? { fontSize: `${fontSize}px` } : {}),
|
|
262
|
+
...(align && align !== initial.align ? { textAlign: align } : {}),
|
|
263
|
+
};
|
|
264
|
+
// FigJam v3 — the edit-mode context toolbar drives the editor through this
|
|
265
|
+
// event (mutating the STROKE mid-edit would re-render the contentEditable
|
|
266
|
+
// and clobber typed text). The editor echoes its state back so the toolbar's
|
|
267
|
+
// pressed-states track live.
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
if (typeof document === 'undefined') return;
|
|
270
|
+
const onFmt = (e: Event) => {
|
|
271
|
+
const d = (e as CustomEvent<{ key?: string; value?: unknown }>).detail;
|
|
272
|
+
if (!d?.key) return;
|
|
273
|
+
if (d.key === 'bold') setBold((v) => !v);
|
|
274
|
+
else if (d.key === 'italic') setItalic((v) => !v);
|
|
275
|
+
else if (d.key === 'underline') setUnderline((v) => !v);
|
|
276
|
+
else if (d.key === 'strike') setStrike((v) => !v);
|
|
277
|
+
else if (d.key === 'fontSize' && typeof d.value === 'number') setFontSize(d.value);
|
|
278
|
+
else if (d.key === 'align' && typeof d.value === 'string') setAlign(d.value as TextAlign);
|
|
1510
279
|
};
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
280
|
+
document.addEventListener('maude:editor-format', onFmt);
|
|
281
|
+
return () => document.removeEventListener('maude:editor-format', onFmt);
|
|
282
|
+
}, []);
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (typeof document === 'undefined') return;
|
|
285
|
+
const broadcast = () => {
|
|
286
|
+
document.dispatchEvent(
|
|
287
|
+
new CustomEvent('maude:editor-format-state', {
|
|
288
|
+
detail: { bold, italic, underline, strike, fontSize, align },
|
|
289
|
+
})
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
broadcast();
|
|
293
|
+
// The toolbar may mount AFTER the editor's first broadcast — it asks.
|
|
294
|
+
document.addEventListener('maude:editor-format-request', broadcast);
|
|
295
|
+
return () => document.removeEventListener('maude:editor-format-request', broadcast);
|
|
296
|
+
}, [bold, italic, underline, strike, fontSize, align]);
|
|
297
|
+
const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
|
|
298
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
|
|
299
|
+
const k = e.key.toLowerCase();
|
|
300
|
+
if (k === 'b') {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
setBold((v) => !v);
|
|
303
|
+
return true;
|
|
1530
304
|
}
|
|
1531
|
-
if (
|
|
1532
|
-
|
|
1533
|
-
|
|
305
|
+
if (k === 'i') {
|
|
306
|
+
e.preventDefault();
|
|
307
|
+
setItalic((v) => !v);
|
|
308
|
+
return true;
|
|
1534
309
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
function normalizeRect(r: RectStroke): RectStroke {
|
|
1541
|
-
return normalizeBox(r);
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
// Phase 21 — sticky shares rect's drag-to-create flip (x = min, w = abs(w)).
|
|
1545
|
-
function normalizeSticky(s: StickyStroke): StickyStroke {
|
|
1546
|
-
return normalizeBox(s);
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
function isStrokeMeaningful(s: Stroke): boolean {
|
|
1550
|
-
if (s.tool === 'pen') return s.points.length >= 2;
|
|
1551
|
-
if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
1552
|
-
if (s.tool === 'polygon') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
1553
|
-
if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
|
|
1554
|
-
if (s.tool === 'text') return s.text.trim().length > 0;
|
|
1555
|
-
// Sticky below a readable floor is discarded like a 2×2 rect.
|
|
1556
|
-
if (s.tool === 'sticky')
|
|
1557
|
-
return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
|
|
1558
|
-
// Phase 23 — an image needs real extent; a link needs a non-empty URL.
|
|
1559
|
-
if (s.tool === 'image') return Math.abs(s.w) >= IMAGE_MIN_SIZE && Math.abs(s.h) >= IMAGE_MIN_SIZE;
|
|
1560
|
-
if (s.tool === 'link') return s.url.trim().length > 0;
|
|
1561
|
-
return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
export function strokeBBox(
|
|
1565
|
-
s: Stroke,
|
|
1566
|
-
anchors?: Map<string, RectStroke | EllipseStroke>
|
|
1567
|
-
): { x: number; y: number; w: number; h: number } | null {
|
|
1568
|
-
if (s.tool === 'pen') {
|
|
1569
|
-
if (!s.points.length) return null;
|
|
1570
|
-
let xMin = Number.POSITIVE_INFINITY;
|
|
1571
|
-
let xMax = Number.NEGATIVE_INFINITY;
|
|
1572
|
-
let yMin = Number.POSITIVE_INFINITY;
|
|
1573
|
-
let yMax = Number.NEGATIVE_INFINITY;
|
|
1574
|
-
for (const [px, py] of s.points) {
|
|
1575
|
-
if (px < xMin) xMin = px;
|
|
1576
|
-
if (px > xMax) xMax = px;
|
|
1577
|
-
if (py < yMin) yMin = py;
|
|
1578
|
-
if (py > yMax) yMax = py;
|
|
310
|
+
if (k === 'u') {
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
setUnderline((v) => !v);
|
|
313
|
+
return true;
|
|
1579
314
|
}
|
|
1580
|
-
return
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
return {
|
|
1584
|
-
x: Math.min(s.x, s.x + s.w),
|
|
1585
|
-
y: Math.min(s.y, s.y + s.h),
|
|
1586
|
-
w: Math.abs(s.w),
|
|
1587
|
-
h: Math.abs(s.h),
|
|
1588
|
-
};
|
|
1589
|
-
}
|
|
1590
|
-
if (s.tool === 'ellipse') {
|
|
1591
|
-
return { x: s.cx - s.rx, y: s.cy - s.ry, w: s.rx * 2, h: s.ry * 2 };
|
|
1592
|
-
}
|
|
1593
|
-
if (s.tool === 'arrow') {
|
|
1594
|
-
return {
|
|
1595
|
-
x: Math.min(s.x1, s.x2),
|
|
1596
|
-
y: Math.min(s.y1, s.y2),
|
|
1597
|
-
w: Math.abs(s.x2 - s.x1),
|
|
1598
|
-
h: Math.abs(s.y2 - s.y1),
|
|
1599
|
-
};
|
|
1600
|
-
}
|
|
1601
|
-
if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
|
|
1602
|
-
// Phase 23 — image + link are rect-shaped media, same bbox as a sticky card.
|
|
1603
|
-
return {
|
|
1604
|
-
x: Math.min(s.x, s.x + s.w),
|
|
1605
|
-
y: Math.min(s.y, s.y + s.h),
|
|
1606
|
-
w: Math.abs(s.w),
|
|
1607
|
-
h: Math.abs(s.h),
|
|
1608
|
-
};
|
|
1609
|
-
}
|
|
1610
|
-
// text — anchored inherits its host's bbox; standalone (Phase 21) gets a
|
|
1611
|
-
// synthetic bbox from its world (x, y) so it's selectable and the context
|
|
1612
|
-
// toolbar can position against it.
|
|
1613
|
-
if (s.anchorId != null && s.anchorId !== '') {
|
|
1614
|
-
const host = anchors?.get(s.anchorId);
|
|
1615
|
-
return host ? strokeBBox(host) : null;
|
|
1616
|
-
}
|
|
1617
|
-
const tx = s.x ?? 0;
|
|
1618
|
-
const ty = s.y ?? 0;
|
|
1619
|
-
// Multi-line text (item 4a) — the bbox spans the longest line (width) and all
|
|
1620
|
-
// lines (height) so the selection halo / hit-test / eraser cover the whole
|
|
1621
|
-
// block, not just line one. List markers widen each line by 2–3 chars.
|
|
1622
|
-
const lines = splitTextLines(s.text);
|
|
1623
|
-
const markerPad = s.listType ? 3 : 0;
|
|
1624
|
-
const longest = lines.reduce((m, l) => Math.max(m, l.length + markerPad), 0);
|
|
1625
|
-
return {
|
|
1626
|
-
x: tx,
|
|
1627
|
-
y: ty,
|
|
1628
|
-
w: Math.max(8, longest * s.fontSize * 0.55),
|
|
1629
|
-
// Single-line keeps the legacy 1.2 height; multi-line grows by line count.
|
|
1630
|
-
h: lines.length <= 1 ? s.fontSize * 1.2 : lines.length * s.fontSize * TEXT_LINE_HEIGHT,
|
|
1631
|
-
};
|
|
315
|
+
return false;
|
|
316
|
+
}, []);
|
|
317
|
+
return { fmtRef, style, onFormatKey };
|
|
1632
318
|
}
|
|
1633
319
|
|
|
320
|
+
// Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
|
|
321
|
+
// Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
|
|
322
|
+
// and the byte-identical canary keep working.
|
|
323
|
+
export { arrowHeadPoints } from './canvas-arrowheads.ts';
|
|
324
|
+
|
|
1634
325
|
function isEditable(t: EventTarget | null): boolean {
|
|
1635
326
|
if (!t || !(t as HTMLElement).tagName) return false;
|
|
1636
327
|
const el = t as HTMLElement;
|
|
@@ -1862,6 +553,10 @@ const ANNOT_CSS = `
|
|
|
1862
553
|
See DDR-067. (No backticks in this comment: the whole block is a JS template
|
|
1863
554
|
literal, so a backtick here would terminate the string.) */
|
|
1864
555
|
.dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
|
|
556
|
+
/* FigJam v3 — connection dots on a selected bindable shape. The important flag
|
|
557
|
+
beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
|
|
558
|
+
resize handles — DDR-067). */
|
|
559
|
+
.dc-annot-conn-dot { cursor: crosshair !important; }
|
|
1865
560
|
`.trim();
|
|
1866
561
|
|
|
1867
562
|
function ensureAnnotStyles(): void {
|
|
@@ -1883,6 +578,36 @@ export interface StrokesStoreValue {
|
|
|
1883
578
|
updateStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
1884
579
|
deleteStrokes: (ids: string[]) => void;
|
|
1885
580
|
translateStrokes: (ids: string[], dx: number, dy: number) => void;
|
|
581
|
+
/**
|
|
582
|
+
* FigJam v3 — bulk mutation in ONE undo record (the per-stroke
|
|
583
|
+
* `updateStroke` loop the context toolbar used pre-v3 pushed N records for
|
|
584
|
+
* an N-stroke selection). `fn` returns the patch for a stroke or null to
|
|
585
|
+
* leave it untouched.
|
|
586
|
+
*/
|
|
587
|
+
applyToStrokes: (
|
|
588
|
+
ids: readonly string[],
|
|
589
|
+
fn: (s: Stroke) => Partial<Stroke> | null,
|
|
590
|
+
label?: string
|
|
591
|
+
) => void;
|
|
592
|
+
/** Group the (expanded) selection; returns the member ids to select, or null. */
|
|
593
|
+
groupSelection: (ids: readonly string[]) => string[] | null;
|
|
594
|
+
/** Dissolve the outermost group of every selected stroke. */
|
|
595
|
+
ungroupSelection: (ids: readonly string[]) => void;
|
|
596
|
+
/** Cmd+D / paste — clone with fresh ids; returns the clone ids to select. */
|
|
597
|
+
duplicateSelection: (ids: readonly string[], dx: number, dy: number) => string[];
|
|
598
|
+
/** Z-order — `]` `[` `Cmd+]` `Cmd+[`; group units move contiguously. */
|
|
599
|
+
reorderSelection: (ids: readonly string[], op: ZOrderOp) => void;
|
|
600
|
+
alignSelection: (ids: readonly string[], edge: AlignEdge) => void;
|
|
601
|
+
distributeSelection: (ids: readonly string[], axis: DistributeAxis) => void;
|
|
602
|
+
/**
|
|
603
|
+
* Wave H — transient gesture preview: applies the patch to local React
|
|
604
|
+
* state ONLY (no undo record, no persistence), exactly like the move-drag's
|
|
605
|
+
* per-tick path. Close the gesture with `commitGesture` on pointerup so the
|
|
606
|
+
* whole drag lands as ONE undo record (undo used to walk every resize px).
|
|
607
|
+
*/
|
|
608
|
+
previewStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
609
|
+
/** Wave H — single undo record from a preview gesture's start snapshot. */
|
|
610
|
+
commitGesture: (before: readonly Stroke[], label?: string) => void;
|
|
1886
611
|
}
|
|
1887
612
|
|
|
1888
613
|
const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
|
|
@@ -1891,37 +616,33 @@ export function useStrokesStore(): StrokesStoreValue | null {
|
|
|
1891
616
|
return useContext(StrokesStoreContext);
|
|
1892
617
|
}
|
|
1893
618
|
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
if (s.tool === 'rect' || s.tool === 'polygon') return { ...s, x: s.x + dx, y: s.y + dy };
|
|
1899
|
-
if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
|
|
1900
|
-
if (s.tool === 'arrow')
|
|
1901
|
-
return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
|
|
1902
|
-
if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link')
|
|
1903
|
-
return { ...s, x: s.x + dx, y: s.y + dy };
|
|
1904
|
-
// text — anchored inherits its host's bbox (moves with the host); standalone
|
|
1905
|
-
// (Phase 21) carries its own world (x, y) and translates directly.
|
|
1906
|
-
if (s.anchorId != null && s.anchorId !== '') return s;
|
|
1907
|
-
return { ...s, x: (s.x ?? 0) + dx, y: (s.y ?? 0) + dy };
|
|
1908
|
-
}
|
|
619
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
620
|
+
// FigJam v3 — one-time contextual hints (first-use discoverability). Behaviour-
|
|
621
|
+
// triggered micro-toasts, never a modal tour: each key fires at most once per
|
|
622
|
+
// browser profile (localStorage bitmap), reusing the existing canvas toast.
|
|
1909
623
|
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
if (
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
624
|
+
// Chrome elements never deselect. Includes the per-shape context toolbar,
|
|
625
|
+
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
626
|
+
// right-click menu. Clicks on these route to their own handlers.
|
|
627
|
+
const CHROME_SELECTOR =
|
|
628
|
+
'.dc-annot-conn-dot, .dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-rotate-zone, .dc-annot-editor';
|
|
629
|
+
|
|
630
|
+
const HINTS_KEY = 'maude-annot-hints-v1';
|
|
631
|
+
|
|
632
|
+
function showOnceHint(key: string, msg: string): void {
|
|
633
|
+
if (typeof window === 'undefined') return;
|
|
634
|
+
try {
|
|
635
|
+
const seen = JSON.parse(window.localStorage.getItem(HINTS_KEY) || '{}') as Record<
|
|
636
|
+
string,
|
|
637
|
+
number
|
|
638
|
+
>;
|
|
639
|
+
if (seen[key]) return;
|
|
640
|
+
seen[key] = 1;
|
|
641
|
+
window.localStorage.setItem(HINTS_KEY, JSON.stringify(seen));
|
|
642
|
+
} catch {
|
|
643
|
+
return; // storage blocked — skip rather than re-toast forever
|
|
1923
644
|
}
|
|
1924
|
-
|
|
645
|
+
showCanvasToast(msg);
|
|
1925
646
|
}
|
|
1926
647
|
|
|
1927
648
|
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
@@ -1988,6 +709,16 @@ export function AnnotationsLayer() {
|
|
|
1988
709
|
// shape/sticky/text tool is armed and nothing is being drawn yet. Pure chrome
|
|
1989
710
|
// (low-opacity, pointer-events:none) — never selectable, hit-tested, or saved.
|
|
1990
711
|
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
|
|
712
|
+
// FigJam v3 — smart-guide lines painted while a drag is snapping, and the
|
|
713
|
+
// id of the host a dragged arrow endpoint would bind to (accent halo).
|
|
714
|
+
const [snapGuides, setSnapGuides] = useState<SnapGuide[] | null>(null);
|
|
715
|
+
const [bindHintId, setBindHintId] = useState<string | null>(null);
|
|
716
|
+
// Cmd/Ctrl held — suppresses binding at arrow draw-end (FigJam: ⌘ keeps the
|
|
717
|
+
// endpoint free). Tracked here because endStroke (pointerup) carries no
|
|
718
|
+
// modifier state of its own.
|
|
719
|
+
const cmdHeldRef = useRef(false);
|
|
720
|
+
const vpRef = useRef(vp);
|
|
721
|
+
vpRef.current = vp;
|
|
1991
722
|
const visibilityCtx = useAnnotationsVisibility();
|
|
1992
723
|
const visible = visibilityCtx?.visible ?? true;
|
|
1993
724
|
const setVisible = useCallback(
|
|
@@ -2026,6 +757,7 @@ export function AnnotationsLayer() {
|
|
|
2026
757
|
tool === 'shape' ||
|
|
2027
758
|
tool === 'arrow' ||
|
|
2028
759
|
tool === 'sticky' ||
|
|
760
|
+
tool === 'section' ||
|
|
2029
761
|
tool === 'text';
|
|
2030
762
|
const isErase = tool === 'eraser';
|
|
2031
763
|
const isActive = isDraw || isErase;
|
|
@@ -2182,30 +914,126 @@ export function AnnotationsLayer() {
|
|
|
2182
914
|
const strokesStore = useMemo<StrokesStoreValue>(() => {
|
|
2183
915
|
const updateStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
2184
916
|
const prev = strokesRef.current;
|
|
2185
|
-
|
|
917
|
+
// FigJam v3 — bound arrow endpoints re-derive from their host after ANY
|
|
918
|
+
// geometry patch (resize ticks included), so connectors track live.
|
|
919
|
+
const next = recomputeBoundArrows(
|
|
920
|
+
prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
|
|
921
|
+
);
|
|
2186
922
|
commitStrokes(prev, next);
|
|
2187
923
|
};
|
|
2188
924
|
const deleteStrokes = (ids: string[]): void => {
|
|
2189
925
|
const set = new Set(ids);
|
|
2190
926
|
const prev = strokesRef.current;
|
|
2191
|
-
const
|
|
927
|
+
const filtered = prev.filter(
|
|
2192
928
|
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
2193
929
|
);
|
|
2194
|
-
if (
|
|
2195
|
-
|
|
930
|
+
if (filtered.length === prev.length) return;
|
|
931
|
+
// FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
|
|
932
|
+
// arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
|
|
933
|
+
commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
|
|
2196
934
|
};
|
|
2197
935
|
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
2198
936
|
const set = new Set(ids);
|
|
2199
937
|
const prev = strokesRef.current;
|
|
2200
|
-
const next =
|
|
938
|
+
const next = recomputeBoundArrows(
|
|
939
|
+
prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s))
|
|
940
|
+
);
|
|
2201
941
|
commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
|
|
2202
942
|
};
|
|
943
|
+
const applyToStrokes = (
|
|
944
|
+
ids: readonly string[],
|
|
945
|
+
fn: (s: Stroke) => Partial<Stroke> | null,
|
|
946
|
+
label?: string
|
|
947
|
+
): void => {
|
|
948
|
+
const set = new Set(ids);
|
|
949
|
+
const prev = strokesRef.current;
|
|
950
|
+
let touched = 0;
|
|
951
|
+
const next = recomputeBoundArrows(
|
|
952
|
+
prev.map((s) => {
|
|
953
|
+
if (!set.has(s.id)) return s;
|
|
954
|
+
const patch = fn(s);
|
|
955
|
+
if (!patch) return s;
|
|
956
|
+
touched++;
|
|
957
|
+
return { ...s, ...patch } as Stroke;
|
|
958
|
+
})
|
|
959
|
+
);
|
|
960
|
+
if (touched === 0) return;
|
|
961
|
+
commitStrokes(prev, next, label ?? `edit ${touched} stroke${touched === 1 ? '' : 's'}`);
|
|
962
|
+
};
|
|
963
|
+
const groupSelection = (ids: readonly string[]): string[] | null => {
|
|
964
|
+
const prev = strokesRef.current;
|
|
965
|
+
const res = groupStrokes(prev, ids);
|
|
966
|
+
if (!res) return null;
|
|
967
|
+
commitStrokes(prev, res.strokes, `group ${res.memberIds.length} strokes`);
|
|
968
|
+
return res.memberIds;
|
|
969
|
+
};
|
|
970
|
+
const ungroupSelection = (ids: readonly string[]): void => {
|
|
971
|
+
const prev = strokesRef.current;
|
|
972
|
+
const next = ungroupStrokes(prev, ids);
|
|
973
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
974
|
+
commitStrokes(prev, next, 'ungroup');
|
|
975
|
+
};
|
|
976
|
+
const duplicateSelection = (ids: readonly string[], dx: number, dy: number): string[] => {
|
|
977
|
+
const prev = strokesRef.current;
|
|
978
|
+
const res = duplicateStrokes(prev, ids, dx, dy);
|
|
979
|
+
if (res.strokes.length === prev.length) return [];
|
|
980
|
+
const added = res.strokes.length - prev.length;
|
|
981
|
+
commitStrokes(prev, res.strokes, `duplicate ${added} stroke${added === 1 ? '' : 's'}`);
|
|
982
|
+
return res.newIds;
|
|
983
|
+
};
|
|
984
|
+
const reorderSelection = (ids: readonly string[], op: ZOrderOp): void => {
|
|
985
|
+
const prev = strokesRef.current;
|
|
986
|
+
const next = reorderStrokes(prev, ids, op);
|
|
987
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
988
|
+
commitStrokes(
|
|
989
|
+
prev,
|
|
990
|
+
next,
|
|
991
|
+
op === 'front' || op === 'forward' ? 'bring forward' : 'send backward'
|
|
992
|
+
);
|
|
993
|
+
};
|
|
994
|
+
const alignSelection = (ids: readonly string[], edge: AlignEdge): void => {
|
|
995
|
+
const prev = strokesRef.current;
|
|
996
|
+
const next = recomputeBoundArrows(alignStrokes(prev, ids, edge));
|
|
997
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
998
|
+
commitStrokes(prev, next, `align ${edge}`);
|
|
999
|
+
};
|
|
1000
|
+
const distributeSelection = (ids: readonly string[], axis: DistributeAxis): void => {
|
|
1001
|
+
const prev = strokesRef.current;
|
|
1002
|
+
const next = recomputeBoundArrows(distributeStrokes(prev, ids, axis));
|
|
1003
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
1004
|
+
commitStrokes(prev, next, 'distribute');
|
|
1005
|
+
};
|
|
1006
|
+
// Wave H — transient per-tick path for handle drags (resize / rotate /
|
|
1007
|
+
// endpoint re-anchor). Local React state only — no undo push, no PUT —
|
|
1008
|
+
// mirroring the move-drag's onMove. `commitGesture` closes it as ONE
|
|
1009
|
+
// record (no-op when the gesture ended where it started).
|
|
1010
|
+
const previewStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
1011
|
+
setStrokesState(
|
|
1012
|
+
recomputeBoundArrows(
|
|
1013
|
+
strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
|
|
1014
|
+
)
|
|
1015
|
+
);
|
|
1016
|
+
};
|
|
1017
|
+
const commitGesture = (before: readonly Stroke[], label?: string): void => {
|
|
1018
|
+
const cur = strokesRef.current;
|
|
1019
|
+
if (strokesShallowEqual(before, cur)) return;
|
|
1020
|
+
commitStrokes(before, cur, label);
|
|
1021
|
+
};
|
|
2203
1022
|
return {
|
|
2204
1023
|
strokes,
|
|
2205
1024
|
setStrokes,
|
|
2206
1025
|
updateStroke,
|
|
2207
1026
|
deleteStrokes,
|
|
2208
1027
|
translateStrokes,
|
|
1028
|
+
applyToStrokes,
|
|
1029
|
+
groupSelection,
|
|
1030
|
+
ungroupSelection,
|
|
1031
|
+
duplicateSelection,
|
|
1032
|
+
reorderSelection,
|
|
1033
|
+
alignSelection,
|
|
1034
|
+
distributeSelection,
|
|
1035
|
+
previewStroke,
|
|
1036
|
+
commitGesture,
|
|
2209
1037
|
};
|
|
2210
1038
|
}, [strokes, setStrokes, commitStrokes]);
|
|
2211
1039
|
|
|
@@ -2474,6 +1302,19 @@ export function AnnotationsLayer() {
|
|
|
2474
1302
|
fontSize: DEFAULT_FONT_SIZE,
|
|
2475
1303
|
cornerRadius: STICKY_CORNER_RADIUS,
|
|
2476
1304
|
});
|
|
1305
|
+
} else if (tool === 'section') {
|
|
1306
|
+
// FigJam v3 — drag-create a labelled container; a bare tap drops the
|
|
1307
|
+
// default-sized region (endStroke applies the default).
|
|
1308
|
+
setDrawing({
|
|
1309
|
+
id,
|
|
1310
|
+
tool: 'section',
|
|
1311
|
+
x: wx,
|
|
1312
|
+
y: wy,
|
|
1313
|
+
w: 0,
|
|
1314
|
+
h: 0,
|
|
1315
|
+
label: 'Section',
|
|
1316
|
+
color: DEFAULT_SECTION_COLOR,
|
|
1317
|
+
});
|
|
2477
1318
|
} else if (tool === 'text') {
|
|
2478
1319
|
// Phase 21 — single click drops an editable caret at the click point.
|
|
2479
1320
|
// No stroke is created until the user commits real text (mirrors the
|
|
@@ -2512,6 +1353,7 @@ export function AnnotationsLayer() {
|
|
|
2512
1353
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
2513
1354
|
if (!isActive || !visible) return;
|
|
2514
1355
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1356
|
+
cmdHeldRef.current = e.metaKey || e.ctrlKey;
|
|
2515
1357
|
if (isErase) {
|
|
2516
1358
|
if ((e.buttons & 1) === 0) return;
|
|
2517
1359
|
eraseAt(wx, wy);
|
|
@@ -2527,6 +1369,15 @@ export function AnnotationsLayer() {
|
|
|
2527
1369
|
lastDrawPointRef.current = { x: wx, y: wy };
|
|
2528
1370
|
const anchor = drawAnchorRef.current;
|
|
2529
1371
|
const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
|
|
1372
|
+
// FigJam v3 — while dragging an arrow, halo the host the dragged end
|
|
1373
|
+
// would magnetically attach to (⌘ suppresses binding entirely).
|
|
1374
|
+
if (drawingRef.current.tool === 'arrow') {
|
|
1375
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
1376
|
+
const cand = cmdHeldRef.current
|
|
1377
|
+
? null
|
|
1378
|
+
: bindCandidate(wx, wy, strokesRef.current, BIND_THRESHOLD_PX / zoom);
|
|
1379
|
+
setBindHintId(cand?.hostId ?? null);
|
|
1380
|
+
}
|
|
2530
1381
|
setDrawing((cur) => {
|
|
2531
1382
|
if (!cur) return cur;
|
|
2532
1383
|
if (cur.tool === 'pen') {
|
|
@@ -2578,12 +1429,51 @@ export function AnnotationsLayer() {
|
|
|
2578
1429
|
Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
|
|
2579
1430
|
? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
|
|
2580
1431
|
: norm;
|
|
1432
|
+
} else if (cur.tool === 'section') {
|
|
1433
|
+
const norm = normalizeBox(cur);
|
|
1434
|
+
final =
|
|
1435
|
+
Math.abs(norm.w) < SECTION_MIN_SIZE || Math.abs(norm.h) < SECTION_MIN_SIZE
|
|
1436
|
+
? { ...norm, w: SECTION_DEFAULT_W, h: SECTION_DEFAULT_H }
|
|
1437
|
+
: norm;
|
|
2581
1438
|
}
|
|
2582
1439
|
if (final && !isStrokeMeaningful(final)) final = null;
|
|
1440
|
+
// FigJam v3 — magnetic connector binding at draw-end. Each arrow endpoint
|
|
1441
|
+
// within the zoom-scaled threshold of a bindable host attaches to its
|
|
1442
|
+
// nearest side/center magnet and snaps onto it; the bind persists and the
|
|
1443
|
+
// endpoint re-derives from the host from then on. ⌘ held = stay free.
|
|
1444
|
+
if (final && final.tool === 'arrow' && !cmdHeldRef.current) {
|
|
1445
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
1446
|
+
const threshold = BIND_THRESHOLD_PX / zoom;
|
|
1447
|
+
const others = strokesRef.current;
|
|
1448
|
+
const sb = bindCandidate(final.x1, final.y1, others, threshold);
|
|
1449
|
+
const eb = bindCandidate(final.x2, final.y2, others, threshold);
|
|
1450
|
+
if (sb) {
|
|
1451
|
+
const host = others.find((s) => s.id === sb.hostId);
|
|
1452
|
+
const pt = host ? anchorPoint(host, sb.nx, sb.ny) : null;
|
|
1453
|
+
if (pt) final = { ...final, startBind: sb, x1: pt[0], y1: pt[1] };
|
|
1454
|
+
}
|
|
1455
|
+
// A zero-length self-loop (both ends on the same magnet) is useless —
|
|
1456
|
+
// keep the end free when it would collapse onto the start bind.
|
|
1457
|
+
const sameMagnet = sb && eb && sb.hostId === eb.hostId && sb.nx === eb.nx && sb.ny === eb.ny;
|
|
1458
|
+
if (eb && !sameMagnet) {
|
|
1459
|
+
const host = others.find((s) => s.id === eb.hostId);
|
|
1460
|
+
const pt = host ? anchorPoint(host, eb.nx, eb.ny) : null;
|
|
1461
|
+
if (pt) final = { ...final, endBind: eb, x2: pt[0], y2: pt[1] };
|
|
1462
|
+
}
|
|
1463
|
+
if ((final as ArrowStroke).startBind || (final as ArrowStroke).endBind) {
|
|
1464
|
+
showOnceHint(
|
|
1465
|
+
'bind',
|
|
1466
|
+
'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
|
|
1467
|
+
);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
setBindHintId(null);
|
|
2583
1471
|
if (final) {
|
|
2584
1472
|
const committed = final;
|
|
2585
1473
|
const prev = strokesRef.current;
|
|
2586
|
-
|
|
1474
|
+
// FigJam — sections are CONTAINERS: they slot in at the BACK of the
|
|
1475
|
+
// z-order so content placed on them keeps rendering above.
|
|
1476
|
+
const next = committed.tool === 'section' ? [committed, ...prev] : [...prev, committed];
|
|
2587
1477
|
commitStrokes(prev, next, `draw ${committed.tool}`);
|
|
2588
1478
|
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
2589
1479
|
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
@@ -2591,7 +1481,10 @@ export function AnnotationsLayer() {
|
|
|
2591
1481
|
if (annotSel) annotSel.replace(committed.id);
|
|
2592
1482
|
// Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
|
|
2593
1483
|
// note, type immediately). Only meaningful deviation from rect/ellipse.
|
|
2594
|
-
if (committed.tool === 'sticky')
|
|
1484
|
+
if (committed.tool === 'sticky') {
|
|
1485
|
+
setEditingId(committed.id);
|
|
1486
|
+
showOnceHint('chain', '⌘Enter commits and creates the next sticky beside it.');
|
|
1487
|
+
}
|
|
2595
1488
|
}
|
|
2596
1489
|
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
2597
1490
|
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
@@ -2666,9 +1559,9 @@ export function AnnotationsLayer() {
|
|
|
2666
1559
|
}, [ghost, ghostCapable, drawing, tool, shapeKind, color, stickyColor]);
|
|
2667
1560
|
|
|
2668
1561
|
const anchorsById = useMemo(() => {
|
|
2669
|
-
const map = new Map<string,
|
|
1562
|
+
const map = new Map<string, AnchorHost>();
|
|
2670
1563
|
for (const s of strokes) {
|
|
2671
|
-
if (s.tool === 'rect' || s.tool === 'ellipse') map.set(s.id, s);
|
|
1564
|
+
if (s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon') map.set(s.id, s);
|
|
2672
1565
|
}
|
|
2673
1566
|
return map;
|
|
2674
1567
|
}, [strokes]);
|
|
@@ -2700,6 +1593,11 @@ export function AnnotationsLayer() {
|
|
|
2700
1593
|
startWY: number;
|
|
2701
1594
|
movedIds: string[];
|
|
2702
1595
|
snapshot: Stroke[];
|
|
1596
|
+
/** FigJam v3 — pre-Alt-duplicate baseline. Differs from `snapshot` only
|
|
1597
|
+
* during an Alt+drag duplicate; the undo record spans undoBase → final
|
|
1598
|
+
* so clone + move land as ONE step. */
|
|
1599
|
+
undoBase: Stroke[];
|
|
1600
|
+
altDup: boolean;
|
|
2703
1601
|
} | null>(null);
|
|
2704
1602
|
|
|
2705
1603
|
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
@@ -2732,19 +1630,14 @@ export function AnnotationsLayer() {
|
|
|
2732
1630
|
t === 'text' ||
|
|
2733
1631
|
t === 'sticky' ||
|
|
2734
1632
|
t === 'image' ||
|
|
2735
|
-
t === 'link'
|
|
1633
|
+
t === 'link' ||
|
|
1634
|
+
t === 'section')
|
|
2736
1635
|
) {
|
|
2737
1636
|
return id;
|
|
2738
1637
|
}
|
|
2739
1638
|
return null;
|
|
2740
1639
|
};
|
|
2741
1640
|
|
|
2742
|
-
// Chrome elements never deselect. Includes the per-shape context toolbar,
|
|
2743
|
-
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
2744
|
-
// right-click menu. Clicks on these route to their own handlers.
|
|
2745
|
-
const CHROME_SELECTOR =
|
|
2746
|
-
'.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-editor';
|
|
2747
|
-
|
|
2748
1641
|
const onDown = (e: PointerEvent) => {
|
|
2749
1642
|
if (e.button !== 0) return;
|
|
2750
1643
|
if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
|
|
@@ -2763,16 +1656,20 @@ export function AnnotationsLayer() {
|
|
|
2763
1656
|
let ids: string[] | null = null;
|
|
2764
1657
|
if (strokeId) {
|
|
2765
1658
|
elementSel?.clear();
|
|
1659
|
+
// FigJam v3 — clicking a group member acts on the WHOLE outermost
|
|
1660
|
+
// group. Double-click deep-selects the member (see the dblclick
|
|
1661
|
+
// handler); an already-selected stroke keeps the current selection, so
|
|
1662
|
+
// a deep-selected member drags alone without re-expanding.
|
|
1663
|
+
const members = expandIdsToGroups([strokeId], strokesStoreRef.current.strokes);
|
|
2766
1664
|
if (e.shiftKey) {
|
|
2767
|
-
annotSel.add(
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
: [...annotSel.selectedIds, strokeId];
|
|
1665
|
+
annotSel.add(members);
|
|
1666
|
+
const merged = new Set([...annotSel.selectedIds, ...members]);
|
|
1667
|
+
ids = [...merged];
|
|
2771
1668
|
} else if (annotSel.contains(strokeId)) {
|
|
2772
1669
|
ids = annotSel.selectedIds;
|
|
2773
1670
|
} else {
|
|
2774
|
-
annotSel.replace(
|
|
2775
|
-
ids =
|
|
1671
|
+
annotSel.replace(members);
|
|
1672
|
+
ids = members;
|
|
2776
1673
|
}
|
|
2777
1674
|
} else if (!e.shiftKey && annotSel.selectedIds.length > 0) {
|
|
2778
1675
|
// Hull hit-test — union bbox of the currently-selected strokes.
|
|
@@ -2793,7 +1690,7 @@ export function AnnotationsLayer() {
|
|
|
2793
1690
|
ids = annotSel.selectedIds;
|
|
2794
1691
|
}
|
|
2795
1692
|
}
|
|
2796
|
-
if (ids
|
|
1693
|
+
if (ids?.length) {
|
|
2797
1694
|
e.preventDefault();
|
|
2798
1695
|
e.stopImmediatePropagation();
|
|
2799
1696
|
// Capture a snapshot of all strokes at drag start. Every pointermove
|
|
@@ -2801,27 +1698,118 @@ export function AnnotationsLayer() {
|
|
|
2801
1698
|
// (NOT a delta-from-last-frame mutation), so dragging back to origin
|
|
2802
1699
|
// restores positions exactly. Optimistic state-only updates during
|
|
2803
1700
|
// the move; ONE undo record + ONE server PUT fires on pointerup.
|
|
2804
|
-
const
|
|
1701
|
+
const undoBase = strokesRef.current.slice();
|
|
1702
|
+
const preAltIds = ids;
|
|
1703
|
+
let dragSnapshot = undoBase;
|
|
1704
|
+
let altDup = false;
|
|
1705
|
+
// FigJam v3 — Alt+drag duplicates: clone the (expanded) selection up
|
|
1706
|
+
// front and drag the CLONES; a zero-movement release reverts so a bare
|
|
1707
|
+
// Alt+click can't silently mint copies. undoBase stays pre-clone, so
|
|
1708
|
+
// clone + move commit as one record.
|
|
1709
|
+
if (e.altKey) {
|
|
1710
|
+
const res = duplicateStrokes(undoBase, ids, 0, 0);
|
|
1711
|
+
if (res.newIds.length) {
|
|
1712
|
+
altDup = true;
|
|
1713
|
+
dragSnapshot = res.strokes;
|
|
1714
|
+
setStrokesState(res.strokes);
|
|
1715
|
+
annotSel.replace(res.newIds);
|
|
1716
|
+
ids = res.newIds;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
2805
1719
|
dragStateRef.current = {
|
|
2806
1720
|
pointerId: e.pointerId,
|
|
2807
1721
|
startWX: wx,
|
|
2808
1722
|
startWY: wy,
|
|
2809
1723
|
movedIds: ids,
|
|
2810
1724
|
snapshot: dragSnapshot,
|
|
1725
|
+
undoBase,
|
|
1726
|
+
altDup,
|
|
2811
1727
|
};
|
|
2812
1728
|
const movedSet = new Set(ids);
|
|
1729
|
+
// FigJam v3 — dragging a SECTION carries everything sitting on it
|
|
1730
|
+
// (bbox-center containment, captured at gesture start).
|
|
1731
|
+
for (const s of dragSnapshot) {
|
|
1732
|
+
if (s.tool !== 'section' || !movedSet.has(s.id)) continue;
|
|
1733
|
+
const sx = Math.min(s.x, s.x + s.w);
|
|
1734
|
+
const sy = Math.min(s.y, s.y + s.h);
|
|
1735
|
+
const sx2 = sx + Math.abs(s.w);
|
|
1736
|
+
const sy2 = sy + Math.abs(s.h);
|
|
1737
|
+
for (const t of dragSnapshot) {
|
|
1738
|
+
if (movedSet.has(t.id) || t.tool === 'section') continue;
|
|
1739
|
+
const bb = strokeBBox(t);
|
|
1740
|
+
if (!bb) continue;
|
|
1741
|
+
const ccx = bb.x + bb.w / 2;
|
|
1742
|
+
const ccy = bb.y + bb.h / 2;
|
|
1743
|
+
if (ccx >= sx && ccx <= sx2 && ccy >= sy && ccy <= sy2) movedSet.add(t.id);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
// FigJam v3 — snap setup, computed ONCE per gesture: candidates are
|
|
1747
|
+
// the bboxes of every non-moved stroke plus the artboard rects (in
|
|
1748
|
+
// world coords); the moving hull is the union bbox of the dragged
|
|
1749
|
+
// strokes at drag start.
|
|
1750
|
+
const candidates: Array<{ x: number; y: number; w: number; h: number }> = [];
|
|
1751
|
+
for (const s of dragSnapshot) {
|
|
1752
|
+
if (movedSet.has(s.id)) continue;
|
|
1753
|
+
const bb = strokeBBox(s);
|
|
1754
|
+
if (bb && bb.w > 0 && bb.h > 0) candidates.push(bb);
|
|
1755
|
+
}
|
|
1756
|
+
for (const screenEl of Array.from(document.querySelectorAll('[data-dc-screen]'))) {
|
|
1757
|
+
const r = screenEl.getBoundingClientRect();
|
|
1758
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
1759
|
+
const [ax, ay] = screenToWorld(r.left, r.top);
|
|
1760
|
+
const [bx, by] = screenToWorld(r.right, r.bottom);
|
|
1761
|
+
candidates.push({ x: ax, y: ay, w: bx - ax, h: by - ay });
|
|
1762
|
+
}
|
|
1763
|
+
let hull: { x: number; y: number; w: number; h: number } | null = null;
|
|
1764
|
+
for (const s of dragSnapshot) {
|
|
1765
|
+
if (!movedSet.has(s.id)) continue;
|
|
1766
|
+
const bb = strokeBBox(s);
|
|
1767
|
+
if (!bb) continue;
|
|
1768
|
+
if (!hull) {
|
|
1769
|
+
hull = { ...bb };
|
|
1770
|
+
} else {
|
|
1771
|
+
const hx = Math.min(hull.x, bb.x);
|
|
1772
|
+
const hy = Math.min(hull.y, bb.y);
|
|
1773
|
+
hull = {
|
|
1774
|
+
x: hx,
|
|
1775
|
+
y: hy,
|
|
1776
|
+
w: Math.max(hull.x + hull.w, bb.x + bb.w) - hx,
|
|
1777
|
+
h: Math.max(hull.y + hull.h, bb.y + bb.h) - hy,
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
2813
1782
|
const onMove = (mv: PointerEvent) => {
|
|
2814
1783
|
const st = dragStateRef.current;
|
|
2815
1784
|
if (!st || mv.pointerId !== st.pointerId) return;
|
|
2816
1785
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
2817
|
-
|
|
2818
|
-
|
|
1786
|
+
let dx = cwx - st.startWX;
|
|
1787
|
+
let dy = cwy - st.startWY;
|
|
1788
|
+
// FigJam v3 — edge/center snapping; ⌘ suppresses (Figma convention).
|
|
1789
|
+
// Axes with no smart-guide match fall back to the 24px dot grid
|
|
1790
|
+
// (GRID_PITCH_PX = the DS --canvas-grid pitch).
|
|
1791
|
+
if (hull && !(mv.metaKey || mv.ctrlKey)) {
|
|
1792
|
+
const snap = computeSnap(
|
|
1793
|
+
{ x: hull.x + dx, y: hull.y + dy, w: hull.w, h: hull.h },
|
|
1794
|
+
candidates,
|
|
1795
|
+
SNAP_THRESHOLD_PX / zoom,
|
|
1796
|
+
{ grid: GRID_PITCH_PX }
|
|
1797
|
+
);
|
|
1798
|
+
dx += snap.dx;
|
|
1799
|
+
dy += snap.dy;
|
|
1800
|
+
setSnapGuides(snap.guides.length ? snap.guides : null);
|
|
1801
|
+
} else {
|
|
1802
|
+
setSnapGuides(null);
|
|
1803
|
+
}
|
|
2819
1804
|
// Drag-back-to-origin: restore exact references so the pointerup
|
|
2820
|
-
// shallow-equality check skips committing a no-op record.
|
|
1805
|
+
// shallow-equality check skips committing a no-op record. Bound
|
|
1806
|
+
// arrows re-derive from their hosts so connectors track live.
|
|
2821
1807
|
const next =
|
|
2822
1808
|
dx === 0 && dy === 0
|
|
2823
1809
|
? st.snapshot
|
|
2824
|
-
:
|
|
1810
|
+
: recomputeBoundArrows(
|
|
1811
|
+
st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s))
|
|
1812
|
+
);
|
|
2825
1813
|
// Local React state only. No commitStrokes — no PUT, no undo push.
|
|
2826
1814
|
setStrokesState(next);
|
|
2827
1815
|
};
|
|
@@ -2829,17 +1817,27 @@ export function AnnotationsLayer() {
|
|
|
2829
1817
|
const st = dragStateRef.current;
|
|
2830
1818
|
if (!st || up.pointerId !== st.pointerId) return;
|
|
2831
1819
|
dragStateRef.current = null;
|
|
1820
|
+
setSnapGuides(null);
|
|
2832
1821
|
document.removeEventListener('pointermove', onMove, true);
|
|
2833
1822
|
document.removeEventListener('pointerup', onUp, true);
|
|
2834
1823
|
document.removeEventListener('pointercancel', onUp, true);
|
|
2835
1824
|
// Commit the gesture as ONE record. Skip on zero-movement
|
|
2836
1825
|
// (click without drag past threshold or drag back to origin).
|
|
2837
1826
|
const final = strokesRef.current;
|
|
2838
|
-
if (strokesShallowEqual(st.snapshot, final))
|
|
1827
|
+
if (strokesShallowEqual(st.snapshot, final)) {
|
|
1828
|
+
if (st.altDup) {
|
|
1829
|
+
// Alt+click without a drag — revert the eager clones.
|
|
1830
|
+
setStrokesState(st.undoBase);
|
|
1831
|
+
annotSel.replace(preAltIds);
|
|
1832
|
+
}
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
2839
1835
|
commitStrokes(
|
|
2840
|
-
st.
|
|
1836
|
+
st.undoBase,
|
|
2841
1837
|
final,
|
|
2842
|
-
|
|
1838
|
+
`${st.altDup ? 'duplicate' : 'move'} ${st.movedIds.length} stroke${
|
|
1839
|
+
st.movedIds.length === 1 ? '' : 's'
|
|
1840
|
+
}`
|
|
2843
1841
|
);
|
|
2844
1842
|
};
|
|
2845
1843
|
document.addEventListener('pointermove', onMove, true);
|
|
@@ -2852,8 +1850,10 @@ export function AnnotationsLayer() {
|
|
|
2852
1850
|
// artboard the gesture belongs to artboard-drag / element-marquee — not
|
|
2853
1851
|
// the annotation marquee (post-Wave-3 grievance G5). Checked AFTER the
|
|
2854
1852
|
// group-drag decision so a multi-selection hull-drag still wins even when
|
|
2855
|
-
// the strokes sit over an artboard.
|
|
2856
|
-
|
|
1853
|
+
// the strokes sit over an artboard. FigJam v3 exception: SHIFT+drag is
|
|
1854
|
+
// the additive annotation marquee, so it rubber-bands annotations
|
|
1855
|
+
// sitting ON an artboard instead of falling through to artboard-drag.
|
|
1856
|
+
if (!strokeId && !e.shiftKey && target?.closest?.('[data-dc-screen]')) return;
|
|
2857
1857
|
|
|
2858
1858
|
// Empty world — start a drag-select gesture. A bare click without
|
|
2859
1859
|
// moving clears annotation selection (post-Wave-3 feedback: click-to-
|
|
@@ -2900,8 +1900,11 @@ export function AnnotationsLayer() {
|
|
|
2900
1900
|
}
|
|
2901
1901
|
// Marquee that captured no strokes — preserve existing selection.
|
|
2902
1902
|
if (hits.length === 0) return;
|
|
2903
|
-
|
|
2904
|
-
|
|
1903
|
+
// FigJam v3 — a marquee touching any group member selects the whole
|
|
1904
|
+
// group (tldraw: the brush resolves to the outermost ancestor).
|
|
1905
|
+
const expanded = expandIdsToGroups(hits, strokesStoreRef.current.strokes);
|
|
1906
|
+
if (addToSelection) annotSel.add(expanded);
|
|
1907
|
+
else annotSel.replace(expanded);
|
|
2905
1908
|
};
|
|
2906
1909
|
document.addEventListener('pointermove', onMove, true);
|
|
2907
1910
|
document.addEventListener('pointerup', onUp, true);
|
|
@@ -2935,7 +1938,16 @@ export function AnnotationsLayer() {
|
|
|
2935
1938
|
const id = node.getAttribute('data-id');
|
|
2936
1939
|
const t = node.getAttribute('data-tool');
|
|
2937
1940
|
if (!id) return;
|
|
2938
|
-
|
|
1941
|
+
// FigJam v3 — double-click DEEP-SELECTS a group member (a single click
|
|
1942
|
+
// selects the whole outermost group; Esc clears back out). The editor
|
|
1943
|
+
// still opens below for text-bearing types — FigJam's enter-group-then-
|
|
1944
|
+
// edit flow in one gesture.
|
|
1945
|
+
const stroke = strokesRef.current.find((s) => s.id === id);
|
|
1946
|
+
if (stroke && outermostGroupOf(stroke) && annotSel) {
|
|
1947
|
+
e.preventDefault();
|
|
1948
|
+
annotSel.replace(id);
|
|
1949
|
+
}
|
|
1950
|
+
if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
|
|
2939
1951
|
e.preventDefault();
|
|
2940
1952
|
setEditingId(id);
|
|
2941
1953
|
return;
|
|
@@ -2947,7 +1959,7 @@ export function AnnotationsLayer() {
|
|
|
2947
1959
|
};
|
|
2948
1960
|
document.addEventListener('dblclick', onDbl, true);
|
|
2949
1961
|
return () => document.removeEventListener('dblclick', onDbl, true);
|
|
2950
|
-
}, [tool]);
|
|
1962
|
+
}, [tool, annotSel]);
|
|
2951
1963
|
|
|
2952
1964
|
const commitText = useCallback(
|
|
2953
1965
|
(anchorId: string, text: string, fmt?: EditorFmt) => {
|
|
@@ -3055,43 +2067,520 @@ export function AnnotationsLayer() {
|
|
|
3055
2067
|
[commitStrokes, color, annotSel]
|
|
3056
2068
|
);
|
|
3057
2069
|
|
|
3058
|
-
// Phase 21 — resolve what (if anything) is being edited, and route a single
|
|
3059
|
-
// commit call to the right writer. `editingId` doubles as the host id
|
|
3060
|
-
// (anchored) OR the sticky/standalone stroke id; `pendingText` is the
|
|
3061
|
-
// not-yet-born text caret.
|
|
3062
|
-
const editingTarget = useMemo<EditingTarget>(() => {
|
|
3063
|
-
if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
|
|
3064
|
-
if (!editingId) return null;
|
|
3065
|
-
const host = anchorsById.get(editingId);
|
|
3066
|
-
if (host) return { kind: 'anchored', anchorId: editingId, host };
|
|
3067
|
-
const s = strokesById.get(editingId);
|
|
3068
|
-
if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
|
|
3069
|
-
if (s?.tool === '
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
2070
|
+
// Phase 21 — resolve what (if anything) is being edited, and route a single
|
|
2071
|
+
// commit call to the right writer. `editingId` doubles as the host id
|
|
2072
|
+
// (anchored) OR the sticky/standalone stroke id; `pendingText` is the
|
|
2073
|
+
// not-yet-born text caret.
|
|
2074
|
+
const editingTarget = useMemo<EditingTarget>(() => {
|
|
2075
|
+
if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
|
|
2076
|
+
if (!editingId) return null;
|
|
2077
|
+
const host = anchorsById.get(editingId);
|
|
2078
|
+
if (host) return { kind: 'anchored', anchorId: editingId, host };
|
|
2079
|
+
const s = strokesById.get(editingId);
|
|
2080
|
+
if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
|
|
2081
|
+
if (s?.tool === 'section') return { kind: 'section', section: s };
|
|
2082
|
+
if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
|
|
2083
|
+
return { kind: 'standalone', text: s };
|
|
2084
|
+
return null;
|
|
2085
|
+
}, [pendingText, editingId, anchorsById, strokesById]);
|
|
2086
|
+
|
|
2087
|
+
const editingTargetRef = useRef(editingTarget);
|
|
2088
|
+
editingTargetRef.current = editingTarget;
|
|
2089
|
+
|
|
2090
|
+
const commitEditing = useCallback(
|
|
2091
|
+
(text: string, fmt?: EditorFmt) => {
|
|
2092
|
+
const target = editingTargetRef.current;
|
|
2093
|
+
setEditingId(null);
|
|
2094
|
+
setPendingText(null);
|
|
2095
|
+
if (!target) return;
|
|
2096
|
+
if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
|
|
2097
|
+
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
|
|
2098
|
+
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
|
|
2099
|
+
else if (target.kind === 'section') {
|
|
2100
|
+
const label = text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
|
|
2101
|
+
if (label !== target.section.label) {
|
|
2102
|
+
strokesStoreRef.current.updateStroke(target.section.id, {
|
|
2103
|
+
label,
|
|
2104
|
+
} as Partial<Stroke>);
|
|
2105
|
+
}
|
|
2106
|
+
} else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
|
|
2107
|
+
},
|
|
2108
|
+
[commitText, commitStickyText, commitStandaloneText, createStandaloneText]
|
|
2109
|
+
);
|
|
2110
|
+
|
|
2111
|
+
const cancelEditing = useCallback(() => {
|
|
2112
|
+
setEditingId(null);
|
|
2113
|
+
setPendingText(null);
|
|
2114
|
+
}, []);
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* FigJam v3 — copy/cut the (expanded) selection to the OS clipboard as a
|
|
2118
|
+
* `{"maudeStrokes":1}` JSON text payload. Shared by ⌘C/⌘X and the
|
|
2119
|
+
* right-click menu. Returns true when something was copied.
|
|
2120
|
+
*/
|
|
2121
|
+
const copySelection = useCallback(
|
|
2122
|
+
(cut: boolean): boolean => {
|
|
2123
|
+
if (!annotSel) return false;
|
|
2124
|
+
const sel = annotSel.selectedIds;
|
|
2125
|
+
if (sel.length === 0) return false;
|
|
2126
|
+
const store = strokesStoreRef.current;
|
|
2127
|
+
const expanded = new Set(expandIdsToGroups(sel, store.strokes));
|
|
2128
|
+
const payload = store.strokes.filter(
|
|
2129
|
+
(s) =>
|
|
2130
|
+
expanded.has(s.id) ||
|
|
2131
|
+
(s.tool === 'text' && s.anchorId != null && expanded.has(s.anchorId))
|
|
2132
|
+
);
|
|
2133
|
+
if (payload.length === 0) return false;
|
|
2134
|
+
try {
|
|
2135
|
+
void navigator.clipboard
|
|
2136
|
+
?.writeText(JSON.stringify({ maudeStrokes: 1, strokes: payload }))
|
|
2137
|
+
.catch(() => {
|
|
2138
|
+
/* clipboard permission denied — copy is best-effort */
|
|
2139
|
+
});
|
|
2140
|
+
} catch {
|
|
2141
|
+
/* clipboard API absent — non-fatal */
|
|
2142
|
+
}
|
|
2143
|
+
if (cut) {
|
|
2144
|
+
store.deleteStrokes([...expanded]);
|
|
2145
|
+
annotSel.clear();
|
|
2146
|
+
}
|
|
2147
|
+
return true;
|
|
2148
|
+
},
|
|
2149
|
+
[annotSel]
|
|
2150
|
+
);
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* FigJam v3 — paste a strokes JSON payload (⌘V or the right-click menu).
|
|
2154
|
+
* Round-trips through the serializer + parser so a malformed foreign payload
|
|
2155
|
+
* coerces to valid strokes or drops; clones get fresh ids + a +16/+16 offset.
|
|
2156
|
+
*/
|
|
2157
|
+
const pasteStrokesText = useCallback(
|
|
2158
|
+
(txt: string): boolean => {
|
|
2159
|
+
if (!annotSel) return false;
|
|
2160
|
+
if (!txt.startsWith('{"maudeStrokes"')) return false;
|
|
2161
|
+
let parsed: { maudeStrokes?: number; strokes?: unknown } | null = null;
|
|
2162
|
+
try {
|
|
2163
|
+
parsed = JSON.parse(txt) as { maudeStrokes?: number; strokes?: unknown };
|
|
2164
|
+
} catch {
|
|
2165
|
+
return false;
|
|
2166
|
+
}
|
|
2167
|
+
if (parsed?.maudeStrokes !== 1 || !Array.isArray(parsed.strokes)) return false;
|
|
2168
|
+
let safe: Stroke[] = [];
|
|
2169
|
+
try {
|
|
2170
|
+
safe = svgToStrokes(strokesToSvg(parsed.strokes as Stroke[]));
|
|
2171
|
+
} catch {
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
if (safe.length === 0) return false;
|
|
2175
|
+
const res = duplicateStrokes(
|
|
2176
|
+
safe,
|
|
2177
|
+
safe.map((s) => s.id),
|
|
2178
|
+
16,
|
|
2179
|
+
16
|
|
2180
|
+
);
|
|
2181
|
+
const clones = res.strokes.slice(safe.length);
|
|
2182
|
+
if (clones.length === 0) return false;
|
|
2183
|
+
const prev = strokesRef.current;
|
|
2184
|
+
// recompute keeps binds to hosts present in THIS canvas and strips the
|
|
2185
|
+
// cross-canvas danglers (endpoint frozen).
|
|
2186
|
+
const next = recomputeBoundArrows([...prev, ...clones]);
|
|
2187
|
+
commitStrokes(prev, next, `paste ${clones.length} stroke${clones.length === 1 ? '' : 's'}`);
|
|
2188
|
+
annotSel.replace(res.newIds);
|
|
2189
|
+
return true;
|
|
2190
|
+
},
|
|
2191
|
+
[annotSel, commitStrokes]
|
|
2192
|
+
);
|
|
2193
|
+
|
|
2194
|
+
/**
|
|
2195
|
+
* FigJam v3 — quick-create chain (⌘Enter): with a sticky or shape selected,
|
|
2196
|
+
* spawn a sibling of the same type/size/style to the right with its editor
|
|
2197
|
+
* active. Shapes ALSO get a bound connector source → sibling (FigJam quick-
|
|
2198
|
+
* create: shapes connect, stickies don't). Returns true when spawned.
|
|
2199
|
+
*/
|
|
2200
|
+
const chainCreate = useCallback(
|
|
2201
|
+
(sourceId: string): boolean => {
|
|
2202
|
+
const prev = strokesRef.current;
|
|
2203
|
+
const src = prev.find((s) => s.id === sourceId);
|
|
2204
|
+
if (!src) return false;
|
|
2205
|
+
if (
|
|
2206
|
+
src.tool !== 'sticky' &&
|
|
2207
|
+
src.tool !== 'rect' &&
|
|
2208
|
+
src.tool !== 'ellipse' &&
|
|
2209
|
+
src.tool !== 'polygon'
|
|
2210
|
+
) {
|
|
2211
|
+
return false;
|
|
2212
|
+
}
|
|
2213
|
+
const bb = strokeBBox(src);
|
|
2214
|
+
if (!bb) return false;
|
|
2215
|
+
const gap = src.tool === 'sticky' ? 40 : 64;
|
|
2216
|
+
const nid = rid();
|
|
2217
|
+
// The sibling copies style + size but starts loose (no group membership,
|
|
2218
|
+
// human provenance) and empty-bodied. Undefined-assignment (not rest-
|
|
2219
|
+
// destructuring) keeps the discriminated-union narrowing intact for the
|
|
2220
|
+
// branches below; the serializer treats undefined as absent.
|
|
2221
|
+
const bare = structuredClone(src);
|
|
2222
|
+
bare.groupIds = undefined;
|
|
2223
|
+
bare.author = undefined;
|
|
2224
|
+
let sibling: Stroke;
|
|
2225
|
+
if (bare.tool === 'ellipse') {
|
|
2226
|
+
sibling = { ...bare, id: nid, cx: bare.cx + bb.w + gap };
|
|
2227
|
+
} else if (bare.tool === 'sticky') {
|
|
2228
|
+
sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y, text: '' };
|
|
2229
|
+
} else {
|
|
2230
|
+
sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y };
|
|
2231
|
+
}
|
|
2232
|
+
let next: Stroke[] = [...prev, sibling];
|
|
2233
|
+
if (src.tool !== 'sticky') {
|
|
2234
|
+
const p1 = anchorPoint(src, 1, 0.5);
|
|
2235
|
+
const p2 = anchorPoint(sibling, 0, 0.5);
|
|
2236
|
+
if (p1 && p2) {
|
|
2237
|
+
next = [
|
|
2238
|
+
...next,
|
|
2239
|
+
{
|
|
2240
|
+
id: rid(),
|
|
2241
|
+
tool: 'arrow',
|
|
2242
|
+
color: resolveDefaultInk(theme),
|
|
2243
|
+
width: STROKE_WIDTH_THIN,
|
|
2244
|
+
x1: p1[0],
|
|
2245
|
+
y1: p1[1],
|
|
2246
|
+
x2: p2[0],
|
|
2247
|
+
y2: p2[1],
|
|
2248
|
+
startBind: { hostId: src.id, nx: 1, ny: 0.5 },
|
|
2249
|
+
endBind: { hostId: nid, nx: 0, ny: 0.5 },
|
|
2250
|
+
},
|
|
2251
|
+
];
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
commitStrokes(prev, next, `quick-create ${sibling.tool}`);
|
|
2255
|
+
annotSel?.replace(nid);
|
|
2256
|
+
// Sticky → body editor; rect/ellipse/polygon → anchored-text editor
|
|
2257
|
+
// (Wave G widened anchorsById to every closed shape).
|
|
2258
|
+
if (
|
|
2259
|
+
sibling.tool === 'sticky' ||
|
|
2260
|
+
sibling.tool === 'rect' ||
|
|
2261
|
+
sibling.tool === 'ellipse' ||
|
|
2262
|
+
sibling.tool === 'polygon'
|
|
2263
|
+
) {
|
|
2264
|
+
setEditingId(nid);
|
|
2265
|
+
}
|
|
2266
|
+
return true;
|
|
2267
|
+
},
|
|
2268
|
+
[commitStrokes, annotSel, theme]
|
|
2269
|
+
);
|
|
2270
|
+
|
|
2271
|
+
// FigJam v3 — ⌘Enter pressed INSIDE a sticky/anchored editor commits there
|
|
2272
|
+
// and asks the layer (via this event) to chain the next sibling. Deferred a
|
|
2273
|
+
// tick so the editor's commit lands in strokesRef first.
|
|
2274
|
+
useEffect(() => {
|
|
2275
|
+
if (typeof document === 'undefined') return;
|
|
2276
|
+
const onChain = (e: Event) => {
|
|
2277
|
+
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
|
|
2278
|
+
if (!id) return;
|
|
2279
|
+
window.setTimeout(() => {
|
|
2280
|
+
chainCreate(id);
|
|
2281
|
+
}, 0);
|
|
2282
|
+
};
|
|
2283
|
+
document.addEventListener('maude:chain-create', onChain);
|
|
2284
|
+
return () => document.removeEventListener('maude:chain-create', onChain);
|
|
2285
|
+
}, [chainCreate]);
|
|
2286
|
+
|
|
2287
|
+
// FigJam v3 — the resize overlay (a sibling component that owns the arrow
|
|
2288
|
+
// endpoint handles) broadcasts the bind candidate while an endpoint drags;
|
|
2289
|
+
// the halo renders here because the SVG layer owns the world overlay.
|
|
2290
|
+
useEffect(() => {
|
|
2291
|
+
if (typeof document === 'undefined') return;
|
|
2292
|
+
const onHint = (e: Event) => {
|
|
2293
|
+
setBindHintId((e as CustomEvent<{ hostId?: string | null }>).detail?.hostId ?? null);
|
|
2294
|
+
};
|
|
2295
|
+
document.addEventListener('maude:bind-hint', onHint);
|
|
2296
|
+
return () => document.removeEventListener('maude:bind-hint', onHint);
|
|
2297
|
+
}, []);
|
|
2298
|
+
|
|
2299
|
+
// FigJam v3 — live size label + dimension-match halos while resizing (the
|
|
2300
|
+
// overlay broadcasts; the SVG layer paints).
|
|
2301
|
+
const [resizeInfo, setResizeInfo] = useState<{
|
|
2302
|
+
box: { x: number; y: number; w: number; h: number } | null;
|
|
2303
|
+
matchIds: string[];
|
|
2304
|
+
} | null>(null);
|
|
2305
|
+
useEffect(() => {
|
|
2306
|
+
if (typeof document === 'undefined') return;
|
|
2307
|
+
const onInfo = (e: Event) => {
|
|
2308
|
+
const detail = (
|
|
2309
|
+
e as CustomEvent<{
|
|
2310
|
+
box?: { x: number; y: number; w: number; h: number } | null;
|
|
2311
|
+
matchIds?: string[];
|
|
2312
|
+
}>
|
|
2313
|
+
).detail;
|
|
2314
|
+
setResizeInfo(detail?.box ? { box: detail.box, matchIds: detail.matchIds ?? [] } : null);
|
|
2315
|
+
};
|
|
2316
|
+
document.addEventListener('maude:resize-info', onInfo);
|
|
2317
|
+
return () => document.removeEventListener('maude:resize-info', onInfo);
|
|
2318
|
+
}, []);
|
|
2319
|
+
|
|
2320
|
+
// FigJam v3 — manipulation shortcuts: ⌘G group / ⌘⇧G ungroup, ⌘D duplicate,
|
|
2321
|
+
// ] [ ⌘] ⌘[ z-order, ⌘C/⌘X copy/cut (selection → OS clipboard as a JSON
|
|
2322
|
+
// text payload), ⌘Enter quick-create chain. Document capture, mirroring the
|
|
2323
|
+
// nudge handler below; the input-router never claims these combos.
|
|
2324
|
+
useEffect(() => {
|
|
2325
|
+
if (typeof document === 'undefined') return;
|
|
2326
|
+
if (!annotSel) return;
|
|
2327
|
+
const onKey = (e: KeyboardEvent) => {
|
|
2328
|
+
if (isEditable(e.target)) return;
|
|
2329
|
+
const store = strokesStoreRef.current;
|
|
2330
|
+
const sel = annotSel.selectedIds;
|
|
2331
|
+
const cmd = e.metaKey || e.ctrlKey;
|
|
2332
|
+
const k = e.key.toLowerCase();
|
|
2333
|
+
if (cmd && !e.altKey && k === 'g') {
|
|
2334
|
+
// ⌘G claims the browser's find-next ONLY when a selection exists.
|
|
2335
|
+
if (sel.length === 0) return;
|
|
2336
|
+
e.preventDefault();
|
|
2337
|
+
e.stopImmediatePropagation();
|
|
2338
|
+
if (e.shiftKey) {
|
|
2339
|
+
store.ungroupSelection(sel);
|
|
2340
|
+
} else {
|
|
2341
|
+
const members = store.groupSelection(sel);
|
|
2342
|
+
if (members) annotSel.replace(members);
|
|
2343
|
+
}
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
if (cmd && !e.shiftKey && !e.altKey && k === 'd') {
|
|
2347
|
+
if (sel.length === 0) return; // browser bookmark stays available
|
|
2348
|
+
e.preventDefault();
|
|
2349
|
+
e.stopImmediatePropagation();
|
|
2350
|
+
const ids = store.duplicateSelection(sel, 16, 16);
|
|
2351
|
+
if (ids.length) annotSel.replace(ids);
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
if ((e.key === ']' || e.key === '[') && !e.altKey && !e.shiftKey) {
|
|
2355
|
+
if (sel.length === 0) return;
|
|
2356
|
+
e.preventDefault();
|
|
2357
|
+
const op: ZOrderOp =
|
|
2358
|
+
e.key === ']' ? (cmd ? 'forward' : 'front') : cmd ? 'backward' : 'back';
|
|
2359
|
+
store.reorderSelection(sel, op);
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
if (cmd && !e.shiftKey && !e.altKey && (k === 'c' || k === 'x')) {
|
|
2363
|
+
if (sel.length === 0) return; // let the native copy run
|
|
2364
|
+
if (copySelection(k === 'x')) e.preventDefault();
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
if (cmd && e.key === 'Enter' && !e.shiftKey && !e.altKey) {
|
|
2368
|
+
if (sel.length !== 1) return;
|
|
2369
|
+
const only = sel[0];
|
|
2370
|
+
if (only && chainCreate(only)) e.preventDefault();
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
// FigJam v3 — plain Enter on a single text-capable stroke opens its
|
|
2374
|
+
// editor (FigJam: select a shape, press Enter, start typing).
|
|
2375
|
+
if (e.key === 'Enter' && !cmd && !e.shiftKey && !e.altKey) {
|
|
2376
|
+
if (sel.length !== 1) return;
|
|
2377
|
+
const only = strokesRef.current.find((x) => x.id === sel[0]);
|
|
2378
|
+
if (!only) return;
|
|
2379
|
+
if (
|
|
2380
|
+
only.tool === 'rect' ||
|
|
2381
|
+
only.tool === 'ellipse' ||
|
|
2382
|
+
only.tool === 'polygon' ||
|
|
2383
|
+
only.tool === 'sticky' ||
|
|
2384
|
+
(only.tool === 'text' && (only.anchorId == null || only.anchorId === ''))
|
|
2385
|
+
) {
|
|
2386
|
+
e.preventDefault();
|
|
2387
|
+
setEditingId(only.id);
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
document.addEventListener('keydown', onKey, true);
|
|
2392
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
2393
|
+
}, [annotSel, chainCreate, copySelection]);
|
|
2394
|
+
|
|
2395
|
+
// FigJam v3 — paste strokes. ⌘C serialized the selection as a JSON text
|
|
2396
|
+
// payload; this CAPTURE-phase listener claims it before the media-intake
|
|
2397
|
+
// hook (bubble phase) so a strokes payload never falls through to the URL/
|
|
2398
|
+
// link branch.
|
|
2399
|
+
useEffect(() => {
|
|
2400
|
+
if (typeof document === 'undefined') return;
|
|
2401
|
+
if (!annotSel) return;
|
|
2402
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
2403
|
+
if (isEditable(e.target)) return;
|
|
2404
|
+
const txt = e.clipboardData?.getData('text/plain') ?? '';
|
|
2405
|
+
if (!txt.startsWith('{"maudeStrokes"')) return;
|
|
2406
|
+
e.preventDefault();
|
|
2407
|
+
e.stopImmediatePropagation();
|
|
2408
|
+
pasteStrokesText(txt);
|
|
2409
|
+
};
|
|
2410
|
+
document.addEventListener('paste', onPaste, true);
|
|
2411
|
+
return () => document.removeEventListener('paste', onPaste, true);
|
|
2412
|
+
}, [annotSel, pasteStrokesText]);
|
|
3073
2413
|
|
|
3074
|
-
|
|
3075
|
-
|
|
2414
|
+
// FigJam v3 — hover "Add text" affordance: an empty rect/ellipse hovered in
|
|
2415
|
+
// move mode shows a ghost label; double-click (existing) or Enter edits.
|
|
2416
|
+
const [addTextHintId, setAddTextHintId] = useState<string | null>(null);
|
|
2417
|
+
useEffect(() => {
|
|
2418
|
+
if (typeof document === 'undefined') return;
|
|
2419
|
+
if (tool !== 'move') {
|
|
2420
|
+
setAddTextHintId(null);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
const onMove = (e: PointerEvent) => {
|
|
2424
|
+
const node = (e.target as Element | null)?.closest?.('[data-id][data-tool]');
|
|
2425
|
+
const t = node?.getAttribute('data-tool');
|
|
2426
|
+
const id = node?.getAttribute('data-id') ?? null;
|
|
2427
|
+
if (!id || (t !== 'rect' && t !== 'ellipse' && t !== 'polygon')) {
|
|
2428
|
+
setAddTextHintId(null);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
const hasText = strokesRef.current.some(
|
|
2432
|
+
(x) => x.tool === 'text' && x.anchorId === id && x.text.length > 0
|
|
2433
|
+
);
|
|
2434
|
+
setAddTextHintId(hasText ? null : id);
|
|
2435
|
+
};
|
|
2436
|
+
document.addEventListener('pointermove', onMove, { passive: true });
|
|
2437
|
+
return () => document.removeEventListener('pointermove', onMove);
|
|
2438
|
+
}, [tool]);
|
|
3076
2439
|
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
}
|
|
3088
|
-
|
|
3089
|
-
);
|
|
2440
|
+
// FigJam v3 — connector draft: dragging from a connection dot (the side
|
|
2441
|
+
// magnets shown on a selected bindable shape) draws a BOUND curved
|
|
2442
|
+
// connector; releasing over another bindable shape binds the far end too
|
|
2443
|
+
// (⌘ keeps it free). The draft renders through the same arrow primitives.
|
|
2444
|
+
const [connDraft, setConnDraft] = useState<{
|
|
2445
|
+
x1: number;
|
|
2446
|
+
y1: number;
|
|
2447
|
+
x2: number;
|
|
2448
|
+
y2: number;
|
|
2449
|
+
startBind: { hostId: string; nx: number; ny: number };
|
|
2450
|
+
endBind?: { hostId: string; nx: number; ny: number };
|
|
2451
|
+
} | null>(null);
|
|
2452
|
+
const connDraftRef = useRef(connDraft);
|
|
2453
|
+
connDraftRef.current = connDraft;
|
|
2454
|
+
useEffect(() => {
|
|
2455
|
+
if (typeof document === 'undefined') return;
|
|
2456
|
+
if (tool !== 'move') return;
|
|
2457
|
+
const onDown = (e: PointerEvent) => {
|
|
2458
|
+
if (e.button !== 0) return;
|
|
2459
|
+
const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
|
|
2460
|
+
if (!dot) return;
|
|
2461
|
+
const hostId = dot.getAttribute('data-host') ?? '';
|
|
2462
|
+
const nx = Number.parseFloat(dot.getAttribute('data-nx') ?? '');
|
|
2463
|
+
const ny = Number.parseFloat(dot.getAttribute('data-ny') ?? '');
|
|
2464
|
+
const host = strokesRef.current.find((s) => s.id === hostId);
|
|
2465
|
+
if (!host || !Number.isFinite(nx) || !Number.isFinite(ny)) return;
|
|
2466
|
+
const pt = anchorPoint(host, nx, ny);
|
|
2467
|
+
if (!pt) return;
|
|
2468
|
+
e.preventDefault();
|
|
2469
|
+
e.stopImmediatePropagation();
|
|
2470
|
+
const pointerId = e.pointerId;
|
|
2471
|
+
const startBind = { hostId, nx, ny };
|
|
2472
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: pt[0], y2: pt[1], startBind });
|
|
2473
|
+
const onMove = (mv: PointerEvent) => {
|
|
2474
|
+
if (mv.pointerId !== pointerId) return;
|
|
2475
|
+
const [wx, wy] = screenToWorld(mv.clientX, mv.clientY);
|
|
2476
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
2477
|
+
const cand =
|
|
2478
|
+
mv.metaKey || mv.ctrlKey
|
|
2479
|
+
? null
|
|
2480
|
+
: bindCandidate(
|
|
2481
|
+
wx,
|
|
2482
|
+
wy,
|
|
2483
|
+
strokesRef.current,
|
|
2484
|
+
BIND_THRESHOLD_PX / zoom,
|
|
2485
|
+
new Set([hostId])
|
|
2486
|
+
);
|
|
2487
|
+
setBindHintId(cand?.hostId ?? null);
|
|
2488
|
+
if (cand) {
|
|
2489
|
+
const target = strokesRef.current.find((s) => s.id === cand.hostId);
|
|
2490
|
+
const tp = target ? anchorPoint(target, cand.nx, cand.ny) : null;
|
|
2491
|
+
if (tp) {
|
|
2492
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: tp[0], y2: tp[1], startBind, endBind: cand });
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: wx, y2: wy, startBind });
|
|
2497
|
+
};
|
|
2498
|
+
const onUp = (up: PointerEvent) => {
|
|
2499
|
+
if (up.pointerId !== pointerId) return;
|
|
2500
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
2501
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
2502
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
2503
|
+
const draft = connDraftRef.current;
|
|
2504
|
+
setConnDraft(null);
|
|
2505
|
+
setBindHintId(null);
|
|
2506
|
+
if (!draft) return;
|
|
2507
|
+
// A bare tap on the dot creates nothing.
|
|
2508
|
+
if (Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) < 8) return;
|
|
2509
|
+
const arrow: ArrowStroke = {
|
|
2510
|
+
id: rid(),
|
|
2511
|
+
tool: 'arrow',
|
|
2512
|
+
color: resolveDefaultInk(theme),
|
|
2513
|
+
width: STROKE_WIDTH_THIN,
|
|
2514
|
+
x1: draft.x1,
|
|
2515
|
+
y1: draft.y1,
|
|
2516
|
+
x2: draft.x2,
|
|
2517
|
+
y2: draft.y2,
|
|
2518
|
+
lineType: 'curved',
|
|
2519
|
+
startBind: draft.startBind,
|
|
2520
|
+
...(draft.endBind ? { endBind: draft.endBind } : {}),
|
|
2521
|
+
};
|
|
2522
|
+
const prev = strokesRef.current;
|
|
2523
|
+
commitStrokes(prev, [...prev, arrow], 'draw connector');
|
|
2524
|
+
annotSel?.replace(arrow.id);
|
|
2525
|
+
if (arrow.endBind) {
|
|
2526
|
+
showOnceHint(
|
|
2527
|
+
'bind',
|
|
2528
|
+
'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
|
|
2529
|
+
);
|
|
2530
|
+
}
|
|
2531
|
+
};
|
|
2532
|
+
document.addEventListener('pointermove', onMove, true);
|
|
2533
|
+
document.addEventListener('pointerup', onUp, true);
|
|
2534
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
2535
|
+
};
|
|
2536
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2537
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2538
|
+
}, [tool, screenToWorld, commitStrokes, annotSel, theme]);
|
|
3090
2539
|
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
2540
|
+
// FigJam v3 — right-click on a stroke SELECTS it (keeping a multi-selection
|
|
2541
|
+
// the press lands inside) and opens the annotation context menu (z-order,
|
|
2542
|
+
// group, copy/paste, delete). Capture phase on document so it claims the
|
|
2543
|
+
// event before the input-router's host-level contextmenu handler.
|
|
2544
|
+
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
|
2545
|
+
useEffect(() => {
|
|
2546
|
+
if (typeof document === 'undefined') return;
|
|
2547
|
+
if (tool !== 'move') return;
|
|
2548
|
+
if (!annotSel) return;
|
|
2549
|
+
const strokeAt = (target: Element | null): string | null => {
|
|
2550
|
+
if (target?.closest?.(CHROME_SELECTOR)) return null;
|
|
2551
|
+
const node = target?.closest?.('[data-id][data-tool]');
|
|
2552
|
+
const id = node?.getAttribute('data-id');
|
|
2553
|
+
if (!id || !strokesRef.current.some((s) => s.id === id)) return null;
|
|
2554
|
+
return id;
|
|
2555
|
+
};
|
|
2556
|
+
const onCtx = (e: MouseEvent) => {
|
|
2557
|
+
const id = strokeAt(e.target as Element | null);
|
|
2558
|
+
if (!id) return;
|
|
2559
|
+
e.preventDefault();
|
|
2560
|
+
e.stopImmediatePropagation();
|
|
2561
|
+
if (!annotSel.contains(id)) {
|
|
2562
|
+
annotSel.replace(expandIdsToGroups([id], strokesRef.current));
|
|
2563
|
+
}
|
|
2564
|
+
setCtxMenu({ x: e.clientX, y: e.clientY });
|
|
2565
|
+
};
|
|
2566
|
+
// Wave G — the input-router ALSO opens the shell canvas menu from a
|
|
2567
|
+
// right-button POINTERDOWN (classify maps button 2 → 'context-menu'), so
|
|
2568
|
+
// claiming only the contextmenu event left BOTH menus open. This document-
|
|
2569
|
+
// capture listener fires before the router's host-capture one and stops
|
|
2570
|
+
// propagation WITHOUT preventDefault, so the native contextmenu event
|
|
2571
|
+
// (which opens OUR menu above) still follows.
|
|
2572
|
+
const onDown = (e: PointerEvent) => {
|
|
2573
|
+
if (e.button !== 2) return;
|
|
2574
|
+
if (!strokeAt(e.target as Element | null)) return;
|
|
2575
|
+
e.stopImmediatePropagation();
|
|
2576
|
+
};
|
|
2577
|
+
document.addEventListener('contextmenu', onCtx, true);
|
|
2578
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2579
|
+
return () => {
|
|
2580
|
+
document.removeEventListener('contextmenu', onCtx, true);
|
|
2581
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
2582
|
+
};
|
|
2583
|
+
}, [tool, annotSel]);
|
|
3095
2584
|
|
|
3096
2585
|
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
3097
2586
|
useEffect(() => {
|
|
@@ -3132,6 +2621,62 @@ export function AnnotationsLayer() {
|
|
|
3132
2621
|
return () => document.removeEventListener('keydown', onKey, true);
|
|
3133
2622
|
}, [annotSel, strokesStore]);
|
|
3134
2623
|
|
|
2624
|
+
/** FigJam v3 — context-menu action dispatcher (shares the shortcut paths). */
|
|
2625
|
+
const onMenuAction = useCallback(
|
|
2626
|
+
(action: string) => {
|
|
2627
|
+
const store = strokesStoreRef.current;
|
|
2628
|
+
if (!annotSel) return;
|
|
2629
|
+
const sel = annotSel.selectedIds;
|
|
2630
|
+
if (action === 'copy') copySelection(false);
|
|
2631
|
+
else if (action === 'cut') copySelection(true);
|
|
2632
|
+
else if (action === 'paste') {
|
|
2633
|
+
try {
|
|
2634
|
+
void navigator.clipboard
|
|
2635
|
+
?.readText()
|
|
2636
|
+
.then((t) => {
|
|
2637
|
+
pasteStrokesText(t);
|
|
2638
|
+
})
|
|
2639
|
+
.catch(() => {
|
|
2640
|
+
/* clipboard read blocked — paste is best-effort from the menu */
|
|
2641
|
+
});
|
|
2642
|
+
} catch {
|
|
2643
|
+
/* clipboard API absent */
|
|
2644
|
+
}
|
|
2645
|
+
} else if (action === 'duplicate') {
|
|
2646
|
+
const ids = store.duplicateSelection(sel, 16, 16);
|
|
2647
|
+
if (ids.length) annotSel.replace(ids);
|
|
2648
|
+
} else if (action === 'delete') {
|
|
2649
|
+
store.deleteStrokes(sel);
|
|
2650
|
+
annotSel.clear();
|
|
2651
|
+
} else if (
|
|
2652
|
+
action === 'front' ||
|
|
2653
|
+
action === 'forward' ||
|
|
2654
|
+
action === 'backward' ||
|
|
2655
|
+
action === 'back'
|
|
2656
|
+
) {
|
|
2657
|
+
store.reorderSelection(sel, action);
|
|
2658
|
+
} else if (action === 'group') {
|
|
2659
|
+
const members = store.groupSelection(sel);
|
|
2660
|
+
if (members) annotSel.replace(members);
|
|
2661
|
+
} else if (action === 'ungroup') {
|
|
2662
|
+
store.ungroupSelection(sel);
|
|
2663
|
+
}
|
|
2664
|
+
},
|
|
2665
|
+
[annotSel, copySelection, pasteStrokesText]
|
|
2666
|
+
);
|
|
2667
|
+
|
|
2668
|
+
// FigJam v3 — first time a multi-selection lands, surface the group /
|
|
2669
|
+
// duplicate affordances once (behaviour-triggered, never a tour).
|
|
2670
|
+
const selCount = annotSel?.selectedIds.length ?? 0;
|
|
2671
|
+
useEffect(() => {
|
|
2672
|
+
if (selCount >= 2) {
|
|
2673
|
+
showOnceHint(
|
|
2674
|
+
'multi',
|
|
2675
|
+
'⌘G groups the selection · drag inside the box moves everything · ⌘D duplicates.'
|
|
2676
|
+
);
|
|
2677
|
+
}
|
|
2678
|
+
}, [selCount]);
|
|
2679
|
+
|
|
3135
2680
|
// Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
|
|
3136
2681
|
const selectedStrokes = useMemo(() => {
|
|
3137
2682
|
if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
|
|
@@ -3145,54 +2690,76 @@ export function AnnotationsLayer() {
|
|
|
3145
2690
|
|
|
3146
2691
|
return (
|
|
3147
2692
|
<StrokesStoreContext.Provider value={strokesStore}>
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
2693
|
+
<AnnotationsInput
|
|
2694
|
+
isActive={isActive}
|
|
2695
|
+
visible={visible}
|
|
2696
|
+
cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
|
|
2697
|
+
beginStroke={beginStroke}
|
|
2698
|
+
moveStroke={moveStroke}
|
|
2699
|
+
endStroke={endStroke}
|
|
2700
|
+
onLeave={() => setGhost(null)}
|
|
2701
|
+
/>
|
|
2702
|
+
{visible ? (
|
|
2703
|
+
<AnnotationsSvg
|
|
2704
|
+
worldRef={worldRef}
|
|
2705
|
+
strokes={renderStrokes}
|
|
2706
|
+
anchorsById={anchorsById}
|
|
2707
|
+
selectMode={tool === 'move'}
|
|
2708
|
+
selectedStrokes={selectedStrokes}
|
|
2709
|
+
marquee={marquee}
|
|
2710
|
+
snapGuides={snapGuides}
|
|
2711
|
+
bindHintId={bindHintId}
|
|
2712
|
+
resizeInfo={resizeInfo}
|
|
2713
|
+
connDraft={connDraft}
|
|
2714
|
+
addTextHintId={editingTarget ? null : addTextHintId}
|
|
2715
|
+
ghost={ghostPreview}
|
|
2716
|
+
editingTarget={editingTarget}
|
|
2717
|
+
inkColor={color}
|
|
2718
|
+
onCommitEdit={commitEditing}
|
|
2719
|
+
onCancelEdit={cancelEditing}
|
|
3157
2720
|
/>
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
2721
|
+
) : null}
|
|
2722
|
+
<AnnotationContextToolbar
|
|
2723
|
+
editingId={
|
|
2724
|
+
editingTarget?.kind === 'anchored'
|
|
2725
|
+
? editingTarget.anchorId
|
|
2726
|
+
: editingTarget?.kind === 'sticky'
|
|
2727
|
+
? editingTarget.sticky.id
|
|
2728
|
+
: editingTarget?.kind === 'standalone'
|
|
2729
|
+
? editingTarget.text.id
|
|
2730
|
+
: null
|
|
2731
|
+
}
|
|
2732
|
+
/>
|
|
2733
|
+
{ctxMenu && annotSel ? (
|
|
2734
|
+
<AnnotationContextMenu
|
|
2735
|
+
pos={ctxMenu}
|
|
2736
|
+
selCount={annotSel.selectedIds.length}
|
|
2737
|
+
canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
|
|
2738
|
+
onAction={onMenuAction}
|
|
2739
|
+
onClose={() => setCtxMenu(null)}
|
|
2740
|
+
/>
|
|
2741
|
+
) : null}
|
|
2742
|
+
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
2743
|
+
{isActive ? (
|
|
2744
|
+
<AnnotationsChrome
|
|
2745
|
+
tool={tool}
|
|
2746
|
+
theme={theme}
|
|
2747
|
+
color={color}
|
|
2748
|
+
setColor={setColor}
|
|
2749
|
+
stickyColor={stickyColor}
|
|
2750
|
+
setStickyColor={setStickyColor}
|
|
2751
|
+
highlighterColor={highlighterColor}
|
|
2752
|
+
setHighlighterColor={setHighlighterColor}
|
|
2753
|
+
highlighterWidth={highlighterWidth}
|
|
2754
|
+
setHighlighterWidth={setHighlighterWidth}
|
|
2755
|
+
supportsFill={supportsFill}
|
|
2756
|
+
fill={fill}
|
|
2757
|
+
setFill={setFill}
|
|
2758
|
+
supportsThickness={supportsThickness}
|
|
2759
|
+
thickness={thickness}
|
|
2760
|
+
setThickness={setThickness}
|
|
2761
|
+
/>
|
|
2762
|
+
) : null}
|
|
3196
2763
|
</StrokesStoreContext.Provider>
|
|
3197
2764
|
);
|
|
3198
2765
|
}
|
|
@@ -3286,6 +2853,11 @@ function AnnotationsSvg({
|
|
|
3286
2853
|
selectMode,
|
|
3287
2854
|
selectedStrokes,
|
|
3288
2855
|
marquee,
|
|
2856
|
+
snapGuides,
|
|
2857
|
+
bindHintId,
|
|
2858
|
+
resizeInfo,
|
|
2859
|
+
connDraft,
|
|
2860
|
+
addTextHintId,
|
|
3289
2861
|
ghost,
|
|
3290
2862
|
editingTarget,
|
|
3291
2863
|
inkColor,
|
|
@@ -3294,10 +2866,30 @@ function AnnotationsSvg({
|
|
|
3294
2866
|
}: {
|
|
3295
2867
|
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
3296
2868
|
strokes: readonly Stroke[];
|
|
3297
|
-
anchorsById: Map<string,
|
|
2869
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3298
2870
|
selectMode: boolean;
|
|
3299
2871
|
selectedStrokes: readonly Stroke[];
|
|
3300
2872
|
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
2873
|
+
/** FigJam v3 — smart-guide lines painted while a drag is snapping. */
|
|
2874
|
+
snapGuides: SnapGuide[] | null;
|
|
2875
|
+
/** FigJam v3 — host a dragged arrow endpoint would bind to (accent halo). */
|
|
2876
|
+
bindHintId: string | null;
|
|
2877
|
+
/** FigJam v3 — live size label + dimension-match halos while resizing. */
|
|
2878
|
+
resizeInfo: {
|
|
2879
|
+
box: { x: number; y: number; w: number; h: number } | null;
|
|
2880
|
+
matchIds: string[];
|
|
2881
|
+
} | null;
|
|
2882
|
+
/** FigJam v3 — in-flight connector drawn from a connection dot. */
|
|
2883
|
+
connDraft: {
|
|
2884
|
+
x1: number;
|
|
2885
|
+
y1: number;
|
|
2886
|
+
x2: number;
|
|
2887
|
+
y2: number;
|
|
2888
|
+
startBind: { hostId: string; nx: number; ny: number };
|
|
2889
|
+
endBind?: { hostId: string; nx: number; ny: number };
|
|
2890
|
+
} | null;
|
|
2891
|
+
/** FigJam v3 — hovered empty shape that shows the "Add text" ghost label. */
|
|
2892
|
+
addTextHintId: string | null;
|
|
3301
2893
|
ghost: GhostDescriptor | null;
|
|
3302
2894
|
editingTarget: EditingTarget;
|
|
3303
2895
|
/** Live default ink (theme-aware) for a not-yet-born pending text caret. */
|
|
@@ -3358,6 +2950,94 @@ function AnnotationsSvg({
|
|
|
3358
2950
|
vectorEffect="non-scaling-stroke"
|
|
3359
2951
|
/>
|
|
3360
2952
|
) : null}
|
|
2953
|
+
{/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
|
|
2954
|
+
center, painted only while a drag is actively snapping. */}
|
|
2955
|
+
{snapGuides?.map((g, i) => (
|
|
2956
|
+
<line
|
|
2957
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
|
|
2958
|
+
key={`guide-${i}`}
|
|
2959
|
+
x1={g.axis === 'x' ? g.at : g.from}
|
|
2960
|
+
y1={g.axis === 'x' ? g.from : g.at}
|
|
2961
|
+
x2={g.axis === 'x' ? g.at : g.to}
|
|
2962
|
+
y2={g.axis === 'x' ? g.to : g.at}
|
|
2963
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
2964
|
+
strokeWidth={1}
|
|
2965
|
+
vectorEffect="non-scaling-stroke"
|
|
2966
|
+
pointerEvents="none"
|
|
2967
|
+
/>
|
|
2968
|
+
))}
|
|
2969
|
+
{/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
|
|
2970
|
+
magnetically attach to. */}
|
|
2971
|
+
<BindHintHalo strokes={strokes} bindHintId={bindHintId} />
|
|
2972
|
+
{/* FigJam v3 — connection dots on a single selected bindable shape;
|
|
2973
|
+
dragging one draws a bound connector (rendered below as a draft). */}
|
|
2974
|
+
{selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
|
|
2975
|
+
<ConnectorDots stroke={selectedStrokes[0]} />
|
|
2976
|
+
) : null}
|
|
2977
|
+
{connDraft ? (
|
|
2978
|
+
<g
|
|
2979
|
+
stroke={inkColor}
|
|
2980
|
+
strokeWidth={2.5}
|
|
2981
|
+
strokeLinecap="round"
|
|
2982
|
+
strokeLinejoin="round"
|
|
2983
|
+
vectorEffect="non-scaling-stroke"
|
|
2984
|
+
fill="none"
|
|
2985
|
+
pointerEvents="none"
|
|
2986
|
+
>
|
|
2987
|
+
{arrowPrimitives({
|
|
2988
|
+
x1: connDraft.x1,
|
|
2989
|
+
y1: connDraft.y1,
|
|
2990
|
+
x2: connDraft.x2,
|
|
2991
|
+
y2: connDraft.y2,
|
|
2992
|
+
width: 2.5,
|
|
2993
|
+
color: inkColor,
|
|
2994
|
+
lineType: 'curved',
|
|
2995
|
+
startBind: connDraft.startBind,
|
|
2996
|
+
...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
|
|
2997
|
+
}).map((prim, i) => renderArrowPrimitive(prim, i))}
|
|
2998
|
+
</g>
|
|
2999
|
+
) : null}
|
|
3000
|
+
{/* FigJam v3 — hover affordance: an empty shape invites text. */}
|
|
3001
|
+
<AddTextHint strokes={strokes} hintId={addTextHintId} />
|
|
3002
|
+
{/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
|
|
3003
|
+
dashed halo on any neighbour whose dimension the resize just matched
|
|
3004
|
+
(the "same size as that one" quota). */}
|
|
3005
|
+
{resizeInfo?.box ? (
|
|
3006
|
+
<g pointerEvents="none">
|
|
3007
|
+
{resizeInfo.matchIds.map((id) => {
|
|
3008
|
+
const m = strokes.find((s) => s.id === id);
|
|
3009
|
+
const bb = m ? strokeBBox(m) : null;
|
|
3010
|
+
if (!bb) return null;
|
|
3011
|
+
return (
|
|
3012
|
+
<rect
|
|
3013
|
+
key={`dim-${id}`}
|
|
3014
|
+
x={bb.x - 2}
|
|
3015
|
+
y={bb.y - 2}
|
|
3016
|
+
width={bb.w + 4}
|
|
3017
|
+
height={bb.h + 4}
|
|
3018
|
+
fill="none"
|
|
3019
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3020
|
+
strokeWidth={1.5}
|
|
3021
|
+
strokeDasharray="5 3"
|
|
3022
|
+
vectorEffect="non-scaling-stroke"
|
|
3023
|
+
rx={2}
|
|
3024
|
+
/>
|
|
3025
|
+
);
|
|
3026
|
+
})}
|
|
3027
|
+
<text
|
|
3028
|
+
x={resizeInfo.box.x + resizeInfo.box.w / 2}
|
|
3029
|
+
y={resizeInfo.box.y + resizeInfo.box.h + 18}
|
|
3030
|
+
textAnchor="middle"
|
|
3031
|
+
fontSize={11}
|
|
3032
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3033
|
+
style={{
|
|
3034
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3035
|
+
}}
|
|
3036
|
+
>
|
|
3037
|
+
{`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
|
|
3038
|
+
</text>
|
|
3039
|
+
</g>
|
|
3040
|
+
) : null}
|
|
3361
3041
|
{ghost ? <GhostPreview ghost={ghost} /> : null}
|
|
3362
3042
|
{editingTarget?.kind === 'anchored' ? (
|
|
3363
3043
|
<TextEditor
|
|
@@ -3403,6 +3083,23 @@ function AnnotationsSvg({
|
|
|
3403
3083
|
onCancel={onCancelEdit}
|
|
3404
3084
|
/>
|
|
3405
3085
|
) : null}
|
|
3086
|
+
{editingTarget?.kind === 'section' ? (
|
|
3087
|
+
<StandaloneTextEditor
|
|
3088
|
+
x={
|
|
3089
|
+
Math.min(editingTarget.section.x, editingTarget.section.x + editingTarget.section.w) + 2
|
|
3090
|
+
}
|
|
3091
|
+
y={
|
|
3092
|
+
Math.min(editingTarget.section.y, editingTarget.section.y + editingTarget.section.h) -
|
|
3093
|
+
SECTION_LABEL_H -
|
|
3094
|
+
2
|
|
3095
|
+
}
|
|
3096
|
+
fontSize={SECTION_LABEL_FONT}
|
|
3097
|
+
color={editingTarget.section.color}
|
|
3098
|
+
initialText={editingTarget.section.label}
|
|
3099
|
+
onCommit={onCommitEdit}
|
|
3100
|
+
onCancel={onCancelEdit}
|
|
3101
|
+
/>
|
|
3102
|
+
) : null}
|
|
3406
3103
|
</svg>,
|
|
3407
3104
|
target
|
|
3408
3105
|
);
|
|
@@ -3416,7 +3113,7 @@ function TextEditor({
|
|
|
3416
3113
|
onCancel,
|
|
3417
3114
|
}: {
|
|
3418
3115
|
anchorId: string;
|
|
3419
|
-
host:
|
|
3116
|
+
host: AnchorHost | null;
|
|
3420
3117
|
existing: TextStroke | undefined;
|
|
3421
3118
|
onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
|
|
3422
3119
|
onCancel: () => void;
|
|
@@ -3437,6 +3134,8 @@ function TextEditor({
|
|
|
3437
3134
|
italic: existing?.italic,
|
|
3438
3135
|
underline: existing?.underline,
|
|
3439
3136
|
strike: existing?.strike,
|
|
3137
|
+
fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
|
|
3138
|
+
align: existing?.align ?? 'center',
|
|
3440
3139
|
});
|
|
3441
3140
|
|
|
3442
3141
|
useEffect(() => {
|
|
@@ -3464,6 +3163,9 @@ function TextEditor({
|
|
|
3464
3163
|
const el = ref.current;
|
|
3465
3164
|
if (!el) return;
|
|
3466
3165
|
if (el.contains(e.target as Node)) return;
|
|
3166
|
+
// FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
|
|
3167
|
+
// it must not commit-and-close the session.
|
|
3168
|
+
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
3467
3169
|
onCommit(
|
|
3468
3170
|
anchorId,
|
|
3469
3171
|
stripEditorMarkers(el.innerText || '', existing?.listType),
|
|
@@ -3528,6 +3230,11 @@ function TextEditor({
|
|
|
3528
3230
|
stripEditorMarkers(el?.innerText || '', existing?.listType),
|
|
3529
3231
|
fmtRef.current
|
|
3530
3232
|
);
|
|
3233
|
+
// FigJam v3 — ⌘Enter chains: commit, then spawn a CONNECTED
|
|
3234
|
+
// sibling shape (quick-create) from the host.
|
|
3235
|
+
document.dispatchEvent(
|
|
3236
|
+
new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
|
|
3237
|
+
);
|
|
3531
3238
|
}
|
|
3532
3239
|
}}
|
|
3533
3240
|
>
|
|
@@ -3564,12 +3271,22 @@ function StickyEditor({
|
|
|
3564
3271
|
italic: sticky.italic,
|
|
3565
3272
|
underline: sticky.underline,
|
|
3566
3273
|
strike: sticky.strike,
|
|
3274
|
+
fontSize: sticky.fontSize,
|
|
3275
|
+
align: sticky.align ?? 'left',
|
|
3567
3276
|
});
|
|
3568
3277
|
const commit = () => {
|
|
3569
3278
|
if (doneRef.current) return;
|
|
3570
3279
|
doneRef.current = true;
|
|
3571
3280
|
onCommit(stripEditorMarkers(ref.current?.innerText ?? '', sticky.listType), fmtRef.current);
|
|
3572
3281
|
};
|
|
3282
|
+
// FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
|
|
3283
|
+
// "done editing" (the button's onMouseDown preventDefault usually stops the
|
|
3284
|
+
// blur, this guards the browsers where it doesn't).
|
|
3285
|
+
const onBlur = (e: { relatedTarget?: EventTarget | null }) => {
|
|
3286
|
+
const to = e.relatedTarget as Element | null;
|
|
3287
|
+
if (to?.closest?.('.dc-annot-ctx')) return;
|
|
3288
|
+
commit();
|
|
3289
|
+
};
|
|
3573
3290
|
useEffect(() => {
|
|
3574
3291
|
const el = ref.current;
|
|
3575
3292
|
if (!el) return;
|
|
@@ -3600,7 +3317,7 @@ function StickyEditor({
|
|
|
3600
3317
|
suppressContentEditableWarning
|
|
3601
3318
|
aria-label="Edit sticky note text"
|
|
3602
3319
|
style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
|
|
3603
|
-
onBlur={
|
|
3320
|
+
onBlur={onBlur}
|
|
3604
3321
|
onKeyDown={(e) => {
|
|
3605
3322
|
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3606
3323
|
if (e.key === 'Escape') {
|
|
@@ -3612,6 +3329,11 @@ function StickyEditor({
|
|
|
3612
3329
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
3613
3330
|
e.preventDefault();
|
|
3614
3331
|
commit();
|
|
3332
|
+
// FigJam v3 — ⌘Enter chains: commit this body, then ask the layer
|
|
3333
|
+
// to spawn + edit the next sticky beside it.
|
|
3334
|
+
document.dispatchEvent(
|
|
3335
|
+
new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
|
|
3336
|
+
);
|
|
3615
3337
|
}
|
|
3616
3338
|
}}
|
|
3617
3339
|
>
|
|
@@ -3665,6 +3387,8 @@ function StandaloneTextEditor({
|
|
|
3665
3387
|
italic,
|
|
3666
3388
|
underline,
|
|
3667
3389
|
strike,
|
|
3390
|
+
fontSize,
|
|
3391
|
+
align: align ?? 'left',
|
|
3668
3392
|
});
|
|
3669
3393
|
// Single-fire commit guard — outside-click + blur can both fire in one tick;
|
|
3670
3394
|
// without this the text would commit twice (two undo records). Markers shown
|
|
@@ -3701,6 +3425,8 @@ function StandaloneTextEditor({
|
|
|
3701
3425
|
const el = ref.current;
|
|
3702
3426
|
if (!el) return;
|
|
3703
3427
|
if (el.contains(e.target as Node)) return;
|
|
3428
|
+
// FigJam v3 — clicks into the edit-mode text toolbar keep the session.
|
|
3429
|
+
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
3704
3430
|
commitOnce(el.innerText || '');
|
|
3705
3431
|
};
|
|
3706
3432
|
document.addEventListener('pointerdown', onDown, true);
|
|
@@ -3758,15 +3484,233 @@ function StandaloneTextEditor({
|
|
|
3758
3484
|
);
|
|
3759
3485
|
}
|
|
3760
3486
|
|
|
3487
|
+
/**
|
|
3488
|
+
* FigJam v3 — centered ghost "Add text" label on a hovered EMPTY rect/ellipse
|
|
3489
|
+
* (FigJam shows the same invitation). Pure chrome; double-click / Enter edits.
|
|
3490
|
+
*/
|
|
3491
|
+
function AddTextHint({ strokes, hintId }: { strokes: readonly Stroke[]; hintId: string | null }) {
|
|
3492
|
+
if (!hintId) return null;
|
|
3493
|
+
const host = strokes.find((s) => s.id === hintId);
|
|
3494
|
+
if (!host || (host.tool !== 'rect' && host.tool !== 'ellipse' && host.tool !== 'polygon'))
|
|
3495
|
+
return null;
|
|
3496
|
+
const bb = strokeBBox(host);
|
|
3497
|
+
if (!bb || bb.w < 48 || bb.h < 28) return null;
|
|
3498
|
+
const rot = strokeRotation(host);
|
|
3499
|
+
const label = (
|
|
3500
|
+
<text
|
|
3501
|
+
x={bb.x + bb.w / 2}
|
|
3502
|
+
y={bb.y + bb.h / 2}
|
|
3503
|
+
textAnchor="middle"
|
|
3504
|
+
dominantBaseline="middle"
|
|
3505
|
+
fontSize={13}
|
|
3506
|
+
fill={host.color}
|
|
3507
|
+
opacity={0.45}
|
|
3508
|
+
pointerEvents="none"
|
|
3509
|
+
style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
|
|
3510
|
+
>
|
|
3511
|
+
Add text
|
|
3512
|
+
</text>
|
|
3513
|
+
);
|
|
3514
|
+
if (rot === 0) return label;
|
|
3515
|
+
return (
|
|
3516
|
+
<g transform={`rotate(${rot} ${bb.x + bb.w / 2} ${bb.y + bb.h / 2})`} pointerEvents="none">
|
|
3517
|
+
{label}
|
|
3518
|
+
</g>
|
|
3519
|
+
);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
/**
|
|
3523
|
+
* FigJam v3 — connection dots: the four side magnets of a selected bindable
|
|
3524
|
+
* shape, screen-constant size, accent-ringed. Dragging one starts a bound
|
|
3525
|
+
* connector (the layer owns the gesture; dots are in CHROME_SELECTOR so the
|
|
3526
|
+
* marquee/drag handler yields).
|
|
3527
|
+
*/
|
|
3528
|
+
function ConnectorDots({ stroke }: { stroke: Stroke }) {
|
|
3529
|
+
const controller = useViewportControllerContext();
|
|
3530
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3531
|
+
if (!isBindable(stroke)) return null;
|
|
3532
|
+
const center = strokeCenter(stroke);
|
|
3533
|
+
if (!center) return null;
|
|
3534
|
+
const magnets: Array<[number, number]> = [
|
|
3535
|
+
[0.5, 0],
|
|
3536
|
+
[1, 0.5],
|
|
3537
|
+
[0.5, 1],
|
|
3538
|
+
[0, 0.5],
|
|
3539
|
+
];
|
|
3540
|
+
// FigJam parity — the dots float a step OUTSIDE the edge (along the outward
|
|
3541
|
+
// normal), which also keeps them clear of the mid-edge RESIZE handles that
|
|
3542
|
+
// sit exactly on the edge midpoints (DOM, higher layer — they'd swallow the
|
|
3543
|
+
// drag otherwise). Deriving the normal from center→anchor keeps rotated
|
|
3544
|
+
// shapes correct for free.
|
|
3545
|
+
const offset = 16 / zoom;
|
|
3546
|
+
return (
|
|
3547
|
+
<g>
|
|
3548
|
+
{magnets.map(([nx, ny]) => {
|
|
3549
|
+
const pt = anchorPoint(stroke, nx, ny);
|
|
3550
|
+
if (!pt) return null;
|
|
3551
|
+
const dx = pt[0] - center[0];
|
|
3552
|
+
const dy = pt[1] - center[1];
|
|
3553
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
3554
|
+
return (
|
|
3555
|
+
<circle
|
|
3556
|
+
key={`${nx}-${ny}`}
|
|
3557
|
+
className="dc-annot-conn-dot"
|
|
3558
|
+
data-host={stroke.id}
|
|
3559
|
+
data-nx={nx}
|
|
3560
|
+
data-ny={ny}
|
|
3561
|
+
cx={pt[0] + (dx / len) * offset}
|
|
3562
|
+
cy={pt[1] + (dy / len) * offset}
|
|
3563
|
+
r={5 / zoom}
|
|
3564
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3565
|
+
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
3566
|
+
strokeWidth={1.5 / zoom}
|
|
3567
|
+
pointerEvents="all"
|
|
3568
|
+
/>
|
|
3569
|
+
);
|
|
3570
|
+
})}
|
|
3571
|
+
</g>
|
|
3572
|
+
);
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
/**
|
|
3576
|
+
* FigJam v3 — right-click context menu for annotation strokes. Reuses the
|
|
3577
|
+
* `.dc-context-menu` visual language (stylesheet injected by context-menu.tsx)
|
|
3578
|
+
* so the annotation menu and the canvas menu read as one product surface.
|
|
3579
|
+
*/
|
|
3580
|
+
function AnnotationContextMenu({
|
|
3581
|
+
pos,
|
|
3582
|
+
selCount,
|
|
3583
|
+
canUngroup,
|
|
3584
|
+
onAction,
|
|
3585
|
+
onClose,
|
|
3586
|
+
}: {
|
|
3587
|
+
pos: { x: number; y: number };
|
|
3588
|
+
selCount: number;
|
|
3589
|
+
canUngroup: boolean;
|
|
3590
|
+
onAction: (action: string) => void;
|
|
3591
|
+
onClose: () => void;
|
|
3592
|
+
}) {
|
|
3593
|
+
ensureCtxMenuStyles();
|
|
3594
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
3595
|
+
const [at, setAt] = useState<{ x: number; y: number }>(pos);
|
|
3596
|
+
useEffect(() => {
|
|
3597
|
+
const el = ref.current;
|
|
3598
|
+
if (!el || typeof window === 'undefined') return;
|
|
3599
|
+
const r = el.getBoundingClientRect();
|
|
3600
|
+
let nx = pos.x;
|
|
3601
|
+
let ny = pos.y;
|
|
3602
|
+
if (nx + r.width > window.innerWidth - 8) nx = Math.max(8, window.innerWidth - r.width - 8);
|
|
3603
|
+
if (ny + r.height > window.innerHeight - 8) ny = Math.max(8, window.innerHeight - r.height - 8);
|
|
3604
|
+
if (nx !== at.x || ny !== at.y) setAt({ x: nx, y: ny });
|
|
3605
|
+
el.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')?.focus();
|
|
3606
|
+
const onDown = (e: PointerEvent) => {
|
|
3607
|
+
if (!el.contains(e.target as Node)) onClose();
|
|
3608
|
+
};
|
|
3609
|
+
const onKey = (e: KeyboardEvent) => {
|
|
3610
|
+
if (e.key === 'Escape') {
|
|
3611
|
+
e.preventDefault();
|
|
3612
|
+
onClose();
|
|
3613
|
+
}
|
|
3614
|
+
};
|
|
3615
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
3616
|
+
document.addEventListener('keydown', onKey, true);
|
|
3617
|
+
return () => {
|
|
3618
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
3619
|
+
document.removeEventListener('keydown', onKey, true);
|
|
3620
|
+
};
|
|
3621
|
+
}, [pos, onClose, at.x, at.y]);
|
|
3622
|
+
const item = (
|
|
3623
|
+
id: string,
|
|
3624
|
+
label: string,
|
|
3625
|
+
shortcut?: string,
|
|
3626
|
+
opts?: { destructive?: boolean; disabled?: boolean }
|
|
3627
|
+
) => (
|
|
3628
|
+
<button
|
|
3629
|
+
type="button"
|
|
3630
|
+
role="menuitem"
|
|
3631
|
+
disabled={opts?.disabled}
|
|
3632
|
+
className={`dc-menu-item${opts?.destructive ? ' is-destructive' : ''}`}
|
|
3633
|
+
onClick={() => {
|
|
3634
|
+
if (opts?.disabled) return;
|
|
3635
|
+
onAction(id);
|
|
3636
|
+
onClose();
|
|
3637
|
+
}}
|
|
3638
|
+
>
|
|
3639
|
+
<span>{label}</span>
|
|
3640
|
+
{shortcut ? <span className="dc-menu-shortcut">{shortcut}</span> : null}
|
|
3641
|
+
</button>
|
|
3642
|
+
);
|
|
3643
|
+
return (
|
|
3644
|
+
<div
|
|
3645
|
+
ref={ref}
|
|
3646
|
+
className="dc-context-menu"
|
|
3647
|
+
role="menu"
|
|
3648
|
+
aria-label="Annotation actions"
|
|
3649
|
+
style={{ left: at.x, top: at.y }}
|
|
3650
|
+
>
|
|
3651
|
+
{item('copy', 'Copy', '⌘C')}
|
|
3652
|
+
{item('cut', 'Cut', '⌘X')}
|
|
3653
|
+
{item('paste', 'Paste', '⌘V')}
|
|
3654
|
+
{item('duplicate', 'Duplicate', '⌘D')}
|
|
3655
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3656
|
+
{item('front', 'Bring to front', ']')}
|
|
3657
|
+
{item('forward', 'Bring forward', '⌘]')}
|
|
3658
|
+
{item('backward', 'Send backward', '⌘[')}
|
|
3659
|
+
{item('back', 'Send to back', '[')}
|
|
3660
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3661
|
+
{item('group', 'Group selection', '⌘G', { disabled: selCount < 2 })}
|
|
3662
|
+
{canUngroup ? item('ungroup', 'Ungroup', '⌘⇧G') : null}
|
|
3663
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3664
|
+
{item('delete', 'Delete', '⌫', { destructive: true })}
|
|
3665
|
+
</div>
|
|
3666
|
+
);
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
/**
|
|
3670
|
+
* FigJam v3 — accent halo on the host a dragged arrow endpoint would bind to.
|
|
3671
|
+
* Pure chrome (pointer-events:none); renders nothing when no candidate.
|
|
3672
|
+
*/
|
|
3673
|
+
function BindHintHalo({
|
|
3674
|
+
strokes,
|
|
3675
|
+
bindHintId,
|
|
3676
|
+
}: {
|
|
3677
|
+
strokes: readonly Stroke[];
|
|
3678
|
+
bindHintId: string | null;
|
|
3679
|
+
}) {
|
|
3680
|
+
if (!bindHintId) return null;
|
|
3681
|
+
const host = strokes.find((s) => s.id === bindHintId);
|
|
3682
|
+
if (!host) return null;
|
|
3683
|
+
const bbox = strokeBBox(host);
|
|
3684
|
+
if (!bbox) return null;
|
|
3685
|
+
const pad = 3;
|
|
3686
|
+
return (
|
|
3687
|
+
<rect
|
|
3688
|
+
x={bbox.x - pad}
|
|
3689
|
+
y={bbox.y - pad}
|
|
3690
|
+
width={bbox.w + pad * 2}
|
|
3691
|
+
height={bbox.h + pad * 2}
|
|
3692
|
+
fill="none"
|
|
3693
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3694
|
+
strokeWidth={2}
|
|
3695
|
+
strokeOpacity={0.8}
|
|
3696
|
+
vectorEffect="non-scaling-stroke"
|
|
3697
|
+
pointerEvents="none"
|
|
3698
|
+
rx={3}
|
|
3699
|
+
/>
|
|
3700
|
+
);
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3761
3703
|
function SelectionHalo({
|
|
3762
3704
|
stroke,
|
|
3763
3705
|
anchorsById,
|
|
3764
3706
|
multi,
|
|
3765
3707
|
}: {
|
|
3766
3708
|
stroke: Stroke;
|
|
3767
|
-
anchorsById: Map<string,
|
|
3709
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3768
3710
|
multi: boolean;
|
|
3769
3711
|
}) {
|
|
3712
|
+
const controller = useViewportControllerContext();
|
|
3713
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3770
3714
|
const bbox = strokeBBox(stroke, anchorsById);
|
|
3771
3715
|
if (!bbox) return null;
|
|
3772
3716
|
// T17 + post-Wave-2 fix — annotation halo idioms:
|
|
@@ -3781,8 +3725,10 @@ function SelectionHalo({
|
|
|
3781
3725
|
// bbox above carries the container affordance).
|
|
3782
3726
|
// Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
|
|
3783
3727
|
// ambient group-container + active-gesture idioms per DDR-046 rev 2.
|
|
3784
|
-
|
|
3785
|
-
|
|
3728
|
+
// Wave H — screen-constant breathing room (matches the resize handles,
|
|
3729
|
+
// which sit on the same padded frame — HALO_PAD_PX single source).
|
|
3730
|
+
const pad = HALO_PAD_PX / zoom;
|
|
3731
|
+
const halo = (
|
|
3786
3732
|
<rect
|
|
3787
3733
|
x={bbox.x - pad}
|
|
3788
3734
|
y={bbox.y - pad}
|
|
@@ -3796,6 +3742,10 @@ function SelectionHalo({
|
|
|
3796
3742
|
rx={2}
|
|
3797
3743
|
/>
|
|
3798
3744
|
);
|
|
3745
|
+
// FigJam v3 — the halo turns with a rotated stroke.
|
|
3746
|
+
const rot = strokeRotation(stroke);
|
|
3747
|
+
if (rot === 0) return halo;
|
|
3748
|
+
return <g transform={`rotate(${rot} ${bbox.x + bbox.w / 2} ${bbox.y + bbox.h / 2})`}>{halo}</g>;
|
|
3799
3749
|
}
|
|
3800
3750
|
|
|
3801
3751
|
// T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
|
|
@@ -3805,8 +3755,10 @@ function AnnotGroupBbox({
|
|
|
3805
3755
|
anchorsById,
|
|
3806
3756
|
}: {
|
|
3807
3757
|
selectedStrokes: readonly Stroke[];
|
|
3808
|
-
anchorsById: Map<string,
|
|
3758
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3809
3759
|
}) {
|
|
3760
|
+
const controller = useViewportControllerContext();
|
|
3761
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3810
3762
|
if (selectedStrokes.length < 2) return null;
|
|
3811
3763
|
let xMin = Number.POSITIVE_INFINITY;
|
|
3812
3764
|
let yMin = Number.POSITIVE_INFINITY;
|
|
@@ -3823,7 +3775,8 @@ function AnnotGroupBbox({
|
|
|
3823
3775
|
if (b.y + b.h > yMax) yMax = b.y + b.h;
|
|
3824
3776
|
}
|
|
3825
3777
|
if (!any) return null;
|
|
3826
|
-
|
|
3778
|
+
// Wave H — screen-constant pad, one step wider than the single halo.
|
|
3779
|
+
const pad = (HALO_PAD_PX + 2) / zoom;
|
|
3827
3780
|
const x = xMin - pad;
|
|
3828
3781
|
const y = yMin - pad;
|
|
3829
3782
|
const w = xMax - xMin + pad * 2;
|
|
@@ -3985,14 +3938,40 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
|
|
|
3985
3938
|
return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
|
|
3986
3939
|
}
|
|
3987
3940
|
|
|
3988
|
-
|
|
3941
|
+
/**
|
|
3942
|
+
* FigJam v3 — rotation wrapper. The base node renders axis-aligned geometry;
|
|
3943
|
+
* a rotated stroke wraps it in a `rotate()` group around its bbox center
|
|
3944
|
+
* (anchored text inherits its HOST's rotation so labels turn with the shape).
|
|
3945
|
+
* Pointer events pass through the group, so hit-testing + the ctx-toolbar's
|
|
3946
|
+
* getBoundingClientRect positioning keep working on the rotated form.
|
|
3947
|
+
*/
|
|
3948
|
+
function StrokeNode(props: {
|
|
3949
|
+
stroke: Stroke;
|
|
3950
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3951
|
+
interactive: boolean;
|
|
3952
|
+
editing?: boolean;
|
|
3953
|
+
}) {
|
|
3954
|
+
const { stroke, anchorsById } = props;
|
|
3955
|
+
let rot = strokeRotation(stroke);
|
|
3956
|
+
let pivot = rot !== 0 ? strokeCenter(stroke) : null;
|
|
3957
|
+
if (stroke.tool === 'text' && stroke.anchorId != null && stroke.anchorId !== '') {
|
|
3958
|
+
const host = anchorsById.get(stroke.anchorId);
|
|
3959
|
+
rot = host ? strokeRotation(host) : 0;
|
|
3960
|
+
pivot = rot !== 0 && host ? strokeCenter(host) : null;
|
|
3961
|
+
}
|
|
3962
|
+
const node = <StrokeNodeBase {...props} />;
|
|
3963
|
+
if (rot === 0 || !pivot) return node;
|
|
3964
|
+
return <g transform={`rotate(${rot} ${pivot[0]} ${pivot[1]})`}>{node}</g>;
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
function StrokeNodeBase({
|
|
3989
3968
|
stroke,
|
|
3990
3969
|
anchorsById,
|
|
3991
3970
|
interactive,
|
|
3992
3971
|
editing = false,
|
|
3993
3972
|
}: {
|
|
3994
3973
|
stroke: Stroke;
|
|
3995
|
-
anchorsById: Map<string,
|
|
3974
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3996
3975
|
interactive: boolean;
|
|
3997
3976
|
/** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
|
|
3998
3977
|
editing?: boolean;
|
|
@@ -4192,6 +4171,75 @@ function StrokeNode({
|
|
|
4192
4171
|
</g>
|
|
4193
4172
|
);
|
|
4194
4173
|
}
|
|
4174
|
+
if (stroke.tool === 'section') {
|
|
4175
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4176
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4177
|
+
const w = Math.abs(stroke.w);
|
|
4178
|
+
const h = Math.abs(stroke.h);
|
|
4179
|
+
const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
|
|
4180
|
+
return (
|
|
4181
|
+
<g data-id={stroke.id} data-tool="section">
|
|
4182
|
+
{/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
|
|
4183
|
+
section selects normally; the section is grabbed by border/chip). */}
|
|
4184
|
+
<rect
|
|
4185
|
+
x={x}
|
|
4186
|
+
y={y}
|
|
4187
|
+
width={w}
|
|
4188
|
+
height={h}
|
|
4189
|
+
rx={SECTION_CORNER_RADIUS}
|
|
4190
|
+
ry={SECTION_CORNER_RADIUS}
|
|
4191
|
+
fill={stroke.color}
|
|
4192
|
+
fillOpacity={0.07}
|
|
4193
|
+
stroke={stroke.color}
|
|
4194
|
+
strokeOpacity={0.45}
|
|
4195
|
+
strokeWidth={1.5}
|
|
4196
|
+
vectorEffect="non-scaling-stroke"
|
|
4197
|
+
pointerEvents="none"
|
|
4198
|
+
/>
|
|
4199
|
+
{/* Invisible border hit ring — the grabbable edge. */}
|
|
4200
|
+
{interactive ? (
|
|
4201
|
+
<rect
|
|
4202
|
+
x={x}
|
|
4203
|
+
y={y}
|
|
4204
|
+
width={w}
|
|
4205
|
+
height={h}
|
|
4206
|
+
rx={SECTION_CORNER_RADIUS}
|
|
4207
|
+
ry={SECTION_CORNER_RADIUS}
|
|
4208
|
+
fill="none"
|
|
4209
|
+
stroke="transparent"
|
|
4210
|
+
strokeWidth={12}
|
|
4211
|
+
vectorEffect="non-scaling-stroke"
|
|
4212
|
+
pointerEvents="stroke"
|
|
4213
|
+
/>
|
|
4214
|
+
) : null}
|
|
4215
|
+
{/* Label chip above the top-left corner — also a grab handle. */}
|
|
4216
|
+
<g pointerEvents={hitMode}>
|
|
4217
|
+
<rect
|
|
4218
|
+
x={x}
|
|
4219
|
+
y={y - SECTION_LABEL_H - 4}
|
|
4220
|
+
width={chipW}
|
|
4221
|
+
height={SECTION_LABEL_H}
|
|
4222
|
+
rx={5}
|
|
4223
|
+
ry={5}
|
|
4224
|
+
fill={stroke.color}
|
|
4225
|
+
fillOpacity={0.16}
|
|
4226
|
+
/>
|
|
4227
|
+
<text
|
|
4228
|
+
x={x + 9}
|
|
4229
|
+
y={y - SECTION_LABEL_H / 2 - 4}
|
|
4230
|
+
dominantBaseline="middle"
|
|
4231
|
+
fontSize={SECTION_LABEL_FONT}
|
|
4232
|
+
fill={stroke.color}
|
|
4233
|
+
style={{
|
|
4234
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4235
|
+
}}
|
|
4236
|
+
>
|
|
4237
|
+
{stroke.label}
|
|
4238
|
+
</text>
|
|
4239
|
+
</g>
|
|
4240
|
+
</g>
|
|
4241
|
+
);
|
|
4242
|
+
}
|
|
4195
4243
|
const common = {
|
|
4196
4244
|
'data-id': stroke.id,
|
|
4197
4245
|
'data-tool': stroke.tool,
|