@1agh/maude 0.19.1 → 0.21.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 +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +17 -9
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
- package/plugins/design/dev-server/annotations-layer.tsx +256 -107
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +534 -32
- package/plugins/design/dev-server/canvas-shell.tsx +479 -29
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -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 +41 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -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/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -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/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +91 -1
- 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/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +15 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -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/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -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/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -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 +7 -7
- 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 +1 -1
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- 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/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +51 -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/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- package/plugins/flow/dependencies.schema.json +107 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -35,11 +35,15 @@ 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';
|
|
43
|
+
import { useCollab } from './use-collab.tsx';
|
|
41
44
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
42
45
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
46
|
+
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
43
47
|
|
|
44
48
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
45
49
|
// Types
|
|
@@ -115,7 +119,7 @@ const FILL_PALETTE = [
|
|
|
115
119
|
'#f4f1ee', // paper
|
|
116
120
|
] as const;
|
|
117
121
|
|
|
118
|
-
const STROKE_WIDTH_THIN =
|
|
122
|
+
const STROKE_WIDTH_THIN = 3;
|
|
119
123
|
const STROKE_WIDTH_THICK = 6;
|
|
120
124
|
type Thickness = typeof STROKE_WIDTH_THIN | typeof STROKE_WIDTH_THICK;
|
|
121
125
|
|
|
@@ -480,11 +484,11 @@ const ANNOT_CSS = `
|
|
|
480
484
|
appearance: none;
|
|
481
485
|
}
|
|
482
486
|
.dc-annot-chrome .dc-annot-sw[aria-pressed="true"] {
|
|
483
|
-
box-shadow: 0 0 0 2px var(--accent, #d63b1f);
|
|
487
|
+
box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
|
|
484
488
|
border-color: transparent;
|
|
485
489
|
}
|
|
486
490
|
.dc-annot-chrome .dc-annot-sw:focus-visible {
|
|
487
|
-
outline: 2px solid var(--accent, #d63b1f);
|
|
491
|
+
outline: 2px solid var(--maude-hud-accent, #d63b1f);
|
|
488
492
|
outline-offset: 2px;
|
|
489
493
|
}
|
|
490
494
|
.dc-annot-chrome .dc-annot-sep {
|
|
@@ -513,7 +517,7 @@ const ANNOT_CSS = `
|
|
|
513
517
|
linear-gradient(135deg, transparent 47%, #d63b1f 47%, #d63b1f 53%, transparent 53%);
|
|
514
518
|
}
|
|
515
519
|
.dc-annot-chrome .dc-annot-fill[aria-pressed="true"] {
|
|
516
|
-
box-shadow: 0 0 0 2px var(--accent, #d63b1f);
|
|
520
|
+
box-shadow: 0 0 0 2px var(--maude-hud-accent, #d63b1f);
|
|
517
521
|
border-color: transparent;
|
|
518
522
|
}
|
|
519
523
|
.dc-annot-chrome .dc-annot-btn {
|
|
@@ -529,13 +533,13 @@ const ANNOT_CSS = `
|
|
|
529
533
|
text-transform: uppercase;
|
|
530
534
|
}
|
|
531
535
|
.dc-annot-chrome .dc-annot-btn[aria-pressed="true"] {
|
|
532
|
-
background: var(--accent, #d63b1f);
|
|
533
|
-
color: var(--accent-fg, #fff);
|
|
536
|
+
background: var(--maude-hud-accent, #d63b1f);
|
|
537
|
+
color: var(--maude-hud-accent-fg, #fff);
|
|
534
538
|
border-color: transparent;
|
|
535
539
|
}
|
|
536
540
|
.dc-annot-chrome .dc-annot-btn:hover { background: rgba(0,0,0,0.04); }
|
|
537
541
|
.dc-annot-chrome .dc-annot-btn:focus-visible {
|
|
538
|
-
outline: 2px solid var(--accent, #d63b1f);
|
|
542
|
+
outline: 2px solid var(--maude-hud-accent, #d63b1f);
|
|
539
543
|
outline-offset: 2px;
|
|
540
544
|
}
|
|
541
545
|
.dc-annot-input {
|
|
@@ -564,8 +568,8 @@ const ANNOT_CSS = `
|
|
|
564
568
|
/* Drag-select marquee — rendered while user is dragging to select strokes. */
|
|
565
569
|
.dc-annot-marquee {
|
|
566
570
|
pointer-events: none;
|
|
567
|
-
fill: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
|
|
568
|
-
stroke: var(--accent, #d63b1f);
|
|
571
|
+
fill: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
|
|
572
|
+
stroke: var(--maude-hud-accent, #d63b1f);
|
|
569
573
|
stroke-width: 1;
|
|
570
574
|
stroke-dasharray: 4 3;
|
|
571
575
|
}
|
|
@@ -609,6 +613,23 @@ function translateOne(s: Stroke, dx: number, dy: number): Stroke {
|
|
|
609
613
|
return s; // text inherits its host's bbox
|
|
610
614
|
}
|
|
611
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Reference-equal stroke comparison — true when the two arrays carry the same
|
|
618
|
+
* stroke object references in the same order. Used by the annotation drag
|
|
619
|
+
* onPointerUp to skip pushing an undo record when the gesture didn't actually
|
|
620
|
+
* move anything (zero movement OR snapshot mapped through a no-op translate
|
|
621
|
+
* back to the original references — `translateOne` short-circuits when dx=dy=0
|
|
622
|
+
* because new objects are still created, so we compare references defensively).
|
|
623
|
+
*/
|
|
624
|
+
export function strokesShallowEqual(a: readonly Stroke[], b: readonly Stroke[]): boolean {
|
|
625
|
+
if (a === b) return true;
|
|
626
|
+
if (a.length !== b.length) return false;
|
|
627
|
+
for (let i = 0; i < a.length; i++) {
|
|
628
|
+
if (a[i] !== b[i]) return false;
|
|
629
|
+
}
|
|
630
|
+
return true;
|
|
631
|
+
}
|
|
632
|
+
|
|
612
633
|
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
613
634
|
// ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
|
|
614
635
|
// can read the same state. Re-exported here for back-compat.
|
|
@@ -650,6 +671,13 @@ export function AnnotationsLayer() {
|
|
|
650
671
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
651
672
|
const drawingRef = useRef<Stroke | null>(null);
|
|
652
673
|
drawingRef.current = drawing;
|
|
674
|
+
/**
|
|
675
|
+
* Phase 20 — latest strokes mirror so command builders can read the
|
|
676
|
+
* pre-mutation snapshot synchronously (React state isn't refreshed
|
|
677
|
+
* between rapid taps in the same tick).
|
|
678
|
+
*/
|
|
679
|
+
const strokesRef = useRef<Stroke[]>(strokes);
|
|
680
|
+
strokesRef.current = strokes;
|
|
653
681
|
|
|
654
682
|
const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
|
|
655
683
|
const isErase = tool === 'eraser';
|
|
@@ -662,6 +690,10 @@ export function AnnotationsLayer() {
|
|
|
662
690
|
const supportsFill = tool === 'rect' || tool === 'ellipse';
|
|
663
691
|
|
|
664
692
|
// Load existing annotations on mount.
|
|
693
|
+
// Phase 8 Task 5 — seed lastAppliedSvgRef so the first Y.Map observe (when
|
|
694
|
+
// collab connects shortly after this fetch lands) doesn't re-apply the
|
|
695
|
+
// same content we just hydrated from REST.
|
|
696
|
+
const lastAppliedSvgRef = useRef<string>('');
|
|
665
697
|
useEffect(() => {
|
|
666
698
|
const file = deriveFile();
|
|
667
699
|
fileRef.current = file;
|
|
@@ -674,7 +706,10 @@ export function AnnotationsLayer() {
|
|
|
674
706
|
.then((text) => {
|
|
675
707
|
if (cancelled) return;
|
|
676
708
|
const loaded = svgToStrokes(text);
|
|
677
|
-
if (loaded.length)
|
|
709
|
+
if (loaded.length) {
|
|
710
|
+
setStrokesState(loaded);
|
|
711
|
+
lastAppliedSvgRef.current = text;
|
|
712
|
+
}
|
|
678
713
|
})
|
|
679
714
|
.catch(() => {
|
|
680
715
|
/* network blip — start with an empty annotation set */
|
|
@@ -684,56 +719,132 @@ export function AnnotationsLayer() {
|
|
|
684
719
|
};
|
|
685
720
|
}, []);
|
|
686
721
|
|
|
687
|
-
|
|
722
|
+
// Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
|
|
723
|
+
// other tabs. Bail when the incoming SVG STRING is identical to the one
|
|
724
|
+
// we last applied (covers the local echo round-trip without missing real
|
|
725
|
+
// foreign changes). The prior length+first/last-id check was wrong: a
|
|
726
|
+
// resize / move keeps the same id list, so all three predicates matched
|
|
727
|
+
// even though geometry changed — foreign edits silently disappeared.
|
|
728
|
+
const collab = useCollab();
|
|
729
|
+
useEffect(() => {
|
|
730
|
+
if (!collab) return;
|
|
731
|
+
const map = collab.doc.getMap<string>('annotations');
|
|
732
|
+
const apply = () => {
|
|
733
|
+
const svg = map.get('svg');
|
|
734
|
+
if (typeof svg !== 'string' || !svg) return;
|
|
735
|
+
if (svg === lastAppliedSvgRef.current) return;
|
|
736
|
+
lastAppliedSvgRef.current = svg;
|
|
737
|
+
setStrokesState(svgToStrokes(svg));
|
|
738
|
+
};
|
|
739
|
+
apply();
|
|
740
|
+
map.observe(apply);
|
|
741
|
+
return () => {
|
|
742
|
+
try {
|
|
743
|
+
map.unobserve(apply);
|
|
744
|
+
} catch {
|
|
745
|
+
/* doc destroyed before unmount */
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}, [collab]);
|
|
749
|
+
|
|
750
|
+
const undoStack = useUndoStackOptional();
|
|
751
|
+
const undoSinks = useUndoSinks();
|
|
752
|
+
const undoStackRef = useRef(undoStack);
|
|
753
|
+
undoStackRef.current = undoStack;
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
|
|
757
|
+
* PUT to the server. Used as the `putFn` injected into the
|
|
758
|
+
* `AnnotationStrokesCommand` — both the initial push AND every undo/redo
|
|
759
|
+
* replay route through here, so the iframe's `strokes` state always
|
|
760
|
+
* tracks the server. (Without the setStrokesState here, Cmd+Z would
|
|
761
|
+
* silently PUT the prior SVG but the canvas would keep painting the
|
|
762
|
+
* post-edit strokes until the user reloaded.)
|
|
763
|
+
*
|
|
764
|
+
* The 200 ms scheduled-save debounce (legacy path) is cleared the moment
|
|
765
|
+
* we push a command, so the server only sees one PUT per edit instead
|
|
766
|
+
* of two-step racing.
|
|
767
|
+
*/
|
|
768
|
+
const putStrokes = useCallback((next: readonly Stroke[]) => {
|
|
769
|
+
setStrokesState(next as Stroke[]);
|
|
688
770
|
const file = fileRef.current;
|
|
689
|
-
if (!file) return;
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
771
|
+
if (!file) return Promise.resolve();
|
|
772
|
+
const svg = strokesToSvg(next);
|
|
773
|
+
// Phase 8 Task 5 — record the SVG we just authored locally so the
|
|
774
|
+
// server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
|
|
775
|
+
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
776
|
+
lastAppliedSvgRef.current = svg;
|
|
777
|
+
return fetch('/_api/annotations', {
|
|
778
|
+
method: 'PUT',
|
|
779
|
+
headers: { 'Content-Type': 'application/json' },
|
|
780
|
+
body: JSON.stringify({ file, svg }),
|
|
781
|
+
})
|
|
782
|
+
.then(() => undefined)
|
|
783
|
+
.catch(() => {
|
|
784
|
+
/* swallow — user sees uncommitted state until the next stroke */
|
|
700
785
|
});
|
|
701
|
-
}, 200);
|
|
702
786
|
}, []);
|
|
703
787
|
|
|
788
|
+
// Register the strokes put sink with the undo provider so the rebuilt
|
|
789
|
+
// AnnotationStrokesCommand (after a canvas switch + return) routes through
|
|
790
|
+
// THIS iframe's React state, not the gone iframe's stale closures.
|
|
791
|
+
useEffect(() => {
|
|
792
|
+
undoSinks.setSink('strokesPutFn', putStrokes);
|
|
793
|
+
return () => undoSinks.setSink('strokesPutFn', undefined);
|
|
794
|
+
}, [undoSinks, putStrokes]);
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Single entry point for every stroke mutation. Builds an undo record
|
|
798
|
+
* and pushes onto the stack — `push()` rebuilds the command via the
|
|
799
|
+
* registered `strokesPutFn` sink and calls `cmd.do() = putStrokes(next)`,
|
|
800
|
+
* which both updates local state and PUTs. Cancels any pending debounced
|
|
801
|
+
* save first — DDR-050 gotcha: a queued auto-save flushing AFTER our PUT
|
|
802
|
+
* would race the stack into a stale state.
|
|
803
|
+
*/
|
|
804
|
+
const commitStrokes = useCallback(
|
|
805
|
+
(prev: readonly Stroke[], next: readonly Stroke[], label?: string) => {
|
|
806
|
+
if (saveTimerRef.current) {
|
|
807
|
+
clearTimeout(saveTimerRef.current);
|
|
808
|
+
saveTimerRef.current = null;
|
|
809
|
+
}
|
|
810
|
+
const record = buildAnnotationStrokesRecord({
|
|
811
|
+
before: prev,
|
|
812
|
+
after: next,
|
|
813
|
+
...(label ? { label } : {}),
|
|
814
|
+
});
|
|
815
|
+
void undoStackRef.current.push(record);
|
|
816
|
+
},
|
|
817
|
+
[]
|
|
818
|
+
);
|
|
819
|
+
|
|
704
820
|
const setStrokes = useCallback(
|
|
705
821
|
(next: Stroke[]) => {
|
|
706
|
-
|
|
707
|
-
|
|
822
|
+
const prev = strokesRef.current;
|
|
823
|
+
commitStrokes(prev, next);
|
|
708
824
|
},
|
|
709
|
-
[
|
|
825
|
+
[commitStrokes]
|
|
710
826
|
);
|
|
711
827
|
|
|
712
828
|
const strokesStore = useMemo<StrokesStoreValue>(() => {
|
|
713
829
|
const updateStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
return next;
|
|
718
|
-
});
|
|
830
|
+
const prev = strokesRef.current;
|
|
831
|
+
const next = prev.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s));
|
|
832
|
+
commitStrokes(prev, next);
|
|
719
833
|
};
|
|
720
834
|
const deleteStrokes = (ids: string[]): void => {
|
|
721
835
|
const set = new Set(ids);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
});
|
|
836
|
+
const prev = strokesRef.current;
|
|
837
|
+
const next = prev.filter(
|
|
838
|
+
(s) => !set.has(s.id) && !(s.tool === 'text' && set.has(s.anchorId))
|
|
839
|
+
);
|
|
840
|
+
if (next.length === prev.length) return;
|
|
841
|
+
commitStrokes(prev, next);
|
|
729
842
|
};
|
|
730
843
|
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
731
844
|
const set = new Set(ids);
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
return next;
|
|
736
|
-
});
|
|
845
|
+
const prev = strokesRef.current;
|
|
846
|
+
const next = prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s));
|
|
847
|
+
commitStrokes(prev, next, `move ${ids.length} stroke${ids.length === 1 ? '' : 's'}`);
|
|
737
848
|
};
|
|
738
849
|
return {
|
|
739
850
|
strokes,
|
|
@@ -742,7 +853,7 @@ export function AnnotationsLayer() {
|
|
|
742
853
|
deleteStrokes,
|
|
743
854
|
translateStrokes,
|
|
744
855
|
};
|
|
745
|
-
}, [strokes, setStrokes,
|
|
856
|
+
}, [strokes, setStrokes, commitStrokes]);
|
|
746
857
|
|
|
747
858
|
// Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
|
|
748
859
|
// dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
|
|
@@ -794,23 +905,21 @@ export function AnnotationsLayer() {
|
|
|
794
905
|
(wx: number, wy: number) => {
|
|
795
906
|
const zoom = vp?.zoom || 1;
|
|
796
907
|
const tol = 8 / zoom;
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
}
|
|
908
|
+
const prev = strokesRef.current;
|
|
909
|
+
for (let i = prev.length - 1; i >= 0; i--) {
|
|
910
|
+
const candidate = prev[i];
|
|
911
|
+
if (candidate && strokeHitTest(candidate, wx, wy, tol)) {
|
|
912
|
+
const removedId = candidate.id;
|
|
913
|
+
const next = prev
|
|
914
|
+
.slice(0, i)
|
|
915
|
+
.concat(prev.slice(i + 1))
|
|
916
|
+
.filter((s) => !(s.tool === 'text' && s.anchorId === removedId));
|
|
917
|
+
commitStrokes(prev, next, 'erase 1 stroke');
|
|
918
|
+
return;
|
|
809
919
|
}
|
|
810
|
-
|
|
811
|
-
});
|
|
920
|
+
}
|
|
812
921
|
},
|
|
813
|
-
[vp,
|
|
922
|
+
[vp, commitStrokes]
|
|
814
923
|
);
|
|
815
924
|
|
|
816
925
|
const beginStroke = useCallback(
|
|
@@ -935,11 +1044,9 @@ export function AnnotationsLayer() {
|
|
|
935
1044
|
if (final && !isStrokeMeaningful(final)) final = null;
|
|
936
1045
|
if (final) {
|
|
937
1046
|
const committed = final;
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
return next;
|
|
942
|
-
});
|
|
1047
|
+
const prev = strokesRef.current;
|
|
1048
|
+
const next = [...prev, committed];
|
|
1049
|
+
commitStrokes(prev, next, `draw ${committed.tool}`);
|
|
943
1050
|
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
944
1051
|
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
945
1052
|
// AnnotationsLayer without the provider), so guard the call.
|
|
@@ -955,7 +1062,7 @@ export function AnnotationsLayer() {
|
|
|
955
1062
|
if (!stickyOnThis) setTool('move');
|
|
956
1063
|
}
|
|
957
1064
|
setDrawing(null);
|
|
958
|
-
}, [isActive, isErase, visible,
|
|
1065
|
+
}, [isActive, isErase, visible, commitStrokes, annotSel, setTool, sticky]);
|
|
959
1066
|
|
|
960
1067
|
// T21 — abort a mid-stroke draw without committing. Dispatched by the
|
|
961
1068
|
// canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
|
|
@@ -1000,11 +1107,19 @@ export function AnnotationsLayer() {
|
|
|
1000
1107
|
// - Cmd / Cmd+Shift falls through to element-selection (we bail).
|
|
1001
1108
|
// Once a stroke is selected, clicking inside its bbox starts a drag.
|
|
1002
1109
|
|
|
1110
|
+
/**
|
|
1111
|
+
* Annotation drag state. Snapshot of strokes captured at pointerdown so the
|
|
1112
|
+
* whole gesture (pointermove × N → pointerup) commits as ONE undo record at
|
|
1113
|
+
* release time. Without the snapshot, each pointermove tick became its own
|
|
1114
|
+
* `translateStrokes` call and each call became its own undo record — Cmd+Z
|
|
1115
|
+
* had to be pressed dozens of times to walk back a single drag.
|
|
1116
|
+
*/
|
|
1003
1117
|
const dragStateRef = useRef<{
|
|
1004
1118
|
pointerId: number;
|
|
1005
1119
|
startWX: number;
|
|
1006
1120
|
startWY: number;
|
|
1007
1121
|
movedIds: string[];
|
|
1122
|
+
snapshot: Stroke[];
|
|
1008
1123
|
} | null>(null);
|
|
1009
1124
|
|
|
1010
1125
|
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
@@ -1016,6 +1131,7 @@ export function AnnotationsLayer() {
|
|
|
1016
1131
|
by: number;
|
|
1017
1132
|
} | null>(null);
|
|
1018
1133
|
|
|
1134
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: commitStrokes/strokesStore are stable refs from parent context; manual dep list reflects effect-trigger semantics, not internal callbacks
|
|
1019
1135
|
useEffect(() => {
|
|
1020
1136
|
if (typeof document === 'undefined') return;
|
|
1021
1137
|
if (tool !== 'move') return;
|
|
@@ -1039,7 +1155,7 @@ export function AnnotationsLayer() {
|
|
|
1039
1155
|
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
1040
1156
|
// right-click menu. Clicks on these route to their own handlers.
|
|
1041
1157
|
const CHROME_SELECTOR =
|
|
1042
|
-
'.dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover';
|
|
1158
|
+
'.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
1159
|
|
|
1044
1160
|
const onDown = (e: PointerEvent) => {
|
|
1045
1161
|
if (e.button !== 0) return;
|
|
@@ -1047,6 +1163,11 @@ export function AnnotationsLayer() {
|
|
|
1047
1163
|
const target = e.target as Element | null;
|
|
1048
1164
|
if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
|
|
1049
1165
|
const strokeId = findStrokeId(target);
|
|
1166
|
+
// When pointerdown lands inside an artboard but not on a stroke, the
|
|
1167
|
+
// gesture belongs to artboard-drag / element-marquee — not the
|
|
1168
|
+
// annotation marquee. Bailing here keeps the annotation marquee from
|
|
1169
|
+
// racing the artboard drag (post-Wave-3 user grievance G5).
|
|
1170
|
+
if (!strokeId && target?.closest?.('[data-dc-screen]')) return;
|
|
1050
1171
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
1051
1172
|
const startClientX = e.clientX;
|
|
1052
1173
|
const startClientY = e.clientY;
|
|
@@ -1068,29 +1189,51 @@ export function AnnotationsLayer() {
|
|
|
1068
1189
|
annotSel.replace(strokeId);
|
|
1069
1190
|
ids = [strokeId];
|
|
1070
1191
|
}
|
|
1192
|
+
// Capture a snapshot of all strokes at drag start. Every pointermove
|
|
1193
|
+
// re-translates FROM the snapshot using the cumulative cursor delta
|
|
1194
|
+
// (NOT a delta-from-last-frame mutation), so dragging back to origin
|
|
1195
|
+
// restores positions exactly. Optimistic state-only updates during
|
|
1196
|
+
// the move; ONE undo record + ONE server PUT fires on pointerup.
|
|
1197
|
+
const dragSnapshot = strokesRef.current.slice();
|
|
1071
1198
|
dragStateRef.current = {
|
|
1072
1199
|
pointerId: e.pointerId,
|
|
1073
1200
|
startWX: wx,
|
|
1074
1201
|
startWY: wy,
|
|
1075
1202
|
movedIds: ids,
|
|
1203
|
+
snapshot: dragSnapshot,
|
|
1076
1204
|
};
|
|
1205
|
+
const movedSet = new Set(ids);
|
|
1077
1206
|
const onMove = (mv: PointerEvent) => {
|
|
1078
1207
|
const st = dragStateRef.current;
|
|
1079
1208
|
if (!st || mv.pointerId !== st.pointerId) return;
|
|
1080
1209
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1081
1210
|
const dx = cwx - st.startWX;
|
|
1082
1211
|
const dy = cwy - st.startWY;
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1212
|
+
// Drag-back-to-origin: restore exact references so the pointerup
|
|
1213
|
+
// shallow-equality check skips committing a no-op record.
|
|
1214
|
+
const next =
|
|
1215
|
+
dx === 0 && dy === 0
|
|
1216
|
+
? st.snapshot
|
|
1217
|
+
: st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s));
|
|
1218
|
+
// Local React state only. No commitStrokes — no PUT, no undo push.
|
|
1219
|
+
setStrokesState(next);
|
|
1087
1220
|
};
|
|
1088
1221
|
const onUp = (up: PointerEvent) => {
|
|
1089
|
-
|
|
1222
|
+
const st = dragStateRef.current;
|
|
1223
|
+
if (!st || up.pointerId !== st.pointerId) return;
|
|
1090
1224
|
dragStateRef.current = null;
|
|
1091
1225
|
document.removeEventListener('pointermove', onMove, true);
|
|
1092
1226
|
document.removeEventListener('pointerup', onUp, true);
|
|
1093
1227
|
document.removeEventListener('pointercancel', onUp, true);
|
|
1228
|
+
// Commit the gesture as ONE record. Skip on zero-movement
|
|
1229
|
+
// (click without drag past threshold or drag back to origin).
|
|
1230
|
+
const final = strokesRef.current;
|
|
1231
|
+
if (strokesShallowEqual(st.snapshot, final)) return;
|
|
1232
|
+
commitStrokes(
|
|
1233
|
+
st.snapshot,
|
|
1234
|
+
final,
|
|
1235
|
+
`move ${st.movedIds.length} stroke${st.movedIds.length === 1 ? '' : 's'}`
|
|
1236
|
+
);
|
|
1094
1237
|
};
|
|
1095
1238
|
document.addEventListener('pointermove', onMove, true);
|
|
1096
1239
|
document.addEventListener('pointerup', onUp, true);
|
|
@@ -1099,13 +1242,14 @@ export function AnnotationsLayer() {
|
|
|
1099
1242
|
}
|
|
1100
1243
|
|
|
1101
1244
|
// Empty world — start a drag-select gesture. A bare click without
|
|
1102
|
-
// moving
|
|
1103
|
-
//
|
|
1245
|
+
// moving clears annotation selection (post-Wave-3 feedback: click-to-
|
|
1246
|
+
// deselect is back; Esc also still works).
|
|
1104
1247
|
const addToSelection = e.shiftKey;
|
|
1105
1248
|
let moved = false;
|
|
1106
1249
|
const onMove = (mv: PointerEvent) => {
|
|
1107
|
-
|
|
1108
|
-
|
|
1250
|
+
if (!moved && !crossedDragThreshold(startClientX, startClientY, mv.clientX, mv.clientY)) {
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1109
1253
|
moved = true;
|
|
1110
1254
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
1111
1255
|
setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
|
|
@@ -1115,8 +1259,10 @@ export function AnnotationsLayer() {
|
|
|
1115
1259
|
document.removeEventListener('pointerup', onUp, true);
|
|
1116
1260
|
document.removeEventListener('pointercancel', onUp, true);
|
|
1117
1261
|
if (!moved) {
|
|
1118
|
-
// Click without movement on empty world →
|
|
1119
|
-
//
|
|
1262
|
+
// Click without movement on empty world → clear annotation
|
|
1263
|
+
// selection (post-Wave-3 user feedback). Shift-click preserves
|
|
1264
|
+
// existing selection for additive-mode workflows.
|
|
1265
|
+
if (!addToSelection) annotSel.clear();
|
|
1120
1266
|
return;
|
|
1121
1267
|
}
|
|
1122
1268
|
const final = marqueeRef.current;
|
|
@@ -1178,33 +1324,36 @@ export function AnnotationsLayer() {
|
|
|
1178
1324
|
const commitText = useCallback(
|
|
1179
1325
|
(anchorId: string, text: string) => {
|
|
1180
1326
|
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
|
-
|
|
1327
|
+
const prev = strokesRef.current;
|
|
1328
|
+
const existing = prev.find((s) => s.tool === 'text' && s.anchorId === anchorId) as
|
|
1329
|
+
| TextStroke
|
|
1330
|
+
| undefined;
|
|
1331
|
+
let next: Stroke[];
|
|
1332
|
+
let label = 'edit text';
|
|
1333
|
+
if (trimmed.length === 0) {
|
|
1334
|
+
if (!existing) return; // nothing to do
|
|
1335
|
+
next = prev.filter((s) => s.id !== existing.id);
|
|
1336
|
+
label = 'delete text';
|
|
1337
|
+
} else if (existing) {
|
|
1338
|
+
if (existing.text === trimmed) return; // identity edit
|
|
1339
|
+
next = prev.map((s) => (s.id === existing.id ? { ...existing, text: trimmed } : s));
|
|
1340
|
+
} else {
|
|
1341
|
+
next = [
|
|
1342
|
+
...prev,
|
|
1343
|
+
{
|
|
1344
|
+
id: rid(),
|
|
1345
|
+
tool: 'text',
|
|
1346
|
+
color: '#1a1a1a',
|
|
1347
|
+
fontSize: DEFAULT_FONT_SIZE,
|
|
1348
|
+
text: trimmed,
|
|
1349
|
+
anchorId,
|
|
1350
|
+
} as TextStroke,
|
|
1351
|
+
];
|
|
1352
|
+
label = 'add text';
|
|
1353
|
+
}
|
|
1354
|
+
commitStrokes(prev, next, label);
|
|
1206
1355
|
},
|
|
1207
|
-
[
|
|
1356
|
+
[commitStrokes]
|
|
1208
1357
|
);
|
|
1209
1358
|
|
|
1210
1359
|
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
@@ -1574,7 +1723,7 @@ function SelectionHalo({
|
|
|
1574
1723
|
width={bbox.w + pad * 2}
|
|
1575
1724
|
height={bbox.h + pad * 2}
|
|
1576
1725
|
fill="none"
|
|
1577
|
-
stroke="var(--accent, #d63b1f)"
|
|
1726
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
1578
1727
|
strokeWidth={multi ? 1.5 : 2}
|
|
1579
1728
|
vectorEffect="non-scaling-stroke"
|
|
1580
1729
|
pointerEvents="none"
|
|
@@ -1629,7 +1778,7 @@ function AnnotGroupBbox({
|
|
|
1629
1778
|
width={w}
|
|
1630
1779
|
height={h}
|
|
1631
1780
|
fill="none"
|
|
1632
|
-
stroke="var(--accent, #d63b1f)"
|
|
1781
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
1633
1782
|
strokeWidth={1}
|
|
1634
1783
|
strokeDasharray="4 3"
|
|
1635
1784
|
vectorEffect="non-scaling-stroke"
|
|
@@ -1642,7 +1791,7 @@ function AnnotGroupBbox({
|
|
|
1642
1791
|
y={c.y}
|
|
1643
1792
|
width={handle}
|
|
1644
1793
|
height={handle}
|
|
1645
|
-
fill="var(--accent, #d63b1f)"
|
|
1794
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
1646
1795
|
stroke="var(--bg-0, #ffffff)"
|
|
1647
1796
|
strokeWidth={1}
|
|
1648
1797
|
vectorEffect="non-scaling-stroke"
|
|
@@ -1816,7 +1965,7 @@ function AnnotationsChrome({
|
|
|
1816
1965
|
type="button"
|
|
1817
1966
|
className="dc-annot-btn"
|
|
1818
1967
|
aria-pressed={thickness === STROKE_WIDTH_THIN}
|
|
1819
|
-
title="Thin (
|
|
1968
|
+
title="Thin (3px)"
|
|
1820
1969
|
onClick={() => setThickness(STROKE_WIDTH_THIN)}
|
|
1821
1970
|
>
|
|
1822
1971
|
Thin
|
|
@@ -140,6 +140,12 @@ export interface Api {
|
|
|
140
140
|
commentsDelete(id: string): Promise<boolean>;
|
|
141
141
|
commentsAddReply(id: string, payload: { body: string; author?: string }): Promise<Comment | null>;
|
|
142
142
|
gitCommitters(): Promise<GitCommitter[]>;
|
|
143
|
+
/**
|
|
144
|
+
* Phase 8 — local `git config user.name`, cached for the process lifetime.
|
|
145
|
+
* Used by the collab client to derive a stable color hash per peer.
|
|
146
|
+
* Empty string when git is unset; the client falls back to `anonymous-<pid>`.
|
|
147
|
+
*/
|
|
148
|
+
gitCurrentUser(): Promise<string>;
|
|
143
149
|
parseMentions(text: string): string[];
|
|
144
150
|
// Canvas state
|
|
145
151
|
loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
|
|
@@ -161,7 +167,15 @@ export interface Api {
|
|
|
161
167
|
appendExportHistory(entry: ExportHistoryEntry): Promise<void>;
|
|
162
168
|
}
|
|
163
169
|
|
|
164
|
-
export
|
|
170
|
+
export interface ApiHooks {
|
|
171
|
+
onCommentsChanged: (file: string) => void;
|
|
172
|
+
/** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
|
|
173
|
+
onAnnotationsChanged?: (file: string, svg: string) => void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
177
|
+
const onCommentsChanged = hooks.onCommentsChanged;
|
|
178
|
+
const onAnnotationsChanged = hooks.onAnnotationsChanged;
|
|
165
179
|
const { paths, cfg } = ctx;
|
|
166
180
|
|
|
167
181
|
function fileSlug(file: string): string {
|
|
@@ -576,6 +590,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
576
590
|
// content fully, so we don't try to sanitize beyond a tag check.
|
|
577
591
|
if (!/^\s*<svg[\s>]/i.test(svg)) return false;
|
|
578
592
|
await Bun.write(annotationsPath(file), svg);
|
|
593
|
+
onAnnotationsChanged?.(file, svg);
|
|
579
594
|
return true;
|
|
580
595
|
}
|
|
581
596
|
|
|
@@ -936,6 +951,7 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
936
951
|
commentsDelete,
|
|
937
952
|
commentsAddReply,
|
|
938
953
|
gitCommitters,
|
|
954
|
+
gitCurrentUser,
|
|
939
955
|
parseMentions,
|
|
940
956
|
loadCanvasState,
|
|
941
957
|
saveCanvasState,
|