@1agh/maude 0.22.2 → 0.24.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 +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -34,9 +34,10 @@ import {
|
|
|
34
34
|
import { createPortal } from 'react-dom';
|
|
35
35
|
|
|
36
36
|
import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
|
|
37
|
+
import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
|
|
37
38
|
import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
|
|
38
39
|
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
39
|
-
import { crossedDragThreshold } from './input-router.tsx';
|
|
40
|
+
import { type Tool, crossedDragThreshold } from './input-router.tsx';
|
|
40
41
|
import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
41
42
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
42
43
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
@@ -67,6 +68,8 @@ export interface RectStroke {
|
|
|
67
68
|
w: number;
|
|
68
69
|
h: number;
|
|
69
70
|
fill?: string | null;
|
|
71
|
+
/** Phase 21 — corner radius (rx/ry). Absent / 0 = sharp 90° corners (back-compat). */
|
|
72
|
+
cornerRadius?: number;
|
|
70
73
|
}
|
|
71
74
|
export interface EllipseStroke {
|
|
72
75
|
id: string;
|
|
@@ -88,6 +91,12 @@ export interface ArrowStroke {
|
|
|
88
91
|
y1: number;
|
|
89
92
|
x2: number;
|
|
90
93
|
y2: number;
|
|
94
|
+
/** Phase 21 — head on the (x1,y1) start. Absent = 'none' (back-compat). */
|
|
95
|
+
startHead?: 'none' | 'triangle';
|
|
96
|
+
/** Phase 21 — head on the (x2,y2) end. Absent = 'triangle' (back-compat). */
|
|
97
|
+
endHead?: 'none' | 'triangle';
|
|
98
|
+
/** Phase 21 — dashed shaft (stroke-dasharray). Absent / false = solid. */
|
|
99
|
+
dashed?: boolean;
|
|
91
100
|
}
|
|
92
101
|
export interface TextStroke {
|
|
93
102
|
id: string;
|
|
@@ -95,28 +104,83 @@ export interface TextStroke {
|
|
|
95
104
|
color: string;
|
|
96
105
|
fontSize: number;
|
|
97
106
|
text: string;
|
|
98
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Host shape id for anchored text (double-click a rect/ellipse). Phase 21
|
|
109
|
+
* relaxed this to optional: standalone text (the `text` tool) carries no
|
|
110
|
+
* anchor and renders at its own world `(x, y)` instead.
|
|
111
|
+
*/
|
|
112
|
+
anchorId?: string;
|
|
113
|
+
/** Phase 21 — world coords for standalone (unanchored) text. */
|
|
114
|
+
x?: number;
|
|
115
|
+
y?: number;
|
|
116
|
+
}
|
|
117
|
+
/** Phase 21 — sticky note: a paper-tone card with its own word-wrapped text. */
|
|
118
|
+
export interface StickyStroke {
|
|
119
|
+
id: string;
|
|
120
|
+
tool: 'sticky';
|
|
121
|
+
color: string;
|
|
122
|
+
x: number;
|
|
123
|
+
y: number;
|
|
124
|
+
w: number;
|
|
125
|
+
h: number;
|
|
126
|
+
text: string;
|
|
127
|
+
fontSize: number;
|
|
128
|
+
/** Corner radius; defaults to STICKY_CORNER_RADIUS (8 = soft). */
|
|
129
|
+
cornerRadius?: number;
|
|
99
130
|
}
|
|
100
|
-
export type Stroke =
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
131
|
+
export type Stroke =
|
|
132
|
+
| PenStroke
|
|
133
|
+
| RectStroke
|
|
134
|
+
| EllipseStroke
|
|
135
|
+
| ArrowStroke
|
|
136
|
+
| TextStroke
|
|
137
|
+
| StickyStroke;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Phase 21 — what the inline editor is currently bound to. `anchored` edits
|
|
141
|
+
* the text hosted by a rect/ellipse; `sticky` edits a card body; `standalone`
|
|
142
|
+
* re-edits a free text node; `pending` is a not-yet-born text caret (no stroke
|
|
143
|
+
* exists until real text is committed).
|
|
144
|
+
*/
|
|
145
|
+
type EditingTarget =
|
|
146
|
+
| { kind: 'anchored'; anchorId: string; host: RectStroke | EllipseStroke }
|
|
147
|
+
| { kind: 'sticky'; sticky: StickyStroke }
|
|
148
|
+
| { kind: 'standalone'; text: TextStroke }
|
|
149
|
+
| { kind: 'pending'; x: number; y: number }
|
|
150
|
+
| null;
|
|
151
|
+
|
|
152
|
+
// Phase 21 colour system — a single coherent hue family used everywhere.
|
|
153
|
+
// FigJam model: stroke (saturated ink) is INDEPENDENT of fill, and fills are
|
|
154
|
+
// light TINTS of the same hue (index-paired with STROKE_PALETTE). Stickies use
|
|
155
|
+
// their own lightened paper set (STICKY_PALETTE). Exported so the draw-time
|
|
156
|
+
// chrome AND the per-selection context toolbar share ONE palette instead of
|
|
157
|
+
// drifting apart.
|
|
158
|
+
export const STROKE_PALETTE = [
|
|
159
|
+
'#e5484d', // red (default — markup ink)
|
|
160
|
+
'#f2762a', // orange
|
|
161
|
+
'#e0a500', // amber
|
|
162
|
+
'#30a46c', // green
|
|
163
|
+
'#3b82f6', // blue
|
|
164
|
+
'#8b5cf6', // purple
|
|
165
|
+
'#e93d82', // pink
|
|
166
|
+
'#7c7c7c', // gray
|
|
167
|
+
'#1f1f1f', // ink
|
|
109
168
|
] as const;
|
|
110
|
-
type PaletteColor = (typeof
|
|
111
|
-
const DEFAULT_COLOR: PaletteColor =
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
'#
|
|
117
|
-
'#
|
|
118
|
-
'#
|
|
119
|
-
'#
|
|
169
|
+
type PaletteColor = (typeof STROKE_PALETTE)[number];
|
|
170
|
+
const DEFAULT_COLOR: PaletteColor = STROKE_PALETTE[0];
|
|
171
|
+
|
|
172
|
+
// Light tints, index-paired to STROKE_PALETTE — picking "blue fill" gives a
|
|
173
|
+
// pale blue wash under a saturated stroke, exactly like FigJam shapes.
|
|
174
|
+
export const FILL_PALETTE = [
|
|
175
|
+
'#fbe0e1', // red tint
|
|
176
|
+
'#fce6d6', // orange tint
|
|
177
|
+
'#fbeec2', // amber tint
|
|
178
|
+
'#d9f1e2', // green tint
|
|
179
|
+
'#e0ebfd', // blue tint
|
|
180
|
+
'#ebe3fc', // purple tint
|
|
181
|
+
'#fbdfeb', // pink tint
|
|
182
|
+
'#ededed', // gray tint
|
|
183
|
+
'#e7e7e7', // ink tint
|
|
120
184
|
] as const;
|
|
121
185
|
|
|
122
186
|
const STROKE_WIDTH_THIN = 3;
|
|
@@ -126,6 +190,23 @@ type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
|
126
190
|
const FONT_SIZE_MEDIUM = 14;
|
|
127
191
|
const DEFAULT_FONT_SIZE = FONT_SIZE_MEDIUM;
|
|
128
192
|
|
|
193
|
+
// Phase 21 — sticky-note paper tints. FigJam-style desaturated tints, wholly
|
|
194
|
+
// separate from the stroke ink PALETTE and the translucent FILL_PALETTE so
|
|
195
|
+
// stickies read as "paper", not "ink". Slot 0 (yellow) is the default.
|
|
196
|
+
export const STICKY_PALETTE = [
|
|
197
|
+
'#ffe27a', // yellow (default — warm FigJam paper)
|
|
198
|
+
'#ffc1d4', // pink
|
|
199
|
+
'#a9d6ff', // blue
|
|
200
|
+
'#b3e6b8', // green
|
|
201
|
+
'#d8c2fb', // purple
|
|
202
|
+
'#f2ece1', // paper-white
|
|
203
|
+
] as const;
|
|
204
|
+
const DEFAULT_STICKY_COLOR = STICKY_PALETTE[0];
|
|
205
|
+
const STICKY_CORNER_RADIUS = 8;
|
|
206
|
+
const STICKY_DEFAULT_W = 200;
|
|
207
|
+
const STICKY_DEFAULT_H = 160;
|
|
208
|
+
const STICKY_MIN_SIZE = 40;
|
|
209
|
+
|
|
129
210
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
211
|
// Pure helpers — exported for unit tests.
|
|
131
212
|
|
|
@@ -165,11 +246,39 @@ export function arrowHeadPoints(
|
|
|
165
246
|
|
|
166
247
|
function strokeToSvgEl(s: Stroke): string {
|
|
167
248
|
if (s.tool === 'text') {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
249
|
+
// Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
|
|
250
|
+
// standalone text (no anchorId) writes its own world x/y and omits
|
|
251
|
+
// data-anchor-id (so the parser routes it back to the standalone branch).
|
|
252
|
+
if (s.anchorId != null && s.anchorId !== '') {
|
|
253
|
+
return `<text data-id="${esc(s.id)}" data-tool="text" data-anchor-id="${esc(
|
|
254
|
+
s.anchorId
|
|
255
|
+
)}" data-font-size="${s.fontSize}" fill="${esc(
|
|
256
|
+
s.color
|
|
257
|
+
)}" text-anchor="middle" dominant-baseline="middle">${esc(s.text)}</text>`;
|
|
258
|
+
}
|
|
259
|
+
const tx = s.x ?? 0;
|
|
260
|
+
const ty = s.y ?? 0;
|
|
261
|
+
return `<text data-id="${esc(s.id)}" data-tool="text" x="${tx}" y="${ty}" data-font-size="${
|
|
262
|
+
s.fontSize
|
|
263
|
+
}" fill="${esc(s.color)}" text-anchor="start" dominant-baseline="hanging">${esc(s.text)}</text>`;
|
|
264
|
+
}
|
|
265
|
+
if (s.tool === 'sticky') {
|
|
266
|
+
// Phase 21 — sticky body lives in an allowlisted <text> child so it
|
|
267
|
+
// survives sanitizeAnnotationSvg (which strips <foreignObject>, DDR-060
|
|
268
|
+
// F1). The live canvas re-renders this stroke with a foreignObject so the
|
|
269
|
+
// text word-wraps; the persisted <text> is the inert, sanitizer-safe form.
|
|
270
|
+
const r = s.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
271
|
+
const w = Math.max(0, s.w);
|
|
272
|
+
const h = Math.max(0, s.h);
|
|
273
|
+
return `<g data-id="${esc(s.id)}" data-tool="sticky" data-r="${r}" data-fs="${
|
|
274
|
+
s.fontSize
|
|
275
|
+
}" fill="${esc(s.color)}"><rect x="${s.x}" y="${
|
|
276
|
+
s.y
|
|
277
|
+
}" width="${w}" height="${h}" rx="${r}" ry="${r}"/><text data-sticky-body="1" x="${
|
|
278
|
+
s.x + 12
|
|
279
|
+
}" y="${s.y + 12}" font-size="${
|
|
280
|
+
s.fontSize
|
|
281
|
+
}" fill="#1a1a1a" dominant-baseline="hanging">${esc(s.text)}</text></g>`;
|
|
173
282
|
}
|
|
174
283
|
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"`;
|
|
175
284
|
if (s.tool === 'pen') {
|
|
@@ -177,10 +286,14 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
177
286
|
}
|
|
178
287
|
if (s.tool === 'rect') {
|
|
179
288
|
const fill = s.fill ? esc(s.fill) : 'none';
|
|
289
|
+
// Phase 21 — corner radius: append rx/ry/data-r ONLY when > 0 so legacy
|
|
290
|
+
// sharp-corner rects serialize byte-identically (Task 10 canary).
|
|
291
|
+
const r = s.cornerRadius ?? 0;
|
|
292
|
+
const round = r > 0 ? ` rx="${r}" ry="${r}" data-r="${r}"` : '';
|
|
180
293
|
return `<rect ${common} fill="${fill}" x="${s.x}" y="${s.y}" width="${Math.max(
|
|
181
294
|
0,
|
|
182
295
|
s.w
|
|
183
|
-
)}" height="${Math.max(0, s.h)}"/>`;
|
|
296
|
+
)}" height="${Math.max(0, s.h)}"${round}/>`;
|
|
184
297
|
}
|
|
185
298
|
if (s.tool === 'ellipse') {
|
|
186
299
|
const fill = s.fill ? esc(s.fill) : 'none';
|
|
@@ -189,8 +302,30 @@ function strokeToSvgEl(s: Stroke): string {
|
|
|
189
302
|
s.rx
|
|
190
303
|
)}" ry="${Math.max(0, s.ry)}"/>`;
|
|
191
304
|
}
|
|
192
|
-
|
|
193
|
-
|
|
305
|
+
// arrow — Phase 21 adds optional start/end heads + dash. Defaults
|
|
306
|
+
// (startHead 'none', endHead 'triangle', solid) emit the byte-identical
|
|
307
|
+
// Phase 5.1 form: data-* attrs + extra polyline + dasharray appear only for
|
|
308
|
+
// non-default values.
|
|
309
|
+
const endHead = s.endHead ?? 'triangle';
|
|
310
|
+
const startHead = s.startHead ?? 'none';
|
|
311
|
+
const dashed = s.dashed ?? false;
|
|
312
|
+
const dataAttrs =
|
|
313
|
+
(startHead !== 'none' ? ` data-start-head="${startHead}"` : '') +
|
|
314
|
+
(endHead !== 'triangle' ? ` data-end-head="${endHead}"` : '') +
|
|
315
|
+
(dashed ? ' data-dash="1"' : '');
|
|
316
|
+
const dash = dashed ? ' stroke-dasharray="6 4"' : '';
|
|
317
|
+
const heads =
|
|
318
|
+
(startHead === 'triangle'
|
|
319
|
+
? `<polyline points="${arrowHeadPoints(s.x2, s.y2, s.x1, s.y1, s.width)}" fill="${esc(
|
|
320
|
+
s.color
|
|
321
|
+
)}"/>`
|
|
322
|
+
: '') +
|
|
323
|
+
(endHead === 'triangle'
|
|
324
|
+
? `<polyline points="${arrowHeadPoints(s.x1, s.y1, s.x2, s.y2, s.width)}" fill="${esc(
|
|
325
|
+
s.color
|
|
326
|
+
)}"/>`
|
|
327
|
+
: '');
|
|
328
|
+
return `<g ${common} fill="none"${dataAttrs}><line x1="${s.x1}" y1="${s.y1}" x2="${s.x2}" y2="${s.y2}"${dash}/>${heads}</g>`;
|
|
194
329
|
}
|
|
195
330
|
|
|
196
331
|
export function strokesToSvg(strokes: readonly Stroke[]): string {
|
|
@@ -238,13 +373,44 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
238
373
|
if (points.length) out.push({ id, tool: 'pen', color, width, points });
|
|
239
374
|
continue;
|
|
240
375
|
}
|
|
376
|
+
if (tool === 'sticky') {
|
|
377
|
+
// Phase 21 — sticky reads geometry off its <rect> child, paper tint
|
|
378
|
+
// off the group fill, body text off the inner <text>.
|
|
379
|
+
const rectEl = el.querySelector('rect');
|
|
380
|
+
const x = Number.parseFloat(rectEl?.getAttribute('x') || '0');
|
|
381
|
+
const y = Number.parseFloat(rectEl?.getAttribute('y') || '0');
|
|
382
|
+
const w = Number.parseFloat(rectEl?.getAttribute('width') || '0');
|
|
383
|
+
const h = Number.parseFloat(rectEl?.getAttribute('height') || '0');
|
|
384
|
+
const cornerRadius =
|
|
385
|
+
Number.parseFloat(el.getAttribute('data-r') || String(STICKY_CORNER_RADIUS)) || 0;
|
|
386
|
+
const fontSize =
|
|
387
|
+
Number.parseFloat(el.getAttribute('data-fs') || String(DEFAULT_FONT_SIZE)) ||
|
|
388
|
+
DEFAULT_FONT_SIZE;
|
|
389
|
+
const stickyColor = el.getAttribute('fill') || DEFAULT_STICKY_COLOR;
|
|
390
|
+
const body = el.querySelector('text');
|
|
391
|
+
out.push({
|
|
392
|
+
id,
|
|
393
|
+
tool: 'sticky',
|
|
394
|
+
color: stickyColor,
|
|
395
|
+
x,
|
|
396
|
+
y,
|
|
397
|
+
w,
|
|
398
|
+
h,
|
|
399
|
+
text: body?.textContent ?? '',
|
|
400
|
+
fontSize,
|
|
401
|
+
cornerRadius,
|
|
402
|
+
});
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
241
405
|
if (tool === 'rect') {
|
|
242
406
|
const x = Number.parseFloat(el.getAttribute('x') || '0');
|
|
243
407
|
const y = Number.parseFloat(el.getAttribute('y') || '0');
|
|
244
408
|
const w = Number.parseFloat(el.getAttribute('width') || '0');
|
|
245
409
|
const h = Number.parseFloat(el.getAttribute('height') || '0');
|
|
246
410
|
const fill = parseFill(el.getAttribute('fill'));
|
|
247
|
-
|
|
411
|
+
// Phase 21 — corner radius; absent ⇒ 0 (sharp, back-compat).
|
|
412
|
+
const cornerRadius = Number.parseFloat(el.getAttribute('data-r') || '0') || 0;
|
|
413
|
+
out.push({ id, tool: 'rect', color, width, x, y, w, h, fill, cornerRadius });
|
|
248
414
|
continue;
|
|
249
415
|
}
|
|
250
416
|
if (tool === 'ellipse') {
|
|
@@ -259,7 +425,7 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
259
425
|
if (tool === 'arrow') {
|
|
260
426
|
const line = el.querySelector('line');
|
|
261
427
|
if (line) {
|
|
262
|
-
|
|
428
|
+
const arrow: ArrowStroke = {
|
|
263
429
|
id,
|
|
264
430
|
tool: 'arrow',
|
|
265
431
|
color,
|
|
@@ -268,23 +434,47 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
268
434
|
y1: Number.parseFloat(line.getAttribute('y1') || '0'),
|
|
269
435
|
x2: Number.parseFloat(line.getAttribute('x2') || '0'),
|
|
270
436
|
y2: Number.parseFloat(line.getAttribute('y2') || '0'),
|
|
271
|
-
}
|
|
437
|
+
};
|
|
438
|
+
// Phase 21 — heads + dash. Serializer only writes a data-* attribute
|
|
439
|
+
// for a NON-default value, so a legacy arrow carries none of these
|
|
440
|
+
// and stays { startHead/endHead/dashed: undefined } → defaults on
|
|
441
|
+
// re-serialize (byte-identical, Task 10 canary).
|
|
442
|
+
if (el.getAttribute('data-start-head') === 'triangle') arrow.startHead = 'triangle';
|
|
443
|
+
if (el.getAttribute('data-end-head') === 'none') arrow.endHead = 'none';
|
|
444
|
+
const dashRaw = el.getAttribute('data-dash');
|
|
445
|
+
if (dashRaw === '1' || dashRaw === 'true') arrow.dashed = true;
|
|
446
|
+
out.push(arrow);
|
|
272
447
|
}
|
|
273
448
|
continue;
|
|
274
449
|
}
|
|
275
450
|
if (tool === 'text') {
|
|
276
|
-
const
|
|
451
|
+
const rawAnchor = el.getAttribute('data-anchor-id');
|
|
277
452
|
const fontSize =
|
|
278
453
|
Number.parseFloat(el.getAttribute('data-font-size') || String(DEFAULT_FONT_SIZE)) ||
|
|
279
454
|
DEFAULT_FONT_SIZE;
|
|
280
455
|
const inkColor = el.getAttribute('fill') || color;
|
|
456
|
+
const body = (el.textContent || '').trim();
|
|
457
|
+
// Phase 21 — standalone text (no data-anchor-id) carries world x/y
|
|
458
|
+
// instead of a host id.
|
|
459
|
+
if (!rawAnchor) {
|
|
460
|
+
out.push({
|
|
461
|
+
id,
|
|
462
|
+
tool: 'text',
|
|
463
|
+
color: inkColor,
|
|
464
|
+
fontSize,
|
|
465
|
+
text: body,
|
|
466
|
+
x: Number.parseFloat(el.getAttribute('x') || '0'),
|
|
467
|
+
y: Number.parseFloat(el.getAttribute('y') || '0'),
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
281
471
|
out.push({
|
|
282
472
|
id,
|
|
283
473
|
tool: 'text',
|
|
284
474
|
color: inkColor,
|
|
285
475
|
fontSize,
|
|
286
|
-
text:
|
|
287
|
-
anchorId,
|
|
476
|
+
text: body,
|
|
477
|
+
anchorId: rawAnchor,
|
|
288
478
|
});
|
|
289
479
|
}
|
|
290
480
|
}
|
|
@@ -312,8 +502,26 @@ function pointSegmentDist(
|
|
|
312
502
|
}
|
|
313
503
|
|
|
314
504
|
export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): boolean {
|
|
315
|
-
if (s.tool === 'text')
|
|
505
|
+
if (s.tool === 'text') {
|
|
506
|
+
// Anchored text isn't independently hit-testable (it inherits its host).
|
|
507
|
+
// Standalone text (Phase 21) uses its synthetic bbox so the eraser can
|
|
508
|
+
// reach it.
|
|
509
|
+
if (s.anchorId != null && s.anchorId !== '') return false;
|
|
510
|
+
const bb = strokeBBox(s);
|
|
511
|
+
if (!bb) return false;
|
|
512
|
+
return (
|
|
513
|
+
wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
|
|
514
|
+
);
|
|
515
|
+
}
|
|
316
516
|
const t = Math.max(tol, 'width' in s ? s.width : 2);
|
|
517
|
+
if (s.tool === 'sticky') {
|
|
518
|
+
// Sticky is a solid paper card — filled-rect hit anywhere inside.
|
|
519
|
+
const xMin = Math.min(s.x, s.x + s.w);
|
|
520
|
+
const xMax = Math.max(s.x, s.x + s.w);
|
|
521
|
+
const yMin = Math.min(s.y, s.y + s.h);
|
|
522
|
+
const yMax = Math.max(s.y, s.y + s.h);
|
|
523
|
+
return wx >= xMin - t && wx <= xMax + t && wy >= yMin - t && wy <= yMax + t;
|
|
524
|
+
}
|
|
317
525
|
if (s.tool === 'pen') {
|
|
318
526
|
if (s.points.length === 1) {
|
|
319
527
|
const p = s.points[0] as WorldPoint;
|
|
@@ -362,7 +570,8 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
|
|
|
362
570
|
return onLeft || onRight || onTop || onBottom;
|
|
363
571
|
}
|
|
364
572
|
|
|
365
|
-
|
|
573
|
+
/** Flip a negative-extent box so x/y is the top-left and w/h are positive. */
|
|
574
|
+
function normalizeBox<T extends { x: number; y: number; w: number; h: number }>(r: T): T {
|
|
366
575
|
if (r.w >= 0 && r.h >= 0) return r;
|
|
367
576
|
return {
|
|
368
577
|
...r,
|
|
@@ -373,11 +582,23 @@ function normalizeRect(r: RectStroke): RectStroke {
|
|
|
373
582
|
};
|
|
374
583
|
}
|
|
375
584
|
|
|
585
|
+
function normalizeRect(r: RectStroke): RectStroke {
|
|
586
|
+
return normalizeBox(r);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Phase 21 — sticky shares rect's drag-to-create flip (x = min, w = abs(w)).
|
|
590
|
+
function normalizeSticky(s: StickyStroke): StickyStroke {
|
|
591
|
+
return normalizeBox(s);
|
|
592
|
+
}
|
|
593
|
+
|
|
376
594
|
function isStrokeMeaningful(s: Stroke): boolean {
|
|
377
595
|
if (s.tool === 'pen') return s.points.length >= 2;
|
|
378
596
|
if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
|
|
379
597
|
if (s.tool === 'ellipse') return s.rx >= 2 && s.ry >= 2;
|
|
380
598
|
if (s.tool === 'text') return s.text.trim().length > 0;
|
|
599
|
+
// Sticky below a readable floor is discarded like a 2×2 rect.
|
|
600
|
+
if (s.tool === 'sticky')
|
|
601
|
+
return Math.abs(s.w) >= STICKY_MIN_SIZE && Math.abs(s.h) >= STICKY_MIN_SIZE;
|
|
381
602
|
return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
|
|
382
603
|
}
|
|
383
604
|
|
|
@@ -418,9 +639,29 @@ export function strokeBBox(
|
|
|
418
639
|
h: Math.abs(s.y2 - s.y1),
|
|
419
640
|
};
|
|
420
641
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
642
|
+
if (s.tool === 'sticky') {
|
|
643
|
+
return {
|
|
644
|
+
x: Math.min(s.x, s.x + s.w),
|
|
645
|
+
y: Math.min(s.y, s.y + s.h),
|
|
646
|
+
w: Math.abs(s.w),
|
|
647
|
+
h: Math.abs(s.h),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
// text — anchored inherits its host's bbox; standalone (Phase 21) gets a
|
|
651
|
+
// synthetic bbox from its world (x, y) so it's selectable and the context
|
|
652
|
+
// toolbar can position against it.
|
|
653
|
+
if (s.anchorId != null && s.anchorId !== '') {
|
|
654
|
+
const host = anchors?.get(s.anchorId);
|
|
655
|
+
return host ? strokeBBox(host) : null;
|
|
656
|
+
}
|
|
657
|
+
const tx = s.x ?? 0;
|
|
658
|
+
const ty = s.y ?? 0;
|
|
659
|
+
return {
|
|
660
|
+
x: tx,
|
|
661
|
+
y: ty,
|
|
662
|
+
w: Math.max(8, s.text.length * s.fontSize * 0.55),
|
|
663
|
+
h: s.fontSize * 1.2,
|
|
664
|
+
};
|
|
424
665
|
}
|
|
425
666
|
|
|
426
667
|
function isEditable(t: EventTarget | null): boolean {
|
|
@@ -454,93 +695,113 @@ function deriveFile(): string | undefined {
|
|
|
454
695
|
const ANNOT_CSS = `
|
|
455
696
|
.dc-annot-chrome {
|
|
456
697
|
/* Stacks directly above the centered tool toolbar (which is bottom:16px,
|
|
457
|
-
32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px.
|
|
698
|
+
32px tall → top edge ~ bottom:48px). 8 px gap → chrome at bottom:60px.
|
|
699
|
+
Phase 21 — dark "marker tray" matching the FigJam selection bar. */
|
|
458
700
|
position: absolute;
|
|
459
701
|
left: 50%;
|
|
460
|
-
bottom:
|
|
702
|
+
bottom: 64px;
|
|
461
703
|
transform: translateX(-50%);
|
|
462
704
|
display: flex;
|
|
463
705
|
align-items: center;
|
|
464
|
-
gap:
|
|
465
|
-
background:
|
|
466
|
-
border: 1px solid
|
|
467
|
-
border-radius:
|
|
468
|
-
padding:
|
|
469
|
-
font-family: var(--
|
|
706
|
+
gap: 2px;
|
|
707
|
+
background: #26262b;
|
|
708
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
709
|
+
border-radius: 12px;
|
|
710
|
+
padding: 5px 8px;
|
|
711
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
470
712
|
font-size: 11px;
|
|
471
|
-
color:
|
|
713
|
+
color: rgba(255,255,255,0.85);
|
|
472
714
|
z-index: 6;
|
|
473
|
-
box-shadow: 0
|
|
715
|
+
box-shadow: 0 8px 28px rgba(0,0,0,0.34), 0 2px 6px rgba(0,0,0,0.22);
|
|
474
716
|
user-select: none;
|
|
475
717
|
}
|
|
476
|
-
.dc-annot-chrome .dc-annot-swatches { display: flex; gap:
|
|
718
|
+
.dc-annot-chrome .dc-annot-swatches { display: flex; align-items: center; gap: 1px; }
|
|
477
719
|
.dc-annot-chrome .dc-annot-sw {
|
|
478
|
-
width:
|
|
479
|
-
height:
|
|
480
|
-
border-radius:
|
|
481
|
-
border: 1px solid rgba(
|
|
720
|
+
width: 20px;
|
|
721
|
+
height: 20px;
|
|
722
|
+
border-radius: 50%;
|
|
723
|
+
border: 1px solid rgba(255,255,255,0.16);
|
|
482
724
|
cursor: pointer;
|
|
483
725
|
padding: 0;
|
|
484
726
|
appearance: none;
|
|
727
|
+
transition: transform 80ms ease;
|
|
485
728
|
}
|
|
729
|
+
.dc-annot-chrome .dc-annot-sw:hover { transform: scale(1.1); }
|
|
486
730
|
.dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
|
|
487
|
-
box-shadow: 0 0 0 2px
|
|
731
|
+
box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
|
|
488
732
|
border-color: transparent;
|
|
489
733
|
}
|
|
490
734
|
.dc-annot-chrome .dc-annot-sw:focus-visible {
|
|
491
|
-
outline: 2px solid
|
|
492
|
-
outline-offset:
|
|
735
|
+
outline: 2px solid #ffffff;
|
|
736
|
+
outline-offset: 1px;
|
|
493
737
|
}
|
|
494
738
|
.dc-annot-chrome .dc-annot-sep {
|
|
495
739
|
width: 1px;
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
740
|
+
height: 16px;
|
|
741
|
+
align-self: center;
|
|
742
|
+
background: rgba(255,255,255,0.09);
|
|
743
|
+
margin: 0 4px;
|
|
499
744
|
}
|
|
500
745
|
.dc-annot-chrome .dc-annot-fill {
|
|
501
|
-
width:
|
|
502
|
-
height:
|
|
503
|
-
border-radius:
|
|
504
|
-
border: 1px solid rgba(
|
|
746
|
+
width: 20px;
|
|
747
|
+
height: 20px;
|
|
748
|
+
border-radius: 50%;
|
|
749
|
+
border: 1px solid rgba(255,255,255,0.16);
|
|
505
750
|
cursor: pointer;
|
|
506
751
|
padding: 0;
|
|
507
752
|
appearance: none;
|
|
508
753
|
position: relative;
|
|
754
|
+
transition: transform 80ms ease;
|
|
755
|
+
}
|
|
756
|
+
.dc-annot-chrome .dc-annot-fill:hover { transform: scale(1.1); }
|
|
757
|
+
.dc-annot-chrome .dc-annot-fill:focus-visible {
|
|
758
|
+
outline: 2px solid #ffffff;
|
|
759
|
+
outline-offset: 1px;
|
|
509
760
|
}
|
|
510
761
|
.dc-annot-chrome .dc-annot-fill--none {
|
|
511
|
-
background: #
|
|
762
|
+
background: #3a3a40;
|
|
512
763
|
}
|
|
513
764
|
.dc-annot-chrome .dc-annot-fill--none::after {
|
|
514
765
|
content: "";
|
|
515
|
-
position: absolute; inset:
|
|
766
|
+
position: absolute; inset: 4px;
|
|
767
|
+
border-radius: 50%;
|
|
516
768
|
background:
|
|
517
|
-
linear-gradient(135deg, transparent
|
|
769
|
+
linear-gradient(135deg, transparent 44%, rgba(255,255,255,0.55) 44%, rgba(255,255,255,0.55) 56%, transparent 56%);
|
|
518
770
|
}
|
|
519
771
|
.dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
|
|
520
|
-
box-shadow: 0 0 0 2px
|
|
772
|
+
box-shadow: 0 0 0 2px #26262b, 0 0 0 3px rgba(255,255,255,0.92);
|
|
521
773
|
border-color: transparent;
|
|
522
774
|
}
|
|
523
|
-
|
|
775
|
+
/* Phase 21 — icon buttons (light glyph on dark, white-tint active). */
|
|
776
|
+
.dc-annot-chrome .dc-annot-ibtn {
|
|
524
777
|
appearance: none;
|
|
525
778
|
background: transparent;
|
|
526
|
-
border:
|
|
527
|
-
border-radius:
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
779
|
+
border: 0;
|
|
780
|
+
border-radius: 7px;
|
|
781
|
+
width: 26px;
|
|
782
|
+
height: 26px;
|
|
783
|
+
display: inline-flex;
|
|
784
|
+
align-items: center;
|
|
785
|
+
justify-content: center;
|
|
786
|
+
color: rgba(255,255,255,0.78);
|
|
531
787
|
cursor: pointer;
|
|
532
|
-
|
|
533
|
-
|
|
788
|
+
padding: 0;
|
|
789
|
+
transition: background-color 80ms linear, color 80ms linear;
|
|
534
790
|
}
|
|
535
|
-
.dc-annot-chrome .dc-annot-
|
|
536
|
-
background:
|
|
537
|
-
color:
|
|
538
|
-
|
|
791
|
+
.dc-annot-chrome .dc-annot-ibtn:hover {
|
|
792
|
+
background: rgba(255,255,255,0.1);
|
|
793
|
+
color: #ffffff;
|
|
794
|
+
}
|
|
795
|
+
.dc-annot-chrome .dc-annot-ibtn[aria-pressed="true"] {
|
|
796
|
+
background: rgba(255,255,255,0.18);
|
|
797
|
+
color: #ffffff;
|
|
798
|
+
}
|
|
799
|
+
.dc-annot-chrome .dc-annot-ibtn:focus-visible {
|
|
800
|
+
outline: 2px solid #ffffff;
|
|
801
|
+
outline-offset: -2px;
|
|
539
802
|
}
|
|
540
|
-
|
|
541
|
-
.dc-annot-chrome .dc-annot-
|
|
542
|
-
outline: 2px solid var(--maude-hud-accent, #d63b1f);
|
|
543
|
-
outline-offset: 2px;
|
|
803
|
+
@media (prefers-reduced-motion: reduce) {
|
|
804
|
+
.dc-annot-chrome .dc-annot-ibtn, .dc-annot-chrome .dc-annot-sw, .dc-annot-chrome .dc-annot-fill { transition: none; }
|
|
544
805
|
}
|
|
545
806
|
.dc-annot-input {
|
|
546
807
|
position: absolute;
|
|
@@ -573,6 +834,25 @@ const ANNOT_CSS = `
|
|
|
573
834
|
stroke-width: 1;
|
|
574
835
|
stroke-dasharray: 4 3;
|
|
575
836
|
}
|
|
837
|
+
/* Phase 21 — sticky-note body. Word-wrapped multi-line text inside the card's
|
|
838
|
+
foreignObject. The editor textarea (.dc-sticky-editor) mirrors the same box
|
|
839
|
+
metrics so the read↔edit swap doesn't shift the text. */
|
|
840
|
+
.dc-sticky-body {
|
|
841
|
+
width: 100%;
|
|
842
|
+
height: 100%;
|
|
843
|
+
box-sizing: border-box;
|
|
844
|
+
padding: 14px 16px;
|
|
845
|
+
display: flex;
|
|
846
|
+
align-items: center;
|
|
847
|
+
justify-content: center;
|
|
848
|
+
text-align: center;
|
|
849
|
+
color: #2a2a28;
|
|
850
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
851
|
+
line-height: 1.35;
|
|
852
|
+
white-space: pre-wrap;
|
|
853
|
+
overflow-wrap: anywhere;
|
|
854
|
+
overflow: hidden;
|
|
855
|
+
}
|
|
576
856
|
`.trim();
|
|
577
857
|
|
|
578
858
|
function ensureAnnotStyles(): void {
|
|
@@ -610,7 +890,11 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
|
610
890
|
if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
|
|
611
891
|
if (s.tool === 'arrow')
|
|
612
892
|
return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
|
|
613
|
-
return s
|
|
893
|
+
if (s.tool === 'sticky') return { ...s, x: s.x + dx, y: s.y + dy };
|
|
894
|
+
// text — anchored inherits its host's bbox (moves with the host); standalone
|
|
895
|
+
// (Phase 21) carries its own world (x, y) and translates directly.
|
|
896
|
+
if (s.anchorId != null && s.anchorId !== '') return s;
|
|
897
|
+
return { ...s, x: (s.x ?? 0) + dx, y: (s.y ?? 0) + dy };
|
|
614
898
|
}
|
|
615
899
|
|
|
616
900
|
/**
|
|
@@ -640,7 +924,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
|
640
924
|
|
|
641
925
|
export function AnnotationsLayer() {
|
|
642
926
|
ensureAnnotStyles();
|
|
643
|
-
const { tool, setTool, sticky } = useToolMode();
|
|
927
|
+
const { tool, setTool, sticky, tools } = useToolMode();
|
|
644
928
|
const controller = useViewportControllerContext();
|
|
645
929
|
const vp = controller?.viewport ?? null;
|
|
646
930
|
const worldRef = useWorldRefContext();
|
|
@@ -652,6 +936,13 @@ export function AnnotationsLayer() {
|
|
|
652
936
|
const [color, setColor] = useState<string>(DEFAULT_COLOR);
|
|
653
937
|
const [fill, setFill] = useState<string | null>(null);
|
|
654
938
|
const [thickness, setThickness] = useState<Thickness>(STROKE_WIDTH_THIN);
|
|
939
|
+
// Phase 21 — draw-time paper tint for the sticky tool (recolor-after via the
|
|
940
|
+
// context toolbar). Separate from `color` (ink for pen/rect/text/arrow).
|
|
941
|
+
const [stickyColor, setStickyColor] = useState<string>(DEFAULT_STICKY_COLOR);
|
|
942
|
+
// Phase 21 — a standalone-text caret waiting for its first keystroke. No
|
|
943
|
+
// stroke exists yet (mirrors anchored text: the stroke is born on commit,
|
|
944
|
+
// so an abandoned empty caret leaves nothing behind / no undo record).
|
|
945
|
+
const [pendingText, setPendingText] = useState<{ x: number; y: number } | null>(null);
|
|
655
946
|
const visibilityCtx = useAnnotationsVisibility();
|
|
656
947
|
const visible = visibilityCtx?.visible ?? true;
|
|
657
948
|
const setVisible = useCallback(
|
|
@@ -679,7 +970,13 @@ export function AnnotationsLayer() {
|
|
|
679
970
|
const strokesRef = useRef<Stroke[]>(strokes);
|
|
680
971
|
strokesRef.current = strokes;
|
|
681
972
|
|
|
682
|
-
const isDraw =
|
|
973
|
+
const isDraw =
|
|
974
|
+
tool === 'pen' ||
|
|
975
|
+
tool === 'rect' ||
|
|
976
|
+
tool === 'arrow' ||
|
|
977
|
+
tool === 'ellipse' ||
|
|
978
|
+
tool === 'sticky' ||
|
|
979
|
+
tool === 'text';
|
|
683
980
|
const isErase = tool === 'eraser';
|
|
684
981
|
const isActive = isDraw || isErase;
|
|
685
982
|
// T20 — rect + ellipse expose stroke weight too (FigJam ships thickness on
|
|
@@ -835,7 +1132,7 @@ export function AnnotationsLayer() {
|
|
|
835
1132
|
const set = new Set(ids);
|
|
836
1133
|
const prev = strokesRef.current;
|
|
837
1134
|
const next = prev.filter(
|
|
838
|
-
(s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
|
|
1135
|
+
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
839
1136
|
);
|
|
840
1137
|
if (next.length === prev.length) return;
|
|
841
1138
|
commitStrokes(prev, next);
|
|
@@ -983,6 +1280,30 @@ export function AnnotationsLayer() {
|
|
|
983
1280
|
x2: wx,
|
|
984
1281
|
y2: wy,
|
|
985
1282
|
});
|
|
1283
|
+
} else if (tool === 'sticky') {
|
|
1284
|
+
// Phase 21 — drag-create a paper card. Default size if the user just
|
|
1285
|
+
// taps (no drag) is applied in endStroke.
|
|
1286
|
+
setDrawing({
|
|
1287
|
+
id,
|
|
1288
|
+
tool: 'sticky',
|
|
1289
|
+
color: stickyColor,
|
|
1290
|
+
x: wx,
|
|
1291
|
+
y: wy,
|
|
1292
|
+
w: 0,
|
|
1293
|
+
h: 0,
|
|
1294
|
+
text: '',
|
|
1295
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
1296
|
+
cornerRadius: STICKY_CORNER_RADIUS,
|
|
1297
|
+
});
|
|
1298
|
+
} else if (tool === 'text') {
|
|
1299
|
+
// Phase 21 — single click drops an editable caret at the click point.
|
|
1300
|
+
// No stroke is created until the user commits real text (mirrors the
|
|
1301
|
+
// anchored double-click flow), so an empty caret leaves nothing behind.
|
|
1302
|
+
setPendingText({ x: wx, y: wy });
|
|
1303
|
+
if (annotSel) annotSel.clear();
|
|
1304
|
+
const stickyOnText = sticky.locked && sticky.tool === 'text';
|
|
1305
|
+
if (!stickyOnText) setTool('move');
|
|
1306
|
+
return true;
|
|
986
1307
|
}
|
|
987
1308
|
return true;
|
|
988
1309
|
},
|
|
@@ -991,6 +1312,7 @@ export function AnnotationsLayer() {
|
|
|
991
1312
|
color,
|
|
992
1313
|
fill,
|
|
993
1314
|
thickness,
|
|
1315
|
+
stickyColor,
|
|
994
1316
|
supportsThickness,
|
|
995
1317
|
supportsFill,
|
|
996
1318
|
isActive,
|
|
@@ -998,6 +1320,9 @@ export function AnnotationsLayer() {
|
|
|
998
1320
|
visible,
|
|
999
1321
|
screenToWorld,
|
|
1000
1322
|
eraseAt,
|
|
1323
|
+
annotSel,
|
|
1324
|
+
sticky,
|
|
1325
|
+
setTool,
|
|
1001
1326
|
]
|
|
1002
1327
|
);
|
|
1003
1328
|
|
|
@@ -1028,6 +1353,9 @@ export function AnnotationsLayer() {
|
|
|
1028
1353
|
if (cur.tool === 'arrow') {
|
|
1029
1354
|
return { ...cur, x2: wx, y2: wy };
|
|
1030
1355
|
}
|
|
1356
|
+
if (cur.tool === 'sticky') {
|
|
1357
|
+
return { ...cur, w: wx - cur.x, h: wy - cur.y };
|
|
1358
|
+
}
|
|
1031
1359
|
return cur;
|
|
1032
1360
|
});
|
|
1033
1361
|
},
|
|
@@ -1041,6 +1369,15 @@ export function AnnotationsLayer() {
|
|
|
1041
1369
|
if (!cur) return;
|
|
1042
1370
|
let final: Stroke | null = cur;
|
|
1043
1371
|
if (cur.tool === 'rect') final = normalizeRect(cur);
|
|
1372
|
+
else if (cur.tool === 'sticky') {
|
|
1373
|
+
const norm = normalizeSticky(cur);
|
|
1374
|
+
// A bare tap (or a drag too small to be a usable card) drops a
|
|
1375
|
+
// default-sized note at the tap point — FigJam parity.
|
|
1376
|
+
final =
|
|
1377
|
+
Math.abs(norm.w) < STICKY_MIN_SIZE || Math.abs(norm.h) < STICKY_MIN_SIZE
|
|
1378
|
+
? { ...norm, w: STICKY_DEFAULT_W, h: STICKY_DEFAULT_H }
|
|
1379
|
+
: norm;
|
|
1380
|
+
}
|
|
1044
1381
|
if (final && !isStrokeMeaningful(final)) final = null;
|
|
1045
1382
|
if (final) {
|
|
1046
1383
|
const committed = final;
|
|
@@ -1051,6 +1388,9 @@ export function AnnotationsLayer() {
|
|
|
1051
1388
|
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
1052
1389
|
// AnnotationsLayer without the provider), so guard the call.
|
|
1053
1390
|
if (annotSel) annotSel.replace(committed.id);
|
|
1391
|
+
// Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
|
|
1392
|
+
// note, type immediately). Only meaningful deviation from rect/ellipse.
|
|
1393
|
+
if (committed.tool === 'sticky') setEditingId(committed.id);
|
|
1054
1394
|
}
|
|
1055
1395
|
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
1056
1396
|
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
@@ -1144,7 +1484,12 @@ export function AnnotationsLayer() {
|
|
|
1144
1484
|
if (
|
|
1145
1485
|
id &&
|
|
1146
1486
|
t &&
|
|
1147
|
-
(t === 'pen' ||
|
|
1487
|
+
(t === 'pen' ||
|
|
1488
|
+
t === 'rect' ||
|
|
1489
|
+
t === 'ellipse' ||
|
|
1490
|
+
t === 'arrow' ||
|
|
1491
|
+
t === 'text' ||
|
|
1492
|
+
t === 'sticky')
|
|
1148
1493
|
) {
|
|
1149
1494
|
return id;
|
|
1150
1495
|
}
|
|
@@ -1155,7 +1500,7 @@ export function AnnotationsLayer() {
|
|
|
1155
1500
|
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
1156
1501
|
// right-click menu. Clicks on these route to their own handlers.
|
|
1157
1502
|
const CHROME_SELECTOR =
|
|
1158
|
-
'.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';
|
|
1503
|
+
'.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';
|
|
1159
1504
|
|
|
1160
1505
|
const onDown = (e: PointerEvent) => {
|
|
1161
1506
|
if (e.button !== 0) return;
|
|
@@ -1274,7 +1619,10 @@ export function AnnotationsLayer() {
|
|
|
1274
1619
|
const yMax = Math.max(final.ay, final.by);
|
|
1275
1620
|
const hits: string[] = [];
|
|
1276
1621
|
for (const s of strokesStoreRef.current.strokes) {
|
|
1277
|
-
|
|
1622
|
+
// Anchored text inherits its host's bbox (selected with the host);
|
|
1623
|
+
// standalone text (Phase 21) has its own synthetic bbox and IS
|
|
1624
|
+
// marquee-selectable.
|
|
1625
|
+
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
|
|
1278
1626
|
const bb = strokeBBox(s);
|
|
1279
1627
|
if (!bb) continue;
|
|
1280
1628
|
if (bb.x + bb.w >= xMin && bb.x <= xMax && bb.y + bb.h >= yMin && bb.y <= yMax) {
|
|
@@ -1302,7 +1650,9 @@ export function AnnotationsLayer() {
|
|
|
1302
1650
|
const strokesStoreRef = useRef(strokesStore);
|
|
1303
1651
|
strokesStoreRef.current = strokesStore;
|
|
1304
1652
|
|
|
1305
|
-
// Double-click
|
|
1653
|
+
// Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
|
|
1654
|
+
// (its own body), or a standalone text node (re-edit in place). Anchored text
|
|
1655
|
+
// nodes are edited via their host, so a data-anchor-id text node is skipped.
|
|
1306
1656
|
useEffect(() => {
|
|
1307
1657
|
if (typeof document === 'undefined') return;
|
|
1308
1658
|
if (tool !== 'move') return;
|
|
@@ -1312,7 +1662,13 @@ export function AnnotationsLayer() {
|
|
|
1312
1662
|
if (!node) return;
|
|
1313
1663
|
const id = node.getAttribute('data-id');
|
|
1314
1664
|
const t = node.getAttribute('data-tool');
|
|
1315
|
-
if (id
|
|
1665
|
+
if (!id) return;
|
|
1666
|
+
if (t === 'rect' || t === 'ellipse' || t === 'sticky') {
|
|
1667
|
+
e.preventDefault();
|
|
1668
|
+
setEditingId(id);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
if (t === 'text' && !node.getAttribute('data-anchor-id')) {
|
|
1316
1672
|
e.preventDefault();
|
|
1317
1673
|
setEditingId(id);
|
|
1318
1674
|
}
|
|
@@ -1356,6 +1712,103 @@ export function AnnotationsLayer() {
|
|
|
1356
1712
|
[commitStrokes]
|
|
1357
1713
|
);
|
|
1358
1714
|
|
|
1715
|
+
// Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
|
|
1716
|
+
// no trim) and the card persists even when blank, so this only updates text.
|
|
1717
|
+
const commitStickyText = useCallback(
|
|
1718
|
+
(id: string, text: string) => {
|
|
1719
|
+
const prev = strokesRef.current;
|
|
1720
|
+
const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
|
|
1721
|
+
| StickyStroke
|
|
1722
|
+
| undefined;
|
|
1723
|
+
if (!existing || existing.text === text) return;
|
|
1724
|
+
const next = prev.map((s) => (s.id === id ? { ...existing, text } : s));
|
|
1725
|
+
commitStrokes(prev, next, 'edit sticky');
|
|
1726
|
+
},
|
|
1727
|
+
[commitStrokes]
|
|
1728
|
+
);
|
|
1729
|
+
|
|
1730
|
+
// Phase 21 — re-edit an EXISTING standalone text node. Empty text deletes it
|
|
1731
|
+
// (same rule as anchored text).
|
|
1732
|
+
const commitStandaloneText = useCallback(
|
|
1733
|
+
(id: string, text: string) => {
|
|
1734
|
+
const trimmed = text.trim();
|
|
1735
|
+
const prev = strokesRef.current;
|
|
1736
|
+
const existing = prev.find((s) => s.id === id && s.tool === 'text') as TextStroke | undefined;
|
|
1737
|
+
if (!existing) return;
|
|
1738
|
+
if (trimmed.length === 0) {
|
|
1739
|
+
commitStrokes(
|
|
1740
|
+
prev,
|
|
1741
|
+
prev.filter((s) => s.id !== id),
|
|
1742
|
+
'delete text'
|
|
1743
|
+
);
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
if (existing.text === trimmed) return;
|
|
1747
|
+
commitStrokes(
|
|
1748
|
+
prev,
|
|
1749
|
+
prev.map((s) => (s.id === id ? { ...existing, text: trimmed } : s)),
|
|
1750
|
+
'edit text'
|
|
1751
|
+
);
|
|
1752
|
+
},
|
|
1753
|
+
[commitStrokes]
|
|
1754
|
+
);
|
|
1755
|
+
|
|
1756
|
+
// Phase 21 — born-on-commit standalone text (from the text-tool caret). An
|
|
1757
|
+
// empty caret persists nothing — ONE undo record only when real text lands.
|
|
1758
|
+
const createStandaloneText = useCallback(
|
|
1759
|
+
(x: number, y: number, text: string) => {
|
|
1760
|
+
const trimmed = text.trim();
|
|
1761
|
+
if (trimmed.length === 0) return;
|
|
1762
|
+
const prev = strokesRef.current;
|
|
1763
|
+
const id = rid();
|
|
1764
|
+
const next: Stroke[] = [
|
|
1765
|
+
...prev,
|
|
1766
|
+
{ id, tool: 'text', color, fontSize: DEFAULT_FONT_SIZE, text: trimmed, x, y },
|
|
1767
|
+
];
|
|
1768
|
+
commitStrokes(prev, next, 'add text');
|
|
1769
|
+
if (annotSel) annotSel.replace(id);
|
|
1770
|
+
},
|
|
1771
|
+
[commitStrokes, color, annotSel]
|
|
1772
|
+
);
|
|
1773
|
+
|
|
1774
|
+
// Phase 21 — resolve what (if anything) is being edited, and route a single
|
|
1775
|
+
// commit call to the right writer. `editingId` doubles as the host id
|
|
1776
|
+
// (anchored) OR the sticky/standalone stroke id; `pendingText` is the
|
|
1777
|
+
// not-yet-born text caret.
|
|
1778
|
+
const editingTarget = useMemo<EditingTarget>(() => {
|
|
1779
|
+
if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
|
|
1780
|
+
if (!editingId) return null;
|
|
1781
|
+
const host = anchorsById.get(editingId);
|
|
1782
|
+
if (host) return { kind: 'anchored', anchorId: editingId, host };
|
|
1783
|
+
const s = strokesById.get(editingId);
|
|
1784
|
+
if (s?.tool === 'sticky') return { kind: 'sticky', sticky: s };
|
|
1785
|
+
if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
|
|
1786
|
+
return { kind: 'standalone', text: s };
|
|
1787
|
+
return null;
|
|
1788
|
+
}, [pendingText, editingId, anchorsById, strokesById]);
|
|
1789
|
+
|
|
1790
|
+
const editingTargetRef = useRef(editingTarget);
|
|
1791
|
+
editingTargetRef.current = editingTarget;
|
|
1792
|
+
|
|
1793
|
+
const commitEditing = useCallback(
|
|
1794
|
+
(text: string) => {
|
|
1795
|
+
const target = editingTargetRef.current;
|
|
1796
|
+
setEditingId(null);
|
|
1797
|
+
setPendingText(null);
|
|
1798
|
+
if (!target) return;
|
|
1799
|
+
if (target.kind === 'anchored') commitText(target.anchorId, text);
|
|
1800
|
+
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text);
|
|
1801
|
+
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text);
|
|
1802
|
+
else if (target.kind === 'pending') createStandaloneText(target.x, target.y, text);
|
|
1803
|
+
},
|
|
1804
|
+
[commitText, commitStickyText, commitStandaloneText, createStandaloneText]
|
|
1805
|
+
);
|
|
1806
|
+
|
|
1807
|
+
const cancelEditing = useCallback(() => {
|
|
1808
|
+
setEditingId(null);
|
|
1809
|
+
setPendingText(null);
|
|
1810
|
+
}, []);
|
|
1811
|
+
|
|
1359
1812
|
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
1360
1813
|
useEffect(() => {
|
|
1361
1814
|
if (typeof document === 'undefined') return;
|
|
@@ -1412,6 +1865,7 @@ export function AnnotationsLayer() {
|
|
|
1412
1865
|
<AnnotationsInput
|
|
1413
1866
|
isActive={isActive}
|
|
1414
1867
|
visible={visible}
|
|
1868
|
+
cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
|
|
1415
1869
|
beginStroke={beginStroke}
|
|
1416
1870
|
moveStroke={moveStroke}
|
|
1417
1871
|
endStroke={endStroke}
|
|
@@ -1424,25 +1878,20 @@ export function AnnotationsLayer() {
|
|
|
1424
1878
|
selectMode={tool === 'move'}
|
|
1425
1879
|
selectedStrokes={selectedStrokes}
|
|
1426
1880
|
marquee={marquee}
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
| TextStroke
|
|
1431
|
-
| undefined
|
|
1432
|
-
}
|
|
1433
|
-
onCommitText={(anchorId, text) => {
|
|
1434
|
-
commitText(anchorId, text);
|
|
1435
|
-
setEditingId(null);
|
|
1436
|
-
}}
|
|
1437
|
-
onCancelEdit={() => setEditingId(null)}
|
|
1881
|
+
editingTarget={editingTarget}
|
|
1882
|
+
onCommitEdit={commitEditing}
|
|
1883
|
+
onCancelEdit={cancelEditing}
|
|
1438
1884
|
/>
|
|
1439
1885
|
) : null}
|
|
1440
1886
|
<AnnotationContextToolbar />
|
|
1441
1887
|
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
1442
1888
|
{isActive ? (
|
|
1443
1889
|
<AnnotationsChrome
|
|
1890
|
+
tool={tool}
|
|
1444
1891
|
color={color}
|
|
1445
1892
|
setColor={setColor}
|
|
1893
|
+
stickyColor={stickyColor}
|
|
1894
|
+
setStickyColor={setStickyColor}
|
|
1446
1895
|
supportsFill={supportsFill}
|
|
1447
1896
|
fill={fill}
|
|
1448
1897
|
setFill={setFill}
|
|
@@ -1466,12 +1915,16 @@ AnnotationsLayer.displayName = 'AnnotationsLayer';
|
|
|
1466
1915
|
function AnnotationsInput({
|
|
1467
1916
|
isActive,
|
|
1468
1917
|
visible,
|
|
1918
|
+
cursor,
|
|
1469
1919
|
beginStroke,
|
|
1470
1920
|
moveStroke,
|
|
1471
1921
|
endStroke,
|
|
1472
1922
|
}: {
|
|
1473
1923
|
isActive: boolean;
|
|
1474
1924
|
visible: boolean;
|
|
1925
|
+
/** Active-tool cursor (crosshair / text / cell) — applied to the capture
|
|
1926
|
+
* overlay so the affordance shows over the whole canvas while drawing. */
|
|
1927
|
+
cursor: string;
|
|
1475
1928
|
beginStroke: (e: ReactPointerEvent<HTMLDivElement>, spaceHeld: boolean) => boolean;
|
|
1476
1929
|
moveStroke: (e: ReactPointerEvent<HTMLDivElement>) => void;
|
|
1477
1930
|
endStroke: () => void;
|
|
@@ -1509,7 +1962,10 @@ function AnnotationsInput({
|
|
|
1509
1962
|
<div
|
|
1510
1963
|
className="dc-annot-input"
|
|
1511
1964
|
aria-hidden="true"
|
|
1512
|
-
style={{
|
|
1965
|
+
style={{
|
|
1966
|
+
pointerEvents: interactive ? 'auto' : 'none',
|
|
1967
|
+
cursor: interactive ? cursor : 'default',
|
|
1968
|
+
}}
|
|
1513
1969
|
onPointerDown={(e) => {
|
|
1514
1970
|
beginStroke(e, spaceHeldRef.current);
|
|
1515
1971
|
}}
|
|
@@ -1534,9 +1990,8 @@ function AnnotationsSvg({
|
|
|
1534
1990
|
selectMode,
|
|
1535
1991
|
selectedStrokes,
|
|
1536
1992
|
marquee,
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
onCommitText,
|
|
1993
|
+
editingTarget,
|
|
1994
|
+
onCommitEdit,
|
|
1540
1995
|
onCancelEdit,
|
|
1541
1996
|
}: {
|
|
1542
1997
|
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
@@ -1545,9 +2000,8 @@ function AnnotationsSvg({
|
|
|
1545
2000
|
selectMode: boolean;
|
|
1546
2001
|
selectedStrokes: readonly Stroke[];
|
|
1547
2002
|
marquee: { ax: number; ay: number; bx: number; by: number } | null;
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
onCommitText: (anchorId: string, text: string) => void;
|
|
2003
|
+
editingTarget: EditingTarget;
|
|
2004
|
+
onCommitEdit: (text: string) => void;
|
|
1551
2005
|
onCancelEdit: () => void;
|
|
1552
2006
|
}) {
|
|
1553
2007
|
const [, force] = useState({});
|
|
@@ -1558,10 +2012,31 @@ function AnnotationsSvg({
|
|
|
1558
2012
|
}, [worldRef]);
|
|
1559
2013
|
const target = worldRef?.current ?? null;
|
|
1560
2014
|
if (!target) return null;
|
|
2015
|
+
// A sticky whose body is being edited hides its read-only text so the
|
|
2016
|
+
// editor textarea (rendered below at the same bbox) isn't double-painted.
|
|
2017
|
+
const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
|
|
2018
|
+
const anchoredExisting =
|
|
2019
|
+
editingTarget?.kind === 'anchored'
|
|
2020
|
+
? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
|
|
2021
|
+
| TextStroke
|
|
2022
|
+
| undefined)
|
|
2023
|
+
: undefined;
|
|
1561
2024
|
return createPortal(
|
|
1562
2025
|
<svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
2026
|
+
<defs>
|
|
2027
|
+
{/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
|
|
2028
|
+
<filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
|
|
2029
|
+
<feDropShadow dx="0" dy="4" stdDeviation="8" floodColor="#000000" floodOpacity="0.28" />
|
|
2030
|
+
</filter>
|
|
2031
|
+
</defs>
|
|
1563
2032
|
{strokes.map((s) => (
|
|
1564
|
-
<StrokeNode
|
|
2033
|
+
<StrokeNode
|
|
2034
|
+
key={s.id}
|
|
2035
|
+
stroke={s}
|
|
2036
|
+
anchorsById={anchorsById}
|
|
2037
|
+
interactive={selectMode}
|
|
2038
|
+
editing={s.id === editingStickyId}
|
|
2039
|
+
/>
|
|
1565
2040
|
))}
|
|
1566
2041
|
{selectedStrokes.map((s) => (
|
|
1567
2042
|
<SelectionHalo
|
|
@@ -1582,12 +2057,41 @@ function AnnotationsSvg({
|
|
|
1582
2057
|
vectorEffect="non-scaling-stroke"
|
|
1583
2058
|
/>
|
|
1584
2059
|
) : null}
|
|
1585
|
-
{
|
|
2060
|
+
{editingTarget?.kind === 'anchored' ? (
|
|
1586
2061
|
<TextEditor
|
|
1587
|
-
anchorId={
|
|
1588
|
-
host={
|
|
1589
|
-
existing={
|
|
1590
|
-
onCommit={
|
|
2062
|
+
anchorId={editingTarget.anchorId}
|
|
2063
|
+
host={editingTarget.host}
|
|
2064
|
+
existing={anchoredExisting}
|
|
2065
|
+
onCommit={(_anchorId, text) => onCommitEdit(text)}
|
|
2066
|
+
onCancel={onCancelEdit}
|
|
2067
|
+
/>
|
|
2068
|
+
) : null}
|
|
2069
|
+
{editingTarget?.kind === 'sticky' ? (
|
|
2070
|
+
<StickyEditor
|
|
2071
|
+
sticky={editingTarget.sticky}
|
|
2072
|
+
onCommit={onCommitEdit}
|
|
2073
|
+
onCancel={onCancelEdit}
|
|
2074
|
+
/>
|
|
2075
|
+
) : null}
|
|
2076
|
+
{editingTarget?.kind === 'standalone' ? (
|
|
2077
|
+
<StandaloneTextEditor
|
|
2078
|
+
x={editingTarget.text.x ?? 0}
|
|
2079
|
+
y={editingTarget.text.y ?? 0}
|
|
2080
|
+
fontSize={editingTarget.text.fontSize}
|
|
2081
|
+
color={editingTarget.text.color}
|
|
2082
|
+
initialText={editingTarget.text.text}
|
|
2083
|
+
onCommit={onCommitEdit}
|
|
2084
|
+
onCancel={onCancelEdit}
|
|
2085
|
+
/>
|
|
2086
|
+
) : null}
|
|
2087
|
+
{editingTarget?.kind === 'pending' ? (
|
|
2088
|
+
<StandaloneTextEditor
|
|
2089
|
+
x={editingTarget.x}
|
|
2090
|
+
y={editingTarget.y}
|
|
2091
|
+
fontSize={DEFAULT_FONT_SIZE}
|
|
2092
|
+
color={DEFAULT_COLOR}
|
|
2093
|
+
initialText=""
|
|
2094
|
+
onCommit={onCommitEdit}
|
|
1591
2095
|
onCancel={onCancelEdit}
|
|
1592
2096
|
/>
|
|
1593
2097
|
) : null}
|
|
@@ -1653,6 +2157,7 @@ function TextEditor({
|
|
|
1653
2157
|
<foreignObject x={bbox.x} y={bbox.y} width={Math.max(20, bbox.w)} height={Math.max(20, bbox.h)}>
|
|
1654
2158
|
<div
|
|
1655
2159
|
ref={ref}
|
|
2160
|
+
className="dc-annot-editor"
|
|
1656
2161
|
contentEditable
|
|
1657
2162
|
suppressContentEditableWarning
|
|
1658
2163
|
aria-label="Edit annotation text"
|
|
@@ -1692,6 +2197,154 @@ function TextEditor({
|
|
|
1692
2197
|
);
|
|
1693
2198
|
}
|
|
1694
2199
|
|
|
2200
|
+
// Phase 21 — sticky body editor. A textarea hosted in a foreignObject at the
|
|
2201
|
+
// card's bbox, so it word-wraps + moves with CSS zoom natively. Commit on blur,
|
|
2202
|
+
// cancel on Esc; Enter inserts a newline (sticky is multi-line).
|
|
2203
|
+
function StickyEditor({
|
|
2204
|
+
sticky,
|
|
2205
|
+
onCommit,
|
|
2206
|
+
onCancel,
|
|
2207
|
+
}: {
|
|
2208
|
+
sticky: StickyStroke;
|
|
2209
|
+
onCommit: (text: string) => void;
|
|
2210
|
+
onCancel: () => void;
|
|
2211
|
+
}) {
|
|
2212
|
+
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
2213
|
+
// the committed `.dc-sticky-body` exactly — text stays centered, no jump on
|
|
2214
|
+
// commit. Multi-line: Enter inserts a line break; Esc cancels; blur commits.
|
|
2215
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
2216
|
+
useEffect(() => {
|
|
2217
|
+
const el = ref.current;
|
|
2218
|
+
if (!el) return;
|
|
2219
|
+
el.focus();
|
|
2220
|
+
try {
|
|
2221
|
+
const r = document.createRange();
|
|
2222
|
+
r.selectNodeContents(el);
|
|
2223
|
+
const sel = window.getSelection();
|
|
2224
|
+
if (sel) {
|
|
2225
|
+
sel.removeAllRanges();
|
|
2226
|
+
sel.addRange(r);
|
|
2227
|
+
}
|
|
2228
|
+
} catch {
|
|
2229
|
+
/* selection API blocked */
|
|
2230
|
+
}
|
|
2231
|
+
}, []);
|
|
2232
|
+
const x = Math.min(sticky.x, sticky.x + sticky.w);
|
|
2233
|
+
const y = Math.min(sticky.y, sticky.y + sticky.h);
|
|
2234
|
+
const w = Math.abs(sticky.w);
|
|
2235
|
+
const h = Math.abs(sticky.h);
|
|
2236
|
+
return (
|
|
2237
|
+
<foreignObject x={x} y={y} width={w} height={h}>
|
|
2238
|
+
<div
|
|
2239
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
2240
|
+
ref={ref}
|
|
2241
|
+
className="dc-annot-editor dc-sticky-body"
|
|
2242
|
+
contentEditable
|
|
2243
|
+
suppressContentEditableWarning
|
|
2244
|
+
aria-label="Edit sticky note text"
|
|
2245
|
+
style={{ fontSize: `${sticky.fontSize}px`, outline: 'none', cursor: 'text' }}
|
|
2246
|
+
onBlur={() => onCommit(ref.current?.innerText ?? '')}
|
|
2247
|
+
onKeyDown={(e) => {
|
|
2248
|
+
if (e.key === 'Escape') {
|
|
2249
|
+
e.preventDefault();
|
|
2250
|
+
onCancel();
|
|
2251
|
+
}
|
|
2252
|
+
}}
|
|
2253
|
+
>
|
|
2254
|
+
{sticky.text}
|
|
2255
|
+
</div>
|
|
2256
|
+
</foreignObject>
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// Phase 21 — standalone text editor. A single-line contentEditable box anchored
|
|
2261
|
+
// at the world (x, y). Enter / blur / outside-click commit; Esc cancels.
|
|
2262
|
+
function StandaloneTextEditor({
|
|
2263
|
+
x,
|
|
2264
|
+
y,
|
|
2265
|
+
fontSize,
|
|
2266
|
+
color,
|
|
2267
|
+
initialText,
|
|
2268
|
+
onCommit,
|
|
2269
|
+
onCancel,
|
|
2270
|
+
}: {
|
|
2271
|
+
x: number;
|
|
2272
|
+
y: number;
|
|
2273
|
+
fontSize: number;
|
|
2274
|
+
color: string;
|
|
2275
|
+
initialText: string;
|
|
2276
|
+
onCommit: (text: string) => void;
|
|
2277
|
+
onCancel: () => void;
|
|
2278
|
+
}) {
|
|
2279
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
2280
|
+
useEffect(() => {
|
|
2281
|
+
const el = ref.current;
|
|
2282
|
+
if (!el) return;
|
|
2283
|
+
el.focus();
|
|
2284
|
+
try {
|
|
2285
|
+
const r = document.createRange();
|
|
2286
|
+
r.selectNodeContents(el);
|
|
2287
|
+
const sel = window.getSelection();
|
|
2288
|
+
if (sel) {
|
|
2289
|
+
sel.removeAllRanges();
|
|
2290
|
+
sel.addRange(r);
|
|
2291
|
+
}
|
|
2292
|
+
} catch {
|
|
2293
|
+
/* selection API blocked */
|
|
2294
|
+
}
|
|
2295
|
+
}, []);
|
|
2296
|
+
// Commit on outside click.
|
|
2297
|
+
useEffect(() => {
|
|
2298
|
+
if (typeof document === 'undefined') return;
|
|
2299
|
+
const onDown = (e: PointerEvent) => {
|
|
2300
|
+
const el = ref.current;
|
|
2301
|
+
if (!el) return;
|
|
2302
|
+
if (el.contains(e.target as Node)) return;
|
|
2303
|
+
onCommit(el.innerText || '');
|
|
2304
|
+
};
|
|
2305
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2306
|
+
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2307
|
+
}, [onCommit]);
|
|
2308
|
+
return (
|
|
2309
|
+
<foreignObject x={x} y={y} width={600} height={Math.max(24, fontSize * 1.6)}>
|
|
2310
|
+
<div
|
|
2311
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
2312
|
+
ref={ref}
|
|
2313
|
+
className="dc-annot-editor"
|
|
2314
|
+
contentEditable
|
|
2315
|
+
suppressContentEditableWarning
|
|
2316
|
+
aria-label="Edit text"
|
|
2317
|
+
style={{
|
|
2318
|
+
display: 'inline-block',
|
|
2319
|
+
minWidth: '8px',
|
|
2320
|
+
whiteSpace: 'pre',
|
|
2321
|
+
padding: '0 2px',
|
|
2322
|
+
color,
|
|
2323
|
+
fontSize: `${fontSize}px`,
|
|
2324
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2325
|
+
lineHeight: 1.2,
|
|
2326
|
+
outline: 'none',
|
|
2327
|
+
background: 'transparent',
|
|
2328
|
+
cursor: 'text',
|
|
2329
|
+
}}
|
|
2330
|
+
onKeyDown={(e) => {
|
|
2331
|
+
if (e.key === 'Escape') {
|
|
2332
|
+
e.preventDefault();
|
|
2333
|
+
onCancel();
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
2337
|
+
e.preventDefault();
|
|
2338
|
+
onCommit(ref.current?.innerText || '');
|
|
2339
|
+
}
|
|
2340
|
+
}}
|
|
2341
|
+
>
|
|
2342
|
+
{initialText}
|
|
2343
|
+
</div>
|
|
2344
|
+
</foreignObject>
|
|
2345
|
+
);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
1695
2348
|
function SelectionHalo({
|
|
1696
2349
|
stroke,
|
|
1697
2350
|
anchorsById,
|
|
@@ -1792,7 +2445,7 @@ function AnnotGroupBbox({
|
|
|
1792
2445
|
width={handle}
|
|
1793
2446
|
height={handle}
|
|
1794
2447
|
fill="var(--maude-hud-accent, #d63b1f)"
|
|
1795
|
-
stroke="var(--bg-0, #ffffff)"
|
|
2448
|
+
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
1796
2449
|
strokeWidth={1}
|
|
1797
2450
|
vectorEffect="non-scaling-stroke"
|
|
1798
2451
|
rx={1}
|
|
@@ -1809,10 +2462,13 @@ function StrokeNode({
|
|
|
1809
2462
|
stroke,
|
|
1810
2463
|
anchorsById,
|
|
1811
2464
|
interactive,
|
|
2465
|
+
editing = false,
|
|
1812
2466
|
}: {
|
|
1813
2467
|
stroke: Stroke;
|
|
1814
2468
|
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1815
2469
|
interactive: boolean;
|
|
2470
|
+
/** Phase 21 — sticky-only: hide the read-only body while its editor is up. */
|
|
2471
|
+
editing?: boolean;
|
|
1816
2472
|
}) {
|
|
1817
2473
|
// In Move mode, individual stroke nodes claim pointer events so we can
|
|
1818
2474
|
// hit-test them from the doc-level capture listener. In draw mode the
|
|
@@ -1820,29 +2476,89 @@ function StrokeNode({
|
|
|
1820
2476
|
const hitMode = interactive ? 'visiblePainted' : ('none' as const);
|
|
1821
2477
|
const strokeHit = interactive ? 'stroke' : ('none' as const);
|
|
1822
2478
|
if (stroke.tool === 'text') {
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
if (
|
|
1826
|
-
|
|
1827
|
-
|
|
2479
|
+
// Anchored text renders centered in its host; standalone (Phase 21) renders
|
|
2480
|
+
// top-left-anchored at its own world (x, y).
|
|
2481
|
+
if (stroke.anchorId != null && stroke.anchorId !== '') {
|
|
2482
|
+
const host = anchorsById.get(stroke.anchorId);
|
|
2483
|
+
const bbox = host ? strokeBBox(host) : null;
|
|
2484
|
+
if (!bbox) return null;
|
|
2485
|
+
const cx = bbox.x + bbox.w / 2;
|
|
2486
|
+
const cy = bbox.y + bbox.h / 2;
|
|
2487
|
+
return (
|
|
2488
|
+
<text
|
|
2489
|
+
data-id={stroke.id}
|
|
2490
|
+
data-tool="text"
|
|
2491
|
+
data-anchor-id={stroke.anchorId}
|
|
2492
|
+
data-font-size={stroke.fontSize}
|
|
2493
|
+
x={cx}
|
|
2494
|
+
y={cy}
|
|
2495
|
+
fill={stroke.color}
|
|
2496
|
+
fontSize={stroke.fontSize}
|
|
2497
|
+
textAnchor="middle"
|
|
2498
|
+
dominantBaseline="middle"
|
|
2499
|
+
style={{
|
|
2500
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
2501
|
+
}}
|
|
2502
|
+
>
|
|
2503
|
+
{stroke.text}
|
|
2504
|
+
</text>
|
|
2505
|
+
);
|
|
2506
|
+
}
|
|
1828
2507
|
return (
|
|
1829
2508
|
<text
|
|
1830
2509
|
data-id={stroke.id}
|
|
1831
2510
|
data-tool="text"
|
|
1832
|
-
data-anchor-id={stroke.anchorId}
|
|
1833
2511
|
data-font-size={stroke.fontSize}
|
|
1834
|
-
x={
|
|
1835
|
-
y={
|
|
2512
|
+
x={stroke.x ?? 0}
|
|
2513
|
+
y={stroke.y ?? 0}
|
|
1836
2514
|
fill={stroke.color}
|
|
1837
2515
|
fontSize={stroke.fontSize}
|
|
1838
|
-
textAnchor="
|
|
1839
|
-
dominantBaseline="
|
|
2516
|
+
textAnchor="start"
|
|
2517
|
+
dominantBaseline="hanging"
|
|
2518
|
+
pointerEvents={interactive ? 'visiblePainted' : 'none'}
|
|
1840
2519
|
style={{ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)' }}
|
|
1841
2520
|
>
|
|
1842
2521
|
{stroke.text}
|
|
1843
2522
|
</text>
|
|
1844
2523
|
);
|
|
1845
2524
|
}
|
|
2525
|
+
if (stroke.tool === 'sticky') {
|
|
2526
|
+
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
2527
|
+
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
2528
|
+
const w = Math.abs(stroke.w);
|
|
2529
|
+
const h = Math.abs(stroke.h);
|
|
2530
|
+
const r = stroke.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
2531
|
+
return (
|
|
2532
|
+
<g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
|
|
2533
|
+
{/* Paper card: soft drop shadow + hairline edge so it reads as a
|
|
2534
|
+
lifted sticky, not a flat colored box (FigJam-style). */}
|
|
2535
|
+
<rect
|
|
2536
|
+
x={x}
|
|
2537
|
+
y={y}
|
|
2538
|
+
width={w}
|
|
2539
|
+
height={h}
|
|
2540
|
+
rx={r}
|
|
2541
|
+
ry={r}
|
|
2542
|
+
fill={stroke.color}
|
|
2543
|
+
stroke="rgba(0,0,0,0.05)"
|
|
2544
|
+
strokeWidth={1}
|
|
2545
|
+
vectorEffect="non-scaling-stroke"
|
|
2546
|
+
filter="url(#dc-sticky-shadow)"
|
|
2547
|
+
/>
|
|
2548
|
+
{editing ? null : (
|
|
2549
|
+
<foreignObject x={x} y={y} width={w} height={h} pointerEvents="none">
|
|
2550
|
+
<div
|
|
2551
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
2552
|
+
className="dc-sticky-body"
|
|
2553
|
+
style={{ fontSize: `${stroke.fontSize}px` }}
|
|
2554
|
+
>
|
|
2555
|
+
{stroke.text}
|
|
2556
|
+
</div>
|
|
2557
|
+
</foreignObject>
|
|
2558
|
+
)}
|
|
2559
|
+
</g>
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
1846
2562
|
const common = {
|
|
1847
2563
|
'data-id': stroke.id,
|
|
1848
2564
|
'data-tool': stroke.tool,
|
|
@@ -1858,6 +2574,7 @@ function StrokeNode({
|
|
|
1858
2574
|
if (stroke.tool === 'rect') {
|
|
1859
2575
|
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
1860
2576
|
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
2577
|
+
const r = stroke.cornerRadius ?? 0;
|
|
1861
2578
|
return (
|
|
1862
2579
|
<rect
|
|
1863
2580
|
{...common}
|
|
@@ -1866,6 +2583,8 @@ function StrokeNode({
|
|
|
1866
2583
|
y={y}
|
|
1867
2584
|
width={Math.abs(stroke.w)}
|
|
1868
2585
|
height={Math.abs(stroke.h)}
|
|
2586
|
+
rx={r}
|
|
2587
|
+
ry={r}
|
|
1869
2588
|
pointerEvents={hitMode}
|
|
1870
2589
|
/>
|
|
1871
2590
|
);
|
|
@@ -1883,11 +2602,31 @@ function StrokeNode({
|
|
|
1883
2602
|
/>
|
|
1884
2603
|
);
|
|
1885
2604
|
}
|
|
1886
|
-
|
|
2605
|
+
// arrow — Phase 21 renders per-side heads + optional dash.
|
|
2606
|
+
const endHead = stroke.endHead ?? 'triangle';
|
|
2607
|
+
const startHead = stroke.startHead ?? 'none';
|
|
2608
|
+
const dashed = stroke.dashed ?? false;
|
|
1887
2609
|
return (
|
|
1888
2610
|
<g {...common} fill="none" pointerEvents={hitMode}>
|
|
1889
|
-
<line
|
|
1890
|
-
|
|
2611
|
+
<line
|
|
2612
|
+
x1={stroke.x1}
|
|
2613
|
+
y1={stroke.y1}
|
|
2614
|
+
x2={stroke.x2}
|
|
2615
|
+
y2={stroke.y2}
|
|
2616
|
+
strokeDasharray={dashed ? '6 4' : undefined}
|
|
2617
|
+
/>
|
|
2618
|
+
{startHead === 'triangle' ? (
|
|
2619
|
+
<polyline
|
|
2620
|
+
points={arrowHeadPoints(stroke.x2, stroke.y2, stroke.x1, stroke.y1, stroke.width)}
|
|
2621
|
+
fill={stroke.color}
|
|
2622
|
+
/>
|
|
2623
|
+
) : null}
|
|
2624
|
+
{endHead === 'triangle' ? (
|
|
2625
|
+
<polyline
|
|
2626
|
+
points={arrowHeadPoints(stroke.x1, stroke.y1, stroke.x2, stroke.y2, stroke.width)}
|
|
2627
|
+
fill={stroke.color}
|
|
2628
|
+
/>
|
|
2629
|
+
) : null}
|
|
1891
2630
|
</g>
|
|
1892
2631
|
);
|
|
1893
2632
|
}
|
|
@@ -1897,8 +2636,11 @@ function StrokeNode({
|
|
|
1897
2636
|
// + presentation toggle + help button.
|
|
1898
2637
|
|
|
1899
2638
|
function AnnotationsChrome({
|
|
2639
|
+
tool,
|
|
1900
2640
|
color,
|
|
1901
2641
|
setColor,
|
|
2642
|
+
stickyColor,
|
|
2643
|
+
setStickyColor,
|
|
1902
2644
|
supportsFill,
|
|
1903
2645
|
fill,
|
|
1904
2646
|
setFill,
|
|
@@ -1906,8 +2648,11 @@ function AnnotationsChrome({
|
|
|
1906
2648
|
thickness,
|
|
1907
2649
|
setThickness,
|
|
1908
2650
|
}: {
|
|
2651
|
+
tool: Tool;
|
|
1909
2652
|
color: string;
|
|
1910
2653
|
setColor: (c: string) => void;
|
|
2654
|
+
stickyColor: string;
|
|
2655
|
+
setStickyColor: (c: string) => void;
|
|
1911
2656
|
supportsFill: boolean;
|
|
1912
2657
|
fill: string | null;
|
|
1913
2658
|
setFill: (f: string | null) => void;
|
|
@@ -1915,10 +2660,32 @@ function AnnotationsChrome({
|
|
|
1915
2660
|
thickness: Thickness;
|
|
1916
2661
|
setThickness: (t: Thickness) => void;
|
|
1917
2662
|
}) {
|
|
2663
|
+
// Sticky tool picks a paper tint (its own palette); every other draw tool
|
|
2664
|
+
// picks ink from the stroke PALETTE.
|
|
2665
|
+
if (tool === 'sticky') {
|
|
2666
|
+
return (
|
|
2667
|
+
<div className="dc-annot-chrome" role="toolbar" aria-label="Sticky note tools">
|
|
2668
|
+
<div className="dc-annot-swatches" role="radiogroup" aria-label="Sticky color">
|
|
2669
|
+
{STICKY_PALETTE.map((c) => (
|
|
2670
|
+
<button
|
|
2671
|
+
key={c}
|
|
2672
|
+
type="button"
|
|
2673
|
+
className="dc-annot-sw"
|
|
2674
|
+
aria-pressed={c === stickyColor}
|
|
2675
|
+
aria-label={`Sticky color ${c}`}
|
|
2676
|
+
title={`Sticky color ${c}`}
|
|
2677
|
+
style={{ background: c }}
|
|
2678
|
+
onClick={() => setStickyColor(c)}
|
|
2679
|
+
/>
|
|
2680
|
+
))}
|
|
2681
|
+
</div>
|
|
2682
|
+
</div>
|
|
2683
|
+
);
|
|
2684
|
+
}
|
|
1918
2685
|
return (
|
|
1919
2686
|
<div className="dc-annot-chrome" role="toolbar" aria-label="Annotation tools">
|
|
1920
2687
|
<div className="dc-annot-swatches" role="radiogroup" aria-label="Stroke color">
|
|
1921
|
-
{
|
|
2688
|
+
{STROKE_PALETTE.map((c) => (
|
|
1922
2689
|
<button
|
|
1923
2690
|
key={c}
|
|
1924
2691
|
type="button"
|
|
@@ -1963,21 +2730,23 @@ function AnnotationsChrome({
|
|
|
1963
2730
|
<div className="dc-annot-sep" />
|
|
1964
2731
|
<button
|
|
1965
2732
|
type="button"
|
|
1966
|
-
className="dc-annot-
|
|
2733
|
+
className="dc-annot-ibtn"
|
|
2734
|
+
aria-label="Thin stroke"
|
|
1967
2735
|
aria-pressed={thickness === STROKE_WIDTH_THIN}
|
|
1968
2736
|
title="Thin (3px)"
|
|
1969
2737
|
onClick={() => setThickness(STROKE_WIDTH_THIN)}
|
|
1970
2738
|
>
|
|
1971
|
-
|
|
2739
|
+
<IconLineThin />
|
|
1972
2740
|
</button>
|
|
1973
2741
|
<button
|
|
1974
2742
|
type="button"
|
|
1975
|
-
className="dc-annot-
|
|
2743
|
+
className="dc-annot-ibtn"
|
|
2744
|
+
aria-label="Thick stroke"
|
|
1976
2745
|
aria-pressed={thickness === STROKE_WIDTH_THICK}
|
|
1977
2746
|
title="Thick (6px)"
|
|
1978
2747
|
onClick={() => setThickness(STROKE_WIDTH_THICK)}
|
|
1979
2748
|
>
|
|
1980
|
-
|
|
2749
|
+
<IconLineThick />
|
|
1981
2750
|
</button>
|
|
1982
2751
|
</>
|
|
1983
2752
|
) : null}
|