@1agh/maude 0.19.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +14 -6
- package/plugins/design/dev-server/annotations-layer.tsx +144 -22
- package/plugins/design/dev-server/api.ts +70 -17
- package/plugins/design/dev-server/artboard-marquee.tsx +170 -0
- package/plugins/design/dev-server/canvas-lib.tsx +190 -94
- package/plugins/design/dev-server/canvas-shell.tsx +478 -34
- package/plugins/design/dev-server/client/app.jsx +173 -52
- 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/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/dist/client.bundle.js +212 -103
- package/plugins/design/dev-server/dist/styles.css +38 -2
- package/plugins/design/dev-server/export-dialog.tsx +3 -3
- package/plugins/design/dev-server/http.ts +14 -2
- package/plugins/design/dev-server/input-router.tsx +22 -8
- package/plugins/design/dev-server/server.mjs +84 -18
- package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -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/tool-palette.tsx +71 -15
- package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
- package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -41,13 +41,13 @@
|
|
|
41
41
|
"prepublishOnly": "bash scripts/check-version-parity.sh"
|
|
42
42
|
},
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@1agh/maude-darwin-arm64": "0.19.
|
|
45
|
-
"@1agh/maude-darwin-x64": "0.19.
|
|
46
|
-
"@1agh/maude-linux-arm64": "0.19.
|
|
47
|
-
"@1agh/maude-linux-arm64-musl": "0.19.
|
|
48
|
-
"@1agh/maude-linux-x64": "0.19.
|
|
49
|
-
"@1agh/maude-linux-x64-musl": "0.19.
|
|
50
|
-
"@1agh/maude-win32-x64": "0.19.
|
|
44
|
+
"@1agh/maude-darwin-arm64": "0.19.1",
|
|
45
|
+
"@1agh/maude-darwin-x64": "0.19.1",
|
|
46
|
+
"@1agh/maude-linux-arm64": "0.19.1",
|
|
47
|
+
"@1agh/maude-linux-arm64-musl": "0.19.1",
|
|
48
|
+
"@1agh/maude-linux-x64": "0.19.1",
|
|
49
|
+
"@1agh/maude-linux-x64-musl": "0.19.1",
|
|
50
|
+
"@1agh/maude-win32-x64": "0.19.1"
|
|
51
51
|
},
|
|
52
52
|
"files": [
|
|
53
53
|
"cli",
|
|
@@ -36,11 +36,11 @@ const TOOLBAR_CSS = `
|
|
|
36
36
|
display: flex;
|
|
37
37
|
align-items: center;
|
|
38
38
|
gap: 6px;
|
|
39
|
-
background: var(--u-bg-
|
|
39
|
+
background: var(--u-bg-0, var(--bg-0, rgba(255,255,255,0.98)));
|
|
40
40
|
border: 1px solid var(--u-fg-0, #1c1917);
|
|
41
|
-
border-radius:
|
|
41
|
+
border-radius: 8px;
|
|
42
42
|
padding: 6px 8px;
|
|
43
|
-
box-shadow:
|
|
43
|
+
box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
|
|
44
44
|
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
45
45
|
font-size: 12px;
|
|
46
46
|
color: var(--u-fg-0, var(--fg-0, #1a1a1a));
|
|
@@ -147,7 +147,10 @@ export function AnnotationContextToolbar() {
|
|
|
147
147
|
return { color: false, fill: false, thickness: false, fontSize: false };
|
|
148
148
|
}
|
|
149
149
|
const allFillable = selectedStrokes.every((s) => s.tool === 'rect' || s.tool === 'ellipse');
|
|
150
|
-
|
|
150
|
+
// T20 — rect + ellipse now carry stroke weight too.
|
|
151
|
+
const allThickness = selectedStrokes.every(
|
|
152
|
+
(s) => s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
|
|
153
|
+
);
|
|
151
154
|
const anyText = selectedStrokes.some((s) => s.tool === 'text');
|
|
152
155
|
return {
|
|
153
156
|
color: true,
|
|
@@ -226,7 +229,8 @@ export function AnnotationContextToolbar() {
|
|
|
226
229
|
(w: number) => {
|
|
227
230
|
if (!store) return;
|
|
228
231
|
for (const s of selectedStrokes) {
|
|
229
|
-
|
|
232
|
+
// T20 — rect + ellipse now carry stroke weight.
|
|
233
|
+
if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse') {
|
|
230
234
|
store.updateStroke(s.id, { width: w } as Partial<Stroke>);
|
|
231
235
|
}
|
|
232
236
|
}
|
|
@@ -263,7 +267,11 @@ export function AnnotationContextToolbar() {
|
|
|
263
267
|
: undefined;
|
|
264
268
|
const uniqThickness = caps.thickness
|
|
265
269
|
? uniformValue(
|
|
266
|
-
selectedStrokes.map((s) =>
|
|
270
|
+
selectedStrokes.map((s) =>
|
|
271
|
+
s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'rect' || s.tool === 'ellipse'
|
|
272
|
+
? s.width
|
|
273
|
+
: undefined
|
|
274
|
+
)
|
|
267
275
|
)
|
|
268
276
|
: undefined;
|
|
269
277
|
const uniqFontSize = caps.fontSize
|
|
@@ -35,6 +35,7 @@ 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 { AnnotationResizeOverlay } from './use-annotation-resize.tsx';
|
|
38
39
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
39
40
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
40
41
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
@@ -457,15 +458,15 @@ const ANNOT_CSS = `
|
|
|
457
458
|
display: flex;
|
|
458
459
|
align-items: center;
|
|
459
460
|
gap: 8px;
|
|
460
|
-
background: var(--u-bg-
|
|
461
|
+
background: var(--u-bg-0, var(--bg-0, rgba(255,255,255,0.98)));
|
|
461
462
|
border: 1px solid var(--u-fg-0, #1c1917);
|
|
462
|
-
border-radius:
|
|
463
|
+
border-radius: 8px;
|
|
463
464
|
padding: 6px 10px;
|
|
464
465
|
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
465
466
|
font-size: 11px;
|
|
466
467
|
color: var(--u-fg-0, rgba(40,30,20,0.85));
|
|
467
468
|
z-index: 6;
|
|
468
|
-
box-shadow:
|
|
469
|
+
box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
|
|
469
470
|
user-select: none;
|
|
470
471
|
}
|
|
471
472
|
.dc-annot-chrome .dc-annot-swatches { display: flex; gap: 4px; }
|
|
@@ -618,7 +619,7 @@ export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
|
618
619
|
|
|
619
620
|
export function AnnotationsLayer() {
|
|
620
621
|
ensureAnnotStyles();
|
|
621
|
-
const { tool } = useToolMode();
|
|
622
|
+
const { tool, setTool, sticky } = useToolMode();
|
|
622
623
|
const controller = useViewportControllerContext();
|
|
623
624
|
const vp = controller?.viewport ?? null;
|
|
624
625
|
const worldRef = useWorldRefContext();
|
|
@@ -653,7 +654,11 @@ export function AnnotationsLayer() {
|
|
|
653
654
|
const isDraw = tool === 'pen' || tool === 'rect' || tool === 'arrow' || tool === 'ellipse';
|
|
654
655
|
const isErase = tool === 'eraser';
|
|
655
656
|
const isActive = isDraw || isErase;
|
|
656
|
-
|
|
657
|
+
// T20 — rect + ellipse expose stroke weight too (FigJam ships thickness on
|
|
658
|
+
// every shape). The annotation toolbar reads supportsThickness to decide
|
|
659
|
+
// whether to render the Thin / Thick chips.
|
|
660
|
+
const supportsThickness =
|
|
661
|
+
tool === 'pen' || tool === 'arrow' || tool === 'rect' || tool === 'ellipse';
|
|
657
662
|
const supportsFill = tool === 'rect' || tool === 'ellipse';
|
|
658
663
|
|
|
659
664
|
// Load existing annotations on mount.
|
|
@@ -839,7 +844,7 @@ export function AnnotationsLayer() {
|
|
|
839
844
|
id,
|
|
840
845
|
tool: 'rect',
|
|
841
846
|
color,
|
|
842
|
-
width
|
|
847
|
+
width,
|
|
843
848
|
x: wx,
|
|
844
849
|
y: wy,
|
|
845
850
|
w: 0,
|
|
@@ -851,7 +856,7 @@ export function AnnotationsLayer() {
|
|
|
851
856
|
id,
|
|
852
857
|
tool: 'ellipse',
|
|
853
858
|
color,
|
|
854
|
-
width
|
|
859
|
+
width,
|
|
855
860
|
cx: wx,
|
|
856
861
|
cy: wy,
|
|
857
862
|
rx: 0,
|
|
@@ -935,9 +940,38 @@ export function AnnotationsLayer() {
|
|
|
935
940
|
scheduleSave(next);
|
|
936
941
|
return next;
|
|
937
942
|
});
|
|
943
|
+
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
944
|
+
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
945
|
+
// AnnotationsLayer without the provider), so guard the call.
|
|
946
|
+
if (annotSel) annotSel.replace(committed.id);
|
|
938
947
|
}
|
|
948
|
+
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
949
|
+
// mode is locked on this tool. Sticky lets the user draw many shapes in a
|
|
950
|
+
// row (canonical pattern: tldraw double-click to lock). Eraser stays
|
|
951
|
+
// armed by default — that tool is destructive, not constructive.
|
|
952
|
+
const toolJustUsed = cur.tool;
|
|
953
|
+
if (toolJustUsed !== 'eraser') {
|
|
954
|
+
const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
|
|
955
|
+
if (!stickyOnThis) setTool('move');
|
|
956
|
+
}
|
|
957
|
+
setDrawing(null);
|
|
958
|
+
}, [isActive, isErase, visible, scheduleSave, annotSel, setTool, sticky]);
|
|
959
|
+
|
|
960
|
+
// T21 — abort a mid-stroke draw without committing. Dispatched by the
|
|
961
|
+
// canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
|
|
962
|
+
// nothing is being drawn — the early-return on drawingRef keeps it
|
|
963
|
+
// a no-op.
|
|
964
|
+
const cancelStroke = useCallback(() => {
|
|
965
|
+
if (!drawingRef.current) return;
|
|
939
966
|
setDrawing(null);
|
|
940
|
-
}, [
|
|
967
|
+
}, []);
|
|
968
|
+
|
|
969
|
+
useEffect(() => {
|
|
970
|
+
if (typeof document === 'undefined') return;
|
|
971
|
+
const onCancel = () => cancelStroke();
|
|
972
|
+
document.addEventListener('maude:cancel-stroke', onCancel);
|
|
973
|
+
return () => document.removeEventListener('maude:cancel-stroke', onCancel);
|
|
974
|
+
}, [cancelStroke]);
|
|
941
975
|
|
|
942
976
|
const renderStrokes = useMemo(
|
|
943
977
|
() => (drawing ? [...strokes, drawing] : strokes),
|
|
@@ -1064,8 +1098,9 @@ export function AnnotationsLayer() {
|
|
|
1064
1098
|
return;
|
|
1065
1099
|
}
|
|
1066
1100
|
|
|
1067
|
-
// Empty world — start a drag-select gesture. A
|
|
1068
|
-
//
|
|
1101
|
+
// Empty world — start a drag-select gesture. A bare click without
|
|
1102
|
+
// moving is a no-op (post-Wave-2 feedback: click-on-empty-space
|
|
1103
|
+
// does NOT clear selection; Esc is the canonical deselect).
|
|
1069
1104
|
const addToSelection = e.shiftKey;
|
|
1070
1105
|
let moved = false;
|
|
1071
1106
|
const onMove = (mv: PointerEvent) => {
|
|
@@ -1080,8 +1115,8 @@ export function AnnotationsLayer() {
|
|
|
1080
1115
|
document.removeEventListener('pointerup', onUp, true);
|
|
1081
1116
|
document.removeEventListener('pointercancel', onUp, true);
|
|
1082
1117
|
if (!moved) {
|
|
1083
|
-
//
|
|
1084
|
-
|
|
1118
|
+
// Click without movement on empty world → no-op. Selection
|
|
1119
|
+
// survives accidental misses; Esc is how the user deselects.
|
|
1085
1120
|
return;
|
|
1086
1121
|
}
|
|
1087
1122
|
const final = marqueeRef.current;
|
|
@@ -1100,13 +1135,10 @@ export function AnnotationsLayer() {
|
|
|
1100
1135
|
hits.push(s.id);
|
|
1101
1136
|
}
|
|
1102
1137
|
}
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
} else {
|
|
1108
|
-
annotSel.replace(hits);
|
|
1109
|
-
}
|
|
1138
|
+
// Marquee that captured no strokes — preserve existing selection.
|
|
1139
|
+
if (hits.length === 0) return;
|
|
1140
|
+
if (addToSelection) annotSel.add(hits);
|
|
1141
|
+
else annotSel.replace(hits);
|
|
1110
1142
|
};
|
|
1111
1143
|
document.addEventListener('pointermove', onMove, true);
|
|
1112
1144
|
document.addEventListener('pointerup', onUp, true);
|
|
@@ -1257,6 +1289,7 @@ export function AnnotationsLayer() {
|
|
|
1257
1289
|
/>
|
|
1258
1290
|
) : null}
|
|
1259
1291
|
<AnnotationContextToolbar />
|
|
1292
|
+
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
1260
1293
|
{isActive ? (
|
|
1261
1294
|
<AnnotationsChrome
|
|
1262
1295
|
color={color}
|
|
@@ -1382,8 +1415,14 @@ function AnnotationsSvg({
|
|
|
1382
1415
|
<StrokeNode key={s.id} stroke={s} anchorsById={anchorsById} interactive={selectMode} />
|
|
1383
1416
|
))}
|
|
1384
1417
|
{selectedStrokes.map((s) => (
|
|
1385
|
-
<SelectionHalo
|
|
1418
|
+
<SelectionHalo
|
|
1419
|
+
key={`halo-${s.id}`}
|
|
1420
|
+
stroke={s}
|
|
1421
|
+
anchorsById={anchorsById}
|
|
1422
|
+
multi={selectedStrokes.length > 1}
|
|
1423
|
+
/>
|
|
1386
1424
|
))}
|
|
1425
|
+
<AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
|
|
1387
1426
|
{marquee ? (
|
|
1388
1427
|
<rect
|
|
1389
1428
|
className="dc-annot-marquee"
|
|
@@ -1507,12 +1546,26 @@ function TextEditor({
|
|
|
1507
1546
|
function SelectionHalo({
|
|
1508
1547
|
stroke,
|
|
1509
1548
|
anchorsById,
|
|
1549
|
+
multi,
|
|
1510
1550
|
}: {
|
|
1511
1551
|
stroke: Stroke;
|
|
1512
1552
|
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1553
|
+
multi: boolean;
|
|
1513
1554
|
}) {
|
|
1514
1555
|
const bbox = strokeBBox(stroke, anchorsById);
|
|
1515
1556
|
if (!bbox) return null;
|
|
1557
|
+
// T17 + post-Wave-2 fix — annotation halo idioms:
|
|
1558
|
+
// * Single select → 2 px solid border, NO ring, NO corner ticks.
|
|
1559
|
+
// The resize overlay (T23) renders the corner handles in screen-space,
|
|
1560
|
+
// so painting SVG ticks here too would duplicate them. The element
|
|
1561
|
+
// halo uses CSS box-shadow for the 18% ring; the SVG equivalent (a
|
|
1562
|
+
// second outline rect) reads as "double frame" rather than a halo —
|
|
1563
|
+
// user feedback flagged this immediately. Solid 2 px is enough signal
|
|
1564
|
+
// once the resize handles claim the corners.
|
|
1565
|
+
// * Multi member → 1.5 px solid full accent, no ring, no ticks (group
|
|
1566
|
+
// bbox above carries the container affordance).
|
|
1567
|
+
// Marquee STAYS dashed (drawn elsewhere) — dashed is reserved for the
|
|
1568
|
+
// ambient group-container + active-gesture idioms per DDR-046 rev 2.
|
|
1516
1569
|
const pad = 4;
|
|
1517
1570
|
return (
|
|
1518
1571
|
<rect
|
|
@@ -1522,8 +1575,7 @@ function SelectionHalo({
|
|
|
1522
1575
|
height={bbox.h + pad * 2}
|
|
1523
1576
|
fill="none"
|
|
1524
1577
|
stroke="var(--accent, #d63b1f)"
|
|
1525
|
-
strokeWidth={1.5}
|
|
1526
|
-
strokeDasharray="4 3"
|
|
1578
|
+
strokeWidth={multi ? 1.5 : 2}
|
|
1527
1579
|
vectorEffect="non-scaling-stroke"
|
|
1528
1580
|
pointerEvents="none"
|
|
1529
1581
|
rx={2}
|
|
@@ -1531,6 +1583,76 @@ function SelectionHalo({
|
|
|
1531
1583
|
);
|
|
1532
1584
|
}
|
|
1533
1585
|
|
|
1586
|
+
// T17 — group bbox dashed rect for multi-stroke annotation selection. Mirrors
|
|
1587
|
+
// the element-side GroupBbox idiom (1 px dashed accent + 6 × 6 corner handles).
|
|
1588
|
+
function AnnotGroupBbox({
|
|
1589
|
+
selectedStrokes,
|
|
1590
|
+
anchorsById,
|
|
1591
|
+
}: {
|
|
1592
|
+
selectedStrokes: readonly Stroke[];
|
|
1593
|
+
anchorsById: Map<string, RectStroke | EllipseStroke>;
|
|
1594
|
+
}) {
|
|
1595
|
+
if (selectedStrokes.length < 2) return null;
|
|
1596
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
1597
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
1598
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
1599
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
1600
|
+
let any = false;
|
|
1601
|
+
for (const s of selectedStrokes) {
|
|
1602
|
+
const b = strokeBBox(s, anchorsById);
|
|
1603
|
+
if (!b) continue;
|
|
1604
|
+
any = true;
|
|
1605
|
+
if (b.x < xMin) xMin = b.x;
|
|
1606
|
+
if (b.y < yMin) yMin = b.y;
|
|
1607
|
+
if (b.x + b.w > xMax) xMax = b.x + b.w;
|
|
1608
|
+
if (b.y + b.h > yMax) yMax = b.y + b.h;
|
|
1609
|
+
}
|
|
1610
|
+
if (!any) return null;
|
|
1611
|
+
const pad = 6;
|
|
1612
|
+
const x = xMin - pad;
|
|
1613
|
+
const y = yMin - pad;
|
|
1614
|
+
const w = xMax - xMin + pad * 2;
|
|
1615
|
+
const h = yMax - yMin + pad * 2;
|
|
1616
|
+
const handle = 6;
|
|
1617
|
+
const inset = 3;
|
|
1618
|
+
const handles = [
|
|
1619
|
+
{ corner: 'nw', x: x - inset, y: y - inset },
|
|
1620
|
+
{ corner: 'ne', x: x + w - handle + inset, y: y - inset },
|
|
1621
|
+
{ corner: 'sw', x: x - inset, y: y + h - handle + inset },
|
|
1622
|
+
{ corner: 'se', x: x + w - handle + inset, y: y + h - handle + inset },
|
|
1623
|
+
];
|
|
1624
|
+
return (
|
|
1625
|
+
<g pointerEvents="none">
|
|
1626
|
+
<rect
|
|
1627
|
+
x={x}
|
|
1628
|
+
y={y}
|
|
1629
|
+
width={w}
|
|
1630
|
+
height={h}
|
|
1631
|
+
fill="none"
|
|
1632
|
+
stroke="var(--accent, #d63b1f)"
|
|
1633
|
+
strokeWidth={1}
|
|
1634
|
+
strokeDasharray="4 3"
|
|
1635
|
+
vectorEffect="non-scaling-stroke"
|
|
1636
|
+
rx={2}
|
|
1637
|
+
/>
|
|
1638
|
+
{handles.map((c) => (
|
|
1639
|
+
<rect
|
|
1640
|
+
key={c.corner}
|
|
1641
|
+
x={c.x}
|
|
1642
|
+
y={c.y}
|
|
1643
|
+
width={handle}
|
|
1644
|
+
height={handle}
|
|
1645
|
+
fill="var(--accent, #d63b1f)"
|
|
1646
|
+
stroke="var(--bg-0, #ffffff)"
|
|
1647
|
+
strokeWidth={1}
|
|
1648
|
+
vectorEffect="non-scaling-stroke"
|
|
1649
|
+
rx={1}
|
|
1650
|
+
/>
|
|
1651
|
+
))}
|
|
1652
|
+
</g>
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1534
1656
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1535
1657
|
// Stroke renderer
|
|
1536
1658
|
|
|
@@ -155,7 +155,7 @@ export interface Api {
|
|
|
155
155
|
saveAnnotations(file: string, svg: string): Promise<boolean>;
|
|
156
156
|
// Aggregate data
|
|
157
157
|
buildIndexData(): Promise<unknown>;
|
|
158
|
-
buildSystemData(): Promise<unknown>;
|
|
158
|
+
buildSystemData(dsName?: string | null): Promise<unknown>;
|
|
159
159
|
// Export history (Phase 6.5 T10)
|
|
160
160
|
loadExportHistory(): Promise<ExportHistoryEntry[]>;
|
|
161
161
|
appendExportHistory(entry: ExportHistoryEntry): Promise<void>;
|
|
@@ -765,9 +765,34 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
765
765
|
return tokens;
|
|
766
766
|
}
|
|
767
767
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
768
|
+
/**
|
|
769
|
+
* Build the System view payload.
|
|
770
|
+
*
|
|
771
|
+
* - When `dsName` is null/undefined, scope to the top-level system dir
|
|
772
|
+
* (`paths.systemDirRel`) and read tokens from `cfg.tokensCssRel`. This is
|
|
773
|
+
* the legacy single-DS shape every pre-DDR-048 caller sees.
|
|
774
|
+
* - When `dsName` is provided, scope to the matching `designSystems[]` entry:
|
|
775
|
+
* `sysAbs`/`sysRel` point at the DS folder, `tokensAbs` reads from the
|
|
776
|
+
* per-DS `tokensCssRel` (auto-resolved by `normalizeDesignSystems`), and
|
|
777
|
+
* the previews/ui_kits galleries are restricted to that DS subtree.
|
|
778
|
+
*
|
|
779
|
+
* Returns `null` when `dsName` is set but not found in `cfg.designSystems` so
|
|
780
|
+
* the HTTP handler can 404 instead of silently falling back.
|
|
781
|
+
*
|
|
782
|
+
* DDR-048 — system view renders user tokens only; the per-DS scope is what
|
|
783
|
+
* keeps multi-DS projects from blending the wrong tokens into the wrong
|
|
784
|
+
* preview gallery.
|
|
785
|
+
*/
|
|
786
|
+
async function buildSystemData(dsName?: string | null) {
|
|
787
|
+
let dsEntry: NonNullable<typeof cfg.designSystems>[number] | null = null;
|
|
788
|
+
if (dsName) {
|
|
789
|
+
dsEntry = cfg.designSystems?.find((d) => d.name === dsName) ?? null;
|
|
790
|
+
if (!dsEntry) return null;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const scopedSystemRel = dsEntry ? dsEntry.path : paths.systemDirRel;
|
|
794
|
+
const sysAbs = path.join(paths.designRoot, scopedSystemRel);
|
|
795
|
+
const sysRel = path.posix.join(paths.designRel, scopedSystemRel);
|
|
771
796
|
|
|
772
797
|
let readme: string | null = null;
|
|
773
798
|
let readmePath: string | null = null;
|
|
@@ -794,8 +819,12 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
794
819
|
|
|
795
820
|
let tokens: ReturnType<typeof parseTokens> = [];
|
|
796
821
|
let tokensPath: string | null = null;
|
|
822
|
+
// Per-DS tokens path (auto-resolved by normalizeDesignSystems) wins; the
|
|
823
|
+
// top-level cfg.tokensCssRel is a project-wide fallback for legacy
|
|
824
|
+
// single-DS configs that don't declare `designSystems[]`. DDR-048.
|
|
825
|
+
const tokensCssRel = dsEntry?.tokensCssRel ?? cfg.tokensCssRel;
|
|
797
826
|
try {
|
|
798
|
-
const tokensAbs = path.join(paths.designRoot,
|
|
827
|
+
const tokensAbs = path.join(paths.designRoot, tokensCssRel);
|
|
799
828
|
const css = await readFile(tokensAbs, 'utf8');
|
|
800
829
|
tokens = parseTokens(css);
|
|
801
830
|
tokensPath = path.relative(paths.repoRoot, tokensAbs);
|
|
@@ -812,16 +841,21 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
812
841
|
async function galleryFor(folderName: string) {
|
|
813
842
|
const matches: { abs: string; rel: string }[] = [];
|
|
814
843
|
try {
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
if (
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
844
|
+
// When scoped to a single DS (sysAbs IS the DS folder), only check
|
|
845
|
+
// the DS-relative `<folderName>` subdir — scanning sub-dirs here
|
|
846
|
+
// would treat the DS's own `preview/` as a sibling DS root.
|
|
847
|
+
if (!dsEntry) {
|
|
848
|
+
const subs = await readdir(sysAbs, { withFileTypes: true });
|
|
849
|
+
for (const s of subs) {
|
|
850
|
+
if (!s.isDirectory()) continue;
|
|
851
|
+
const candidate = path.join(sysAbs, s.name, folderName);
|
|
852
|
+
try {
|
|
853
|
+
const st = await statp(candidate);
|
|
854
|
+
if (st.isDirectory())
|
|
855
|
+
matches.push({ abs: candidate, rel: path.posix.join(sysRel, s.name, folderName) });
|
|
856
|
+
} catch {
|
|
857
|
+
/* ignore */
|
|
858
|
+
}
|
|
825
859
|
}
|
|
826
860
|
}
|
|
827
861
|
try {
|
|
@@ -856,10 +890,29 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
856
890
|
const previewGallery = await galleryFor('preview');
|
|
857
891
|
const uiKitsGallery = await galleryFor('ui_kits');
|
|
858
892
|
|
|
893
|
+
// Always advertise the available DSes so the client can render the picker
|
|
894
|
+
// even when the initial fetch was unscoped — avoids a second roundtrip.
|
|
895
|
+
const availableDesignSystems = (cfg.designSystems ?? []).map((d) => ({
|
|
896
|
+
name: d.name,
|
|
897
|
+
path: d.path,
|
|
898
|
+
description: d.description ?? null,
|
|
899
|
+
}));
|
|
900
|
+
|
|
859
901
|
return {
|
|
860
902
|
project: cfg.name,
|
|
861
903
|
designRoot: paths.designRel,
|
|
862
904
|
systemDir: sysRel,
|
|
905
|
+
ds: dsEntry
|
|
906
|
+
? {
|
|
907
|
+
name: dsEntry.name,
|
|
908
|
+
path: dsEntry.path,
|
|
909
|
+
description: dsEntry.description ?? null,
|
|
910
|
+
rootClass: dsEntry.rootClass ?? null,
|
|
911
|
+
themeDefault: dsEntry.themeDefault ?? null,
|
|
912
|
+
}
|
|
913
|
+
: null,
|
|
914
|
+
availableDesignSystems,
|
|
915
|
+
defaultDesignSystem: cfg.defaultDesignSystem ?? availableDesignSystems[0]?.name ?? null,
|
|
863
916
|
readme,
|
|
864
917
|
readmePath,
|
|
865
918
|
tokens,
|
|
@@ -867,8 +920,8 @@ export function createApi(ctx: Context, onCommentsChanged: (file: string) => voi
|
|
|
867
920
|
tokensPath,
|
|
868
921
|
previewGallery,
|
|
869
922
|
uiKitsGallery,
|
|
870
|
-
rootClass: cfg.rootClass,
|
|
871
|
-
themeDefault: cfg.themeDefault,
|
|
923
|
+
rootClass: dsEntry?.rootClass ?? cfg.rootClass,
|
|
924
|
+
themeDefault: dsEntry?.themeDefault ?? cfg.themeDefault,
|
|
872
925
|
teamAccentDefault: cfg.teamAccentDefault,
|
|
873
926
|
};
|
|
874
927
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file artboard-marquee.tsx — Task 24 sub-item (Wave 2 follow-up, G8)
|
|
3
|
+
* @scope plugins/design/dev-server/artboard-marquee.tsx
|
|
4
|
+
* @purpose Drag-to-lasso multi-select for artboards. Bare-button pointerdown
|
|
5
|
+
* on empty world (NOT inside any artboard, NOT on floating chrome)
|
|
6
|
+
* starts a marquee. 4 px drag threshold suppresses click jitter.
|
|
7
|
+
* pointerup intersects the marquee AABB with every artboard's
|
|
8
|
+
* screen-coord bbox; hits become Selection entries with
|
|
9
|
+
* `artboardId` set. Modifier semantics — bare = replace, Shift
|
|
10
|
+
* = add (Alt-subtract / Shift+Alt-intersect are deferred).
|
|
11
|
+
*
|
|
12
|
+
* The marquee rect itself follows DDR-046 rev 2: 1 px solid
|
|
13
|
+
* accent border + 8 % accent fill. SOLID, not dashed — solid +
|
|
14
|
+
* tinted fill = "active gesture" idiom, dashed = persistent
|
|
15
|
+
* group bbox.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useEffect, useRef } from 'react';
|
|
19
|
+
|
|
20
|
+
import { useArtboardsContext } from './canvas-lib.tsx';
|
|
21
|
+
import { type Selection, useSelectionSet } from './use-selection-set.tsx';
|
|
22
|
+
import { useToolMode } from './use-tool-mode.tsx';
|
|
23
|
+
|
|
24
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
25
|
+
|
|
26
|
+
const MARQUEE_CSS = `
|
|
27
|
+
.dc-cv-marquee {
|
|
28
|
+
position: fixed;
|
|
29
|
+
pointer-events: none;
|
|
30
|
+
z-index: 5;
|
|
31
|
+
border: 1px solid var(--accent, #0d99ff);
|
|
32
|
+
background: color-mix(in oklab, var(--accent, #0d99ff) 8%, transparent);
|
|
33
|
+
display: none;
|
|
34
|
+
}
|
|
35
|
+
`.trim();
|
|
36
|
+
|
|
37
|
+
function ensureMarqueeStyles(): void {
|
|
38
|
+
if (typeof document === 'undefined') return;
|
|
39
|
+
if (document.getElementById('dc-cv-marquee-css')) return;
|
|
40
|
+
const s = document.createElement('style');
|
|
41
|
+
s.id = 'dc-cv-marquee-css';
|
|
42
|
+
s.textContent = MARQUEE_CSS;
|
|
43
|
+
document.head.appendChild(s);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function shouldIgnoreTarget(t: EventTarget | null): boolean {
|
|
47
|
+
if (!t || !(t as Element).closest) return true;
|
|
48
|
+
const el = t as Element;
|
|
49
|
+
// Skip if the pointerdown landed inside any artboard — that's a select-
|
|
50
|
+
// or-drag gesture, not a marquee. Same hard ceiling as resolveHoverTarget.
|
|
51
|
+
if (el.closest('[data-dc-screen]')) return true;
|
|
52
|
+
// Skip floating chrome and existing overlays (their click handlers run).
|
|
53
|
+
if (
|
|
54
|
+
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'
|
|
56
|
+
)
|
|
57
|
+
) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ArtboardMarqueeOverlay() {
|
|
64
|
+
ensureMarqueeStyles();
|
|
65
|
+
const selSet = useSelectionSet();
|
|
66
|
+
const artboardsCtx = useArtboardsContext();
|
|
67
|
+
const { tool } = useToolMode();
|
|
68
|
+
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
69
|
+
const stateRef = useRef<{
|
|
70
|
+
pointerId: number;
|
|
71
|
+
startX: number;
|
|
72
|
+
startY: number;
|
|
73
|
+
crossed: boolean;
|
|
74
|
+
shift: boolean;
|
|
75
|
+
} | null>(null);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (tool !== 'move') return;
|
|
79
|
+
if (typeof document === 'undefined') return;
|
|
80
|
+
|
|
81
|
+
const onDown = (e: PointerEvent) => {
|
|
82
|
+
if (e.button !== 0) return;
|
|
83
|
+
// Bare or Shift only — Cmd/Ctrl/Alt clicks belong to other gestures
|
|
84
|
+
// (Cmd-select, Alt-copy-drag) and shouldn't start a marquee.
|
|
85
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
86
|
+
if (shouldIgnoreTarget(e.target)) return;
|
|
87
|
+
stateRef.current = {
|
|
88
|
+
pointerId: e.pointerId,
|
|
89
|
+
startX: e.clientX,
|
|
90
|
+
startY: e.clientY,
|
|
91
|
+
crossed: false,
|
|
92
|
+
shift: e.shiftKey,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const onMove = (e: PointerEvent) => {
|
|
97
|
+
const s = stateRef.current;
|
|
98
|
+
if (!s || e.pointerId !== s.pointerId) return;
|
|
99
|
+
const dx = e.clientX - s.startX;
|
|
100
|
+
const dy = e.clientY - s.startY;
|
|
101
|
+
if (!s.crossed) {
|
|
102
|
+
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
|
103
|
+
s.crossed = true;
|
|
104
|
+
// Per post-Wave-2 feedback, the marquee never pre-emptively clears
|
|
105
|
+
// the existing selection. The set is only updated on pointerup if
|
|
106
|
+
// the marquee actually captured something — empty-marquee + bare-
|
|
107
|
+
// click preserve the previous selection. Esc is the only deselect.
|
|
108
|
+
}
|
|
109
|
+
const div = overlayRef.current;
|
|
110
|
+
if (!div) return;
|
|
111
|
+
const left = Math.min(s.startX, e.clientX);
|
|
112
|
+
const top = Math.min(s.startY, e.clientY);
|
|
113
|
+
const w = Math.abs(dx);
|
|
114
|
+
const h = Math.abs(dy);
|
|
115
|
+
div.style.display = 'block';
|
|
116
|
+
div.style.left = `${left}px`;
|
|
117
|
+
div.style.top = `${top}px`;
|
|
118
|
+
div.style.width = `${w}px`;
|
|
119
|
+
div.style.height = `${h}px`;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const finish = (e: PointerEvent) => {
|
|
123
|
+
const s = stateRef.current;
|
|
124
|
+
if (!s || e.pointerId !== s.pointerId) return;
|
|
125
|
+
stateRef.current = null;
|
|
126
|
+
const div = overlayRef.current;
|
|
127
|
+
if (div) div.style.display = 'none';
|
|
128
|
+
if (!s.crossed) return;
|
|
129
|
+
if (!artboardsCtx) return;
|
|
130
|
+
|
|
131
|
+
// Intersect the marquee AABB with each artboard's screen-coord bbox.
|
|
132
|
+
// getBoundingClientRect on the article reads the post-zoom, post-pan
|
|
133
|
+
// screen position — exactly what we want, no manual world↔screen math.
|
|
134
|
+
const left = Math.min(s.startX, e.clientX);
|
|
135
|
+
const top = Math.min(s.startY, e.clientY);
|
|
136
|
+
const right = Math.max(s.startX, e.clientX);
|
|
137
|
+
const bottom = Math.max(s.startY, e.clientY);
|
|
138
|
+
const hits: Selection[] = [];
|
|
139
|
+
for (const r of artboardsCtx.artboards) {
|
|
140
|
+
const el = document.querySelector(`[data-dc-screen="${r.id}"]`);
|
|
141
|
+
if (!el) continue;
|
|
142
|
+
const b = (el as HTMLElement).getBoundingClientRect();
|
|
143
|
+
if (b.width === 0 && b.height === 0) continue;
|
|
144
|
+
// AABB intersection test — any overlap counts as a hit.
|
|
145
|
+
if (b.right < left || b.left > right) continue;
|
|
146
|
+
if (b.bottom < top || b.top > bottom) continue;
|
|
147
|
+
hits.push({
|
|
148
|
+
selector: `[data-dc-screen="${r.id}"]`,
|
|
149
|
+
artboardId: r.id,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (hits.length === 0) return;
|
|
153
|
+
if (s.shift) selSet.add(hits);
|
|
154
|
+
else selSet.replace(hits);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
158
|
+
document.addEventListener('pointermove', onMove);
|
|
159
|
+
document.addEventListener('pointerup', finish);
|
|
160
|
+
document.addEventListener('pointercancel', finish);
|
|
161
|
+
return () => {
|
|
162
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
163
|
+
document.removeEventListener('pointermove', onMove);
|
|
164
|
+
document.removeEventListener('pointerup', finish);
|
|
165
|
+
document.removeEventListener('pointercancel', finish);
|
|
166
|
+
};
|
|
167
|
+
}, [tool, selSet, artboardsCtx]);
|
|
168
|
+
|
|
169
|
+
return <div ref={overlayRef} className="dc-cv-marquee" aria-hidden="true" />;
|
|
170
|
+
}
|