@1agh/maude 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -21,15 +21,18 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
24
|
-
|
|
24
|
+
import type { AlignEdge } from './annotations-align.ts';
|
|
25
25
|
import {
|
|
26
26
|
type ArrowHead,
|
|
27
27
|
type ArrowLineType,
|
|
28
|
+
type ConvertibleShapeKind,
|
|
29
|
+
convertShapeKind,
|
|
28
30
|
FILL_PALETTE,
|
|
29
31
|
type ListType,
|
|
30
32
|
STICKY_PALETTE,
|
|
31
33
|
STROKE_PALETTE,
|
|
32
34
|
type Stroke,
|
|
35
|
+
shapeKindOf,
|
|
33
36
|
type TextAlign,
|
|
34
37
|
useStrokesStore,
|
|
35
38
|
} from './annotations-layer.tsx';
|
|
@@ -45,10 +48,10 @@ import {
|
|
|
45
48
|
IconArrowNone,
|
|
46
49
|
IconBold,
|
|
47
50
|
IconChevronDown,
|
|
48
|
-
IconCornerPill,
|
|
49
|
-
IconCornerSoft,
|
|
50
|
-
IconCornerSquare,
|
|
51
51
|
IconDash,
|
|
52
|
+
IconDistributeH,
|
|
53
|
+
IconDistributeV,
|
|
54
|
+
IconGroup,
|
|
52
55
|
IconItalic,
|
|
53
56
|
IconLineCurved,
|
|
54
57
|
IconLineElbow,
|
|
@@ -58,9 +61,17 @@ import {
|
|
|
58
61
|
IconLink,
|
|
59
62
|
IconListBullet,
|
|
60
63
|
IconListOrdered,
|
|
64
|
+
IconObjAlignBottom,
|
|
65
|
+
IconObjAlignHCenter,
|
|
66
|
+
IconObjAlignLeft,
|
|
67
|
+
IconObjAlignRight,
|
|
68
|
+
IconObjAlignTop,
|
|
69
|
+
IconObjAlignVCenter,
|
|
61
70
|
IconStrike,
|
|
62
71
|
IconTrash,
|
|
63
72
|
IconUnderline,
|
|
73
|
+
IconUngroup,
|
|
74
|
+
SHAPE_KIND_ICONS,
|
|
64
75
|
} from './canvas-icons.tsx';
|
|
65
76
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
66
77
|
import { isHttpUrl, linkDomain } from './use-canvas-media-drop.tsx';
|
|
@@ -104,6 +115,28 @@ const ALIGN_OPTIONS: ReadonlyArray<{ value: TextAlign; label: string }> = [
|
|
|
104
115
|
{ value: 'center', label: 'Align center' },
|
|
105
116
|
{ value: 'right', label: 'Align right' },
|
|
106
117
|
];
|
|
118
|
+
// FigJam v3 — object align + distribute actions for the multi-select cluster.
|
|
119
|
+
type AlignAction = AlignEdge | 'dist-h' | 'dist-v';
|
|
120
|
+
const ALIGN_ACTION_OPTIONS: ReadonlyArray<{ value: AlignAction; label: string }> = [
|
|
121
|
+
{ value: 'left', label: 'Align left' },
|
|
122
|
+
{ value: 'h-center', label: 'Align horizontal centers' },
|
|
123
|
+
{ value: 'right', label: 'Align right' },
|
|
124
|
+
{ value: 'top', label: 'Align top' },
|
|
125
|
+
{ value: 'v-center', label: 'Align vertical centers' },
|
|
126
|
+
{ value: 'bottom', label: 'Align bottom' },
|
|
127
|
+
{ value: 'dist-h', label: 'Distribute horizontal spacing' },
|
|
128
|
+
{ value: 'dist-v', label: 'Distribute vertical spacing' },
|
|
129
|
+
];
|
|
130
|
+
const ALIGN_ACTION_ICON: Record<string, (p: { size?: number }) => ReactNode> = {
|
|
131
|
+
left: IconObjAlignLeft,
|
|
132
|
+
'h-center': IconObjAlignHCenter,
|
|
133
|
+
right: IconObjAlignRight,
|
|
134
|
+
top: IconObjAlignTop,
|
|
135
|
+
'v-center': IconObjAlignVCenter,
|
|
136
|
+
bottom: IconObjAlignBottom,
|
|
137
|
+
'dist-h': IconDistributeH,
|
|
138
|
+
'dist-v': IconDistributeV,
|
|
139
|
+
};
|
|
107
140
|
const FONT_SIZE_PRESETS: ReadonlyArray<{ px: number; label: string }> = [
|
|
108
141
|
{ px: 12, label: 'Small' },
|
|
109
142
|
{ px: 16, label: 'Medium' },
|
|
@@ -400,13 +433,43 @@ function unionRect(rects: DOMRect[]): { x: number; y: number; w: number; h: numb
|
|
|
400
433
|
|
|
401
434
|
type SwatchMode = 'stroke' | 'fill';
|
|
402
435
|
|
|
403
|
-
export function AnnotationContextToolbar(
|
|
436
|
+
export function AnnotationContextToolbar({
|
|
437
|
+
editingId = null,
|
|
438
|
+
}: {
|
|
439
|
+
/**
|
|
440
|
+
* FigJam v3 — while an inline text editor is open, the toolbar flips into
|
|
441
|
+
* TEXT mode: size / B / I / S / U / align controls that drive the EDITOR
|
|
442
|
+
* through `maude:editor-format` events (mutating the stroke mid-edit would
|
|
443
|
+
* re-render the contentEditable and clobber typed text). The editor echoes
|
|
444
|
+
* its live state back via `maude:editor-format-state`.
|
|
445
|
+
*/
|
|
446
|
+
editingId?: string | null;
|
|
447
|
+
} = {}) {
|
|
404
448
|
ensureToolbarStyles();
|
|
405
449
|
const annotSel = useAnnotationSelectionOptional();
|
|
406
450
|
const store = useStrokesStore();
|
|
407
451
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
408
452
|
const rafRef = useRef<number | null>(null);
|
|
409
453
|
const [, force] = useState({});
|
|
454
|
+
// Edit-mode mirror of the editor's live formatting state.
|
|
455
|
+
const [editorFmt, setEditorFmt] = useState<{
|
|
456
|
+
bold?: boolean;
|
|
457
|
+
italic?: boolean;
|
|
458
|
+
underline?: boolean;
|
|
459
|
+
strike?: boolean;
|
|
460
|
+
fontSize?: number;
|
|
461
|
+
align?: string;
|
|
462
|
+
}>({});
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
if (!editingId || typeof document === 'undefined') return;
|
|
465
|
+
const onState = (e: Event) => {
|
|
466
|
+
setEditorFmt((e as CustomEvent<Record<string, unknown>>).detail ?? {});
|
|
467
|
+
};
|
|
468
|
+
document.addEventListener('maude:editor-format-state', onState);
|
|
469
|
+
// Ask the already-mounted editor for its current state (mount-order race).
|
|
470
|
+
document.dispatchEvent(new CustomEvent('maude:editor-format-request'));
|
|
471
|
+
return () => document.removeEventListener('maude:editor-format-state', onState);
|
|
472
|
+
}, [editingId]);
|
|
410
473
|
// T30 / G_S1 — collapsed Stroke|Fill toggle state. Defaults to 'stroke';
|
|
411
474
|
// auto-reverts to 'stroke' whenever caps.fill is false so the toggle
|
|
412
475
|
// can't get stranded on a hidden mode after the selection changes type.
|
|
@@ -431,25 +494,34 @@ export function AnnotationContextToolbar() {
|
|
|
431
494
|
fill: false,
|
|
432
495
|
thickness: false,
|
|
433
496
|
fontSize: false,
|
|
434
|
-
cornerRadius: false,
|
|
435
497
|
arrowDir: false,
|
|
436
498
|
dash: false,
|
|
499
|
+
shapeKind: false,
|
|
437
500
|
};
|
|
438
501
|
}
|
|
439
|
-
|
|
502
|
+
// Wave H — polygons (diamond / triangle) are full shapes: fill + stroke
|
|
503
|
+
// weight, identical toolbar to rect/ellipse. The same "every selected
|
|
504
|
+
// stroke is a closed shape" test gates fill AND the shape-kind switcher.
|
|
505
|
+
const allClosedShapes = selectedStrokes.every(
|
|
506
|
+
(s) => s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon'
|
|
507
|
+
);
|
|
440
508
|
// T20 — rect + ellipse now carry stroke weight too.
|
|
441
509
|
const allThickness = selectedStrokes.every(
|
|
442
|
-
(s) =>
|
|
510
|
+
(s) =>
|
|
511
|
+
s.tool === 'pen' ||
|
|
512
|
+
s.tool === 'arrow' ||
|
|
513
|
+
s.tool === 'rect' ||
|
|
514
|
+
s.tool === 'ellipse' ||
|
|
515
|
+
s.tool === 'polygon'
|
|
443
516
|
);
|
|
444
|
-
// Phase 21 — fontSize applies to text + sticky;
|
|
445
|
-
//
|
|
517
|
+
// Phase 21 — fontSize applies to text + sticky; arrow direction + dash to
|
|
518
|
+
// arrows. (The Phase-21 rect corner-radius cluster was RETIRED in Wave H —
|
|
519
|
+
// the shape-kind switcher's square/rounded covers it and showing both read
|
|
520
|
+
// as "shapes twice". Pill rects keep rendering; `cornerRadius` stays in
|
|
521
|
+
// the model + serialization, there's just no dedicated toolbar control.)
|
|
446
522
|
const fontSizeApplicable = selectedStrokes.some(
|
|
447
523
|
(s) => s.tool === 'text' || s.tool === 'sticky'
|
|
448
524
|
);
|
|
449
|
-
// Phase 24 — the corner-radius control is rect-only now. Stickies have a
|
|
450
|
-
// fixed soft radius (no switch); the sticky-color swatch row is handled
|
|
451
|
-
// separately below.
|
|
452
|
-
const allRect = selectedStrokes.every((s) => s.tool === 'rect');
|
|
453
525
|
const allArrow = selectedStrokes.every((s) => s.tool === 'arrow');
|
|
454
526
|
// Dash now applies to arrows AND every outlined shape (rect / ellipse /
|
|
455
527
|
// polygon) — item 7. Heads + line-type (arrowDir) stay arrow-only.
|
|
@@ -458,25 +530,30 @@ export function AnnotationContextToolbar() {
|
|
|
458
530
|
);
|
|
459
531
|
return {
|
|
460
532
|
color: true,
|
|
461
|
-
fill:
|
|
533
|
+
fill: allClosedShapes,
|
|
462
534
|
thickness: allThickness,
|
|
463
535
|
fontSize: fontSizeApplicable,
|
|
464
|
-
cornerRadius: allRect,
|
|
465
536
|
arrowDir: allArrow,
|
|
466
537
|
dash: allDashable,
|
|
538
|
+
// Wave H — the shape-kind switcher (square/rounded/circle/diamond/
|
|
539
|
+
// triangle conversion) shows when EVERY selected stroke is a closed
|
|
540
|
+
// shape. Conversions keep ids, so anchored text + binds follow.
|
|
541
|
+
shapeKind: allClosedShapes,
|
|
467
542
|
};
|
|
468
543
|
}, [selectedStrokes]);
|
|
469
544
|
|
|
470
545
|
// Position tracker — uses rAF to follow pan/zoom while the toolbar is up.
|
|
546
|
+
// Edit mode anchors over the EDITED stroke (the selection may be elsewhere).
|
|
471
547
|
useEffect(() => {
|
|
472
|
-
if (selectedStrokes.length === 0) return;
|
|
548
|
+
if (selectedStrokes.length === 0 && !editingId) return;
|
|
549
|
+
const targetIds = editingId ? [editingId] : selectedStrokes.map((s) => s.id);
|
|
473
550
|
const tick = () => {
|
|
474
551
|
rafRef.current = null;
|
|
475
552
|
const el = ref.current;
|
|
476
553
|
if (!el) return;
|
|
477
554
|
const rects: DOMRect[] = [];
|
|
478
|
-
for (const
|
|
479
|
-
const node = document.querySelector(`[data-id="${cssEscape(
|
|
555
|
+
for (const id of targetIds) {
|
|
556
|
+
const node = document.querySelector(`[data-id="${cssEscape(id)}"]`);
|
|
480
557
|
if (node) rects.push(node.getBoundingClientRect());
|
|
481
558
|
}
|
|
482
559
|
const u = unionRect(rects);
|
|
@@ -489,7 +566,10 @@ export function AnnotationContextToolbar() {
|
|
|
489
566
|
// Lay out off-screen first so the size is real, then position.
|
|
490
567
|
const tbW = el.offsetWidth || 280;
|
|
491
568
|
const tbH = el.offsetHeight || 36;
|
|
492
|
-
|
|
569
|
+
// Wave G — clear the selection chrome that floats around the stroke
|
|
570
|
+
// (halo pad + connection dots at ~16 px + the corner rotate zones)
|
|
571
|
+
// instead of sitting glued to the bbox and covering the top dot.
|
|
572
|
+
const margin = 28;
|
|
493
573
|
let top = u.y - tbH - margin;
|
|
494
574
|
if (top < 8) top = u.y + u.h + margin;
|
|
495
575
|
let left = u.x + (u.w - tbW) / 2;
|
|
@@ -504,7 +584,7 @@ export function AnnotationContextToolbar() {
|
|
|
504
584
|
return () => {
|
|
505
585
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
506
586
|
};
|
|
507
|
-
}, [selectedStrokes]);
|
|
587
|
+
}, [selectedStrokes, editingId]);
|
|
508
588
|
|
|
509
589
|
// Force a re-render when the strokes themselves mutate (the position rAF
|
|
510
590
|
// already follows the bbox; this ensures the active-state of the swatches
|
|
@@ -514,173 +594,168 @@ export function AnnotationContextToolbar() {
|
|
|
514
594
|
force({});
|
|
515
595
|
}, [selectedStrokes]);
|
|
516
596
|
|
|
597
|
+
// FigJam v3 — every bulk style mutation routes through `applyToStrokes` so
|
|
598
|
+
// an N-stroke selection edit is ONE undo record (the pre-v3 per-stroke
|
|
599
|
+
// `updateStroke` loop pushed N records — ⌘Z had to be pressed N times).
|
|
600
|
+
const selectedIds = useMemo(() => selectedStrokes.map((s) => s.id), [selectedStrokes]);
|
|
517
601
|
const setColor = useCallback(
|
|
518
602
|
(c: string) => {
|
|
519
|
-
|
|
520
|
-
for (const s of selectedStrokes) store.updateStroke(s.id, { color: c });
|
|
603
|
+
store?.applyToStrokes(selectedIds, () => ({ color: c }), 'set color');
|
|
521
604
|
},
|
|
522
|
-
[store,
|
|
605
|
+
[store, selectedIds]
|
|
523
606
|
);
|
|
524
607
|
const setFill = useCallback(
|
|
525
608
|
(f: string | null) => {
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
609
|
+
store?.applyToStrokes(
|
|
610
|
+
selectedIds,
|
|
611
|
+
(s) =>
|
|
612
|
+
s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon'
|
|
613
|
+
? ({ fill: f } as Partial<Stroke>)
|
|
614
|
+
: null,
|
|
615
|
+
'set fill'
|
|
616
|
+
);
|
|
532
617
|
},
|
|
533
|
-
[store,
|
|
618
|
+
[store, selectedIds]
|
|
534
619
|
);
|
|
535
620
|
const setThickness = useCallback(
|
|
536
621
|
(w: number) => {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
622
|
+
// T20 — rect + ellipse carry stroke weight too; Wave H adds polygon.
|
|
623
|
+
store?.applyToStrokes(
|
|
624
|
+
selectedIds,
|
|
625
|
+
(s) =>
|
|
626
|
+
s.tool === 'pen' ||
|
|
627
|
+
s.tool === 'arrow' ||
|
|
628
|
+
s.tool === 'rect' ||
|
|
629
|
+
s.tool === 'ellipse' ||
|
|
630
|
+
s.tool === 'polygon'
|
|
631
|
+
? ({ width: w } as Partial<Stroke>)
|
|
632
|
+
: null,
|
|
633
|
+
'set thickness'
|
|
634
|
+
);
|
|
544
635
|
},
|
|
545
|
-
[store,
|
|
636
|
+
[store, selectedIds]
|
|
546
637
|
);
|
|
547
638
|
const setFontSize = useCallback(
|
|
548
639
|
(sz: number) => {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
640
|
+
// Phase 21 — sticky carries fontSize too.
|
|
641
|
+
store?.applyToStrokes(
|
|
642
|
+
selectedIds,
|
|
643
|
+
(s) =>
|
|
644
|
+
s.tool === 'text' || s.tool === 'sticky' ? ({ fontSize: sz } as Partial<Stroke>) : null,
|
|
645
|
+
'set font size'
|
|
646
|
+
);
|
|
556
647
|
},
|
|
557
|
-
[store,
|
|
648
|
+
[store, selectedIds]
|
|
558
649
|
);
|
|
559
|
-
// Phase 24 — bold / strike / align on text +
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
650
|
+
// Phase 24 — bold / strike / italic / underline / list / align on text +
|
|
651
|
+
// sticky bodies, all single-record.
|
|
652
|
+
const applyTextPatch = useCallback(
|
|
653
|
+
(patch: Partial<Stroke>, label: string) => {
|
|
654
|
+
store?.applyToStrokes(
|
|
655
|
+
selectedIds,
|
|
656
|
+
(s) => (s.tool === 'text' || s.tool === 'sticky' ? patch : null),
|
|
657
|
+
label
|
|
658
|
+
);
|
|
568
659
|
},
|
|
569
|
-
[store,
|
|
660
|
+
[store, selectedIds]
|
|
661
|
+
);
|
|
662
|
+
const setBold = useCallback(
|
|
663
|
+
(bold: boolean) => applyTextPatch({ bold } as Partial<Stroke>, 'set bold'),
|
|
664
|
+
[applyTextPatch]
|
|
570
665
|
);
|
|
571
666
|
const setStrike = useCallback(
|
|
572
|
-
(strike: boolean) => {
|
|
573
|
-
|
|
574
|
-
for (const s of selectedStrokes) {
|
|
575
|
-
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
576
|
-
store.updateStroke(s.id, { strike } as Partial<Stroke>);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
},
|
|
580
|
-
[store, selectedStrokes]
|
|
667
|
+
(strike: boolean) => applyTextPatch({ strike } as Partial<Stroke>, 'set strikethrough'),
|
|
668
|
+
[applyTextPatch]
|
|
581
669
|
);
|
|
582
|
-
// Italic / underline / list (items 4b + 4c) — same text + sticky fan-out.
|
|
583
670
|
const setItalic = useCallback(
|
|
584
|
-
(italic: boolean) => {
|
|
585
|
-
|
|
586
|
-
for (const s of selectedStrokes) {
|
|
587
|
-
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
588
|
-
store.updateStroke(s.id, { italic } as Partial<Stroke>);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
},
|
|
592
|
-
[store, selectedStrokes]
|
|
671
|
+
(italic: boolean) => applyTextPatch({ italic } as Partial<Stroke>, 'set italic'),
|
|
672
|
+
[applyTextPatch]
|
|
593
673
|
);
|
|
594
674
|
const setUnderline = useCallback(
|
|
595
|
-
(underline: boolean) => {
|
|
596
|
-
|
|
597
|
-
for (const s of selectedStrokes) {
|
|
598
|
-
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
599
|
-
store.updateStroke(s.id, { underline } as Partial<Stroke>);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
},
|
|
603
|
-
[store, selectedStrokes]
|
|
675
|
+
(underline: boolean) => applyTextPatch({ underline } as Partial<Stroke>, 'set underline'),
|
|
676
|
+
[applyTextPatch]
|
|
604
677
|
);
|
|
605
678
|
const setListType = useCallback(
|
|
606
|
-
(listType: ListType | undefined) =>
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
610
|
-
store.updateStroke(s.id, { listType } as Partial<Stroke>);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
},
|
|
614
|
-
[store, selectedStrokes]
|
|
679
|
+
(listType: ListType | undefined) =>
|
|
680
|
+
applyTextPatch({ listType } as Partial<Stroke>, 'set list style'),
|
|
681
|
+
[applyTextPatch]
|
|
615
682
|
);
|
|
616
683
|
const setAlign = useCallback(
|
|
617
|
-
(align: TextAlign) => {
|
|
618
|
-
|
|
619
|
-
for (const s of selectedStrokes) {
|
|
620
|
-
if (s.tool === 'text' || s.tool === 'sticky') {
|
|
621
|
-
store.updateStroke(s.id, { align } as Partial<Stroke>);
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
},
|
|
625
|
-
[store, selectedStrokes]
|
|
684
|
+
(align: TextAlign) => applyTextPatch({ align } as Partial<Stroke>, 'set alignment'),
|
|
685
|
+
[applyTextPatch]
|
|
626
686
|
);
|
|
627
|
-
//
|
|
628
|
-
const
|
|
629
|
-
(
|
|
630
|
-
|
|
631
|
-
for (const s of selectedStrokes) {
|
|
632
|
-
if (s.tool === 'rect' || s.tool === 'sticky') {
|
|
633
|
-
store.updateStroke(s.id, { cornerRadius: r } as Partial<Stroke>);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
687
|
+
// Wave H — convert the selection to another shape kind (one undo record).
|
|
688
|
+
const setSelectionShapeKind = useCallback(
|
|
689
|
+
(kind: ConvertibleShapeKind) => {
|
|
690
|
+
store?.applyToStrokes(selectedIds, (s) => convertShapeKind(s, kind), 'change shape');
|
|
636
691
|
},
|
|
637
|
-
[store,
|
|
692
|
+
[store, selectedIds]
|
|
638
693
|
);
|
|
639
|
-
// Phase 24 — per-end arrowhead + line-type
|
|
640
|
-
// presets with two head dropdowns + a routing dropdown).
|
|
694
|
+
// Phase 24 — per-end arrowhead + line-type.
|
|
641
695
|
const setStartHead = useCallback(
|
|
642
696
|
(startHead: ArrowHead) => {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
697
|
+
store?.applyToStrokes(
|
|
698
|
+
selectedIds,
|
|
699
|
+
(s) => (s.tool === 'arrow' ? ({ startHead } as Partial<Stroke>) : null),
|
|
700
|
+
'set arrowhead'
|
|
701
|
+
);
|
|
647
702
|
},
|
|
648
|
-
[store,
|
|
703
|
+
[store, selectedIds]
|
|
649
704
|
);
|
|
650
705
|
const setEndHead = useCallback(
|
|
651
706
|
(endHead: ArrowHead) => {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
707
|
+
store?.applyToStrokes(
|
|
708
|
+
selectedIds,
|
|
709
|
+
(s) => (s.tool === 'arrow' ? ({ endHead } as Partial<Stroke>) : null),
|
|
710
|
+
'set arrowhead'
|
|
711
|
+
);
|
|
656
712
|
},
|
|
657
|
-
[store,
|
|
713
|
+
[store, selectedIds]
|
|
658
714
|
);
|
|
659
715
|
const setLineType = useCallback(
|
|
660
716
|
(lineType: ArrowLineType) => {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
717
|
+
store?.applyToStrokes(
|
|
718
|
+
selectedIds,
|
|
719
|
+
(s) => (s.tool === 'arrow' ? ({ lineType } as Partial<Stroke>) : null),
|
|
720
|
+
'set line type'
|
|
721
|
+
);
|
|
665
722
|
},
|
|
666
|
-
[store,
|
|
723
|
+
[store, selectedIds]
|
|
667
724
|
);
|
|
668
725
|
// Dash toggle — arrow + rect / ellipse / polygon (item 7).
|
|
669
726
|
const setDashed = useCallback(
|
|
670
727
|
(dashed: boolean) => {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
s.tool === 'arrow' ||
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
728
|
+
store?.applyToStrokes(
|
|
729
|
+
selectedIds,
|
|
730
|
+
(s) =>
|
|
731
|
+
s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon'
|
|
732
|
+
? ({ dashed } as Partial<Stroke>)
|
|
733
|
+
: null,
|
|
734
|
+
'set dash'
|
|
735
|
+
);
|
|
736
|
+
},
|
|
737
|
+
[store, selectedIds]
|
|
738
|
+
);
|
|
739
|
+
// FigJam v3 — group / ungroup / align / distribute on the selection.
|
|
740
|
+
const groupSel = useCallback(() => {
|
|
741
|
+
if (!annotSel || !store) return;
|
|
742
|
+
const members = store.groupSelection(annotSel.selectedIds);
|
|
743
|
+
if (members) annotSel.replace(members);
|
|
744
|
+
}, [annotSel, store]);
|
|
745
|
+
const ungroupSel = useCallback(() => {
|
|
746
|
+
if (!annotSel || !store) return;
|
|
747
|
+
store.ungroupSelection(annotSel.selectedIds);
|
|
748
|
+
}, [annotSel, store]);
|
|
749
|
+
const alignSel = useCallback(
|
|
750
|
+
(op: AlignAction) => {
|
|
751
|
+
if (!annotSel || !store) return;
|
|
752
|
+
if (op === 'dist-h' || op === 'dist-v') {
|
|
753
|
+
store.distributeSelection(annotSel.selectedIds, op === 'dist-h' ? 'h' : 'v');
|
|
754
|
+
} else {
|
|
755
|
+
store.alignSelection(annotSel.selectedIds, op);
|
|
681
756
|
}
|
|
682
757
|
},
|
|
683
|
-
[
|
|
758
|
+
[annotSel, store]
|
|
684
759
|
);
|
|
685
760
|
const remove = useCallback(() => {
|
|
686
761
|
if (!annotSel || !store) return;
|
|
@@ -688,6 +763,60 @@ export function AnnotationContextToolbar() {
|
|
|
688
763
|
annotSel.clear();
|
|
689
764
|
}, [annotSel, store]);
|
|
690
765
|
|
|
766
|
+
// FigJam v3 — TEXT mode while an inline editor is open: a focused strip of
|
|
767
|
+
// text controls dispatching to the editor (Image-6 FigJam parity).
|
|
768
|
+
if (editingId) {
|
|
769
|
+
const dispatchFmt = (key: string, value?: unknown) => {
|
|
770
|
+
document.dispatchEvent(new CustomEvent('maude:editor-format', { detail: { key, value } }));
|
|
771
|
+
};
|
|
772
|
+
const fmtBtn = (
|
|
773
|
+
key: 'bold' | 'italic' | 'strike' | 'underline',
|
|
774
|
+
label: string,
|
|
775
|
+
icon: ReactNode
|
|
776
|
+
) => (
|
|
777
|
+
<button
|
|
778
|
+
type="button"
|
|
779
|
+
className="dc-annot-ctx-ibtn"
|
|
780
|
+
aria-label={label}
|
|
781
|
+
aria-pressed={!!editorFmt[key]}
|
|
782
|
+
title={label}
|
|
783
|
+
// The editor commits on blur/outside-pointerdown — suppress the
|
|
784
|
+
// mousedown so clicking the toolbar doesn't end the edit session.
|
|
785
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
786
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
787
|
+
onClick={() => dispatchFmt(key)}
|
|
788
|
+
>
|
|
789
|
+
{icon}
|
|
790
|
+
</button>
|
|
791
|
+
);
|
|
792
|
+
return (
|
|
793
|
+
<div
|
|
794
|
+
ref={ref}
|
|
795
|
+
className="dc-annot-ctx"
|
|
796
|
+
role="toolbar"
|
|
797
|
+
aria-label="Text formatting"
|
|
798
|
+
style={{ display: 'flex', top: -9999, left: -9999 }}
|
|
799
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
800
|
+
>
|
|
801
|
+
<FontSizeDropdown value={editorFmt.fontSize} onPick={(n) => dispatchFmt('fontSize', n)} />
|
|
802
|
+
{fmtBtn('bold', 'Bold', <IconBold />)}
|
|
803
|
+
{fmtBtn('italic', 'Italic', <IconItalic />)}
|
|
804
|
+
{fmtBtn('strike', 'Strikethrough', <IconStrike />)}
|
|
805
|
+
{fmtBtn('underline', 'Underline', <IconUnderline />)}
|
|
806
|
+
<IconDropdown
|
|
807
|
+
ariaLabel="Text alignment"
|
|
808
|
+
value={editorFmt.align ?? 'left'}
|
|
809
|
+
options={ALIGN_OPTIONS}
|
|
810
|
+
renderIcon={(v) => {
|
|
811
|
+
const I = ALIGN_ICON[v as TextAlign];
|
|
812
|
+
return I ? <I size={16} /> : null;
|
|
813
|
+
}}
|
|
814
|
+
onPick={(v) => dispatchFmt('align', v)}
|
|
815
|
+
/>
|
|
816
|
+
</div>
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
691
820
|
if (!annotSel || !store || selectedStrokes.length === 0) return null;
|
|
692
821
|
|
|
693
822
|
// ── Phase 23 — dedicated panel for a single image / link selection. ─────────
|
|
@@ -829,19 +958,31 @@ export function AnnotationContextToolbar() {
|
|
|
829
958
|
);
|
|
830
959
|
}
|
|
831
960
|
|
|
961
|
+
// FigJam v3 — group / align availability. Selection expansion guarantees a
|
|
962
|
+
// group is always selected whole, so "any member grouped" ⇒ ungroupable.
|
|
963
|
+
const canGroup = selectedStrokes.length >= 2;
|
|
964
|
+
const canUngroup = selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0);
|
|
965
|
+
const canAlign = selectedStrokes.length >= 2;
|
|
966
|
+
|
|
832
967
|
// Active values for swatch highlighting — when uniform across selection.
|
|
833
968
|
const uniqColor = uniformValue(selectedStrokes.map((s) => s.color));
|
|
834
969
|
const uniqFill = caps.fill
|
|
835
970
|
? uniformValue(
|
|
836
971
|
selectedStrokes.map((s) =>
|
|
837
|
-
s.tool === 'rect' || s.tool === 'ellipse'
|
|
972
|
+
s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon'
|
|
973
|
+
? (s.fill ?? null)
|
|
974
|
+
: undefined
|
|
838
975
|
)
|
|
839
976
|
)
|
|
840
977
|
: undefined;
|
|
841
978
|
const uniqThickness = caps.thickness
|
|
842
979
|
? uniformValue(
|
|
843
980
|
selectedStrokes.map((s) =>
|
|
844
|
-
s.tool === 'pen' ||
|
|
981
|
+
s.tool === 'pen' ||
|
|
982
|
+
s.tool === 'arrow' ||
|
|
983
|
+
s.tool === 'rect' ||
|
|
984
|
+
s.tool === 'ellipse' ||
|
|
985
|
+
s.tool === 'polygon'
|
|
845
986
|
? s.width
|
|
846
987
|
: undefined
|
|
847
988
|
)
|
|
@@ -903,18 +1044,9 @@ export function AnnotationContextToolbar() {
|
|
|
903
1044
|
})
|
|
904
1045
|
)
|
|
905
1046
|
: undefined;
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
? uniformValue(
|
|
910
|
-
selectedStrokes.map((s) =>
|
|
911
|
-
s.tool === 'rect'
|
|
912
|
-
? (s.cornerRadius ?? 0)
|
|
913
|
-
: s.tool === 'sticky'
|
|
914
|
-
? (s.cornerRadius ?? 8)
|
|
915
|
-
: undefined
|
|
916
|
-
)
|
|
917
|
-
)
|
|
1047
|
+
// Wave H — uniform shape kind across the selection (undefined = mixed).
|
|
1048
|
+
const uniqShapeKind = caps.shapeKind
|
|
1049
|
+
? uniformValue(selectedStrokes.map((s) => shapeKindOf(s) ?? undefined))
|
|
918
1050
|
: undefined;
|
|
919
1051
|
// Phase 21 — uniform arrow head pair (default start none / end triangle).
|
|
920
1052
|
const uniqStartHead = caps.arrowDir
|
|
@@ -1119,39 +1251,34 @@ export function AnnotationContextToolbar() {
|
|
|
1119
1251
|
</button>
|
|
1120
1252
|
</>
|
|
1121
1253
|
) : null}
|
|
1122
|
-
{caps.
|
|
1254
|
+
{caps.shapeKind ? (
|
|
1123
1255
|
<>
|
|
1124
1256
|
<div className="dc-annot-ctx-sep" />
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
<
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
title="Pill corners"
|
|
1151
|
-
onClick={() => setCornerRadius(999)}
|
|
1152
|
-
>
|
|
1153
|
-
<IconCornerPill />
|
|
1154
|
-
</button>
|
|
1257
|
+
{(
|
|
1258
|
+
[
|
|
1259
|
+
['square', 'Square'],
|
|
1260
|
+
['rounded', 'Rounded square'],
|
|
1261
|
+
['circle', 'Circle'],
|
|
1262
|
+
['diamond', 'Diamond'],
|
|
1263
|
+
['triangle', 'Triangle'],
|
|
1264
|
+
['triangle-down', 'Triangle down'],
|
|
1265
|
+
] as Array<[ConvertibleShapeKind, string]>
|
|
1266
|
+
).map(([kind, label]) => {
|
|
1267
|
+
const KindIcon = SHAPE_KIND_ICONS[kind];
|
|
1268
|
+
return (
|
|
1269
|
+
<button
|
|
1270
|
+
key={kind}
|
|
1271
|
+
type="button"
|
|
1272
|
+
className="dc-annot-ctx-ibtn"
|
|
1273
|
+
aria-label={label}
|
|
1274
|
+
aria-pressed={uniqShapeKind === kind}
|
|
1275
|
+
title={label}
|
|
1276
|
+
onClick={() => setSelectionShapeKind(kind)}
|
|
1277
|
+
>
|
|
1278
|
+
{KindIcon ? <KindIcon size={16} /> : null}
|
|
1279
|
+
</button>
|
|
1280
|
+
);
|
|
1281
|
+
})}
|
|
1155
1282
|
</>
|
|
1156
1283
|
) : null}
|
|
1157
1284
|
{caps.arrowDir ? (
|
|
@@ -1204,6 +1331,46 @@ export function AnnotationContextToolbar() {
|
|
|
1204
1331
|
</button>
|
|
1205
1332
|
</>
|
|
1206
1333
|
) : null}
|
|
1334
|
+
{canGroup || canUngroup || canAlign ? (
|
|
1335
|
+
<>
|
|
1336
|
+
<div className="dc-annot-ctx-sep" />
|
|
1337
|
+
{canGroup ? (
|
|
1338
|
+
<button
|
|
1339
|
+
type="button"
|
|
1340
|
+
className="dc-annot-ctx-ibtn"
|
|
1341
|
+
aria-label="Group selection"
|
|
1342
|
+
title="Group (⌘G)"
|
|
1343
|
+
onClick={groupSel}
|
|
1344
|
+
>
|
|
1345
|
+
<IconGroup />
|
|
1346
|
+
</button>
|
|
1347
|
+
) : null}
|
|
1348
|
+
{canUngroup ? (
|
|
1349
|
+
<button
|
|
1350
|
+
type="button"
|
|
1351
|
+
className="dc-annot-ctx-ibtn"
|
|
1352
|
+
aria-label="Ungroup selection"
|
|
1353
|
+
title="Ungroup (⌘⇧G)"
|
|
1354
|
+
onClick={ungroupSel}
|
|
1355
|
+
>
|
|
1356
|
+
<IconUngroup />
|
|
1357
|
+
</button>
|
|
1358
|
+
) : null}
|
|
1359
|
+
{canAlign ? (
|
|
1360
|
+
<ActionDropdown
|
|
1361
|
+
ariaLabel="Align and distribute"
|
|
1362
|
+
triggerIcon={<IconObjAlignLeft size={16} />}
|
|
1363
|
+
options={ALIGN_ACTION_OPTIONS}
|
|
1364
|
+
renderIcon={(v) => {
|
|
1365
|
+
const I = ALIGN_ACTION_ICON[v];
|
|
1366
|
+
return I ? <I size={16} /> : null;
|
|
1367
|
+
}}
|
|
1368
|
+
isDisabled={(v) => (v === 'dist-h' || v === 'dist-v') && selectedStrokes.length < 3}
|
|
1369
|
+
onPick={(v) => alignSel(v as AlignAction)}
|
|
1370
|
+
/>
|
|
1371
|
+
) : null}
|
|
1372
|
+
</>
|
|
1373
|
+
) : null}
|
|
1207
1374
|
<div className="dc-annot-ctx-sep" />
|
|
1208
1375
|
<button
|
|
1209
1376
|
type="button"
|
|
@@ -1297,6 +1464,89 @@ function IconDropdown({
|
|
|
1297
1464
|
);
|
|
1298
1465
|
}
|
|
1299
1466
|
|
|
1467
|
+
/**
|
|
1468
|
+
* FigJam v3 — an ACTION dropdown (vs IconDropdown's radio semantics): each
|
|
1469
|
+
* item fires an operation instead of reflecting a current value. Used by the
|
|
1470
|
+
* align/distribute cluster; distribute items disable below three strokes.
|
|
1471
|
+
*/
|
|
1472
|
+
function ActionDropdown({
|
|
1473
|
+
ariaLabel,
|
|
1474
|
+
triggerIcon,
|
|
1475
|
+
options,
|
|
1476
|
+
renderIcon,
|
|
1477
|
+
isDisabled,
|
|
1478
|
+
onPick,
|
|
1479
|
+
}: {
|
|
1480
|
+
ariaLabel: string;
|
|
1481
|
+
triggerIcon: ReactNode;
|
|
1482
|
+
options: ReadonlyArray<{ value: string; label: string }>;
|
|
1483
|
+
renderIcon: (v: string) => ReactNode;
|
|
1484
|
+
isDisabled?: (v: string) => boolean;
|
|
1485
|
+
onPick: (v: string) => void;
|
|
1486
|
+
}) {
|
|
1487
|
+
const [open, setOpen] = useState(false);
|
|
1488
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
1489
|
+
useEffect(() => {
|
|
1490
|
+
if (!open) return;
|
|
1491
|
+
const onDown = (e: PointerEvent) => {
|
|
1492
|
+
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
|
1493
|
+
};
|
|
1494
|
+
const onKey = (e: KeyboardEvent) => {
|
|
1495
|
+
if (e.key === 'Escape') setOpen(false);
|
|
1496
|
+
};
|
|
1497
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
1498
|
+
document.addEventListener('keydown', onKey, true);
|
|
1499
|
+
return () => {
|
|
1500
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
1501
|
+
document.removeEventListener('keydown', onKey, true);
|
|
1502
|
+
};
|
|
1503
|
+
}, [open]);
|
|
1504
|
+
return (
|
|
1505
|
+
<div ref={ref} className="dc-annot-ctx-dd">
|
|
1506
|
+
<button
|
|
1507
|
+
type="button"
|
|
1508
|
+
className="dc-annot-ctx-ibtn dc-annot-ctx-dd-trigger"
|
|
1509
|
+
aria-haspopup="menu"
|
|
1510
|
+
aria-expanded={open}
|
|
1511
|
+
aria-label={ariaLabel}
|
|
1512
|
+
title={ariaLabel}
|
|
1513
|
+
onClick={() => setOpen((o) => !o)}
|
|
1514
|
+
>
|
|
1515
|
+
{triggerIcon}
|
|
1516
|
+
<span className="dc-annot-ctx-dd-caret" aria-hidden="true">
|
|
1517
|
+
<IconChevronDown size={8} />
|
|
1518
|
+
</span>
|
|
1519
|
+
</button>
|
|
1520
|
+
{open ? (
|
|
1521
|
+
<div className="dc-annot-ctx-menu" role="menu" aria-label={ariaLabel}>
|
|
1522
|
+
{options.map((o) => {
|
|
1523
|
+
const disabled = isDisabled?.(o.value) ?? false;
|
|
1524
|
+
return (
|
|
1525
|
+
<button
|
|
1526
|
+
key={o.value}
|
|
1527
|
+
type="button"
|
|
1528
|
+
role="menuitem"
|
|
1529
|
+
aria-label={o.label}
|
|
1530
|
+
title={o.label}
|
|
1531
|
+
className="dc-annot-ctx-ibtn"
|
|
1532
|
+
disabled={disabled}
|
|
1533
|
+
style={disabled ? { opacity: 0.35, cursor: 'default' } : undefined}
|
|
1534
|
+
onClick={() => {
|
|
1535
|
+
if (disabled) return;
|
|
1536
|
+
onPick(o.value);
|
|
1537
|
+
setOpen(false);
|
|
1538
|
+
}}
|
|
1539
|
+
>
|
|
1540
|
+
{renderIcon(o.value)}
|
|
1541
|
+
</button>
|
|
1542
|
+
);
|
|
1543
|
+
})}
|
|
1544
|
+
</div>
|
|
1545
|
+
) : null}
|
|
1546
|
+
</div>
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1300
1550
|
/**
|
|
1301
1551
|
* Phase 24 — font-size control: named presets (Small → Huge) + a numeric input
|
|
1302
1552
|
* for an arbitrary px value (clamped 8–200, applied on Enter / blur). The
|