@1agh/maude 0.40.0 → 0.41.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/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -3
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1451 -22
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +48 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +223 -0
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +28 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -146,6 +146,19 @@ export function selectorIndex(doc: Document, selector: string, el: Element | nul
|
|
|
146
146
|
return 0;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* GLOBAL occurrence index of `el` among EVERY node in the document that shares
|
|
151
|
+
* its `data-cd-id` — the DOM instance index the server-side reused-component
|
|
152
|
+
* usage resolver (`resolveUsageId`) expects, in source order. Distinct from a
|
|
153
|
+
* `Selection.index` (which counts within an artboard-SCOPED selector). Used to
|
|
154
|
+
* route a whole-instance move/resize per-occurrence (Stage H3) so it stays local
|
|
155
|
+
* to the dragged instance. Matches the reorder drag's own snapshot occurrence.
|
|
156
|
+
*/
|
|
157
|
+
export function globalCdOccurrence(doc: Document, cdId: string, el: Element | null): number {
|
|
158
|
+
if (!cdId || !el) return 0;
|
|
159
|
+
return selectorIndex(doc, `[data-cd-id="${cssEscape(cdId)}"]`, el);
|
|
160
|
+
}
|
|
161
|
+
|
|
149
162
|
/**
|
|
150
163
|
* Resolve a stored Selection to its live element, artboard-scoped. Prefers the
|
|
151
164
|
* id+artboardId scoped selector (the robust path), then the stored `selector`
|
|
@@ -175,6 +188,63 @@ export function resolveSelectionEl(
|
|
|
175
188
|
return null;
|
|
176
189
|
}
|
|
177
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Length of the trailing run `a` and `b` share, walked from the end. `domPath()`
|
|
193
|
+
* hops carry no positional (nth-child) info, so a shared suffix survives a
|
|
194
|
+
* sibling insertion/removal anywhere earlier in the tree — exactly the DDR-019
|
|
195
|
+
* `data-cd-id` renumbering case this fallback exists for.
|
|
196
|
+
*/
|
|
197
|
+
function matchingSuffixLength(a: string[], b: string[]): number {
|
|
198
|
+
let n = 0;
|
|
199
|
+
const max = Math.min(a.length, b.length);
|
|
200
|
+
for (let i = 1; i <= max; i++) {
|
|
201
|
+
if (a[a.length - i] !== b[b.length - i]) break;
|
|
202
|
+
n++;
|
|
203
|
+
}
|
|
204
|
+
return n;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Best-effort structural fallback for when a stored `data-cd-id` selector no
|
|
209
|
+
* longer identifies the intended element. A canvas rewrite (`/design:edit`
|
|
210
|
+
* regenerating JSX) renumbers `data-cd-id` — it's an AST-position fingerprint,
|
|
211
|
+
* not a stable identity (DDR-019) — so the id can end up on an unrelated
|
|
212
|
+
* element, or on none at all. This scores every stamped element in the
|
|
213
|
+
* artboard by how much of its live `domPath()` matches the STORED path
|
|
214
|
+
* (as a trailing run) plus authored-class overlap, and requires the leaf tag
|
|
215
|
+
* to match. Returns null when nothing scores above zero (no plausible match —
|
|
216
|
+
* the caller should treat the target as gone).
|
|
217
|
+
*/
|
|
218
|
+
export function resolveByDomPath(
|
|
219
|
+
doc: Document,
|
|
220
|
+
opts: { artboardId?: string | null; tag?: string; classes?: string; dom_path?: string[] }
|
|
221
|
+
): Element | null {
|
|
222
|
+
const storedPath = opts.dom_path;
|
|
223
|
+
const wantTag = (opts.tag || '').toLowerCase();
|
|
224
|
+
if (!storedPath || storedPath.length === 0 || !wantTag) return null;
|
|
225
|
+
let scope: ParentNode = doc;
|
|
226
|
+
if (opts.artboardId) {
|
|
227
|
+
const artboard = doc.querySelector(`[data-dc-screen="${opts.artboardId}"]`);
|
|
228
|
+
if (artboard) scope = artboard;
|
|
229
|
+
}
|
|
230
|
+
const wantClasses = (opts.classes || '').split(/\s+/).filter(Boolean);
|
|
231
|
+
let best: Element | null = null;
|
|
232
|
+
let bestScore = 0;
|
|
233
|
+
for (const el of Array.from(scope.querySelectorAll('[data-cd-id]'))) {
|
|
234
|
+
if (el.tagName.toLowerCase() !== wantTag) continue;
|
|
235
|
+
const suffix = matchingSuffixLength(domPath(el), storedPath);
|
|
236
|
+
if (suffix === 0) continue;
|
|
237
|
+
const liveClasses = realClasses(el).split(/\s+/).filter(Boolean);
|
|
238
|
+
const overlap = wantClasses.filter((c) => liveClasses.includes(c)).length;
|
|
239
|
+
const score = suffix * 10 + overlap;
|
|
240
|
+
if (score > bestScore) {
|
|
241
|
+
bestScore = score;
|
|
242
|
+
best = el;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return best;
|
|
246
|
+
}
|
|
247
|
+
|
|
178
248
|
/**
|
|
179
249
|
* Phase 12.2 — style maps for the CSS-knob properties. `authored` is what the
|
|
180
250
|
* element sets INLINE (React renders `style={{padding:8}}` → `style="padding:8px"`),
|
|
@@ -193,9 +263,18 @@ const KNOB_PROPS = [
|
|
|
193
263
|
// Layout
|
|
194
264
|
'display',
|
|
195
265
|
'flex-direction',
|
|
266
|
+
'flex-wrap',
|
|
196
267
|
'align-items',
|
|
197
268
|
'justify-content',
|
|
198
269
|
'gap',
|
|
270
|
+
// Stage M — flex-CHILD props (sizing mode Fill + the Auto-layout child rows).
|
|
271
|
+
// Shown only when the PARENT is flex (Selection.parentDisplay); captured here so
|
|
272
|
+
// they round-trip. `flex` shorthand is listed for customStyles-exclusion only.
|
|
273
|
+
'flex',
|
|
274
|
+
'flex-grow',
|
|
275
|
+
'flex-shrink',
|
|
276
|
+
'flex-basis',
|
|
277
|
+
'align-self',
|
|
199
278
|
// Typography
|
|
200
279
|
'font-family',
|
|
201
280
|
'color',
|
|
@@ -219,7 +298,10 @@ const KNOB_PROPS = [
|
|
|
219
298
|
// Size
|
|
220
299
|
'width',
|
|
221
300
|
'height',
|
|
301
|
+
'min-width',
|
|
302
|
+
'min-height',
|
|
222
303
|
'max-width',
|
|
304
|
+
'max-height',
|
|
223
305
|
// Appearance
|
|
224
306
|
'background-color',
|
|
225
307
|
'border-radius',
|
|
@@ -232,6 +314,32 @@ const KNOB_PROPS = [
|
|
|
232
314
|
'border-color',
|
|
233
315
|
'box-shadow',
|
|
234
316
|
'opacity',
|
|
317
|
+
// feature-element-editing-robustness Stage B — promotes DDR-104 §3's OUT-list
|
|
318
|
+
// into curated rows (superseded by the Stage-G DDR). Adding them here captures
|
|
319
|
+
// their authored/computed values for the new panel controls AND moves them out
|
|
320
|
+
// of the Advanced "customStyles" hatch (a canvas that carried one as a raw
|
|
321
|
+
// custom prop now surfaces it in its curated row instead).
|
|
322
|
+
// Position + stacking
|
|
323
|
+
'position',
|
|
324
|
+
'top',
|
|
325
|
+
'right',
|
|
326
|
+
'bottom',
|
|
327
|
+
'left',
|
|
328
|
+
'z-index',
|
|
329
|
+
// Transform
|
|
330
|
+
'transform',
|
|
331
|
+
'transform-origin',
|
|
332
|
+
// Typography (extra)
|
|
333
|
+
'font-style',
|
|
334
|
+
'text-transform',
|
|
335
|
+
'text-decoration',
|
|
336
|
+
'white-space',
|
|
337
|
+
// Overflow
|
|
338
|
+
'overflow',
|
|
339
|
+
// Media framing
|
|
340
|
+
'object-fit',
|
|
341
|
+
'aspect-ratio',
|
|
342
|
+
'object-position',
|
|
235
343
|
] as const;
|
|
236
344
|
|
|
237
345
|
// Phase 12.3 — HTML attributes the custom-attribute hatch may have written, so a
|
|
@@ -245,6 +353,8 @@ function styleMapsFor(el: Element | null): {
|
|
|
245
353
|
computed: Record<string, string>;
|
|
246
354
|
customStyles: Record<string, string>;
|
|
247
355
|
attrs: Record<string, string>;
|
|
356
|
+
parentDisplay?: string;
|
|
357
|
+
parentFlexDirection?: string;
|
|
248
358
|
} {
|
|
249
359
|
if (!el || typeof window === 'undefined' || !window.getComputedStyle) {
|
|
250
360
|
return { authored: {}, computed: {}, customStyles: {}, attrs: {} };
|
|
@@ -283,7 +393,16 @@ function styleMapsFor(el: Element | null): {
|
|
|
283
393
|
if (ATTR_SKIP.test(a.name)) continue;
|
|
284
394
|
attrs[a.name] = a.value;
|
|
285
395
|
}
|
|
286
|
-
|
|
396
|
+
// Stage M — parent's layout context (for the Fixed/Hug/Fill sizing control +
|
|
397
|
+
// flex-child row gating). Read here because the shell can't reach the
|
|
398
|
+
// cross-origin iframe to compute it after selection.
|
|
399
|
+
const parent = (el as HTMLElement).parentElement;
|
|
400
|
+
const parentDisplay = parent ? window.getComputedStyle(parent).display : '';
|
|
401
|
+
const parentFlexDirection =
|
|
402
|
+
parent && (parentDisplay === 'flex' || parentDisplay === 'inline-flex')
|
|
403
|
+
? window.getComputedStyle(parent).flexDirection
|
|
404
|
+
: '';
|
|
405
|
+
return { authored, computed, customStyles, attrs, parentDisplay, parentFlexDirection };
|
|
287
406
|
} catch {
|
|
288
407
|
return { authored: {}, computed: {}, customStyles: {}, attrs: {} };
|
|
289
408
|
}
|
|
@@ -336,6 +455,13 @@ export function hoverTargetToSelection(target: HoverTarget, file?: string): Sele
|
|
|
336
455
|
h: Math.round(rect.height),
|
|
337
456
|
}
|
|
338
457
|
: null,
|
|
458
|
+
// WORLD-unit size — `offsetWidth`/`offsetHeight` are the element's own local
|
|
459
|
+
// pixel box, unaffected by an ancestor's `.dc-world` zoom transform (unlike
|
|
460
|
+
// `bounds`, which is the SCREEN rect and lies at any zoom other than 100%).
|
|
461
|
+
// The Inspector's artboard-resize fields (Stage D4 tail) need the true
|
|
462
|
+
// JSX-authored width/height to pre-fill correctly regardless of zoom.
|
|
463
|
+
worldW: el instanceof HTMLElement ? Math.round(el.offsetWidth) : undefined,
|
|
464
|
+
worldH: el instanceof HTMLElement ? Math.round(el.offsetHeight) : undefined,
|
|
339
465
|
html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
|
|
340
466
|
...styleMapsFor(el),
|
|
341
467
|
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file drag-state.ts
|
|
3
|
+
* @purpose Tiny shared module-scope flag: is an in-canvas ELEMENT drag
|
|
4
|
+
* (`ReorderDrag` — reorder or out-of-flow reposition) currently
|
|
5
|
+
* active? Consulted by per-frame rAF overlays (`ElementResizeOverlay`,
|
|
6
|
+
* `SpacingHandlesOverlay`) so they can skip their own
|
|
7
|
+
* getBoundingClientRect/getComputedStyle work while the drag's OWN
|
|
8
|
+
* live-preview chrome is what matters — avoids compounding rAF cost
|
|
9
|
+
* during the single most DOM-churn-heavy gesture the canvas has
|
|
10
|
+
* (dogfood 2026-07-07: "hrozně zavaší ten toolbar" during reorder).
|
|
11
|
+
* A standalone module (not exported from canvas-shell.tsx) so the
|
|
12
|
+
* overlay files can import it without a circular import back into
|
|
13
|
+
* canvas-shell.tsx (which imports THEM).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
let active = false;
|
|
17
|
+
|
|
18
|
+
export function setElementDragActive(v: boolean): void {
|
|
19
|
+
active = v;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isElementDragActive(): boolean {
|
|
23
|
+
return active;
|
|
24
|
+
}
|
|
@@ -96,3 +96,208 @@ export function detectEqualSpacing(
|
|
|
96
96
|
|
|
97
97
|
return { axis, gapPx: median, midpoints };
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
// Task L7 — Alt-hover distance measurement between exactly TWO rects (the
|
|
102
|
+
// selected element + whatever's hovered). Same pairwise-gap formula as
|
|
103
|
+
// `detectEqualSpacing`'s loop body, but without its 3-rect / equal-tolerance
|
|
104
|
+
// preconditions, and shaped for line-painting rather than midpoint dots.
|
|
105
|
+
|
|
106
|
+
export interface PairGap {
|
|
107
|
+
axis: SpacingAxis;
|
|
108
|
+
/** The gap between the two rects' facing edges, in the caller's coord space. */
|
|
109
|
+
gap: number;
|
|
110
|
+
/** Leading edge of the gap span — `left` for an x-axis line, `top` for y. */
|
|
111
|
+
from: number;
|
|
112
|
+
/** Perpendicular-axis center of the pair — `top` for an x-axis line
|
|
113
|
+
* (vertically centers it between the two rects), `left` for y. */
|
|
114
|
+
cross: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Distance between two rects along one axis. Returns `null` when they
|
|
119
|
+
* overlap on that axis (no meaningful gap — e.g. two rects stacked only on
|
|
120
|
+
* y have no x-axis gap to show).
|
|
121
|
+
*/
|
|
122
|
+
export function computePairGap(a: Rect, b: Rect, axis: SpacingAxis): PairGap | null {
|
|
123
|
+
const [first, second] =
|
|
124
|
+
axis === 'x' ? (a.x <= b.x ? [a, b] : [b, a]) : a.y <= b.y ? [a, b] : [b, a];
|
|
125
|
+
if (axis === 'x') {
|
|
126
|
+
const gap = second.x - (first.x + first.w);
|
|
127
|
+
if (gap <= 0) return null;
|
|
128
|
+
return {
|
|
129
|
+
axis,
|
|
130
|
+
gap,
|
|
131
|
+
from: first.x + first.w,
|
|
132
|
+
cross: (first.y + first.h / 2 + second.y + second.h / 2) / 2,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const gap = second.y - (first.y + first.h);
|
|
136
|
+
if (gap <= 0) return null;
|
|
137
|
+
return {
|
|
138
|
+
axis,
|
|
139
|
+
gap,
|
|
140
|
+
from: first.y + first.h,
|
|
141
|
+
cross: (first.x + first.w / 2 + second.x + second.w / 2) / 2,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
// Task L5 — distribute / align / tidy-up for a multi-selection of ELEMENTS.
|
|
147
|
+
//
|
|
148
|
+
// These mirror the artboard `distributeArtboards`/`alignArtboards` algorithms
|
|
149
|
+
// in canvas-shell.tsx verbatim, generalized from `ArtboardRect` to any rect
|
|
150
|
+
// set. Kept pure/DOM-free like `detectEqualSpacing` above — the caller
|
|
151
|
+
// resolves DOM rects, calls these, then posts one `reposition-request` per
|
|
152
|
+
// moved rect through the existing element-drag write lane (no new endpoint).
|
|
153
|
+
//
|
|
154
|
+
// `key` is an OPAQUE caller-supplied correlation token (not a `data-cd-id`) —
|
|
155
|
+
// a shared-component instance's cd-id can repeat across selections
|
|
156
|
+
// (disambiguated only by occurrence index), so matching results back to the
|
|
157
|
+
// caller's own bookkeeping by `id` would collide. Callers pass e.g. the
|
|
158
|
+
// selection's array index as `key` and zip the result back themselves.
|
|
159
|
+
|
|
160
|
+
export interface KeyedRect extends Rect {
|
|
161
|
+
key: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type AlignAxisMode = 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y';
|
|
165
|
+
|
|
166
|
+
export interface MovedRect {
|
|
167
|
+
key: string;
|
|
168
|
+
x: number;
|
|
169
|
+
y: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Align 2+ rects to a common edge/midline of their union bbox. Returns only
|
|
174
|
+
* the rects whose position actually changes (rounded-equal = no-op, skipped).
|
|
175
|
+
*/
|
|
176
|
+
export function computeAlign(rects: KeyedRect[], mode: AlignAxisMode): MovedRect[] {
|
|
177
|
+
if (rects.length < 2) return [];
|
|
178
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
179
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
180
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
181
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
182
|
+
for (const r of rects) {
|
|
183
|
+
if (r.x < xMin) xMin = r.x;
|
|
184
|
+
if (r.y < yMin) yMin = r.y;
|
|
185
|
+
if (r.x + r.w > xMax) xMax = r.x + r.w;
|
|
186
|
+
if (r.y + r.h > yMax) yMax = r.y + r.h;
|
|
187
|
+
}
|
|
188
|
+
const cx = (xMin + xMax) / 2;
|
|
189
|
+
const cy = (yMin + yMax) / 2;
|
|
190
|
+
const moved: MovedRect[] = [];
|
|
191
|
+
for (const r of rects) {
|
|
192
|
+
let nx = r.x;
|
|
193
|
+
let ny = r.y;
|
|
194
|
+
switch (mode) {
|
|
195
|
+
case 'left':
|
|
196
|
+
nx = xMin;
|
|
197
|
+
break;
|
|
198
|
+
case 'right':
|
|
199
|
+
nx = xMax - r.w;
|
|
200
|
+
break;
|
|
201
|
+
case 'center-x':
|
|
202
|
+
nx = cx - r.w / 2;
|
|
203
|
+
break;
|
|
204
|
+
case 'top':
|
|
205
|
+
ny = yMin;
|
|
206
|
+
break;
|
|
207
|
+
case 'bottom':
|
|
208
|
+
ny = yMax - r.h;
|
|
209
|
+
break;
|
|
210
|
+
case 'center-y':
|
|
211
|
+
ny = cy - r.h / 2;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
if (Math.round(nx) === Math.round(r.x) && Math.round(ny) === Math.round(r.y)) continue;
|
|
215
|
+
moved.push({ key: r.key, x: Math.round(nx), y: Math.round(ny) });
|
|
216
|
+
}
|
|
217
|
+
return moved;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Distribute 3+ rects with equal gaps on one axis. Sorts by leading edge,
|
|
222
|
+
* holds the first + last fixed, redistributes the middle ones so the trailing
|
|
223
|
+
* → next-leading gap is equal everywhere.
|
|
224
|
+
*/
|
|
225
|
+
export function computeDistribute(rects: KeyedRect[], axis: SpacingAxis): MovedRect[] {
|
|
226
|
+
if (rects.length < 3) return [];
|
|
227
|
+
const sorted = [...rects].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
|
|
228
|
+
const first = sorted[0];
|
|
229
|
+
const last = sorted[sorted.length - 1];
|
|
230
|
+
if (!first || !last) return [];
|
|
231
|
+
const sideLen = (r: KeyedRect) => (axis === 'x' ? r.w : r.h);
|
|
232
|
+
const totalSides = sorted.reduce((acc, r) => acc + sideLen(r), 0);
|
|
233
|
+
const span = (axis === 'x' ? last.x + last.w - first.x : last.y + last.h - first.y) - totalSides;
|
|
234
|
+
const gap = span / (sorted.length - 1);
|
|
235
|
+
const moved: MovedRect[] = [];
|
|
236
|
+
let cursor = axis === 'x' ? first.x + first.w + gap : first.y + first.h + gap;
|
|
237
|
+
for (let i = 1; i < sorted.length - 1; i++) {
|
|
238
|
+
const r = sorted[i];
|
|
239
|
+
if (!r) continue;
|
|
240
|
+
if (axis === 'x') {
|
|
241
|
+
moved.push({ key: r.key, x: Math.round(cursor), y: Math.round(r.y) });
|
|
242
|
+
cursor += r.w + gap;
|
|
243
|
+
} else {
|
|
244
|
+
moved.push({ key: r.key, x: Math.round(r.x), y: Math.round(cursor) });
|
|
245
|
+
cursor += r.h + gap;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return moved;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface TidyOptions {
|
|
252
|
+
/** Pixel gap between grid cells, both axes. Default 16. */
|
|
253
|
+
gap?: number;
|
|
254
|
+
/** Column count. Default: near-square (`round(sqrt(n))`). */
|
|
255
|
+
columns?: number;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Snap 2+ rects into a clean grid: reading-order sort (top-to-bottom, then
|
|
260
|
+
* left-to-right), laid out at a fixed gap from the union bbox's top-left,
|
|
261
|
+
* each column/row sized to its widest/tallest member so nothing overlaps.
|
|
262
|
+
*/
|
|
263
|
+
export function computeTidyGrid(rects: KeyedRect[], opts: TidyOptions = {}): MovedRect[] {
|
|
264
|
+
const n = rects.length;
|
|
265
|
+
if (n < 2) return [];
|
|
266
|
+
const gap = opts.gap ?? 16;
|
|
267
|
+
const columns = Math.max(1, Math.min(n, opts.columns ?? Math.round(Math.sqrt(n))));
|
|
268
|
+
const sorted = [...rects].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
269
|
+
const originX = Math.min(...rects.map((r) => r.x));
|
|
270
|
+
const originY = Math.min(...rects.map((r) => r.y));
|
|
271
|
+
const rows = Math.ceil(n / columns);
|
|
272
|
+
const colWidths: number[] = new Array(columns).fill(0);
|
|
273
|
+
const rowHeights: number[] = new Array(rows).fill(0);
|
|
274
|
+
sorted.forEach((r, i) => {
|
|
275
|
+
const row = Math.floor(i / columns);
|
|
276
|
+
const col = i % columns;
|
|
277
|
+
colWidths[col] = Math.max(colWidths[col] ?? 0, r.w);
|
|
278
|
+
rowHeights[row] = Math.max(rowHeights[row] ?? 0, r.h);
|
|
279
|
+
});
|
|
280
|
+
const colX: number[] = [];
|
|
281
|
+
{
|
|
282
|
+
let cursor = originX;
|
|
283
|
+
for (let c = 0; c < columns; c++) {
|
|
284
|
+
colX.push(cursor);
|
|
285
|
+
cursor += (colWidths[c] ?? 0) + gap;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const rowY: number[] = [];
|
|
289
|
+
{
|
|
290
|
+
let cursor = originY;
|
|
291
|
+
for (let r = 0; r < rows; r++) {
|
|
292
|
+
rowY.push(cursor);
|
|
293
|
+
cursor += (rowHeights[r] ?? 0) + gap;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return sorted.map((r, i) => {
|
|
297
|
+
const row = Math.floor(i / columns);
|
|
298
|
+
const col = i % columns;
|
|
299
|
+
const nx = colX[col] ?? r.x;
|
|
300
|
+
const ny = rowY[row] ?? r.y;
|
|
301
|
+
return { key: r.key, x: Math.round(nx), y: Math.round(ny) };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
@@ -271,7 +271,7 @@ const DIALOG_CSS = `
|
|
|
271
271
|
.dc-export-dialog footer button.dc-ed-primary { background: var(--maude-hud-accent, #1a1a1a); color: var(--maude-hud-accent-fg, #fff); border-color: transparent; }
|
|
272
272
|
.dc-export-dialog footer button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
273
273
|
.dc-export-dialog .dc-ed-status { padding: 8px 20px; font-size: 12px; color: var(--maude-chrome-fg-1, rgba(40,30,20,0.65)); border-top: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08)); }
|
|
274
|
-
.dc-export-dialog .dc-ed-status.is-error { color:
|
|
274
|
+
.dc-export-dialog .dc-ed-status.is-error { color: #c0392b; }
|
|
275
275
|
`;
|
|
276
276
|
|
|
277
277
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/apps/studio/history.ts
CHANGED
|
@@ -6,11 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Dirent } from 'node:fs';
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
|
-
import { readdir } from 'node:fs/promises';
|
|
9
|
+
import { readdir, unlink } from 'node:fs/promises';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
|
|
12
12
|
import type { Context } from './context.ts';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Per-slug snapshot cap (G3 security, DDR-152). The structural-edit routes
|
|
16
|
+
* (delete / insert / resize-artboard) each write a WHOLE-FILE snapshot here, and
|
|
17
|
+
* an untrusted active canvas can drive them in a loop — so `_history/<slug>/`
|
|
18
|
+
* was the uncapped disk-fill surface the adversarial review flagged. Pruning the
|
|
19
|
+
* oldest pairs bounds disk regardless of edit rate; 300 pairs is still a deep
|
|
20
|
+
* rollback runway for real work. Read lazily so tests can tune it via env.
|
|
21
|
+
*/
|
|
22
|
+
function maxSnapshotsPerSlug(): number {
|
|
23
|
+
const env = Number(process.env.MAUDE_MAX_SNAPSHOTS);
|
|
24
|
+
return Number.isFinite(env) && env > 0 ? Math.floor(env) : 300;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export interface Snapshot {
|
|
15
28
|
slug: string;
|
|
16
29
|
ts: string; // ISO
|
|
@@ -92,9 +105,42 @@ export function createHistory(ctx: Context): History {
|
|
|
92
105
|
};
|
|
93
106
|
await Bun.write(contentPath, contentBytes);
|
|
94
107
|
await Bun.write(meta.metaPath, JSON.stringify({ ...meta, file }, null, 2));
|
|
108
|
+
await pruneSnapshots(slug).catch(() => {
|
|
109
|
+
/* pruning is best-effort — never fail a snapshot over it */
|
|
110
|
+
});
|
|
95
111
|
return meta;
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Keep only the newest MAX_SNAPSHOTS_PER_SLUG snapshot pairs for a slug,
|
|
116
|
+
* unlinking the oldest content-blob + `.json` sidecar beyond the cap.
|
|
117
|
+
* Filenames are ts-derived (`tsForFilename`) so a lexical sort is chronological.
|
|
118
|
+
*/
|
|
119
|
+
async function pruneSnapshots(slug: string): Promise<void> {
|
|
120
|
+
const dir = path.join(ctx.paths.historyDir, slug);
|
|
121
|
+
let names: string[];
|
|
122
|
+
try {
|
|
123
|
+
names = await readdir(dir);
|
|
124
|
+
} catch {
|
|
125
|
+
return; // no dir yet
|
|
126
|
+
}
|
|
127
|
+
const stems = names
|
|
128
|
+
.filter((n) => n.endsWith('.json'))
|
|
129
|
+
.map((n) => n.slice(0, -'.json'.length))
|
|
130
|
+
.sort();
|
|
131
|
+
const excess = stems.length - maxSnapshotsPerSlug();
|
|
132
|
+
if (excess <= 0) return;
|
|
133
|
+
for (const stem of stems.slice(0, excess)) {
|
|
134
|
+
for (const n of names) {
|
|
135
|
+
// The meta (`<stem>.json`) and its content blob (`<stem>.<ext>`) share
|
|
136
|
+
// the stem; the fixed-length ISO stem is never a prefix of another.
|
|
137
|
+
if (n === `${stem}.json` || n.startsWith(`${stem}.`)) {
|
|
138
|
+
await unlink(path.join(dir, n)).catch(() => {});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
98
144
|
async function listSnapshots(file: string): Promise<Snapshot[]> {
|
|
99
145
|
const slug = fileSlug(file, ctx.paths.designRel);
|
|
100
146
|
const dir = path.join(ctx.paths.historyDir, slug);
|