@1agh/maude 0.19.0 → 0.20.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 +9 -9
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
- package/plugins/design/dev-server/annotations-layer.tsx +343 -112
- package/plugins/design/dev-server/api.ts +70 -17
- package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +691 -119
- package/plugins/design/dev-server/canvas-shell.tsx +818 -42
- package/plugins/design/dev-server/client/app.jsx +173 -52
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
- package/plugins/design/dev-server/client/styles/4-components.css +32 -2
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +12 -4
- package/plugins/design/dev-server/config.schema.json +31 -5
- package/plugins/design/dev-server/context-menu.tsx +3 -3
- package/plugins/design/dev-server/context.ts +37 -3
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +212 -103
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/dist/styles.css +38 -2
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/export-dialog.tsx +3 -3
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +41 -2
- package/plugins/design/dev-server/input-router.tsx +74 -10
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/runtime-bundle.ts +7 -0
- package/plugins/design/dev-server/server.mjs +84 -18
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
- package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
- package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/tool-palette.tsx +71 -15
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
- package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/templates/_shell.html +17 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -35,10 +35,14 @@ import { createPortal } from 'react-dom';
|
|
|
35
35
|
|
|
36
36
|
import { AnnotationContextToolbar } from './annotations-context-toolbar.tsx';
|
|
37
37
|
import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
|
|
38
|
+
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
39
|
+
import { crossedDragThreshold } from './input-router.tsx';
|
|
40
|
+
import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
38
41
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
39
42
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
40
43
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
41
44
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
45
|
+
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
42
46
|
|
|
43
47
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
48
|
// Types
|
|
@@ -114,7 +118,7 @@ const FILL_PALETTE = [
|
|
|
114
118
|
'#f4f1ee', // paper
|
|
115
119
|
] as const;
|
|
116
120
|
|
|
117
|
-
const STROKE_WIDTH_THIN =
|
|
121
|
+
const STROKE_WIDTH_THIN = 3;
|
|
118
122
|
const STROKE_WIDTH_THICK = 6;
|
|
119
123
|
type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
120
124
|
|
|
@@ -457,15 +461,15 @@ const ANNOT_CSS = `
|
|
|
457
461
|
display: flex;
|
|
458
462
|
align-items: center;
|
|
459
463
|
gap: 8px;
|
|
460
|
-
background: var(--u-bg-
|
|
464
|
+
background: var(--u-bg-0, var(--bg-0, rgba(255,255,255,0.98)));
|
|
461
465
|
border: 1px solid var(--u-fg-0, #1c1917);
|
|
462
|
-
border-radius:
|
|
466
|
+
border-radius: 8px;
|
|
463
467
|
padding: 6px 10px;
|
|
464
468
|
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
465
469
|
font-size: 11px;
|
|
466
470
|
color: var(--u-fg-0, rgba(40,30,20,0.85));
|
|
467
471
|
z-index: 6;
|
|
468
|
-
box-shadow:
|
|
472
|
+
box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
|
|
469
473
|
user-select: none;
|
|
470
474
|
}
|
|
471
475
|
.dc-annot-chrome .dc-annot-swatches { display: flex; gap: 4px; }
|
|
@@ -608,6 +612,23 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
|
608
612
|
return s; // text inherits its host's bbox
|
|
609
613
|
}
|
|
610
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Reference-equal stroke comparison — true when the two arrays carry the same
|
|
617
|
+
* stroke object references in the same order. Used by the annotation drag
|
|
618
|
+
* onPointerUp to skip pushing an undo record when the gesture didn't actually
|
|
619
|
+
* move anything (zero movement OR snapshot mapped through a no-op translate
|
|
620
|
+
* back to the original references — `translateOne` short-circuits when dx=dy=0
|
|
621
|
+
* because new objects are still created, so we compare references defensively).
|
|
622
|
+
*/
|
|
623
|
+
export function strokesShallowEqual(a: readonly Stroke[], b: readonly Stroke[]): boolean {
|
|
624
|
+
if (a === b) return true;
|
|
625
|
+
if (a.length !== b.length) return false;
|
|
626
|
+
for (let i = 0; i < a.length; i++) {
|
|
627
|
+
if (a[i] !== b[i]) return false;
|
|
628
|
+
}
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
|
|
611
632
|
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
612
633
|
// ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
|
|
613
634
|
// can read the same state. Re-exported here for back-compat.
|
|
@@ -618,7 +639,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
|
618
639
|
|
|
619
640
|
export function AnnotationsLayer() {
|
|
620
641
|
ensureAnnotStyles();
|
|
621
|
-
const { tool } = useToolMode();
|
|
642
|
+
const { tool, setTool, sticky } = useToolMode();
|
|
622
643
|
const controller = useViewportControllerContext();
|
|
623
644
|
const vp = controller?.viewport ?? null;
|
|
624
645
|
const worldRef = useWorldRefContext();
|
|
@@ -649,11 +670,22 @@ export function AnnotationsLayer() {
|
|
|
649
670
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
650
671
|
const drawingRef = useRef<Stroke | null>(null);
|
|
651
672
|
drawingRef.current = drawing;
|
|
673
|
+
/**
|
|
674
|
+
* Phase 20 — latest strokes mirror so command builders can read the
|
|
675
|
+
* pre-mutation snapshot synchronously (React state isn't refreshed
|
|
676
|
+
* between rapid taps in the same tick).
|
|
677
|
+
*/
|
|
678
|
+
const strokesRef = useRef<Stroke[]>(strokes);
|
|
679
|
+
strokesRef.current = strokes;
|
|
652
680
|
|
|
653
681
|
const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
|
|
654
682
|
const isErase = tool === 'eraser';
|
|
655
683
|
const isActive = isDraw || isErase;
|
|
656
|
-
|
|
684
|
+
// T20 — rect + ellipse expose stroke weight too (FigJam ships thickness on
|
|
685
|
+
// every shape). The annotation toolbar reads supportsThickness to decide
|
|
686
|
+
// whether to render the Thin / Thick chips.
|
|
687
|
+
const supportsThickness =
|
|
688
|
+
tool === 'pen' || tool === 'arrow' || tool === 'rect' || tool === 'ellipse';
|
|
657
689
|
const supportsFill = tool === 'rect' || tool === 'ellipse';
|
|
658
690
|
|
|
659
691
|
// Load existing annotations on mount.
|
|
@@ -679,56 +711,100 @@ export function AnnotationsLayer() {
|
|
|
679
711
|
};
|
|
680
712
|
}, []);
|
|
681
713
|
|
|
682
|
-
const
|
|
714
|
+
const undoStack = useUndoStackOptional();
|
|
715
|
+
const undoSinks = useUndoSinks();
|
|
716
|
+
const undoStackRef = useRef(undoStack);
|
|
717
|
+
undoStackRef.current = undoStack;
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
|
|
721
|
+
* PUT to the server. Used as the `putFn` injected into the
|
|
722
|
+
* `AnnotationStrokesCommand` — both the initial push AND every undo/redo
|
|
723
|
+
* replay route through here, so the iframe's `strokes` state always
|
|
724
|
+
* tracks the server. (Without the setStrokesState here, Cmd+Z would
|
|
725
|
+
* silently PUT the prior SVG but the canvas would keep painting the
|
|
726
|
+
* post-edit strokes until the user reloaded.)
|
|
727
|
+
*
|
|
728
|
+
* The 200 ms scheduled-save debounce (legacy path) is cleared the moment
|
|
729
|
+
* we push a command, so the server only sees one PUT per edit instead
|
|
730
|
+
* of two-step racing.
|
|
731
|
+
*/
|
|
732
|
+
const putStrokes = useCallback((next: readonly Stroke[]) => {
|
|
733
|
+
setStrokesState(next as Stroke[]);
|
|
683
734
|
const file = fileRef.current;
|
|
684
|
-
if (!file) return;
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
/* swallow — the user will see uncommitted state until the next stroke */
|
|
735
|
+
if (!file) return Promise.resolve();
|
|
736
|
+
const svg = strokesToSvg(next);
|
|
737
|
+
return fetch('/_api/annotations', {
|
|
738
|
+
method: 'PUT',
|
|
739
|
+
headers: { 'Content-Type': 'application/json' },
|
|
740
|
+
body: JSON.stringify({ file, svg }),
|
|
741
|
+
})
|
|
742
|
+
.then(() => undefined)
|
|
743
|
+
.catch(() => {
|
|
744
|
+
/* swallow — user sees uncommitted state until the next stroke */
|
|
695
745
|
});
|
|
696
|
-
}, 200);
|
|
697
746
|
}, []);
|
|
698
747
|
|
|
748
|
+
// Register the strokes put sink with the undo provider so the rebuilt
|
|
749
|
+
// AnnotationStrokesCommand (after a canvas switch + return) routes through
|
|
750
|
+
// THIS iframe's React state, not the gone iframe's stale closures.
|
|
751
|
+
useEffect(() => {
|
|
752
|
+
undoSinks.setSink('strokesPutFn', putStrokes);
|
|
753
|
+
return () => undoSinks.setSink('strokesPutFn', undefined);
|
|
754
|
+
}, [undoSinks, putStrokes]);
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Single entry point for every stroke mutation. Builds an undo record
|
|
758
|
+
* and pushes onto the stack — `push()` rebuilds the command via the
|
|
759
|
+
* registered `strokesPutFn` sink and calls `cmd.do() = putStrokes(next)`,
|
|
760
|
+
* which both updates local state and PUTs. Cancels any pending debounced
|
|
761
|
+
* save first — DDR-050 gotcha: a queued auto-save flushing AFTER our PUT
|
|
762
|
+
* would race the stack into a stale state.
|
|
763
|
+
*/
|
|
764
|
+
const commitStrokes = useCallback(
|
|
765
|
+
(prev: readonly Stroke[], next: readonly Stroke[], label?: string) => {
|
|
766
|
+
if (saveTimerRef.current) {
|
|
767
|
+
clearTimeout(saveTimerRef.current);
|
|
768
|
+
saveTimerRef.current = null;
|
|
769
|
+
}
|
|
770
|
+
const record = buildAnnotationStrokesRecord({
|
|
771
|
+
before: prev,
|
|
772
|
+
after: next,
|
|
773
|
+
...(label ? { label } : {}),
|
|
774
|
+
});
|
|
775
|
+
void undoStackRef.current.push(record);
|
|
776
|
+
},
|
|
777
|
+
[]
|
|
778
|
+
);
|
|
779
|
+
|
|
699
780
|
const setStrokes = useCallback(
|
|
700
781
|
(next: Stroke[]) => {
|
|
701
|
-
|
|
702
|
-
|
|
782
|
+
const prev = strokesRef.current;
|
|
783
|
+
commitStrokes(prev, next);
|
|
703
784
|
},
|
|
704
|
-
[
|
|
785
|
+
[commitStrokes]
|
|
705
786
|
);
|
|
706
787
|
|
|
707
788
|
const strokesStore = useMemo<StrokesStoreValue>(() => {
|
|
708
789
|
const updateStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
return next;
|
|
713
|
-
});
|
|
790
|
+
const prev = strokesRef.current;
|
|
791
|
+
const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
|
|
792
|
+
commitStrokes(prev, next);
|
|
714
793
|
};
|
|
715
794
|
const deleteStrokes = (ids: string[]): void => {
|
|
716
795
|
const set = new Set(ids);
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
});
|
|
796
|
+
const prev = strokesRef.current;
|
|
797
|
+
const next = prev.filter(
|
|
798
|
+
(s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
|
|
799
|
+
);
|
|
800
|
+
if (next.length === prev.length) return;
|
|
801
|
+
commitStrokes(prev, next);
|
|
724
802
|
};
|
|
725
803
|
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
726
804
|
const set = new Set(ids);
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
return next;
|
|
731
|
-
});
|
|
805
|
+
const prev = strokesRef.current;
|
|
806
|
+
const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
|
|
807
|
+
commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
|
|
732
808
|
};
|
|
733
809
|
return {
|
|
734
810
|
strokes,
|
|
@@ -737,7 +813,7 @@ export function AnnotationsLayer() {
|
|
|
737
813
|
deleteStrokes,
|
|
738
814
|
translateStrokes,
|
|
739
815
|
};
|
|
740
|
-
}, [strokes, setStrokes,
|
|
816
|
+
}, [strokes, setStrokes, commitStrokes]);
|
|
741
817
|
|
|
742
818
|
// Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
|
|
743
819
|
// dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
|
|
@@ -789,23 +865,21 @@ export function AnnotationsLayer() {
|
|
|
789
865
|
(wx: number, wy: number) => {
|
|
790
866
|
const zoom = vp?.zoom || 1;
|
|
791
867
|
const tol = 8 / zoom;
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
}
|
|
868
|
+
const prev = strokesRef.current;
|
|
869
|
+
for (let i = prev.length - 1; i >= 0; i--) {
|
|
870
|
+
const candidate = prev[i];
|
|
871
|
+
if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
|
|
872
|
+
const removedId = candidate.id;
|
|
873
|
+
const next = prev
|
|
874
|
+
.slice(0, i)
|
|
875
|
+
.concat(prev.slice(i + 1))
|
|
876
|
+
.filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
|
|
877
|
+
commitStrokes(prev, next, 'erase 1 stroke');
|
|
878
|
+
return;
|
|
804
879
|
}
|
|
805
|
-
|
|
806
|
-
});
|
|
880
|
+
}
|
|
807
881
|
},
|
|
808
|
-
[vp,
|
|
882
|
+
[vp, commitStrokes]
|
|
809
883
|
);
|
|
810
884
|
|
|
811
885
|
const beginStroke = useCallback(
|
|
@@ -839,7 +913,7 @@ export function AnnotationsLayer() {
|
|
|
839
913
|
id,
|
|
840
914
|
tool: 'rect',
|
|
841
915
|
color,
|
|
842
|
-
width
|
|
916
|
+
width,
|
|
843
917
|
x: wx,
|
|
844
918
|
y: wy,
|
|
845
919
|
w: 0,
|
|
@@ -851,7 +925,7 @@ export function AnnotationsLayer() {
|
|
|
851
925
|
id,
|
|
852
926
|
tool: 'ellipse',
|
|
853
927
|
color,
|
|
854
|
-
width
|
|
928
|
+
width,
|
|
855
929
|
cx: wx,
|
|
856
930
|
cy: wy,
|
|
857
931
|
rx: 0,
|
|
@@ -930,14 +1004,41 @@ export function AnnotationsLayer() {
|
|
|
930
1004
|
if (final && !isStrokeMeaningful(final)) final = null;
|
|
931
1005
|
if (final) {
|
|
932
1006
|
const committed = final;
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1007
|
+
const prev = strokesRef.current;
|
|
1008
|
+
const next = [...prev, committed];
|
|
1009
|
+
commitStrokes(prev, next, `draw ${committed.tool}`);
|
|
1010
|
+
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
1011
|
+
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
1012
|
+
// AnnotationsLayer without the provider), so guard the call.
|
|
1013
|
+
if (annotSel) annotSel.replace(committed.id);
|
|
1014
|
+
}
|
|
1015
|
+
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
1016
|
+
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
1017
|
+
// row (canonical pattern: tldraw double-click to lock). Eraser stays
|
|
1018
|
+
// armed by default — that tool is destructive, not constructive.
|
|
1019
|
+
const toolJustUsed = cur.tool;
|
|
1020
|
+
if (toolJustUsed !== 'eraser') {
|
|
1021
|
+
const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
|
|
1022
|
+
if (!stickyOnThis) setTool('move');
|
|
938
1023
|
}
|
|
939
1024
|
setDrawing(null);
|
|
940
|
-
}, [isActive, isErase, visible,
|
|
1025
|
+
}, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
|
|
1026
|
+
|
|
1027
|
+
// T21 — abort a mid-stroke draw without committing. Dispatched by the
|
|
1028
|
+
// canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
|
|
1029
|
+
// nothing is being drawn — the early-return on drawingRef keeps it
|
|
1030
|
+
// a no-op.
|
|
1031
|
+
const cancelStroke = useCallback(() => {
|
|
1032
|
+
if (!drawingRef.current) return;
|
|
1033
|
+
setDrawing(null);
|
|
1034
|
+
}, []);
|
|
1035
|
+
|
|
1036
|
+
useEffect(() => {
|
|
1037
|
+
if (typeof document === 'undefined') return;
|
|
1038
|
+
const onCancel = () => cancelStroke();
|
|
1039
|
+
document.addEventListener('maude:cancel-stroke', onCancel);
|
|
1040
|
+
return () => document.removeEventListener('maude:cancel-stroke', onCancel);
|
|
1041
|
+
}, [cancelStroke]);
|
|
941
1042
|
|
|
942
1043
|
const renderStrokes = useMemo(
|
|
943
1044
|
() => (drawing ? [...strokes, drawing] : strokes),
|
|
@@ -966,11 +1067,19 @@ export function AnnotationsLayer() {
|
|
|
966
1067
|
// - Cmd / Cmd+Shift falls through to element-selection (we bail).
|
|
967
1068
|
// Once a stroke is selected, clicking inside its bbox starts a drag.
|
|
968
1069
|
|
|
1070
|
+
/**
|
|
1071
|
+
* Annotation drag state. Snapshot of strokes captured at pointerdown so the
|
|
1072
|
+
* whole gesture (pointermove × N → pointerup) commits as ONE undo record at
|
|
1073
|
+
* release time. Without the snapshot, each pointermove tick became its own
|
|
1074
|
+
* `translateStrokes` call and each call became its own undo record — Cmd+Z
|
|
1075
|
+
* had to be pressed dozens of times to walk back a single drag.
|
|
1076
|
+
*/
|
|
969
1077
|
const dragStateRef = useRef<{
|
|
970
1078
|
pointerId: number;
|
|
971
1079
|
startWX: number;
|
|
972
1080
|
startWY: number;
|
|
973
1081
|
movedIds: string[];
|
|
1082
|
+
snapshot: Stroke[];
|
|
974
1083
|
} | null>(null);
|
|
975
1084
|
|
|
976
1085
|
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
@@ -982,6 +1091,7 @@ export function AnnotationsLayer() {
|
|
|
982
1091
|
by: number;
|
|
983
1092
|
} | null>(null);
|
|
984
1093
|
|
|
1094
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: commitStrokes/strokesStore are stable refs from parent context; manual dep list reflects effect-trigger semantics, not internal callbacks
|
|
985
1095
|
useEffect(() => {
|
|
986
1096
|
if (typeof document === 'undefined') return;
|
|
987
1097
|
if (tool !== 'move') return;
|
|
@@ -1005,7 +1115,7 @@ export function AnnotationsLayer() {
|
|
|
1005
1115
|
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
1006
1116
|
// right-click menu. Clicks on these route to their own handlers.
|
|
1007
1117
|
const CHROME_SELECTOR =
|
|
1008
|
-
'.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover';
|
|
1118
|
+
'.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle';
|
|
1009
1119
|
|
|
1010
1120
|
const onDown = (e: PointerEvent) => {
|
|
1011
1121
|
if (e.button !== 0) return;
|
|
@@ -1013,6 +1123,11 @@ export function AnnotationsLayer() {
|
|
|
1013
1123
|
const target = e.target as Element | null;
|
|
1014
1124
|
if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
|
|
1015
1125
|
const strokeId = findStrokeId(target);
|
|
1126
|
+
// When pointerdown lands inside an artboard but not on a stroke, the
|
|
1127
|
+
// gesture belongs to artboard-drag / element-marquee — not the
|
|
1128
|
+
// annotation marquee. Bailing here keeps the annotation marquee from
|
|
1129
|
+
// racing the artboard drag (post-Wave-3 user grievance G5).
|
|
1130
|
+
if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
|
|
1016
1131
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1017
1132
|
const startClientX = e.clientX;
|
|
1018
1133
|
const startClientY = e.clientY;
|
|
@@ -1034,29 +1149,51 @@ export function AnnotationsLayer() {
|
|
|
1034
1149
|
annotSel.replace(strokeId);
|
|
1035
1150
|
ids = [strokeId];
|
|
1036
1151
|
}
|
|
1152
|
+
// Capture a snapshot of all strokes at drag start. Every pointermove
|
|
1153
|
+
// re-translates FROM the snapshot using the cumulative cursor delta
|
|
1154
|
+
// (NOT a delta-from-last-frame mutation), so dragging back to origin
|
|
1155
|
+
// restores positions exactly. Optimistic state-only updates during
|
|
1156
|
+
// the move; ONE undo record + ONE server PUT fires on pointerup.
|
|
1157
|
+
const dragSnapshot = strokesRef.current.slice();
|
|
1037
1158
|
dragStateRef.current = {
|
|
1038
1159
|
pointerId: e.pointerId,
|
|
1039
1160
|
startWX: wx,
|
|
1040
1161
|
startWY: wy,
|
|
1041
1162
|
movedIds: ids,
|
|
1163
|
+
snapshot: dragSnapshot,
|
|
1042
1164
|
};
|
|
1165
|
+
const movedSet = new Set(ids);
|
|
1043
1166
|
const onMove = (mv: PointerEvent) => {
|
|
1044
1167
|
const st = dragStateRef.current;
|
|
1045
1168
|
if (!st || mv.pointerId !== st.pointerId) return;
|
|
1046
1169
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1047
1170
|
const dx = cwx - st.startWX;
|
|
1048
1171
|
const dy = cwy - st.startWY;
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1172
|
+
// Drag-back-to-origin: restore exact references so the pointerup
|
|
1173
|
+
// shallow-equality check skips committing a no-op record.
|
|
1174
|
+
const next =
|
|
1175
|
+
dx === 0 && dy === 0
|
|
1176
|
+
? st.snapshot
|
|
1177
|
+
: st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s));
|
|
1178
|
+
// Local React state only. No commitStrokes — no PUT, no undo push.
|
|
1179
|
+
setStrokesState(next);
|
|
1053
1180
|
};
|
|
1054
1181
|
const onUp = (up: PointerEvent) => {
|
|
1055
|
-
|
|
1182
|
+
const st = dragStateRef.current;
|
|
1183
|
+
if (!st || up.pointerId !== st.pointerId) return;
|
|
1056
1184
|
dragStateRef.current = null;
|
|
1057
1185
|
document.removeEventListener('pointermove', onMove, true);
|
|
1058
1186
|
document.removeEventListener('pointerup', onUp, true);
|
|
1059
1187
|
document.removeEventListener('pointercancel', onUp, true);
|
|
1188
|
+
// Commit the gesture as ONE record. Skip on zero-movement
|
|
1189
|
+
// (click without drag past threshold or drag back to origin).
|
|
1190
|
+
const final = strokesRef.current;
|
|
1191
|
+
if (strokesShallowEqual(st.snapshot, final)) return;
|
|
1192
|
+
commitStrokes(
|
|
1193
|
+
st.snapshot,
|
|
1194
|
+
final,
|
|
1195
|
+
`move ${st.movedIds.length} stroke${st.movedIds.length === 1 ? '' : 's'}`
|
|
1196
|
+
);
|
|
1060
1197
|
};
|
|
1061
1198
|
document.addEventListener('pointermove', onMove, true);
|
|
1062
1199
|
document.addEventListener('pointerup', onUp, true);
|
|
@@ -1064,13 +1201,15 @@ export function AnnotationsLayer() {
|
|
|
1064
1201
|
return;
|
|
1065
1202
|
}
|
|
1066
1203
|
|
|
1067
|
-
// Empty world — start a drag-select gesture. A
|
|
1068
|
-
//
|
|
1204
|
+
// Empty world — start a drag-select gesture. A bare click without
|
|
1205
|
+
// moving clears annotation selection (post-Wave-3 feedback: click-to-
|
|
1206
|
+
// deselect is back; Esc also still works).
|
|
1069
1207
|
const addToSelection = e.shiftKey;
|
|
1070
1208
|
let moved = false;
|
|
1071
1209
|
const onMove = (mv: PointerEvent) => {
|
|
1072
|
-
|
|
1073
|
-
|
|
1210
|
+
if (!moved && !crossedDragThreshold(startClientX, startClientY, mv.clientX, mv.clientY)) {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1074
1213
|
moved = true;
|
|
1075
1214
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1076
1215
|
setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
|
|
@@ -1080,8 +1219,10 @@ export function AnnotationsLayer() {
|
|
|
1080
1219
|
document.removeEventListener('pointerup', onUp, true);
|
|
1081
1220
|
document.removeEventListener('pointercancel', onUp, true);
|
|
1082
1221
|
if (!moved) {
|
|
1083
|
-
//
|
|
1084
|
-
|
|
1222
|
+
// Click without movement on empty world → clear annotation
|
|
1223
|
+
// selection (post-Wave-3 user feedback). Shift-click preserves
|
|
1224
|
+
// existing selection for additive-mode workflows.
|
|
1225
|
+
if (!addToSelection) annotSel.clear();
|
|
1085
1226
|
return;
|
|
1086
1227
|
}
|
|
1087
1228
|
const final = marqueeRef.current;
|
|
@@ -1100,13 +1241,10 @@ export function AnnotationsLayer() {
|
|
|
1100
1241
|
hits.push(s.id);
|
|
1101
1242
|
}
|
|
1102
1243
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
} else {
|
|
1108
|
-
annotSel.replace(hits);
|
|
1109
|
-
}
|
|
1244
|
+
// Marquee that captured no strokes — preserve existing selection.
|
|
1245
|
+
if (hits.length === 0) return;
|
|
1246
|
+
if (addToSelection) annotSel.add(hits);
|
|
1247
|
+
else annotSel.replace(hits);
|
|
1110
1248
|
};
|
|
1111
1249
|
document.addEventListener('pointermove', onMove, true);
|
|
1112
1250
|
document.addEventListener('pointerup', onUp, true);
|
|
@@ -1146,33 +1284,36 @@ export function AnnotationsLayer() {
|
|
|
1146
1284
|
const commitText = useCallback(
|
|
1147
1285
|
(anchorId: string, text: string) => {
|
|
1148
1286
|
const trimmed = text.trim();
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1287
|
+
const prev = strokesRef.current;
|
|
1288
|
+
const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
|
|
1289
|
+
| TextStroke
|
|
1290
|
+
| undefined;
|
|
1291
|
+
let next: Stroke[];
|
|
1292
|
+
let label = 'edit text';
|
|
1293
|
+
if (trimmed.length === 0) {
|
|
1294
|
+
if (!existing) return; // nothing to do
|
|
1295
|
+
next = prev.filter((s) => s.id !== existing.id);
|
|
1296
|
+
label = 'delete text';
|
|
1297
|
+
} else if (existing) {
|
|
1298
|
+
if (existing.text === trimmed) return; // identity edit
|
|
1299
|
+
next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
|
|
1300
|
+
} else {
|
|
1301
|
+
next = [
|
|
1302
|
+
...prev,
|
|
1303
|
+
{
|
|
1304
|
+
id: rid(),
|
|
1305
|
+
tool: 'text',
|
|
1306
|
+
color: '#1a1a1a',
|
|
1307
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
1308
|
+
text: trimmed,
|
|
1309
|
+
anchorId,
|
|
1310
|
+
} as TextStroke,
|
|
1311
|
+
];
|
|
1312
|
+
label = 'add text';
|
|
1313
|
+
}
|
|
1314
|
+
commitStrokes(prev, next, label);
|
|
1174
1315
|
},
|
|
1175
|
-
[
|
|
1316
|
+
[commitStrokes]
|
|
1176
1317
|
);
|
|
1177
1318
|
|
|
1178
1319
|
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
@@ -1257,6 +1398,7 @@ export function AnnotationsLayer() {
|
|
|
1257
1398
|
/>
|
|
1258
1399
|
) : null}
|
|
1259
1400
|
<AnnotationContextToolbar />
|
|
1401
|
+
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
1260
1402
|
{isActive ? (
|
|
1261
1403
|
<AnnotationsChrome
|
|
1262
1404
|
color={color}
|
|
@@ -1382,8 +1524,14 @@ function AnnotationsSvg({
|
|
|
1382
1524
|
<StrokeNode key={s.id} stroke={s} anchorsById={anchorsById} interactive={selectMode} />
|
|
1383
1525
|
))}
|
|
1384
1526
|
{selectedStrokes.map((s) => (
|
|
1385
|
-
<SelectionHalo
|
|
1527
|
+
<SelectionHalo
|
|
1528
|
+
key={`halo-${s.id}`}
|
|
1529
|
+
stroke={s}
|
|
1530
|
+
anchorsById={anchorsById}
|
|
1531
|
+
multi={selectedStrokes.length > 1}
|
|
1532
|
+
/>
|
|
1386
1533
|
))}
|
|
1534
|
+
<AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
|
|
1387
1535
|
{marquee ? (
|
|
1388
1536
|
<rect
|
|
1389
1537
|
className="dc-annot-marquee"
|
|
@@ -1507,12 +1655,26 @@ function TextEditor({
|
|
|
1507
1655
|
function SelectionHalo({
|
|
1508
1656
|
stroke,
|
|
1509
1657
|
anchorsById,
|
|
1658
|
+
multi,
|
|
1510
1659
|
}: {
|
|
1511
1660
|
stroke: Stroke;
|
|
1512
1661
|
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1662
|
+
multi: boolean;
|
|
1513
1663
|
}) {
|
|
1514
1664
|
const bbox = strokeBBox(stroke, anchorsById);
|
|
1515
1665
|
if (!bbox) return null;
|
|
1666
|
+
// T17 + post-Wave-2 fix — annotation halo idioms:
|
|
1667
|
+
// * Single select → 2 px solid border, NO ring, NO corner ticks.
|
|
1668
|
+
// The resize overlay (T23) renders the corner handles in screen-space,
|
|
1669
|
+
// so painting SVG ticks here too would duplicate them. The element
|
|
1670
|
+
// halo uses CSS box-shadow for the 18% ring; the SVG equivalent (a
|
|
1671
|
+
// second outline rect) reads as "double frame" rather than a halo —
|
|
1672
|
+
// user feedback flagged this immediately. Solid 2 px is enough signal
|
|
1673
|
+
// once the resize handles claim the corners.
|
|
1674
|
+
// * Multi member → 1.5 px solid full accent, no ring, no ticks (group
|
|
1675
|
+
// bbox above carries the container affordance).
|
|
1676
|
+
// Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
|
|
1677
|
+
// ambient group-container + active-gesture idioms per DDR-046 rev 2.
|
|
1516
1678
|
const pad = 4;
|
|
1517
1679
|
return (
|
|
1518
1680
|
<rect
|
|
@@ -1522,8 +1684,7 @@ function SelectionHalo({
|
|
|
1522
1684
|
height={bbox.h + pad * 2}
|
|
1523
1685
|
fill="none"
|
|
1524
1686
|
stroke="var(--accent, #d63b1f)"
|
|
1525
|
-
strokeWidth={1.5}
|
|
1526
|
-
strokeDasharray="4 3"
|
|
1687
|
+
strokeWidth={multi ? 1.5 : 2}
|
|
1527
1688
|
vectorEffect="non-scaling-stroke"
|
|
1528
1689
|
pointerEvents="none"
|
|
1529
1690
|
rx={2}
|
|
@@ -1531,6 +1692,76 @@ function SelectionHalo({
|
|
|
1531
1692
|
);
|
|
1532
1693
|
}
|
|
1533
1694
|
|
|
1695
|
+
// T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
|
|
1696
|
+
// the element-side GroupBbox idiom (1 px dashed accent + 6 × 6 corner handles).
|
|
1697
|
+
function AnnotGroupBbox({
|
|
1698
|
+
selectedStrokes,
|
|
1699
|
+
anchorsById,
|
|
1700
|
+
}: {
|
|
1701
|
+
selectedStrokes: readonly Stroke[];
|
|
1702
|
+
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1703
|
+
}) {
|
|
1704
|
+
if (selectedStrokes.length < 2) return null;
|
|
1705
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
1706
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
1707
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
1708
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
1709
|
+
let any = false;
|
|
1710
|
+
for (const s of selectedStrokes) {
|
|
1711
|
+
const b = strokeBBox(s, anchorsById);
|
|
1712
|
+
if (!b) continue;
|
|
1713
|
+
any = true;
|
|
1714
|
+
if (b.x < xMin) xMin = b.x;
|
|
1715
|
+
if (b.y < yMin) yMin = b.y;
|
|
1716
|
+
if (b.x + b.w > xMax) xMax = b.x + b.w;
|
|
1717
|
+
if (b.y + b.h > yMax) yMax = b.y + b.h;
|
|
1718
|
+
}
|
|
1719
|
+
if (!any) return null;
|
|
1720
|
+
const pad = 6;
|
|
1721
|
+
const x = xMin - pad;
|
|
1722
|
+
const y = yMin - pad;
|
|
1723
|
+
const w = xMax - xMin + pad * 2;
|
|
1724
|
+
const h = yMax - yMin + pad * 2;
|
|
1725
|
+
const handle = 6;
|
|
1726
|
+
const inset = 3;
|
|
1727
|
+
const handles = [
|
|
1728
|
+
{ corner: 'nw', x: x - inset, y: y - inset },
|
|
1729
|
+
{ corner: 'ne', x: x + w - handle + inset, y: y - inset },
|
|
1730
|
+
{ corner: 'sw', x: x - inset, y: y + h - handle + inset },
|
|
1731
|
+
{ corner: 'se', x: x + w - handle + inset, y: y + h - handle + inset },
|
|
1732
|
+
];
|
|
1733
|
+
return (
|
|
1734
|
+
<g pointerEvents="none">
|
|
1735
|
+
<rect
|
|
1736
|
+
x={x}
|
|
1737
|
+
y={y}
|
|
1738
|
+
width={w}
|
|
1739
|
+
height={h}
|
|
1740
|
+
fill="none"
|
|
1741
|
+
stroke="var(--accent, #d63b1f)"
|
|
1742
|
+
strokeWidth={1}
|
|
1743
|
+
strokeDasharray="4 3"
|
|
1744
|
+
vectorEffect="non-scaling-stroke"
|
|
1745
|
+
rx={2}
|
|
1746
|
+
/>
|
|
1747
|
+
{handles.map((c) => (
|
|
1748
|
+
<rect
|
|
1749
|
+
key={c.corner}
|
|
1750
|
+
x={c.x}
|
|
1751
|
+
y={c.y}
|
|
1752
|
+
width={handle}
|
|
1753
|
+
height={handle}
|
|
1754
|
+
fill="var(--accent, #d63b1f)"
|
|
1755
|
+
stroke="var(--bg-0, #ffffff)"
|
|
1756
|
+
strokeWidth={1}
|
|
1757
|
+
vectorEffect="non-scaling-stroke"
|
|
1758
|
+
rx={1}
|
|
1759
|
+
/>
|
|
1760
|
+
))}
|
|
1761
|
+
</g>
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1534
1765
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1535
1766
|
// Stroke renderer
|
|
1536
1767
|
|
|
@@ -1694,7 +1925,7 @@ function AnnotationsChrome({
|
|
|
1694
1925
|
type="button"
|
|
1695
1926
|
className="dc-annot-btn"
|
|
1696
1927
|
aria-pressed={thickness === STROKE_WIDTH_THIN}
|
|
1697
|
-
title="Thin (
|
|
1928
|
+
title="Thin (3px)"
|
|
1698
1929
|
onClick={() => setThickness(STROKE_WIDTH_THIN)}
|
|
1699
1930
|
>
|
|
1700
1931
|
Thin
|