@1agh/maude 0.24.0 → 0.26.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/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
- package/plugins/design/dev-server/annotations-layer.tsx +817 -170
- package/plugins/design/dev-server/api.ts +15 -1
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/smoke.sh +114 -12
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +130 -82
- package/plugins/design/dev-server/canvas-icons.tsx +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/canvas-shell.tsx +113 -89
- package/plugins/design/dev-server/client/app.jsx +1084 -417
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +242 -20
- package/plugins/design/dev-server/dist/comment-mount.js +40 -62
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/export-dialog.tsx +189 -1
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/http.ts +40 -9
- package/plugins/design/dev-server/input-router.tsx +9 -2
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +11 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
- package/plugins/design/dev-server/tool-palette.tsx +140 -11
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
- package/plugins/design/templates/_shell.html +36 -9
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
|
@@ -20,30 +20,93 @@
|
|
|
20
20
|
* - font-size: only when at least one stroke is text (and the rest are its host)
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
|
+
type ArrowHead,
|
|
27
|
+
type ArrowLineType,
|
|
26
28
|
FILL_PALETTE,
|
|
29
|
+
STICKY_PALETTE,
|
|
27
30
|
STROKE_PALETTE,
|
|
28
31
|
type Stroke,
|
|
32
|
+
type TextAlign,
|
|
29
33
|
useStrokesStore,
|
|
30
34
|
} from './annotations-layer.tsx';
|
|
31
35
|
import {
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
IconAlignCenter,
|
|
37
|
+
IconAlignLeft,
|
|
38
|
+
IconAlignRight,
|
|
34
39
|
IconArrowNone,
|
|
35
|
-
|
|
40
|
+
IconArrowheadCircle,
|
|
41
|
+
IconArrowheadDiamond,
|
|
42
|
+
IconArrowheadLine,
|
|
43
|
+
IconArrowheadTriangle,
|
|
44
|
+
IconArrowheadTriangleOutline,
|
|
45
|
+
IconBold,
|
|
46
|
+
IconChevronDown,
|
|
36
47
|
IconCornerPill,
|
|
37
48
|
IconCornerSoft,
|
|
38
49
|
IconCornerSquare,
|
|
39
50
|
IconDash,
|
|
40
|
-
|
|
51
|
+
IconLineCurved,
|
|
52
|
+
IconLineElbow,
|
|
53
|
+
IconLineStraight,
|
|
41
54
|
IconLineThick,
|
|
42
55
|
IconLineThin,
|
|
56
|
+
IconStrike,
|
|
43
57
|
IconTrash,
|
|
44
58
|
} from './canvas-icons.tsx';
|
|
45
59
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
46
60
|
|
|
61
|
+
// Phase 24 — arrowhead + line-type option metadata (icon + label per value),
|
|
62
|
+
// driving the per-end head dropdowns + the line-type dropdown.
|
|
63
|
+
const HEAD_ICON: Record<ArrowHead, (p: { size?: number }) => ReactNode> = {
|
|
64
|
+
none: IconArrowNone,
|
|
65
|
+
line: IconArrowheadLine,
|
|
66
|
+
triangle: IconArrowheadTriangle,
|
|
67
|
+
'triangle-outline': IconArrowheadTriangleOutline,
|
|
68
|
+
circle: IconArrowheadCircle,
|
|
69
|
+
diamond: IconArrowheadDiamond,
|
|
70
|
+
};
|
|
71
|
+
const HEAD_OPTIONS: ReadonlyArray<{ value: ArrowHead; label: string }> = [
|
|
72
|
+
{ value: 'none', label: 'None' },
|
|
73
|
+
{ value: 'line', label: 'Line' },
|
|
74
|
+
{ value: 'triangle', label: 'Triangle' },
|
|
75
|
+
{ value: 'triangle-outline', label: 'Triangle (outline)' },
|
|
76
|
+
{ value: 'circle', label: 'Circle' },
|
|
77
|
+
{ value: 'diamond', label: 'Diamond' },
|
|
78
|
+
];
|
|
79
|
+
const LINETYPE_ICON: Record<ArrowLineType, (p: { size?: number }) => ReactNode> = {
|
|
80
|
+
straight: IconLineStraight,
|
|
81
|
+
curved: IconLineCurved,
|
|
82
|
+
elbow: IconLineElbow,
|
|
83
|
+
};
|
|
84
|
+
const LINETYPE_OPTIONS: ReadonlyArray<{ value: ArrowLineType; label: string }> = [
|
|
85
|
+
{ value: 'straight', label: 'Straight' },
|
|
86
|
+
{ value: 'curved', label: 'Curved' },
|
|
87
|
+
{ value: 'elbow', label: 'Elbow' },
|
|
88
|
+
];
|
|
89
|
+
// Phase 24 — text alignment (text + sticky) + named font-size presets.
|
|
90
|
+
const ALIGN_ICON: Record<TextAlign, (p: { size?: number }) => ReactNode> = {
|
|
91
|
+
left: IconAlignLeft,
|
|
92
|
+
center: IconAlignCenter,
|
|
93
|
+
right: IconAlignRight,
|
|
94
|
+
};
|
|
95
|
+
const ALIGN_OPTIONS: ReadonlyArray<{ value: TextAlign; label: string }> = [
|
|
96
|
+
{ value: 'left', label: 'Align left' },
|
|
97
|
+
{ value: 'center', label: 'Align center' },
|
|
98
|
+
{ value: 'right', label: 'Align right' },
|
|
99
|
+
];
|
|
100
|
+
const FONT_SIZE_PRESETS: ReadonlyArray<{ px: number; label: string }> = [
|
|
101
|
+
{ px: 12, label: 'Small' },
|
|
102
|
+
{ px: 16, label: 'Medium' },
|
|
103
|
+
{ px: 24, label: 'Large' },
|
|
104
|
+
{ px: 36, label: 'Extra large' },
|
|
105
|
+
{ px: 64, label: 'Huge' },
|
|
106
|
+
];
|
|
107
|
+
const FONT_SIZE_MIN = 8;
|
|
108
|
+
const FONT_SIZE_MAX = 200;
|
|
109
|
+
|
|
47
110
|
// Phase 21 — the swatch palettes come from annotations-layer so the draw-time
|
|
48
111
|
// chrome and this per-selection toolbar share ONE hue family. STROKE mode shows
|
|
49
112
|
// saturated inks; FILL mode shows the index-paired light tints (FigJam: a
|
|
@@ -190,6 +253,91 @@ const TOOLBAR_CSS = `
|
|
|
190
253
|
background: color-mix(in oklab, #ff5a4d 26%, transparent);
|
|
191
254
|
color: #ffffff;
|
|
192
255
|
}
|
|
256
|
+
/* Phase 24 — icon dropdown (arrowhead / line-type / text-align). The wrapper
|
|
257
|
+
anchors the menu directly above the trigger. */
|
|
258
|
+
.dc-annot-ctx-dd { position: relative; display: inline-flex; }
|
|
259
|
+
.dc-annot-ctx-dd-trigger { padding-right: 9px; }
|
|
260
|
+
.dc-annot-ctx-dd-caret {
|
|
261
|
+
position: absolute;
|
|
262
|
+
right: 1px;
|
|
263
|
+
bottom: 2px;
|
|
264
|
+
display: inline-flex;
|
|
265
|
+
opacity: 0.5;
|
|
266
|
+
pointer-events: none;
|
|
267
|
+
}
|
|
268
|
+
.dc-annot-ctx-menu {
|
|
269
|
+
position: absolute;
|
|
270
|
+
bottom: calc(100% + 6px);
|
|
271
|
+
left: 50%;
|
|
272
|
+
transform: translateX(-50%);
|
|
273
|
+
display: flex;
|
|
274
|
+
gap: 1px;
|
|
275
|
+
padding: 4px;
|
|
276
|
+
background: ${CTX_SURFACE};
|
|
277
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
278
|
+
border-radius: 9px;
|
|
279
|
+
box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.24);
|
|
280
|
+
z-index: 9;
|
|
281
|
+
}
|
|
282
|
+
.dc-annot-ctx-menu button[aria-checked="true"] {
|
|
283
|
+
background: rgba(255,255,255,0.18);
|
|
284
|
+
color: #ffffff;
|
|
285
|
+
}
|
|
286
|
+
/* Font-size dropdown — wider trigger showing the current px + a vertical menu
|
|
287
|
+
of named presets and a numeric input. */
|
|
288
|
+
.dc-annot-ctx-fs-trigger {
|
|
289
|
+
width: auto;
|
|
290
|
+
min-width: 30px;
|
|
291
|
+
padding: 0 14px 0 7px;
|
|
292
|
+
font-variant-numeric: tabular-nums;
|
|
293
|
+
}
|
|
294
|
+
.dc-annot-ctx-fs-val { font-size: 12px; }
|
|
295
|
+
.dc-annot-ctx-fs-menu {
|
|
296
|
+
position: absolute;
|
|
297
|
+
bottom: calc(100% + 6px);
|
|
298
|
+
left: 50%;
|
|
299
|
+
transform: translateX(-50%);
|
|
300
|
+
display: flex;
|
|
301
|
+
flex-direction: column;
|
|
302
|
+
gap: 1px;
|
|
303
|
+
padding: 4px;
|
|
304
|
+
min-width: 132px;
|
|
305
|
+
background: ${CTX_SURFACE};
|
|
306
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
307
|
+
border-radius: 9px;
|
|
308
|
+
box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 2px 6px rgba(0,0,0,0.24);
|
|
309
|
+
z-index: 9;
|
|
310
|
+
}
|
|
311
|
+
.dc-annot-ctx-fs-item {
|
|
312
|
+
appearance: none;
|
|
313
|
+
background: transparent;
|
|
314
|
+
border: 0;
|
|
315
|
+
border-radius: 6px;
|
|
316
|
+
padding: 5px 8px;
|
|
317
|
+
display: flex;
|
|
318
|
+
justify-content: space-between;
|
|
319
|
+
gap: 16px;
|
|
320
|
+
font: inherit;
|
|
321
|
+
font-size: 12px;
|
|
322
|
+
color: rgba(255,255,255,0.85);
|
|
323
|
+
cursor: pointer;
|
|
324
|
+
}
|
|
325
|
+
.dc-annot-ctx-fs-item:hover { background: rgba(255,255,255,0.1); }
|
|
326
|
+
.dc-annot-ctx-fs-item[aria-checked="true"] { background: rgba(255,255,255,0.18); color: #fff; }
|
|
327
|
+
.dc-annot-ctx-fs-px { opacity: 0.5; font-variant-numeric: tabular-nums; }
|
|
328
|
+
.dc-annot-ctx-fs-input {
|
|
329
|
+
margin-top: 3px;
|
|
330
|
+
width: 100%;
|
|
331
|
+
box-sizing: border-box;
|
|
332
|
+
background: rgba(255,255,255,0.08);
|
|
333
|
+
border: 1px solid rgba(255,255,255,0.14);
|
|
334
|
+
border-radius: 6px;
|
|
335
|
+
padding: 4px 8px;
|
|
336
|
+
color: #fff;
|
|
337
|
+
font: inherit;
|
|
338
|
+
font-size: 12px;
|
|
339
|
+
}
|
|
340
|
+
.dc-annot-ctx-fs-input:focus-visible { outline: 2px solid #fff; outline-offset: -1px; }
|
|
193
341
|
@media (prefers-reduced-motion: reduce) {
|
|
194
342
|
.dc-annot-ctx-ibtn, .dc-annot-ctx-sw { transition: none; }
|
|
195
343
|
}
|
|
@@ -269,14 +417,17 @@ export function AnnotationContextToolbar() {
|
|
|
269
417
|
const fontSizeApplicable = selectedStrokes.some(
|
|
270
418
|
(s) => s.tool === 'text' || s.tool === 'sticky'
|
|
271
419
|
);
|
|
272
|
-
|
|
420
|
+
// Phase 24 — the corner-radius control is rect-only now. Stickies have a
|
|
421
|
+
// fixed soft radius (no switch); the sticky-color swatch row is handled
|
|
422
|
+
// separately below.
|
|
423
|
+
const allRect = selectedStrokes.every((s) => s.tool === 'rect');
|
|
273
424
|
const allArrow = selectedStrokes.every((s) => s.tool === 'arrow');
|
|
274
425
|
return {
|
|
275
426
|
color: true,
|
|
276
427
|
fill: allFillable,
|
|
277
428
|
thickness: allThickness,
|
|
278
429
|
fontSize: fontSizeApplicable,
|
|
279
|
-
cornerRadius:
|
|
430
|
+
cornerRadius: allRect,
|
|
280
431
|
arrowDir: allArrow,
|
|
281
432
|
dash: allArrow,
|
|
282
433
|
};
|
|
@@ -371,6 +522,40 @@ export function AnnotationContextToolbar() {
|
|
|
371
522
|
},
|
|
372
523
|
[store, selectedStrokes]
|
|
373
524
|
);
|
|
525
|
+
// Phase 24 — bold / strike / align on text + sticky bodies.
|
|
526
|
+
const setBold = useCallback(
|
|
527
|
+
(bold: boolean) => {
|
|
528
|
+
if (!store) return;
|
|
529
|
+
for (const s of selectedStrokes) {
|
|
530
|
+
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
531
|
+
store.updateStroke(s.id, { bold } as Partial<Stroke>);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
[store, selectedStrokes]
|
|
536
|
+
);
|
|
537
|
+
const setStrike = useCallback(
|
|
538
|
+
(strike: boolean) => {
|
|
539
|
+
if (!store) return;
|
|
540
|
+
for (const s of selectedStrokes) {
|
|
541
|
+
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
542
|
+
store.updateStroke(s.id, { strike } as Partial<Stroke>);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
[store, selectedStrokes]
|
|
547
|
+
);
|
|
548
|
+
const setAlign = useCallback(
|
|
549
|
+
(align: TextAlign) => {
|
|
550
|
+
if (!store) return;
|
|
551
|
+
for (const s of selectedStrokes) {
|
|
552
|
+
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
553
|
+
store.updateStroke(s.id, { align } as Partial<Stroke>);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
[store, selectedStrokes]
|
|
558
|
+
);
|
|
374
559
|
// Phase 21 — corner radius (rect + sticky).
|
|
375
560
|
const setCornerRadius = useCallback(
|
|
376
561
|
(r: number) => {
|
|
@@ -383,14 +568,31 @@ export function AnnotationContextToolbar() {
|
|
|
383
568
|
},
|
|
384
569
|
[store, selectedStrokes]
|
|
385
570
|
);
|
|
386
|
-
// Phase
|
|
387
|
-
|
|
388
|
-
|
|
571
|
+
// Phase 24 — per-end arrowhead + line-type (replaces the 4 fixed direction
|
|
572
|
+
// presets with two head dropdowns + a routing dropdown).
|
|
573
|
+
const setStartHead = useCallback(
|
|
574
|
+
(startHead: ArrowHead) => {
|
|
389
575
|
if (!store) return;
|
|
390
576
|
for (const s of selectedStrokes) {
|
|
391
|
-
if (s.tool === 'arrow') {
|
|
392
|
-
|
|
393
|
-
|
|
577
|
+
if (s.tool === 'arrow') store.updateStroke(s.id, { startHead } as Partial<Stroke>);
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
[store, selectedStrokes]
|
|
581
|
+
);
|
|
582
|
+
const setEndHead = useCallback(
|
|
583
|
+
(endHead: ArrowHead) => {
|
|
584
|
+
if (!store) return;
|
|
585
|
+
for (const s of selectedStrokes) {
|
|
586
|
+
if (s.tool === 'arrow') store.updateStroke(s.id, { endHead } as Partial<Stroke>);
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
[store, selectedStrokes]
|
|
590
|
+
);
|
|
591
|
+
const setLineType = useCallback(
|
|
592
|
+
(lineType: ArrowLineType) => {
|
|
593
|
+
if (!store) return;
|
|
594
|
+
for (const s of selectedStrokes) {
|
|
595
|
+
if (s.tool === 'arrow') store.updateStroke(s.id, { lineType } as Partial<Stroke>);
|
|
394
596
|
}
|
|
395
597
|
},
|
|
396
598
|
[store, selectedStrokes]
|
|
@@ -440,6 +642,32 @@ export function AnnotationContextToolbar() {
|
|
|
440
642
|
)
|
|
441
643
|
)
|
|
442
644
|
: undefined;
|
|
645
|
+
// Phase 24 — uniform bold / strike / align across the text-bearing selection.
|
|
646
|
+
// Align default differs per kind: anchored text = centre, standalone + sticky
|
|
647
|
+
// = left (matches the serializer defaults so the active-state reads true).
|
|
648
|
+
const uniqBold = caps.fontSize
|
|
649
|
+
? uniformValue(
|
|
650
|
+
selectedStrokes.map((s) =>
|
|
651
|
+
s.tool === 'text' || s.tool === 'sticky' ? !!s.bold : undefined
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
: undefined;
|
|
655
|
+
const uniqStrike = caps.fontSize
|
|
656
|
+
? uniformValue(
|
|
657
|
+
selectedStrokes.map((s) =>
|
|
658
|
+
s.tool === 'text' || s.tool === 'sticky' ? !!s.strike : undefined
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
: undefined;
|
|
662
|
+
const uniqAlign = caps.fontSize
|
|
663
|
+
? uniformValue(
|
|
664
|
+
selectedStrokes.map((s) => {
|
|
665
|
+
if (s.tool === 'sticky') return s.align ?? 'left';
|
|
666
|
+
if (s.tool === 'text') return s.align ?? (s.anchorId ? 'center' : 'left');
|
|
667
|
+
return undefined;
|
|
668
|
+
})
|
|
669
|
+
)
|
|
670
|
+
: undefined;
|
|
443
671
|
// Phase 21 — uniform corner radius across rect/sticky (default per type: rect
|
|
444
672
|
// sharp = 0, sticky soft = 8).
|
|
445
673
|
const uniqRadius = caps.cornerRadius
|
|
@@ -464,6 +692,11 @@ export function AnnotationContextToolbar() {
|
|
|
464
692
|
selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.endHead ?? 'triangle') : undefined))
|
|
465
693
|
)
|
|
466
694
|
: undefined;
|
|
695
|
+
const uniqLineType = caps.arrowDir
|
|
696
|
+
? uniformValue(
|
|
697
|
+
selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.lineType ?? 'straight') : undefined))
|
|
698
|
+
)
|
|
699
|
+
: undefined;
|
|
467
700
|
const uniqDashed = caps.dash
|
|
468
701
|
? uniformValue(
|
|
469
702
|
selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.dashed ?? false) : undefined))
|
|
@@ -474,10 +707,21 @@ export function AnnotationContextToolbar() {
|
|
|
474
707
|
// useEffect below could call setSwatchMode('stroke') but reading the
|
|
475
708
|
// effective mode inline avoids an extra render cycle.
|
|
476
709
|
const effectiveMode: SwatchMode = caps.fill ? swatchMode : 'stroke';
|
|
477
|
-
|
|
710
|
+
// Phase 24 — a sticky selection picks its PAPER TINT, not stroke ink. The
|
|
711
|
+
// pre-Phase-24 toolbar showed STROKE_PALETTE for stickies (a latent bug):
|
|
712
|
+
// sticky has no stroke, so its swatch must paint the muted STICKY_PALETTE and
|
|
713
|
+
// write to `color` (the paper tint). No Stroke|Fill toggle for sticky
|
|
714
|
+
// (caps.fill is already false).
|
|
715
|
+
const allSticky = selectedStrokes.every((s) => s.tool === 'sticky');
|
|
716
|
+
const showPalette = allSticky
|
|
717
|
+
? STICKY_PALETTE
|
|
718
|
+
: effectiveMode === 'stroke'
|
|
719
|
+
? STROKE_PALETTE
|
|
720
|
+
: FILL_PALETTE;
|
|
478
721
|
const onSwatchClick =
|
|
479
|
-
effectiveMode === '
|
|
480
|
-
const activeValue = effectiveMode === 'stroke' ? uniqColor : (uniqFill ?? null);
|
|
722
|
+
!allSticky && effectiveMode === 'fill' ? (c: string) => setFill(c) : (c: string) => setColor(c);
|
|
723
|
+
const activeValue = allSticky || effectiveMode === 'stroke' ? uniqColor : (uniqFill ?? null);
|
|
724
|
+
const swatchKind = allSticky ? 'Sticky color' : effectiveMode === 'stroke' ? 'Color' : 'Fill';
|
|
481
725
|
|
|
482
726
|
return (
|
|
483
727
|
<div
|
|
@@ -526,9 +770,9 @@ export function AnnotationContextToolbar() {
|
|
|
526
770
|
key={c}
|
|
527
771
|
type="button"
|
|
528
772
|
className="dc-annot-ctx-sw"
|
|
529
|
-
aria-label={`${
|
|
773
|
+
aria-label={`${swatchKind} ${c}`}
|
|
530
774
|
aria-pressed={activeValue === c}
|
|
531
|
-
title={`${
|
|
775
|
+
title={`${swatchKind} ${c}`}
|
|
532
776
|
style={{ background: c }}
|
|
533
777
|
onClick={() => onSwatchClick(c)}
|
|
534
778
|
/>
|
|
@@ -562,36 +806,37 @@ export function AnnotationContextToolbar() {
|
|
|
562
806
|
{caps.fontSize ? (
|
|
563
807
|
<>
|
|
564
808
|
<div className="dc-annot-ctx-sep" />
|
|
809
|
+
<FontSizeDropdown value={uniqFontSize} onPick={setFontSize} />
|
|
565
810
|
<button
|
|
566
811
|
type="button"
|
|
567
812
|
className="dc-annot-ctx-ibtn"
|
|
568
|
-
aria-label="
|
|
569
|
-
aria-pressed={
|
|
570
|
-
title="
|
|
571
|
-
onClick={() =>
|
|
572
|
-
>
|
|
573
|
-
<IconLetterA size={13} />
|
|
574
|
-
</button>
|
|
575
|
-
<button
|
|
576
|
-
type="button"
|
|
577
|
-
className="dc-annot-ctx-ibtn"
|
|
578
|
-
aria-label="Medium text"
|
|
579
|
-
aria-pressed={uniqFontSize === 14}
|
|
580
|
-
title="Medium (14px)"
|
|
581
|
-
onClick={() => setFontSize(14)}
|
|
813
|
+
aria-label="Bold"
|
|
814
|
+
aria-pressed={uniqBold === true}
|
|
815
|
+
title="Bold"
|
|
816
|
+
onClick={() => setBold(!(uniqBold === true))}
|
|
582
817
|
>
|
|
583
|
-
<
|
|
818
|
+
<IconBold />
|
|
584
819
|
</button>
|
|
585
820
|
<button
|
|
586
821
|
type="button"
|
|
587
822
|
className="dc-annot-ctx-ibtn"
|
|
588
|
-
aria-label="
|
|
589
|
-
aria-pressed={
|
|
590
|
-
title="
|
|
591
|
-
onClick={() =>
|
|
823
|
+
aria-label="Strikethrough"
|
|
824
|
+
aria-pressed={uniqStrike === true}
|
|
825
|
+
title="Strikethrough"
|
|
826
|
+
onClick={() => setStrike(!(uniqStrike === true))}
|
|
592
827
|
>
|
|
593
|
-
<
|
|
828
|
+
<IconStrike />
|
|
594
829
|
</button>
|
|
830
|
+
<IconDropdown
|
|
831
|
+
ariaLabel="Text alignment"
|
|
832
|
+
value={uniqAlign ?? 'left'}
|
|
833
|
+
options={ALIGN_OPTIONS}
|
|
834
|
+
renderIcon={(v) => {
|
|
835
|
+
const I = ALIGN_ICON[v as TextAlign];
|
|
836
|
+
return I ? <I size={16} /> : null;
|
|
837
|
+
}}
|
|
838
|
+
onPick={(v) => setAlign(v as TextAlign)}
|
|
839
|
+
/>
|
|
595
840
|
</>
|
|
596
841
|
) : null}
|
|
597
842
|
{caps.cornerRadius ? (
|
|
@@ -632,46 +877,36 @@ export function AnnotationContextToolbar() {
|
|
|
632
877
|
{caps.arrowDir ? (
|
|
633
878
|
<>
|
|
634
879
|
<div className="dc-annot-ctx-sep" />
|
|
635
|
-
<
|
|
636
|
-
|
|
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
|
-
<button
|
|
666
|
-
type="button"
|
|
667
|
-
className="dc-annot-ctx-ibtn"
|
|
668
|
-
aria-label="Arrowheads at both ends"
|
|
669
|
-
aria-pressed={uniqStartHead === 'triangle' && uniqEndHead === 'triangle'}
|
|
670
|
-
title="Heads at both ends"
|
|
671
|
-
onClick={() => setArrowDir('triangle', 'triangle')}
|
|
672
|
-
>
|
|
673
|
-
<IconArrowBothHeads />
|
|
674
|
-
</button>
|
|
880
|
+
<IconDropdown
|
|
881
|
+
ariaLabel="Start arrowhead"
|
|
882
|
+
value={uniqStartHead ?? 'none'}
|
|
883
|
+
options={HEAD_OPTIONS}
|
|
884
|
+
renderIcon={(v) => {
|
|
885
|
+
const I = HEAD_ICON[v as ArrowHead];
|
|
886
|
+
return I ? <I size={16} /> : null;
|
|
887
|
+
}}
|
|
888
|
+
onPick={(v) => setStartHead(v as ArrowHead)}
|
|
889
|
+
/>
|
|
890
|
+
<IconDropdown
|
|
891
|
+
ariaLabel="End arrowhead"
|
|
892
|
+
value={uniqEndHead ?? 'triangle'}
|
|
893
|
+
options={HEAD_OPTIONS}
|
|
894
|
+
renderIcon={(v) => {
|
|
895
|
+
const I = HEAD_ICON[v as ArrowHead];
|
|
896
|
+
return I ? <I size={16} /> : null;
|
|
897
|
+
}}
|
|
898
|
+
onPick={(v) => setEndHead(v as ArrowHead)}
|
|
899
|
+
/>
|
|
900
|
+
<IconDropdown
|
|
901
|
+
ariaLabel="Line type"
|
|
902
|
+
value={uniqLineType ?? 'straight'}
|
|
903
|
+
options={LINETYPE_OPTIONS}
|
|
904
|
+
renderIcon={(v) => {
|
|
905
|
+
const I = LINETYPE_ICON[v as ArrowLineType];
|
|
906
|
+
return I ? <I size={16} /> : null;
|
|
907
|
+
}}
|
|
908
|
+
onPick={(v) => setLineType(v as ArrowLineType)}
|
|
909
|
+
/>
|
|
675
910
|
</>
|
|
676
911
|
) : null}
|
|
677
912
|
{caps.dash ? (
|
|
@@ -704,6 +939,174 @@ export function AnnotationContextToolbar() {
|
|
|
704
939
|
}
|
|
705
940
|
AnnotationContextToolbar.displayName = 'AnnotationContextToolbar';
|
|
706
941
|
|
|
942
|
+
/**
|
|
943
|
+
* Phase 24 — a compact icon dropdown for the context toolbar (arrowhead per
|
|
944
|
+
* end, line-type). Trigger shows the current value's icon + a caret; the menu
|
|
945
|
+
* is a single row of icon-only radio items. Closes on outside-pointerdown or
|
|
946
|
+
* Escape. Reuses the existing `.dc-annot-ctx-ibtn` button styling.
|
|
947
|
+
*/
|
|
948
|
+
function IconDropdown({
|
|
949
|
+
ariaLabel,
|
|
950
|
+
value,
|
|
951
|
+
options,
|
|
952
|
+
renderIcon,
|
|
953
|
+
onPick,
|
|
954
|
+
}: {
|
|
955
|
+
ariaLabel: string;
|
|
956
|
+
value: string;
|
|
957
|
+
options: ReadonlyArray<{ value: string; label: string }>;
|
|
958
|
+
renderIcon: (v: string) => ReactNode;
|
|
959
|
+
onPick: (v: string) => void;
|
|
960
|
+
}) {
|
|
961
|
+
const [open, setOpen] = useState(false);
|
|
962
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
963
|
+
useEffect(() => {
|
|
964
|
+
if (!open) return;
|
|
965
|
+
const onDown = (e: PointerEvent) => {
|
|
966
|
+
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
|
967
|
+
};
|
|
968
|
+
const onKey = (e: KeyboardEvent) => {
|
|
969
|
+
if (e.key === 'Escape') setOpen(false);
|
|
970
|
+
};
|
|
971
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
972
|
+
document.addEventListener('keydown', onKey, true);
|
|
973
|
+
return () => {
|
|
974
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
975
|
+
document.removeEventListener('keydown', onKey, true);
|
|
976
|
+
};
|
|
977
|
+
}, [open]);
|
|
978
|
+
const current = options.find((o) => o.value === value);
|
|
979
|
+
return (
|
|
980
|
+
<div ref={ref} className="dc-annot-ctx-dd">
|
|
981
|
+
<button
|
|
982
|
+
type="button"
|
|
983
|
+
className="dc-annot-ctx-ibtn dc-annot-ctx-dd-trigger"
|
|
984
|
+
aria-haspopup="menu"
|
|
985
|
+
aria-expanded={open}
|
|
986
|
+
aria-label={`${ariaLabel}: ${current?.label ?? value}`}
|
|
987
|
+
title={ariaLabel}
|
|
988
|
+
onClick={() => setOpen((o) => !o)}
|
|
989
|
+
>
|
|
990
|
+
{renderIcon(value)}
|
|
991
|
+
<span className="dc-annot-ctx-dd-caret" aria-hidden="true">
|
|
992
|
+
<IconChevronDown size={8} />
|
|
993
|
+
</span>
|
|
994
|
+
</button>
|
|
995
|
+
{open ? (
|
|
996
|
+
<div className="dc-annot-ctx-menu" role="menu" aria-label={ariaLabel}>
|
|
997
|
+
{options.map((o) => (
|
|
998
|
+
<button
|
|
999
|
+
key={o.value}
|
|
1000
|
+
type="button"
|
|
1001
|
+
role="menuitemradio"
|
|
1002
|
+
aria-checked={o.value === value}
|
|
1003
|
+
aria-label={o.label}
|
|
1004
|
+
title={o.label}
|
|
1005
|
+
className="dc-annot-ctx-ibtn"
|
|
1006
|
+
onClick={() => {
|
|
1007
|
+
onPick(o.value);
|
|
1008
|
+
setOpen(false);
|
|
1009
|
+
}}
|
|
1010
|
+
>
|
|
1011
|
+
{renderIcon(o.value)}
|
|
1012
|
+
</button>
|
|
1013
|
+
))}
|
|
1014
|
+
</div>
|
|
1015
|
+
) : null}
|
|
1016
|
+
</div>
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Phase 24 — font-size control: named presets (Small → Huge) + a numeric input
|
|
1022
|
+
* for an arbitrary px value (clamped 8–200, applied on Enter / blur). The
|
|
1023
|
+
* trigger shows the current size, or "—" when the selection is mixed.
|
|
1024
|
+
*/
|
|
1025
|
+
function FontSizeDropdown({
|
|
1026
|
+
value,
|
|
1027
|
+
onPick,
|
|
1028
|
+
}: {
|
|
1029
|
+
value: number | undefined;
|
|
1030
|
+
onPick: (n: number) => void;
|
|
1031
|
+
}) {
|
|
1032
|
+
const [open, setOpen] = useState(false);
|
|
1033
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
1034
|
+
useEffect(() => {
|
|
1035
|
+
if (!open) return;
|
|
1036
|
+
const onDown = (e: PointerEvent) => {
|
|
1037
|
+
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
|
1038
|
+
};
|
|
1039
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1040
|
+
if (e.key === 'Escape') setOpen(false);
|
|
1041
|
+
};
|
|
1042
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
1043
|
+
document.addEventListener('keydown', onKey, true);
|
|
1044
|
+
return () => {
|
|
1045
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
1046
|
+
document.removeEventListener('keydown', onKey, true);
|
|
1047
|
+
};
|
|
1048
|
+
}, [open]);
|
|
1049
|
+
const applyInput = (raw: string) => {
|
|
1050
|
+
const n = Math.round(Number(raw));
|
|
1051
|
+
if (Number.isFinite(n) && n >= FONT_SIZE_MIN && n <= FONT_SIZE_MAX) onPick(n);
|
|
1052
|
+
};
|
|
1053
|
+
return (
|
|
1054
|
+
<div ref={ref} className="dc-annot-ctx-dd">
|
|
1055
|
+
<button
|
|
1056
|
+
type="button"
|
|
1057
|
+
className="dc-annot-ctx-ibtn dc-annot-ctx-fs-trigger"
|
|
1058
|
+
aria-haspopup="menu"
|
|
1059
|
+
aria-expanded={open}
|
|
1060
|
+
aria-label={`Font size: ${value != null ? `${value} pixels` : 'mixed'}`}
|
|
1061
|
+
title="Font size"
|
|
1062
|
+
onClick={() => setOpen((o) => !o)}
|
|
1063
|
+
>
|
|
1064
|
+
<span className="dc-annot-ctx-fs-val">{value != null ? value : '—'}</span>
|
|
1065
|
+
<span className="dc-annot-ctx-dd-caret" aria-hidden="true">
|
|
1066
|
+
<IconChevronDown size={8} />
|
|
1067
|
+
</span>
|
|
1068
|
+
</button>
|
|
1069
|
+
{open ? (
|
|
1070
|
+
<div className="dc-annot-ctx-fs-menu" role="menu" aria-label="Font size">
|
|
1071
|
+
{FONT_SIZE_PRESETS.map((p) => (
|
|
1072
|
+
<button
|
|
1073
|
+
key={p.px}
|
|
1074
|
+
type="button"
|
|
1075
|
+
role="menuitemradio"
|
|
1076
|
+
aria-checked={value === p.px}
|
|
1077
|
+
className="dc-annot-ctx-fs-item"
|
|
1078
|
+
onClick={() => {
|
|
1079
|
+
onPick(p.px);
|
|
1080
|
+
setOpen(false);
|
|
1081
|
+
}}
|
|
1082
|
+
>
|
|
1083
|
+
<span>{p.label}</span>
|
|
1084
|
+
<span className="dc-annot-ctx-fs-px">{p.px}</span>
|
|
1085
|
+
</button>
|
|
1086
|
+
))}
|
|
1087
|
+
<input
|
|
1088
|
+
key={value ?? 'mixed'}
|
|
1089
|
+
className="dc-annot-ctx-fs-input"
|
|
1090
|
+
type="number"
|
|
1091
|
+
min={FONT_SIZE_MIN}
|
|
1092
|
+
max={FONT_SIZE_MAX}
|
|
1093
|
+
defaultValue={value ?? ''}
|
|
1094
|
+
aria-label="Custom font size in pixels"
|
|
1095
|
+
onKeyDown={(e) => {
|
|
1096
|
+
if (e.key === 'Enter') {
|
|
1097
|
+
e.preventDefault();
|
|
1098
|
+
applyInput((e.target as HTMLInputElement).value);
|
|
1099
|
+
setOpen(false);
|
|
1100
|
+
}
|
|
1101
|
+
}}
|
|
1102
|
+
onBlur={(e) => applyInput(e.target.value)}
|
|
1103
|
+
/>
|
|
1104
|
+
</div>
|
|
1105
|
+
) : null}
|
|
1106
|
+
</div>
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
707
1110
|
function uniformValue<T>(values: (T | undefined)[]): T | undefined {
|
|
708
1111
|
const filtered = values.filter((v) => v !== undefined) as T[];
|
|
709
1112
|
if (filtered.length === 0) return undefined;
|