@1agh/maude 0.23.0 → 0.25.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 +32 -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 +126 -2
- 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 +74 -10
- 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/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- 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-playwright.mjs +125 -19
- 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 +208 -23
- 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 +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -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 +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +208 -18
- 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/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- 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-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- 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/csp-canvas-shell.test.ts +46 -0
- 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-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -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-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- 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-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- 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 +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -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
|
@@ -20,131 +20,326 @@
|
|
|
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
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
type ArrowHead,
|
|
27
|
+
type ArrowLineType,
|
|
28
|
+
FILL_PALETTE,
|
|
29
|
+
STICKY_PALETTE,
|
|
30
|
+
STROKE_PALETTE,
|
|
31
|
+
type Stroke,
|
|
32
|
+
type TextAlign,
|
|
33
|
+
useStrokesStore,
|
|
34
|
+
} from './annotations-layer.tsx';
|
|
35
|
+
import {
|
|
36
|
+
IconAlignCenter,
|
|
37
|
+
IconAlignLeft,
|
|
38
|
+
IconAlignRight,
|
|
39
|
+
IconArrowNone,
|
|
40
|
+
IconArrowheadCircle,
|
|
41
|
+
IconArrowheadDiamond,
|
|
42
|
+
IconArrowheadLine,
|
|
43
|
+
IconArrowheadTriangle,
|
|
44
|
+
IconArrowheadTriangleOutline,
|
|
45
|
+
IconBold,
|
|
46
|
+
IconChevronDown,
|
|
47
|
+
IconCornerPill,
|
|
48
|
+
IconCornerSoft,
|
|
49
|
+
IconCornerSquare,
|
|
50
|
+
IconDash,
|
|
51
|
+
IconLineCurved,
|
|
52
|
+
IconLineElbow,
|
|
53
|
+
IconLineStraight,
|
|
54
|
+
IconLineThick,
|
|
55
|
+
IconLineThin,
|
|
56
|
+
IconStrike,
|
|
57
|
+
IconTrash,
|
|
58
|
+
} from './canvas-icons.tsx';
|
|
26
59
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
27
60
|
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
'
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'
|
|
44
|
-
|
|
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;
|
|
45
109
|
|
|
46
|
-
|
|
110
|
+
// Phase 21 — the swatch palettes come from annotations-layer so the draw-time
|
|
111
|
+
// chrome and this per-selection toolbar share ONE hue family. STROKE mode shows
|
|
112
|
+
// saturated inks; FILL mode shows the index-paired light tints (FigJam: a
|
|
113
|
+
// saturated outline over a pale wash of the same hue). They're referenced
|
|
114
|
+
// INSIDE the component (render time) — never at module top-level — because
|
|
115
|
+
// annotations-layer ↔ this file form an import cycle and a top-level read would
|
|
116
|
+
// hit the TDZ before STROKE_PALETTE initializes.
|
|
47
117
|
|
|
118
|
+
// Phase 21 — dark "property bar" matching FigJam's selection toolbar. A
|
|
119
|
+
// near-black rounded pill that floats above the selection; swatches + icons sit
|
|
120
|
+
// on dark so colour reads true. Fixed dark values (not canvas tokens) so the
|
|
121
|
+
// bar looks identical on any canvas / DS.
|
|
122
|
+
const CTX_SURFACE = '#26262b';
|
|
48
123
|
const TOOLBAR_CSS = `
|
|
49
124
|
.dc-annot-ctx {
|
|
50
125
|
position: fixed;
|
|
51
126
|
z-index: 7;
|
|
52
127
|
display: flex;
|
|
53
128
|
align-items: center;
|
|
54
|
-
gap:
|
|
55
|
-
background:
|
|
56
|
-
border: 1px solid
|
|
57
|
-
border-radius:
|
|
58
|
-
padding:
|
|
59
|
-
box-shadow: 0
|
|
129
|
+
gap: 2px;
|
|
130
|
+
background: ${CTX_SURFACE};
|
|
131
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
132
|
+
border-radius: 12px;
|
|
133
|
+
padding: 5px 7px;
|
|
134
|
+
box-shadow: 0 8px 28px rgba(0,0,0,0.34), 0 2px 6px rgba(0,0,0,0.22);
|
|
60
135
|
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
61
136
|
font-size: 12px;
|
|
62
|
-
color:
|
|
137
|
+
color: rgba(255,255,255,0.85);
|
|
63
138
|
user-select: none;
|
|
64
139
|
pointer-events: auto;
|
|
65
140
|
}
|
|
141
|
+
/* Swatches sit in a tight touching band so the colour row reads as ONE
|
|
142
|
+
control, not a loose ramp (graphic-critic blocker 1). 22px hit target. */
|
|
143
|
+
.dc-annot-ctx-swrow {
|
|
144
|
+
display: inline-flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 1px;
|
|
147
|
+
}
|
|
66
148
|
.dc-annot-ctx-sw {
|
|
67
|
-
width:
|
|
68
|
-
height:
|
|
69
|
-
border-radius:
|
|
70
|
-
border: 1px solid rgba(
|
|
149
|
+
width: 20px;
|
|
150
|
+
height: 20px;
|
|
151
|
+
border-radius: 50%;
|
|
152
|
+
border: 1px solid rgba(255,255,255,0.16);
|
|
71
153
|
cursor: pointer;
|
|
72
154
|
padding: 0;
|
|
155
|
+
margin: 0;
|
|
73
156
|
appearance: none;
|
|
157
|
+
transition: transform 80ms ease;
|
|
74
158
|
}
|
|
159
|
+
.dc-annot-ctx-sw:hover { transform: scale(1.1); }
|
|
75
160
|
.dc-annot-ctx-sw[aria-pressed="true"] {
|
|
76
|
-
box-shadow: 0 0 0 2px
|
|
161
|
+
box-shadow: 0 0 0 2px ${CTX_SURFACE}, 0 0 0 3px rgba(255,255,255,0.92);
|
|
77
162
|
border-color: transparent;
|
|
78
163
|
}
|
|
164
|
+
.dc-annot-ctx-sw:focus-visible {
|
|
165
|
+
outline: 2px solid #ffffff;
|
|
166
|
+
outline-offset: 1px;
|
|
167
|
+
}
|
|
79
168
|
.dc-annot-ctx-fill--none {
|
|
80
|
-
background: #
|
|
169
|
+
background: #3a3a40;
|
|
81
170
|
position: relative;
|
|
82
171
|
}
|
|
83
172
|
.dc-annot-ctx-fill--none::after {
|
|
84
173
|
content: "";
|
|
85
|
-
position: absolute; inset:
|
|
174
|
+
position: absolute; inset: 4px;
|
|
175
|
+
border-radius: 50%;
|
|
86
176
|
background:
|
|
87
|
-
linear-gradient(135deg, transparent
|
|
177
|
+
linear-gradient(135deg, transparent 44%, rgba(255,255,255,0.55) 44%, rgba(255,255,255,0.55) 56%, transparent 56%);
|
|
88
178
|
}
|
|
89
179
|
.dc-annot-ctx-sep {
|
|
90
180
|
width: 1px;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
.dc-annot-ctx-btn {
|
|
96
|
-
appearance: none;
|
|
97
|
-
background: transparent;
|
|
98
|
-
border: 1px solid var(--u-fg-0, rgba(0,0,0,0.6));
|
|
99
|
-
border-radius: 0;
|
|
100
|
-
padding: 3px 8px;
|
|
101
|
-
font: inherit;
|
|
102
|
-
color: inherit;
|
|
103
|
-
cursor: pointer;
|
|
104
|
-
}
|
|
105
|
-
.dc-annot-ctx-btn[aria-pressed="true"] {
|
|
106
|
-
background: var(--maude-hud-accent, #d63b1f);
|
|
107
|
-
color: var(--maude-hud-accent-fg, #fff);
|
|
108
|
-
border-color: transparent;
|
|
181
|
+
height: 16px;
|
|
182
|
+
align-self: center;
|
|
183
|
+
background: rgba(255,255,255,0.09);
|
|
184
|
+
margin: 0 4px;
|
|
109
185
|
}
|
|
110
|
-
|
|
111
|
-
.dc-annot-ctx-btn--danger { color: #b3271a; }
|
|
112
|
-
.dc-annot-ctx-btn--danger:hover { background: rgba(214,59,31,0.08); }
|
|
113
|
-
|
|
114
|
-
/* T30 / G_S1 — collapsed Stroke|Fill mode toggle. Shown only when the
|
|
115
|
-
selection supports both stroke + fill (rect / ellipse). When the toggle is
|
|
116
|
-
present the swatch row beneath shows ONE palette at a time. */
|
|
186
|
+
/* Collapsed Stroke|Fill mode toggle (rect / ellipse selections). */
|
|
117
187
|
.dc-annot-ctx-mode {
|
|
118
188
|
display: inline-flex;
|
|
119
|
-
border-radius:
|
|
189
|
+
border-radius: 7px;
|
|
120
190
|
overflow: hidden;
|
|
121
|
-
|
|
191
|
+
background: rgba(255,255,255,0.07);
|
|
192
|
+
padding: 2px;
|
|
193
|
+
gap: 2px;
|
|
122
194
|
}
|
|
123
195
|
.dc-annot-ctx-mode-btn {
|
|
124
196
|
appearance: none;
|
|
125
197
|
background: transparent;
|
|
126
198
|
border: 0;
|
|
127
|
-
|
|
199
|
+
border-radius: 5px;
|
|
200
|
+
padding: 3px 9px;
|
|
128
201
|
font: inherit;
|
|
129
|
-
color:
|
|
202
|
+
color: rgba(255,255,255,0.6);
|
|
130
203
|
cursor: pointer;
|
|
131
204
|
font-size: 11px;
|
|
132
205
|
letter-spacing: 0.02em;
|
|
133
|
-
text-transform: uppercase;
|
|
134
206
|
line-height: 1;
|
|
135
207
|
}
|
|
136
208
|
.dc-annot-ctx-mode-btn[aria-pressed="true"] {
|
|
137
|
-
background:
|
|
138
|
-
color:
|
|
209
|
+
background: rgba(255,255,255,0.16);
|
|
210
|
+
color: #ffffff;
|
|
139
211
|
font-weight: 600;
|
|
140
212
|
}
|
|
141
213
|
.dc-annot-ctx-mode-btn:not([aria-pressed="true"]):hover {
|
|
142
|
-
|
|
214
|
+
color: rgba(255,255,255,0.9);
|
|
215
|
+
}
|
|
216
|
+
.dc-annot-ctx-mode-btn:focus-visible {
|
|
217
|
+
outline: 2px solid #ffffff;
|
|
218
|
+
outline-offset: -1px;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* Icon buttons — light glyph on dark, white-tint hover, white-tint active.
|
|
222
|
+
26px to sit closer to the 20px swatch rhythm (graphic-critic blocker 2). */
|
|
223
|
+
.dc-annot-ctx-ibtn {
|
|
224
|
+
appearance: none;
|
|
225
|
+
background: transparent;
|
|
226
|
+
border: 0;
|
|
227
|
+
border-radius: 7px;
|
|
228
|
+
width: 26px;
|
|
229
|
+
height: 26px;
|
|
230
|
+
display: inline-flex;
|
|
231
|
+
align-items: center;
|
|
232
|
+
justify-content: center;
|
|
233
|
+
color: rgba(255,255,255,0.78);
|
|
234
|
+
cursor: pointer;
|
|
235
|
+
padding: 0;
|
|
236
|
+
position: relative;
|
|
237
|
+
transition: background-color 80ms linear, color 80ms linear;
|
|
238
|
+
}
|
|
239
|
+
.dc-annot-ctx-ibtn:hover {
|
|
240
|
+
background: rgba(255,255,255,0.1);
|
|
241
|
+
color: #ffffff;
|
|
143
242
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
243
|
+
.dc-annot-ctx-ibtn[aria-pressed="true"] {
|
|
244
|
+
background: rgba(255,255,255,0.18);
|
|
245
|
+
color: #ffffff;
|
|
246
|
+
}
|
|
247
|
+
.dc-annot-ctx-ibtn:focus-visible {
|
|
248
|
+
outline: 2px solid #ffffff;
|
|
249
|
+
outline-offset: -2px;
|
|
250
|
+
}
|
|
251
|
+
.dc-annot-ctx-ibtn--danger { color: rgba(255,255,255,0.7); }
|
|
252
|
+
.dc-annot-ctx-ibtn--danger:hover {
|
|
253
|
+
background: color-mix(in oklab, #ff5a4d 26%, transparent);
|
|
254
|
+
color: #ffffff;
|
|
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; }
|
|
341
|
+
@media (prefers-reduced-motion: reduce) {
|
|
342
|
+
.dc-annot-ctx-ibtn, .dc-annot-ctx-sw { transition: none; }
|
|
148
343
|
}
|
|
149
344
|
`.trim();
|
|
150
345
|
|
|
@@ -202,19 +397,39 @@ export function AnnotationContextToolbar() {
|
|
|
202
397
|
// Capabilities — intersection across selected types.
|
|
203
398
|
const caps = useMemo(() => {
|
|
204
399
|
if (selectedStrokes.length === 0) {
|
|
205
|
-
return {
|
|
400
|
+
return {
|
|
401
|
+
color: false,
|
|
402
|
+
fill: false,
|
|
403
|
+
thickness: false,
|
|
404
|
+
fontSize: false,
|
|
405
|
+
cornerRadius: false,
|
|
406
|
+
arrowDir: false,
|
|
407
|
+
dash: false,
|
|
408
|
+
};
|
|
206
409
|
}
|
|
207
410
|
const allFillable = selectedStrokes.every((s) => s.tool === 'rect' || s.tool === 'ellipse');
|
|
208
411
|
// T20 — rect + ellipse now carry stroke weight too.
|
|
209
412
|
const allThickness = selectedStrokes.every(
|
|
210
413
|
(s) => s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
|
|
211
414
|
);
|
|
212
|
-
|
|
415
|
+
// Phase 21 — fontSize applies to text + sticky; cornerRadius to rect +
|
|
416
|
+
// sticky; arrow direction + dash to arrows.
|
|
417
|
+
const fontSizeApplicable = selectedStrokes.some(
|
|
418
|
+
(s) => s.tool === 'text' || s.tool === 'sticky'
|
|
419
|
+
);
|
|
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');
|
|
424
|
+
const allArrow = selectedStrokes.every((s) => s.tool === 'arrow');
|
|
213
425
|
return {
|
|
214
426
|
color: true,
|
|
215
427
|
fill: allFillable,
|
|
216
428
|
thickness: allThickness,
|
|
217
|
-
fontSize:
|
|
429
|
+
fontSize: fontSizeApplicable,
|
|
430
|
+
cornerRadius: allRect,
|
|
431
|
+
arrowDir: allArrow,
|
|
432
|
+
dash: allArrow,
|
|
218
433
|
};
|
|
219
434
|
}, [selectedStrokes]);
|
|
220
435
|
|
|
@@ -299,13 +514,101 @@ export function AnnotationContextToolbar() {
|
|
|
299
514
|
(sz: number) => {
|
|
300
515
|
if (!store) return;
|
|
301
516
|
for (const s of selectedStrokes) {
|
|
302
|
-
|
|
517
|
+
// Phase 21 — sticky carries fontSize too.
|
|
518
|
+
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
303
519
|
store.updateStroke(s.id, { fontSize: sz } as Partial<Stroke>);
|
|
304
520
|
}
|
|
305
521
|
}
|
|
306
522
|
},
|
|
307
523
|
[store, selectedStrokes]
|
|
308
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
|
+
);
|
|
559
|
+
// Phase 21 — corner radius (rect + sticky).
|
|
560
|
+
const setCornerRadius = useCallback(
|
|
561
|
+
(r: number) => {
|
|
562
|
+
if (!store) return;
|
|
563
|
+
for (const s of selectedStrokes) {
|
|
564
|
+
if (s.tool === 'rect' || s.tool === 'sticky') {
|
|
565
|
+
store.updateStroke(s.id, { cornerRadius: r } as Partial<Stroke>);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
[store, selectedStrokes]
|
|
570
|
+
);
|
|
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) => {
|
|
575
|
+
if (!store) return;
|
|
576
|
+
for (const s of selectedStrokes) {
|
|
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>);
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
[store, selectedStrokes]
|
|
599
|
+
);
|
|
600
|
+
// Phase 21 — arrow dash toggle.
|
|
601
|
+
const setDashed = useCallback(
|
|
602
|
+
(dashed: boolean) => {
|
|
603
|
+
if (!store) return;
|
|
604
|
+
for (const s of selectedStrokes) {
|
|
605
|
+
if (s.tool === 'arrow') {
|
|
606
|
+
store.updateStroke(s.id, { dashed } as Partial<Stroke>);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
[store, selectedStrokes]
|
|
611
|
+
);
|
|
309
612
|
const remove = useCallback(() => {
|
|
310
613
|
if (!annotSel || !store) return;
|
|
311
614
|
store.deleteStrokes(annotSel.selectedIds);
|
|
@@ -333,17 +636,92 @@ export function AnnotationContextToolbar() {
|
|
|
333
636
|
)
|
|
334
637
|
: undefined;
|
|
335
638
|
const uniqFontSize = caps.fontSize
|
|
336
|
-
? uniformValue(
|
|
639
|
+
? uniformValue(
|
|
640
|
+
selectedStrokes.map((s) =>
|
|
641
|
+
s.tool === 'text' || s.tool === 'sticky' ? s.fontSize : undefined
|
|
642
|
+
)
|
|
643
|
+
)
|
|
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;
|
|
671
|
+
// Phase 21 — uniform corner radius across rect/sticky (default per type: rect
|
|
672
|
+
// sharp = 0, sticky soft = 8).
|
|
673
|
+
const uniqRadius = caps.cornerRadius
|
|
674
|
+
? uniformValue(
|
|
675
|
+
selectedStrokes.map((s) =>
|
|
676
|
+
s.tool === 'rect'
|
|
677
|
+
? (s.cornerRadius ?? 0)
|
|
678
|
+
: s.tool === 'sticky'
|
|
679
|
+
? (s.cornerRadius ?? 8)
|
|
680
|
+
: undefined
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
: undefined;
|
|
684
|
+
// Phase 21 — uniform arrow head pair (default start none / end triangle).
|
|
685
|
+
const uniqStartHead = caps.arrowDir
|
|
686
|
+
? uniformValue(
|
|
687
|
+
selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.startHead ?? 'none') : undefined))
|
|
688
|
+
)
|
|
689
|
+
: undefined;
|
|
690
|
+
const uniqEndHead = caps.arrowDir
|
|
691
|
+
? uniformValue(
|
|
692
|
+
selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.endHead ?? 'triangle') : undefined))
|
|
693
|
+
)
|
|
694
|
+
: undefined;
|
|
695
|
+
const uniqLineType = caps.arrowDir
|
|
696
|
+
? uniformValue(
|
|
697
|
+
selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.lineType ?? 'straight') : undefined))
|
|
698
|
+
)
|
|
699
|
+
: undefined;
|
|
700
|
+
const uniqDashed = caps.dash
|
|
701
|
+
? uniformValue(
|
|
702
|
+
selectedStrokes.map((s) => (s.tool === 'arrow' ? (s.dashed ?? false) : undefined))
|
|
703
|
+
)
|
|
337
704
|
: undefined;
|
|
338
705
|
|
|
339
706
|
// T30 / G_S1 — when caps.fill is false we never enter fill mode. The
|
|
340
707
|
// useEffect below could call setSwatchMode('stroke') but reading the
|
|
341
708
|
// effective mode inline avoids an extra render cycle.
|
|
342
709
|
const effectiveMode: SwatchMode = caps.fill ? swatchMode : 'stroke';
|
|
343
|
-
|
|
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;
|
|
344
721
|
const onSwatchClick =
|
|
345
|
-
effectiveMode === '
|
|
346
|
-
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';
|
|
347
725
|
|
|
348
726
|
return (
|
|
349
727
|
<div
|
|
@@ -376,97 +754,359 @@ export function AnnotationContextToolbar() {
|
|
|
376
754
|
<div className="dc-annot-ctx-sep" />
|
|
377
755
|
</>
|
|
378
756
|
) : null}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
757
|
+
<div className="dc-annot-ctx-swrow" role="radiogroup" aria-label="Color">
|
|
758
|
+
{effectiveMode === 'fill' ? (
|
|
759
|
+
<button
|
|
760
|
+
type="button"
|
|
761
|
+
className="dc-annot-ctx-sw dc-annot-ctx-fill--none"
|
|
762
|
+
aria-label="No fill"
|
|
763
|
+
aria-pressed={uniqFill == null}
|
|
764
|
+
title="No fill"
|
|
765
|
+
onClick={() => setFill(null)}
|
|
766
|
+
/>
|
|
767
|
+
) : null}
|
|
768
|
+
{showPalette.map((c) => (
|
|
769
|
+
<button
|
|
770
|
+
key={c}
|
|
771
|
+
type="button"
|
|
772
|
+
className="dc-annot-ctx-sw"
|
|
773
|
+
aria-label={`${swatchKind} ${c}`}
|
|
774
|
+
aria-pressed={activeValue === c}
|
|
775
|
+
title={`${swatchKind} ${c}`}
|
|
776
|
+
style={{ background: c }}
|
|
777
|
+
onClick={() => onSwatchClick(c)}
|
|
778
|
+
/>
|
|
779
|
+
))}
|
|
780
|
+
</div>
|
|
401
781
|
{caps.thickness && effectiveMode === 'stroke' ? (
|
|
402
782
|
<>
|
|
403
783
|
<div className="dc-annot-ctx-sep" />
|
|
404
784
|
<button
|
|
405
785
|
type="button"
|
|
406
|
-
className="dc-annot-ctx-
|
|
786
|
+
className="dc-annot-ctx-ibtn"
|
|
787
|
+
aria-label="Thin stroke"
|
|
407
788
|
aria-pressed={uniqThickness === 3}
|
|
408
789
|
title="Thin (3px)"
|
|
409
790
|
onClick={() => setThickness(3)}
|
|
410
791
|
>
|
|
411
|
-
|
|
792
|
+
<IconLineThin />
|
|
412
793
|
</button>
|
|
413
794
|
<button
|
|
414
795
|
type="button"
|
|
415
|
-
className="dc-annot-ctx-
|
|
796
|
+
className="dc-annot-ctx-ibtn"
|
|
797
|
+
aria-label="Thick stroke"
|
|
416
798
|
aria-pressed={uniqThickness === 6}
|
|
417
799
|
title="Thick (6px)"
|
|
418
800
|
onClick={() => setThickness(6)}
|
|
419
801
|
>
|
|
420
|
-
|
|
802
|
+
<IconLineThick />
|
|
421
803
|
</button>
|
|
422
804
|
</>
|
|
423
805
|
) : null}
|
|
424
806
|
{caps.fontSize ? (
|
|
425
807
|
<>
|
|
426
808
|
<div className="dc-annot-ctx-sep" />
|
|
809
|
+
<FontSizeDropdown value={uniqFontSize} onPick={setFontSize} />
|
|
810
|
+
<button
|
|
811
|
+
type="button"
|
|
812
|
+
className="dc-annot-ctx-ibtn"
|
|
813
|
+
aria-label="Bold"
|
|
814
|
+
aria-pressed={uniqBold === true}
|
|
815
|
+
title="Bold"
|
|
816
|
+
onClick={() => setBold(!(uniqBold === true))}
|
|
817
|
+
>
|
|
818
|
+
<IconBold />
|
|
819
|
+
</button>
|
|
820
|
+
<button
|
|
821
|
+
type="button"
|
|
822
|
+
className="dc-annot-ctx-ibtn"
|
|
823
|
+
aria-label="Strikethrough"
|
|
824
|
+
aria-pressed={uniqStrike === true}
|
|
825
|
+
title="Strikethrough"
|
|
826
|
+
onClick={() => setStrike(!(uniqStrike === true))}
|
|
827
|
+
>
|
|
828
|
+
<IconStrike />
|
|
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
|
+
/>
|
|
840
|
+
</>
|
|
841
|
+
) : null}
|
|
842
|
+
{caps.cornerRadius ? (
|
|
843
|
+
<>
|
|
844
|
+
<div className="dc-annot-ctx-sep" />
|
|
845
|
+
<button
|
|
846
|
+
type="button"
|
|
847
|
+
className="dc-annot-ctx-ibtn"
|
|
848
|
+
aria-label="Square corners"
|
|
849
|
+
aria-pressed={uniqRadius === 0}
|
|
850
|
+
title="Square corners"
|
|
851
|
+
onClick={() => setCornerRadius(0)}
|
|
852
|
+
>
|
|
853
|
+
<IconCornerSquare />
|
|
854
|
+
</button>
|
|
427
855
|
<button
|
|
428
856
|
type="button"
|
|
429
|
-
className="dc-annot-ctx-
|
|
430
|
-
aria-
|
|
431
|
-
|
|
432
|
-
|
|
857
|
+
className="dc-annot-ctx-ibtn"
|
|
858
|
+
aria-label="Soft corners"
|
|
859
|
+
aria-pressed={uniqRadius === 8}
|
|
860
|
+
title="Soft corners"
|
|
861
|
+
onClick={() => setCornerRadius(8)}
|
|
433
862
|
>
|
|
434
|
-
|
|
863
|
+
<IconCornerSoft />
|
|
435
864
|
</button>
|
|
436
865
|
<button
|
|
437
866
|
type="button"
|
|
438
|
-
className="dc-annot-ctx-
|
|
439
|
-
aria-
|
|
440
|
-
|
|
441
|
-
|
|
867
|
+
className="dc-annot-ctx-ibtn"
|
|
868
|
+
aria-label="Pill corners"
|
|
869
|
+
aria-pressed={uniqRadius === 999}
|
|
870
|
+
title="Pill corners"
|
|
871
|
+
onClick={() => setCornerRadius(999)}
|
|
442
872
|
>
|
|
443
|
-
|
|
873
|
+
<IconCornerPill />
|
|
444
874
|
</button>
|
|
875
|
+
</>
|
|
876
|
+
) : null}
|
|
877
|
+
{caps.arrowDir ? (
|
|
878
|
+
<>
|
|
879
|
+
<div className="dc-annot-ctx-sep" />
|
|
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
|
+
/>
|
|
910
|
+
</>
|
|
911
|
+
) : null}
|
|
912
|
+
{caps.dash ? (
|
|
913
|
+
<>
|
|
914
|
+
<div className="dc-annot-ctx-sep" />
|
|
445
915
|
<button
|
|
446
916
|
type="button"
|
|
447
|
-
className="dc-annot-ctx-
|
|
448
|
-
aria-
|
|
449
|
-
|
|
450
|
-
|
|
917
|
+
className="dc-annot-ctx-ibtn"
|
|
918
|
+
aria-label="Dashed line"
|
|
919
|
+
aria-pressed={uniqDashed === true}
|
|
920
|
+
title="Dashed line"
|
|
921
|
+
onClick={() => setDashed(!(uniqDashed === true))}
|
|
451
922
|
>
|
|
452
|
-
|
|
923
|
+
<IconDash />
|
|
453
924
|
</button>
|
|
454
925
|
</>
|
|
455
926
|
) : null}
|
|
927
|
+
<div className="dc-annot-ctx-sep" />
|
|
456
928
|
<button
|
|
457
929
|
type="button"
|
|
458
|
-
className="dc-annot-ctx-
|
|
930
|
+
className="dc-annot-ctx-ibtn dc-annot-ctx-ibtn--danger"
|
|
459
931
|
title="Delete (Backspace)"
|
|
460
932
|
aria-label="Delete selected annotations"
|
|
461
933
|
onClick={remove}
|
|
462
934
|
>
|
|
463
|
-
|
|
935
|
+
<IconTrash />
|
|
464
936
|
</button>
|
|
465
937
|
</div>
|
|
466
938
|
);
|
|
467
939
|
}
|
|
468
940
|
AnnotationContextToolbar.displayName = 'AnnotationContextToolbar';
|
|
469
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
|
+
|
|
470
1110
|
function uniformValue<T>(values: (T | undefined)[]): T | undefined {
|
|
471
1111
|
const filtered = values.filter((v) => v !== undefined) as T[];
|
|
472
1112
|
if (filtered.length === 0) return undefined;
|