@1agh/maude 0.24.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/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-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/smoke.sh +114 -12
- 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-shell.tsx +113 -89
- package/plugins/design/dev-server/client/app.jsx +1084 -417
- 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/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/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/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/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
|
@@ -97,4 +97,36 @@ describe('sanitizeAnnotationSvg — A3', () => {
|
|
|
97
97
|
expect(out.toLowerCase()).not.toContain('<set');
|
|
98
98
|
expect(out.toLowerCase()).not.toContain('<animate');
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
// Phase 24 (DDR-067 security review) — a handler glued onto the previous
|
|
102
|
+
// attribute's closing quote with NO separating whitespace is a distinct
|
|
103
|
+
// attribute to HTML/SVG parsers; the old `\s`-anchored denylist missed it.
|
|
104
|
+
test('strips a quote-glued on*= handler (no separating whitespace)', () => {
|
|
105
|
+
const out = sanitizeAnnotationSvg(
|
|
106
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
107
|
+
'<circle cx="1" cy="1" r="2"onload="alert(document.cookie)"/>' +
|
|
108
|
+
'<rect x="0" y="0" width="9" height="9"onclick=\'evil()\'/></svg>'
|
|
109
|
+
);
|
|
110
|
+
// (a bare /on\w+=/ would false-match `data-mdcc-annotations="1"` — target
|
|
111
|
+
// the actual handler names instead)
|
|
112
|
+
expect(out).not.toMatch(/\bon(?:load|click|error|mouse\w+|focus)\s*=/i);
|
|
113
|
+
expect(out).not.toContain('alert(document.cookie)');
|
|
114
|
+
expect(out).not.toContain('evil()');
|
|
115
|
+
// The legit element + its real attributes survive intact.
|
|
116
|
+
expect(out).toContain('<circle');
|
|
117
|
+
expect(out).toContain('r="2"');
|
|
118
|
+
expect(out).toContain('<rect');
|
|
119
|
+
expect(out).toContain('width="9"');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Phase 24 — the new shape/arrowhead vocabulary survives byte-intact.
|
|
123
|
+
test('keeps Phase 24 <polygon>/<circle> + data-* attrs byte-intact', () => {
|
|
124
|
+
const phase24 =
|
|
125
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
126
|
+
'<polygon data-id="pg" data-tool="polygon" stroke="#222" stroke-width="2" fill="none" data-shape="diamond" points="20,0 40,20 20,40 0,20"/>' +
|
|
127
|
+
'<g data-id="a" data-tool="arrow" stroke="#111" stroke-width="2" fill="none" data-start-head="circle" data-line-type="curved"><path d="M0 0 Q10 10 20 0"/><circle cx="20" cy="0" r="6" fill="#111"/></g>' +
|
|
128
|
+
'<g data-id="st" data-tool="sticky" data-r="8" data-fs="16" fill="#fce8a6" data-bold="1" data-align="center"><rect x="0" y="0" width="200" height="200" rx="8" ry="8"/><text data-sticky-body="1" x="12" y="12" font-size="16" fill="#1a1a1a" dominant-baseline="hanging">x</text></g>' +
|
|
129
|
+
'</svg>';
|
|
130
|
+
expect(sanitizeAnnotationSvg(phase24)).toBe(phase24);
|
|
131
|
+
});
|
|
100
132
|
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// use-annotation-resize — FigJam resize-modifier math (pure `resizeStroke`).
|
|
2
|
+
//
|
|
3
|
+
// Covers the no-modifier baseline (must stay algebraically identical to the
|
|
4
|
+
// pre-modifier corner math) plus Shift (aspect-lock / 45° snap), Alt
|
|
5
|
+
// (scale-from-center / mirror-from-midpoint), and Shift+Alt combined, across
|
|
6
|
+
// every resizable stroke kind. Pure function — no DOM render needed.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ArrowStroke,
|
|
12
|
+
EllipseStroke,
|
|
13
|
+
PenStroke,
|
|
14
|
+
PolygonStroke,
|
|
15
|
+
RectStroke,
|
|
16
|
+
StickyStroke,
|
|
17
|
+
} from '../annotations-layer.tsx';
|
|
18
|
+
import { resizeStroke } from '../use-annotation-resize.tsx';
|
|
19
|
+
|
|
20
|
+
const rect: RectStroke = {
|
|
21
|
+
id: 'r1',
|
|
22
|
+
tool: 'rect',
|
|
23
|
+
color: '#000',
|
|
24
|
+
width: 2,
|
|
25
|
+
x: 0,
|
|
26
|
+
y: 0,
|
|
27
|
+
w: 100,
|
|
28
|
+
h: 50,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe('resizeStroke / rect — no modifiers (baseline)', () => {
|
|
32
|
+
test('se corner anchors the nw corner (back-compat)', () => {
|
|
33
|
+
expect(resizeStroke(rect, 'se', 120, 80)).toEqual({ x: 0, y: 0, w: 120, h: 80 });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('nw corner anchors the se corner', () => {
|
|
37
|
+
expect(resizeStroke(rect, 'nw', -20, -10)).toEqual({ x: -20, y: -10, w: 120, h: 60 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('dragging past the anchor flips the box (min/max parity)', () => {
|
|
41
|
+
// se dragged to the left of x=0 → box lives to the left of the anchor.
|
|
42
|
+
expect(resizeStroke(rect, 'se', -40, 80)).toEqual({ x: -40, y: 0, w: 40, h: 80 });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('resizeStroke / rect — Shift (aspect lock)', () => {
|
|
47
|
+
test('keeps the 2:1 start ratio, dominant axis drives scale', () => {
|
|
48
|
+
// raw 120×200 → scale = max(1.2, 4) = 4 → 400×200 (ratio preserved).
|
|
49
|
+
expect(resizeStroke(rect, 'se', 120, 200, { shift: true, alt: false })).toEqual({
|
|
50
|
+
x: 0,
|
|
51
|
+
y: 0,
|
|
52
|
+
w: 400,
|
|
53
|
+
h: 200,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('resizeStroke / rect — Alt (scale from center)', () => {
|
|
59
|
+
test('center stays fixed; both sides grow symmetrically', () => {
|
|
60
|
+
const out = resizeStroke(rect, 'se', 120, 80, { shift: false, alt: true });
|
|
61
|
+
expect(out).toEqual({ x: -20, y: -30, w: 140, h: 110 });
|
|
62
|
+
// center invariant: (-20 + 140/2, -30 + 110/2) === (50, 25) === start center.
|
|
63
|
+
expect((out as RectStroke).x + (out as RectStroke).w / 2).toBe(50);
|
|
64
|
+
expect((out as RectStroke).y + (out as RectStroke).h / 2).toBe(25);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('resizeStroke / rect — Shift+Alt (ratio + center)', () => {
|
|
69
|
+
test('center-anchored and ratio-locked at once', () => {
|
|
70
|
+
expect(resizeStroke(rect, 'se', 120, 200, { shift: true, alt: true })).toEqual({
|
|
71
|
+
x: -300,
|
|
72
|
+
y: -150,
|
|
73
|
+
w: 700,
|
|
74
|
+
h: 350,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('resizeStroke / polygon — routes through the shared bbox math', () => {
|
|
80
|
+
const poly: PolygonStroke = {
|
|
81
|
+
id: 'p1',
|
|
82
|
+
tool: 'polygon',
|
|
83
|
+
shape: 'diamond',
|
|
84
|
+
color: '#000',
|
|
85
|
+
width: 2,
|
|
86
|
+
x: 0,
|
|
87
|
+
y: 0,
|
|
88
|
+
w: 100,
|
|
89
|
+
h: 50,
|
|
90
|
+
};
|
|
91
|
+
test('Shift aspect-locks the polygon like a rect', () => {
|
|
92
|
+
expect(resizeStroke(poly, 'se', 120, 200, { shift: true, alt: false })).toEqual({
|
|
93
|
+
x: 0,
|
|
94
|
+
y: 0,
|
|
95
|
+
w: 400,
|
|
96
|
+
h: 200,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('resizeStroke / sticky — always 1:1 square', () => {
|
|
102
|
+
const sticky: StickyStroke = {
|
|
103
|
+
id: 's1',
|
|
104
|
+
tool: 'sticky',
|
|
105
|
+
color: '#fce8a6',
|
|
106
|
+
x: 0,
|
|
107
|
+
y: 0,
|
|
108
|
+
w: 100,
|
|
109
|
+
h: 100,
|
|
110
|
+
text: '',
|
|
111
|
+
fontSize: 16,
|
|
112
|
+
};
|
|
113
|
+
test('no modifiers — larger axis becomes the side', () => {
|
|
114
|
+
expect(resizeStroke(sticky, 'se', 150, 120)).toEqual({ x: 0, y: 0, w: 150, h: 150 });
|
|
115
|
+
});
|
|
116
|
+
test('Alt — square grows from the center', () => {
|
|
117
|
+
expect(resizeStroke(sticky, 'se', 150, 120, { shift: false, alt: true })).toEqual({
|
|
118
|
+
x: -50,
|
|
119
|
+
y: -50,
|
|
120
|
+
w: 200,
|
|
121
|
+
h: 200,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('resizeStroke / ellipse — Alt keeps the center fixed', () => {
|
|
127
|
+
const ell: EllipseStroke = {
|
|
128
|
+
id: 'e1',
|
|
129
|
+
tool: 'ellipse',
|
|
130
|
+
color: '#000',
|
|
131
|
+
width: 2,
|
|
132
|
+
cx: 50,
|
|
133
|
+
cy: 50,
|
|
134
|
+
rx: 40,
|
|
135
|
+
ry: 20,
|
|
136
|
+
};
|
|
137
|
+
test('center invariant under symmetric scale', () => {
|
|
138
|
+
expect(resizeStroke(ell, 'se', 120, 90, { shift: false, alt: true })).toEqual({
|
|
139
|
+
cx: 50,
|
|
140
|
+
cy: 50,
|
|
141
|
+
rx: 70,
|
|
142
|
+
ry: 40,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('resizeStroke / arrow', () => {
|
|
148
|
+
const arrow: ArrowStroke = {
|
|
149
|
+
id: 'a1',
|
|
150
|
+
tool: 'arrow',
|
|
151
|
+
color: '#000',
|
|
152
|
+
width: 2,
|
|
153
|
+
x1: 0,
|
|
154
|
+
y1: 0,
|
|
155
|
+
x2: 100,
|
|
156
|
+
y2: 0,
|
|
157
|
+
};
|
|
158
|
+
test('no modifiers — only the dragged endpoint moves', () => {
|
|
159
|
+
expect(resizeStroke(arrow, 'ep2', 100, 40)).toEqual({ x2: 100, y2: 40 });
|
|
160
|
+
});
|
|
161
|
+
test('Alt — the midpoint is pinned; the far end mirrors', () => {
|
|
162
|
+
expect(resizeStroke(arrow, 'ep2', 100, 40, { shift: false, alt: true })).toEqual({
|
|
163
|
+
x2: 100,
|
|
164
|
+
y2: 40,
|
|
165
|
+
x1: 0,
|
|
166
|
+
y1: -40,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
test('Shift — shaft angle snaps to the nearest 45°', () => {
|
|
170
|
+
const out = resizeStroke(arrow, 'ep2', 50, 60, { shift: true, alt: false }) as ArrowStroke;
|
|
171
|
+
// Pointer at atan2(60,50)≈49° snaps to 45° → dx === dy along the shaft.
|
|
172
|
+
expect(out.x2).toBeCloseTo(out.y2 ?? Number.NaN, 6);
|
|
173
|
+
expect(out.x2).toBeGreaterThan(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('resizeStroke / pen — point scaling', () => {
|
|
178
|
+
const pen: PenStroke = {
|
|
179
|
+
id: 'pen1',
|
|
180
|
+
tool: 'pen',
|
|
181
|
+
color: '#000',
|
|
182
|
+
width: 2,
|
|
183
|
+
points: [
|
|
184
|
+
[0, 0],
|
|
185
|
+
[100, 0],
|
|
186
|
+
[100, 100],
|
|
187
|
+
[0, 100],
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
test('no modifiers — scales around the opposite corner', () => {
|
|
191
|
+
const out = resizeStroke(pen, 'se', 200, 200) as PenStroke;
|
|
192
|
+
expect(out.points[0]).toEqual([0, 0]);
|
|
193
|
+
expect(out.points[2]).toEqual([200, 200]);
|
|
194
|
+
});
|
|
195
|
+
test('Alt — scales around the bbox center', () => {
|
|
196
|
+
const out = resizeStroke(pen, 'se', 150, 150, { shift: false, alt: true }) as PenStroke;
|
|
197
|
+
expect(out.points[0]).toEqual([-50, -50]);
|
|
198
|
+
expect(out.points[2]).toEqual([150, 150]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -12,14 +12,13 @@ import {
|
|
|
12
12
|
} from '../use-tool-mode.tsx';
|
|
13
13
|
|
|
14
14
|
describe('use-tool-mode / static', () => {
|
|
15
|
-
test('DEFAULT_TOOLS exposes V/H/C + draw set B/R/
|
|
15
|
+
test('DEFAULT_TOOLS exposes V/H/C + draw set B/R/N/A/T/E (Phase 24 single Shape tool)', () => {
|
|
16
16
|
expect(DEFAULT_TOOLS.map((t) => t.id)).toEqual([
|
|
17
17
|
'move',
|
|
18
18
|
'hand',
|
|
19
19
|
'comment',
|
|
20
20
|
'pen',
|
|
21
|
-
'
|
|
22
|
-
'ellipse',
|
|
21
|
+
'shape',
|
|
23
22
|
'sticky',
|
|
24
23
|
'arrow',
|
|
25
24
|
'text',
|
|
@@ -31,7 +30,6 @@ describe('use-tool-mode / static', () => {
|
|
|
31
30
|
'C',
|
|
32
31
|
'B',
|
|
33
32
|
'R',
|
|
34
|
-
'O',
|
|
35
33
|
'N',
|
|
36
34
|
'A',
|
|
37
35
|
'T',
|
|
@@ -43,17 +41,16 @@ describe('use-tool-mode / static', () => {
|
|
|
43
41
|
expect(Object.isFrozen(DEFAULT_TOOLS)).toBe(true);
|
|
44
42
|
});
|
|
45
43
|
|
|
46
|
-
test('
|
|
44
|
+
test('cursors per tool (Phase 24 — ONE Kenney library for every tool + native fallback)', () => {
|
|
47
45
|
const byId = Object.fromEntries(DEFAULT_TOOLS.map((t) => [t.id, t.cursor]));
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
expect(byId.move).toBe('default');
|
|
46
|
+
// Phase 24 — EVERY tool (incl. move) ships a Kenney data-URI SVG cursor that
|
|
47
|
+
// falls back to the right native cursor if the image is rejected.
|
|
51
48
|
for (const id of [
|
|
49
|
+
'move',
|
|
52
50
|
'hand',
|
|
53
51
|
'comment',
|
|
54
52
|
'pen',
|
|
55
|
-
'
|
|
56
|
-
'ellipse',
|
|
53
|
+
'shape',
|
|
57
54
|
'sticky',
|
|
58
55
|
'arrow',
|
|
59
56
|
'text',
|
|
@@ -63,11 +60,12 @@ describe('use-tool-mode / static', () => {
|
|
|
63
60
|
expect(byId[id]).toContain('32'); // 32×32 cursor
|
|
64
61
|
}
|
|
65
62
|
// Native fallbacks are preserved after the custom URL.
|
|
63
|
+
expect(byId.move).toMatch(/, default$/);
|
|
66
64
|
expect(byId.hand).toMatch(/, grab$/);
|
|
67
65
|
expect(byId.text).toMatch(/, text$/);
|
|
68
66
|
expect(byId.eraser).toMatch(/, cell$/);
|
|
69
67
|
expect(byId.pen).toMatch(/, crosshair$/);
|
|
70
|
-
expect(byId.
|
|
68
|
+
expect(byId.shape).toMatch(/, crosshair$/);
|
|
71
69
|
});
|
|
72
70
|
});
|
|
73
71
|
|
|
@@ -14,10 +14,15 @@
|
|
|
14
14
|
|
|
15
15
|
import { useEffect, useRef, useState } from 'react';
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
IconChevronDown,
|
|
19
|
+
IconPresentation,
|
|
20
|
+
SHAPE_KIND_ICONS,
|
|
21
|
+
TOOL_ICONS,
|
|
22
|
+
} from './canvas-icons.tsx';
|
|
18
23
|
import { useViewportControllerContext } from './canvas-lib.tsx';
|
|
19
24
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
20
|
-
import { useToolMode } from './use-tool-mode.tsx';
|
|
25
|
+
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
21
26
|
|
|
22
27
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
23
28
|
// Styles — pulled from menubar tokens (`.mb` family). Centered bottom toolbar,
|
|
@@ -177,6 +182,39 @@ const PALETTE_CSS = `
|
|
|
177
182
|
font-size: 11px;
|
|
178
183
|
opacity: 0.55;
|
|
179
184
|
}
|
|
185
|
+
/* Phase 24 — Shape tool button + its kind popover. The wrapper is the popover's
|
|
186
|
+
positioning context so it anchors directly above the button. */
|
|
187
|
+
.dc-tool-palette .dc-tp-shape {
|
|
188
|
+
position: relative;
|
|
189
|
+
display: inline-flex;
|
|
190
|
+
}
|
|
191
|
+
.dc-tool-palette .dc-tp-shape > button { position: relative; }
|
|
192
|
+
.dc-tool-palette .dc-tp-shape-caret {
|
|
193
|
+
position: absolute;
|
|
194
|
+
right: 2px;
|
|
195
|
+
bottom: 1px;
|
|
196
|
+
display: inline-flex;
|
|
197
|
+
opacity: 0.5;
|
|
198
|
+
pointer-events: none;
|
|
199
|
+
}
|
|
200
|
+
.dc-tp-shape-popover {
|
|
201
|
+
position: absolute;
|
|
202
|
+
bottom: calc(100% + 8px);
|
|
203
|
+
left: 50%;
|
|
204
|
+
transform: translateX(-50%);
|
|
205
|
+
display: flex;
|
|
206
|
+
gap: 2px;
|
|
207
|
+
padding: 4px;
|
|
208
|
+
background: var(--maude-chrome-bg-0, #ffffff);
|
|
209
|
+
border: 1px solid var(--maude-chrome-fg-0, #1c1917);
|
|
210
|
+
border-radius: 8px;
|
|
211
|
+
box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
|
|
212
|
+
z-index: 8;
|
|
213
|
+
}
|
|
214
|
+
.dc-tp-shape-popover button[aria-checked="true"] {
|
|
215
|
+
background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
|
|
216
|
+
color: var(--maude-hud-accent, #d63b1f);
|
|
217
|
+
}
|
|
180
218
|
`.trim();
|
|
181
219
|
|
|
182
220
|
function ensurePaletteStyles(): void {
|
|
@@ -189,31 +227,59 @@ function ensurePaletteStyles(): void {
|
|
|
189
227
|
}
|
|
190
228
|
|
|
191
229
|
const NAV_TOOLS = ['move', 'hand', 'comment'] as const;
|
|
192
|
-
// Phase
|
|
193
|
-
//
|
|
194
|
-
const DRAW_TOOLS = ['pen', '
|
|
230
|
+
// Phase 24 — the two rect/ellipse buttons collapse into one Shape tool (with a
|
|
231
|
+
// kind popover); sticky/arrow/text/eraser keep their order.
|
|
232
|
+
const DRAW_TOOLS = ['pen', 'shape', 'sticky', 'arrow', 'text', 'eraser'] as const;
|
|
233
|
+
|
|
234
|
+
// Phase 24 — the Shape tool's primitive picker (popover order matches FigJam).
|
|
235
|
+
const SHAPE_KINDS: ReadonlyArray<{ kind: ShapeKind; label: string }> = [
|
|
236
|
+
{ kind: 'square', label: 'Square' },
|
|
237
|
+
{ kind: 'rounded', label: 'Rounded square' },
|
|
238
|
+
{ kind: 'circle', label: 'Circle' },
|
|
239
|
+
{ kind: 'diamond', label: 'Diamond' },
|
|
240
|
+
{ kind: 'triangle', label: 'Triangle' },
|
|
241
|
+
{ kind: 'triangle-down', label: 'Triangle down' },
|
|
242
|
+
];
|
|
195
243
|
|
|
196
244
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
197
245
|
// Component
|
|
198
246
|
|
|
199
247
|
export function ToolPalette() {
|
|
200
248
|
ensurePaletteStyles();
|
|
201
|
-
const { tool, setTool, tools, sticky, toggleSticky } = useToolMode();
|
|
249
|
+
const { tool, setTool, tools, sticky, toggleSticky, shapeKind, setShapeKind } = useToolMode();
|
|
202
250
|
const controller = useViewportControllerContext();
|
|
203
251
|
const visibilityCtx = useAnnotationsVisibility();
|
|
204
252
|
const [mounted, setMounted] = useState(false);
|
|
205
253
|
const [zoomOpen, setZoomOpen] = useState(false);
|
|
254
|
+
const [shapeOpen, setShapeOpen] = useState(false);
|
|
206
255
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
207
256
|
|
|
208
257
|
useEffect(() => setMounted(true), []);
|
|
209
258
|
useEffect(() => {
|
|
210
|
-
if (!zoomOpen) return;
|
|
259
|
+
if (!zoomOpen && !shapeOpen) return;
|
|
211
260
|
const onDown = (e: PointerEvent) => {
|
|
212
|
-
if (!containerRef.current?.contains(e.target as Node))
|
|
261
|
+
if (!containerRef.current?.contains(e.target as Node)) {
|
|
262
|
+
setZoomOpen(false);
|
|
263
|
+
setShapeOpen(false);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const onKey = (e: KeyboardEvent) => {
|
|
267
|
+
if (e.key === 'Escape') {
|
|
268
|
+
setZoomOpen(false);
|
|
269
|
+
setShapeOpen(false);
|
|
270
|
+
}
|
|
213
271
|
};
|
|
214
272
|
document.addEventListener('pointerdown', onDown, true);
|
|
215
|
-
|
|
216
|
-
|
|
273
|
+
document.addEventListener('keydown', onKey, true);
|
|
274
|
+
return () => {
|
|
275
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
276
|
+
document.removeEventListener('keydown', onKey, true);
|
|
277
|
+
};
|
|
278
|
+
}, [zoomOpen, shapeOpen]);
|
|
279
|
+
// Close the shape popover whenever the tool changes away from Shape.
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (tool !== 'shape') setShapeOpen(false);
|
|
282
|
+
}, [tool]);
|
|
217
283
|
|
|
218
284
|
if (!mounted) return null;
|
|
219
285
|
|
|
@@ -248,6 +314,63 @@ export function ToolPalette() {
|
|
|
248
314
|
);
|
|
249
315
|
};
|
|
250
316
|
|
|
317
|
+
// Phase 24 — the single Shape tool. The button shows the CURRENT shape-kind
|
|
318
|
+
// glyph (so the toolbar reflects what you'll draw); first click arms the tool,
|
|
319
|
+
// clicking it again (or the caret) opens the 6-kind popover.
|
|
320
|
+
const renderShapeButton = () => {
|
|
321
|
+
const isShape = tool === 'shape';
|
|
322
|
+
const isSticky = sticky.locked && sticky.tool === 'shape';
|
|
323
|
+
const KindIcon = SHAPE_KIND_ICONS[shapeKind] ?? TOOL_ICONS.shape;
|
|
324
|
+
return (
|
|
325
|
+
<span key="shape" className="dc-tp-shape">
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
aria-label={`Shape (R) — ${shapeKind}, click again for shape types`}
|
|
329
|
+
aria-pressed={isShape}
|
|
330
|
+
aria-haspopup="menu"
|
|
331
|
+
aria-expanded={shapeOpen}
|
|
332
|
+
data-sticky={isSticky ? 'true' : undefined}
|
|
333
|
+
title="Shape (R) · double-click to lock"
|
|
334
|
+
onClick={() => {
|
|
335
|
+
if (!isShape) setTool('shape');
|
|
336
|
+
else setShapeOpen((o) => !o);
|
|
337
|
+
}}
|
|
338
|
+
onDoubleClick={() => toggleSticky('shape')}
|
|
339
|
+
>
|
|
340
|
+
{KindIcon ? <KindIcon /> : null}
|
|
341
|
+
<span className="dc-tp-shape-caret" aria-hidden="true">
|
|
342
|
+
<IconChevronDown size={9} />
|
|
343
|
+
</span>
|
|
344
|
+
{isSticky ? <span className="dc-tp-sticky-badge" aria-hidden="true" /> : null}
|
|
345
|
+
</button>
|
|
346
|
+
{shapeOpen ? (
|
|
347
|
+
<div className="dc-tp-shape-popover" role="menu" aria-label="Shape type">
|
|
348
|
+
{SHAPE_KINDS.map(({ kind, label }) => {
|
|
349
|
+
const Icon = SHAPE_KIND_ICONS[kind];
|
|
350
|
+
return (
|
|
351
|
+
<button
|
|
352
|
+
key={kind}
|
|
353
|
+
type="button"
|
|
354
|
+
role="menuitemradio"
|
|
355
|
+
aria-checked={shapeKind === kind}
|
|
356
|
+
aria-label={label}
|
|
357
|
+
title={label}
|
|
358
|
+
onClick={() => {
|
|
359
|
+
setShapeKind(kind);
|
|
360
|
+
setTool('shape');
|
|
361
|
+
setShapeOpen(false);
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
{Icon ? <Icon /> : null}
|
|
365
|
+
</button>
|
|
366
|
+
);
|
|
367
|
+
})}
|
|
368
|
+
</div>
|
|
369
|
+
) : null}
|
|
370
|
+
</span>
|
|
371
|
+
);
|
|
372
|
+
};
|
|
373
|
+
|
|
251
374
|
return (
|
|
252
375
|
<div ref={containerRef} className="dc-tool-palette" role="toolbar" aria-label="Canvas tools">
|
|
253
376
|
<div className="dc-tp-group">
|
|
@@ -255,7 +378,13 @@ export function ToolPalette() {
|
|
|
255
378
|
</div>
|
|
256
379
|
<div className="dc-tp-sep" />
|
|
257
380
|
<div className="dc-tp-group">
|
|
258
|
-
{drawList.map((t) =>
|
|
381
|
+
{drawList.map((t) =>
|
|
382
|
+
t
|
|
383
|
+
? t.id === 'shape'
|
|
384
|
+
? renderShapeButton()
|
|
385
|
+
: renderToolButton(t.id, t.label, t.shortcut)
|
|
386
|
+
: null
|
|
387
|
+
)}
|
|
259
388
|
</div>
|
|
260
389
|
<div className="dc-tp-sep" />
|
|
261
390
|
<div className="dc-tp-group">
|