@1agh/maude 0.46.0 → 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 +113 -5
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/api.ts +417 -2
- 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/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +678 -11
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +3 -0
- package/apps/studio/canvas-shell.tsx +646 -26
- package/apps/studio/client/app.jsx +898 -52
- package/apps/studio/client/panels/ChatPanel.jsx +47 -2
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1103 -1103
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +118 -0
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
- 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 +70 -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 +113 -0
- 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 +54 -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-element-resize.tsx +3 -1
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/commands/design.mjs +16 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +4 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file grid-track-handles.ts — feature-3-web-artboards T5 (absorbed
|
|
3
|
+
* feature-grid-track-editor stub).
|
|
4
|
+
* @purpose Pure geometry + value math for the on-canvas grid gutter
|
|
5
|
+
* drag-resize overlay. Framework-free (unit-testable without a
|
|
6
|
+
* DOM), mirroring `spacing-handles.ts`'s shape exactly — this is
|
|
7
|
+
* the CSS-Grid sibling of that flex/box-model module, deferred
|
|
8
|
+
* there in a doc comment ("CSS-Grid gap editing is out of v1
|
|
9
|
+
* scope — grid tracks are a separate follow-up plan").
|
|
10
|
+
*
|
|
11
|
+
* Track SIZING math is a two-layer split, same idiom as the
|
|
12
|
+
* padding/gap overlay: geometry (gutter positions) reads the
|
|
13
|
+
* browser's RESOLVED px sizes (the caller reads
|
|
14
|
+
* `getComputedStyle(el).gridTemplateColumns` — the browser
|
|
15
|
+
* already ran the grid-sizing algorithm, resolving `fr`/`auto`/
|
|
16
|
+
* `%` tracks to concrete px, so this module never re-implements
|
|
17
|
+
* that algorithm); DRAG math re-derives a new AUTHORED value
|
|
18
|
+
* (the JSX-authored unit — px/%/fr/em — read from the element's
|
|
19
|
+
* inline style, since these on-canvas tools only edit inline-
|
|
20
|
+
* style-authored values, same constraint as every other curated
|
|
21
|
+
* knob) from the ratio between the target resolved size and the
|
|
22
|
+
* track's CURRENT resolved size.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type GridTrackUnit = 'px' | '%' | 'fr' | 'em' | 'auto' | 'min-content' | 'max-content';
|
|
26
|
+
|
|
27
|
+
export const GRID_KEYWORD_UNITS: ReadonlySet<GridTrackUnit> = new Set([
|
|
28
|
+
'auto',
|
|
29
|
+
'min-content',
|
|
30
|
+
'max-content',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
export interface GridTrack {
|
|
34
|
+
/** Numeric value; 0 (ignored) for keyword-only units. */
|
|
35
|
+
value: number;
|
|
36
|
+
unit: GridTrackUnit;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NUMERIC_TRACK_RE = /^(-?\d+(?:\.\d+)?)(px|%|fr|em)$/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a `grid-template-columns`/`grid-template-rows` value into a track
|
|
43
|
+
* list. Handles a plain space-separated list of numeric (px/%/fr/em) and
|
|
44
|
+
* keyword (auto/min-content/max-content) tracks. Returns `[]` for an empty
|
|
45
|
+
* string OR anything it can't parse token-for-token (e.g. `repeat(...)`,
|
|
46
|
+
* `minmax(...)`, `subgrid`) — the caller treats an empty parse as "no
|
|
47
|
+
* drag-editable tracks", falling back to the Inspector's raw-value field
|
|
48
|
+
* rather than guessing at a lossy round-trip.
|
|
49
|
+
*/
|
|
50
|
+
export function parseTrackList(raw: string | null | undefined): GridTrack[] {
|
|
51
|
+
const s = (raw ?? '').trim();
|
|
52
|
+
if (!s) return [];
|
|
53
|
+
const parts = s.split(/\s+/);
|
|
54
|
+
const out: GridTrack[] = [];
|
|
55
|
+
for (const part of parts) {
|
|
56
|
+
if (GRID_KEYWORD_UNITS.has(part as GridTrackUnit)) {
|
|
57
|
+
out.push({ value: 0, unit: part as GridTrackUnit });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const m = NUMERIC_TRACK_RE.exec(part);
|
|
61
|
+
if (!m) return [];
|
|
62
|
+
out.push({ value: Number(m[1]), unit: m[2] as GridTrackUnit });
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Serialize a track list back to a `grid-template-columns`/`-rows` value —
|
|
68
|
+
* the inverse of `parseTrackList`. `fr` (and every other unit) round-trips
|
|
69
|
+
* byte-for-byte modulo the `round2` value rounding applied during drag. */
|
|
70
|
+
export function serializeTrackList(tracks: GridTrack[]): string {
|
|
71
|
+
return tracks
|
|
72
|
+
.map((t) => (GRID_KEYWORD_UNITS.has(t.unit) ? t.unit : `${round2(t.value)}${t.unit}`))
|
|
73
|
+
.join(' ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ScreenRect {
|
|
77
|
+
x: number;
|
|
78
|
+
y: number;
|
|
79
|
+
w: number;
|
|
80
|
+
h: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface GutterLine {
|
|
84
|
+
/** Index of the track BEFORE this gutter; track `index + 1` is after it. */
|
|
85
|
+
index: number;
|
|
86
|
+
axis: 'x' | 'y';
|
|
87
|
+
x: number;
|
|
88
|
+
y: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compute gutter handle positions from a grid container's screen rect, each
|
|
93
|
+
* track's RESOLVED size in screen px (already zoom-scaled — same convention
|
|
94
|
+
* as `computePaddingLines`'s `padding` argument), and the resolved gap
|
|
95
|
+
* between tracks. One gutter per adjacent pair, positioned at the gap's
|
|
96
|
+
* midpoint. Fewer than 2 tracks → no gutters.
|
|
97
|
+
*/
|
|
98
|
+
export function computeGutterLines(
|
|
99
|
+
rect: ScreenRect,
|
|
100
|
+
resolvedSizesPx: number[],
|
|
101
|
+
gapPx: number,
|
|
102
|
+
axis: 'col' | 'row'
|
|
103
|
+
): GutterLine[] {
|
|
104
|
+
if (resolvedSizesPx.length < 2) return [];
|
|
105
|
+
const out: GutterLine[] = [];
|
|
106
|
+
let cum = axis === 'col' ? rect.x : rect.y;
|
|
107
|
+
for (let i = 0; i < resolvedSizesPx.length - 1; i++) {
|
|
108
|
+
cum += resolvedSizesPx[i] as number;
|
|
109
|
+
const mid = cum + gapPx / 2;
|
|
110
|
+
if (axis === 'col') {
|
|
111
|
+
out.push({ index: i, axis: 'x', x: mid, y: rect.y + rect.h / 2 });
|
|
112
|
+
} else {
|
|
113
|
+
out.push({ index: i, axis: 'y', x: rect.x + rect.w / 2, y: mid });
|
|
114
|
+
}
|
|
115
|
+
cum += gapPx;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* New AUTHORED value for one track being dragged, given the screen-px delta
|
|
122
|
+
* (world units — the caller already divides by zoom, same convention as
|
|
123
|
+
* `computePaddingDrag`/`computeGapDrag`... actually this one takes the RAW
|
|
124
|
+
* screen delta + zoom together, dividing internally, to match those two
|
|
125
|
+
* functions' own signature exactly) and the track's CURRENT resolved px size.
|
|
126
|
+
*
|
|
127
|
+
* - `px` / `em` — additive: 1 world px of drag = 1 unit (em is treated as a
|
|
128
|
+
* px-equivalent for drag purposes — a precise em→px conversion would need
|
|
129
|
+
* the element's own font-size; this is a disclosed simplification, same
|
|
130
|
+
* spirit as the rest of this drag lane favoring predictable math over
|
|
131
|
+
* pixel-perfect unit fidelity mid-drag).
|
|
132
|
+
* - `fr` / `%` — proportional SHARE units, not absolute sizes: the new value
|
|
133
|
+
* is the OLD value scaled by (targetResolvedPx / currentResolvedPx) — the
|
|
134
|
+
* same "re-derive the ratio" approach a flex-grow-style drag uses. Clamped
|
|
135
|
+
* to a small positive floor so a track never collapses to (or past) zero
|
|
136
|
+
* share.
|
|
137
|
+
* - keyword tracks (auto/min-content/max-content) have no numeric value —
|
|
138
|
+
* returns the track unchanged; the caller must not offer this as a
|
|
139
|
+
* drag target (see `gutterTrackIndices` — the hook filters keyword-only
|
|
140
|
+
* tracks out of the touched set before calling this).
|
|
141
|
+
*/
|
|
142
|
+
export function computeTrackDrag(
|
|
143
|
+
track: GridTrack,
|
|
144
|
+
resolvedPx: number,
|
|
145
|
+
dxScreen: number,
|
|
146
|
+
dyScreen: number,
|
|
147
|
+
zoom: number,
|
|
148
|
+
axis: 'x' | 'y'
|
|
149
|
+
): number {
|
|
150
|
+
if (GRID_KEYWORD_UNITS.has(track.unit)) return track.value;
|
|
151
|
+
const z = zoom > 0 ? zoom : 1;
|
|
152
|
+
const d = (axis === 'x' ? dxScreen : dyScreen) / z;
|
|
153
|
+
if (track.unit === 'px' || track.unit === 'em') {
|
|
154
|
+
return Math.max(0, round2(track.value + d));
|
|
155
|
+
}
|
|
156
|
+
const targetPx = Math.max(1, resolvedPx + d);
|
|
157
|
+
if (resolvedPx <= 0) return track.value; // no ratio to derive from — no-op
|
|
158
|
+
const ratio = targetPx / resolvedPx;
|
|
159
|
+
const floor = track.unit === '%' ? 1 : 0.1;
|
|
160
|
+
return Math.max(floor, round2(track.value * ratio));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Which track indices a gutter drag touches, given the Shift modifier —
|
|
165
|
+
* mirrors `paddingSideSet`'s Alt/Shift convention: plain drag touches only
|
|
166
|
+
* the track BEFORE the gutter; Shift touches BOTH neighbors (each driven to
|
|
167
|
+
* its own new value from the SAME cursor delta — "symmetric" in this
|
|
168
|
+
* codebase's established vocabulary means both move together, not an
|
|
169
|
+
* inversely-linked splitter, matching how `paddingSideSet`'s Alt sets both
|
|
170
|
+
* opposite padding sides to the identical dragged value rather than trading
|
|
171
|
+
* space between them).
|
|
172
|
+
*/
|
|
173
|
+
export function gutterTrackIndices(gutterIndex: number, shiftKey: boolean): number[] {
|
|
174
|
+
return shiftKey ? [gutterIndex, gutterIndex + 1] : [gutterIndex];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function round2(n: number): number {
|
|
178
|
+
return Math.round(n * 100) / 100;
|
|
179
|
+
}
|
package/apps/studio/handoff.ts
CHANGED
|
@@ -80,6 +80,15 @@ export interface RegistryItem {
|
|
|
80
80
|
light?: Record<string, string>;
|
|
81
81
|
dark?: Record<string, string>;
|
|
82
82
|
};
|
|
83
|
+
/**
|
|
84
|
+
* shadcn's generic item-specific-metadata extension point. feature-3-web-
|
|
85
|
+
* artboards T6 — carries the resolved artboard `kind` (`digital` | `print`
|
|
86
|
+
* | `web` | `video` | `mixed`) so a consumer (or a future re-import) knows
|
|
87
|
+
* what authoring contract produced this drop without re-parsing the TSX.
|
|
88
|
+
*/
|
|
89
|
+
meta?: {
|
|
90
|
+
kind: string;
|
|
91
|
+
};
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
export interface EmitOptions {
|
|
@@ -103,6 +112,31 @@ export interface EmitOptions {
|
|
|
103
112
|
designRoot?: string;
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// feature-3-web-artboards T6 — thread the artboard kind into the emitted
|
|
117
|
+
// registry-item's `meta` (a first-class shadcn registry-item.json extension
|
|
118
|
+
// point for item-specific metadata, not a maude-invented field). A regex scan
|
|
119
|
+
// is deliberate here (not an AST walk like stripDataCdId) — this only needs
|
|
120
|
+
// the `kind="…"` string values, never a node reference to edit.
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve a single `kind` value for the whole canvas file, from its
|
|
124
|
+
* `<DCArtboard kind="…">` occurrences. A canvas with zero explicit `kind`
|
|
125
|
+
* attributes resolves to `'digital'` (DCArtboard's own implicit default). A
|
|
126
|
+
* canvas whose artboards carry more than one distinct explicit kind (e.g. a
|
|
127
|
+
* multi-breakpoint web canvas assembled via "Duplicate at width…" next to an
|
|
128
|
+
* unrelated screen) resolves to `'mixed'` rather than picking one arbitrarily.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveCanvasKind(rawSource: string): string {
|
|
131
|
+
const kinds = new Set<string>();
|
|
132
|
+
for (const m of rawSource.matchAll(/<DCArtboard\b[^>]*\bkind="([a-z]+)"/g)) {
|
|
133
|
+
if (m[1]) kinds.add(m[1]);
|
|
134
|
+
}
|
|
135
|
+
if (kinds.size === 0) return 'digital';
|
|
136
|
+
if (kinds.size === 1) return [...kinds][0] as string;
|
|
137
|
+
return 'mixed';
|
|
138
|
+
}
|
|
139
|
+
|
|
106
140
|
// ---------------------------------------------------------------------------
|
|
107
141
|
// Strip data-cd-id from source — the inverse of canvas-pipeline.ts pass 1.
|
|
108
142
|
|
|
@@ -668,6 +702,7 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
|
|
|
668
702
|
registryDependencies,
|
|
669
703
|
files,
|
|
670
704
|
...(cssVars ? { cssVars } : {}),
|
|
705
|
+
meta: { kind: resolveCanvasKind(rawTsx) },
|
|
671
706
|
};
|
|
672
707
|
|
|
673
708
|
// Drop undefined keys for a clean JSON.
|
package/apps/studio/http.ts
CHANGED
|
@@ -2221,6 +2221,70 @@ export function createHttp(
|
|
|
2221
2221
|
);
|
|
2222
2222
|
},
|
|
2223
2223
|
|
|
2224
|
+
'/_api/convert-to-absolute': async (req: Request) => {
|
|
2225
|
+
// feature-4 T8 (convert-to-absolute, DDR-188) — "Remove auto layout": POST
|
|
2226
|
+
// { canvas, containerId, containerIdIndex?, containerSetRelative,
|
|
2227
|
+
// children: [{ id, idIndex?, left, top, width, height }] } →
|
|
2228
|
+
// api.convertChildrenToAbsoluteOp. ONE whole-file undo seq. MAIN-ORIGIN
|
|
2229
|
+
// ONLY (absent from both allowlists); sameOriginWrite + loopback-Host gated
|
|
2230
|
+
// — same DDR-054 posture as /_api/insert-element.
|
|
2231
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2232
|
+
if (!sameOriginWrite(req))
|
|
2233
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2234
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2235
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2236
|
+
const body = await readJson<{
|
|
2237
|
+
canvas?: unknown;
|
|
2238
|
+
containerId?: unknown;
|
|
2239
|
+
containerIdIndex?: unknown;
|
|
2240
|
+
containerSetRelative?: unknown;
|
|
2241
|
+
allowShared?: unknown;
|
|
2242
|
+
children?: unknown;
|
|
2243
|
+
containers?: unknown;
|
|
2244
|
+
dissolve?: unknown;
|
|
2245
|
+
}>(req, 256 * 1024);
|
|
2246
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2247
|
+
const result = await api.convertChildrenToAbsoluteOp(body);
|
|
2248
|
+
if (!result.ok) {
|
|
2249
|
+
return Response.json(
|
|
2250
|
+
{ ok: false, error: result.error },
|
|
2251
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
return Response.json(
|
|
2255
|
+
{ ok: true, seq: result.seq },
|
|
2256
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2257
|
+
);
|
|
2258
|
+
},
|
|
2259
|
+
|
|
2260
|
+
'/_api/detach-component': async (req: Request) => {
|
|
2261
|
+
// feature-4 detach-component (2026-07-19) — POST { canvas, id, idIndex? }
|
|
2262
|
+
// → api.detachComponentOp (clone definition + repoint this usage; one
|
|
2263
|
+
// whole-file undo seq). MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host
|
|
2264
|
+
// gated — same DDR-054 posture as the other structural writes.
|
|
2265
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2266
|
+
if (!sameOriginWrite(req))
|
|
2267
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2268
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2269
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2270
|
+
const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
|
|
2271
|
+
req,
|
|
2272
|
+
8 * 1024
|
|
2273
|
+
);
|
|
2274
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2275
|
+
const result = await api.detachComponentOp(body);
|
|
2276
|
+
if (!result.ok) {
|
|
2277
|
+
return Response.json(
|
|
2278
|
+
{ ok: false, error: result.error },
|
|
2279
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
return Response.json(
|
|
2283
|
+
{ ok: true, detachedName: result.detachedName, seq: result.seq },
|
|
2284
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2285
|
+
);
|
|
2286
|
+
},
|
|
2287
|
+
|
|
2224
2288
|
'/_api/duplicate-element': async (req: Request) => {
|
|
2225
2289
|
// Cmd+D (Task L3) — duplicate an element (a copy as the next sibling). POST
|
|
2226
2290
|
// { canvas, id, idIndex? } → api.duplicateElementOp. Whole-file undo seq.
|
|
@@ -2280,6 +2344,38 @@ export function createHttp(
|
|
|
2280
2344
|
);
|
|
2281
2345
|
},
|
|
2282
2346
|
|
|
2347
|
+
'/_api/duplicate-artboard': async (req: Request) => {
|
|
2348
|
+
// feature-3-web-artboards T3 — "Duplicate at width…". POST
|
|
2349
|
+
// { canvas, artboardId, width } → api.duplicateArtboardOp (clones the
|
|
2350
|
+
// artboard as the next sibling with a suffixed id/label + the new
|
|
2351
|
+
// width; every other prop, incl. kind/guides/print, carries over
|
|
2352
|
+
// verbatim). Whole-file undo seq. MAIN-ORIGIN ONLY (absent from both
|
|
2353
|
+
// allowlists); sameOriginWrite + loopback-Host gated, same posture as
|
|
2354
|
+
// /_api/insert-artboard above.
|
|
2355
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
2356
|
+
if (!sameOriginWrite(req))
|
|
2357
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
2358
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2359
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2360
|
+
const body = await readJson<{
|
|
2361
|
+
canvas?: unknown;
|
|
2362
|
+
artboardId?: unknown;
|
|
2363
|
+
width?: unknown;
|
|
2364
|
+
}>(req, 8 * 1024);
|
|
2365
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
2366
|
+
const result = await api.duplicateArtboardOp(body);
|
|
2367
|
+
if (!result.ok) {
|
|
2368
|
+
return Response.json(
|
|
2369
|
+
{ ok: false, error: result.error },
|
|
2370
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2371
|
+
);
|
|
2372
|
+
}
|
|
2373
|
+
return Response.json(
|
|
2374
|
+
{ ok: true, artboardId: result.artboardId, seq: result.seq },
|
|
2375
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2376
|
+
);
|
|
2377
|
+
},
|
|
2378
|
+
|
|
2283
2379
|
'/_api/assets': async (req: Request) => {
|
|
2284
2380
|
// Stage F1 (feature-element-editing-robustness) — list content-addressed
|
|
2285
2381
|
// image/video assets under <designRoot>/assets/ for the AssetPicker (Replace
|
|
@@ -2340,6 +2436,28 @@ export function createHttp(
|
|
|
2340
2436
|
return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
|
|
2341
2437
|
},
|
|
2342
2438
|
|
|
2439
|
+
'/_api/component-map': async (req: Request) => {
|
|
2440
|
+
// feature-4 T7a — Layers-panel purple instance rows. GET ?canvas →
|
|
2441
|
+
// { ok, map: { [cdId]: { component, root, usages } } }. READ-only parse
|
|
2442
|
+
// (same posture as /_api/edit-scope). MAIN-ORIGIN ONLY: absent from
|
|
2443
|
+
// CANVAS_SAFE_API + startCanvasServer routes (DDR-054); loopback-Host
|
|
2444
|
+
// gated; no sameOriginWrite (GET).
|
|
2445
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
2446
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
2447
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
2448
|
+
const url = new URL(req.url);
|
|
2449
|
+
const result = await api.componentMapOp({
|
|
2450
|
+
canvas: url.searchParams.get('canvas') ?? undefined,
|
|
2451
|
+
});
|
|
2452
|
+
if (!result.ok) {
|
|
2453
|
+
return Response.json(
|
|
2454
|
+
{ ok: false, error: result.error },
|
|
2455
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
|
|
2459
|
+
},
|
|
2460
|
+
|
|
2343
2461
|
'/_api/resize-artboard': async (req: Request) => {
|
|
2344
2462
|
// Stage D4 — free-hand artboard resize. POST { canvas, artboardId, width?,
|
|
2345
2463
|
// height? } → api.resizeArtboardOp (writes the NUMERIC width/height props on
|
|
@@ -64,6 +64,12 @@ export function crossedDragThreshold(
|
|
|
64
64
|
* can grab them natively.
|
|
65
65
|
*/
|
|
66
66
|
export type Tool =
|
|
67
|
+
// feature-4 (browse/move split) — `browse` is the BOOT default: a pure
|
|
68
|
+
// native pass-through so a freshly-opened mock stays ALIVE (buttons click,
|
|
69
|
+
// links follow, inputs focus). It carries ZERO select machinery — the router
|
|
70
|
+
// returns `no-op` for every browse-tool pointer event so descendants see them
|
|
71
|
+
// untouched. Pressing V flips to `move` (the select tool). See DDR-187.
|
|
72
|
+
| 'browse'
|
|
67
73
|
| 'move'
|
|
68
74
|
| 'hand'
|
|
69
75
|
| 'comment'
|
|
@@ -113,9 +119,12 @@ export type RouterAction =
|
|
|
113
119
|
/**
|
|
114
120
|
* `true` resolves to the deepest descendant under the cursor (Cmd-held
|
|
115
121
|
* mode). `false` resolves to the topmost interesting ancestor (top mode).
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* (
|
|
122
|
+
* feature-4 (browse/move split, DDR-187) — the Move (select) tool now
|
|
123
|
+
* fires the FULL Figma ladder: a BARE click selects the TOP-level object
|
|
124
|
+
* (`deep:false`), Cmd selects the DEEPEST element (`deep:true`), Shift
|
|
125
|
+
* adds, Cmd+Shift adds-deep. (Pre-feature-4 Move-tool select was
|
|
126
|
+
* Cmd-only and always deep; bare clicks were passthrough — that role now
|
|
127
|
+
* belongs to the `browse` tool, which never selects at all.)
|
|
119
128
|
*/
|
|
120
129
|
deep: boolean;
|
|
121
130
|
clientX: number;
|
|
@@ -210,6 +219,19 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
210
219
|
if (isAnnotationTool(input.activeTool)) return { kind: 'no-op' };
|
|
211
220
|
// Hand tool: drag pan is owned by useViewportController; no hover paint.
|
|
212
221
|
if (input.activeTool === 'hand') return { kind: 'no-op' };
|
|
222
|
+
// Browse tool (feature-4 boot default): pass-through — no hover halo,
|
|
223
|
+
// native interactions flow. Cmd-held hover previews the deepest element
|
|
224
|
+
// (the escape-hatch select affordance, mirroring the old move-tool
|
|
225
|
+
// behavior); bare hover stays native.
|
|
226
|
+
if (input.activeTool === 'browse') {
|
|
227
|
+
if (!metaOrCtrl(input)) return { kind: 'no-op' };
|
|
228
|
+
return {
|
|
229
|
+
kind: 'hover',
|
|
230
|
+
deep: true,
|
|
231
|
+
clientX: input.clientX ?? 0,
|
|
232
|
+
clientY: input.clientY ?? 0,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
213
235
|
// Comment tool: always paint a preview halo on the deepest element under
|
|
214
236
|
// cursor — that's the element the user is about to comment on. Comment
|
|
215
237
|
// pin attachment is to the same element they were hovering.
|
|
@@ -221,12 +243,12 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
221
243
|
clientY: input.clientY ?? 0,
|
|
222
244
|
};
|
|
223
245
|
}
|
|
224
|
-
// Move tool:
|
|
225
|
-
//
|
|
226
|
-
|
|
246
|
+
// Move (select) tool: paint a preview halo of exactly what a click would
|
|
247
|
+
// select — the TOP-level object on a bare hover (Figma's hover outline),
|
|
248
|
+
// the DEEPEST element while Cmd is held (deep-select preview).
|
|
227
249
|
return {
|
|
228
250
|
kind: 'hover',
|
|
229
|
-
deep:
|
|
251
|
+
deep: metaOrCtrl(input),
|
|
230
252
|
clientX: input.clientX ?? 0,
|
|
231
253
|
clientY: input.clientY ?? 0,
|
|
232
254
|
};
|
|
@@ -267,17 +289,38 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
267
289
|
// the controller's pointerdown listener on the same host claims the drag.
|
|
268
290
|
if (input.activeTool === 'hand') return { kind: 'no-op' };
|
|
269
291
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
292
|
+
// Browse tool (feature-4 boot default): bare/Shift left-clicks pass
|
|
293
|
+
// through so the mock stays alive (a button press fires, a link follows,
|
|
294
|
+
// an input focuses). Cmd/Ctrl+click is the ESCAPE HATCH (user steer
|
|
295
|
+
// 2026-07-19): it selects the deepest element AND the consumer flips the
|
|
296
|
+
// tool to Move — "I clicked to edit" shouldn't require pressing V first.
|
|
297
|
+
if (input.activeTool === 'browse') {
|
|
298
|
+
if (!metaOrCtrl(input)) return { kind: 'no-op' };
|
|
299
|
+
return {
|
|
300
|
+
kind: 'select',
|
|
301
|
+
mode: input.shiftKey ? 'add' : 'replace',
|
|
302
|
+
deep: true,
|
|
303
|
+
clientX: input.clientX ?? 0,
|
|
304
|
+
clientY: input.clientY ?? 0,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Move (select) tool. feature-4 (DDR-187) — the full Figma ladder:
|
|
309
|
+
// bare click → select TOP-level object (deep:false, replace)
|
|
310
|
+
// Shift+click → add TOP-level object (deep:false, add)
|
|
311
|
+
// Cmd/Ctrl+click → select DEEPEST element (deep:true, replace)
|
|
312
|
+
// Cmd+Shift+click → add DEEPEST element (deep:true, add)
|
|
313
|
+
// Bare clicks are claimed (preventDefault) so native canvas interactions do
|
|
314
|
+
// NOT fire in select mode — that's the browse tool's job. The click-vs-drag
|
|
315
|
+
// threshold (canvas-shell.tsx ReorderDrag / marquee overlays, all still
|
|
316
|
+
// gated on `tool === 'move'`) means a press that turns into a drag reorders
|
|
317
|
+
// / marquees instead; a release-in-place is the select.
|
|
274
318
|
const cmd = metaOrCtrl(input);
|
|
275
|
-
if (!cmd) return { kind: 'no-op' };
|
|
276
319
|
const shift = !!input.shiftKey;
|
|
277
320
|
return {
|
|
278
321
|
kind: 'select',
|
|
279
322
|
mode: shift ? 'add' : 'replace',
|
|
280
|
-
deep:
|
|
323
|
+
deep: cmd,
|
|
281
324
|
clientX: input.clientX ?? 0,
|
|
282
325
|
clientY: input.clientY ?? 0,
|
|
283
326
|
};
|
|
@@ -434,6 +477,11 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
434
477
|
// their own clicks. The router is in capture phase, so we have to
|
|
435
478
|
// bail HERE before classify can claim the event.
|
|
436
479
|
if (isOverlayTarget(e.target)) return;
|
|
480
|
+
// feature-4 text-gestures (2026-07-20) — an ACTIVE inline text editor
|
|
481
|
+
// (contenteditable) owns its clicks natively: single click places the
|
|
482
|
+
// caret, dblclick selects a word, triple-click selects all. Claiming
|
|
483
|
+
// these as `select` killed every in-edit gesture.
|
|
484
|
+
if (isEditableTarget(e.target)) return;
|
|
437
485
|
const action = claim(
|
|
438
486
|
classify({
|
|
439
487
|
type: 'pointerdown',
|
|
@@ -468,6 +516,7 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
468
516
|
*/
|
|
469
517
|
const onMouseDown = (e: MouseEvent): void => {
|
|
470
518
|
if (isOverlayTarget(e.target)) return;
|
|
519
|
+
if (isEditableTarget(e.target)) return;
|
|
471
520
|
const action = claim(
|
|
472
521
|
classify({
|
|
473
522
|
type: 'pointerdown',
|
|
@@ -508,19 +557,26 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
508
557
|
*/
|
|
509
558
|
const onClick = (e: MouseEvent): void => {
|
|
510
559
|
if (isOverlayTarget(e.target)) return;
|
|
560
|
+
if (isEditableTarget(e.target)) return;
|
|
511
561
|
const tool = getActiveTool();
|
|
512
562
|
const mod = e.metaKey || e.ctrlKey;
|
|
513
563
|
// Map the click to the action kind the matching pointerdown would have
|
|
514
564
|
// produced, then honor the claim allowlist so a scoped router (the
|
|
515
565
|
// comment mount layer) doesn't suppress clicks it never claimed.
|
|
566
|
+
// feature-4 — in the Move (select) tool EVERY bare left click is a select
|
|
567
|
+
// (not just Cmd), so its synthetic click must be swallowed too or the
|
|
568
|
+
// native handler under the just-selected element would still fire. Draw
|
|
569
|
+
// tools keep the Cmd-only escape-hatch select. Browse claims nothing.
|
|
516
570
|
const wouldRouteKind: RouterAction['kind'] | null =
|
|
517
571
|
tool === 'comment'
|
|
518
572
|
? 'drop-comment'
|
|
519
|
-
: tool === 'move' &&
|
|
573
|
+
: tool === 'move' && e.button === 0
|
|
520
574
|
? 'select'
|
|
521
|
-
: e.button ===
|
|
522
|
-
? '
|
|
523
|
-
:
|
|
575
|
+
: (isAnnotationTool(tool) || tool === 'browse') && mod && e.button === 0
|
|
576
|
+
? 'select'
|
|
577
|
+
: e.button === 2
|
|
578
|
+
? 'context-menu'
|
|
579
|
+
: null;
|
|
524
580
|
if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
|
|
525
581
|
e.preventDefault();
|
|
526
582
|
e.stopImmediatePropagation();
|