@1agh/maude 0.40.0 → 0.42.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 -44
- 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/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- 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/screenshot.sh +53 -4
- 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 +1476 -76
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +65 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +2 -2
- 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 +19 -26
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +368 -50
- 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/server.ts +3 -1
- 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 +24 -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/dns-rebinding-guard.test.ts +27 -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/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- 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 +45 -0
- package/apps/studio/ws.ts +6 -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/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file measure-overlay.tsx — Task L7 (feature-element-editing-robustness)
|
|
3
|
+
* @scope apps/studio/measure-overlay.tsx
|
|
4
|
+
* @purpose Alt-hover distance measurement (Figma parity): with exactly one
|
|
5
|
+
* element selected, holding Alt and hovering a DIFFERENT stamped
|
|
6
|
+
* element paints a red guide line + pixel-distance pill between
|
|
7
|
+
* the two boxes' facing edges, on whichever axis(es) actually
|
|
8
|
+
* have a gap. Read-only — never writes anything, no undo, no
|
|
9
|
+
* write-surface implications.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors `equal-spacing-handles.tsx`'s fixed-rAF-follow scaffold
|
|
12
|
+
* (persistent DOM children, no React re-render per frame) and
|
|
13
|
+
* reuses its pairwise-gap sibling `computePairGap`
|
|
14
|
+
* (equal-spacing-detector.ts) for the math.
|
|
15
|
+
*
|
|
16
|
+
* Alt-tracking mirrors `use-cursor-modifiers.tsx`'s
|
|
17
|
+
* `reduceModifiers` reducer, but keeps the result in React state
|
|
18
|
+
* (gating hover RESOLUTION, a real cost) rather than a DOM
|
|
19
|
+
* attribute (that hook only drives CSS cursor swaps).
|
|
20
|
+
*
|
|
21
|
+
* Distances are printed in WORLD px (screen delta ÷ the world's
|
|
22
|
+
* zoom) so the number stays correct at any zoom level — the
|
|
23
|
+
* Stage-A host-scroll-0 invariant is what makes a screen-rect
|
|
24
|
+
* delta ÷ zoom equivalent to a true world-space measurement.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useEffect, useRef, useState } from 'react';
|
|
28
|
+
import { useArtboardsContext } from './canvas-lib.tsx';
|
|
29
|
+
import { resolveSelectionEl } from './dom-selection.ts';
|
|
30
|
+
import { computePairGap } from './equal-spacing-detector.ts';
|
|
31
|
+
import { resolveHoverTarget } from './input-router.tsx';
|
|
32
|
+
import { type ModifierState, reduceModifiers } from './use-cursor-modifiers.tsx';
|
|
33
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
34
|
+
|
|
35
|
+
const MEASURE_CSS = `
|
|
36
|
+
.dc-cv-measure-layer {
|
|
37
|
+
position: fixed;
|
|
38
|
+
inset: 0;
|
|
39
|
+
pointer-events: none;
|
|
40
|
+
z-index: 7;
|
|
41
|
+
}
|
|
42
|
+
.dc-cv-measure-line {
|
|
43
|
+
position: absolute;
|
|
44
|
+
background: #FF3B30;
|
|
45
|
+
opacity: 0;
|
|
46
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
47
|
+
}
|
|
48
|
+
.dc-cv-measure-line[data-axis="x"] { height: 1px; }
|
|
49
|
+
.dc-cv-measure-line[data-axis="y"] { width: 1px; }
|
|
50
|
+
.dc-cv-measure-line[data-on="true"] { opacity: 1; }
|
|
51
|
+
.dc-cv-measure-pill {
|
|
52
|
+
position: absolute;
|
|
53
|
+
transform: translate(-50%, -50%);
|
|
54
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
55
|
+
font-size: 10px;
|
|
56
|
+
padding: 2px 5px;
|
|
57
|
+
background: #FF3B30;
|
|
58
|
+
color: #ffffff;
|
|
59
|
+
border-radius: 2px;
|
|
60
|
+
letter-spacing: 0.02em;
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
opacity: 0;
|
|
63
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
64
|
+
}
|
|
65
|
+
.dc-cv-measure-pill[data-on="true"] { opacity: 1; }
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
.dc-cv-measure-line, .dc-cv-measure-pill { transition-duration: 1ms; }
|
|
68
|
+
}
|
|
69
|
+
`.trim();
|
|
70
|
+
|
|
71
|
+
function ensureStyles(): void {
|
|
72
|
+
if (typeof document === 'undefined') return;
|
|
73
|
+
if (document.getElementById('dc-cv-measure-css')) return;
|
|
74
|
+
const s = document.createElement('style');
|
|
75
|
+
s.id = 'dc-cv-measure-css';
|
|
76
|
+
s.textContent = MEASURE_CSS;
|
|
77
|
+
document.head.appendChild(s);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ScreenRect {
|
|
81
|
+
x: number;
|
|
82
|
+
y: number;
|
|
83
|
+
w: number;
|
|
84
|
+
h: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function rectOf(el: Element): ScreenRect | null {
|
|
88
|
+
const b = (el as HTMLElement).getBoundingClientRect();
|
|
89
|
+
if (b.width === 0 && b.height === 0) return null;
|
|
90
|
+
return { x: b.left, y: b.top, w: b.width, h: b.height };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Persistent line+pill pair for one axis — created once, toggled via
|
|
94
|
+
* `data-on` (opacity transition) rather than added/removed each frame. */
|
|
95
|
+
function ensureAxisNodes(layer: HTMLDivElement, axis: 'x' | 'y'): [HTMLDivElement, HTMLDivElement] {
|
|
96
|
+
let line = layer.querySelector<HTMLDivElement>(`.dc-cv-measure-line[data-axis="${axis}"]`);
|
|
97
|
+
let pill = layer.querySelector<HTMLDivElement>(`.dc-cv-measure-pill[data-axis="${axis}"]`);
|
|
98
|
+
if (!line) {
|
|
99
|
+
line = document.createElement('div');
|
|
100
|
+
line.className = 'dc-cv-measure-line';
|
|
101
|
+
line.dataset.axis = axis;
|
|
102
|
+
layer.appendChild(line);
|
|
103
|
+
}
|
|
104
|
+
if (!pill) {
|
|
105
|
+
pill = document.createElement('div');
|
|
106
|
+
pill.className = 'dc-cv-measure-pill';
|
|
107
|
+
pill.dataset.axis = axis;
|
|
108
|
+
layer.appendChild(pill);
|
|
109
|
+
}
|
|
110
|
+
return [line, pill];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hideAxis(layer: HTMLDivElement, axis: 'x' | 'y'): void {
|
|
114
|
+
const [line, pill] = ensureAxisNodes(layer, axis);
|
|
115
|
+
line.dataset.on = 'false';
|
|
116
|
+
pill.dataset.on = 'false';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function paintAxis(
|
|
120
|
+
layer: HTMLDivElement,
|
|
121
|
+
axis: 'x' | 'y',
|
|
122
|
+
a: ScreenRect,
|
|
123
|
+
b: ScreenRect,
|
|
124
|
+
zoom: number
|
|
125
|
+
): void {
|
|
126
|
+
const g = computePairGap(a, b, axis);
|
|
127
|
+
const [line, pill] = ensureAxisNodes(layer, axis);
|
|
128
|
+
if (!g) {
|
|
129
|
+
line.dataset.on = 'false';
|
|
130
|
+
pill.dataset.on = 'false';
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const worldGap = Math.round(g.gap / (zoom || 1));
|
|
134
|
+
if (axis === 'x') {
|
|
135
|
+
line.style.left = `${Math.round(g.from)}px`;
|
|
136
|
+
line.style.top = `${Math.round(g.cross)}px`;
|
|
137
|
+
line.style.width = `${Math.round(g.gap)}px`;
|
|
138
|
+
pill.style.left = `${Math.round(g.from + g.gap / 2)}px`;
|
|
139
|
+
pill.style.top = `${Math.round(g.cross)}px`;
|
|
140
|
+
} else {
|
|
141
|
+
line.style.left = `${Math.round(g.cross)}px`;
|
|
142
|
+
line.style.top = `${Math.round(g.from)}px`;
|
|
143
|
+
line.style.height = `${Math.round(g.gap)}px`;
|
|
144
|
+
pill.style.left = `${Math.round(g.cross)}px`;
|
|
145
|
+
pill.style.top = `${Math.round(g.from + g.gap / 2)}px`;
|
|
146
|
+
}
|
|
147
|
+
pill.textContent = `${worldGap}`;
|
|
148
|
+
line.dataset.on = 'true';
|
|
149
|
+
pill.dataset.on = 'true';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function MeasureOverlay() {
|
|
153
|
+
ensureStyles();
|
|
154
|
+
const { selected } = useSelectionSet();
|
|
155
|
+
const world = useArtboardsContext();
|
|
156
|
+
const layerRef = useRef<HTMLDivElement | null>(null);
|
|
157
|
+
const [alt, setAlt] = useState(false);
|
|
158
|
+
const [hoverEl, setHoverEl] = useState<Element | null>(null);
|
|
159
|
+
|
|
160
|
+
// Alt + hover tracking, gated together: hover RESOLUTION (a real per-move
|
|
161
|
+
// cost — resolveHoverTarget walks the DOM) only runs while Alt is held.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (typeof document === 'undefined') return;
|
|
164
|
+
let mods: ModifierState = { alt: false, shift: false, meta: false };
|
|
165
|
+
const onKeyChange = (e: KeyboardEvent) => {
|
|
166
|
+
const next = reduceModifiers(mods, e);
|
|
167
|
+
if (next === mods) return;
|
|
168
|
+
mods = next;
|
|
169
|
+
setAlt(mods.alt);
|
|
170
|
+
if (!mods.alt) setHoverEl(null);
|
|
171
|
+
};
|
|
172
|
+
const onPointer = (e: PointerEvent) => {
|
|
173
|
+
const next = reduceModifiers(mods, e);
|
|
174
|
+
if (next !== mods) {
|
|
175
|
+
mods = next;
|
|
176
|
+
setAlt(mods.alt);
|
|
177
|
+
}
|
|
178
|
+
if (!mods.alt) {
|
|
179
|
+
setHoverEl(null);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const t = resolveHoverTarget(document, e.clientX, e.clientY, { deep: true });
|
|
183
|
+
setHoverEl(t?.el ?? null);
|
|
184
|
+
};
|
|
185
|
+
const onBlur = () => {
|
|
186
|
+
mods = { alt: false, shift: false, meta: false };
|
|
187
|
+
setAlt(false);
|
|
188
|
+
setHoverEl(null);
|
|
189
|
+
};
|
|
190
|
+
document.addEventListener('keydown', onKeyChange, true);
|
|
191
|
+
document.addEventListener('keyup', onKeyChange, true);
|
|
192
|
+
document.addEventListener('pointermove', onPointer, { passive: true });
|
|
193
|
+
window.addEventListener('blur', onBlur);
|
|
194
|
+
return () => {
|
|
195
|
+
document.removeEventListener('keydown', onKeyChange, true);
|
|
196
|
+
document.removeEventListener('keyup', onKeyChange, true);
|
|
197
|
+
document.removeEventListener('pointermove', onPointer);
|
|
198
|
+
window.removeEventListener('blur', onBlur);
|
|
199
|
+
};
|
|
200
|
+
}, []);
|
|
201
|
+
|
|
202
|
+
const one = selected.length === 1 ? selected[0] : null;
|
|
203
|
+
const zoom = world?.viewport?.zoom || 1;
|
|
204
|
+
const active = alt && !!one && !!hoverEl;
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const layer = layerRef.current;
|
|
208
|
+
if (!layer) return;
|
|
209
|
+
const hideBoth = () => {
|
|
210
|
+
hideAxis(layer, 'x');
|
|
211
|
+
hideAxis(layer, 'y');
|
|
212
|
+
};
|
|
213
|
+
if (!active || !one || !hoverEl) {
|
|
214
|
+
hideBoth();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
let alive = true;
|
|
218
|
+
let rafId: number | null = null;
|
|
219
|
+
const tick = () => {
|
|
220
|
+
rafId = null;
|
|
221
|
+
if (!alive) return;
|
|
222
|
+
const selEl = resolveSelectionEl(document, one);
|
|
223
|
+
const a = selEl && selEl !== hoverEl && hoverEl.isConnected ? rectOf(selEl) : null;
|
|
224
|
+
const b = a ? rectOf(hoverEl) : null;
|
|
225
|
+
if (a && b) {
|
|
226
|
+
paintAxis(layer, 'x', a, b, zoom);
|
|
227
|
+
paintAxis(layer, 'y', a, b, zoom);
|
|
228
|
+
} else {
|
|
229
|
+
hideBoth();
|
|
230
|
+
}
|
|
231
|
+
rafId = requestAnimationFrame(tick);
|
|
232
|
+
};
|
|
233
|
+
rafId = requestAnimationFrame(tick);
|
|
234
|
+
return () => {
|
|
235
|
+
alive = false;
|
|
236
|
+
if (rafId != null) cancelAnimationFrame(rafId);
|
|
237
|
+
};
|
|
238
|
+
}, [active, one, hoverEl, zoom]);
|
|
239
|
+
|
|
240
|
+
return <div ref={layerRef} className="dc-cv-measure-layer" aria-hidden="true" />;
|
|
241
|
+
}
|
|
@@ -29,7 +29,7 @@ const CHROME_CSS = `
|
|
|
29
29
|
display: flex;
|
|
30
30
|
align-items: center;
|
|
31
31
|
pointer-events: auto;
|
|
32
|
-
font-family:
|
|
32
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
33
33
|
}
|
|
34
34
|
.dc-participant {
|
|
35
35
|
width: 26px;
|
|
@@ -74,7 +74,7 @@ const CHROME_CSS = `
|
|
|
74
74
|
background: var(--maude-chrome-bg-1, #fff);
|
|
75
75
|
color: var(--maude-chrome-fg-0, #111);
|
|
76
76
|
border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16));
|
|
77
|
-
border-radius:
|
|
77
|
+
border-radius: 4px;
|
|
78
78
|
font-size: 12px;
|
|
79
79
|
line-height: 1.35;
|
|
80
80
|
white-space: nowrap;
|
|
@@ -93,7 +93,7 @@ const CHROME_CSS = `
|
|
|
93
93
|
background: var(--maude-hud-accent, oklch(56% 0.170 50));
|
|
94
94
|
color: var(--maude-hud-accent-fg, #fff);
|
|
95
95
|
border: none;
|
|
96
|
-
border-radius:
|
|
96
|
+
border-radius: 2px;
|
|
97
97
|
font-family: inherit;
|
|
98
98
|
font-weight: 500;
|
|
99
99
|
font-size: 11px;
|
package/apps/studio/server.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts'
|
|
|
25
25
|
import { createGitLifecycle } from './collab/git-lifecycle.ts';
|
|
26
26
|
import { createCollab } from './collab/index.ts';
|
|
27
27
|
import { createContext, reloadConfig } from './context.ts';
|
|
28
|
+
import { createExportJobQueue } from './exporters/jobs.ts';
|
|
28
29
|
import { createFsWatch } from './fs-watch.ts';
|
|
29
30
|
import { createGitWatch } from './git/watch.ts';
|
|
30
31
|
import { createHttp } from './http.ts';
|
|
@@ -105,7 +106,8 @@ const activity = createActivity(ctx);
|
|
|
105
106
|
// rca/issue-canvas-hmr-optimistic-update-consistency).
|
|
106
107
|
const acp = createAcp(ctx, aiActivity);
|
|
107
108
|
const ws = createWs(ctx, api, inspect, collab, activity, acp);
|
|
108
|
-
const
|
|
109
|
+
const exportJobs = createExportJobQueue(ctx.bus, ctx.paths.designRoot);
|
|
110
|
+
const http = createHttp(ctx, api, inspect, aiActivity, exportJobs);
|
|
109
111
|
const fsWatch = createFsWatch(ctx);
|
|
110
112
|
|
|
111
113
|
// Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file sizing-mode.ts
|
|
3
|
+
* @purpose Pure Fixed / Hug / Fill sizing-mode logic for the CssKnobs Size
|
|
4
|
+
* control (feature-element-editing-robustness Stage M1). Kept framework-
|
|
5
|
+
* free + in a .ts file so it's unit-testable (app.jsx is bundled JSX).
|
|
6
|
+
*
|
|
7
|
+
* Figma auto-layout parity: a per-axis Fixed / Hug / Fill mode is the single
|
|
8
|
+
* biggest source of layout-behaviour clarity for React/flexbox mockups.
|
|
9
|
+
* - Fixed = an explicit width/height value.
|
|
10
|
+
* - Hug = shrink to children (`fit-content`).
|
|
11
|
+
* - Fill = stretch to fill the parent — and the CORRECT CSS depends on the
|
|
12
|
+
* parent's layout: a flex child fills its MAIN axis via `flex-grow`,
|
|
13
|
+
* its CROSS axis via `align-self: stretch`; a block/grid child fills
|
|
14
|
+
* via `width/height: 100%`. So the mode must know the parent context
|
|
15
|
+
* (captured at selection time in Selection.parentDisplay /
|
|
16
|
+
* .parentFlexDirection — the shell can't reach the iframe later).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type SizeAxis = 'width' | 'height';
|
|
20
|
+
export type SizeMode = 'fixed' | 'hug' | 'fill';
|
|
21
|
+
|
|
22
|
+
export interface ParentLayout {
|
|
23
|
+
display?: string;
|
|
24
|
+
flexDirection?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HUG_VALUES = new Set(['fit-content', 'max-content', 'min-content']);
|
|
28
|
+
const FILL_VALUES = new Set(['100%', 'fill', '-webkit-fill-available', 'stretch']);
|
|
29
|
+
|
|
30
|
+
function parentIsFlex(p: ParentLayout | undefined): boolean {
|
|
31
|
+
const d = (p?.display || '').trim();
|
|
32
|
+
return d === 'flex' || d === 'inline-flex';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `axis` is the flex MAIN axis of the parent (row → width, column → height). */
|
|
36
|
+
export function isMainAxis(axis: SizeAxis, p: ParentLayout | undefined): boolean {
|
|
37
|
+
const dir = (p?.flexDirection || 'row').trim();
|
|
38
|
+
const rowMain = dir.startsWith('row'); // row / row-reverse
|
|
39
|
+
return rowMain ? axis === 'width' : axis === 'height';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort classification of the current sizing mode for the segmented
|
|
44
|
+
* control's active-state highlight. Reads AUTHORED values first (what the user
|
|
45
|
+
* set), so a value the panel wrote round-trips; defaults to `fixed`. Detection is
|
|
46
|
+
* advisory — the WRITE side (`sizingModePatch`) is what must be deterministic.
|
|
47
|
+
*/
|
|
48
|
+
export function sizingModeOf(
|
|
49
|
+
axis: SizeAxis,
|
|
50
|
+
authored: Record<string, string> = {},
|
|
51
|
+
_computed: Record<string, string> = {},
|
|
52
|
+
parent?: ParentLayout
|
|
53
|
+
): SizeMode {
|
|
54
|
+
const a = (authored[axis] || '').trim().toLowerCase();
|
|
55
|
+
if (HUG_VALUES.has(a)) return 'hug';
|
|
56
|
+
if (FILL_VALUES.has(a)) return 'fill';
|
|
57
|
+
if (parentIsFlex(parent)) {
|
|
58
|
+
const main = isMainAxis(axis, parent);
|
|
59
|
+
if (main && Number.parseFloat(authored['flex-grow'] || '0') >= 1) return 'fill';
|
|
60
|
+
if (!main && (authored['align-self'] || '').trim() === 'stretch') return 'fill';
|
|
61
|
+
}
|
|
62
|
+
// A numeric authored value (px/rem/%…) or nothing authored → Fixed.
|
|
63
|
+
return 'fixed';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SizingPatch {
|
|
67
|
+
/** Properties to write (in order), each `[prop, value]`. */
|
|
68
|
+
set: Array<[string, string]>;
|
|
69
|
+
/** Fill-specific properties to REMOVE so a prior Fill doesn't linger. */
|
|
70
|
+
reset: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The deterministic CSS writes for switching `axis` to `mode`. `currentPx` seeds
|
|
75
|
+
* the Fixed value (the element's current rendered size). Fill is context-aware:
|
|
76
|
+
* a flex child fills its main axis via `flex-grow:1` + `flex-basis:0%`, its cross
|
|
77
|
+
* axis via `align-self:stretch`; a block/grid child via `<axis>: 100%`.
|
|
78
|
+
*/
|
|
79
|
+
export function sizingModePatch(
|
|
80
|
+
axis: SizeAxis,
|
|
81
|
+
mode: SizeMode,
|
|
82
|
+
parent: ParentLayout | undefined,
|
|
83
|
+
currentPx: number
|
|
84
|
+
): SizingPatch {
|
|
85
|
+
const flex = parentIsFlex(parent);
|
|
86
|
+
const main = isMainAxis(axis, parent);
|
|
87
|
+
// Which fill-specific props a non-fill mode must clear so the old Fill doesn't linger.
|
|
88
|
+
const fillProps = flex ? (main ? ['flex-grow', 'flex-basis'] : ['align-self']) : [];
|
|
89
|
+
if (mode === 'fixed') {
|
|
90
|
+
const px = Number.isFinite(currentPx) && currentPx > 0 ? Math.round(currentPx) : 0;
|
|
91
|
+
return { set: [[axis, `${px}px`]], reset: fillProps };
|
|
92
|
+
}
|
|
93
|
+
if (mode === 'hug') {
|
|
94
|
+
return { set: [[axis, 'fit-content']], reset: fillProps };
|
|
95
|
+
}
|
|
96
|
+
// fill
|
|
97
|
+
if (flex) {
|
|
98
|
+
if (main) {
|
|
99
|
+
return {
|
|
100
|
+
set: [
|
|
101
|
+
['flex-grow', '1'],
|
|
102
|
+
['flex-basis', '0%'],
|
|
103
|
+
[axis, 'auto'],
|
|
104
|
+
],
|
|
105
|
+
reset: [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
set: [
|
|
110
|
+
['align-self', 'stretch'],
|
|
111
|
+
[axis, 'auto'],
|
|
112
|
+
],
|
|
113
|
+
reset: [],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { set: [[axis, '100%']], reset: [] };
|
|
117
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file spacing-handles.ts — Stage J (feature-element-editing-robustness)
|
|
3
|
+
* @purpose Pure geometry + value math for the on-canvas padding + gap drag
|
|
4
|
+
* overlay. Framework-free (unit-testable without a DOM), mirroring
|
|
5
|
+
* `equal-spacing-detector.ts`'s screen-coordinate midpoint shape so
|
|
6
|
+
* the two spacing affordances (read-only equal-spacing dots vs this
|
|
7
|
+
* draggable padding/gap overlay) share a visual vocabulary.
|
|
8
|
+
*
|
|
9
|
+
* Padding: 4 edge lines inset from the element's screen border-box by
|
|
10
|
+
* its resolved padding × zoom, one per side, each carrying a drag axis
|
|
11
|
+
* (top/bottom = vertical, left/right = horizontal).
|
|
12
|
+
* Gap: midpoints between consecutive DIRECT children along the flex
|
|
13
|
+
* main axis (row → x, column → y) — flex-only in v1; CSS-Grid gap
|
|
14
|
+
* editing is deferred to `feature-grid-track-editor.md` (Stage M3).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ScreenRect {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
w: number;
|
|
21
|
+
h: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PaddingSide = 'top' | 'right' | 'bottom' | 'left';
|
|
25
|
+
|
|
26
|
+
export interface PaddingLine {
|
|
27
|
+
side: PaddingSide;
|
|
28
|
+
/** Drag axis: top/bottom edges scrub vertically, left/right horizontally. */
|
|
29
|
+
axis: 'x' | 'y';
|
|
30
|
+
/** Line midpoint in screen coords (the handle's paint position). */
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
/** Line length in screen px (spans the box's inner width/height on that side). */
|
|
34
|
+
length: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute the 4 padding-edge handle positions from the element's screen
|
|
39
|
+
* border-box rect + its resolved padding (WORLD px, pre-zoom) + the render
|
|
40
|
+
* zoom. A handle sits at the midpoint of its edge, INSET from the border by
|
|
41
|
+
* `padding × zoom` — i.e. right on the padding/content boundary, so dragging it
|
|
42
|
+
* outward (toward the border) shrinks padding, inward (toward center) grows it.
|
|
43
|
+
*/
|
|
44
|
+
export function computePaddingLines(
|
|
45
|
+
rect: ScreenRect,
|
|
46
|
+
padding: { top: number; right: number; bottom: number; left: number },
|
|
47
|
+
zoom: number
|
|
48
|
+
): PaddingLine[] {
|
|
49
|
+
const z = zoom > 0 ? zoom : 1;
|
|
50
|
+
const pt = padding.top * z;
|
|
51
|
+
const pr = padding.right * z;
|
|
52
|
+
const pb = padding.bottom * z;
|
|
53
|
+
const pl = padding.left * z;
|
|
54
|
+
return [
|
|
55
|
+
{ side: 'top', axis: 'y', x: rect.x + rect.w / 2, y: rect.y + pt, length: rect.w - pl - pr },
|
|
56
|
+
{
|
|
57
|
+
side: 'right',
|
|
58
|
+
axis: 'x',
|
|
59
|
+
x: rect.x + rect.w - pr,
|
|
60
|
+
y: rect.y + rect.h / 2,
|
|
61
|
+
length: rect.h - pt - pb,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
side: 'bottom',
|
|
65
|
+
axis: 'y',
|
|
66
|
+
x: rect.x + rect.w / 2,
|
|
67
|
+
y: rect.y + rect.h - pb,
|
|
68
|
+
length: rect.w - pl - pr,
|
|
69
|
+
},
|
|
70
|
+
{ side: 'left', axis: 'x', x: rect.x + pl, y: rect.y + rect.h / 2, length: rect.h - pt - pb },
|
|
71
|
+
];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* New padding value (WORLD px, clamped ≥ 0) for a drag on `side`, given the
|
|
76
|
+
* screen-space cursor delta and the render zoom. Top/bottom respond to
|
|
77
|
+
* vertical delta, left/right to horizontal — and the SIGN flips for
|
|
78
|
+
* bottom/right (dragging toward the element's center — up for bottom, left for
|
|
79
|
+
* right — GROWS that side's padding, mirroring how top/left already grow when
|
|
80
|
+
* dragged down/right).
|
|
81
|
+
*/
|
|
82
|
+
export function computePaddingDrag(
|
|
83
|
+
side: PaddingSide,
|
|
84
|
+
startValue: number,
|
|
85
|
+
dxScreen: number,
|
|
86
|
+
dyScreen: number,
|
|
87
|
+
zoom: number
|
|
88
|
+
): number {
|
|
89
|
+
const z = zoom > 0 ? zoom : 1;
|
|
90
|
+
const d = side === 'top' || side === 'bottom' ? dyScreen / z : dxScreen / z;
|
|
91
|
+
const signed = side === 'bottom' || side === 'right' ? -d : d;
|
|
92
|
+
return Math.max(0, round2(startValue + signed));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type FlexAxis = 'x' | 'y';
|
|
96
|
+
|
|
97
|
+
/** Row/row-reverse → x (horizontal main axis); column/column-reverse → y. */
|
|
98
|
+
export function flexMainAxis(flexDirection: string | null | undefined): FlexAxis {
|
|
99
|
+
const dir = (flexDirection || 'row').trim();
|
|
100
|
+
return dir.startsWith('column') ? 'y' : 'x';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Midpoints between consecutive DIRECT children's screen rects, along `axis`.
|
|
105
|
+
* Mirrors `detectEqualSpacing`'s midpoint shape but needs no equal-spacing
|
|
106
|
+
* DETECTION (there's exactly one `gap` value to visualize, not several to
|
|
107
|
+
* confirm) — just the geometric midpoint of each adjacent pair's facing edges.
|
|
108
|
+
* Fewer than 2 children → no gaps to show.
|
|
109
|
+
*/
|
|
110
|
+
export function computeGapMidpoints(
|
|
111
|
+
childRects: ScreenRect[],
|
|
112
|
+
axis: FlexAxis
|
|
113
|
+
): Array<{ x: number; y: number }> {
|
|
114
|
+
if (childRects.length < 2) return [];
|
|
115
|
+
const sorted = [...childRects].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
|
|
116
|
+
const out: Array<{ x: number; y: number }> = [];
|
|
117
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
118
|
+
const a = sorted[i] as ScreenRect;
|
|
119
|
+
const b = sorted[i + 1] as ScreenRect;
|
|
120
|
+
if (axis === 'x') {
|
|
121
|
+
out.push({ x: (a.x + a.w + b.x) / 2, y: a.y + a.h / 2 });
|
|
122
|
+
} else {
|
|
123
|
+
out.push({ x: a.x + a.w / 2, y: (a.y + a.h + b.y) / 2 });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** New `gap` value (WORLD px, clamped ≥ 0) for a drag along `axis`. Gap is a
|
|
130
|
+
* single shared CSS value, so every handle drags the SAME number 1:1 with the
|
|
131
|
+
* cursor (no doubling/halving — dragging a handle N screen px outward should
|
|
132
|
+
* feel like N world px of extra breathing room). */
|
|
133
|
+
export function computeGapDrag(
|
|
134
|
+
axis: FlexAxis,
|
|
135
|
+
startValue: number,
|
|
136
|
+
dxScreen: number,
|
|
137
|
+
dyScreen: number,
|
|
138
|
+
zoom: number
|
|
139
|
+
): number {
|
|
140
|
+
const z = zoom > 0 ? zoom : 1;
|
|
141
|
+
const d = axis === 'x' ? dxScreen / z : dyScreen / z;
|
|
142
|
+
return Math.max(0, round2(startValue + d));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function round2(n: number): number {
|
|
146
|
+
return Math.round(n * 100) / 100;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Which padding sides a drag on `side` touches, given the held modifiers —
|
|
151
|
+
* matches the CSS-panel box-model widget's OWN scrub grammar exactly (`side()`
|
|
152
|
+
* in `client/app.jsx`, `title="drag to scrub · alt = symmetric · alt+shift =
|
|
153
|
+
* all sides"`): plain drag = just this side; Alt = the symmetric axis PAIR
|
|
154
|
+
* (top+bottom, or left+right); Alt+Shift = all four. Every touched side is set
|
|
155
|
+
* to the SAME value (not each grown by its own delta) — same semantics as the
|
|
156
|
+
* panel's `sidesFor`/`last` write.
|
|
157
|
+
*/
|
|
158
|
+
export function paddingSideSet(
|
|
159
|
+
side: PaddingSide,
|
|
160
|
+
altKey: boolean,
|
|
161
|
+
shiftKey: boolean
|
|
162
|
+
): PaddingSide[] {
|
|
163
|
+
if (altKey && shiftKey) return ['top', 'right', 'bottom', 'left'];
|
|
164
|
+
if (altKey) return side === 'top' || side === 'bottom' ? ['top', 'bottom'] : ['left', 'right'];
|
|
165
|
+
return [side];
|
|
166
|
+
}
|