@1agh/maude 0.29.0 → 0.31.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 +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- 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 +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- 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/chat-open.sh +44 -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-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- 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 +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- 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/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- 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-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -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/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -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/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- 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-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -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/tour-overlay.test.tsx +18 -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 +30 -11
- 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-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -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';
|
|
@@ -58,24 +161,26 @@ import {
|
|
|
58
161
|
uploadAsset,
|
|
59
162
|
useCanvasMediaDrop,
|
|
60
163
|
} from './use-canvas-media-drop.tsx';
|
|
164
|
+
import { useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
61
165
|
import { useCollab } from './use-collab.tsx';
|
|
62
166
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
63
167
|
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
64
168
|
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
65
169
|
|
|
170
|
+
// FigJam v3 — the pure data model (Stroke types, palettes, serialize/parse,
|
|
171
|
+
// geometry) lives in annotations-model.ts: React-free, importable headlessly
|
|
172
|
+
// by bun tests and the `maude design annotate` write verb. The layer
|
|
173
|
+
// re-exports the whole model so every existing
|
|
174
|
+
// `from './annotations-layer.tsx'` import keeps working unchanged.
|
|
175
|
+
export * from './annotations-model.ts';
|
|
176
|
+
|
|
66
177
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
178
|
// Types
|
|
68
179
|
|
|
69
|
-
type WorldPoint = readonly [number, number];
|
|
70
|
-
|
|
71
180
|
// Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
|
|
72
181
|
// module imports nothing back from here — no cycle, see DDR-067) and re-exported
|
|
73
182
|
// here for back-compat (context-toolbar etc. import them from this module).
|
|
74
183
|
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
184
|
|
|
80
185
|
/** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
|
|
81
186
|
type GhostDescriptor =
|
|
@@ -83,199 +188,6 @@ type GhostDescriptor =
|
|
|
83
188
|
| { kind: 'sticky'; x: number; y: number; color: string }
|
|
84
189
|
| { kind: 'shape'; x: number; y: number; shapeKind: ShapeKind; color: string };
|
|
85
190
|
|
|
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
191
|
/**
|
|
280
192
|
* Phase 21 — what the inline editor is currently bound to. `anchored` edits
|
|
281
193
|
* the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
|
|
@@ -283,48 +195,14 @@ export type Stroke =
|
|
|
283
195
|
* exists until real text is committed).
|
|
284
196
|
*/
|
|
285
197
|
type EditingTarget =
|
|
286
|
-
| { kind: 'anchored'; anchorId: string; host:
|
|
198
|
+
| { kind: 'anchored'; anchorId: string; host: AnchorHost }
|
|
287
199
|
| { kind: 'sticky'; sticky: StickyStroke }
|
|
288
200
|
| { kind: 'standalone'; text: TextStroke }
|
|
201
|
+
/** FigJam v3 — renaming a section's label chip. */
|
|
202
|
+
| { kind: 'section'; section: SectionStroke }
|
|
289
203
|
| { kind: 'pending'; x: number; y: number }
|
|
290
204
|
| null;
|
|
291
205
|
|
|
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
206
|
/**
|
|
329
207
|
* The canvas-shell CHROME theme — `data-maude-theme` on `<html>`, default
|
|
330
208
|
* 'dark'. This (NOT the DS `data-theme`, which is deliberately separate and
|
|
@@ -353,266 +231,6 @@ function useCanvasChromeTheme(): 'light' | 'dark' {
|
|
|
353
231
|
return theme;
|
|
354
232
|
}
|
|
355
233
|
|
|
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
234
|
/**
|
|
617
235
|
* Shared inline-formatting state for the three text editors (sticky / anchored /
|
|
618
236
|
* standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
|
|
@@ -625,1012 +243,86 @@ function fmtEqual(s: EditorFmt, fmt?: EditorFmt): boolean {
|
|
|
625
243
|
function useEditorFormat(initial: EditorFmt): {
|
|
626
244
|
fmtRef: { current: EditorFmt };
|
|
627
245
|
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,
|
|
246
|
+
onFormatKey: (e: ReactKeyboardEvent) => boolean;
|
|
247
|
+
} {
|
|
248
|
+
const [bold, setBold] = useState(!!initial.bold);
|
|
249
|
+
const [italic, setItalic] = useState(!!initial.italic);
|
|
250
|
+
const [underline, setUnderline] = useState(!!initial.underline);
|
|
251
|
+
const [strike, setStrike] = useState(!!initial.strike);
|
|
252
|
+
// FigJam v3 — edit-mode toolbar extensions: size + alignment preview live in
|
|
253
|
+
// the editor and commit with the text (normFmt carries them through).
|
|
254
|
+
const [fontSize, setFontSize] = useState<number | undefined>(initial.fontSize);
|
|
255
|
+
const [align, setAlign] = useState<TextAlign | undefined>(initial.align);
|
|
256
|
+
const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike, fontSize, align });
|
|
257
|
+
fmtRef.current = { bold, italic, underline, strike, fontSize, align };
|
|
258
|
+
const style: CSSProperties = {
|
|
259
|
+
fontWeight: bold ? 700 : undefined,
|
|
260
|
+
fontStyle: italic ? 'italic' : undefined,
|
|
261
|
+
textDecoration: textDecoCss(strike, underline),
|
|
262
|
+
...(fontSize != null && fontSize !== initial.fontSize ? { fontSize: `${fontSize}px` } : {}),
|
|
263
|
+
...(align && align !== initial.align ? { textAlign: align } : {}),
|
|
264
|
+
};
|
|
265
|
+
// FigJam v3 — the edit-mode context toolbar drives the editor through this
|
|
266
|
+
// event (mutating the STROKE mid-edit would re-render the contentEditable
|
|
267
|
+
// and clobber typed text). The editor echoes its state back so the toolbar's
|
|
268
|
+
// pressed-states track live.
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
if (typeof document === 'undefined') return;
|
|
271
|
+
const onFmt = (e: Event) => {
|
|
272
|
+
const d = (e as CustomEvent<{ key?: string; value?: unknown }>).detail;
|
|
273
|
+
if (!d?.key) return;
|
|
274
|
+
if (d.key === 'bold') setBold((v) => !v);
|
|
275
|
+
else if (d.key === 'italic') setItalic((v) => !v);
|
|
276
|
+
else if (d.key === 'underline') setUnderline((v) => !v);
|
|
277
|
+
else if (d.key === 'strike') setStrike((v) => !v);
|
|
278
|
+
else if (d.key === 'fontSize' && typeof d.value === 'number') setFontSize(d.value);
|
|
279
|
+
else if (d.key === 'align' && typeof d.value === 'string') setAlign(d.value as TextAlign);
|
|
1510
280
|
};
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
281
|
+
document.addEventListener('maude:editor-format', onFmt);
|
|
282
|
+
return () => document.removeEventListener('maude:editor-format', onFmt);
|
|
283
|
+
}, []);
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (typeof document === 'undefined') return;
|
|
286
|
+
const broadcast = () => {
|
|
287
|
+
document.dispatchEvent(
|
|
288
|
+
new CustomEvent('maude:editor-format-state', {
|
|
289
|
+
detail: { bold, italic, underline, strike, fontSize, align },
|
|
290
|
+
})
|
|
291
|
+
);
|
|
292
|
+
};
|
|
293
|
+
broadcast();
|
|
294
|
+
// The toolbar may mount AFTER the editor's first broadcast — it asks.
|
|
295
|
+
document.addEventListener('maude:editor-format-request', broadcast);
|
|
296
|
+
return () => document.removeEventListener('maude:editor-format-request', broadcast);
|
|
297
|
+
}, [bold, italic, underline, strike, fontSize, align]);
|
|
298
|
+
const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
|
|
299
|
+
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
|
|
300
|
+
const k = e.key.toLowerCase();
|
|
301
|
+
if (k === 'b') {
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
setBold((v) => !v);
|
|
304
|
+
return true;
|
|
1530
305
|
}
|
|
1531
|
-
if (
|
|
1532
|
-
|
|
1533
|
-
|
|
306
|
+
if (k === 'i') {
|
|
307
|
+
e.preventDefault();
|
|
308
|
+
setItalic((v) => !v);
|
|
309
|
+
return true;
|
|
1534
310
|
}
|
|
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;
|
|
311
|
+
if (k === 'u') {
|
|
312
|
+
e.preventDefault();
|
|
313
|
+
setUnderline((v) => !v);
|
|
314
|
+
return true;
|
|
1579
315
|
}
|
|
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
|
-
};
|
|
316
|
+
return false;
|
|
317
|
+
}, []);
|
|
318
|
+
return { fmtRef, style, onFormatKey };
|
|
1632
319
|
}
|
|
1633
320
|
|
|
321
|
+
// Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
|
|
322
|
+
// Re-exported so the existing test import (`from '../annotations-layer.tsx'`)
|
|
323
|
+
// and the byte-identical canary keep working.
|
|
324
|
+
export { arrowHeadPoints } from './canvas-arrowheads.ts';
|
|
325
|
+
|
|
1634
326
|
function isEditable(t: EventTarget | null): boolean {
|
|
1635
327
|
if (!t || !(t as HTMLElement).tagName) return false;
|
|
1636
328
|
const el = t as HTMLElement;
|
|
@@ -1862,6 +554,10 @@ const ANNOT_CSS = `
|
|
|
1862
554
|
See DDR-067. (No backticks in this comment: the whole block is a JS template
|
|
1863
555
|
literal, so a backtick here would terminate the string.) */
|
|
1864
556
|
.dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
|
|
557
|
+
/* FigJam v3 — connection dots on a selected bindable shape. The important flag
|
|
558
|
+
beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
|
|
559
|
+
resize handles — DDR-067). */
|
|
560
|
+
.dc-annot-conn-dot { cursor: crosshair !important; }
|
|
1865
561
|
`.trim();
|
|
1866
562
|
|
|
1867
563
|
function ensureAnnotStyles(): void {
|
|
@@ -1883,6 +579,36 @@ export interface StrokesStoreValue {
|
|
|
1883
579
|
updateStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
1884
580
|
deleteStrokes: (ids: string[]) => void;
|
|
1885
581
|
translateStrokes: (ids: string[], dx: number, dy: number) => void;
|
|
582
|
+
/**
|
|
583
|
+
* FigJam v3 — bulk mutation in ONE undo record (the per-stroke
|
|
584
|
+
* `updateStroke` loop the context toolbar used pre-v3 pushed N records for
|
|
585
|
+
* an N-stroke selection). `fn` returns the patch for a stroke or null to
|
|
586
|
+
* leave it untouched.
|
|
587
|
+
*/
|
|
588
|
+
applyToStrokes: (
|
|
589
|
+
ids: readonly string[],
|
|
590
|
+
fn: (s: Stroke) => Partial<Stroke> | null,
|
|
591
|
+
label?: string
|
|
592
|
+
) => void;
|
|
593
|
+
/** Group the (expanded) selection; returns the member ids to select, or null. */
|
|
594
|
+
groupSelection: (ids: readonly string[]) => string[] | null;
|
|
595
|
+
/** Dissolve the outermost group of every selected stroke. */
|
|
596
|
+
ungroupSelection: (ids: readonly string[]) => void;
|
|
597
|
+
/** Cmd+D / paste — clone with fresh ids; returns the clone ids to select. */
|
|
598
|
+
duplicateSelection: (ids: readonly string[], dx: number, dy: number) => string[];
|
|
599
|
+
/** Z-order — `]` `[` `Cmd+]` `Cmd+[`; group units move contiguously. */
|
|
600
|
+
reorderSelection: (ids: readonly string[], op: ZOrderOp) => void;
|
|
601
|
+
alignSelection: (ids: readonly string[], edge: AlignEdge) => void;
|
|
602
|
+
distributeSelection: (ids: readonly string[], axis: DistributeAxis) => void;
|
|
603
|
+
/**
|
|
604
|
+
* Wave H — transient gesture preview: applies the patch to local React
|
|
605
|
+
* state ONLY (no undo record, no persistence), exactly like the move-drag's
|
|
606
|
+
* per-tick path. Close the gesture with `commitGesture` on pointerup so the
|
|
607
|
+
* whole drag lands as ONE undo record (undo used to walk every resize px).
|
|
608
|
+
*/
|
|
609
|
+
previewStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
610
|
+
/** Wave H — single undo record from a preview gesture's start snapshot. */
|
|
611
|
+
commitGesture: (before: readonly Stroke[], label?: string) => void;
|
|
1886
612
|
}
|
|
1887
613
|
|
|
1888
614
|
const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
|
|
@@ -1891,37 +617,33 @@ export function useStrokesStore(): StrokesStoreValue | null {
|
|
|
1891
617
|
return useContext(StrokesStoreContext);
|
|
1892
618
|
}
|
|
1893
619
|
|
|
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
|
-
}
|
|
620
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
621
|
+
// FigJam v3 — one-time contextual hints (first-use discoverability). Behaviour-
|
|
622
|
+
// triggered micro-toasts, never a modal tour: each key fires at most once per
|
|
623
|
+
// browser profile (localStorage bitmap), reusing the existing canvas toast.
|
|
1909
624
|
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
if (
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
625
|
+
// Chrome elements never deselect. Includes the per-shape context toolbar,
|
|
626
|
+
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
627
|
+
// right-click menu. Clicks on these route to their own handlers.
|
|
628
|
+
const CHROME_SELECTOR =
|
|
629
|
+
'.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';
|
|
630
|
+
|
|
631
|
+
const HINTS_KEY = 'maude-annot-hints-v1';
|
|
632
|
+
|
|
633
|
+
function showOnceHint(key: string, msg: string): void {
|
|
634
|
+
if (typeof window === 'undefined') return;
|
|
635
|
+
try {
|
|
636
|
+
const seen = JSON.parse(window.localStorage.getItem(HINTS_KEY) || '{}') as Record<
|
|
637
|
+
string,
|
|
638
|
+
number
|
|
639
|
+
>;
|
|
640
|
+
if (seen[key]) return;
|
|
641
|
+
seen[key] = 1;
|
|
642
|
+
window.localStorage.setItem(HINTS_KEY, JSON.stringify(seen));
|
|
643
|
+
} catch {
|
|
644
|
+
return; // storage blocked — skip rather than re-toast forever
|
|
1923
645
|
}
|
|
1924
|
-
|
|
646
|
+
showCanvasToast(msg);
|
|
1925
647
|
}
|
|
1926
648
|
|
|
1927
649
|
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
@@ -1988,8 +710,22 @@ export function AnnotationsLayer() {
|
|
|
1988
710
|
// shape/sticky/text tool is armed and nothing is being drawn yet. Pure chrome
|
|
1989
711
|
// (low-opacity, pointer-events:none) — never selectable, hit-tested, or saved.
|
|
1990
712
|
const [ghost, setGhost] = useState<{ x: number; y: number } | null>(null);
|
|
713
|
+
// FigJam v3 — smart-guide lines painted while a drag is snapping, and the
|
|
714
|
+
// id of the host a dragged arrow endpoint would bind to (accent halo).
|
|
715
|
+
const [snapGuides, setSnapGuides] = useState<SnapGuide[] | null>(null);
|
|
716
|
+
const [bindHintId, setBindHintId] = useState<string | null>(null);
|
|
717
|
+
// Cmd/Ctrl held — suppresses binding at arrow draw-end (FigJam: ⌘ keeps the
|
|
718
|
+
// endpoint free). Tracked here because endStroke (pointerup) carries no
|
|
719
|
+
// modifier state of its own.
|
|
720
|
+
const cmdHeldRef = useRef(false);
|
|
721
|
+
const vpRef = useRef(vp);
|
|
722
|
+
vpRef.current = vp;
|
|
1991
723
|
const visibilityCtx = useAnnotationsVisibility();
|
|
1992
|
-
const
|
|
724
|
+
const chrome = useChromeVisibility();
|
|
725
|
+
// Presentation Mode hides annotations without mutating the user's own
|
|
726
|
+
// visibility toggle — render/input gate folds `present` in, the stored value
|
|
727
|
+
// (visibilityCtx.visible) is left untouched so exiting restores it.
|
|
728
|
+
const visible = (visibilityCtx?.visible ?? true) && !(chrome?.present ?? false);
|
|
1993
729
|
const setVisible = useCallback(
|
|
1994
730
|
(next: boolean | ((cur: boolean) => boolean)) => {
|
|
1995
731
|
if (!visibilityCtx) return;
|
|
@@ -2026,6 +762,7 @@ export function AnnotationsLayer() {
|
|
|
2026
762
|
tool === 'shape' ||
|
|
2027
763
|
tool === 'arrow' ||
|
|
2028
764
|
tool === 'sticky' ||
|
|
765
|
+
tool === 'section' ||
|
|
2029
766
|
tool === 'text';
|
|
2030
767
|
const isErase = tool === 'eraser';
|
|
2031
768
|
const isActive = isDraw || isErase;
|
|
@@ -2182,30 +919,126 @@ export function AnnotationsLayer() {
|
|
|
2182
919
|
const strokesStore = useMemo<StrokesStoreValue>(() => {
|
|
2183
920
|
const updateStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
2184
921
|
const prev = strokesRef.current;
|
|
2185
|
-
|
|
922
|
+
// FigJam v3 — bound arrow endpoints re-derive from their host after ANY
|
|
923
|
+
// geometry patch (resize ticks included), so connectors track live.
|
|
924
|
+
const next = recomputeBoundArrows(
|
|
925
|
+
prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
|
|
926
|
+
);
|
|
2186
927
|
commitStrokes(prev, next);
|
|
2187
928
|
};
|
|
2188
929
|
const deleteStrokes = (ids: string[]): void => {
|
|
2189
930
|
const set = new Set(ids);
|
|
2190
931
|
const prev = strokesRef.current;
|
|
2191
|
-
const
|
|
932
|
+
const filtered = prev.filter(
|
|
2192
933
|
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
2193
934
|
);
|
|
2194
|
-
if (
|
|
2195
|
-
|
|
935
|
+
if (filtered.length === prev.length) return;
|
|
936
|
+
// FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
|
|
937
|
+
// arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
|
|
938
|
+
commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
|
|
2196
939
|
};
|
|
2197
940
|
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
2198
941
|
const set = new Set(ids);
|
|
2199
942
|
const prev = strokesRef.current;
|
|
2200
|
-
const next =
|
|
943
|
+
const next = recomputeBoundArrows(
|
|
944
|
+
prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s))
|
|
945
|
+
);
|
|
2201
946
|
commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
|
|
2202
947
|
};
|
|
948
|
+
const applyToStrokes = (
|
|
949
|
+
ids: readonly string[],
|
|
950
|
+
fn: (s: Stroke) => Partial<Stroke> | null,
|
|
951
|
+
label?: string
|
|
952
|
+
): void => {
|
|
953
|
+
const set = new Set(ids);
|
|
954
|
+
const prev = strokesRef.current;
|
|
955
|
+
let touched = 0;
|
|
956
|
+
const next = recomputeBoundArrows(
|
|
957
|
+
prev.map((s) => {
|
|
958
|
+
if (!set.has(s.id)) return s;
|
|
959
|
+
const patch = fn(s);
|
|
960
|
+
if (!patch) return s;
|
|
961
|
+
touched++;
|
|
962
|
+
return { ...s, ...patch } as Stroke;
|
|
963
|
+
})
|
|
964
|
+
);
|
|
965
|
+
if (touched === 0) return;
|
|
966
|
+
commitStrokes(prev, next, label ?? `edit ${touched} stroke${touched === 1 ? '' : 's'}`);
|
|
967
|
+
};
|
|
968
|
+
const groupSelection = (ids: readonly string[]): string[] | null => {
|
|
969
|
+
const prev = strokesRef.current;
|
|
970
|
+
const res = groupStrokes(prev, ids);
|
|
971
|
+
if (!res) return null;
|
|
972
|
+
commitStrokes(prev, res.strokes, `group ${res.memberIds.length} strokes`);
|
|
973
|
+
return res.memberIds;
|
|
974
|
+
};
|
|
975
|
+
const ungroupSelection = (ids: readonly string[]): void => {
|
|
976
|
+
const prev = strokesRef.current;
|
|
977
|
+
const next = ungroupStrokes(prev, ids);
|
|
978
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
979
|
+
commitStrokes(prev, next, 'ungroup');
|
|
980
|
+
};
|
|
981
|
+
const duplicateSelection = (ids: readonly string[], dx: number, dy: number): string[] => {
|
|
982
|
+
const prev = strokesRef.current;
|
|
983
|
+
const res = duplicateStrokes(prev, ids, dx, dy);
|
|
984
|
+
if (res.strokes.length === prev.length) return [];
|
|
985
|
+
const added = res.strokes.length - prev.length;
|
|
986
|
+
commitStrokes(prev, res.strokes, `duplicate ${added} stroke${added === 1 ? '' : 's'}`);
|
|
987
|
+
return res.newIds;
|
|
988
|
+
};
|
|
989
|
+
const reorderSelection = (ids: readonly string[], op: ZOrderOp): void => {
|
|
990
|
+
const prev = strokesRef.current;
|
|
991
|
+
const next = reorderStrokes(prev, ids, op);
|
|
992
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
993
|
+
commitStrokes(
|
|
994
|
+
prev,
|
|
995
|
+
next,
|
|
996
|
+
op === 'front' || op === 'forward' ? 'bring forward' : 'send backward'
|
|
997
|
+
);
|
|
998
|
+
};
|
|
999
|
+
const alignSelection = (ids: readonly string[], edge: AlignEdge): void => {
|
|
1000
|
+
const prev = strokesRef.current;
|
|
1001
|
+
const next = recomputeBoundArrows(alignStrokes(prev, ids, edge));
|
|
1002
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
1003
|
+
commitStrokes(prev, next, `align ${edge}`);
|
|
1004
|
+
};
|
|
1005
|
+
const distributeSelection = (ids: readonly string[], axis: DistributeAxis): void => {
|
|
1006
|
+
const prev = strokesRef.current;
|
|
1007
|
+
const next = recomputeBoundArrows(distributeStrokes(prev, ids, axis));
|
|
1008
|
+
if (strokesShallowEqual(prev, next)) return;
|
|
1009
|
+
commitStrokes(prev, next, 'distribute');
|
|
1010
|
+
};
|
|
1011
|
+
// Wave H — transient per-tick path for handle drags (resize / rotate /
|
|
1012
|
+
// endpoint re-anchor). Local React state only — no undo push, no PUT —
|
|
1013
|
+
// mirroring the move-drag's onMove. `commitGesture` closes it as ONE
|
|
1014
|
+
// record (no-op when the gesture ended where it started).
|
|
1015
|
+
const previewStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
1016
|
+
setStrokesState(
|
|
1017
|
+
recomputeBoundArrows(
|
|
1018
|
+
strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
|
|
1019
|
+
)
|
|
1020
|
+
);
|
|
1021
|
+
};
|
|
1022
|
+
const commitGesture = (before: readonly Stroke[], label?: string): void => {
|
|
1023
|
+
const cur = strokesRef.current;
|
|
1024
|
+
if (strokesShallowEqual(before, cur)) return;
|
|
1025
|
+
commitStrokes(before, cur, label);
|
|
1026
|
+
};
|
|
2203
1027
|
return {
|
|
2204
1028
|
strokes,
|
|
2205
1029
|
setStrokes,
|
|
2206
1030
|
updateStroke,
|
|
2207
1031
|
deleteStrokes,
|
|
2208
1032
|
translateStrokes,
|
|
1033
|
+
applyToStrokes,
|
|
1034
|
+
groupSelection,
|
|
1035
|
+
ungroupSelection,
|
|
1036
|
+
duplicateSelection,
|
|
1037
|
+
reorderSelection,
|
|
1038
|
+
alignSelection,
|
|
1039
|
+
distributeSelection,
|
|
1040
|
+
previewStroke,
|
|
1041
|
+
commitGesture,
|
|
2209
1042
|
};
|
|
2210
1043
|
}, [strokes, setStrokes, commitStrokes]);
|
|
2211
1044
|
|
|
@@ -2474,6 +1307,19 @@ export function AnnotationsLayer() {
|
|
|
2474
1307
|
fontSize: DEFAULT_FONT_SIZE,
|
|
2475
1308
|
cornerRadius: STICKY_CORNER_RADIUS,
|
|
2476
1309
|
});
|
|
1310
|
+
} else if (tool === 'section') {
|
|
1311
|
+
// FigJam v3 — drag-create a labelled container; a bare tap drops the
|
|
1312
|
+
// default-sized region (endStroke applies the default).
|
|
1313
|
+
setDrawing({
|
|
1314
|
+
id,
|
|
1315
|
+
tool: 'section',
|
|
1316
|
+
x: wx,
|
|
1317
|
+
y: wy,
|
|
1318
|
+
w: 0,
|
|
1319
|
+
h: 0,
|
|
1320
|
+
label: 'Section',
|
|
1321
|
+
color: DEFAULT_SECTION_COLOR,
|
|
1322
|
+
});
|
|
2477
1323
|
} else if (tool === 'text') {
|
|
2478
1324
|
// Phase 21 — single click drops an editable caret at the click point.
|
|
2479
1325
|
// No stroke is created until the user commits real text (mirrors the
|
|
@@ -2512,6 +1358,7 @@ export function AnnotationsLayer() {
|
|
|
2512
1358
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
2513
1359
|
if (!isActive || !visible) return;
|
|
2514
1360
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1361
|
+
cmdHeldRef.current = e.metaKey || e.ctrlKey;
|
|
2515
1362
|
if (isErase) {
|
|
2516
1363
|
if ((e.buttons & 1) === 0) return;
|
|
2517
1364
|
eraseAt(wx, wy);
|
|
@@ -2527,6 +1374,15 @@ export function AnnotationsLayer() {
|
|
|
2527
1374
|
lastDrawPointRef.current = { x: wx, y: wy };
|
|
2528
1375
|
const anchor = drawAnchorRef.current;
|
|
2529
1376
|
const mods: DrawMods = { shift: e.shiftKey, alt: e.altKey };
|
|
1377
|
+
// FigJam v3 — while dragging an arrow, halo the host the dragged end
|
|
1378
|
+
// would magnetically attach to (⌘ suppresses binding entirely).
|
|
1379
|
+
if (drawingRef.current.tool === 'arrow') {
|
|
1380
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
1381
|
+
const cand = cmdHeldRef.current
|
|
1382
|
+
? null
|
|
1383
|
+
: bindCandidate(wx, wy, strokesRef.current, BIND_THRESHOLD_PX / zoom);
|
|
1384
|
+
setBindHintId(cand?.hostId ?? null);
|
|
1385
|
+
}
|
|
2530
1386
|
setDrawing((cur) => {
|
|
2531
1387
|
if (!cur) return cur;
|
|
2532
1388
|
if (cur.tool === 'pen') {
|
|
@@ -2578,12 +1434,51 @@ export function AnnotationsLayer() {
|
|
|
2578
1434
|
Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
|
|
2579
1435
|
? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
|
|
2580
1436
|
: norm;
|
|
1437
|
+
} else if (cur.tool === 'section') {
|
|
1438
|
+
const norm = normalizeBox(cur);
|
|
1439
|
+
final =
|
|
1440
|
+
Math.abs(norm.w) < SECTION_MIN_SIZE || Math.abs(norm.h) < SECTION_MIN_SIZE
|
|
1441
|
+
? { ...norm, w: SECTION_DEFAULT_W, h: SECTION_DEFAULT_H }
|
|
1442
|
+
: norm;
|
|
2581
1443
|
}
|
|
2582
1444
|
if (final && !isStrokeMeaningful(final)) final = null;
|
|
1445
|
+
// FigJam v3 — magnetic connector binding at draw-end. Each arrow endpoint
|
|
1446
|
+
// within the zoom-scaled threshold of a bindable host attaches to its
|
|
1447
|
+
// nearest side/center magnet and snaps onto it; the bind persists and the
|
|
1448
|
+
// endpoint re-derives from the host from then on. ⌘ held = stay free.
|
|
1449
|
+
if (final && final.tool === 'arrow' && !cmdHeldRef.current) {
|
|
1450
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
1451
|
+
const threshold = BIND_THRESHOLD_PX / zoom;
|
|
1452
|
+
const others = strokesRef.current;
|
|
1453
|
+
const sb = bindCandidate(final.x1, final.y1, others, threshold);
|
|
1454
|
+
const eb = bindCandidate(final.x2, final.y2, others, threshold);
|
|
1455
|
+
if (sb) {
|
|
1456
|
+
const host = others.find((s) => s.id === sb.hostId);
|
|
1457
|
+
const pt = host ? anchorPoint(host, sb.nx, sb.ny) : null;
|
|
1458
|
+
if (pt) final = { ...final, startBind: sb, x1: pt[0], y1: pt[1] };
|
|
1459
|
+
}
|
|
1460
|
+
// A zero-length self-loop (both ends on the same magnet) is useless —
|
|
1461
|
+
// keep the end free when it would collapse onto the start bind.
|
|
1462
|
+
const sameMagnet = sb && eb && sb.hostId === eb.hostId && sb.nx === eb.nx && sb.ny === eb.ny;
|
|
1463
|
+
if (eb && !sameMagnet) {
|
|
1464
|
+
const host = others.find((s) => s.id === eb.hostId);
|
|
1465
|
+
const pt = host ? anchorPoint(host, eb.nx, eb.ny) : null;
|
|
1466
|
+
if (pt) final = { ...final, endBind: eb, x2: pt[0], y2: pt[1] };
|
|
1467
|
+
}
|
|
1468
|
+
if ((final as ArrowStroke).startBind || (final as ArrowStroke).endBind) {
|
|
1469
|
+
showOnceHint(
|
|
1470
|
+
'bind',
|
|
1471
|
+
'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
setBindHintId(null);
|
|
2583
1476
|
if (final) {
|
|
2584
1477
|
const committed = final;
|
|
2585
1478
|
const prev = strokesRef.current;
|
|
2586
|
-
|
|
1479
|
+
// FigJam — sections are CONTAINERS: they slot in at the BACK of the
|
|
1480
|
+
// z-order so content placed on them keeps rendering above.
|
|
1481
|
+
const next = committed.tool === 'section' ? [committed, ...prev] : [...prev, committed];
|
|
2587
1482
|
commitStrokes(prev, next, `draw ${committed.tool}`);
|
|
2588
1483
|
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
2589
1484
|
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
@@ -2591,7 +1486,10 @@ export function AnnotationsLayer() {
|
|
|
2591
1486
|
if (annotSel) annotSel.replace(committed.id);
|
|
2592
1487
|
// Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
|
|
2593
1488
|
// note, type immediately). Only meaningful deviation from rect/ellipse.
|
|
2594
|
-
if (committed.tool === 'sticky')
|
|
1489
|
+
if (committed.tool === 'sticky') {
|
|
1490
|
+
setEditingId(committed.id);
|
|
1491
|
+
showOnceHint('chain', '⌘Enter commits and creates the next sticky beside it.');
|
|
1492
|
+
}
|
|
2595
1493
|
}
|
|
2596
1494
|
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
2597
1495
|
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
@@ -2666,9 +1564,9 @@ export function AnnotationsLayer() {
|
|
|
2666
1564
|
}, [ghost, ghostCapable, drawing, tool, shapeKind, color, stickyColor]);
|
|
2667
1565
|
|
|
2668
1566
|
const anchorsById = useMemo(() => {
|
|
2669
|
-
const map = new Map<string,
|
|
1567
|
+
const map = new Map<string, AnchorHost>();
|
|
2670
1568
|
for (const s of strokes) {
|
|
2671
|
-
if (s.tool === 'rect' || s.tool === 'ellipse') map.set(s.id, s);
|
|
1569
|
+
if (s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon') map.set(s.id, s);
|
|
2672
1570
|
}
|
|
2673
1571
|
return map;
|
|
2674
1572
|
}, [strokes]);
|
|
@@ -2700,6 +1598,11 @@ export function AnnotationsLayer() {
|
|
|
2700
1598
|
startWY: number;
|
|
2701
1599
|
movedIds: string[];
|
|
2702
1600
|
snapshot: Stroke[];
|
|
1601
|
+
/** FigJam v3 — pre-Alt-duplicate baseline. Differs from `snapshot` only
|
|
1602
|
+
* during an Alt+drag duplicate; the undo record spans undoBase → final
|
|
1603
|
+
* so clone + move land as ONE step. */
|
|
1604
|
+
undoBase: Stroke[];
|
|
1605
|
+
altDup: boolean;
|
|
2703
1606
|
} | null>(null);
|
|
2704
1607
|
|
|
2705
1608
|
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
@@ -2732,19 +1635,14 @@ export function AnnotationsLayer() {
|
|
|
2732
1635
|
t === 'text' ||
|
|
2733
1636
|
t === 'sticky' ||
|
|
2734
1637
|
t === 'image' ||
|
|
2735
|
-
t === 'link'
|
|
1638
|
+
t === 'link' ||
|
|
1639
|
+
t === 'section')
|
|
2736
1640
|
) {
|
|
2737
1641
|
return id;
|
|
2738
1642
|
}
|
|
2739
1643
|
return null;
|
|
2740
1644
|
};
|
|
2741
1645
|
|
|
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
1646
|
const onDown = (e: PointerEvent) => {
|
|
2749
1647
|
if (e.button !== 0) return;
|
|
2750
1648
|
if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
|
|
@@ -2763,16 +1661,20 @@ export function AnnotationsLayer() {
|
|
|
2763
1661
|
let ids: string[] | null = null;
|
|
2764
1662
|
if (strokeId) {
|
|
2765
1663
|
elementSel?.clear();
|
|
1664
|
+
// FigJam v3 — clicking a group member acts on the WHOLE outermost
|
|
1665
|
+
// group. Double-click deep-selects the member (see the dblclick
|
|
1666
|
+
// handler); an already-selected stroke keeps the current selection, so
|
|
1667
|
+
// a deep-selected member drags alone without re-expanding.
|
|
1668
|
+
const members = expandIdsToGroups([strokeId], strokesStoreRef.current.strokes);
|
|
2766
1669
|
if (e.shiftKey) {
|
|
2767
|
-
annotSel.add(
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
: [...annotSel.selectedIds, strokeId];
|
|
1670
|
+
annotSel.add(members);
|
|
1671
|
+
const merged = new Set([...annotSel.selectedIds, ...members]);
|
|
1672
|
+
ids = [...merged];
|
|
2771
1673
|
} else if (annotSel.contains(strokeId)) {
|
|
2772
1674
|
ids = annotSel.selectedIds;
|
|
2773
1675
|
} else {
|
|
2774
|
-
annotSel.replace(
|
|
2775
|
-
ids =
|
|
1676
|
+
annotSel.replace(members);
|
|
1677
|
+
ids = members;
|
|
2776
1678
|
}
|
|
2777
1679
|
} else if (!e.shiftKey && annotSel.selectedIds.length > 0) {
|
|
2778
1680
|
// Hull hit-test — union bbox of the currently-selected strokes.
|
|
@@ -2793,7 +1695,7 @@ export function AnnotationsLayer() {
|
|
|
2793
1695
|
ids = annotSel.selectedIds;
|
|
2794
1696
|
}
|
|
2795
1697
|
}
|
|
2796
|
-
if (ids
|
|
1698
|
+
if (ids?.length) {
|
|
2797
1699
|
e.preventDefault();
|
|
2798
1700
|
e.stopImmediatePropagation();
|
|
2799
1701
|
// Capture a snapshot of all strokes at drag start. Every pointermove
|
|
@@ -2801,27 +1703,118 @@ export function AnnotationsLayer() {
|
|
|
2801
1703
|
// (NOT a delta-from-last-frame mutation), so dragging back to origin
|
|
2802
1704
|
// restores positions exactly. Optimistic state-only updates during
|
|
2803
1705
|
// the move; ONE undo record + ONE server PUT fires on pointerup.
|
|
2804
|
-
const
|
|
1706
|
+
const undoBase = strokesRef.current.slice();
|
|
1707
|
+
const preAltIds = ids;
|
|
1708
|
+
let dragSnapshot = undoBase;
|
|
1709
|
+
let altDup = false;
|
|
1710
|
+
// FigJam v3 — Alt+drag duplicates: clone the (expanded) selection up
|
|
1711
|
+
// front and drag the CLONES; a zero-movement release reverts so a bare
|
|
1712
|
+
// Alt+click can't silently mint copies. undoBase stays pre-clone, so
|
|
1713
|
+
// clone + move commit as one record.
|
|
1714
|
+
if (e.altKey) {
|
|
1715
|
+
const res = duplicateStrokes(undoBase, ids, 0, 0);
|
|
1716
|
+
if (res.newIds.length) {
|
|
1717
|
+
altDup = true;
|
|
1718
|
+
dragSnapshot = res.strokes;
|
|
1719
|
+
setStrokesState(res.strokes);
|
|
1720
|
+
annotSel.replace(res.newIds);
|
|
1721
|
+
ids = res.newIds;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
2805
1724
|
dragStateRef.current = {
|
|
2806
1725
|
pointerId: e.pointerId,
|
|
2807
1726
|
startWX: wx,
|
|
2808
1727
|
startWY: wy,
|
|
2809
1728
|
movedIds: ids,
|
|
2810
1729
|
snapshot: dragSnapshot,
|
|
1730
|
+
undoBase,
|
|
1731
|
+
altDup,
|
|
2811
1732
|
};
|
|
2812
1733
|
const movedSet = new Set(ids);
|
|
1734
|
+
// FigJam v3 — dragging a SECTION carries everything sitting on it
|
|
1735
|
+
// (bbox-center containment, captured at gesture start).
|
|
1736
|
+
for (const s of dragSnapshot) {
|
|
1737
|
+
if (s.tool !== 'section' || !movedSet.has(s.id)) continue;
|
|
1738
|
+
const sx = Math.min(s.x, s.x + s.w);
|
|
1739
|
+
const sy = Math.min(s.y, s.y + s.h);
|
|
1740
|
+
const sx2 = sx + Math.abs(s.w);
|
|
1741
|
+
const sy2 = sy + Math.abs(s.h);
|
|
1742
|
+
for (const t of dragSnapshot) {
|
|
1743
|
+
if (movedSet.has(t.id) || t.tool === 'section') continue;
|
|
1744
|
+
const bb = strokeBBox(t);
|
|
1745
|
+
if (!bb) continue;
|
|
1746
|
+
const ccx = bb.x + bb.w / 2;
|
|
1747
|
+
const ccy = bb.y + bb.h / 2;
|
|
1748
|
+
if (ccx >= sx && ccx <= sx2 && ccy >= sy && ccy <= sy2) movedSet.add(t.id);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
// FigJam v3 — snap setup, computed ONCE per gesture: candidates are
|
|
1752
|
+
// the bboxes of every non-moved stroke plus the artboard rects (in
|
|
1753
|
+
// world coords); the moving hull is the union bbox of the dragged
|
|
1754
|
+
// strokes at drag start.
|
|
1755
|
+
const candidates: Array<{ x: number; y: number; w: number; h: number }> = [];
|
|
1756
|
+
for (const s of dragSnapshot) {
|
|
1757
|
+
if (movedSet.has(s.id)) continue;
|
|
1758
|
+
const bb = strokeBBox(s);
|
|
1759
|
+
if (bb && bb.w > 0 && bb.h > 0) candidates.push(bb);
|
|
1760
|
+
}
|
|
1761
|
+
for (const screenEl of Array.from(document.querySelectorAll('[data-dc-screen]'))) {
|
|
1762
|
+
const r = screenEl.getBoundingClientRect();
|
|
1763
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
1764
|
+
const [ax, ay] = screenToWorld(r.left, r.top);
|
|
1765
|
+
const [bx, by] = screenToWorld(r.right, r.bottom);
|
|
1766
|
+
candidates.push({ x: ax, y: ay, w: bx - ax, h: by - ay });
|
|
1767
|
+
}
|
|
1768
|
+
let hull: { x: number; y: number; w: number; h: number } | null = null;
|
|
1769
|
+
for (const s of dragSnapshot) {
|
|
1770
|
+
if (!movedSet.has(s.id)) continue;
|
|
1771
|
+
const bb = strokeBBox(s);
|
|
1772
|
+
if (!bb) continue;
|
|
1773
|
+
if (!hull) {
|
|
1774
|
+
hull = { ...bb };
|
|
1775
|
+
} else {
|
|
1776
|
+
const hx = Math.min(hull.x, bb.x);
|
|
1777
|
+
const hy = Math.min(hull.y, bb.y);
|
|
1778
|
+
hull = {
|
|
1779
|
+
x: hx,
|
|
1780
|
+
y: hy,
|
|
1781
|
+
w: Math.max(hull.x + hull.w, bb.x + bb.w) - hx,
|
|
1782
|
+
h: Math.max(hull.y + hull.h, bb.y + bb.h) - hy,
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
2813
1787
|
const onMove = (mv: PointerEvent) => {
|
|
2814
1788
|
const st = dragStateRef.current;
|
|
2815
1789
|
if (!st || mv.pointerId !== st.pointerId) return;
|
|
2816
1790
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
2817
|
-
|
|
2818
|
-
|
|
1791
|
+
let dx = cwx - st.startWX;
|
|
1792
|
+
let dy = cwy - st.startWY;
|
|
1793
|
+
// FigJam v3 — edge/center snapping; ⌘ suppresses (Figma convention).
|
|
1794
|
+
// Axes with no smart-guide match fall back to the 24px dot grid
|
|
1795
|
+
// (GRID_PITCH_PX = the DS --canvas-grid pitch).
|
|
1796
|
+
if (hull && !(mv.metaKey || mv.ctrlKey)) {
|
|
1797
|
+
const snap = computeSnap(
|
|
1798
|
+
{ x: hull.x + dx, y: hull.y + dy, w: hull.w, h: hull.h },
|
|
1799
|
+
candidates,
|
|
1800
|
+
SNAP_THRESHOLD_PX / zoom,
|
|
1801
|
+
{ grid: GRID_PITCH_PX }
|
|
1802
|
+
);
|
|
1803
|
+
dx += snap.dx;
|
|
1804
|
+
dy += snap.dy;
|
|
1805
|
+
setSnapGuides(snap.guides.length ? snap.guides : null);
|
|
1806
|
+
} else {
|
|
1807
|
+
setSnapGuides(null);
|
|
1808
|
+
}
|
|
2819
1809
|
// Drag-back-to-origin: restore exact references so the pointerup
|
|
2820
|
-
// shallow-equality check skips committing a no-op record.
|
|
1810
|
+
// shallow-equality check skips committing a no-op record. Bound
|
|
1811
|
+
// arrows re-derive from their hosts so connectors track live.
|
|
2821
1812
|
const next =
|
|
2822
1813
|
dx === 0 && dy === 0
|
|
2823
1814
|
? st.snapshot
|
|
2824
|
-
:
|
|
1815
|
+
: recomputeBoundArrows(
|
|
1816
|
+
st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s))
|
|
1817
|
+
);
|
|
2825
1818
|
// Local React state only. No commitStrokes — no PUT, no undo push.
|
|
2826
1819
|
setStrokesState(next);
|
|
2827
1820
|
};
|
|
@@ -2829,17 +1822,27 @@ export function AnnotationsLayer() {
|
|
|
2829
1822
|
const st = dragStateRef.current;
|
|
2830
1823
|
if (!st || up.pointerId !== st.pointerId) return;
|
|
2831
1824
|
dragStateRef.current = null;
|
|
1825
|
+
setSnapGuides(null);
|
|
2832
1826
|
document.removeEventListener('pointermove', onMove, true);
|
|
2833
1827
|
document.removeEventListener('pointerup', onUp, true);
|
|
2834
1828
|
document.removeEventListener('pointercancel', onUp, true);
|
|
2835
1829
|
// Commit the gesture as ONE record. Skip on zero-movement
|
|
2836
1830
|
// (click without drag past threshold or drag back to origin).
|
|
2837
1831
|
const final = strokesRef.current;
|
|
2838
|
-
if (strokesShallowEqual(st.snapshot, final))
|
|
1832
|
+
if (strokesShallowEqual(st.snapshot, final)) {
|
|
1833
|
+
if (st.altDup) {
|
|
1834
|
+
// Alt+click without a drag — revert the eager clones.
|
|
1835
|
+
setStrokesState(st.undoBase);
|
|
1836
|
+
annotSel.replace(preAltIds);
|
|
1837
|
+
}
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
2839
1840
|
commitStrokes(
|
|
2840
|
-
st.
|
|
1841
|
+
st.undoBase,
|
|
2841
1842
|
final,
|
|
2842
|
-
|
|
1843
|
+
`${st.altDup ? 'duplicate' : 'move'} ${st.movedIds.length} stroke${
|
|
1844
|
+
st.movedIds.length === 1 ? '' : 's'
|
|
1845
|
+
}`
|
|
2843
1846
|
);
|
|
2844
1847
|
};
|
|
2845
1848
|
document.addEventListener('pointermove', onMove, true);
|
|
@@ -2852,8 +1855,10 @@ export function AnnotationsLayer() {
|
|
|
2852
1855
|
// artboard the gesture belongs to artboard-drag / element-marquee — not
|
|
2853
1856
|
// the annotation marquee (post-Wave-3 grievance G5). Checked AFTER the
|
|
2854
1857
|
// group-drag decision so a multi-selection hull-drag still wins even when
|
|
2855
|
-
// the strokes sit over an artboard.
|
|
2856
|
-
|
|
1858
|
+
// the strokes sit over an artboard. FigJam v3 exception: SHIFT+drag is
|
|
1859
|
+
// the additive annotation marquee, so it rubber-bands annotations
|
|
1860
|
+
// sitting ON an artboard instead of falling through to artboard-drag.
|
|
1861
|
+
if (!strokeId && !e.shiftKey && target?.closest?.('[data-dc-screen]')) return;
|
|
2857
1862
|
|
|
2858
1863
|
// Empty world — start a drag-select gesture. A bare click without
|
|
2859
1864
|
// moving clears annotation selection (post-Wave-3 feedback: click-to-
|
|
@@ -2900,8 +1905,11 @@ export function AnnotationsLayer() {
|
|
|
2900
1905
|
}
|
|
2901
1906
|
// Marquee that captured no strokes — preserve existing selection.
|
|
2902
1907
|
if (hits.length === 0) return;
|
|
2903
|
-
|
|
2904
|
-
|
|
1908
|
+
// FigJam v3 — a marquee touching any group member selects the whole
|
|
1909
|
+
// group (tldraw: the brush resolves to the outermost ancestor).
|
|
1910
|
+
const expanded = expandIdsToGroups(hits, strokesStoreRef.current.strokes);
|
|
1911
|
+
if (addToSelection) annotSel.add(expanded);
|
|
1912
|
+
else annotSel.replace(expanded);
|
|
2905
1913
|
};
|
|
2906
1914
|
document.addEventListener('pointermove', onMove, true);
|
|
2907
1915
|
document.addEventListener('pointerup', onUp, true);
|
|
@@ -2935,7 +1943,16 @@ export function AnnotationsLayer() {
|
|
|
2935
1943
|
const id = node.getAttribute('data-id');
|
|
2936
1944
|
const t = node.getAttribute('data-tool');
|
|
2937
1945
|
if (!id) return;
|
|
2938
|
-
|
|
1946
|
+
// FigJam v3 — double-click DEEP-SELECTS a group member (a single click
|
|
1947
|
+
// selects the whole outermost group; Esc clears back out). The editor
|
|
1948
|
+
// still opens below for text-bearing types — FigJam's enter-group-then-
|
|
1949
|
+
// edit flow in one gesture.
|
|
1950
|
+
const stroke = strokesRef.current.find((s) => s.id === id);
|
|
1951
|
+
if (stroke && outermostGroupOf(stroke) && annotSel) {
|
|
1952
|
+
e.preventDefault();
|
|
1953
|
+
annotSel.replace(id);
|
|
1954
|
+
}
|
|
1955
|
+
if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
|
|
2939
1956
|
e.preventDefault();
|
|
2940
1957
|
setEditingId(id);
|
|
2941
1958
|
return;
|
|
@@ -2947,7 +1964,7 @@ export function AnnotationsLayer() {
|
|
|
2947
1964
|
};
|
|
2948
1965
|
document.addEventListener('dblclick', onDbl, true);
|
|
2949
1966
|
return () => document.removeEventListener('dblclick', onDbl, true);
|
|
2950
|
-
}, [tool]);
|
|
1967
|
+
}, [tool, annotSel]);
|
|
2951
1968
|
|
|
2952
1969
|
const commitText = useCallback(
|
|
2953
1970
|
(anchorId: string, text: string, fmt?: EditorFmt) => {
|
|
@@ -3055,43 +2072,520 @@ export function AnnotationsLayer() {
|
|
|
3055
2072
|
[commitStrokes, color, annotSel]
|
|
3056
2073
|
);
|
|
3057
2074
|
|
|
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
|
-
|
|
2075
|
+
// Phase 21 — resolve what (if anything) is being edited, and route a single
|
|
2076
|
+
// commit call to the right writer. `editingId` doubles as the host id
|
|
2077
|
+
// (anchored) OR the sticky/standalone stroke id; `pendingText` is the
|
|
2078
|
+
// not-yet-born text caret.
|
|
2079
|
+
const editingTarget = useMemo<EditingTarget>(() => {
|
|
2080
|
+
if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
|
|
2081
|
+
if (!editingId) return null;
|
|
2082
|
+
const host = anchorsById.get(editingId);
|
|
2083
|
+
if (host) return { kind: 'anchored', anchorId: editingId, host };
|
|
2084
|
+
const s = strokesById.get(editingId);
|
|
2085
|
+
if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
|
|
2086
|
+
if (s?.tool === 'section') return { kind: 'section', section: s };
|
|
2087
|
+
if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
|
|
2088
|
+
return { kind: 'standalone', text: s };
|
|
2089
|
+
return null;
|
|
2090
|
+
}, [pendingText, editingId, anchorsById, strokesById]);
|
|
2091
|
+
|
|
2092
|
+
const editingTargetRef = useRef(editingTarget);
|
|
2093
|
+
editingTargetRef.current = editingTarget;
|
|
2094
|
+
|
|
2095
|
+
const commitEditing = useCallback(
|
|
2096
|
+
(text: string, fmt?: EditorFmt) => {
|
|
2097
|
+
const target = editingTargetRef.current;
|
|
2098
|
+
setEditingId(null);
|
|
2099
|
+
setPendingText(null);
|
|
2100
|
+
if (!target) return;
|
|
2101
|
+
if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
|
|
2102
|
+
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
|
|
2103
|
+
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
|
|
2104
|
+
else if (target.kind === 'section') {
|
|
2105
|
+
const label = text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
|
|
2106
|
+
if (label !== target.section.label) {
|
|
2107
|
+
strokesStoreRef.current.updateStroke(target.section.id, {
|
|
2108
|
+
label,
|
|
2109
|
+
} as Partial<Stroke>);
|
|
2110
|
+
}
|
|
2111
|
+
} else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text, fmt);
|
|
2112
|
+
},
|
|
2113
|
+
[commitText, commitStickyText, commitStandaloneText, createStandaloneText]
|
|
2114
|
+
);
|
|
2115
|
+
|
|
2116
|
+
const cancelEditing = useCallback(() => {
|
|
2117
|
+
setEditingId(null);
|
|
2118
|
+
setPendingText(null);
|
|
2119
|
+
}, []);
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* FigJam v3 — copy/cut the (expanded) selection to the OS clipboard as a
|
|
2123
|
+
* `{"maudeStrokes":1}` JSON text payload. Shared by ⌘C/⌘X and the
|
|
2124
|
+
* right-click menu. Returns true when something was copied.
|
|
2125
|
+
*/
|
|
2126
|
+
const copySelection = useCallback(
|
|
2127
|
+
(cut: boolean): boolean => {
|
|
2128
|
+
if (!annotSel) return false;
|
|
2129
|
+
const sel = annotSel.selectedIds;
|
|
2130
|
+
if (sel.length === 0) return false;
|
|
2131
|
+
const store = strokesStoreRef.current;
|
|
2132
|
+
const expanded = new Set(expandIdsToGroups(sel, store.strokes));
|
|
2133
|
+
const payload = store.strokes.filter(
|
|
2134
|
+
(s) =>
|
|
2135
|
+
expanded.has(s.id) ||
|
|
2136
|
+
(s.tool === 'text' && s.anchorId != null && expanded.has(s.anchorId))
|
|
2137
|
+
);
|
|
2138
|
+
if (payload.length === 0) return false;
|
|
2139
|
+
try {
|
|
2140
|
+
void navigator.clipboard
|
|
2141
|
+
?.writeText(JSON.stringify({ maudeStrokes: 1, strokes: payload }))
|
|
2142
|
+
.catch(() => {
|
|
2143
|
+
/* clipboard permission denied — copy is best-effort */
|
|
2144
|
+
});
|
|
2145
|
+
} catch {
|
|
2146
|
+
/* clipboard API absent — non-fatal */
|
|
2147
|
+
}
|
|
2148
|
+
if (cut) {
|
|
2149
|
+
store.deleteStrokes([...expanded]);
|
|
2150
|
+
annotSel.clear();
|
|
2151
|
+
}
|
|
2152
|
+
return true;
|
|
2153
|
+
},
|
|
2154
|
+
[annotSel]
|
|
2155
|
+
);
|
|
2156
|
+
|
|
2157
|
+
/**
|
|
2158
|
+
* FigJam v3 — paste a strokes JSON payload (⌘V or the right-click menu).
|
|
2159
|
+
* Round-trips through the serializer + parser so a malformed foreign payload
|
|
2160
|
+
* coerces to valid strokes or drops; clones get fresh ids + a +16/+16 offset.
|
|
2161
|
+
*/
|
|
2162
|
+
const pasteStrokesText = useCallback(
|
|
2163
|
+
(txt: string): boolean => {
|
|
2164
|
+
if (!annotSel) return false;
|
|
2165
|
+
if (!txt.startsWith('{"maudeStrokes"')) return false;
|
|
2166
|
+
let parsed: { maudeStrokes?: number; strokes?: unknown } | null = null;
|
|
2167
|
+
try {
|
|
2168
|
+
parsed = JSON.parse(txt) as { maudeStrokes?: number; strokes?: unknown };
|
|
2169
|
+
} catch {
|
|
2170
|
+
return false;
|
|
2171
|
+
}
|
|
2172
|
+
if (parsed?.maudeStrokes !== 1 || !Array.isArray(parsed.strokes)) return false;
|
|
2173
|
+
let safe: Stroke[] = [];
|
|
2174
|
+
try {
|
|
2175
|
+
safe = svgToStrokes(strokesToSvg(parsed.strokes as Stroke[]));
|
|
2176
|
+
} catch {
|
|
2177
|
+
return false;
|
|
2178
|
+
}
|
|
2179
|
+
if (safe.length === 0) return false;
|
|
2180
|
+
const res = duplicateStrokes(
|
|
2181
|
+
safe,
|
|
2182
|
+
safe.map((s) => s.id),
|
|
2183
|
+
16,
|
|
2184
|
+
16
|
|
2185
|
+
);
|
|
2186
|
+
const clones = res.strokes.slice(safe.length);
|
|
2187
|
+
if (clones.length === 0) return false;
|
|
2188
|
+
const prev = strokesRef.current;
|
|
2189
|
+
// recompute keeps binds to hosts present in THIS canvas and strips the
|
|
2190
|
+
// cross-canvas danglers (endpoint frozen).
|
|
2191
|
+
const next = recomputeBoundArrows([...prev, ...clones]);
|
|
2192
|
+
commitStrokes(prev, next, `paste ${clones.length} stroke${clones.length === 1 ? '' : 's'}`);
|
|
2193
|
+
annotSel.replace(res.newIds);
|
|
2194
|
+
return true;
|
|
2195
|
+
},
|
|
2196
|
+
[annotSel, commitStrokes]
|
|
2197
|
+
);
|
|
2198
|
+
|
|
2199
|
+
/**
|
|
2200
|
+
* FigJam v3 — quick-create chain (⌘Enter): with a sticky or shape selected,
|
|
2201
|
+
* spawn a sibling of the same type/size/style to the right with its editor
|
|
2202
|
+
* active. Shapes ALSO get a bound connector source → sibling (FigJam quick-
|
|
2203
|
+
* create: shapes connect, stickies don't). Returns true when spawned.
|
|
2204
|
+
*/
|
|
2205
|
+
const chainCreate = useCallback(
|
|
2206
|
+
(sourceId: string): boolean => {
|
|
2207
|
+
const prev = strokesRef.current;
|
|
2208
|
+
const src = prev.find((s) => s.id === sourceId);
|
|
2209
|
+
if (!src) return false;
|
|
2210
|
+
if (
|
|
2211
|
+
src.tool !== 'sticky' &&
|
|
2212
|
+
src.tool !== 'rect' &&
|
|
2213
|
+
src.tool !== 'ellipse' &&
|
|
2214
|
+
src.tool !== 'polygon'
|
|
2215
|
+
) {
|
|
2216
|
+
return false;
|
|
2217
|
+
}
|
|
2218
|
+
const bb = strokeBBox(src);
|
|
2219
|
+
if (!bb) return false;
|
|
2220
|
+
const gap = src.tool === 'sticky' ? 40 : 64;
|
|
2221
|
+
const nid = rid();
|
|
2222
|
+
// The sibling copies style + size but starts loose (no group membership,
|
|
2223
|
+
// human provenance) and empty-bodied. Undefined-assignment (not rest-
|
|
2224
|
+
// destructuring) keeps the discriminated-union narrowing intact for the
|
|
2225
|
+
// branches below; the serializer treats undefined as absent.
|
|
2226
|
+
const bare = structuredClone(src);
|
|
2227
|
+
bare.groupIds = undefined;
|
|
2228
|
+
bare.author = undefined;
|
|
2229
|
+
let sibling: Stroke;
|
|
2230
|
+
if (bare.tool === 'ellipse') {
|
|
2231
|
+
sibling = { ...bare, id: nid, cx: bare.cx + bb.w + gap };
|
|
2232
|
+
} else if (bare.tool === 'sticky') {
|
|
2233
|
+
sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y, text: '' };
|
|
2234
|
+
} else {
|
|
2235
|
+
sibling = { ...bare, id: nid, x: bb.x + bb.w + gap, y: bb.y };
|
|
2236
|
+
}
|
|
2237
|
+
let next: Stroke[] = [...prev, sibling];
|
|
2238
|
+
if (src.tool !== 'sticky') {
|
|
2239
|
+
const p1 = anchorPoint(src, 1, 0.5);
|
|
2240
|
+
const p2 = anchorPoint(sibling, 0, 0.5);
|
|
2241
|
+
if (p1 && p2) {
|
|
2242
|
+
next = [
|
|
2243
|
+
...next,
|
|
2244
|
+
{
|
|
2245
|
+
id: rid(),
|
|
2246
|
+
tool: 'arrow',
|
|
2247
|
+
color: resolveDefaultInk(theme),
|
|
2248
|
+
width: STROKE_WIDTH_THIN,
|
|
2249
|
+
x1: p1[0],
|
|
2250
|
+
y1: p1[1],
|
|
2251
|
+
x2: p2[0],
|
|
2252
|
+
y2: p2[1],
|
|
2253
|
+
startBind: { hostId: src.id, nx: 1, ny: 0.5 },
|
|
2254
|
+
endBind: { hostId: nid, nx: 0, ny: 0.5 },
|
|
2255
|
+
},
|
|
2256
|
+
];
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
commitStrokes(prev, next, `quick-create ${sibling.tool}`);
|
|
2260
|
+
annotSel?.replace(nid);
|
|
2261
|
+
// Sticky → body editor; rect/ellipse/polygon → anchored-text editor
|
|
2262
|
+
// (Wave G widened anchorsById to every closed shape).
|
|
2263
|
+
if (
|
|
2264
|
+
sibling.tool === 'sticky' ||
|
|
2265
|
+
sibling.tool === 'rect' ||
|
|
2266
|
+
sibling.tool === 'ellipse' ||
|
|
2267
|
+
sibling.tool === 'polygon'
|
|
2268
|
+
) {
|
|
2269
|
+
setEditingId(nid);
|
|
2270
|
+
}
|
|
2271
|
+
return true;
|
|
2272
|
+
},
|
|
2273
|
+
[commitStrokes, annotSel, theme]
|
|
2274
|
+
);
|
|
2275
|
+
|
|
2276
|
+
// FigJam v3 — ⌘Enter pressed INSIDE a sticky/anchored editor commits there
|
|
2277
|
+
// and asks the layer (via this event) to chain the next sibling. Deferred a
|
|
2278
|
+
// tick so the editor's commit lands in strokesRef first.
|
|
2279
|
+
useEffect(() => {
|
|
2280
|
+
if (typeof document === 'undefined') return;
|
|
2281
|
+
const onChain = (e: Event) => {
|
|
2282
|
+
const id = (e as CustomEvent<{ id?: string }>).detail?.id;
|
|
2283
|
+
if (!id) return;
|
|
2284
|
+
window.setTimeout(() => {
|
|
2285
|
+
chainCreate(id);
|
|
2286
|
+
}, 0);
|
|
2287
|
+
};
|
|
2288
|
+
document.addEventListener('maude:chain-create', onChain);
|
|
2289
|
+
return () => document.removeEventListener('maude:chain-create', onChain);
|
|
2290
|
+
}, [chainCreate]);
|
|
2291
|
+
|
|
2292
|
+
// FigJam v3 — the resize overlay (a sibling component that owns the arrow
|
|
2293
|
+
// endpoint handles) broadcasts the bind candidate while an endpoint drags;
|
|
2294
|
+
// the halo renders here because the SVG layer owns the world overlay.
|
|
2295
|
+
useEffect(() => {
|
|
2296
|
+
if (typeof document === 'undefined') return;
|
|
2297
|
+
const onHint = (e: Event) => {
|
|
2298
|
+
setBindHintId((e as CustomEvent<{ hostId?: string | null }>).detail?.hostId ?? null);
|
|
2299
|
+
};
|
|
2300
|
+
document.addEventListener('maude:bind-hint', onHint);
|
|
2301
|
+
return () => document.removeEventListener('maude:bind-hint', onHint);
|
|
2302
|
+
}, []);
|
|
2303
|
+
|
|
2304
|
+
// FigJam v3 — live size label + dimension-match halos while resizing (the
|
|
2305
|
+
// overlay broadcasts; the SVG layer paints).
|
|
2306
|
+
const [resizeInfo, setResizeInfo] = useState<{
|
|
2307
|
+
box: { x: number; y: number; w: number; h: number } | null;
|
|
2308
|
+
matchIds: string[];
|
|
2309
|
+
} | null>(null);
|
|
2310
|
+
useEffect(() => {
|
|
2311
|
+
if (typeof document === 'undefined') return;
|
|
2312
|
+
const onInfo = (e: Event) => {
|
|
2313
|
+
const detail = (
|
|
2314
|
+
e as CustomEvent<{
|
|
2315
|
+
box?: { x: number; y: number; w: number; h: number } | null;
|
|
2316
|
+
matchIds?: string[];
|
|
2317
|
+
}>
|
|
2318
|
+
).detail;
|
|
2319
|
+
setResizeInfo(detail?.box ? { box: detail.box, matchIds: detail.matchIds ?? [] } : null);
|
|
2320
|
+
};
|
|
2321
|
+
document.addEventListener('maude:resize-info', onInfo);
|
|
2322
|
+
return () => document.removeEventListener('maude:resize-info', onInfo);
|
|
2323
|
+
}, []);
|
|
2324
|
+
|
|
2325
|
+
// FigJam v3 — manipulation shortcuts: ⌘G group / ⌘⇧G ungroup, ⌘D duplicate,
|
|
2326
|
+
// ] [ ⌘] ⌘[ z-order, ⌘C/⌘X copy/cut (selection → OS clipboard as a JSON
|
|
2327
|
+
// text payload), ⌘Enter quick-create chain. Document capture, mirroring the
|
|
2328
|
+
// nudge handler below; the input-router never claims these combos.
|
|
2329
|
+
useEffect(() => {
|
|
2330
|
+
if (typeof document === 'undefined') return;
|
|
2331
|
+
if (!annotSel) return;
|
|
2332
|
+
const onKey = (e: KeyboardEvent) => {
|
|
2333
|
+
if (isEditable(e.target)) return;
|
|
2334
|
+
const store = strokesStoreRef.current;
|
|
2335
|
+
const sel = annotSel.selectedIds;
|
|
2336
|
+
const cmd = e.metaKey || e.ctrlKey;
|
|
2337
|
+
const k = e.key.toLowerCase();
|
|
2338
|
+
if (cmd && !e.altKey && k === 'g') {
|
|
2339
|
+
// ⌘G claims the browser's find-next ONLY when a selection exists.
|
|
2340
|
+
if (sel.length === 0) return;
|
|
2341
|
+
e.preventDefault();
|
|
2342
|
+
e.stopImmediatePropagation();
|
|
2343
|
+
if (e.shiftKey) {
|
|
2344
|
+
store.ungroupSelection(sel);
|
|
2345
|
+
} else {
|
|
2346
|
+
const members = store.groupSelection(sel);
|
|
2347
|
+
if (members) annotSel.replace(members);
|
|
2348
|
+
}
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
if (cmd && !e.shiftKey && !e.altKey && k === 'd') {
|
|
2352
|
+
if (sel.length === 0) return; // browser bookmark stays available
|
|
2353
|
+
e.preventDefault();
|
|
2354
|
+
e.stopImmediatePropagation();
|
|
2355
|
+
const ids = store.duplicateSelection(sel, 16, 16);
|
|
2356
|
+
if (ids.length) annotSel.replace(ids);
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
if ((e.key === ']' || e.key === '[') && !e.altKey && !e.shiftKey) {
|
|
2360
|
+
if (sel.length === 0) return;
|
|
2361
|
+
e.preventDefault();
|
|
2362
|
+
const op: ZOrderOp =
|
|
2363
|
+
e.key === ']' ? (cmd ? 'forward' : 'front') : cmd ? 'backward' : 'back';
|
|
2364
|
+
store.reorderSelection(sel, op);
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
if (cmd && !e.shiftKey && !e.altKey && (k === 'c' || k === 'x')) {
|
|
2368
|
+
if (sel.length === 0) return; // let the native copy run
|
|
2369
|
+
if (copySelection(k === 'x')) e.preventDefault();
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
if (cmd && e.key === 'Enter' && !e.shiftKey && !e.altKey) {
|
|
2373
|
+
if (sel.length !== 1) return;
|
|
2374
|
+
const only = sel[0];
|
|
2375
|
+
if (only && chainCreate(only)) e.preventDefault();
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
// FigJam v3 — plain Enter on a single text-capable stroke opens its
|
|
2379
|
+
// editor (FigJam: select a shape, press Enter, start typing).
|
|
2380
|
+
if (e.key === 'Enter' && !cmd && !e.shiftKey && !e.altKey) {
|
|
2381
|
+
if (sel.length !== 1) return;
|
|
2382
|
+
const only = strokesRef.current.find((x) => x.id === sel[0]);
|
|
2383
|
+
if (!only) return;
|
|
2384
|
+
if (
|
|
2385
|
+
only.tool === 'rect' ||
|
|
2386
|
+
only.tool === 'ellipse' ||
|
|
2387
|
+
only.tool === 'polygon' ||
|
|
2388
|
+
only.tool === 'sticky' ||
|
|
2389
|
+
(only.tool === 'text' && (only.anchorId == null || only.anchorId === ''))
|
|
2390
|
+
) {
|
|
2391
|
+
e.preventDefault();
|
|
2392
|
+
setEditingId(only.id);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
};
|
|
2396
|
+
document.addEventListener('keydown', onKey, true);
|
|
2397
|
+
return () => document.removeEventListener('keydown', onKey, true);
|
|
2398
|
+
}, [annotSel, chainCreate, copySelection]);
|
|
2399
|
+
|
|
2400
|
+
// FigJam v3 — paste strokes. ⌘C serialized the selection as a JSON text
|
|
2401
|
+
// payload; this CAPTURE-phase listener claims it before the media-intake
|
|
2402
|
+
// hook (bubble phase) so a strokes payload never falls through to the URL/
|
|
2403
|
+
// link branch.
|
|
2404
|
+
useEffect(() => {
|
|
2405
|
+
if (typeof document === 'undefined') return;
|
|
2406
|
+
if (!annotSel) return;
|
|
2407
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
2408
|
+
if (isEditable(e.target)) return;
|
|
2409
|
+
const txt = e.clipboardData?.getData('text/plain') ?? '';
|
|
2410
|
+
if (!txt.startsWith('{"maudeStrokes"')) return;
|
|
2411
|
+
e.preventDefault();
|
|
2412
|
+
e.stopImmediatePropagation();
|
|
2413
|
+
pasteStrokesText(txt);
|
|
2414
|
+
};
|
|
2415
|
+
document.addEventListener('paste', onPaste, true);
|
|
2416
|
+
return () => document.removeEventListener('paste', onPaste, true);
|
|
2417
|
+
}, [annotSel, pasteStrokesText]);
|
|
3073
2418
|
|
|
3074
|
-
|
|
3075
|
-
|
|
2419
|
+
// FigJam v3 — hover "Add text" affordance: an empty rect/ellipse hovered in
|
|
2420
|
+
// move mode shows a ghost label; double-click (existing) or Enter edits.
|
|
2421
|
+
const [addTextHintId, setAddTextHintId] = useState<string | null>(null);
|
|
2422
|
+
useEffect(() => {
|
|
2423
|
+
if (typeof document === 'undefined') return;
|
|
2424
|
+
if (tool !== 'move') {
|
|
2425
|
+
setAddTextHintId(null);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
const onMove = (e: PointerEvent) => {
|
|
2429
|
+
const node = (e.target as Element | null)?.closest?.('[data-id][data-tool]');
|
|
2430
|
+
const t = node?.getAttribute('data-tool');
|
|
2431
|
+
const id = node?.getAttribute('data-id') ?? null;
|
|
2432
|
+
if (!id || (t !== 'rect' && t !== 'ellipse' && t !== 'polygon')) {
|
|
2433
|
+
setAddTextHintId(null);
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
const hasText = strokesRef.current.some(
|
|
2437
|
+
(x) => x.tool === 'text' && x.anchorId === id && x.text.length > 0
|
|
2438
|
+
);
|
|
2439
|
+
setAddTextHintId(hasText ? null : id);
|
|
2440
|
+
};
|
|
2441
|
+
document.addEventListener('pointermove', onMove, { passive: true });
|
|
2442
|
+
return () => document.removeEventListener('pointermove', onMove);
|
|
2443
|
+
}, [tool]);
|
|
3076
2444
|
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
}
|
|
3088
|
-
|
|
3089
|
-
);
|
|
2445
|
+
// FigJam v3 — connector draft: dragging from a connection dot (the side
|
|
2446
|
+
// magnets shown on a selected bindable shape) draws a BOUND curved
|
|
2447
|
+
// connector; releasing over another bindable shape binds the far end too
|
|
2448
|
+
// (⌘ keeps it free). The draft renders through the same arrow primitives.
|
|
2449
|
+
const [connDraft, setConnDraft] = useState<{
|
|
2450
|
+
x1: number;
|
|
2451
|
+
y1: number;
|
|
2452
|
+
x2: number;
|
|
2453
|
+
y2: number;
|
|
2454
|
+
startBind: { hostId: string; nx: number; ny: number };
|
|
2455
|
+
endBind?: { hostId: string; nx: number; ny: number };
|
|
2456
|
+
} | null>(null);
|
|
2457
|
+
const connDraftRef = useRef(connDraft);
|
|
2458
|
+
connDraftRef.current = connDraft;
|
|
2459
|
+
useEffect(() => {
|
|
2460
|
+
if (typeof document === 'undefined') return;
|
|
2461
|
+
if (tool !== 'move') return;
|
|
2462
|
+
const onDown = (e: PointerEvent) => {
|
|
2463
|
+
if (e.button !== 0) return;
|
|
2464
|
+
const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
|
|
2465
|
+
if (!dot) return;
|
|
2466
|
+
const hostId = dot.getAttribute('data-host') ?? '';
|
|
2467
|
+
const nx = Number.parseFloat(dot.getAttribute('data-nx') ?? '');
|
|
2468
|
+
const ny = Number.parseFloat(dot.getAttribute('data-ny') ?? '');
|
|
2469
|
+
const host = strokesRef.current.find((s) => s.id === hostId);
|
|
2470
|
+
if (!host || !Number.isFinite(nx) || !Number.isFinite(ny)) return;
|
|
2471
|
+
const pt = anchorPoint(host, nx, ny);
|
|
2472
|
+
if (!pt) return;
|
|
2473
|
+
e.preventDefault();
|
|
2474
|
+
e.stopImmediatePropagation();
|
|
2475
|
+
const pointerId = e.pointerId;
|
|
2476
|
+
const startBind = { hostId, nx, ny };
|
|
2477
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: pt[0], y2: pt[1], startBind });
|
|
2478
|
+
const onMove = (mv: PointerEvent) => {
|
|
2479
|
+
if (mv.pointerId !== pointerId) return;
|
|
2480
|
+
const [wx, wy] = screenToWorld(mv.clientX, mv.clientY);
|
|
2481
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
2482
|
+
const cand =
|
|
2483
|
+
mv.metaKey || mv.ctrlKey
|
|
2484
|
+
? null
|
|
2485
|
+
: bindCandidate(
|
|
2486
|
+
wx,
|
|
2487
|
+
wy,
|
|
2488
|
+
strokesRef.current,
|
|
2489
|
+
BIND_THRESHOLD_PX / zoom,
|
|
2490
|
+
new Set([hostId])
|
|
2491
|
+
);
|
|
2492
|
+
setBindHintId(cand?.hostId ?? null);
|
|
2493
|
+
if (cand) {
|
|
2494
|
+
const target = strokesRef.current.find((s) => s.id === cand.hostId);
|
|
2495
|
+
const tp = target ? anchorPoint(target, cand.nx, cand.ny) : null;
|
|
2496
|
+
if (tp) {
|
|
2497
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: tp[0], y2: tp[1], startBind, endBind: cand });
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
setConnDraft({ x1: pt[0], y1: pt[1], x2: wx, y2: wy, startBind });
|
|
2502
|
+
};
|
|
2503
|
+
const onUp = (up: PointerEvent) => {
|
|
2504
|
+
if (up.pointerId !== pointerId) return;
|
|
2505
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
2506
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
2507
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
2508
|
+
const draft = connDraftRef.current;
|
|
2509
|
+
setConnDraft(null);
|
|
2510
|
+
setBindHintId(null);
|
|
2511
|
+
if (!draft) return;
|
|
2512
|
+
// A bare tap on the dot creates nothing.
|
|
2513
|
+
if (Math.hypot(draft.x2 - draft.x1, draft.y2 - draft.y1) < 8) return;
|
|
2514
|
+
const arrow: ArrowStroke = {
|
|
2515
|
+
id: rid(),
|
|
2516
|
+
tool: 'arrow',
|
|
2517
|
+
color: resolveDefaultInk(theme),
|
|
2518
|
+
width: STROKE_WIDTH_THIN,
|
|
2519
|
+
x1: draft.x1,
|
|
2520
|
+
y1: draft.y1,
|
|
2521
|
+
x2: draft.x2,
|
|
2522
|
+
y2: draft.y2,
|
|
2523
|
+
lineType: 'curved',
|
|
2524
|
+
startBind: draft.startBind,
|
|
2525
|
+
...(draft.endBind ? { endBind: draft.endBind } : {}),
|
|
2526
|
+
};
|
|
2527
|
+
const prev = strokesRef.current;
|
|
2528
|
+
commitStrokes(prev, [...prev, arrow], 'draw connector');
|
|
2529
|
+
annotSel?.replace(arrow.id);
|
|
2530
|
+
if (arrow.endBind) {
|
|
2531
|
+
showOnceHint(
|
|
2532
|
+
'bind',
|
|
2533
|
+
'Arrow attached — it follows the shape now. Drag an endpoint to re-anchor, hold ⌘ to keep it free.'
|
|
2534
|
+
);
|
|
2535
|
+
}
|
|
2536
|
+
};
|
|
2537
|
+
document.addEventListener('pointermove', onMove, true);
|
|
2538
|
+
document.addEventListener('pointerup', onUp, true);
|
|
2539
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
2540
|
+
};
|
|
2541
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2542
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2543
|
+
}, [tool, screenToWorld, commitStrokes, annotSel, theme]);
|
|
3090
2544
|
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
2545
|
+
// FigJam v3 — right-click on a stroke SELECTS it (keeping a multi-selection
|
|
2546
|
+
// the press lands inside) and opens the annotation context menu (z-order,
|
|
2547
|
+
// group, copy/paste, delete). Capture phase on document so it claims the
|
|
2548
|
+
// event before the input-router's host-level contextmenu handler.
|
|
2549
|
+
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
|
2550
|
+
useEffect(() => {
|
|
2551
|
+
if (typeof document === 'undefined') return;
|
|
2552
|
+
if (tool !== 'move') return;
|
|
2553
|
+
if (!annotSel) return;
|
|
2554
|
+
const strokeAt = (target: Element | null): string | null => {
|
|
2555
|
+
if (target?.closest?.(CHROME_SELECTOR)) return null;
|
|
2556
|
+
const node = target?.closest?.('[data-id][data-tool]');
|
|
2557
|
+
const id = node?.getAttribute('data-id');
|
|
2558
|
+
if (!id || !strokesRef.current.some((s) => s.id === id)) return null;
|
|
2559
|
+
return id;
|
|
2560
|
+
};
|
|
2561
|
+
const onCtx = (e: MouseEvent) => {
|
|
2562
|
+
const id = strokeAt(e.target as Element | null);
|
|
2563
|
+
if (!id) return;
|
|
2564
|
+
e.preventDefault();
|
|
2565
|
+
e.stopImmediatePropagation();
|
|
2566
|
+
if (!annotSel.contains(id)) {
|
|
2567
|
+
annotSel.replace(expandIdsToGroups([id], strokesRef.current));
|
|
2568
|
+
}
|
|
2569
|
+
setCtxMenu({ x: e.clientX, y: e.clientY });
|
|
2570
|
+
};
|
|
2571
|
+
// Wave G — the input-router ALSO opens the shell canvas menu from a
|
|
2572
|
+
// right-button POINTERDOWN (classify maps button 2 → 'context-menu'), so
|
|
2573
|
+
// claiming only the contextmenu event left BOTH menus open. This document-
|
|
2574
|
+
// capture listener fires before the router's host-capture one and stops
|
|
2575
|
+
// propagation WITHOUT preventDefault, so the native contextmenu event
|
|
2576
|
+
// (which opens OUR menu above) still follows.
|
|
2577
|
+
const onDown = (e: PointerEvent) => {
|
|
2578
|
+
if (e.button !== 2) return;
|
|
2579
|
+
if (!strokeAt(e.target as Element | null)) return;
|
|
2580
|
+
e.stopImmediatePropagation();
|
|
2581
|
+
};
|
|
2582
|
+
document.addEventListener('contextmenu', onCtx, true);
|
|
2583
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2584
|
+
return () => {
|
|
2585
|
+
document.removeEventListener('contextmenu', onCtx, true);
|
|
2586
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
2587
|
+
};
|
|
2588
|
+
}, [tool, annotSel]);
|
|
3095
2589
|
|
|
3096
2590
|
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
3097
2591
|
useEffect(() => {
|
|
@@ -3132,6 +2626,62 @@ export function AnnotationsLayer() {
|
|
|
3132
2626
|
return () => document.removeEventListener('keydown', onKey, true);
|
|
3133
2627
|
}, [annotSel, strokesStore]);
|
|
3134
2628
|
|
|
2629
|
+
/** FigJam v3 — context-menu action dispatcher (shares the shortcut paths). */
|
|
2630
|
+
const onMenuAction = useCallback(
|
|
2631
|
+
(action: string) => {
|
|
2632
|
+
const store = strokesStoreRef.current;
|
|
2633
|
+
if (!annotSel) return;
|
|
2634
|
+
const sel = annotSel.selectedIds;
|
|
2635
|
+
if (action === 'copy') copySelection(false);
|
|
2636
|
+
else if (action === 'cut') copySelection(true);
|
|
2637
|
+
else if (action === 'paste') {
|
|
2638
|
+
try {
|
|
2639
|
+
void navigator.clipboard
|
|
2640
|
+
?.readText()
|
|
2641
|
+
.then((t) => {
|
|
2642
|
+
pasteStrokesText(t);
|
|
2643
|
+
})
|
|
2644
|
+
.catch(() => {
|
|
2645
|
+
/* clipboard read blocked — paste is best-effort from the menu */
|
|
2646
|
+
});
|
|
2647
|
+
} catch {
|
|
2648
|
+
/* clipboard API absent */
|
|
2649
|
+
}
|
|
2650
|
+
} else if (action === 'duplicate') {
|
|
2651
|
+
const ids = store.duplicateSelection(sel, 16, 16);
|
|
2652
|
+
if (ids.length) annotSel.replace(ids);
|
|
2653
|
+
} else if (action === 'delete') {
|
|
2654
|
+
store.deleteStrokes(sel);
|
|
2655
|
+
annotSel.clear();
|
|
2656
|
+
} else if (
|
|
2657
|
+
action === 'front' ||
|
|
2658
|
+
action === 'forward' ||
|
|
2659
|
+
action === 'backward' ||
|
|
2660
|
+
action === 'back'
|
|
2661
|
+
) {
|
|
2662
|
+
store.reorderSelection(sel, action);
|
|
2663
|
+
} else if (action === 'group') {
|
|
2664
|
+
const members = store.groupSelection(sel);
|
|
2665
|
+
if (members) annotSel.replace(members);
|
|
2666
|
+
} else if (action === 'ungroup') {
|
|
2667
|
+
store.ungroupSelection(sel);
|
|
2668
|
+
}
|
|
2669
|
+
},
|
|
2670
|
+
[annotSel, copySelection, pasteStrokesText]
|
|
2671
|
+
);
|
|
2672
|
+
|
|
2673
|
+
// FigJam v3 — first time a multi-selection lands, surface the group /
|
|
2674
|
+
// duplicate affordances once (behaviour-triggered, never a tour).
|
|
2675
|
+
const selCount = annotSel?.selectedIds.length ?? 0;
|
|
2676
|
+
useEffect(() => {
|
|
2677
|
+
if (selCount >= 2) {
|
|
2678
|
+
showOnceHint(
|
|
2679
|
+
'multi',
|
|
2680
|
+
'⌘G groups the selection · drag inside the box moves everything · ⌘D duplicates.'
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
}, [selCount]);
|
|
2684
|
+
|
|
3135
2685
|
// Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
|
|
3136
2686
|
const selectedStrokes = useMemo(() => {
|
|
3137
2687
|
if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
|
|
@@ -3145,54 +2695,76 @@ export function AnnotationsLayer() {
|
|
|
3145
2695
|
|
|
3146
2696
|
return (
|
|
3147
2697
|
<StrokesStoreContext.Provider value={strokesStore}>
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
2698
|
+
<AnnotationsInput
|
|
2699
|
+
isActive={isActive}
|
|
2700
|
+
visible={visible}
|
|
2701
|
+
cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
|
|
2702
|
+
beginStroke={beginStroke}
|
|
2703
|
+
moveStroke={moveStroke}
|
|
2704
|
+
endStroke={endStroke}
|
|
2705
|
+
onLeave={() => setGhost(null)}
|
|
2706
|
+
/>
|
|
2707
|
+
{visible ? (
|
|
2708
|
+
<AnnotationsSvg
|
|
2709
|
+
worldRef={worldRef}
|
|
2710
|
+
strokes={renderStrokes}
|
|
2711
|
+
anchorsById={anchorsById}
|
|
2712
|
+
selectMode={tool === 'move'}
|
|
2713
|
+
selectedStrokes={selectedStrokes}
|
|
2714
|
+
marquee={marquee}
|
|
2715
|
+
snapGuides={snapGuides}
|
|
2716
|
+
bindHintId={bindHintId}
|
|
2717
|
+
resizeInfo={resizeInfo}
|
|
2718
|
+
connDraft={connDraft}
|
|
2719
|
+
addTextHintId={editingTarget ? null : addTextHintId}
|
|
2720
|
+
ghost={ghostPreview}
|
|
2721
|
+
editingTarget={editingTarget}
|
|
2722
|
+
inkColor={color}
|
|
2723
|
+
onCommitEdit={commitEditing}
|
|
2724
|
+
onCancelEdit={cancelEditing}
|
|
3157
2725
|
/>
|
|
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
|
-
|
|
2726
|
+
) : null}
|
|
2727
|
+
<AnnotationContextToolbar
|
|
2728
|
+
editingId={
|
|
2729
|
+
editingTarget?.kind === 'anchored'
|
|
2730
|
+
? editingTarget.anchorId
|
|
2731
|
+
: editingTarget?.kind === 'sticky'
|
|
2732
|
+
? editingTarget.sticky.id
|
|
2733
|
+
: editingTarget?.kind === 'standalone'
|
|
2734
|
+
? editingTarget.text.id
|
|
2735
|
+
: null
|
|
2736
|
+
}
|
|
2737
|
+
/>
|
|
2738
|
+
{ctxMenu && annotSel ? (
|
|
2739
|
+
<AnnotationContextMenu
|
|
2740
|
+
pos={ctxMenu}
|
|
2741
|
+
selCount={annotSel.selectedIds.length}
|
|
2742
|
+
canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
|
|
2743
|
+
onAction={onMenuAction}
|
|
2744
|
+
onClose={() => setCtxMenu(null)}
|
|
2745
|
+
/>
|
|
2746
|
+
) : null}
|
|
2747
|
+
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
2748
|
+
{isActive ? (
|
|
2749
|
+
<AnnotationsChrome
|
|
2750
|
+
tool={tool}
|
|
2751
|
+
theme={theme}
|
|
2752
|
+
color={color}
|
|
2753
|
+
setColor={setColor}
|
|
2754
|
+
stickyColor={stickyColor}
|
|
2755
|
+
setStickyColor={setStickyColor}
|
|
2756
|
+
highlighterColor={highlighterColor}
|
|
2757
|
+
setHighlighterColor={setHighlighterColor}
|
|
2758
|
+
highlighterWidth={highlighterWidth}
|
|
2759
|
+
setHighlighterWidth={setHighlighterWidth}
|
|
2760
|
+
supportsFill={supportsFill}
|
|
2761
|
+
fill={fill}
|
|
2762
|
+
setFill={setFill}
|
|
2763
|
+
supportsThickness={supportsThickness}
|
|
2764
|
+
thickness={thickness}
|
|
2765
|
+
setThickness={setThickness}
|
|
2766
|
+
/>
|
|
2767
|
+
) : null}
|
|
3196
2768
|
</StrokesStoreContext.Provider>
|
|
3197
2769
|
);
|
|
3198
2770
|
}
|
|
@@ -3286,6 +2858,11 @@ function AnnotationsSvg({
|
|
|
3286
2858
|
selectMode,
|
|
3287
2859
|
selectedStrokes,
|
|
3288
2860
|
marquee,
|
|
2861
|
+
snapGuides,
|
|
2862
|
+
bindHintId,
|
|
2863
|
+
resizeInfo,
|
|
2864
|
+
connDraft,
|
|
2865
|
+
addTextHintId,
|
|
3289
2866
|
ghost,
|
|
3290
2867
|
editingTarget,
|
|
3291
2868
|
inkColor,
|
|
@@ -3294,10 +2871,30 @@ function AnnotationsSvg({
|
|
|
3294
2871
|
}: {
|
|
3295
2872
|
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
3296
2873
|
strokes: readonly Stroke[];
|
|
3297
|
-
anchorsById: Map<string,
|
|
2874
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3298
2875
|
selectMode: boolean;
|
|
3299
2876
|
selectedStrokes: readonly Stroke[];
|
|
3300
2877
|
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
2878
|
+
/** FigJam v3 — smart-guide lines painted while a drag is snapping. */
|
|
2879
|
+
snapGuides: SnapGuide[] | null;
|
|
2880
|
+
/** FigJam v3 — host a dragged arrow endpoint would bind to (accent halo). */
|
|
2881
|
+
bindHintId: string | null;
|
|
2882
|
+
/** FigJam v3 — live size label + dimension-match halos while resizing. */
|
|
2883
|
+
resizeInfo: {
|
|
2884
|
+
box: { x: number; y: number; w: number; h: number } | null;
|
|
2885
|
+
matchIds: string[];
|
|
2886
|
+
} | null;
|
|
2887
|
+
/** FigJam v3 — in-flight connector drawn from a connection dot. */
|
|
2888
|
+
connDraft: {
|
|
2889
|
+
x1: number;
|
|
2890
|
+
y1: number;
|
|
2891
|
+
x2: number;
|
|
2892
|
+
y2: number;
|
|
2893
|
+
startBind: { hostId: string; nx: number; ny: number };
|
|
2894
|
+
endBind?: { hostId: string; nx: number; ny: number };
|
|
2895
|
+
} | null;
|
|
2896
|
+
/** FigJam v3 — hovered empty shape that shows the "Add text" ghost label. */
|
|
2897
|
+
addTextHintId: string | null;
|
|
3301
2898
|
ghost: GhostDescriptor | null;
|
|
3302
2899
|
editingTarget: EditingTarget;
|
|
3303
2900
|
/** Live default ink (theme-aware) for a not-yet-born pending text caret. */
|
|
@@ -3358,6 +2955,94 @@ function AnnotationsSvg({
|
|
|
3358
2955
|
vectorEffect="non-scaling-stroke"
|
|
3359
2956
|
/>
|
|
3360
2957
|
) : null}
|
|
2958
|
+
{/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
|
|
2959
|
+
center, painted only while a drag is actively snapping. */}
|
|
2960
|
+
{snapGuides?.map((g, i) => (
|
|
2961
|
+
<line
|
|
2962
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
|
|
2963
|
+
key={`guide-${i}`}
|
|
2964
|
+
x1={g.axis === 'x' ? g.at : g.from}
|
|
2965
|
+
y1={g.axis === 'x' ? g.from : g.at}
|
|
2966
|
+
x2={g.axis === 'x' ? g.at : g.to}
|
|
2967
|
+
y2={g.axis === 'x' ? g.to : g.at}
|
|
2968
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
2969
|
+
strokeWidth={1}
|
|
2970
|
+
vectorEffect="non-scaling-stroke"
|
|
2971
|
+
pointerEvents="none"
|
|
2972
|
+
/>
|
|
2973
|
+
))}
|
|
2974
|
+
{/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
|
|
2975
|
+
magnetically attach to. */}
|
|
2976
|
+
<BindHintHalo strokes={strokes} bindHintId={bindHintId} />
|
|
2977
|
+
{/* FigJam v3 — connection dots on a single selected bindable shape;
|
|
2978
|
+
dragging one draws a bound connector (rendered below as a draft). */}
|
|
2979
|
+
{selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
|
|
2980
|
+
<ConnectorDots stroke={selectedStrokes[0]} />
|
|
2981
|
+
) : null}
|
|
2982
|
+
{connDraft ? (
|
|
2983
|
+
<g
|
|
2984
|
+
stroke={inkColor}
|
|
2985
|
+
strokeWidth={2.5}
|
|
2986
|
+
strokeLinecap="round"
|
|
2987
|
+
strokeLinejoin="round"
|
|
2988
|
+
vectorEffect="non-scaling-stroke"
|
|
2989
|
+
fill="none"
|
|
2990
|
+
pointerEvents="none"
|
|
2991
|
+
>
|
|
2992
|
+
{arrowPrimitives({
|
|
2993
|
+
x1: connDraft.x1,
|
|
2994
|
+
y1: connDraft.y1,
|
|
2995
|
+
x2: connDraft.x2,
|
|
2996
|
+
y2: connDraft.y2,
|
|
2997
|
+
width: 2.5,
|
|
2998
|
+
color: inkColor,
|
|
2999
|
+
lineType: 'curved',
|
|
3000
|
+
startBind: connDraft.startBind,
|
|
3001
|
+
...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
|
|
3002
|
+
}).map((prim, i) => renderArrowPrimitive(prim, i))}
|
|
3003
|
+
</g>
|
|
3004
|
+
) : null}
|
|
3005
|
+
{/* FigJam v3 — hover affordance: an empty shape invites text. */}
|
|
3006
|
+
<AddTextHint strokes={strokes} hintId={addTextHintId} />
|
|
3007
|
+
{/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
|
|
3008
|
+
dashed halo on any neighbour whose dimension the resize just matched
|
|
3009
|
+
(the "same size as that one" quota). */}
|
|
3010
|
+
{resizeInfo?.box ? (
|
|
3011
|
+
<g pointerEvents="none">
|
|
3012
|
+
{resizeInfo.matchIds.map((id) => {
|
|
3013
|
+
const m = strokes.find((s) => s.id === id);
|
|
3014
|
+
const bb = m ? strokeBBox(m) : null;
|
|
3015
|
+
if (!bb) return null;
|
|
3016
|
+
return (
|
|
3017
|
+
<rect
|
|
3018
|
+
key={`dim-${id}`}
|
|
3019
|
+
x={bb.x - 2}
|
|
3020
|
+
y={bb.y - 2}
|
|
3021
|
+
width={bb.w + 4}
|
|
3022
|
+
height={bb.h + 4}
|
|
3023
|
+
fill="none"
|
|
3024
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3025
|
+
strokeWidth={1.5}
|
|
3026
|
+
strokeDasharray="5 3"
|
|
3027
|
+
vectorEffect="non-scaling-stroke"
|
|
3028
|
+
rx={2}
|
|
3029
|
+
/>
|
|
3030
|
+
);
|
|
3031
|
+
})}
|
|
3032
|
+
<text
|
|
3033
|
+
x={resizeInfo.box.x + resizeInfo.box.w / 2}
|
|
3034
|
+
y={resizeInfo.box.y + resizeInfo.box.h + 18}
|
|
3035
|
+
textAnchor="middle"
|
|
3036
|
+
fontSize={11}
|
|
3037
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3038
|
+
style={{
|
|
3039
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3040
|
+
}}
|
|
3041
|
+
>
|
|
3042
|
+
{`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
|
|
3043
|
+
</text>
|
|
3044
|
+
</g>
|
|
3045
|
+
) : null}
|
|
3361
3046
|
{ghost ? <GhostPreview ghost={ghost} /> : null}
|
|
3362
3047
|
{editingTarget?.kind === 'anchored' ? (
|
|
3363
3048
|
<TextEditor
|
|
@@ -3403,6 +3088,23 @@ function AnnotationsSvg({
|
|
|
3403
3088
|
onCancel={onCancelEdit}
|
|
3404
3089
|
/>
|
|
3405
3090
|
) : null}
|
|
3091
|
+
{editingTarget?.kind === 'section' ? (
|
|
3092
|
+
<StandaloneTextEditor
|
|
3093
|
+
x={
|
|
3094
|
+
Math.min(editingTarget.section.x, editingTarget.section.x + editingTarget.section.w) + 2
|
|
3095
|
+
}
|
|
3096
|
+
y={
|
|
3097
|
+
Math.min(editingTarget.section.y, editingTarget.section.y + editingTarget.section.h) -
|
|
3098
|
+
SECTION_LABEL_H -
|
|
3099
|
+
2
|
|
3100
|
+
}
|
|
3101
|
+
fontSize={SECTION_LABEL_FONT}
|
|
3102
|
+
color={editingTarget.section.color}
|
|
3103
|
+
initialText={editingTarget.section.label}
|
|
3104
|
+
onCommit={onCommitEdit}
|
|
3105
|
+
onCancel={onCancelEdit}
|
|
3106
|
+
/>
|
|
3107
|
+
) : null}
|
|
3406
3108
|
</svg>,
|
|
3407
3109
|
target
|
|
3408
3110
|
);
|
|
@@ -3416,7 +3118,7 @@ function TextEditor({
|
|
|
3416
3118
|
onCancel,
|
|
3417
3119
|
}: {
|
|
3418
3120
|
anchorId: string;
|
|
3419
|
-
host:
|
|
3121
|
+
host: AnchorHost | null;
|
|
3420
3122
|
existing: TextStroke | undefined;
|
|
3421
3123
|
onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
|
|
3422
3124
|
onCancel: () => void;
|
|
@@ -3437,6 +3139,8 @@ function TextEditor({
|
|
|
3437
3139
|
italic: existing?.italic,
|
|
3438
3140
|
underline: existing?.underline,
|
|
3439
3141
|
strike: existing?.strike,
|
|
3142
|
+
fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
|
|
3143
|
+
align: existing?.align ?? 'center',
|
|
3440
3144
|
});
|
|
3441
3145
|
|
|
3442
3146
|
useEffect(() => {
|
|
@@ -3464,6 +3168,9 @@ function TextEditor({
|
|
|
3464
3168
|
const el = ref.current;
|
|
3465
3169
|
if (!el) return;
|
|
3466
3170
|
if (el.contains(e.target as Node)) return;
|
|
3171
|
+
// FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
|
|
3172
|
+
// it must not commit-and-close the session.
|
|
3173
|
+
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
3467
3174
|
onCommit(
|
|
3468
3175
|
anchorId,
|
|
3469
3176
|
stripEditorMarkers(el.innerText || '', existing?.listType),
|
|
@@ -3528,6 +3235,11 @@ function TextEditor({
|
|
|
3528
3235
|
stripEditorMarkers(el?.innerText || '', existing?.listType),
|
|
3529
3236
|
fmtRef.current
|
|
3530
3237
|
);
|
|
3238
|
+
// FigJam v3 — ⌘Enter chains: commit, then spawn a CONNECTED
|
|
3239
|
+
// sibling shape (quick-create) from the host.
|
|
3240
|
+
document.dispatchEvent(
|
|
3241
|
+
new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
|
|
3242
|
+
);
|
|
3531
3243
|
}
|
|
3532
3244
|
}}
|
|
3533
3245
|
>
|
|
@@ -3564,12 +3276,22 @@ function StickyEditor({
|
|
|
3564
3276
|
italic: sticky.italic,
|
|
3565
3277
|
underline: sticky.underline,
|
|
3566
3278
|
strike: sticky.strike,
|
|
3279
|
+
fontSize: sticky.fontSize,
|
|
3280
|
+
align: sticky.align ?? 'left',
|
|
3567
3281
|
});
|
|
3568
3282
|
const commit = () => {
|
|
3569
3283
|
if (doneRef.current) return;
|
|
3570
3284
|
doneRef.current = true;
|
|
3571
3285
|
onCommit(stripEditorMarkers(ref.current?.innerText ?? '', sticky.listType), fmtRef.current);
|
|
3572
3286
|
};
|
|
3287
|
+
// FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
|
|
3288
|
+
// "done editing" (the button's onMouseDown preventDefault usually stops the
|
|
3289
|
+
// blur, this guards the browsers where it doesn't).
|
|
3290
|
+
const onBlur = (e: { relatedTarget?: EventTarget | null }) => {
|
|
3291
|
+
const to = e.relatedTarget as Element | null;
|
|
3292
|
+
if (to?.closest?.('.dc-annot-ctx')) return;
|
|
3293
|
+
commit();
|
|
3294
|
+
};
|
|
3573
3295
|
useEffect(() => {
|
|
3574
3296
|
const el = ref.current;
|
|
3575
3297
|
if (!el) return;
|
|
@@ -3600,7 +3322,7 @@ function StickyEditor({
|
|
|
3600
3322
|
suppressContentEditableWarning
|
|
3601
3323
|
aria-label="Edit sticky note text"
|
|
3602
3324
|
style={{ ...stickyBodyStyle(sticky), ...fmtStyle, outline: 'none', cursor: 'text' }}
|
|
3603
|
-
onBlur={
|
|
3325
|
+
onBlur={onBlur}
|
|
3604
3326
|
onKeyDown={(e) => {
|
|
3605
3327
|
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3606
3328
|
if (e.key === 'Escape') {
|
|
@@ -3612,6 +3334,11 @@ function StickyEditor({
|
|
|
3612
3334
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
3613
3335
|
e.preventDefault();
|
|
3614
3336
|
commit();
|
|
3337
|
+
// FigJam v3 — ⌘Enter chains: commit this body, then ask the layer
|
|
3338
|
+
// to spawn + edit the next sticky beside it.
|
|
3339
|
+
document.dispatchEvent(
|
|
3340
|
+
new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
|
|
3341
|
+
);
|
|
3615
3342
|
}
|
|
3616
3343
|
}}
|
|
3617
3344
|
>
|
|
@@ -3665,6 +3392,8 @@ function StandaloneTextEditor({
|
|
|
3665
3392
|
italic,
|
|
3666
3393
|
underline,
|
|
3667
3394
|
strike,
|
|
3395
|
+
fontSize,
|
|
3396
|
+
align: align ?? 'left',
|
|
3668
3397
|
});
|
|
3669
3398
|
// Single-fire commit guard — outside-click + blur can both fire in one tick;
|
|
3670
3399
|
// without this the text would commit twice (two undo records). Markers shown
|
|
@@ -3701,6 +3430,8 @@ function StandaloneTextEditor({
|
|
|
3701
3430
|
const el = ref.current;
|
|
3702
3431
|
if (!el) return;
|
|
3703
3432
|
if (el.contains(e.target as Node)) return;
|
|
3433
|
+
// FigJam v3 — clicks into the edit-mode text toolbar keep the session.
|
|
3434
|
+
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
3704
3435
|
commitOnce(el.innerText || '');
|
|
3705
3436
|
};
|
|
3706
3437
|
document.addEventListener('pointerdown', onDown, true);
|
|
@@ -3758,15 +3489,233 @@ function StandaloneTextEditor({
|
|
|
3758
3489
|
);
|
|
3759
3490
|
}
|
|
3760
3491
|
|
|
3492
|
+
/**
|
|
3493
|
+
* FigJam v3 — centered ghost "Add text" label on a hovered EMPTY rect/ellipse
|
|
3494
|
+
* (FigJam shows the same invitation). Pure chrome; double-click / Enter edits.
|
|
3495
|
+
*/
|
|
3496
|
+
function AddTextHint({ strokes, hintId }: { strokes: readonly Stroke[]; hintId: string | null }) {
|
|
3497
|
+
if (!hintId) return null;
|
|
3498
|
+
const host = strokes.find((s) => s.id === hintId);
|
|
3499
|
+
if (!host || (host.tool !== 'rect' && host.tool !== 'ellipse' && host.tool !== 'polygon'))
|
|
3500
|
+
return null;
|
|
3501
|
+
const bb = strokeBBox(host);
|
|
3502
|
+
if (!bb || bb.w < 48 || bb.h < 28) return null;
|
|
3503
|
+
const rot = strokeRotation(host);
|
|
3504
|
+
const label = (
|
|
3505
|
+
<text
|
|
3506
|
+
x={bb.x + bb.w / 2}
|
|
3507
|
+
y={bb.y + bb.h / 2}
|
|
3508
|
+
textAnchor="middle"
|
|
3509
|
+
dominantBaseline="middle"
|
|
3510
|
+
fontSize={13}
|
|
3511
|
+
fill={host.color}
|
|
3512
|
+
opacity={0.45}
|
|
3513
|
+
pointerEvents="none"
|
|
3514
|
+
style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
|
|
3515
|
+
>
|
|
3516
|
+
Add text
|
|
3517
|
+
</text>
|
|
3518
|
+
);
|
|
3519
|
+
if (rot === 0) return label;
|
|
3520
|
+
return (
|
|
3521
|
+
<g transform={`rotate(${rot} ${bb.x + bb.w / 2} ${bb.y + bb.h / 2})`} pointerEvents="none">
|
|
3522
|
+
{label}
|
|
3523
|
+
</g>
|
|
3524
|
+
);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
/**
|
|
3528
|
+
* FigJam v3 — connection dots: the four side magnets of a selected bindable
|
|
3529
|
+
* shape, screen-constant size, accent-ringed. Dragging one starts a bound
|
|
3530
|
+
* connector (the layer owns the gesture; dots are in CHROME_SELECTOR so the
|
|
3531
|
+
* marquee/drag handler yields).
|
|
3532
|
+
*/
|
|
3533
|
+
function ConnectorDots({ stroke }: { stroke: Stroke }) {
|
|
3534
|
+
const controller = useViewportControllerContext();
|
|
3535
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3536
|
+
if (!isBindable(stroke)) return null;
|
|
3537
|
+
const center = strokeCenter(stroke);
|
|
3538
|
+
if (!center) return null;
|
|
3539
|
+
const magnets: Array<[number, number]> = [
|
|
3540
|
+
[0.5, 0],
|
|
3541
|
+
[1, 0.5],
|
|
3542
|
+
[0.5, 1],
|
|
3543
|
+
[0, 0.5],
|
|
3544
|
+
];
|
|
3545
|
+
// FigJam parity — the dots float a step OUTSIDE the edge (along the outward
|
|
3546
|
+
// normal), which also keeps them clear of the mid-edge RESIZE handles that
|
|
3547
|
+
// sit exactly on the edge midpoints (DOM, higher layer — they'd swallow the
|
|
3548
|
+
// drag otherwise). Deriving the normal from center→anchor keeps rotated
|
|
3549
|
+
// shapes correct for free.
|
|
3550
|
+
const offset = 16 / zoom;
|
|
3551
|
+
return (
|
|
3552
|
+
<g>
|
|
3553
|
+
{magnets.map(([nx, ny]) => {
|
|
3554
|
+
const pt = anchorPoint(stroke, nx, ny);
|
|
3555
|
+
if (!pt) return null;
|
|
3556
|
+
const dx = pt[0] - center[0];
|
|
3557
|
+
const dy = pt[1] - center[1];
|
|
3558
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
3559
|
+
return (
|
|
3560
|
+
<circle
|
|
3561
|
+
key={`${nx}-${ny}`}
|
|
3562
|
+
className="dc-annot-conn-dot"
|
|
3563
|
+
data-host={stroke.id}
|
|
3564
|
+
data-nx={nx}
|
|
3565
|
+
data-ny={ny}
|
|
3566
|
+
cx={pt[0] + (dx / len) * offset}
|
|
3567
|
+
cy={pt[1] + (dy / len) * offset}
|
|
3568
|
+
r={5 / zoom}
|
|
3569
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3570
|
+
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
3571
|
+
strokeWidth={1.5 / zoom}
|
|
3572
|
+
pointerEvents="all"
|
|
3573
|
+
/>
|
|
3574
|
+
);
|
|
3575
|
+
})}
|
|
3576
|
+
</g>
|
|
3577
|
+
);
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
/**
|
|
3581
|
+
* FigJam v3 — right-click context menu for annotation strokes. Reuses the
|
|
3582
|
+
* `.dc-context-menu` visual language (stylesheet injected by context-menu.tsx)
|
|
3583
|
+
* so the annotation menu and the canvas menu read as one product surface.
|
|
3584
|
+
*/
|
|
3585
|
+
function AnnotationContextMenu({
|
|
3586
|
+
pos,
|
|
3587
|
+
selCount,
|
|
3588
|
+
canUngroup,
|
|
3589
|
+
onAction,
|
|
3590
|
+
onClose,
|
|
3591
|
+
}: {
|
|
3592
|
+
pos: { x: number; y: number };
|
|
3593
|
+
selCount: number;
|
|
3594
|
+
canUngroup: boolean;
|
|
3595
|
+
onAction: (action: string) => void;
|
|
3596
|
+
onClose: () => void;
|
|
3597
|
+
}) {
|
|
3598
|
+
ensureCtxMenuStyles();
|
|
3599
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
3600
|
+
const [at, setAt] = useState<{ x: number; y: number }>(pos);
|
|
3601
|
+
useEffect(() => {
|
|
3602
|
+
const el = ref.current;
|
|
3603
|
+
if (!el || typeof window === 'undefined') return;
|
|
3604
|
+
const r = el.getBoundingClientRect();
|
|
3605
|
+
let nx = pos.x;
|
|
3606
|
+
let ny = pos.y;
|
|
3607
|
+
if (nx + r.width > window.innerWidth - 8) nx = Math.max(8, window.innerWidth - r.width - 8);
|
|
3608
|
+
if (ny + r.height > window.innerHeight - 8) ny = Math.max(8, window.innerHeight - r.height - 8);
|
|
3609
|
+
if (nx !== at.x || ny !== at.y) setAt({ x: nx, y: ny });
|
|
3610
|
+
el.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')?.focus();
|
|
3611
|
+
const onDown = (e: PointerEvent) => {
|
|
3612
|
+
if (!el.contains(e.target as Node)) onClose();
|
|
3613
|
+
};
|
|
3614
|
+
const onKey = (e: KeyboardEvent) => {
|
|
3615
|
+
if (e.key === 'Escape') {
|
|
3616
|
+
e.preventDefault();
|
|
3617
|
+
onClose();
|
|
3618
|
+
}
|
|
3619
|
+
};
|
|
3620
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
3621
|
+
document.addEventListener('keydown', onKey, true);
|
|
3622
|
+
return () => {
|
|
3623
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
3624
|
+
document.removeEventListener('keydown', onKey, true);
|
|
3625
|
+
};
|
|
3626
|
+
}, [pos, onClose, at.x, at.y]);
|
|
3627
|
+
const item = (
|
|
3628
|
+
id: string,
|
|
3629
|
+
label: string,
|
|
3630
|
+
shortcut?: string,
|
|
3631
|
+
opts?: { destructive?: boolean; disabled?: boolean }
|
|
3632
|
+
) => (
|
|
3633
|
+
<button
|
|
3634
|
+
type="button"
|
|
3635
|
+
role="menuitem"
|
|
3636
|
+
disabled={opts?.disabled}
|
|
3637
|
+
className={`dc-menu-item${opts?.destructive ? ' is-destructive' : ''}`}
|
|
3638
|
+
onClick={() => {
|
|
3639
|
+
if (opts?.disabled) return;
|
|
3640
|
+
onAction(id);
|
|
3641
|
+
onClose();
|
|
3642
|
+
}}
|
|
3643
|
+
>
|
|
3644
|
+
<span>{label}</span>
|
|
3645
|
+
{shortcut ? <span className="dc-menu-shortcut">{shortcut}</span> : null}
|
|
3646
|
+
</button>
|
|
3647
|
+
);
|
|
3648
|
+
return (
|
|
3649
|
+
<div
|
|
3650
|
+
ref={ref}
|
|
3651
|
+
className="dc-context-menu"
|
|
3652
|
+
role="menu"
|
|
3653
|
+
aria-label="Annotation actions"
|
|
3654
|
+
style={{ left: at.x, top: at.y }}
|
|
3655
|
+
>
|
|
3656
|
+
{item('copy', 'Copy', '⌘C')}
|
|
3657
|
+
{item('cut', 'Cut', '⌘X')}
|
|
3658
|
+
{item('paste', 'Paste', '⌘V')}
|
|
3659
|
+
{item('duplicate', 'Duplicate', '⌘D')}
|
|
3660
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3661
|
+
{item('front', 'Bring to front', ']')}
|
|
3662
|
+
{item('forward', 'Bring forward', '⌘]')}
|
|
3663
|
+
{item('backward', 'Send backward', '⌘[')}
|
|
3664
|
+
{item('back', 'Send to back', '[')}
|
|
3665
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3666
|
+
{item('group', 'Group selection', '⌘G', { disabled: selCount < 2 })}
|
|
3667
|
+
{canUngroup ? item('ungroup', 'Ungroup', '⌘⇧G') : null}
|
|
3668
|
+
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3669
|
+
{item('delete', 'Delete', '⌫', { destructive: true })}
|
|
3670
|
+
</div>
|
|
3671
|
+
);
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
/**
|
|
3675
|
+
* FigJam v3 — accent halo on the host a dragged arrow endpoint would bind to.
|
|
3676
|
+
* Pure chrome (pointer-events:none); renders nothing when no candidate.
|
|
3677
|
+
*/
|
|
3678
|
+
function BindHintHalo({
|
|
3679
|
+
strokes,
|
|
3680
|
+
bindHintId,
|
|
3681
|
+
}: {
|
|
3682
|
+
strokes: readonly Stroke[];
|
|
3683
|
+
bindHintId: string | null;
|
|
3684
|
+
}) {
|
|
3685
|
+
if (!bindHintId) return null;
|
|
3686
|
+
const host = strokes.find((s) => s.id === bindHintId);
|
|
3687
|
+
if (!host) return null;
|
|
3688
|
+
const bbox = strokeBBox(host);
|
|
3689
|
+
if (!bbox) return null;
|
|
3690
|
+
const pad = 3;
|
|
3691
|
+
return (
|
|
3692
|
+
<rect
|
|
3693
|
+
x={bbox.x - pad}
|
|
3694
|
+
y={bbox.y - pad}
|
|
3695
|
+
width={bbox.w + pad * 2}
|
|
3696
|
+
height={bbox.h + pad * 2}
|
|
3697
|
+
fill="none"
|
|
3698
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3699
|
+
strokeWidth={2}
|
|
3700
|
+
strokeOpacity={0.8}
|
|
3701
|
+
vectorEffect="non-scaling-stroke"
|
|
3702
|
+
pointerEvents="none"
|
|
3703
|
+
rx={3}
|
|
3704
|
+
/>
|
|
3705
|
+
);
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3761
3708
|
function SelectionHalo({
|
|
3762
3709
|
stroke,
|
|
3763
3710
|
anchorsById,
|
|
3764
3711
|
multi,
|
|
3765
3712
|
}: {
|
|
3766
3713
|
stroke: Stroke;
|
|
3767
|
-
anchorsById: Map<string,
|
|
3714
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3768
3715
|
multi: boolean;
|
|
3769
3716
|
}) {
|
|
3717
|
+
const controller = useViewportControllerContext();
|
|
3718
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3770
3719
|
const bbox = strokeBBox(stroke, anchorsById);
|
|
3771
3720
|
if (!bbox) return null;
|
|
3772
3721
|
// T17 + post-Wave-2 fix — annotation halo idioms:
|
|
@@ -3781,8 +3730,10 @@ function SelectionHalo({
|
|
|
3781
3730
|
// bbox above carries the container affordance).
|
|
3782
3731
|
// Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
|
|
3783
3732
|
// ambient group-container + active-gesture idioms per DDR-046 rev 2.
|
|
3784
|
-
|
|
3785
|
-
|
|
3733
|
+
// Wave H — screen-constant breathing room (matches the resize handles,
|
|
3734
|
+
// which sit on the same padded frame — HALO_PAD_PX single source).
|
|
3735
|
+
const pad = HALO_PAD_PX / zoom;
|
|
3736
|
+
const halo = (
|
|
3786
3737
|
<rect
|
|
3787
3738
|
x={bbox.x - pad}
|
|
3788
3739
|
y={bbox.y - pad}
|
|
@@ -3796,6 +3747,10 @@ function SelectionHalo({
|
|
|
3796
3747
|
rx={2}
|
|
3797
3748
|
/>
|
|
3798
3749
|
);
|
|
3750
|
+
// FigJam v3 — the halo turns with a rotated stroke.
|
|
3751
|
+
const rot = strokeRotation(stroke);
|
|
3752
|
+
if (rot === 0) return halo;
|
|
3753
|
+
return <g transform={`rotate(${rot} ${bbox.x + bbox.w / 2} ${bbox.y + bbox.h / 2})`}>{halo}</g>;
|
|
3799
3754
|
}
|
|
3800
3755
|
|
|
3801
3756
|
// T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
|
|
@@ -3805,8 +3760,10 @@ function AnnotGroupBbox({
|
|
|
3805
3760
|
anchorsById,
|
|
3806
3761
|
}: {
|
|
3807
3762
|
selectedStrokes: readonly Stroke[];
|
|
3808
|
-
anchorsById: Map<string,
|
|
3763
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3809
3764
|
}) {
|
|
3765
|
+
const controller = useViewportControllerContext();
|
|
3766
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
3810
3767
|
if (selectedStrokes.length < 2) return null;
|
|
3811
3768
|
let xMin = Number.POSITIVE_INFINITY;
|
|
3812
3769
|
let yMin = Number.POSITIVE_INFINITY;
|
|
@@ -3823,7 +3780,8 @@ function AnnotGroupBbox({
|
|
|
3823
3780
|
if (b.y + b.h > yMax) yMax = b.y + b.h;
|
|
3824
3781
|
}
|
|
3825
3782
|
if (!any) return null;
|
|
3826
|
-
|
|
3783
|
+
// Wave H — screen-constant pad, one step wider than the single halo.
|
|
3784
|
+
const pad = (HALO_PAD_PX + 2) / zoom;
|
|
3827
3785
|
const x = xMin - pad;
|
|
3828
3786
|
const y = yMin - pad;
|
|
3829
3787
|
const w = xMax - xMin + pad * 2;
|
|
@@ -3985,14 +3943,40 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
|
|
|
3985
3943
|
return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
|
|
3986
3944
|
}
|
|
3987
3945
|
|
|
3988
|
-
|
|
3946
|
+
/**
|
|
3947
|
+
* FigJam v3 — rotation wrapper. The base node renders axis-aligned geometry;
|
|
3948
|
+
* a rotated stroke wraps it in a `rotate()` group around its bbox center
|
|
3949
|
+
* (anchored text inherits its HOST's rotation so labels turn with the shape).
|
|
3950
|
+
* Pointer events pass through the group, so hit-testing + the ctx-toolbar's
|
|
3951
|
+
* getBoundingClientRect positioning keep working on the rotated form.
|
|
3952
|
+
*/
|
|
3953
|
+
function StrokeNode(props: {
|
|
3954
|
+
stroke: Stroke;
|
|
3955
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3956
|
+
interactive: boolean;
|
|
3957
|
+
editing?: boolean;
|
|
3958
|
+
}) {
|
|
3959
|
+
const { stroke, anchorsById } = props;
|
|
3960
|
+
let rot = strokeRotation(stroke);
|
|
3961
|
+
let pivot = rot !== 0 ? strokeCenter(stroke) : null;
|
|
3962
|
+
if (stroke.tool === 'text' && stroke.anchorId != null && stroke.anchorId !== '') {
|
|
3963
|
+
const host = anchorsById.get(stroke.anchorId);
|
|
3964
|
+
rot = host ? strokeRotation(host) : 0;
|
|
3965
|
+
pivot = rot !== 0 && host ? strokeCenter(host) : null;
|
|
3966
|
+
}
|
|
3967
|
+
const node = <StrokeNodeBase {...props} />;
|
|
3968
|
+
if (rot === 0 || !pivot) return node;
|
|
3969
|
+
return <g transform={`rotate(${rot} ${pivot[0]} ${pivot[1]})`}>{node}</g>;
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
function StrokeNodeBase({
|
|
3989
3973
|
stroke,
|
|
3990
3974
|
anchorsById,
|
|
3991
3975
|
interactive,
|
|
3992
3976
|
editing = false,
|
|
3993
3977
|
}: {
|
|
3994
3978
|
stroke: Stroke;
|
|
3995
|
-
anchorsById: Map<string,
|
|
3979
|
+
anchorsById: Map<string, AnchorHost>;
|
|
3996
3980
|
interactive: boolean;
|
|
3997
3981
|
/** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
|
|
3998
3982
|
editing?: boolean;
|
|
@@ -4192,6 +4176,75 @@ function StrokeNode({
|
|
|
4192
4176
|
</g>
|
|
4193
4177
|
);
|
|
4194
4178
|
}
|
|
4179
|
+
if (stroke.tool === 'section') {
|
|
4180
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
4181
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4182
|
+
const w = Math.abs(stroke.w);
|
|
4183
|
+
const h = Math.abs(stroke.h);
|
|
4184
|
+
const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
|
|
4185
|
+
return (
|
|
4186
|
+
<g data-id={stroke.id} data-tool="section">
|
|
4187
|
+
{/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
|
|
4188
|
+
section selects normally; the section is grabbed by border/chip). */}
|
|
4189
|
+
<rect
|
|
4190
|
+
x={x}
|
|
4191
|
+
y={y}
|
|
4192
|
+
width={w}
|
|
4193
|
+
height={h}
|
|
4194
|
+
rx={SECTION_CORNER_RADIUS}
|
|
4195
|
+
ry={SECTION_CORNER_RADIUS}
|
|
4196
|
+
fill={stroke.color}
|
|
4197
|
+
fillOpacity={0.07}
|
|
4198
|
+
stroke={stroke.color}
|
|
4199
|
+
strokeOpacity={0.45}
|
|
4200
|
+
strokeWidth={1.5}
|
|
4201
|
+
vectorEffect="non-scaling-stroke"
|
|
4202
|
+
pointerEvents="none"
|
|
4203
|
+
/>
|
|
4204
|
+
{/* Invisible border hit ring — the grabbable edge. */}
|
|
4205
|
+
{interactive ? (
|
|
4206
|
+
<rect
|
|
4207
|
+
x={x}
|
|
4208
|
+
y={y}
|
|
4209
|
+
width={w}
|
|
4210
|
+
height={h}
|
|
4211
|
+
rx={SECTION_CORNER_RADIUS}
|
|
4212
|
+
ry={SECTION_CORNER_RADIUS}
|
|
4213
|
+
fill="none"
|
|
4214
|
+
stroke="transparent"
|
|
4215
|
+
strokeWidth={12}
|
|
4216
|
+
vectorEffect="non-scaling-stroke"
|
|
4217
|
+
pointerEvents="stroke"
|
|
4218
|
+
/>
|
|
4219
|
+
) : null}
|
|
4220
|
+
{/* Label chip above the top-left corner — also a grab handle. */}
|
|
4221
|
+
<g pointerEvents={hitMode}>
|
|
4222
|
+
<rect
|
|
4223
|
+
x={x}
|
|
4224
|
+
y={y - SECTION_LABEL_H - 4}
|
|
4225
|
+
width={chipW}
|
|
4226
|
+
height={SECTION_LABEL_H}
|
|
4227
|
+
rx={5}
|
|
4228
|
+
ry={5}
|
|
4229
|
+
fill={stroke.color}
|
|
4230
|
+
fillOpacity={0.16}
|
|
4231
|
+
/>
|
|
4232
|
+
<text
|
|
4233
|
+
x={x + 9}
|
|
4234
|
+
y={y - SECTION_LABEL_H / 2 - 4}
|
|
4235
|
+
dominantBaseline="middle"
|
|
4236
|
+
fontSize={SECTION_LABEL_FONT}
|
|
4237
|
+
fill={stroke.color}
|
|
4238
|
+
style={{
|
|
4239
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4240
|
+
}}
|
|
4241
|
+
>
|
|
4242
|
+
{stroke.label}
|
|
4243
|
+
</text>
|
|
4244
|
+
</g>
|
|
4245
|
+
</g>
|
|
4246
|
+
);
|
|
4247
|
+
}
|
|
4195
4248
|
const common = {
|
|
4196
4249
|
'data-id': stroke.id,
|
|
4197
4250
|
'data-tool': stroke.tool,
|