@1agh/maude 0.45.2 → 0.47.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/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +151 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/annotations-snap.ts +50 -10
- package/apps/studio/api.ts +760 -15
- package/apps/studio/artboard-guides-overlay.tsx +270 -0
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/_pdf-playwright.mjs +35 -4
- package/apps/studio/bin/_png-playwright.mjs +38 -4
- package/apps/studio/bin/_pw-launch.mjs +52 -0
- package/apps/studio/bin/_pw-launch.test.mjs +90 -0
- package/apps/studio/bin/_smart-frames.mjs +419 -0
- package/apps/studio/bin/_smart-frames.test.mjs +140 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/bin/smart-frames.sh +30 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +972 -6
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +206 -11
- package/apps/studio/canvas-shell.tsx +809 -50
- package/apps/studio/client/app.jsx +1554 -206
- package/apps/studio/client/panels/ChatPanel.jsx +92 -3
- package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
- package/apps/studio/client/panels/acp-capabilities.js +11 -0
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/client/styles/6-acp-chat.css +41 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1627 -1627
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +20 -0
- package/apps/studio/export-dialog.tsx +138 -18
- package/apps/studio/exporters/pdf.ts +332 -18
- package/apps/studio/exporters/png.ts +45 -3
- package/apps/studio/footage/schema.ts +17 -1
- package/apps/studio/generation/gemma-models.ts +224 -0
- package/apps/studio/generation/prefs.ts +43 -0
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +331 -7
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/print/marks.ts +113 -0
- package/apps/studio/print/units.ts +269 -0
- package/apps/studio/print-overlay-content.tsx +132 -0
- package/apps/studio/test/acp-mode-banner.test.ts +45 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
- package/apps/studio/test/annotations-snap.test.ts +56 -0
- package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
- package/apps/studio/test/artboard-kinds.test.tsx +83 -0
- package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +237 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +363 -0
- package/apps/studio/test/exporters/png.test.ts +49 -1
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
- package/apps/studio/test/print-marks.test.ts +113 -0
- package/apps/studio/test/print-units.test.ts +173 -0
- package/apps/studio/test/use-snap-guides.test.ts +81 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-chrome-visibility.tsx +19 -0
- package/apps/studio/use-element-resize.tsx +24 -3
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-snap-guides.tsx +73 -5
- package/apps/studio/use-spacing-handles.tsx +9 -5
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +55 -0
- package/cli/commands/design.mjs +25 -1
- package/package.json +8 -8
- package/plugins/design/dependencies.json +35 -0
- package/plugins/design/templates/_shell.html +4 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-grid-track-handles.tsx — feature-3-web-artboards T5
|
|
3
|
+
* (absorbed feature-grid-track-editor stub).
|
|
4
|
+
* @scope apps/studio/use-grid-track-handles.tsx
|
|
5
|
+
* @purpose On-canvas CSS-Grid gutter drag-resize — the grid sibling of
|
|
6
|
+
* `use-spacing-handles.tsx`'s padding/gap overlay, same shape:
|
|
7
|
+
* live preview via direct inline-style writes, commit rides the
|
|
8
|
+
* SAME `resize-request` lane (`grid-template-columns`/`-rows`
|
|
9
|
+
* added to the shell's `resizeElement` allowed-prop list — no
|
|
10
|
+
* new write surface, mirroring how `padding-*`/`gap` were added
|
|
11
|
+
* there for Stage J). A component-instance drag threads the
|
|
12
|
+
* Stage H3 occurrence index the same way (`globalCdOccurrence`).
|
|
13
|
+
*
|
|
14
|
+
* Gated on computed `display: grid`/`inline-grid` (CLAUDE.md
|
|
15
|
+
* gotcha: "gate all of it on computed display" — a container
|
|
16
|
+
* that was converted to absolute, or never was a grid, is not an
|
|
17
|
+
* editing target). Only tracks AUTHORED inline via
|
|
18
|
+
* `grid-template-columns`/`-rows` (parseable by
|
|
19
|
+
* `grid-track-handles.ts`'s `parseTrackList`) get drag handles —
|
|
20
|
+
* `repeat()`/`minmax()`/a stylesheet-authored grid have no
|
|
21
|
+
* drag surface here (same constraint every curated knob already
|
|
22
|
+
* has: these on-canvas tools edit inline-style-authored values
|
|
23
|
+
* only); the Inspector's Grid section still shows the raw value
|
|
24
|
+
* for those cases.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { type ReactNode, useCallback, useEffect, useRef } from 'react';
|
|
28
|
+
import { globalCdOccurrence, resolveSelectionEl } from './dom-selection.ts';
|
|
29
|
+
import { isElementDragActive } from './drag-state.ts';
|
|
30
|
+
import {
|
|
31
|
+
computeGutterLines,
|
|
32
|
+
computeTrackDrag,
|
|
33
|
+
GRID_KEYWORD_UNITS,
|
|
34
|
+
type GridTrack,
|
|
35
|
+
type GutterLine,
|
|
36
|
+
gutterTrackIndices,
|
|
37
|
+
parseTrackList,
|
|
38
|
+
serializeTrackList,
|
|
39
|
+
} from './grid-track-handles.ts';
|
|
40
|
+
import { worldZoomFor } from './use-element-resize.tsx';
|
|
41
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
42
|
+
import { useToolMode } from './use-tool-mode.tsx';
|
|
43
|
+
|
|
44
|
+
const GRID_HANDLE_CSS = `
|
|
45
|
+
.dc-grid-gutter-handle {
|
|
46
|
+
position: fixed;
|
|
47
|
+
transform: translate(-50%, -50%);
|
|
48
|
+
/* Same layering rationale as the spacing handles (z-index 4) — below the
|
|
49
|
+
Stage-D resize handles (6), which win any frame-edge hit-test tie. */
|
|
50
|
+
z-index: 4;
|
|
51
|
+
pointer-events: auto;
|
|
52
|
+
touch-action: none;
|
|
53
|
+
width: 10px;
|
|
54
|
+
height: 10px;
|
|
55
|
+
border-radius: 2px;
|
|
56
|
+
background: var(--maude-hud-accent, oklch(0.680 0.180 268));
|
|
57
|
+
border: 1px solid var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
|
|
58
|
+
opacity: 0.55;
|
|
59
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
60
|
+
}
|
|
61
|
+
.dc-grid-gutter-handle:hover,
|
|
62
|
+
.dc-grid-gutter-handle[data-dragging="true"] { opacity: 1; }
|
|
63
|
+
.dc-grid-gutter-handle[data-axis="x"] { cursor: ew-resize; }
|
|
64
|
+
.dc-grid-gutter-handle[data-axis="y"] { cursor: ns-resize; }
|
|
65
|
+
@media (prefers-reduced-motion: reduce) {
|
|
66
|
+
.dc-grid-gutter-handle { transition-duration: 1ms; }
|
|
67
|
+
}
|
|
68
|
+
`.trim();
|
|
69
|
+
|
|
70
|
+
function ensureGridHandleStyles(): void {
|
|
71
|
+
if (typeof document === 'undefined') return;
|
|
72
|
+
if (document.getElementById('dc-grid-gutter-css')) return;
|
|
73
|
+
const s = document.createElement('style');
|
|
74
|
+
s.id = 'dc-grid-gutter-css';
|
|
75
|
+
s.textContent = GRID_HANDLE_CSS;
|
|
76
|
+
document.head.appendChild(s);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function computedPx(cs: CSSStyleDeclaration, prop: string): number {
|
|
80
|
+
return Number.parseFloat(cs.getPropertyValue(prop)) || 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parse a browser-RESOLVED `grid-template-columns`/`-rows` computed value
|
|
84
|
+
* ("100px 200px 100px") into a plain px array. Never contains keywords or
|
|
85
|
+
* `fr` — the browser already ran the grid-sizing algorithm. */
|
|
86
|
+
function resolvedTrackSizes(computedValue: string): number[] {
|
|
87
|
+
const s = computedValue.trim();
|
|
88
|
+
if (!s || s === 'none') return [];
|
|
89
|
+
return s.split(/\s+/).map((tok) => Number.parseFloat(tok) || 0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface AxisState {
|
|
93
|
+
container: 'col' | 'row';
|
|
94
|
+
authored: GridTrack[];
|
|
95
|
+
resolvedPx: number[];
|
|
96
|
+
gutters: GutterLine[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface GridDrag {
|
|
100
|
+
pointerId: number;
|
|
101
|
+
axis: 'x' | 'y';
|
|
102
|
+
container: 'col' | 'row';
|
|
103
|
+
gutterIndex: number;
|
|
104
|
+
el: HTMLElement;
|
|
105
|
+
cdId: string;
|
|
106
|
+
idIndex: number;
|
|
107
|
+
startClientX: number;
|
|
108
|
+
startClientY: number;
|
|
109
|
+
zoom: number;
|
|
110
|
+
/** Snapshot of the AUTHORED track list for this axis at drag-start. */
|
|
111
|
+
tracks: GridTrack[];
|
|
112
|
+
/** Snapshot of each touched track's resolved px at drag-start. */
|
|
113
|
+
resolvedAtStart: number[];
|
|
114
|
+
touched: number[];
|
|
115
|
+
prop: 'grid-template-columns' | 'grid-template-rows';
|
|
116
|
+
before: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function GridTrackHandlesOverlay(): ReactNode {
|
|
120
|
+
ensureGridHandleStyles();
|
|
121
|
+
const { selected } = useSelectionSet();
|
|
122
|
+
const { tool } = useToolMode();
|
|
123
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
124
|
+
const rafRef = useRef<number | null>(null);
|
|
125
|
+
const dragRef = useRef<GridDrag | null>(null);
|
|
126
|
+
const lastCommitRef = useRef<{ el: HTMLElement; prop: string; before: string } | null>(null);
|
|
127
|
+
// Latest per-axis geometry, read by the pointerdown handler (it fires
|
|
128
|
+
// between rAF ticks, so it needs the most recent computed snapshot rather
|
|
129
|
+
// than recomputing getComputedStyle itself).
|
|
130
|
+
const axesRef = useRef<{ col: AxisState | null; row: AxisState | null }>({
|
|
131
|
+
col: null,
|
|
132
|
+
row: null,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const one = selected.length === 1 ? selected[0] : null;
|
|
136
|
+
const cdId = one && typeof one.id === 'string' ? one.id : null;
|
|
137
|
+
const active = tool === 'move' && !!cdId;
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const c = containerRef.current;
|
|
141
|
+
if (!c) return;
|
|
142
|
+
const hideAll = () => {
|
|
143
|
+
for (const child of Array.from(c.children)) (child as HTMLElement).style.display = 'none';
|
|
144
|
+
axesRef.current = { col: null, row: null };
|
|
145
|
+
};
|
|
146
|
+
if (!active || !one) {
|
|
147
|
+
hideAll();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const tick = () => {
|
|
151
|
+
rafRef.current = null;
|
|
152
|
+
const reschedule = () => {
|
|
153
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
154
|
+
};
|
|
155
|
+
const bail = () => {
|
|
156
|
+
hideAll();
|
|
157
|
+
reschedule();
|
|
158
|
+
};
|
|
159
|
+
if (isElementDragActive()) {
|
|
160
|
+
bail();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
164
|
+
if (!el) {
|
|
165
|
+
bail();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const cs = getComputedStyle(el);
|
|
169
|
+
const isGrid = cs.display === 'grid' || cs.display === 'inline-grid';
|
|
170
|
+
if (!isGrid) {
|
|
171
|
+
bail();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const r = el.getBoundingClientRect();
|
|
175
|
+
if (r.width <= 0 && r.height <= 0) {
|
|
176
|
+
bail();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const zoom = worldZoomFor(el);
|
|
180
|
+
const rect = { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
181
|
+
const colGap = computedPx(cs, 'column-gap') * zoom;
|
|
182
|
+
const rowGap = computedPx(cs, 'row-gap') * zoom;
|
|
183
|
+
|
|
184
|
+
const buildAxis = (
|
|
185
|
+
container: 'col' | 'row',
|
|
186
|
+
prop: string,
|
|
187
|
+
gapPx: number
|
|
188
|
+
): AxisState | null => {
|
|
189
|
+
const authored = parseTrackList(el.style.getPropertyValue(prop));
|
|
190
|
+
if (authored.length < 2) return null;
|
|
191
|
+
const resolvedPx = resolvedTrackSizes(cs.getPropertyValue(prop)).map((px) => px * zoom);
|
|
192
|
+
if (resolvedPx.length !== authored.length) return null; // repeat()/mismatch guard
|
|
193
|
+
const gutters = computeGutterLines(rect, resolvedPx, gapPx, container).filter((_, i) => {
|
|
194
|
+
const before = authored[i] as GridTrack;
|
|
195
|
+
const after = authored[i + 1] as GridTrack;
|
|
196
|
+
return !GRID_KEYWORD_UNITS.has(before.unit) || !GRID_KEYWORD_UNITS.has(after.unit);
|
|
197
|
+
});
|
|
198
|
+
if (gutters.length === 0) return null;
|
|
199
|
+
return { container, authored, resolvedPx, gutters };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const col = buildAxis('col', 'grid-template-columns', colGap);
|
|
203
|
+
const row = buildAxis('row', 'grid-template-rows', rowGap);
|
|
204
|
+
axesRef.current = { col, row };
|
|
205
|
+
|
|
206
|
+
const allGutters = [...(col?.gutters ?? []), ...(row?.gutters ?? [])];
|
|
207
|
+
const TOTAL = allGutters.length;
|
|
208
|
+
while (c.children.length < TOTAL) c.appendChild(document.createElement('div'));
|
|
209
|
+
while (c.children.length > TOTAL) c.lastChild && c.removeChild(c.lastChild);
|
|
210
|
+
let i = 0;
|
|
211
|
+
for (const [container, axisState] of [
|
|
212
|
+
['col', col],
|
|
213
|
+
['row', row],
|
|
214
|
+
] as const) {
|
|
215
|
+
const kind = container === 'col' ? 'column' : 'row';
|
|
216
|
+
for (const g of axisState?.gutters ?? []) {
|
|
217
|
+
const h = c.children[i] as HTMLElement;
|
|
218
|
+
h.className = 'dc-grid-gutter-handle';
|
|
219
|
+
h.dataset.container = container;
|
|
220
|
+
h.dataset.gutterIndex = String(g.index);
|
|
221
|
+
h.dataset.axis = g.axis;
|
|
222
|
+
h.title = `grid ${kind} gutter · drag to resize · shift = resize both neighbors`;
|
|
223
|
+
h.style.display = 'block';
|
|
224
|
+
h.style.left = `${Math.round(g.x)}px`;
|
|
225
|
+
h.style.top = `${Math.round(g.y)}px`;
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
230
|
+
};
|
|
231
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
232
|
+
return () => {
|
|
233
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
234
|
+
};
|
|
235
|
+
}, [active, one]);
|
|
236
|
+
|
|
237
|
+
const commit = useCallback((d: GridDrag, serialized: string) => {
|
|
238
|
+
lastCommitRef.current = { el: d.el, prop: d.prop, before: d.before };
|
|
239
|
+
try {
|
|
240
|
+
window.parent.postMessage(
|
|
241
|
+
{
|
|
242
|
+
dgn: 'resize-request',
|
|
243
|
+
id: d.cdId,
|
|
244
|
+
patch: { [d.prop]: serialized },
|
|
245
|
+
before: { [d.prop]: d.before },
|
|
246
|
+
idIndex: d.idIndex,
|
|
247
|
+
},
|
|
248
|
+
'*'
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
/* detached / cross-origin teardown */
|
|
252
|
+
}
|
|
253
|
+
}, []);
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
const c = containerRef.current;
|
|
257
|
+
if (!c) return;
|
|
258
|
+
|
|
259
|
+
const onDown = (e: PointerEvent) => {
|
|
260
|
+
const t = e.target as HTMLElement | null;
|
|
261
|
+
if (!t?.classList.contains('dc-grid-gutter-handle')) return;
|
|
262
|
+
if (!one || typeof one.id !== 'string') return;
|
|
263
|
+
const el = resolveSelectionEl(document, one) as HTMLElement | null;
|
|
264
|
+
if (!el) return;
|
|
265
|
+
const container = t.dataset.container as 'col' | 'row';
|
|
266
|
+
const axisState = axesRef.current[container];
|
|
267
|
+
if (!axisState) return;
|
|
268
|
+
const gutterIndex = Number(t.dataset.gutterIndex);
|
|
269
|
+
if (!Number.isFinite(gutterIndex)) return;
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
e.stopPropagation();
|
|
272
|
+
const zoom = worldZoomFor(el);
|
|
273
|
+
const prop: GridDrag['prop'] =
|
|
274
|
+
container === 'col' ? 'grid-template-columns' : 'grid-template-rows';
|
|
275
|
+
dragRef.current = {
|
|
276
|
+
pointerId: e.pointerId,
|
|
277
|
+
axis: (t.dataset.axis as 'x' | 'y') ?? 'x',
|
|
278
|
+
container,
|
|
279
|
+
gutterIndex,
|
|
280
|
+
el,
|
|
281
|
+
cdId: one.id,
|
|
282
|
+
idIndex: globalCdOccurrence(document, one.id, el),
|
|
283
|
+
startClientX: e.clientX,
|
|
284
|
+
startClientY: e.clientY,
|
|
285
|
+
zoom: zoom > 0 ? zoom : 1,
|
|
286
|
+
tracks: axisState.authored,
|
|
287
|
+
resolvedAtStart: axisState.resolvedPx,
|
|
288
|
+
touched: gutterTrackIndices(gutterIndex, false),
|
|
289
|
+
prop,
|
|
290
|
+
before: serializeTrackList(axisState.authored),
|
|
291
|
+
};
|
|
292
|
+
t.dataset.dragging = 'true';
|
|
293
|
+
try {
|
|
294
|
+
t.setPointerCapture(e.pointerId);
|
|
295
|
+
} catch {
|
|
296
|
+
/* synthetic events may reject capture */
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const previewFor = (d: GridDrag, e: PointerEvent): GridTrack[] => {
|
|
301
|
+
const dx = e.clientX - d.startClientX;
|
|
302
|
+
const dy = e.clientY - d.startClientY;
|
|
303
|
+
const touched = gutterTrackIndices(d.gutterIndex, e.shiftKey).filter(
|
|
304
|
+
(idx) => idx < d.tracks.length
|
|
305
|
+
);
|
|
306
|
+
const next = d.tracks.map((tr) => ({ ...tr }));
|
|
307
|
+
for (const idx of touched) {
|
|
308
|
+
const tr = d.tracks[idx];
|
|
309
|
+
const resolvedPx = d.resolvedAtStart[idx];
|
|
310
|
+
if (!tr || !Number.isFinite(resolvedPx) || GRID_KEYWORD_UNITS.has(tr.unit)) continue;
|
|
311
|
+
const newValue = computeTrackDrag(tr, resolvedPx as number, dx, dy, d.zoom, d.axis);
|
|
312
|
+
next[idx] = { value: newValue, unit: tr.unit };
|
|
313
|
+
}
|
|
314
|
+
return next;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const onMove = (e: PointerEvent) => {
|
|
318
|
+
const d = dragRef.current;
|
|
319
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
const next = previewFor(d, e);
|
|
322
|
+
d.el.style.setProperty(d.prop, serializeTrackList(next));
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const onUp = (e: PointerEvent) => {
|
|
326
|
+
const d = dragRef.current;
|
|
327
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
328
|
+
dragRef.current = null;
|
|
329
|
+
const handle = containerRef.current?.querySelector<HTMLElement>(
|
|
330
|
+
'.dc-grid-gutter-handle[data-dragging="true"]'
|
|
331
|
+
);
|
|
332
|
+
if (handle) delete handle.dataset.dragging;
|
|
333
|
+
const next = previewFor(d, e);
|
|
334
|
+
const serialized = serializeTrackList(next);
|
|
335
|
+
if (serialized === d.before) return; // no-op guard
|
|
336
|
+
commit(d, serialized);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const onFail = (e: MessageEvent) => {
|
|
340
|
+
const m = e.data as { dgn?: string } | null;
|
|
341
|
+
if (m?.dgn !== 'resize-failed') return;
|
|
342
|
+
if (e.source !== window.parent) return; // only the parent shell (DDR-054)
|
|
343
|
+
const last = lastCommitRef.current;
|
|
344
|
+
if (!last) return;
|
|
345
|
+
last.el.style.setProperty(last.prop, last.before);
|
|
346
|
+
lastCommitRef.current = null;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
c.addEventListener('pointerdown', onDown);
|
|
350
|
+
window.addEventListener('pointermove', onMove);
|
|
351
|
+
window.addEventListener('pointerup', onUp);
|
|
352
|
+
window.addEventListener('pointercancel', onUp);
|
|
353
|
+
window.addEventListener('message', onFail);
|
|
354
|
+
return () => {
|
|
355
|
+
c.removeEventListener('pointerdown', onDown);
|
|
356
|
+
window.removeEventListener('pointermove', onMove);
|
|
357
|
+
window.removeEventListener('pointerup', onUp);
|
|
358
|
+
window.removeEventListener('pointercancel', onUp);
|
|
359
|
+
window.removeEventListener('message', onFail);
|
|
360
|
+
};
|
|
361
|
+
}, [one, commit]);
|
|
362
|
+
|
|
363
|
+
return <div ref={containerRef} aria-hidden="true" />;
|
|
364
|
+
}
|
|
@@ -29,6 +29,7 @@ import { useArtboardsContext, useDragStateContext } from './canvas-lib.tsx';
|
|
|
29
29
|
import { resolveSelectionEl, scopedCdSelector, selectorIndex } from './dom-selection.ts';
|
|
30
30
|
import { isEditableTarget } from './input-router.tsx';
|
|
31
31
|
import { type Selection, useSelectionSet } from './use-selection-set.tsx';
|
|
32
|
+
import { useToolModeOptional } from './use-tool-mode.tsx';
|
|
32
33
|
|
|
33
34
|
const STEP_SMALL = 1;
|
|
34
35
|
const STEP_LARGE = 10;
|
|
@@ -62,6 +63,18 @@ export function useKeyboardDiscipline(): void {
|
|
|
62
63
|
const selSet = useSelectionSet();
|
|
63
64
|
const artboardsCtx = useArtboardsContext();
|
|
64
65
|
const dragBus = useDragStateContext();
|
|
66
|
+
// feature-4 (browse/move split) — every keyboard EDITING op here (Cmd+A,
|
|
67
|
+
// Cmd+D, copy/paste-style, Enter/Tab tree traversal, Delete, arrow-nudge) is
|
|
68
|
+
// scoped OUT of the `browse` tool: in the alive mode the mock owns the
|
|
69
|
+
// keyboard (a real form submits on Enter, an input takes Delete). A live ref
|
|
70
|
+
// keeps the document capture listener from re-attaching on every tool change.
|
|
71
|
+
// Default to 'move' (NOT 'browse') when no ToolProvider is above us — a
|
|
72
|
+
// context without a tool store (a bare specimen) must keep its keyboard
|
|
73
|
+
// editing ops, exactly as before this feature. Only an ACTIVE `browse` tool
|
|
74
|
+
// suppresses them.
|
|
75
|
+
const toolCtx = useToolModeOptional();
|
|
76
|
+
const toolRef = useRef(toolCtx?.tool ?? 'move');
|
|
77
|
+
toolRef.current = toolCtx?.tool ?? 'move';
|
|
65
78
|
// Element arrow-nudge burst (Task L1): accumulate left/top, preview inline, and
|
|
66
79
|
// commit once after a short pause (one undo per settle, not per keypress).
|
|
67
80
|
const nudgeRef = useRef<{
|
|
@@ -78,6 +91,8 @@ export function useKeyboardDiscipline(): void {
|
|
|
78
91
|
|
|
79
92
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
80
93
|
if (isEditableTarget(e.target)) return;
|
|
94
|
+
// feature-4 — browse is a pure pass-through; the mock owns the keyboard.
|
|
95
|
+
if (toolRef.current === 'browse') return;
|
|
81
96
|
|
|
82
97
|
// Cmd+A → select all stamped elements in the active artboard.
|
|
83
98
|
const isMeta = e.metaKey || e.ctrlKey;
|
|
@@ -44,8 +44,10 @@ export type SnapAxis = 'x' | 'y';
|
|
|
44
44
|
* different visual treatments (grid = lighter gray fallback; sibling = full
|
|
45
45
|
* magenta confidence). `delta` is the signed correction the snap applied;
|
|
46
46
|
* the overlay renders a `Δ{Math.round(delta)}` pill mid-span when |delta| > 0
|
|
47
|
-
* and the guide span > 60 px.
|
|
48
|
-
|
|
47
|
+
* and the guide span > 60 px. `guide` (feature-1-artboard-kinds-foundation
|
|
48
|
+
* T7) is a third source — an artboard's own generic layout guide lines
|
|
49
|
+
* (T5), offered the same confidence treatment as `sibling`. */
|
|
50
|
+
export type SnapKind = 'grid' | 'sibling' | 'guide';
|
|
49
51
|
|
|
50
52
|
export interface SnapGuide {
|
|
51
53
|
/** `"x"` → vertical line (snapping X coord). Line sits at `pos` on X,
|
|
@@ -72,6 +74,29 @@ export interface SnapResult {
|
|
|
72
74
|
guides: SnapGuide[];
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
/**
|
|
78
|
+
* T7 — a single generic-layout-guide line (T5) in WORLD coordinates, ready to
|
|
79
|
+
* snap against. The caller (canvas-lib/canvas-shell) resolves an artboard's
|
|
80
|
+
* `guides` prop (columns/rows/grid, artboard-LOCAL coordinates) into these
|
|
81
|
+
* world-space lines — `computeSnap` itself stays artboard-agnostic, same as
|
|
82
|
+
* it already is for sibling rects.
|
|
83
|
+
*/
|
|
84
|
+
export interface GuideLineCandidate {
|
|
85
|
+
axis: SnapAxis;
|
|
86
|
+
pos: number;
|
|
87
|
+
from: number;
|
|
88
|
+
to: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* T7 — the two snap-source presets: `layout` (default) offers sibling edges/
|
|
93
|
+
* centers AND guide lines, falling back to the grid; `pixel` offers ONLY the
|
|
94
|
+
* grid — for pixel-precise placement work where guide/sibling magnetism would
|
|
95
|
+
* get in the way. Affinity's "2 intent presets, not 15 toggles" call (see the
|
|
96
|
+
* plan's Design Decision 4).
|
|
97
|
+
*/
|
|
98
|
+
export type SnapIntent = 'layout' | 'pixel';
|
|
99
|
+
|
|
75
100
|
export interface SnapOptions {
|
|
76
101
|
/** World-units between grid lines. Default 40. */
|
|
77
102
|
gridSize: number;
|
|
@@ -79,6 +104,11 @@ export interface SnapOptions {
|
|
|
79
104
|
tolerance: number;
|
|
80
105
|
/** Skip all snap math + return proposed unchanged (Alt-held bypass). */
|
|
81
106
|
disabled: boolean;
|
|
107
|
+
/** Generic layout guide lines to snap against, in world coordinates. */
|
|
108
|
+
guideLines?: GuideLineCandidate[];
|
|
109
|
+
/** Which candidate sources are active. Default `'layout'` when omitted, so
|
|
110
|
+
* every existing call site (pre-T7) keeps today's sibling+grid behavior. */
|
|
111
|
+
intent?: SnapIntent;
|
|
82
112
|
}
|
|
83
113
|
|
|
84
114
|
interface AxisCandidate {
|
|
@@ -141,6 +171,7 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
|
|
|
141
171
|
return { x: proposed.x, y: proposed.y, guides: [] };
|
|
142
172
|
}
|
|
143
173
|
const { gridSize, tolerance } = opts;
|
|
174
|
+
const intent = opts.intent ?? 'layout';
|
|
144
175
|
const propLeft = proposed.x;
|
|
145
176
|
const propRight = proposed.x + proposed.w;
|
|
146
177
|
const propCenterX = proposed.x + proposed.w / 2;
|
|
@@ -152,7 +183,8 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
|
|
|
152
183
|
const yCands: AxisCandidate[] = [];
|
|
153
184
|
|
|
154
185
|
// Grid candidates — left edge for X, top edge for Y. Perpendicular extent
|
|
155
|
-
// is just the proposed rect's own range (no sibling involved).
|
|
186
|
+
// is just the proposed rect's own range (no sibling involved). Grid is the
|
|
187
|
+
// one source active under BOTH intent presets (T7 Design Decision 4).
|
|
156
188
|
const gridX = nearestGridDelta(propLeft, gridSize, tolerance);
|
|
157
189
|
if (gridX !== null) {
|
|
158
190
|
xCands.push({
|
|
@@ -174,8 +206,44 @@ export function computeSnap(proposed: Rect, others: Rect[], opts: SnapOptions):
|
|
|
174
206
|
});
|
|
175
207
|
}
|
|
176
208
|
|
|
177
|
-
//
|
|
178
|
-
|
|
209
|
+
// T7 — generic-layout-guide-line candidates ("Layout" intent only). Each
|
|
210
|
+
// world-space line is tested the same three ways a sibling edge/center is
|
|
211
|
+
// (leading edge, trailing edge, center) — Figma's own layout-guide snap
|
|
212
|
+
// behavior — so a guide line snaps whichever part of the proposed rect
|
|
213
|
+
// lands closest, not just its leading edge (unlike the grid pass above).
|
|
214
|
+
if (intent === 'layout') {
|
|
215
|
+
for (const g of opts.guideLines ?? []) {
|
|
216
|
+
if (g.axis === 'x') {
|
|
217
|
+
for (const propCoord of [propLeft, propRight, propCenterX]) {
|
|
218
|
+
const delta = g.pos - propCoord;
|
|
219
|
+
if (Math.abs(delta) > tolerance) continue;
|
|
220
|
+
xCands.push({
|
|
221
|
+
delta,
|
|
222
|
+
pos: g.pos,
|
|
223
|
+
from: Math.min(propTop, g.from),
|
|
224
|
+
to: Math.max(propBottom, g.to),
|
|
225
|
+
kind: 'guide',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
for (const propCoord of [propTop, propBottom, propCenterY]) {
|
|
230
|
+
const delta = g.pos - propCoord;
|
|
231
|
+
if (Math.abs(delta) > tolerance) continue;
|
|
232
|
+
yCands.push({
|
|
233
|
+
delta,
|
|
234
|
+
pos: g.pos,
|
|
235
|
+
from: Math.min(propLeft, g.from),
|
|
236
|
+
to: Math.max(propRight, g.to),
|
|
237
|
+
kind: 'guide',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Sibling candidates ("Layout" intent only — "Pixel" is grid-only per T7
|
|
245
|
+
// Design Decision 4).
|
|
246
|
+
for (const other of intent === 'layout' ? others : []) {
|
|
179
247
|
const oLeft = other.x;
|
|
180
248
|
const oRight = other.x + other.w;
|
|
181
249
|
const oCenterX = other.x + other.w / 2;
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
type PaddingSide,
|
|
29
29
|
paddingSideSet,
|
|
30
30
|
} from './spacing-handles.ts';
|
|
31
|
-
import {
|
|
31
|
+
import { worldZoomFor } from './use-element-resize.tsx';
|
|
32
32
|
import { useSelectionSet } from './use-selection-set.tsx';
|
|
33
33
|
import { useToolMode } from './use-tool-mode.tsx';
|
|
34
34
|
|
|
@@ -174,8 +174,10 @@ export function SpacingHandlesOverlay(): ReactNode {
|
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
176
|
const cs = getComputedStyle(el);
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
// Dogfood fix — engine-split-aware zoom read (see worldZoomFor's own
|
|
178
|
+
// doc comment in use-element-resize: the Chromium camera writes the CSS
|
|
179
|
+
// `zoom` property, not a transform matrix).
|
|
180
|
+
const zoom = worldZoomFor(el);
|
|
179
181
|
const padding = {
|
|
180
182
|
top: computedPx(cs, 'padding-top'),
|
|
181
183
|
right: computedPx(cs, 'padding-right'),
|
|
@@ -274,8 +276,10 @@ export function SpacingHandlesOverlay(): ReactNode {
|
|
|
274
276
|
const prop = t.dataset.prop || '';
|
|
275
277
|
const isGap = prop === 'gap';
|
|
276
278
|
const cs = getComputedStyle(el);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
+
// Dogfood fix — engine-split-aware zoom read (see worldZoomFor's own
|
|
280
|
+
// doc comment in use-element-resize: the Chromium camera writes the CSS
|
|
281
|
+
// `zoom` property, not a transform matrix).
|
|
282
|
+
const zoom = worldZoomFor(el);
|
|
279
283
|
const drag: SpacingDrag = {
|
|
280
284
|
pointerId: e.pointerId,
|
|
281
285
|
kind: isGap ? 'gap' : 'padding',
|
|
@@ -48,7 +48,11 @@ export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle'
|
|
|
48
48
|
// crosshair/text/cell were thin + tiny ("pen almost invisible"); these mirror
|
|
49
49
|
// the tool-palette icons. `move` keeps the system arrow on purpose.
|
|
50
50
|
export const DEFAULT_TOOLS: readonly ToolDescriptor[] = Object.freeze([
|
|
51
|
-
|
|
51
|
+
// feature-4 — Browse is the BOOT default: the mock is alive (buttons click).
|
|
52
|
+
// Press V for the Move (select) tool. No letter shortcut — it's a deliberate
|
|
53
|
+
// "I'm done editing" choice via the palette / Esc-from-a-draw-tool.
|
|
54
|
+
{ id: 'browse', label: 'Browse', shortcut: '', cursor: TOOL_CURSORS.browse },
|
|
55
|
+
{ id: 'move', label: 'Select', shortcut: 'V', cursor: TOOL_CURSORS.move },
|
|
52
56
|
{ id: 'hand', label: 'Hand', shortcut: 'H', cursor: TOOL_CURSORS.hand },
|
|
53
57
|
{ id: 'comment', label: 'Comment', shortcut: 'C', cursor: TOOL_CURSORS.comment },
|
|
54
58
|
{ id: 'pen', label: 'Pen', shortcut: 'B', cursor: TOOL_CURSORS.pen },
|
|
@@ -89,7 +93,11 @@ const ToolContext = createContext<ToolContextValue | null>(null);
|
|
|
89
93
|
export function ToolProvider({
|
|
90
94
|
children,
|
|
91
95
|
tools = DEFAULT_TOOLS,
|
|
92
|
-
|
|
96
|
+
// feature-4 (browse/move split, DDR-187) — boot into `browse` so a freshly
|
|
97
|
+
// opened mock is ALIVE (native pass-through). V flips to the Move (select)
|
|
98
|
+
// tool. This is the user-decided boot posture (2026-07-15), zero regression
|
|
99
|
+
// for existing canvases + every native-input surface.
|
|
100
|
+
initial = 'browse',
|
|
93
101
|
}: {
|
|
94
102
|
children: ReactNode;
|
|
95
103
|
tools?: readonly ToolDescriptor[];
|
|
@@ -134,13 +142,32 @@ export function ToolProvider({
|
|
|
134
142
|
const desc = tools.find((t) => t.id === tool);
|
|
135
143
|
if (!desc) return;
|
|
136
144
|
const prev = document.body.style.cursor;
|
|
137
|
-
document.body.style.cursor = desc.cursor;
|
|
138
145
|
let styleEl = document.getElementById('dc-tool-cursor') as HTMLStyleElement | null;
|
|
139
146
|
if (!styleEl) {
|
|
140
147
|
styleEl = document.createElement('style');
|
|
141
148
|
styleEl.id = 'dc-tool-cursor';
|
|
142
149
|
document.head.appendChild(styleEl);
|
|
143
150
|
}
|
|
151
|
+
// feature-4 — the browse tool is a pure pass-through: it must NOT force a
|
|
152
|
+
// global cursor, or the `* { cursor: … !important }` rule below would beat
|
|
153
|
+
// the mock's own affordance cursors (pointer over a button, text over an
|
|
154
|
+
// input) and the canvas would stop reading as alive. Clear the forced rule
|
|
155
|
+
// and let the body/native cursors win.
|
|
156
|
+
if (tool === 'browse') {
|
|
157
|
+
document.body.style.cursor = '';
|
|
158
|
+
styleEl.textContent = '';
|
|
159
|
+
if (typeof window !== 'undefined' && window.parent && window.parent !== window) {
|
|
160
|
+
try {
|
|
161
|
+
window.parent.postMessage({ dgn: 'tool-cursor', tool }, '*');
|
|
162
|
+
} catch {
|
|
163
|
+
/* cross-origin parent rejected */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return () => {
|
|
167
|
+
document.body.style.cursor = prev;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
document.body.style.cursor = desc.cursor;
|
|
144
171
|
// Truly GLOBAL inside the canvas document — `*` so it covers the empty grid
|
|
145
172
|
// host, `.dc-world`, every artboard + its content, AND the floating chrome
|
|
146
173
|
// (minimap, toolbar). The earlier `.dc-world`-scoped rule left the empty
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web-overlay-content.tsx — feature-3-web-artboards T2.
|
|
3
|
+
* @scope apps/studio/web-overlay-content.tsx
|
|
4
|
+
* @purpose Registers the 'web' kind's on-canvas chrome into the
|
|
5
|
+
* foundation's kindOverlayRegistry (artboard-guides-overlay.tsx):
|
|
6
|
+
* a small breakpoint-band chip in the header's top-right corner
|
|
7
|
+
* (the video badge's own slot — 'web' and 'video' are mutually
|
|
8
|
+
* exclusive via DCArtboard's resolvedKind, so there's no
|
|
9
|
+
* collision) showing the artboard's CURRENT live width as
|
|
10
|
+
* "≤ {width}px" — the Framer-breakpoints-bar vocabulary (Design
|
|
11
|
+
* Decisions 2/3). Dragging the artboard's resize handle (T4)
|
|
12
|
+
* visibly updates this label live, which is how "drag width to
|
|
13
|
+
* test reflow" reads as a breakpoint change rather than a plain
|
|
14
|
+
* resize.
|
|
15
|
+
*
|
|
16
|
+
* Decorative — aria-hidden, pointer-events none. Reuses the
|
|
17
|
+
* engine's own HUD chrome tokens (--bg-2/--fg-1/--font-mono),
|
|
18
|
+
* same as the artboard label strip itself, rather than DS tokens
|
|
19
|
+
* — this chip IS chrome, never exported content. Always-on (no
|
|
20
|
+
* visibility gate): unlike print's bleed/trim/margin overlay
|
|
21
|
+
* (which visually covers artboard content and needs an opt-in
|
|
22
|
+
* toggle), this is a single small corner label with nothing to
|
|
23
|
+
* collide with, so there's no reason to hide it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { type KindOverlayRenderFn, registerKindOverlay } from './artboard-guides-overlay.tsx';
|
|
27
|
+
|
|
28
|
+
const WebOverlayContent: KindOverlayRenderFn = ({ rect }) => {
|
|
29
|
+
const width = Math.round(rect.w);
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
style={{
|
|
33
|
+
position: 'absolute',
|
|
34
|
+
top: 4,
|
|
35
|
+
right: 6,
|
|
36
|
+
padding: '2px 6px',
|
|
37
|
+
borderRadius: 3,
|
|
38
|
+
background: 'var(--bg-2, #e8e3d8)',
|
|
39
|
+
color: 'var(--fg-1, #4a3f30)',
|
|
40
|
+
fontFamily: 'var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
41
|
+
fontSize: 9,
|
|
42
|
+
letterSpacing: '0.04em',
|
|
43
|
+
whiteSpace: 'nowrap',
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{`≤ ${width}px`}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
WebOverlayContent.displayName = 'WebOverlayContent';
|
|
51
|
+
|
|
52
|
+
registerKindOverlay('web', WebOverlayContent);
|