@1agh/maude 0.19.1 → 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 +112 -38
- package/plugins/design/dev-server/annotations-layer.tsx +204 -95
- package/plugins/design/dev-server/artboard-marquee.tsx +8 -4
- 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 +506 -30
- package/plugins/design/dev-server/canvas-shell.tsx +352 -20
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- 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/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/dist/client.bundle.js +3 -3
- 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/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/http.ts +27 -0
- package/plugins/design/dev-server/input-router.tsx +52 -2
- 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/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/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/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/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -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-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,11 +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';
|
|
38
40
|
import { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
39
41
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
40
42
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
41
43
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
42
44
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
45
|
+
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
43
46
|
|
|
44
47
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
48
|
// Types
|
|
@@ -115,7 +118,7 @@ const FILL_PALETTE = [
|
|
|
115
118
|
'#f4f1ee', // paper
|
|
116
119
|
] as const;
|
|
117
120
|
|
|
118
|
-
const STROKE_WIDTH_THIN =
|
|
121
|
+
const STROKE_WIDTH_THIN = 3;
|
|
119
122
|
const STROKE_WIDTH_THICK = 6;
|
|
120
123
|
type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
121
124
|
|
|
@@ -609,6 +612,23 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
|
609
612
|
return s; // text inherits its host's bbox
|
|
610
613
|
}
|
|
611
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
|
+
|
|
612
632
|
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
613
633
|
// ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
|
|
614
634
|
// can read the same state. Re-exported here for back-compat.
|
|
@@ -650,6 +670,13 @@ export function AnnotationsLayer() {
|
|
|
650
670
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
651
671
|
const drawingRef = useRef<Stroke | null>(null);
|
|
652
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;
|
|
653
680
|
|
|
654
681
|
const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
|
|
655
682
|
const isErase = tool === 'eraser';
|
|
@@ -684,56 +711,100 @@ export function AnnotationsLayer() {
|
|
|
684
711
|
};
|
|
685
712
|
}, []);
|
|
686
713
|
|
|
687
|
-
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[]);
|
|
688
734
|
const file = fileRef.current;
|
|
689
|
-
if (!file) return;
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
/* 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 */
|
|
700
745
|
});
|
|
701
|
-
}, 200);
|
|
702
746
|
}, []);
|
|
703
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
|
+
|
|
704
780
|
const setStrokes = useCallback(
|
|
705
781
|
(next: Stroke[]) => {
|
|
706
|
-
|
|
707
|
-
|
|
782
|
+
const prev = strokesRef.current;
|
|
783
|
+
commitStrokes(prev, next);
|
|
708
784
|
},
|
|
709
|
-
[
|
|
785
|
+
[commitStrokes]
|
|
710
786
|
);
|
|
711
787
|
|
|
712
788
|
const strokesStore = useMemo<StrokesStoreValue>(() => {
|
|
713
789
|
const updateStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
return next;
|
|
718
|
-
});
|
|
790
|
+
const prev = strokesRef.current;
|
|
791
|
+
const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
|
|
792
|
+
commitStrokes(prev, next);
|
|
719
793
|
};
|
|
720
794
|
const deleteStrokes = (ids: string[]): void => {
|
|
721
795
|
const set = new Set(ids);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
});
|
|
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);
|
|
729
802
|
};
|
|
730
803
|
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
731
804
|
const set = new Set(ids);
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
return next;
|
|
736
|
-
});
|
|
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'}`);
|
|
737
808
|
};
|
|
738
809
|
return {
|
|
739
810
|
strokes,
|
|
@@ -742,7 +813,7 @@ export function AnnotationsLayer() {
|
|
|
742
813
|
deleteStrokes,
|
|
743
814
|
translateStrokes,
|
|
744
815
|
};
|
|
745
|
-
}, [strokes, setStrokes,
|
|
816
|
+
}, [strokes, setStrokes, commitStrokes]);
|
|
746
817
|
|
|
747
818
|
// Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
|
|
748
819
|
// dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
|
|
@@ -794,23 +865,21 @@ export function AnnotationsLayer() {
|
|
|
794
865
|
(wx: number, wy: number) => {
|
|
795
866
|
const zoom = vp?.zoom || 1;
|
|
796
867
|
const tol = 8 / zoom;
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
}
|
|
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;
|
|
809
879
|
}
|
|
810
|
-
|
|
811
|
-
});
|
|
880
|
+
}
|
|
812
881
|
},
|
|
813
|
-
[vp,
|
|
882
|
+
[vp, commitStrokes]
|
|
814
883
|
);
|
|
815
884
|
|
|
816
885
|
const beginStroke = useCallback(
|
|
@@ -935,11 +1004,9 @@ export function AnnotationsLayer() {
|
|
|
935
1004
|
if (final && !isStrokeMeaningful(final)) final = null;
|
|
936
1005
|
if (final) {
|
|
937
1006
|
const committed = final;
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
return next;
|
|
942
|
-
});
|
|
1007
|
+
const prev = strokesRef.current;
|
|
1008
|
+
const next = [...prev, committed];
|
|
1009
|
+
commitStrokes(prev, next, `draw ${committed.tool}`);
|
|
943
1010
|
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
944
1011
|
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
945
1012
|
// AnnotationsLayer without the provider), so guard the call.
|
|
@@ -955,7 +1022,7 @@ export function AnnotationsLayer() {
|
|
|
955
1022
|
if (!stickyOnThis) setTool('move');
|
|
956
1023
|
}
|
|
957
1024
|
setDrawing(null);
|
|
958
|
-
}, [isActive, isErase, visible,
|
|
1025
|
+
}, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
|
|
959
1026
|
|
|
960
1027
|
// T21 — abort a mid-stroke draw without committing. Dispatched by the
|
|
961
1028
|
// canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
|
|
@@ -1000,11 +1067,19 @@ export function AnnotationsLayer() {
|
|
|
1000
1067
|
// - Cmd / Cmd+Shift falls through to element-selection (we bail).
|
|
1001
1068
|
// Once a stroke is selected, clicking inside its bbox starts a drag.
|
|
1002
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
|
+
*/
|
|
1003
1077
|
const dragStateRef = useRef<{
|
|
1004
1078
|
pointerId: number;
|
|
1005
1079
|
startWX: number;
|
|
1006
1080
|
startWY: number;
|
|
1007
1081
|
movedIds: string[];
|
|
1082
|
+
snapshot: Stroke[];
|
|
1008
1083
|
} | null>(null);
|
|
1009
1084
|
|
|
1010
1085
|
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
@@ -1016,6 +1091,7 @@ export function AnnotationsLayer() {
|
|
|
1016
1091
|
by: number;
|
|
1017
1092
|
} | null>(null);
|
|
1018
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
|
|
1019
1095
|
useEffect(() => {
|
|
1020
1096
|
if (typeof document === 'undefined') return;
|
|
1021
1097
|
if (tool !== 'move') return;
|
|
@@ -1039,7 +1115,7 @@ export function AnnotationsLayer() {
|
|
|
1039
1115
|
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
1040
1116
|
// right-click menu. Clicks on these route to their own handlers.
|
|
1041
1117
|
const CHROME_SELECTOR =
|
|
1042
|
-
'.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';
|
|
1043
1119
|
|
|
1044
1120
|
const onDown = (e: PointerEvent) => {
|
|
1045
1121
|
if (e.button !== 0) return;
|
|
@@ -1047,6 +1123,11 @@ export function AnnotationsLayer() {
|
|
|
1047
1123
|
const target = e.target as Element | null;
|
|
1048
1124
|
if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
|
|
1049
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;
|
|
1050
1131
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1051
1132
|
const startClientX = e.clientX;
|
|
1052
1133
|
const startClientY = e.clientY;
|
|
@@ -1068,29 +1149,51 @@ export function AnnotationsLayer() {
|
|
|
1068
1149
|
annotSel.replace(strokeId);
|
|
1069
1150
|
ids = [strokeId];
|
|
1070
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();
|
|
1071
1158
|
dragStateRef.current = {
|
|
1072
1159
|
pointerId: e.pointerId,
|
|
1073
1160
|
startWX: wx,
|
|
1074
1161
|
startWY: wy,
|
|
1075
1162
|
movedIds: ids,
|
|
1163
|
+
snapshot: dragSnapshot,
|
|
1076
1164
|
};
|
|
1165
|
+
const movedSet = new Set(ids);
|
|
1077
1166
|
const onMove = (mv: PointerEvent) => {
|
|
1078
1167
|
const st = dragStateRef.current;
|
|
1079
1168
|
if (!st || mv.pointerId !== st.pointerId) return;
|
|
1080
1169
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1081
1170
|
const dx = cwx - st.startWX;
|
|
1082
1171
|
const dy = cwy - st.startWY;
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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);
|
|
1087
1180
|
};
|
|
1088
1181
|
const onUp = (up: PointerEvent) => {
|
|
1089
|
-
|
|
1182
|
+
const st = dragStateRef.current;
|
|
1183
|
+
if (!st || up.pointerId !== st.pointerId) return;
|
|
1090
1184
|
dragStateRef.current = null;
|
|
1091
1185
|
document.removeEventListener('pointermove', onMove, true);
|
|
1092
1186
|
document.removeEventListener('pointerup', onUp, true);
|
|
1093
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
|
+
);
|
|
1094
1197
|
};
|
|
1095
1198
|
document.addEventListener('pointermove', onMove, true);
|
|
1096
1199
|
document.addEventListener('pointerup', onUp, true);
|
|
@@ -1099,13 +1202,14 @@ export function AnnotationsLayer() {
|
|
|
1099
1202
|
}
|
|
1100
1203
|
|
|
1101
1204
|
// Empty world — start a drag-select gesture. A bare click without
|
|
1102
|
-
// moving
|
|
1103
|
-
//
|
|
1205
|
+
// moving clears annotation selection (post-Wave-3 feedback: click-to-
|
|
1206
|
+
// deselect is back; Esc also still works).
|
|
1104
1207
|
const addToSelection = e.shiftKey;
|
|
1105
1208
|
let moved = false;
|
|
1106
1209
|
const onMove = (mv: PointerEvent) => {
|
|
1107
|
-
|
|
1108
|
-
|
|
1210
|
+
if (!moved && !crossedDragThreshold(startClientX, startClientY, mv.clientX, mv.clientY)) {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1109
1213
|
moved = true;
|
|
1110
1214
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1111
1215
|
setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
|
|
@@ -1115,8 +1219,10 @@ export function AnnotationsLayer() {
|
|
|
1115
1219
|
document.removeEventListener('pointerup', onUp, true);
|
|
1116
1220
|
document.removeEventListener('pointercancel', onUp, true);
|
|
1117
1221
|
if (!moved) {
|
|
1118
|
-
// Click without movement on empty world →
|
|
1119
|
-
//
|
|
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();
|
|
1120
1226
|
return;
|
|
1121
1227
|
}
|
|
1122
1228
|
const final = marqueeRef.current;
|
|
@@ -1178,33 +1284,36 @@ export function AnnotationsLayer() {
|
|
|
1178
1284
|
const commitText = useCallback(
|
|
1179
1285
|
(anchorId: string, text: string) => {
|
|
1180
1286
|
const trimmed = text.trim();
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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);
|
|
1206
1315
|
},
|
|
1207
|
-
[
|
|
1316
|
+
[commitStrokes]
|
|
1208
1317
|
);
|
|
1209
1318
|
|
|
1210
1319
|
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
@@ -1816,7 +1925,7 @@ function AnnotationsChrome({
|
|
|
1816
1925
|
type="button"
|
|
1817
1926
|
className="dc-annot-btn"
|
|
1818
1927
|
aria-pressed={thickness === STROKE_WIDTH_THIN}
|
|
1819
|
-
title="Thin (
|
|
1928
|
+
title="Thin (3px)"
|
|
1820
1929
|
onClick={() => setThickness(STROKE_WIDTH_THIN)}
|
|
1821
1930
|
>
|
|
1822
1931
|
Thin
|
|
@@ -18,11 +18,10 @@
|
|
|
18
18
|
import { useEffect, useRef } from 'react';
|
|
19
19
|
|
|
20
20
|
import { useArtboardsContext } from './canvas-lib.tsx';
|
|
21
|
+
import { DRAG_THRESHOLD_PX } from './input-router.tsx';
|
|
21
22
|
import { type Selection, useSelectionSet } from './use-selection-set.tsx';
|
|
22
23
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
23
24
|
|
|
24
|
-
const DRAG_THRESHOLD_PX = 4;
|
|
25
|
-
|
|
26
25
|
const MARQUEE_CSS = `
|
|
27
26
|
.dc-cv-marquee {
|
|
28
27
|
position: fixed;
|
|
@@ -52,7 +51,7 @@ function shouldIgnoreTarget(t: EventTarget | null): boolean {
|
|
|
52
51
|
// Skip floating chrome and existing overlays (their click handlers run).
|
|
53
52
|
if (
|
|
54
53
|
el.closest(
|
|
55
|
-
'.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle'
|
|
54
|
+
'.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
|
|
56
55
|
)
|
|
57
56
|
) {
|
|
58
57
|
return true;
|
|
@@ -125,7 +124,12 @@ export function ArtboardMarqueeOverlay() {
|
|
|
125
124
|
stateRef.current = null;
|
|
126
125
|
const div = overlayRef.current;
|
|
127
126
|
if (div) div.style.display = 'none';
|
|
128
|
-
if (!s.crossed)
|
|
127
|
+
if (!s.crossed) {
|
|
128
|
+
// Bare click on empty world without drag → clear selection
|
|
129
|
+
// (post-Wave-3 user feedback). Shift-click preserves it.
|
|
130
|
+
if (!s.shift) selSet.clear();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
129
133
|
if (!artboardsCtx) return;
|
|
130
134
|
|
|
131
135
|
// Intersect the marquee AABB with each artboard's screen-coord bbox.
|