@1agh/maude 0.36.1 → 0.38.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/README.md +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +305 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +686 -35
- package/apps/studio/client/github.js +6 -0
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/CreateProject.jsx +57 -23
- package/apps/studio/client/panels/GitPanel.jsx +1 -1
- package/apps/studio/client/panels/IdentityBar.jsx +43 -2
- package/apps/studio/client/panels/OnboardingWizard.jsx +24 -21
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +4 -4
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/client/tour/collab-tour.js +3 -3
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +17 -13
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/github/endpoints.ts +42 -0
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +183 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/scaffold-design.ts +95 -2
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +10 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/test/sync-agent.test.ts +9 -2
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +63 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +3 -3
|
@@ -5148,38 +5148,123 @@ const LAYER_TYPE_ICON = {
|
|
|
5148
5148
|
box: 'box',
|
|
5149
5149
|
};
|
|
5150
5150
|
|
|
5151
|
+
|
|
5152
|
+
// Optimistic layers-tree reorder — move the node with `draggedId` relative to
|
|
5153
|
+
// `refId` (before / after a sibling, or inside-end as a last child). Pure: clones
|
|
5154
|
+
// the node array and returns a new one, so React re-renders. Ids are distinct
|
|
5155
|
+
// here (repeated/list nodes can't be dragged), so first-match-by-id is safe.
|
|
5156
|
+
// Returns the input unchanged if either node isn't found (the HMR rebuild will
|
|
5157
|
+
// reconcile).
|
|
5158
|
+
function moveLayerNode(nodes, draggedId, refId, position) {
|
|
5159
|
+
if (!Array.isArray(nodes) || draggedId === refId) return nodes;
|
|
5160
|
+
const clone = JSON.parse(JSON.stringify(nodes));
|
|
5161
|
+
let dragged = null;
|
|
5162
|
+
const remove = (arr) => {
|
|
5163
|
+
for (let i = 0; i < arr.length; i++) {
|
|
5164
|
+
if (arr[i].id === draggedId) {
|
|
5165
|
+
dragged = arr[i];
|
|
5166
|
+
arr.splice(i, 1);
|
|
5167
|
+
return true;
|
|
5168
|
+
}
|
|
5169
|
+
if (arr[i].children && remove(arr[i].children)) return true;
|
|
5170
|
+
}
|
|
5171
|
+
return false;
|
|
5172
|
+
};
|
|
5173
|
+
remove(clone);
|
|
5174
|
+
if (!dragged) return nodes;
|
|
5175
|
+
const insert = (arr) => {
|
|
5176
|
+
for (let i = 0; i < arr.length; i++) {
|
|
5177
|
+
if (arr[i].id === refId) {
|
|
5178
|
+
if (position === 'inside-start' || position === 'inside-end') {
|
|
5179
|
+
arr[i].children = arr[i].children || [];
|
|
5180
|
+
if (position === 'inside-start') arr[i].children.unshift(dragged);
|
|
5181
|
+
else arr[i].children.push(dragged);
|
|
5182
|
+
} else {
|
|
5183
|
+
arr.splice(position === 'before' ? i : i + 1, 0, dragged);
|
|
5184
|
+
}
|
|
5185
|
+
return true;
|
|
5186
|
+
}
|
|
5187
|
+
if (arr[i].children && insert(arr[i].children)) return true;
|
|
5188
|
+
}
|
|
5189
|
+
return false;
|
|
5190
|
+
};
|
|
5191
|
+
return insert(clone) ? clone : nodes;
|
|
5192
|
+
}
|
|
5193
|
+
|
|
5194
|
+
// Void / self-closing tags can't hold children → not a nest target. Everything
|
|
5195
|
+
// else (div, section, span, …) can be nested into (matches "drop into any div").
|
|
5196
|
+
const LAYER_VOID_TAGS = new Set([
|
|
5197
|
+
'img', 'input', 'br', 'hr', 'area', 'base', 'col', 'embed', 'link', 'meta',
|
|
5198
|
+
'param', 'source', 'track', 'wbr',
|
|
5199
|
+
]);
|
|
5200
|
+
|
|
5151
5201
|
function LayerRow({
|
|
5152
5202
|
node,
|
|
5153
5203
|
depth,
|
|
5154
5204
|
selectedId,
|
|
5205
|
+
selectedIndex,
|
|
5155
5206
|
collapsed,
|
|
5156
5207
|
hidden,
|
|
5157
5208
|
onToggle,
|
|
5158
5209
|
onSelect,
|
|
5159
5210
|
onHover,
|
|
5160
5211
|
onToggleVisibility,
|
|
5212
|
+
onReorder,
|
|
5213
|
+
onRowPointerDown,
|
|
5214
|
+
dragState,
|
|
5161
5215
|
}) {
|
|
5162
5216
|
const key = `${node.id}:${node.index}`;
|
|
5163
5217
|
const hasKids = node.children && node.children.length > 0;
|
|
5164
5218
|
const isCollapsed = collapsed.has(key);
|
|
5165
|
-
|
|
5219
|
+
// Match the specific INSTANCE (id + occurrence index), not every element that
|
|
5220
|
+
// shares this source id — otherwise a `.map`ed element highlights all its
|
|
5221
|
+
// clones at once. Fall back to id-only when the selection carries no index.
|
|
5222
|
+
const isSel =
|
|
5223
|
+
node.id === selectedId && (selectedIndex == null || node.index === selectedIndex);
|
|
5166
5224
|
const isHidden = hidden?.has(key);
|
|
5225
|
+
// A shared data-cd-id (reused component instance) IS reorderable now — the
|
|
5226
|
+
// server maps the occurrence index to the parent <Component> usage — so these
|
|
5227
|
+
// are no longer greyed/blocked. A `.map()`ed single-usage element still can't
|
|
5228
|
+
// split; that move is refused server-side and reverts.
|
|
5229
|
+
const canDrag = !!onReorder;
|
|
5230
|
+
// Phase 12.1 — the row being dragged FLOATS with the cursor (same model as the
|
|
5231
|
+
// in-canvas drag): a transform follows the pointer, its layout box stays
|
|
5232
|
+
// reserved (empty slot at the origin), and pointer-events:none lets the row
|
|
5233
|
+
// under the pointer be hit-tested. A blue divider (rendered by the tree) marks
|
|
5234
|
+
// where it drops — indented to show the depth it will nest at (Figma-style).
|
|
5235
|
+
const isDragging = dragState?.key === key;
|
|
5236
|
+
const dragStyle = isDragging
|
|
5237
|
+
? {
|
|
5238
|
+
transform: `translate(${dragState.dx}px, ${dragState.dy}px)`,
|
|
5239
|
+
opacity: 0.9,
|
|
5240
|
+
zIndex: 20,
|
|
5241
|
+
position: 'relative',
|
|
5242
|
+
pointerEvents: 'none',
|
|
5243
|
+
cursor: 'grabbing',
|
|
5244
|
+
boxShadow: '0 6px 18px rgba(0, 0, 0, 0.28)',
|
|
5245
|
+
}
|
|
5246
|
+
: null;
|
|
5167
5247
|
return (
|
|
5168
5248
|
<>
|
|
5169
5249
|
<div
|
|
5170
5250
|
className={
|
|
5171
5251
|
'st-layer st-layer--row' + (isSel ? ' is-sel' : '') + (isHidden ? ' is-hidden' : '')
|
|
5172
5252
|
}
|
|
5173
|
-
style={{ paddingLeft: 6 + depth * 14 }}
|
|
5253
|
+
style={{ paddingLeft: 6 + depth * 14, ...dragStyle }}
|
|
5174
5254
|
role="treeitem"
|
|
5175
5255
|
aria-selected={isSel}
|
|
5176
5256
|
aria-expanded={hasKids ? !isCollapsed : undefined}
|
|
5257
|
+
aria-grabbed={canDrag ? isDragging : undefined}
|
|
5177
5258
|
tabIndex={0}
|
|
5178
5259
|
title={`${node.tag} · ${node.type}`}
|
|
5260
|
+
data-layer-key={key}
|
|
5179
5261
|
onClick={() => onSelect(node)}
|
|
5180
5262
|
onMouseEnter={() => onHover(node)}
|
|
5181
5263
|
onMouseLeave={() => onHover(null)}
|
|
5264
|
+
onPointerDown={canDrag ? (e) => onRowPointerDown(e, node, key) : undefined}
|
|
5182
5265
|
onKeyDown={(e) => {
|
|
5266
|
+
// Enter/Space select; ↑/↓ (+ modifiers) are handled at the tree
|
|
5267
|
+
// container (selection-driven — survives the HMR re-render).
|
|
5183
5268
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
5184
5269
|
e.preventDefault();
|
|
5185
5270
|
onSelect(node);
|
|
@@ -5221,18 +5306,22 @@ function LayerRow({
|
|
|
5221
5306
|
) : null}
|
|
5222
5307
|
</div>
|
|
5223
5308
|
{hasKids && !isCollapsed
|
|
5224
|
-
? node.children.map((c) => (
|
|
5309
|
+
? node.children.map((c, ci) => (
|
|
5225
5310
|
<LayerRow
|
|
5226
5311
|
key={`${c.id}:${c.index}`}
|
|
5227
5312
|
node={c}
|
|
5228
5313
|
depth={depth + 1}
|
|
5229
5314
|
selectedId={selectedId}
|
|
5315
|
+
selectedIndex={selectedIndex}
|
|
5230
5316
|
collapsed={collapsed}
|
|
5231
5317
|
hidden={hidden}
|
|
5232
5318
|
onToggle={onToggle}
|
|
5233
5319
|
onSelect={onSelect}
|
|
5234
5320
|
onHover={onHover}
|
|
5235
5321
|
onToggleVisibility={onToggleVisibility}
|
|
5322
|
+
onReorder={onReorder}
|
|
5323
|
+
onRowPointerDown={onRowPointerDown}
|
|
5324
|
+
dragState={dragState}
|
|
5236
5325
|
/>
|
|
5237
5326
|
))
|
|
5238
5327
|
: null}
|
|
@@ -5311,6 +5400,8 @@ function InspectorPanel({
|
|
|
5311
5400
|
layersTree,
|
|
5312
5401
|
onSelectLayer,
|
|
5313
5402
|
onHoverLayer,
|
|
5403
|
+
onReorderLayer,
|
|
5404
|
+
layersBusyRef,
|
|
5314
5405
|
cfg,
|
|
5315
5406
|
onOptimistic,
|
|
5316
5407
|
onRecordEdit,
|
|
@@ -5334,6 +5425,237 @@ function InspectorPanel({
|
|
|
5334
5425
|
// Phase 12.3 (W3.1) — per-layer visibility toggle. Live-only (display:none via
|
|
5335
5426
|
// the optimistic apply bus); not persisted to source. Keyed by `${id}:${index}`.
|
|
5336
5427
|
const [hiddenLayers, setHiddenLayers] = useState(() => new Set());
|
|
5428
|
+
// Phase 12.1 (DDR-138) — drag-to-reorder state (lifted so every row sees the
|
|
5429
|
+
// same drop target) + an aria-live announcement for keyboard moves.
|
|
5430
|
+
const [dragState, setDragState] = useState(null);
|
|
5431
|
+
const [reorderMsg, setReorderMsg] = useState('');
|
|
5432
|
+
const handleReorder = onReorderLayer
|
|
5433
|
+
? (dragged, ref, position) => {
|
|
5434
|
+
// Gate keyboard + drop moves while a prior reorder is still landing — the
|
|
5435
|
+
// write churns positional ids, so acting on the stale tree would misfire.
|
|
5436
|
+
if (layersBusyRef?.current) return;
|
|
5437
|
+
const verb =
|
|
5438
|
+
position === 'before'
|
|
5439
|
+
? `before ${ref.label}`
|
|
5440
|
+
: position === 'after'
|
|
5441
|
+
? `after ${ref.label}`
|
|
5442
|
+
: `into ${ref.label}`;
|
|
5443
|
+
setReorderMsg(`Moved ${dragged.label} ${verb}`);
|
|
5444
|
+
// Pass occurrence indices so a reused-component instance maps to its
|
|
5445
|
+
// parent <Component> usage server-side (same-id instances are distinct).
|
|
5446
|
+
onReorderLayer(dragged.id, ref.id, position, {
|
|
5447
|
+
idIndex: dragged.index,
|
|
5448
|
+
refIndex: ref.index,
|
|
5449
|
+
});
|
|
5450
|
+
}
|
|
5451
|
+
: undefined;
|
|
5452
|
+
// Flatten the VISIBLE tree (respecting collapse) with each node's depth,
|
|
5453
|
+
// parent, siblings, and index — the basis for keyboard nav + moves.
|
|
5454
|
+
const flattenVisibleLayers = () => {
|
|
5455
|
+
const flat = [];
|
|
5456
|
+
(function walk(nodes, depth, parentNode) {
|
|
5457
|
+
(nodes || []).forEach((n, i) => {
|
|
5458
|
+
flat.push({ node: n, depth, parentNode, siblings: nodes, pos: i });
|
|
5459
|
+
if (n.children && n.children.length && !collapsed.has(`${n.id}:${n.index}`))
|
|
5460
|
+
walk(n.children, depth + 1, n);
|
|
5461
|
+
});
|
|
5462
|
+
})(layersTree?.nodes, 0, null);
|
|
5463
|
+
return flat;
|
|
5464
|
+
};
|
|
5465
|
+
// The keyboard cursor is the SELECTED element (NOT DOM focus, which the HMR
|
|
5466
|
+
// re-render churns). ↑/↓ move the selection through the flattened tree;
|
|
5467
|
+
// Alt+↑/↓ reorder within the parent; Alt+Shift+↑/↓ reorder across the parent
|
|
5468
|
+
// boundary (out at first/last, into an adjacent open container). After a move
|
|
5469
|
+
// the reorder re-selects the moved element (movedId), so the cursor follows
|
|
5470
|
+
// and the next press keeps working — the fix for "Alt+arrow works once".
|
|
5471
|
+
const onTreeKeyDown = (e) => {
|
|
5472
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
5473
|
+
if (!el) return;
|
|
5474
|
+
const flat = flattenVisibleLayers();
|
|
5475
|
+
const i = flat.findIndex((f) => f.node.id === el.id && f.node.index === el.index);
|
|
5476
|
+
if (i < 0) return;
|
|
5477
|
+
const dir = e.key === 'ArrowDown' ? 1 : -1;
|
|
5478
|
+
const cur = flat[i];
|
|
5479
|
+
if (!e.altKey) {
|
|
5480
|
+
const t = flat[i + dir];
|
|
5481
|
+
if (t) {
|
|
5482
|
+
e.preventDefault();
|
|
5483
|
+
onSelectLayer?.(t.node);
|
|
5484
|
+
}
|
|
5485
|
+
return;
|
|
5486
|
+
}
|
|
5487
|
+
if (!handleReorder || !Array.isArray(cur.siblings)) return;
|
|
5488
|
+
e.preventDefault();
|
|
5489
|
+
const expanded = (n) =>
|
|
5490
|
+
n && n.children && n.children.length && !collapsed.has(`${n.id}:${n.index}`);
|
|
5491
|
+
if (!e.shiftKey) {
|
|
5492
|
+
// Within-parent reorder; stop at the first/last sibling.
|
|
5493
|
+
if (dir < 0 && cur.pos > 0) handleReorder(cur.node, cur.siblings[cur.pos - 1], 'before');
|
|
5494
|
+
else if (dir > 0 && cur.pos < cur.siblings.length - 1)
|
|
5495
|
+
handleReorder(cur.node, cur.siblings[cur.pos + 1], 'after');
|
|
5496
|
+
return;
|
|
5497
|
+
}
|
|
5498
|
+
// Cross-parent reorder (flattened traversal).
|
|
5499
|
+
if (dir > 0) {
|
|
5500
|
+
const next = cur.siblings[cur.pos + 1];
|
|
5501
|
+
if (next) handleReorder(cur.node, next, expanded(next) ? 'inside-start' : 'after');
|
|
5502
|
+
else if (cur.parentNode) handleReorder(cur.node, cur.parentNode, 'after');
|
|
5503
|
+
} else {
|
|
5504
|
+
const prev = cur.siblings[cur.pos - 1];
|
|
5505
|
+
if (prev) handleReorder(cur.node, prev, expanded(prev) ? 'inside-end' : 'before');
|
|
5506
|
+
else if (cur.parentNode) handleReorder(cur.node, cur.parentNode, 'before');
|
|
5507
|
+
}
|
|
5508
|
+
};
|
|
5509
|
+
// Pointer-based drag-to-reorder in the Layers tree — same model as the
|
|
5510
|
+
// in-canvas drag: the row FLOATS with the cursor (transform, slot reserved), a
|
|
5511
|
+
// blue divider (or nest ring) marks the drop, and the move commits only on
|
|
5512
|
+
// release. Same-origin shell, so it's all local (no dgn bus).
|
|
5513
|
+
const layerDragRef = useRef(null);
|
|
5514
|
+
const layerTreeRef = useRef(null);
|
|
5515
|
+
const startLayerDrag = (e, node, key) => {
|
|
5516
|
+
if (!handleReorder || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
5517
|
+
if (layersBusyRef?.current) return; // a prior reorder is still landing (ids churning)
|
|
5518
|
+
const startX = e.clientX;
|
|
5519
|
+
const startY = e.clientY;
|
|
5520
|
+
// Forbid dropping a node into itself or its own subtree.
|
|
5521
|
+
const forbidden = new Set([key]);
|
|
5522
|
+
(function walk(n) {
|
|
5523
|
+
(n.children || []).forEach((c) => {
|
|
5524
|
+
forbidden.add(`${c.id}:${c.index}`);
|
|
5525
|
+
walk(c);
|
|
5526
|
+
});
|
|
5527
|
+
})(node);
|
|
5528
|
+
// Flatten the VISIBLE tree (respecting collapse) with depth. The drop is
|
|
5529
|
+
// computed dnd-kit-tree style: a GAP between rows + a DEPTH from the pointer's
|
|
5530
|
+
// X — so you can nest into any container (even an empty one) and the divider
|
|
5531
|
+
// INDENTS to show it's going into that nested list (Figma-style).
|
|
5532
|
+
const INDENT = 14;
|
|
5533
|
+
const BASE = 6;
|
|
5534
|
+
const flat = [];
|
|
5535
|
+
(function walk(nodes, depth) {
|
|
5536
|
+
(nodes || []).forEach((n) => {
|
|
5537
|
+
const k = `${n.id}:${n.index}`;
|
|
5538
|
+
flat.push({ node: n, key: k, id: n.id, depth, tag: n.tag });
|
|
5539
|
+
if (n.children && n.children.length && !collapsed.has(k)) walk(n.children, depth + 1);
|
|
5540
|
+
});
|
|
5541
|
+
})(layersTree?.nodes, 0);
|
|
5542
|
+
const flatByKey = new Map(flat.map((it) => [it.key, it]));
|
|
5543
|
+
let started = false;
|
|
5544
|
+
const onMove = (ev) => {
|
|
5545
|
+
if (!started) {
|
|
5546
|
+
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 4) return;
|
|
5547
|
+
started = true;
|
|
5548
|
+
}
|
|
5549
|
+
const dx = ev.clientX - startX;
|
|
5550
|
+
const dy = ev.clientY - startY;
|
|
5551
|
+
let target = null;
|
|
5552
|
+
// Visible rows in DOM (== flat) order, minus the dragged one (its rect is
|
|
5553
|
+
// floated, so it's meaningless for gap math).
|
|
5554
|
+
const rows = [].slice
|
|
5555
|
+
.call(document.querySelectorAll('.st-layer--row[data-layer-key]'))
|
|
5556
|
+
.map((el) => ({ rect: el.getBoundingClientRect(), it: flatByKey.get(el.getAttribute('data-layer-key')) }))
|
|
5557
|
+
.filter((r) => r.it && r.it.key !== key);
|
|
5558
|
+
if (rows.length) {
|
|
5559
|
+
const rowLeft = rows[0].rect.left;
|
|
5560
|
+
const rowRight = rows[0].rect.right;
|
|
5561
|
+
// Gap = index of the first row whose vertical midpoint is below the pointer.
|
|
5562
|
+
let gap = rows.length;
|
|
5563
|
+
for (let i = 0; i < rows.length; i++) {
|
|
5564
|
+
if (ev.clientY < rows[i].rect.top + rows[i].rect.height / 2) {
|
|
5565
|
+
gap = i;
|
|
5566
|
+
break;
|
|
5567
|
+
}
|
|
5568
|
+
}
|
|
5569
|
+
const prev = rows[gap - 1]?.it || null;
|
|
5570
|
+
const nextIt = rows[gap]?.it || null;
|
|
5571
|
+
// Depth from pointer X. Clamp between the row below (min) and one level
|
|
5572
|
+
// under the row above (max, if it can hold children).
|
|
5573
|
+
const raw = Math.round((ev.clientX - rowLeft - BASE) / INDENT);
|
|
5574
|
+
const maxDepth = prev ? prev.depth + (LAYER_VOID_TAGS.has(prev.tag) ? 0 : 1) : 0;
|
|
5575
|
+
const minDepth = nextIt ? nextIt.depth : 0;
|
|
5576
|
+
const depth = Math.max(minDepth, Math.min(raw, maxDepth));
|
|
5577
|
+
// Resolve (prev, depth) → { refId, position, refNode, targetDepth }.
|
|
5578
|
+
let refIt = null;
|
|
5579
|
+
let position = 'before';
|
|
5580
|
+
let targetDepth = 0;
|
|
5581
|
+
if (!prev) {
|
|
5582
|
+
if (nextIt) {
|
|
5583
|
+
refIt = nextIt;
|
|
5584
|
+
position = 'before';
|
|
5585
|
+
targetDepth = nextIt.depth;
|
|
5586
|
+
}
|
|
5587
|
+
} else if (depth > prev.depth) {
|
|
5588
|
+
refIt = prev; // nest as prev's FIRST child
|
|
5589
|
+
position = 'inside-start';
|
|
5590
|
+
targetDepth = prev.depth + 1;
|
|
5591
|
+
} else if (depth === prev.depth) {
|
|
5592
|
+
refIt = prev;
|
|
5593
|
+
position = 'after';
|
|
5594
|
+
targetDepth = prev.depth;
|
|
5595
|
+
} else {
|
|
5596
|
+
for (let i = gap - 1; i >= 0; i--) {
|
|
5597
|
+
if (rows[i].it.depth === depth) {
|
|
5598
|
+
refIt = rows[i].it;
|
|
5599
|
+
break;
|
|
5600
|
+
}
|
|
5601
|
+
}
|
|
5602
|
+
if (!refIt) refIt = prev;
|
|
5603
|
+
position = 'after';
|
|
5604
|
+
targetDepth = depth;
|
|
5605
|
+
}
|
|
5606
|
+
// Guard: no self, no dragged-subtree, no repeated (list) target.
|
|
5607
|
+
// forbidden already holds the dragged node's own key + its subtree, so a
|
|
5608
|
+
// different INSTANCE of the same reused component (same id, other key) is
|
|
5609
|
+
// still a valid target.
|
|
5610
|
+
if (refIt && refIt.key !== key && !forbidden.has(refIt.key)) {
|
|
5611
|
+
const y = prev ? rows[gap - 1].rect.bottom : rows[0].rect.top;
|
|
5612
|
+
const left = rowLeft + BASE + targetDepth * INDENT;
|
|
5613
|
+
target = { refId: refIt.id, position, node: refIt.node, y, left, w: Math.max(24, rowRight - left) };
|
|
5614
|
+
}
|
|
5615
|
+
}
|
|
5616
|
+
const next = { key, node, dx, dy, target };
|
|
5617
|
+
layerDragRef.current = next;
|
|
5618
|
+
setDragState(next);
|
|
5619
|
+
};
|
|
5620
|
+
const teardown = () => {
|
|
5621
|
+
window.removeEventListener('pointermove', onMove);
|
|
5622
|
+
window.removeEventListener('pointerup', onUp);
|
|
5623
|
+
window.removeEventListener('keydown', onKey, true);
|
|
5624
|
+
};
|
|
5625
|
+
// Swallow the click that trails a drag so it doesn't also select a row.
|
|
5626
|
+
const suppressClick = () => {
|
|
5627
|
+
const sup = (ce) => {
|
|
5628
|
+
ce.preventDefault();
|
|
5629
|
+
ce.stopImmediatePropagation();
|
|
5630
|
+
document.removeEventListener('click', sup, true);
|
|
5631
|
+
};
|
|
5632
|
+
document.addEventListener('click', sup, true);
|
|
5633
|
+
setTimeout(() => document.removeEventListener('click', sup, true), 300);
|
|
5634
|
+
};
|
|
5635
|
+
const onUp = () => {
|
|
5636
|
+
teardown();
|
|
5637
|
+
const d = layerDragRef.current;
|
|
5638
|
+
layerDragRef.current = null;
|
|
5639
|
+
setDragState(null);
|
|
5640
|
+
if (started && d?.target) {
|
|
5641
|
+
suppressClick();
|
|
5642
|
+
handleReorder(d.node, d.target.node, d.target.position);
|
|
5643
|
+
}
|
|
5644
|
+
};
|
|
5645
|
+
// Esc — abort the drag: the row snaps back, nothing commits.
|
|
5646
|
+
const onKey = (ke) => {
|
|
5647
|
+
if (ke.key !== 'Escape') return;
|
|
5648
|
+
ke.preventDefault();
|
|
5649
|
+
ke.stopImmediatePropagation();
|
|
5650
|
+
teardown();
|
|
5651
|
+
layerDragRef.current = null;
|
|
5652
|
+
setDragState(null);
|
|
5653
|
+
if (started) suppressClick();
|
|
5654
|
+
};
|
|
5655
|
+
window.addEventListener('pointermove', onMove);
|
|
5656
|
+
window.addEventListener('pointerup', onUp);
|
|
5657
|
+
window.addEventListener('keydown', onKey, true);
|
|
5658
|
+
};
|
|
5337
5659
|
const toggleCollapse = (key) =>
|
|
5338
5660
|
setCollapsed((prev) => {
|
|
5339
5661
|
const next = new Set(prev);
|
|
@@ -5454,22 +5776,58 @@ function InspectorPanel({
|
|
|
5454
5776
|
<>
|
|
5455
5777
|
<div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
|
|
5456
5778
|
{layersTree?.nodes?.length ? (
|
|
5457
|
-
|
|
5458
|
-
{
|
|
5459
|
-
<
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5779
|
+
<>
|
|
5780
|
+
{handleReorder ? (
|
|
5781
|
+
<div className="st-rp-hint" aria-hidden="true">
|
|
5782
|
+
Drag or ↑/↓ select · Alt+↑/↓ move · Alt+Shift+↑/↓ move across
|
|
5783
|
+
</div>
|
|
5784
|
+
) : null}
|
|
5785
|
+
{/* Keyboard nav/move handled at the container (tabIndex=0), driven
|
|
5786
|
+
by the SELECTION not row focus — survives the HMR re-render. */}
|
|
5787
|
+
<div
|
|
5788
|
+
role="tree"
|
|
5789
|
+
aria-label="Artboard layers"
|
|
5790
|
+
tabIndex={0}
|
|
5791
|
+
ref={layerTreeRef}
|
|
5792
|
+
onKeyDown={onTreeKeyDown}
|
|
5793
|
+
>
|
|
5794
|
+
{layersTree.nodes.map((n, ni) => (
|
|
5795
|
+
<LayerRow
|
|
5796
|
+
key={`${n.id}:${n.index}`}
|
|
5797
|
+
node={n}
|
|
5798
|
+
depth={0}
|
|
5799
|
+
selectedId={el.id}
|
|
5800
|
+
selectedIndex={el.index}
|
|
5801
|
+
collapsed={collapsed}
|
|
5802
|
+
hidden={hiddenLayers}
|
|
5803
|
+
onToggle={toggleCollapse}
|
|
5804
|
+
onSelect={(node) => {
|
|
5805
|
+
onSelectLayer?.(node);
|
|
5806
|
+
layerTreeRef.current?.focus();
|
|
5807
|
+
}}
|
|
5808
|
+
onHover={(node) => onHoverLayer?.(node)}
|
|
5809
|
+
onToggleVisibility={toggleVisibility}
|
|
5810
|
+
onReorder={handleReorder}
|
|
5811
|
+
onRowPointerDown={startLayerDrag}
|
|
5812
|
+
dragState={dragState}
|
|
5813
|
+
/>
|
|
5814
|
+
))}
|
|
5815
|
+
</div>
|
|
5816
|
+
{dragState?.target ? (
|
|
5817
|
+
<div
|
|
5818
|
+
className="st-layer-divider"
|
|
5819
|
+
aria-hidden="true"
|
|
5820
|
+
style={{
|
|
5821
|
+
left: dragState.target.left,
|
|
5822
|
+
top: dragState.target.y - 1,
|
|
5823
|
+
width: dragState.target.w,
|
|
5824
|
+
}}
|
|
5470
5825
|
/>
|
|
5471
|
-
)
|
|
5472
|
-
|
|
5826
|
+
) : null}
|
|
5827
|
+
<div className="sr-only" role="status" aria-live="polite">
|
|
5828
|
+
{reorderMsg}
|
|
5829
|
+
</div>
|
|
5830
|
+
</>
|
|
5473
5831
|
) : Array.isArray(el.dom_path) && el.dom_path.length ? (
|
|
5474
5832
|
el.dom_path.map((node, i) => (
|
|
5475
5833
|
<div
|
|
@@ -5516,6 +5874,56 @@ function App() {
|
|
|
5516
5874
|
useEffect(() => {
|
|
5517
5875
|
selectedRef.current = selected;
|
|
5518
5876
|
}, [selected]);
|
|
5877
|
+
// feature-acp-context-hardening — halo re-apply retry ladder. A single
|
|
5878
|
+
// select-by-id post races the fresh iframe: dgn:'loaded' fires from the
|
|
5879
|
+
// inline inspector script at HTML-parse time, BEFORE the React canvas-shell
|
|
5880
|
+
// mounts its message listener (module fetch + mount takes 100ms–seconds), so
|
|
5881
|
+
// a one-shot post silently evaporates and the restored selection never gets
|
|
5882
|
+
// its halo back. Re-post on a fixed ladder; each attempt aborts when the
|
|
5883
|
+
// user meanwhile selected something else. select-by-id is idempotent
|
|
5884
|
+
// (replace-same → select-set echo → ws guard sees same id → no re-schedule),
|
|
5885
|
+
// so the ladder can't loop.
|
|
5886
|
+
const haloRestoreTimersRef = useRef([]);
|
|
5887
|
+
const scheduleHaloRestore = useCallback((one) => {
|
|
5888
|
+
if (!one?.id || !one.file) return;
|
|
5889
|
+
for (const t of haloRestoreTimersRef.current) clearTimeout(t);
|
|
5890
|
+
haloRestoreTimersRef.current = [50, 450, 1200, 2500, 5000].map((delay) =>
|
|
5891
|
+
setTimeout(() => {
|
|
5892
|
+
const cur = selectedRef.current;
|
|
5893
|
+
const c1 = Array.isArray(cur) ? cur[0] : cur;
|
|
5894
|
+
if (!c1 || c1.id !== one.id || c1.file !== one.file) return; // superseded
|
|
5895
|
+
const frame = iframesRef.current.get(one.file);
|
|
5896
|
+
if (!frame?.contentWindow) return;
|
|
5897
|
+
try {
|
|
5898
|
+
frame.contentWindow.postMessage(
|
|
5899
|
+
{
|
|
5900
|
+
dgn: 'select-by-id',
|
|
5901
|
+
id: one.id,
|
|
5902
|
+
artboardId: one.artboardId ?? null,
|
|
5903
|
+
index: one.index ?? 0,
|
|
5904
|
+
},
|
|
5905
|
+
'*'
|
|
5906
|
+
);
|
|
5907
|
+
} catch {}
|
|
5908
|
+
}, delay)
|
|
5909
|
+
);
|
|
5910
|
+
}, []);
|
|
5911
|
+
// Phase 12.1 (DDR-138) — after a reorder writes source, the HMR reload remounts
|
|
5912
|
+
// the canvas and the positional data-cd-id of the moved element (and everything
|
|
5913
|
+
// after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
|
|
5914
|
+
// so the dgn:'loaded' handler re-selects the moved element by its NEW id.
|
|
5915
|
+
const pendingReorderRef = useRef(null);
|
|
5916
|
+
// Latest `reorderLayer` (defined far below), read from the stale-closure
|
|
5917
|
+
// onMessage handler when the in-canvas grip posts dgn:'reorder-request'
|
|
5918
|
+
// (same freshness idiom as selectedRef; avoids a render-time TDZ on the
|
|
5919
|
+
// later useCallback).
|
|
5920
|
+
const reorderLayerRef = useRef(null);
|
|
5921
|
+
// Phase 12.1 — true between a layers-panel reorder and the fresh layers-tree
|
|
5922
|
+
// landing. A reorder churns positional data-cd-ids, so a rapid 2nd drag would
|
|
5923
|
+
// target stale ids; the Layers tree gates new drags on this until the rebuilt
|
|
5924
|
+
// tree (with correct ids) arrives. Cleared by the layers-tree message.
|
|
5925
|
+
const layersBusyRef = useRef(false);
|
|
5926
|
+
const layersBusyTimerRef = useRef(null);
|
|
5519
5927
|
// Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
|
|
5520
5928
|
const [layersTree, setLayersTree] = useState(null);
|
|
5521
5929
|
const [wsConnected, setWsConnected] = useState(false);
|
|
@@ -6108,7 +6516,25 @@ function App() {
|
|
|
6108
6516
|
if (m.type === 'snapshot' && m.state) {
|
|
6109
6517
|
setSelected((prev) => mergeSelClientFields(m.state.selected, prev));
|
|
6110
6518
|
} else if (m.type === 'selected') {
|
|
6111
|
-
|
|
6519
|
+
// feature-acp-context-hardening — a canvas switch RESTORES the
|
|
6520
|
+
// per-canvas selection server-side and broadcasts it here. The
|
|
6521
|
+
// dgn:'loaded' re-select (Phase 12.3 path below) only works when
|
|
6522
|
+
// this frame lands BEFORE the new iframe loads; on a fast cached
|
|
6523
|
+
// mount it lands after, so re-apply the halo from THIS edge too.
|
|
6524
|
+
// Guarded to restore-transitions (prev missing / different id or
|
|
6525
|
+
// file) so the select-set echo of our own re-select can't loop.
|
|
6526
|
+
const incoming = m.selected;
|
|
6527
|
+
const one = Array.isArray(incoming) ? incoming[0] : incoming;
|
|
6528
|
+
const prevSel = selectedRef.current;
|
|
6529
|
+
const prevOne = Array.isArray(prevSel) ? prevSel[0] : prevSel;
|
|
6530
|
+
setSelected((prev) => mergeSelClientFields(incoming, prev));
|
|
6531
|
+
if (
|
|
6532
|
+
one?.id &&
|
|
6533
|
+
one.file &&
|
|
6534
|
+
(!prevOne || prevOne.id !== one.id || prevOne.file !== one.file)
|
|
6535
|
+
) {
|
|
6536
|
+
scheduleHaloRestore(one);
|
|
6537
|
+
}
|
|
6112
6538
|
} else if (m.type === 'comments' && typeof m.file === 'string') {
|
|
6113
6539
|
setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
|
|
6114
6540
|
} else if (m.type === 'ai-activity' && typeof m.file === 'string') {
|
|
@@ -6546,6 +6972,24 @@ function App() {
|
|
|
6546
6972
|
}
|
|
6547
6973
|
return;
|
|
6548
6974
|
}
|
|
6975
|
+
// SECURITY (attacker Finding 2, mirrors the phase-28 F-2 reorder/present
|
|
6976
|
+
// gate at the handlers below): selection posts are honored ONLY from the
|
|
6977
|
+
// ACTIVE canvas's window. Every open canvas iframe shares one
|
|
6978
|
+
// `canvasOrigin`, so the origin check above can't tell the foreground
|
|
6979
|
+
// canvas from a background/synced one — and a selection carries an
|
|
6980
|
+
// attacker-chosen `file` that the server trusts verbatim and write-throughs
|
|
6981
|
+
// into the persistent per-canvas `selections` map, then rides into the
|
|
6982
|
+
// auto-approving ACP agent's frozen context. A background (untrusted, DDR-054)
|
|
6983
|
+
// canvas must not be able to plant a selection with no user gesture. The
|
|
6984
|
+
// user can only click the visible active canvas, so a non-active source is
|
|
6985
|
+
// never a legitimate selection.
|
|
6986
|
+
if (m.dgn === 'select' || m.dgn === 'select-set' || m.dgn === 'clear-select') {
|
|
6987
|
+
const activeWin =
|
|
6988
|
+
activePath && activePath !== SYSTEM_TAB
|
|
6989
|
+
? iframesRef.current.get(activePath)?.contentWindow
|
|
6990
|
+
: null;
|
|
6991
|
+
if (e.source !== activeWin) return;
|
|
6992
|
+
}
|
|
6549
6993
|
if (m.dgn === 'select' && m.selection) {
|
|
6550
6994
|
wsSend({ type: 'select', selection: m.selection });
|
|
6551
6995
|
setSelected(m.selection);
|
|
@@ -6631,6 +7075,88 @@ function App() {
|
|
|
6631
7075
|
} else if (m.dgn === 'layers-tree') {
|
|
6632
7076
|
// Phase 12 Task 4 — browsable layers tree for the active artboard.
|
|
6633
7077
|
setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
|
|
7078
|
+
// fresh tree (correct ids) landed — drags OK again
|
|
7079
|
+
layersBusyRef.current = false;
|
|
7080
|
+
if (layersBusyTimerRef.current) {
|
|
7081
|
+
clearTimeout(layersBusyTimerRef.current);
|
|
7082
|
+
layersBusyTimerRef.current = null;
|
|
7083
|
+
}
|
|
7084
|
+
// Phase 12.1 — a reorder writes source → SOFT HMR (no dgn:'loaded' fires,
|
|
7085
|
+
// so the loaded-handler re-select never runs). The live observer's fresh
|
|
7086
|
+
// tree is our signal instead: re-select the moved element by its NEW
|
|
7087
|
+
// positional id (movedId) so the selection halo + the keyboard cursor
|
|
7088
|
+
// follow it. Without this the next Alt+arrow acts on the STALE id, which
|
|
7089
|
+
// positionally now points at a different element. Only act once the tree
|
|
7090
|
+
// actually CONTAINS movedId — an in-canvas drag posts a PREVIEW tree
|
|
7091
|
+
// first (old ids, pre-write); consuming then would re-select nothing and
|
|
7092
|
+
// burn the pending ref before the real post-HMR tree lands.
|
|
7093
|
+
const pend = pendingReorderRef.current;
|
|
7094
|
+
if (pend && pend.movedId) {
|
|
7095
|
+
const has = (function find(nodes) {
|
|
7096
|
+
return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
|
|
7097
|
+
})(m.tree);
|
|
7098
|
+
if (has) {
|
|
7099
|
+
pendingReorderRef.current = null;
|
|
7100
|
+
const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
7101
|
+
if (win) {
|
|
7102
|
+
try {
|
|
7103
|
+
win.postMessage(
|
|
7104
|
+
{ dgn: 'select-by-id', id: pend.movedId, artboardId: m.artboardId ?? null, index: 0 },
|
|
7105
|
+
'*'
|
|
7106
|
+
);
|
|
7107
|
+
} catch {}
|
|
7108
|
+
}
|
|
7109
|
+
}
|
|
7110
|
+
}
|
|
7111
|
+
} else if (m.dgn === 'reorder-revert') {
|
|
7112
|
+
// Phase 12.1 follow-up — Cmd+Z/Cmd+Shift+Z on a reorder. The canvas undo
|
|
7113
|
+
// stack (untrusted iframe) can't reach the main-origin-only
|
|
7114
|
+
// /_api/reorder-revert, so it REQUESTS; the shell writes. Active canvas
|
|
7115
|
+
// only. SECURITY (adversarial F4): pin the target to `activePath` — do NOT
|
|
7116
|
+
// forward m.canvas (an untrusted iframe could name a different canvas); the
|
|
7117
|
+
// undo stack is per active canvas anyway, and the server's seq→entry.abs +
|
|
7118
|
+
// 409 content-match are the backstop.
|
|
7119
|
+
const rvWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
7120
|
+
const rvShape = typeof m.seq === 'number' && (m.dir === 'undo' || m.dir === 'redo');
|
|
7121
|
+
if (e.source === rvWin && rvShape && activePath) {
|
|
7122
|
+
layersBusyRef.current = true;
|
|
7123
|
+
if (layersBusyTimerRef.current) clearTimeout(layersBusyTimerRef.current);
|
|
7124
|
+
layersBusyTimerRef.current = setTimeout(() => {
|
|
7125
|
+
layersBusyRef.current = false;
|
|
7126
|
+
layersBusyTimerRef.current = null;
|
|
7127
|
+
}, 700);
|
|
7128
|
+
fetch('/_api/reorder-revert', {
|
|
7129
|
+
method: 'POST',
|
|
7130
|
+
headers: { 'content-type': 'application/json' },
|
|
7131
|
+
body: JSON.stringify({ canvas: activePath, seq: m.seq, dir: m.dir }),
|
|
7132
|
+
})
|
|
7133
|
+
.then((r) => r.json().catch(() => ({})))
|
|
7134
|
+
.then((j) => {
|
|
7135
|
+
if (!j.ok) console.warn('[reorder-revert]', j.error || 'failed');
|
|
7136
|
+
})
|
|
7137
|
+
.catch(() => {});
|
|
7138
|
+
}
|
|
7139
|
+
} else if (m.dgn === 'reorder-request') {
|
|
7140
|
+
// Phase 12.1 (DDR-138) — the in-canvas ReorderGrip (untrusted canvas
|
|
7141
|
+
// iframe) can't reach the main-origin-only /_api/reorder (DDR-054), so it
|
|
7142
|
+
// REQUESTS a move; the shell performs the privileged write via reorderLayer
|
|
7143
|
+
// (same lane as edit-text → /_api/edit-text). Honor it ONLY from the ACTIVE
|
|
7144
|
+
// canvas (a background tab's untrusted canvas must not mutate source), and
|
|
7145
|
+
// only for well-formed ids/position (reorderLayer + the server re-validate).
|
|
7146
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
7147
|
+
const okShape =
|
|
7148
|
+
typeof m.id === 'string' &&
|
|
7149
|
+
typeof m.refId === 'string' &&
|
|
7150
|
+
(m.position === 'before' ||
|
|
7151
|
+
m.position === 'after' ||
|
|
7152
|
+
m.position === 'inside-start' ||
|
|
7153
|
+
m.position === 'inside-end');
|
|
7154
|
+
if (e.source === activeWin && okShape) {
|
|
7155
|
+
reorderLayerRef.current?.(m.id, m.refId, m.position, {
|
|
7156
|
+
idIndex: Number.isInteger(m.idIndex) ? m.idIndex : undefined,
|
|
7157
|
+
refIndex: Number.isInteger(m.refIndex) ? m.refIndex : undefined,
|
|
7158
|
+
});
|
|
7159
|
+
}
|
|
6634
7160
|
} else if (m.dgn === 'open-inspector') {
|
|
6635
7161
|
// Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
|
|
6636
7162
|
openRightPanel('inspector');
|
|
@@ -6749,25 +7275,48 @@ function App() {
|
|
|
6749
7275
|
el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
|
|
6750
7276
|
} catch {}
|
|
6751
7277
|
}
|
|
6752
|
-
// Phase 12.
|
|
6753
|
-
//
|
|
6754
|
-
//
|
|
6755
|
-
//
|
|
6756
|
-
//
|
|
6757
|
-
|
|
6758
|
-
|
|
6759
|
-
|
|
7278
|
+
// Phase 12.1 (DDR-138) — a reorder just wrote source: the moved
|
|
7279
|
+
// element's positional data-cd-id renumbered, so re-selecting by the
|
|
7280
|
+
// PRE-move id would land on the wrong node. Re-select by the recomputed
|
|
7281
|
+
// movedId instead (null ⇒ leave selection to the user, never guess), and
|
|
7282
|
+
// rebuild the layers tree so the panel reflects the new order.
|
|
7283
|
+
const pend = pendingReorderRef.current;
|
|
7284
|
+
if (pend && pend.file === m.file) {
|
|
7285
|
+
pendingReorderRef.current = null;
|
|
6760
7286
|
try {
|
|
6761
7287
|
el.contentWindow.postMessage(
|
|
6762
|
-
{
|
|
6763
|
-
dgn: 'select-by-id',
|
|
6764
|
-
id: sel.id,
|
|
6765
|
-
artboardId: sel.artboardId ?? null,
|
|
6766
|
-
index: sel.index ?? 0,
|
|
6767
|
-
},
|
|
7288
|
+
{ dgn: 'request-layers', artboardId: pend.artboardId ?? null },
|
|
6768
7289
|
'*'
|
|
6769
7290
|
);
|
|
6770
7291
|
} catch {}
|
|
7292
|
+
if (pend.movedId) {
|
|
7293
|
+
try {
|
|
7294
|
+
el.contentWindow.postMessage(
|
|
7295
|
+
{
|
|
7296
|
+
dgn: 'select-by-id',
|
|
7297
|
+
id: pend.movedId,
|
|
7298
|
+
artboardId: pend.artboardId ?? null,
|
|
7299
|
+
index: 0,
|
|
7300
|
+
},
|
|
7301
|
+
'*'
|
|
7302
|
+
);
|
|
7303
|
+
} catch {}
|
|
7304
|
+
}
|
|
7305
|
+
} else {
|
|
7306
|
+
// Phase 12.3 (W1.1) — an edit-css/edit-attr commit triggers the file
|
|
7307
|
+
// watcher's HMR reload, which remounts the canvas and drops the
|
|
7308
|
+
// in-canvas selection halo. Re-select the same element by its stable
|
|
7309
|
+
// data-cd-id so the user keeps focus on what they're editing. The
|
|
7310
|
+
// canvas-shell `select-by-id` handler re-emits select-set, which keeps
|
|
7311
|
+
// the Inspector panel + halo in sync. Guarded to the active file.
|
|
7312
|
+
// Retry-laddered: dgn:'loaded' fires from the inline inspector script
|
|
7313
|
+
// BEFORE the React canvas-shell mounts its listener, so a one-shot
|
|
7314
|
+
// post is lost on fresh iframes (canvas-switch restore case).
|
|
7315
|
+
const sel0 = selectedRef.current;
|
|
7316
|
+
const sel = Array.isArray(sel0) ? sel0[0] : sel0;
|
|
7317
|
+
if (sel && sel.id && sel.file === m.file) {
|
|
7318
|
+
scheduleHaloRestore(sel);
|
|
7319
|
+
}
|
|
6771
7320
|
}
|
|
6772
7321
|
}
|
|
6773
7322
|
} else if (m.dgn === 'export-request' && m.id && m.payload) {
|
|
@@ -6903,6 +7452,104 @@ function App() {
|
|
|
6903
7452
|
// iframe sink is fire-and-forget, so without this the POSTs could race).
|
|
6904
7453
|
const editApplyChainRef = useRef(Promise.resolve());
|
|
6905
7454
|
|
|
7455
|
+
// Phase 12.1 (DDR-138) — commit a Layers-panel drag/keyboard reorder. The shell
|
|
7456
|
+
// is main-origin, so it calls the privileged /_api/reorder directly (the CSS/
|
|
7457
|
+
// text edits go the other way — canvas requests, shell writes; here the gesture
|
|
7458
|
+
// originates in the shell). Serialized on the same chain as apply-edit so a
|
|
7459
|
+
// reorder can't race an in-flight edit write to the same file.
|
|
7460
|
+
const reorderLayer = useCallback(
|
|
7461
|
+
(draggedId, refId, position, occ) => {
|
|
7462
|
+
const idIndex = Number.isInteger(occ?.idIndex) ? occ.idIndex : undefined;
|
|
7463
|
+
const refIndex = Number.isInteger(occ?.refIndex) ? occ.refIndex : undefined;
|
|
7464
|
+
// A same-id move is valid for two INSTANCES of a reused component (distinct
|
|
7465
|
+
// occurrence indices → distinct usages); only a true self-move is a no-op.
|
|
7466
|
+
if (!draggedId || !refId) return;
|
|
7467
|
+
if (draggedId === refId && (idIndex ?? 0) === (refIndex ?? 0)) return;
|
|
7468
|
+
const sel = selectedRef.current;
|
|
7469
|
+
const one = Array.isArray(sel) ? sel[0] : sel;
|
|
7470
|
+
// SECURITY (DDR-139 / adversarial F1): a reorder ALWAYS applies to the
|
|
7471
|
+
// ACTIVE canvas — both the in-canvas drag and the Layers panel operate on
|
|
7472
|
+
// what the user is viewing. Pin the target to `activePath`; never derive it
|
|
7473
|
+
// from `selection.canvas`, which an untrusted iframe can spoof (an ungated
|
|
7474
|
+
// dgn:'select' followed by dgn:'reorder-request') to retarget the write to
|
|
7475
|
+
// a DIFFERENT canvas (confused deputy, breaks DDR-054/DDR-138 containment).
|
|
7476
|
+
const canvas = activePath;
|
|
7477
|
+
if (!canvas) return;
|
|
7478
|
+
const file = activePath;
|
|
7479
|
+
const artboardId = layersTree?.artboardId ?? one?.artboardId ?? null;
|
|
7480
|
+
// Optimistically reorder the layers tree so the panel reflects the move
|
|
7481
|
+
// instantly; the HMR rebuild (request-layers, dgn:'loaded') confirms it a
|
|
7482
|
+
// beat later. Ids here are distinct (repeated/list nodes are non-draggable).
|
|
7483
|
+
setLayersTree((prev) =>
|
|
7484
|
+
prev ? { ...prev, nodes: moveLayerNode(prev.nodes, draggedId, refId, position) } : prev
|
|
7485
|
+
);
|
|
7486
|
+
// Gate the next layers-panel drag until the rebuilt tree lands — the write
|
|
7487
|
+
// churns positional ids, so a rapid 2nd drag on the optimistic tree would
|
|
7488
|
+
// carry stale ids. Cleared by the incoming layers-tree message, with a
|
|
7489
|
+
// hard timeout fallback so the list can NEVER stay frozen if that message
|
|
7490
|
+
// doesn't arrive (a no-op reorder, no HMR, etc.).
|
|
7491
|
+
layersBusyRef.current = true;
|
|
7492
|
+
if (layersBusyTimerRef.current) clearTimeout(layersBusyTimerRef.current);
|
|
7493
|
+
layersBusyTimerRef.current = setTimeout(() => {
|
|
7494
|
+
layersBusyRef.current = false;
|
|
7495
|
+
layersBusyTimerRef.current = null;
|
|
7496
|
+
}, 700);
|
|
7497
|
+
editApplyChainRef.current = editApplyChainRef.current
|
|
7498
|
+
.catch(() => {})
|
|
7499
|
+
.then(() =>
|
|
7500
|
+
fetch('/_api/reorder', {
|
|
7501
|
+
method: 'POST',
|
|
7502
|
+
headers: { 'content-type': 'application/json' },
|
|
7503
|
+
body: JSON.stringify({ canvas, id: draggedId, refId, position, idIndex, refIndex }),
|
|
7504
|
+
})
|
|
7505
|
+
.then((r) => r.json().catch(() => ({})))
|
|
7506
|
+
.then((j) => {
|
|
7507
|
+
if (!j.ok) {
|
|
7508
|
+
console.warn('[reorder]', j.error || 'failed');
|
|
7509
|
+
// The move was REJECTED (e.g. a .map()ed/looped element, or a
|
|
7510
|
+
// reparent that would break the JSX) — but the canvas already
|
|
7511
|
+
// applied it optimistically (applyDrop) and the layers panel via
|
|
7512
|
+
// moveLayerNode. Nothing was written, so without a revert the user
|
|
7513
|
+
// sees a phantom change that vanishes on the next canvas switch
|
|
7514
|
+
// ("it didn't save") and Cmd+Z has no entry to undo. Tell the
|
|
7515
|
+
// canvas to put the node back; its observer re-posts the tree, so
|
|
7516
|
+
// the panel reverts too. For a LAYERS-panel reorder (no canvas DOM
|
|
7517
|
+
// move to observe) also re-request the tree to drop the optimistic
|
|
7518
|
+
// moveLayerNode.
|
|
7519
|
+
postToActiveCanvas({ dgn: 'reorder-failed' });
|
|
7520
|
+
postToActiveCanvas({ dgn: 'request-layers', artboardId });
|
|
7521
|
+
layersBusyRef.current = false;
|
|
7522
|
+
if (layersBusyTimerRef.current) {
|
|
7523
|
+
clearTimeout(layersBusyTimerRef.current);
|
|
7524
|
+
layersBusyTimerRef.current = null;
|
|
7525
|
+
}
|
|
7526
|
+
return;
|
|
7527
|
+
}
|
|
7528
|
+
// The write triggers an HMR reload; the dgn:'loaded' handler
|
|
7529
|
+
// re-selects the moved element by its recomputed id + rebuilds the
|
|
7530
|
+
// tree. movedId is best-effort — null means "leave selection to the
|
|
7531
|
+
// user" (never re-select by the stale pre-move id).
|
|
7532
|
+
pendingReorderRef.current = { file, movedId: j.movedId || null, artboardId };
|
|
7533
|
+
// Record onto the canvas undo stack so Cmd+Z reverts the move via
|
|
7534
|
+
// the server's reorder log (id-churn-proof whole-file swap).
|
|
7535
|
+
if (typeof j.seq === 'number') {
|
|
7536
|
+
postToActiveCanvas({
|
|
7537
|
+
dgn: 'record-edit',
|
|
7538
|
+
payload: { op: 'reorder', canvas, seq: j.seq, label: 'move element' },
|
|
7539
|
+
});
|
|
7540
|
+
}
|
|
7541
|
+
})
|
|
7542
|
+
.catch(() => {})
|
|
7543
|
+
);
|
|
7544
|
+
},
|
|
7545
|
+
[activePath, layersTree, postToActiveCanvas]
|
|
7546
|
+
);
|
|
7547
|
+
// Keep the ref the (stale-closure) onMessage reorder-request handler reads
|
|
7548
|
+
// pointed at the latest reorderLayer.
|
|
7549
|
+
useEffect(() => {
|
|
7550
|
+
reorderLayerRef.current = reorderLayer;
|
|
7551
|
+
}, [reorderLayer]);
|
|
7552
|
+
|
|
6906
7553
|
const resolveComment = useCallback((id) => {
|
|
6907
7554
|
wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
|
|
6908
7555
|
}, []);
|
|
@@ -7531,6 +8178,8 @@ function App() {
|
|
|
7531
8178
|
index: n ? n.index : 0,
|
|
7532
8179
|
})
|
|
7533
8180
|
}
|
|
8181
|
+
onReorderLayer={reorderLayer}
|
|
8182
|
+
layersBusyRef={layersBusyRef}
|
|
7534
8183
|
width={rpSize.w}
|
|
7535
8184
|
resizing={dragSide === 'rp'}
|
|
7536
8185
|
/>
|
|
@@ -7560,6 +8209,7 @@ function App() {
|
|
|
7560
8209
|
? activePath
|
|
7561
8210
|
: null
|
|
7562
8211
|
}
|
|
8212
|
+
selected={selected}
|
|
7563
8213
|
width={rpSize.w}
|
|
7564
8214
|
resizing={dragSide === 'rp'}
|
|
7565
8215
|
onClose={() => setAssistantOpen(false)}
|
|
@@ -7623,8 +8273,9 @@ function App() {
|
|
|
7623
8273
|
loadLog={gitLoadLog}
|
|
7624
8274
|
onClose={() => setDiffTarget(null)}
|
|
7625
8275
|
onRestore={async (file) => {
|
|
7626
|
-
await gitDiscard([file]);
|
|
7627
|
-
setDiffTarget(null);
|
|
8276
|
+
const res = await gitDiscard([file]);
|
|
8277
|
+
if (res?.ok) setDiffTarget(null);
|
|
8278
|
+
else window.alert(res?.error || 'Could not restore that version. Try again.');
|
|
7628
8279
|
}}
|
|
7629
8280
|
onResolve={async (choice) => {
|
|
7630
8281
|
// phase-28 (E3): apply the chosen side via /_api/git/resolve, which
|