@agent-native/core 0.85.6 → 0.85.7
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
- package/corpus/core/src/collab/presence.ts +63 -3
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
- package/corpus/templates/design/AGENTS.md +33 -1
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
- package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
- package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
- package/corpus/templates/design/actions/connect-localhost.ts +25 -18
- package/corpus/templates/design/actions/export-pdf.ts +8 -1
- package/corpus/templates/design/actions/export-zip.ts +10 -1
- package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
- package/corpus/templates/design/actions/insert-asset.ts +190 -7
- package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
- package/corpus/templates/design/actions/present-design-variants.ts +2 -2
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
- package/corpus/templates/design/actions/run-design-audit.ts +7 -4
- package/corpus/templates/design/actions/write-local-file.ts +47 -22
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
- package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
- package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
- package/corpus/templates/design/app/components/design/types.ts +16 -0
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +18 -0
- package/corpus/templates/design/app/lib/design-import.ts +95 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
- package/corpus/templates/design/server/lib/design-export.ts +43 -1
- package/corpus/templates/design/shared/board-file.ts +25 -5
- package/corpus/templates/design/shared/canvas-math.ts +754 -9
- package/corpus/templates/design/shared/code-layer.ts +304 -26
- package/corpus/templates/design/shared/color-utils.ts +84 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
- package/corpus/templates/design/shared/motion-compiler.ts +75 -31
- package/corpus/templates/design/shared/motion-timeline.ts +335 -0
- package/corpus/templates/design/shared/pen-path.ts +200 -23
- package/dist/client/use-pinch-zoom.d.ts.map +1 -1
- package/dist/client/use-pinch-zoom.js +76 -11
- package/dist/client/use-pinch-zoom.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/presence.d.ts.map +1 -1
- package/dist/collab/presence.js +54 -3
- package/dist/collab/presence.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
IconSearch,
|
|
13
13
|
} from "@tabler/icons-react";
|
|
14
14
|
import {
|
|
15
|
+
memo,
|
|
15
16
|
useCallback,
|
|
16
17
|
useEffect,
|
|
17
18
|
useLayoutEffect,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
type DragEvent,
|
|
23
24
|
type MouseEvent,
|
|
24
25
|
type ReactNode,
|
|
26
|
+
type RefObject,
|
|
25
27
|
} from "react";
|
|
26
28
|
|
|
27
29
|
import { Button } from "@/components/ui/button";
|
|
@@ -40,9 +42,6 @@ import {
|
|
|
40
42
|
} from "@/components/ui/tooltip";
|
|
41
43
|
import { cn } from "@/lib/utils";
|
|
42
44
|
|
|
43
|
-
const LAYER_ROW_BASE_INDENT = 4;
|
|
44
|
-
const LAYER_ROW_DEPTH_INDENT = 18;
|
|
45
|
-
|
|
46
45
|
export type LayersPanelNodeType =
|
|
47
46
|
| "file"
|
|
48
47
|
| "screen"
|
|
@@ -215,10 +214,13 @@ let activeDropIntent: LayersPanelMoveIntent | null = null;
|
|
|
215
214
|
|
|
216
215
|
const ROW_BASE_INDENT = 4;
|
|
217
216
|
const ROW_INDENT_STEP = 28;
|
|
218
|
-
const ROW_MAX_INDENT = 96;
|
|
219
217
|
|
|
218
|
+
// No indent cap: deeply nested trees (Figma-style component instances easily
|
|
219
|
+
// exceed 4 levels) must stay visually distinguishable by depth. A previous
|
|
220
|
+
// 96px cap made every row at depth >= 4 render at the same indent, making
|
|
221
|
+
// nested structure ambiguous in the panel.
|
|
220
222
|
function rowIndent(depth: number): number {
|
|
221
|
-
return
|
|
223
|
+
return ROW_BASE_INDENT + depth * ROW_INDENT_STEP;
|
|
222
224
|
}
|
|
223
225
|
|
|
224
226
|
function defaultLabels(t: ReturnType<typeof useT>): LayersPanelLabels {
|
|
@@ -348,7 +350,24 @@ function filterNode(
|
|
|
348
350
|
return null;
|
|
349
351
|
}
|
|
350
352
|
|
|
351
|
-
|
|
353
|
+
// ORDER CONVENTION (L5): the panel's top row within a sibling group is the
|
|
354
|
+
// topmost-RENDERED layer, matching Figma. LayersPanelNode.children arrives in
|
|
355
|
+
// DOM order (first array element = first DOM child = bottom of the paint
|
|
356
|
+
// stack for overlapping siblings; last DOM child = topmost paint). So the
|
|
357
|
+
// panel must display each sibling group in REVERSE DOM order. This is the
|
|
358
|
+
// single place that convention is applied — everything else (drop-placement
|
|
359
|
+
// mapping in dropPlacementForEvent/handleDrop callers, and the CL:2769
|
|
360
|
+
// moveNode "inside" insertion point) is written to agree with it:
|
|
361
|
+
// - drop "above row X" (before X in the reversed panel list) => DOM order
|
|
362
|
+
// "after" X (closer to the paint-top), i.e. inserted after X in the DOM.
|
|
363
|
+
// - drop "below row X" (after X in the reversed panel list) => DOM order
|
|
364
|
+
// "before" X, i.e. inserted before X in the DOM.
|
|
365
|
+
// - "inside" a container drops at the END of the panel's child list, i.e.
|
|
366
|
+
// the FIRST DOM child position (contentStart), so the dropped node
|
|
367
|
+
// becomes the bottom-most-painted / top-of-panel-list child. See
|
|
368
|
+
// mapPanelPlacementToDomPlacement below and its use at the LP/DE drop
|
|
369
|
+
// boundary.
|
|
370
|
+
export function flattenRows(
|
|
352
371
|
nodes: LayersPanelNode[],
|
|
353
372
|
expandedIds: ReadonlySet<string>,
|
|
354
373
|
forceExpanded: boolean,
|
|
@@ -357,7 +376,8 @@ function flattenRows(
|
|
|
357
376
|
ancestorIds: string[] = [],
|
|
358
377
|
rows: FlatLayerRow[] = [],
|
|
359
378
|
) {
|
|
360
|
-
nodes.
|
|
379
|
+
const displayOrder = [...nodes].reverse();
|
|
380
|
+
displayOrder.forEach((node, index) => {
|
|
361
381
|
const children = node.children ?? [];
|
|
362
382
|
const hasChildren = children.length > 0;
|
|
363
383
|
const canAcceptChildren = CONTAINER_TYPES.has(node.type);
|
|
@@ -385,6 +405,33 @@ function flattenRows(
|
|
|
385
405
|
return rows;
|
|
386
406
|
}
|
|
387
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Maps a panel-order drop placement (computed from where the user dropped
|
|
410
|
+
* relative to a row's position in the reversed, top-row-is-topmost panel
|
|
411
|
+
* list) to the DOM-order placement the underlying moveNode/applyMoveNodeEdit
|
|
412
|
+
* primitive expects (see CL applyMoveNodeEdit: "before" = anchor.start,
|
|
413
|
+
* "after" = anchor.end, "inside" = anchor.contentEnd i.e. last DOM child).
|
|
414
|
+
*
|
|
415
|
+
* Because the panel displays each sibling group in reverse DOM order:
|
|
416
|
+
* - "before" in the panel (drop above row X, i.e. towards the top/topmost)
|
|
417
|
+
* means the moved node should render ABOVE X, i.e. paint AFTER X in the
|
|
418
|
+
* DOM => DOM placement "after".
|
|
419
|
+
* - "after" in the panel (drop below row X, towards the bottom/backmost)
|
|
420
|
+
* means the moved node should render BELOW X, i.e. paint BEFORE X in the
|
|
421
|
+
* DOM => DOM placement "before".
|
|
422
|
+
* - "inside" is unchanged in kind, but the DOM primitive already inserts at
|
|
423
|
+
* contentEnd (last DOM child), which is exactly the panel's "top of this
|
|
424
|
+
* group's list" — i.e. inside-drops naturally land at the top of the
|
|
425
|
+
* panel's child list with no further mapping needed.
|
|
426
|
+
*/
|
|
427
|
+
export function mapPanelPlacementToDomPlacement(
|
|
428
|
+
placement: LayersPanelMoveIntent["placement"],
|
|
429
|
+
): LayersPanelMoveIntent["placement"] {
|
|
430
|
+
if (placement === "before") return "after";
|
|
431
|
+
if (placement === "after") return "before";
|
|
432
|
+
return "inside";
|
|
433
|
+
}
|
|
434
|
+
|
|
388
435
|
function nextExpandedIds(
|
|
389
436
|
ids: readonly string[],
|
|
390
437
|
nodeId: string,
|
|
@@ -399,6 +446,34 @@ function nextExpandedIds(
|
|
|
399
446
|
return Array.from(next);
|
|
400
447
|
}
|
|
401
448
|
|
|
449
|
+
/**
|
|
450
|
+
* L1: pure computation for the auto-expand-ancestors-of-selection effect.
|
|
451
|
+
* Given the current selection's ancestor ids and the CURRENT expanded set,
|
|
452
|
+
* returns the next expanded id list with any missing ancestors added, or
|
|
453
|
+
* null if nothing needs to change. Extracted as a pure function (mirroring
|
|
454
|
+
* shouldResyncLayerSelectionAnchor) so the auto-expand decision is testable
|
|
455
|
+
* without mounting the component. The caller is responsible for only
|
|
456
|
+
* invoking this once per NEW selection signature — see the
|
|
457
|
+
* lastAutoExpandedSelectionRef gate in the effect below, which is what
|
|
458
|
+
* actually fixes the collapse-bounces-back-instantly bug (this function
|
|
459
|
+
* itself is a straightforward set-union and isn't where that bug lived).
|
|
460
|
+
*/
|
|
461
|
+
export function nextAutoExpandedIds(args: {
|
|
462
|
+
selectedAncestorIds: readonly string[];
|
|
463
|
+
expandedIds: readonly string[];
|
|
464
|
+
}): string[] | null {
|
|
465
|
+
if (args.selectedAncestorIds.length === 0) return null;
|
|
466
|
+
const next = new Set(args.expandedIds);
|
|
467
|
+
let changed = false;
|
|
468
|
+
args.selectedAncestorIds.forEach((id) => {
|
|
469
|
+
if (!next.has(id)) {
|
|
470
|
+
next.add(id);
|
|
471
|
+
changed = true;
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
return changed ? Array.from(next) : null;
|
|
475
|
+
}
|
|
476
|
+
|
|
402
477
|
function collectAncestorIds(
|
|
403
478
|
nodes: LayersPanelNode[],
|
|
404
479
|
targetIds: ReadonlySet<string>,
|
|
@@ -424,6 +499,26 @@ function collectAncestorIds(
|
|
|
424
499
|
return Array.from(ancestors);
|
|
425
500
|
}
|
|
426
501
|
|
|
502
|
+
// Full-tree ancestor map (id -> ancestor id chain from root), independent of
|
|
503
|
+
// expand/collapse or search-filter state. Used at drag start to correctly
|
|
504
|
+
// identify selected descendants even when their row is currently not
|
|
505
|
+
// rendered in visibleRows (e.g. inside a collapsed ancestor) — see
|
|
506
|
+
// getDraggedLayerIdsForRows below.
|
|
507
|
+
export function buildAncestorIdMap(
|
|
508
|
+
nodes: LayersPanelNode[],
|
|
509
|
+
): Map<string, string[]> {
|
|
510
|
+
const map = new Map<string, string[]>();
|
|
511
|
+
|
|
512
|
+
function visit(node: LayersPanelNode, ancestorIds: string[]) {
|
|
513
|
+
map.set(node.id, ancestorIds);
|
|
514
|
+
const children = node.children ?? [];
|
|
515
|
+
children.forEach((child) => visit(child, [...ancestorIds, node.id]));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
nodes.forEach((node) => visit(node, []));
|
|
519
|
+
return map;
|
|
520
|
+
}
|
|
521
|
+
|
|
427
522
|
export function getLayerSelectionAnchorFromExternalSelection(args: {
|
|
428
523
|
selectedIds: readonly string[];
|
|
429
524
|
selectableVisibleIds: readonly string[];
|
|
@@ -451,20 +546,48 @@ export function getTreeOrderedLayerIds(
|
|
|
451
546
|
];
|
|
452
547
|
}
|
|
453
548
|
|
|
549
|
+
// Shift-range selection is a straight slice through the flattened visible
|
|
550
|
+
// rows, so when the range spans an expanded parent AND some of its children,
|
|
551
|
+
// both end up selected. Figma normalizes this away: selecting an ancestor
|
|
552
|
+
// already implies its descendants for move/visual purposes, so a descendant
|
|
553
|
+
// whose ancestor is also in the resulting set should be dropped from the
|
|
554
|
+
// selection (the ancestor "wins"). Order-preserving.
|
|
555
|
+
export function dropDescendantsOfSelectedAncestors(
|
|
556
|
+
ids: readonly string[],
|
|
557
|
+
visibleRows: readonly FlatLayerRow[],
|
|
558
|
+
): string[] {
|
|
559
|
+
const idSet = new Set(ids);
|
|
560
|
+
const ancestorIdsById = new Map(
|
|
561
|
+
visibleRows.map((row) => [row.node.id, row.ancestorIds] as const),
|
|
562
|
+
);
|
|
563
|
+
return ids.filter((id) => {
|
|
564
|
+
const ancestorIds = ancestorIdsById.get(id);
|
|
565
|
+
return !ancestorIds?.some((ancestorId) => idSet.has(ancestorId));
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
454
569
|
export function getDraggedLayerIdsForRows(args: {
|
|
455
570
|
selectedIds: readonly string[];
|
|
456
571
|
nodeId: string;
|
|
457
572
|
visibleRows: readonly FlatLayerRow[];
|
|
573
|
+
// Full-tree ancestor map (see buildAncestorIdMap), independent of
|
|
574
|
+
// expand/collapse or search-filter state. Required to correctly drop
|
|
575
|
+
// selected descendants whose row is currently not in visibleRows (e.g.
|
|
576
|
+
// inside a collapsed dragged parent) — falling back to visibleRows-only
|
|
577
|
+
// ancestor lookup would treat those as separate top-level drags and
|
|
578
|
+
// extract them from the parent being dragged. Optional only for
|
|
579
|
+
// call-site/back-compat convenience; always pass it in the real panel.
|
|
580
|
+
ancestorIdMap?: ReadonlyMap<string, string[]>;
|
|
458
581
|
}): string[] {
|
|
459
582
|
const rawDraggedIds = args.selectedIds.includes(args.nodeId)
|
|
460
583
|
? getTreeOrderedLayerIds(args.selectedIds, args.visibleRows)
|
|
461
584
|
: [args.nodeId];
|
|
462
585
|
const draggedIdSet = new Set(rawDraggedIds);
|
|
463
586
|
return rawDraggedIds.filter((id) => {
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
);
|
|
587
|
+
const ancestorIds =
|
|
588
|
+
args.ancestorIdMap?.get(id) ??
|
|
589
|
+
args.visibleRows.find((row) => row.node.id === id)?.ancestorIds;
|
|
590
|
+
return !ancestorIds?.some((ancestorId) => draggedIdSet.has(ancestorId));
|
|
468
591
|
});
|
|
469
592
|
}
|
|
470
593
|
|
|
@@ -491,7 +614,15 @@ function layerCanShowBadge(node: LayersPanelNode) {
|
|
|
491
614
|
);
|
|
492
615
|
}
|
|
493
616
|
|
|
494
|
-
|
|
617
|
+
// PF8: DesignEditor re-renders on many state changes unrelated to the layers
|
|
618
|
+
// tree (drag gestures, zoom, canvas hover, etc). All of LayersPanel's call-site
|
|
619
|
+
// props are already stabilized (useMemo/useCallback/plain state — see
|
|
620
|
+
// DesignEditor.tsx's layerPanelFiles/overviewLayerPanelFiles/
|
|
621
|
+
// activeLayerPanelNodes/boardElements and the onXxx handlers passed to
|
|
622
|
+
// <LayersPanel>), so a default shallow-prop comparator is sufficient here;
|
|
623
|
+
// no custom comparator is needed or wanted since it would risk silently
|
|
624
|
+
// ignoring a genuinely-changed prop.
|
|
625
|
+
export const LayersPanel = memo(function LayersPanel({
|
|
495
626
|
screens,
|
|
496
627
|
activeScreenId,
|
|
497
628
|
screenOverviewActive = false,
|
|
@@ -524,11 +655,35 @@ export function LayersPanel({
|
|
|
524
655
|
const labels = useMemo(() => mergeLabels(labelsProp, t), [labelsProp, t]);
|
|
525
656
|
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
526
657
|
const selectedIdsRef = useRef<readonly string[]>(selectedIds);
|
|
658
|
+
// Rows need the full visible-row order for keyboard navigation and
|
|
659
|
+
// multi-drag payload ordering, but that's whole-tree state, not a per-row
|
|
660
|
+
// primitive. Route it through a stable ref instead of a prop so passing it
|
|
661
|
+
// to LayerRow doesn't defeat React.memo (the ref object identity never
|
|
662
|
+
// changes; only .current does).
|
|
663
|
+
const visibleRowsRef = useRef<FlatLayerRow[]>([]);
|
|
664
|
+
// Full (unfiltered-by-expand/collapse) root nodes, threaded the same way so
|
|
665
|
+
// drag start can build a full-tree ancestor map (see buildAncestorIdMap)
|
|
666
|
+
// without adding a per-row array prop that would defeat React.memo.
|
|
667
|
+
const rootsRef = useRef<LayersPanelNode[]>([]);
|
|
668
|
+
// Same idea for expandedIds: onToggleExpanded needs the current expanded
|
|
669
|
+
// set to compute the next one, but reading it from a ref lets the
|
|
670
|
+
// per-row callback stay referentially stable across renders.
|
|
671
|
+
const expandedIdsRef = useRef<readonly string[]>(expandedIds);
|
|
672
|
+
expandedIdsRef.current = expandedIds;
|
|
527
673
|
const lastPanelSelectionSignatureRef = useRef(selectedIds.join("\0"));
|
|
528
674
|
const expandedIdSet = useMemo(() => new Set(expandedIds), [expandedIds]);
|
|
529
675
|
const lastSelectionAnchorRef = useRef<string | null>(selectedIds[0] ?? null);
|
|
530
676
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
531
677
|
const rowElementRefs = useRef(new Map<string, HTMLDivElement>());
|
|
678
|
+
// L20: edge auto-scroll during a row drag. scrollContainerRef is the
|
|
679
|
+
// scrollable rows list; autoScrollFrameRef holds the active rAF handle (or
|
|
680
|
+
// null when idle); autoScrollDirectionRef holds the current scroll
|
|
681
|
+
// direction/speed so the rAF loop keeps scrolling smoothly across frames
|
|
682
|
+
// without needing dragover to fire every frame (dragover cadence is
|
|
683
|
+
// browser-throttled and not reliable enough on its own for smooth scroll).
|
|
684
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
685
|
+
const autoScrollFrameRef = useRef<number | null>(null);
|
|
686
|
+
const autoScrollSpeedRef = useRef(0);
|
|
532
687
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
533
688
|
const [renameDraft, setRenameDraft] = useState("");
|
|
534
689
|
const renameOriginalNameRef = useRef<string>("");
|
|
@@ -570,6 +725,13 @@ export function LayersPanel({
|
|
|
570
725
|
[visibleRows],
|
|
571
726
|
);
|
|
572
727
|
|
|
728
|
+
// Keep the row-facing refs current every render. This runs during render
|
|
729
|
+
// (not an effect) so event handlers created during this same commit already
|
|
730
|
+
// see the latest arrays; it never triggers a re-render itself since only
|
|
731
|
+
// `.current` is written.
|
|
732
|
+
visibleRowsRef.current = visibleRows;
|
|
733
|
+
rootsRef.current = roots;
|
|
734
|
+
|
|
573
735
|
useLayoutEffect(() => {
|
|
574
736
|
selectedIdsRef.current = selectedIds;
|
|
575
737
|
const signature = selectedIds.join("\0");
|
|
@@ -591,18 +753,28 @@ export function LayersPanel({
|
|
|
591
753
|
});
|
|
592
754
|
}, [selectableVisibleIds, selectedIds]);
|
|
593
755
|
|
|
756
|
+
// Auto-expand ancestors of the current selection. This must run only when
|
|
757
|
+
// the SELECTION changes (a new selection signature), not whenever
|
|
758
|
+
// expandedIds changes — otherwise collapsing an ancestor of the selected
|
|
759
|
+
// layer (which changes expandedIds but not the selection) would
|
|
760
|
+
// immediately re-expand it, since selectedAncestorIds still contains it.
|
|
761
|
+
// Track the selection signature we last auto-expanded for in a ref so the
|
|
762
|
+
// effect can bail out on every render triggered purely by a collapse.
|
|
763
|
+
const lastAutoExpandedSelectionRef = useRef<string | null>(null);
|
|
594
764
|
useEffect(() => {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
changed = true;
|
|
602
|
-
}
|
|
765
|
+
const signature = selectedIds.join("\0");
|
|
766
|
+
if (lastAutoExpandedSelectionRef.current === signature) return;
|
|
767
|
+
lastAutoExpandedSelectionRef.current = signature;
|
|
768
|
+
const nextExpanded = nextAutoExpandedIds({
|
|
769
|
+
selectedAncestorIds,
|
|
770
|
+
expandedIds: expandedIdsRef.current,
|
|
603
771
|
});
|
|
604
|
-
if (
|
|
605
|
-
|
|
772
|
+
if (nextExpanded) onExpandedIdsChange(nextExpanded);
|
|
773
|
+
// Intentionally NOT depending on expandedIds: this effect must only react
|
|
774
|
+
// to selection changes (selectedIds / selectedAncestorIds), and reads the
|
|
775
|
+
// current expanded set from expandedIdsRef so a collapse doesn't retrigger it.
|
|
776
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
777
|
+
}, [onExpandedIdsChange, selectedAncestorIds, selectedIds]);
|
|
606
778
|
|
|
607
779
|
const selectedScrollId = selectedIds[selectedIds.length - 1] ?? null;
|
|
608
780
|
const selectedScrollRowKey = useMemo(() => {
|
|
@@ -656,9 +828,17 @@ export function LayersPanel({
|
|
|
656
828
|
if (from >= 0 && to >= 0) {
|
|
657
829
|
const [start, end] = from < to ? [from, to] : [to, from];
|
|
658
830
|
const rangeIds = selectableVisibleIds.slice(start, end + 1);
|
|
659
|
-
|
|
831
|
+
const merged = options.additive
|
|
660
832
|
? Array.from(new Set([...currentSelectedIds, ...rangeIds]))
|
|
661
833
|
: rangeIds;
|
|
834
|
+
// A range that spans an expanded parent and some of its children
|
|
835
|
+
// would otherwise co-select both. Normalize so a selected
|
|
836
|
+
// descendant whose ancestor is also selected gets dropped — the
|
|
837
|
+
// ancestor selection already implies it.
|
|
838
|
+
nextIds = dropDescendantsOfSelectedAncestors(
|
|
839
|
+
merged,
|
|
840
|
+
visibleRowsRef.current,
|
|
841
|
+
);
|
|
662
842
|
} else {
|
|
663
843
|
nextIds = [id];
|
|
664
844
|
}
|
|
@@ -718,6 +898,24 @@ export function LayersPanel({
|
|
|
718
898
|
[],
|
|
719
899
|
);
|
|
720
900
|
|
|
901
|
+
// Id-first, stable callbacks for LayerRow. Each reads current
|
|
902
|
+
// expandedIds/onExpandedIdsChange/onRename from refs/closure-captured
|
|
903
|
+
// props at call time rather than recreating a fresh per-row closure every
|
|
904
|
+
// render — this keeps LayerRow's props referentially stable so
|
|
905
|
+
// React.memo(LayerRow) actually skips re-renders.
|
|
906
|
+
const handleToggleExpanded = useCallback(
|
|
907
|
+
(id: string, expanded: boolean) => {
|
|
908
|
+
onExpandedIdsChange(
|
|
909
|
+
nextExpandedIds(expandedIdsRef.current, id, expanded),
|
|
910
|
+
);
|
|
911
|
+
},
|
|
912
|
+
[onExpandedIdsChange],
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
const handleCancelRename = useCallback(() => {
|
|
916
|
+
setRenamingId(null);
|
|
917
|
+
}, []);
|
|
918
|
+
|
|
721
919
|
const hasAnyRows = roots.length > 0;
|
|
722
920
|
const screenRows = screens ?? files ?? [];
|
|
723
921
|
const openSearch = useCallback(() => {
|
|
@@ -745,6 +943,65 @@ export function LayersPanel({
|
|
|
745
943
|
);
|
|
746
944
|
}, [collapseTargetId, expandedIds, onExpandedIdsChange]);
|
|
747
945
|
|
|
946
|
+
// L20: auto-scroll the rows list while dragging near the top/bottom edge.
|
|
947
|
+
// Runs a rAF loop so the scroll speed stays smooth and independent of the
|
|
948
|
+
// browser's dragover event cadence.
|
|
949
|
+
const AUTO_SCROLL_EDGE_PX = 40;
|
|
950
|
+
const AUTO_SCROLL_MAX_SPEED_PX = 14;
|
|
951
|
+
|
|
952
|
+
const stopAutoScroll = useCallback(() => {
|
|
953
|
+
if (autoScrollFrameRef.current !== null) {
|
|
954
|
+
window.cancelAnimationFrame(autoScrollFrameRef.current);
|
|
955
|
+
autoScrollFrameRef.current = null;
|
|
956
|
+
}
|
|
957
|
+
autoScrollSpeedRef.current = 0;
|
|
958
|
+
}, []);
|
|
959
|
+
|
|
960
|
+
const runAutoScrollFrame = useCallback(() => {
|
|
961
|
+
const container = scrollContainerRef.current;
|
|
962
|
+
const speed = autoScrollSpeedRef.current;
|
|
963
|
+
if (!container || speed === 0) {
|
|
964
|
+
autoScrollFrameRef.current = null;
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
container.scrollTop += speed;
|
|
968
|
+
autoScrollFrameRef.current =
|
|
969
|
+
window.requestAnimationFrame(runAutoScrollFrame);
|
|
970
|
+
}, []);
|
|
971
|
+
|
|
972
|
+
const handleRowsDragOver = useCallback(
|
|
973
|
+
(event: DragEvent<HTMLDivElement>) => {
|
|
974
|
+
if (!activeDragState) {
|
|
975
|
+
stopAutoScroll();
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const container = scrollContainerRef.current;
|
|
979
|
+
if (!container) return;
|
|
980
|
+
const rect = container.getBoundingClientRect();
|
|
981
|
+
const offsetFromTop = event.clientY - rect.top;
|
|
982
|
+
const offsetFromBottom = rect.bottom - event.clientY;
|
|
983
|
+
let speed = 0;
|
|
984
|
+
if (offsetFromTop < AUTO_SCROLL_EDGE_PX) {
|
|
985
|
+
const intensity = 1 - Math.max(0, offsetFromTop) / AUTO_SCROLL_EDGE_PX;
|
|
986
|
+
speed = -Math.ceil(intensity * AUTO_SCROLL_MAX_SPEED_PX);
|
|
987
|
+
} else if (offsetFromBottom < AUTO_SCROLL_EDGE_PX) {
|
|
988
|
+
const intensity =
|
|
989
|
+
1 - Math.max(0, offsetFromBottom) / AUTO_SCROLL_EDGE_PX;
|
|
990
|
+
speed = Math.ceil(intensity * AUTO_SCROLL_MAX_SPEED_PX);
|
|
991
|
+
}
|
|
992
|
+
autoScrollSpeedRef.current = speed;
|
|
993
|
+
if (speed !== 0 && autoScrollFrameRef.current === null) {
|
|
994
|
+
autoScrollFrameRef.current =
|
|
995
|
+
window.requestAnimationFrame(runAutoScrollFrame);
|
|
996
|
+
} else if (speed === 0) {
|
|
997
|
+
stopAutoScroll();
|
|
998
|
+
}
|
|
999
|
+
},
|
|
1000
|
+
[runAutoScrollFrame, stopAutoScroll],
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
useEffect(() => stopAutoScroll, [stopAutoScroll]);
|
|
1004
|
+
|
|
748
1005
|
return (
|
|
749
1006
|
<aside
|
|
750
1007
|
className={cn(
|
|
@@ -866,55 +1123,71 @@ export function LayersPanel({
|
|
|
866
1123
|
</div>
|
|
867
1124
|
) : null}
|
|
868
1125
|
|
|
869
|
-
<div
|
|
1126
|
+
<div
|
|
1127
|
+
ref={scrollContainerRef}
|
|
1128
|
+
className="min-h-0 flex-1 overflow-auto overscroll-contain py-2"
|
|
1129
|
+
onDragOver={handleRowsDragOver}
|
|
1130
|
+
onDrop={stopAutoScroll}
|
|
1131
|
+
onDragEnd={stopAutoScroll}
|
|
1132
|
+
>
|
|
870
1133
|
{visibleRows.length ? (
|
|
871
1134
|
<div
|
|
872
1135
|
className="w-max min-w-full px-2"
|
|
873
1136
|
role="tree"
|
|
874
1137
|
aria-label={labels.title}
|
|
875
1138
|
>
|
|
876
|
-
{visibleRows.map((row) =>
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1139
|
+
{visibleRows.map((row) => {
|
|
1140
|
+
// Per-row primitives computed here (not inside LayerRow) so the
|
|
1141
|
+
// row only receives booleans/strings it needs — no whole-tree
|
|
1142
|
+
// arrays that would force a re-render every time any other
|
|
1143
|
+
// row's selection state changes.
|
|
1144
|
+
const isSelected = selectedIdSet.has(row.node.id);
|
|
1145
|
+
const isInSelectedSubtree = row.ancestorIds.some((id) =>
|
|
1146
|
+
selectedIdSet.has(id),
|
|
1147
|
+
);
|
|
1148
|
+
const isActiveScreen =
|
|
1149
|
+
row.node.id === activeScreenId &&
|
|
1150
|
+
(row.node.type === "file" ||
|
|
1151
|
+
row.node.type === "screen" ||
|
|
1152
|
+
row.node.type === "frame");
|
|
1153
|
+
const isRenaming = renamingId === row.node.id;
|
|
1154
|
+
const activeDropPlacement =
|
|
1155
|
+
dropIndicator?.targetId === row.node.id
|
|
1156
|
+
? dropIndicator.placement
|
|
1157
|
+
: null;
|
|
1158
|
+
return (
|
|
1159
|
+
<LayerRow
|
|
1160
|
+
key={row.rowKey}
|
|
1161
|
+
row={row}
|
|
1162
|
+
labels={labels}
|
|
1163
|
+
isExpanded={expandedIdSet.has(row.node.id)}
|
|
1164
|
+
isSelected={isSelected}
|
|
1165
|
+
isInSelectedSubtree={isInSelectedSubtree}
|
|
1166
|
+
isActiveScreen={isActiveScreen}
|
|
1167
|
+
isRenaming={isRenaming}
|
|
1168
|
+
renameDraft={isRenaming ? renameDraft : ""}
|
|
1169
|
+
registerRowElement={registerRowElement}
|
|
1170
|
+
onRenameDraftChange={setRenameDraft}
|
|
1171
|
+
onCommitRename={commitRename}
|
|
1172
|
+
onCancelRename={handleCancelRename}
|
|
1173
|
+
onStartRename={startRename}
|
|
1174
|
+
onRename={onRename}
|
|
1175
|
+
onSelect={selectNode}
|
|
1176
|
+
onToggleExpanded={handleToggleExpanded}
|
|
1177
|
+
onToggleLocked={onToggleLocked}
|
|
1178
|
+
onToggleHidden={onToggleHidden}
|
|
1179
|
+
onHoverLayer={onHoverLayer}
|
|
1180
|
+
onLeaveLayer={onLeaveLayer}
|
|
1181
|
+
onMoveLayer={onMoveLayer}
|
|
1182
|
+
canMoveLayer={canMoveLayer}
|
|
1183
|
+
activeDropPlacement={activeDropPlacement}
|
|
1184
|
+
onDropIndicatorChange={setDropIndicator}
|
|
1185
|
+
selectedIdsRef={selectedIdsRef}
|
|
1186
|
+
visibleRowsRef={visibleRowsRef}
|
|
1187
|
+
rootsRef={rootsRef}
|
|
1188
|
+
/>
|
|
1189
|
+
);
|
|
1190
|
+
})}
|
|
918
1191
|
</div>
|
|
919
1192
|
) : (
|
|
920
1193
|
<div className="px-3 py-8 text-center !text-[11px] text-muted-foreground">
|
|
@@ -925,36 +1198,9 @@ export function LayersPanel({
|
|
|
925
1198
|
{footer ? <div className="shrink-0">{footer}</div> : null}
|
|
926
1199
|
</aside>
|
|
927
1200
|
);
|
|
928
|
-
}
|
|
1201
|
+
});
|
|
929
1202
|
|
|
930
|
-
|
|
931
|
-
row,
|
|
932
|
-
labels,
|
|
933
|
-
isExpanded,
|
|
934
|
-
isSelected,
|
|
935
|
-
isInSelectedSubtree,
|
|
936
|
-
isActiveScreen,
|
|
937
|
-
isRenaming,
|
|
938
|
-
rowRef,
|
|
939
|
-
renameDraft,
|
|
940
|
-
onRenameDraftChange,
|
|
941
|
-
onCommitRename,
|
|
942
|
-
onCancelRename,
|
|
943
|
-
onStartRename,
|
|
944
|
-
onRename,
|
|
945
|
-
onSelect,
|
|
946
|
-
onToggleExpanded,
|
|
947
|
-
onToggleLocked,
|
|
948
|
-
onToggleHidden,
|
|
949
|
-
onHoverLayer,
|
|
950
|
-
onLeaveLayer,
|
|
951
|
-
onMoveLayer,
|
|
952
|
-
canMoveLayer,
|
|
953
|
-
dropIndicator,
|
|
954
|
-
onDropIndicatorChange,
|
|
955
|
-
selectedIds,
|
|
956
|
-
visibleRows,
|
|
957
|
-
}: {
|
|
1203
|
+
interface LayerRowProps {
|
|
958
1204
|
row: FlatLayerRow;
|
|
959
1205
|
labels: LayersPanelLabels;
|
|
960
1206
|
isExpanded: boolean;
|
|
@@ -962,11 +1208,11 @@ function LayerRow({
|
|
|
962
1208
|
isInSelectedSubtree: boolean;
|
|
963
1209
|
isActiveScreen: boolean;
|
|
964
1210
|
isRenaming: boolean;
|
|
965
|
-
|
|
1211
|
+
registerRowElement: (rowKey: string, element: HTMLDivElement | null) => void;
|
|
966
1212
|
renameDraft: string;
|
|
967
1213
|
onRenameDraftChange: (value: string) => void;
|
|
968
1214
|
onCommitRename: (id: string) => void;
|
|
969
|
-
onCancelRename: () => void;
|
|
1215
|
+
onCancelRename: (id: string) => void;
|
|
970
1216
|
onStartRename: (node: LayersPanelNode) => void;
|
|
971
1217
|
onRename?: (id: string, name: string) => void;
|
|
972
1218
|
onSelect: (
|
|
@@ -978,39 +1224,108 @@ function LayerRow({
|
|
|
978
1224
|
source: "keyboard" | "pointer";
|
|
979
1225
|
},
|
|
980
1226
|
) => void;
|
|
981
|
-
onToggleExpanded: (expanded: boolean) => void;
|
|
1227
|
+
onToggleExpanded: (id: string, expanded: boolean) => void;
|
|
982
1228
|
onToggleLocked?: (id: string, locked: boolean) => void;
|
|
983
1229
|
onToggleHidden?: (id: string, hidden: boolean) => void;
|
|
984
1230
|
onHoverLayer?: (id: string) => void;
|
|
985
1231
|
onLeaveLayer?: (id: string) => void;
|
|
986
1232
|
onMoveLayer?: (intent: LayersPanelMoveIntent) => void;
|
|
987
1233
|
canMoveLayer?: (intent: LayersPanelMoveIntent) => boolean;
|
|
988
|
-
|
|
1234
|
+
// Only this row's own drop-indicator placement ("before" | "after" |
|
|
1235
|
+
// "inside" | null) — not the whole dropIndicator object, so a dragover on
|
|
1236
|
+
// one row doesn't force every other row to re-render.
|
|
1237
|
+
activeDropPlacement: LayersPanelMoveIntent["placement"] | null;
|
|
989
1238
|
onDropIndicatorChange: (intent: LayersPanelMoveIntent | null) => void;
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1239
|
+
// Whole-tree state needed for keyboard nav / multi-drag ordering, threaded
|
|
1240
|
+
// through stable refs instead of arrays so it never defeats memo — see the
|
|
1241
|
+
// comment where these refs are created in LayersPanel.
|
|
1242
|
+
selectedIdsRef: RefObject<readonly string[]>;
|
|
1243
|
+
visibleRowsRef: RefObject<FlatLayerRow[]>;
|
|
1244
|
+
// Full (unfiltered) root nodes, used only at drag start to build a
|
|
1245
|
+
// full-tree ancestor map — see buildAncestorIdMap and L13 in the drag-start
|
|
1246
|
+
// handler below.
|
|
1247
|
+
rootsRef: RefObject<LayersPanelNode[]>;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const LayerRow = memo(function LayerRow({
|
|
1251
|
+
row,
|
|
1252
|
+
labels,
|
|
1253
|
+
isExpanded,
|
|
1254
|
+
isSelected,
|
|
1255
|
+
isInSelectedSubtree,
|
|
1256
|
+
isActiveScreen,
|
|
1257
|
+
isRenaming,
|
|
1258
|
+
registerRowElement,
|
|
1259
|
+
renameDraft,
|
|
1260
|
+
onRenameDraftChange,
|
|
1261
|
+
onCommitRename,
|
|
1262
|
+
onCancelRename,
|
|
1263
|
+
onStartRename,
|
|
1264
|
+
onRename,
|
|
1265
|
+
onSelect,
|
|
1266
|
+
onToggleExpanded,
|
|
1267
|
+
onToggleLocked,
|
|
1268
|
+
onToggleHidden,
|
|
1269
|
+
onHoverLayer,
|
|
1270
|
+
onLeaveLayer,
|
|
1271
|
+
onMoveLayer,
|
|
1272
|
+
canMoveLayer,
|
|
1273
|
+
activeDropPlacement,
|
|
1274
|
+
onDropIndicatorChange,
|
|
1275
|
+
selectedIdsRef,
|
|
1276
|
+
visibleRowsRef,
|
|
1277
|
+
rootsRef,
|
|
1278
|
+
}: LayerRowProps) {
|
|
993
1279
|
const { node, depth, hasChildren, canAcceptChildren } = row;
|
|
994
1280
|
const isComponentLayer = layerNodeIsComponent(node);
|
|
995
1281
|
const selectable = node.selectable !== false;
|
|
996
1282
|
const lockable = node.lockable !== false && Boolean(onToggleLocked);
|
|
997
1283
|
const hideable = node.hideable !== false && Boolean(onToggleHidden);
|
|
998
|
-
|
|
999
|
-
|
|
1284
|
+
// L8: being a valid DRAG SOURCE and being a valid DROP ANCHOR (before/
|
|
1285
|
+
// after/inside target) are different concerns and must not share one gate.
|
|
1286
|
+
// - dragSourceEligible: only "locked" should block picking this row up to
|
|
1287
|
+
// drag it — locked means "don't let me move", not "don't let me be
|
|
1288
|
+
// referenced". Hidden no longer blocks dragging a row: visibility is
|
|
1289
|
+
// orthogonal to whether the layer can be reordered.
|
|
1290
|
+
// - anchorEligible: locked/hidden rows must still be usable as before/
|
|
1291
|
+
// after/inside drop targets so the user can position new layers next to
|
|
1292
|
+
// or inside a locked/hidden one without having to unlock/show it first.
|
|
1293
|
+
// (The corresponding DE:15181 canMoveLayer gate is fixed separately by
|
|
1294
|
+
// the DesignEditor owner; this only removes LP's OWN early-return that
|
|
1295
|
+
// would otherwise block reaching canMoveLayer at all.)
|
|
1296
|
+
const dragSourceEligible = selectable && !node.locked;
|
|
1297
|
+
const anchorEligible = selectable;
|
|
1298
|
+
const draggable = dragSourceEligible && Boolean(onMoveLayer);
|
|
1000
1299
|
const canDropInside = layerCanDropInside(
|
|
1001
1300
|
node,
|
|
1002
1301
|
hasChildren,
|
|
1003
1302
|
canAcceptChildren,
|
|
1004
1303
|
);
|
|
1005
|
-
|
|
1006
|
-
|
|
1304
|
+
// L10: whether this row's bottom hover zone should resolve to "inside"
|
|
1305
|
+
// instead of "after" — see dropPlacementForEvent.
|
|
1306
|
+
const isExpandedWithChildren = hasChildren && isExpanded;
|
|
1307
|
+
const activeDrop = activeDropPlacement;
|
|
1007
1308
|
// Tracks whether the user pressed Escape to cancel rename so that the
|
|
1008
1309
|
// subsequent blur event does not commit the edit.
|
|
1009
1310
|
const renameCancelledRef = useRef(false);
|
|
1311
|
+
// L20: spring-loaded expand. Tracks the pending timer id for "hovering
|
|
1312
|
+
// this collapsed container during a drag" so a sustained hover expands it
|
|
1313
|
+
// (Figma-style) without requiring the user to drop and re-drag. Cleared on
|
|
1314
|
+
// drag-leave/drop/dragend and whenever the hover target/placement changes.
|
|
1315
|
+
const springLoadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1316
|
+
const SPRING_LOAD_DELAY_MS = 600;
|
|
1317
|
+
const clearSpringLoadTimer = () => {
|
|
1318
|
+
if (springLoadTimerRef.current !== null) {
|
|
1319
|
+
clearTimeout(springLoadTimerRef.current);
|
|
1320
|
+
springLoadTimerRef.current = null;
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
useEffect(() => clearSpringLoadTimer, []);
|
|
1010
1324
|
|
|
1011
1325
|
const readSelectedIdsFromTree = (target: HTMLElement): string[] => {
|
|
1012
1326
|
const tree = target.closest('[role="tree"]');
|
|
1013
|
-
if (!tree)
|
|
1327
|
+
if (!tree)
|
|
1328
|
+
return selectedIdsRef.current.filter((id) => !id.startsWith("__"));
|
|
1014
1329
|
return Array.from(
|
|
1015
1330
|
tree.querySelectorAll<HTMLElement>(
|
|
1016
1331
|
'[role="treeitem"][aria-selected="true"] [data-layer-row-button][data-layer-node-id]',
|
|
@@ -1061,7 +1376,7 @@ function LayerRow({
|
|
|
1061
1376
|
target.focus();
|
|
1062
1377
|
return true;
|
|
1063
1378
|
};
|
|
1064
|
-
const currentIndex =
|
|
1379
|
+
const currentIndex = visibleRowsRef.current.findIndex(
|
|
1065
1380
|
(visibleRow) => visibleRow.rowKey === row.rowKey,
|
|
1066
1381
|
);
|
|
1067
1382
|
const focusableButtons = getFocusableButtons();
|
|
@@ -1105,7 +1420,7 @@ function LayerRow({
|
|
|
1105
1420
|
}
|
|
1106
1421
|
if (event.key === "ArrowRight" && hasChildren && !isExpanded) {
|
|
1107
1422
|
event.preventDefault();
|
|
1108
|
-
onToggleExpanded(true);
|
|
1423
|
+
onToggleExpanded(node.id, true);
|
|
1109
1424
|
return;
|
|
1110
1425
|
}
|
|
1111
1426
|
if (event.key === "ArrowRight" && hasChildren && isExpanded) {
|
|
@@ -1113,7 +1428,9 @@ function LayerRow({
|
|
|
1113
1428
|
const nextButton = focusableButtons[currentFocusableIndex + 1];
|
|
1114
1429
|
const childId = nextButton?.dataset.layerNodeId;
|
|
1115
1430
|
const child = childId
|
|
1116
|
-
?
|
|
1431
|
+
? visibleRowsRef.current.find(
|
|
1432
|
+
(visibleRow) => visibleRow.node.id === childId,
|
|
1433
|
+
)
|
|
1117
1434
|
: null;
|
|
1118
1435
|
if (child?.ancestorIds.includes(node.id)) {
|
|
1119
1436
|
focusVisibleButton(currentFocusableIndex + 1);
|
|
@@ -1122,7 +1439,7 @@ function LayerRow({
|
|
|
1122
1439
|
}
|
|
1123
1440
|
if (event.key === "ArrowLeft" && hasChildren && isExpanded) {
|
|
1124
1441
|
event.preventDefault();
|
|
1125
|
-
onToggleExpanded(false);
|
|
1442
|
+
onToggleExpanded(node.id, false);
|
|
1126
1443
|
return;
|
|
1127
1444
|
}
|
|
1128
1445
|
if (event.key === "ArrowLeft" && row.ancestorIds.length > 0) {
|
|
@@ -1137,10 +1454,18 @@ function LayerRow({
|
|
|
1137
1454
|
event.preventDefault();
|
|
1138
1455
|
return;
|
|
1139
1456
|
}
|
|
1457
|
+
// Build the full-tree ancestor map once, here at drag start, rather than
|
|
1458
|
+
// on every render — this is O(tree size) so it must not run per-row per
|
|
1459
|
+
// render. It uses the FULL root tree (not visibleRows) so a selected
|
|
1460
|
+
// descendant nested inside a currently-collapsed dragged ancestor is
|
|
1461
|
+
// still correctly recognized as a descendant and excluded from the drag
|
|
1462
|
+
// payload (see L13 / buildAncestorIdMap).
|
|
1463
|
+
const ancestorIdMap = buildAncestorIdMap(rootsRef.current);
|
|
1140
1464
|
const draggedIds = getDraggedLayerIdsForRows({
|
|
1141
|
-
selectedIds,
|
|
1465
|
+
selectedIds: selectedIdsRef.current,
|
|
1142
1466
|
nodeId: node.id,
|
|
1143
|
-
visibleRows,
|
|
1467
|
+
visibleRows: visibleRowsRef.current,
|
|
1468
|
+
ancestorIdMap,
|
|
1144
1469
|
});
|
|
1145
1470
|
event.dataTransfer.effectAllowed = "move";
|
|
1146
1471
|
event.dataTransfer.setData("application/x-design-layer-id", node.id);
|
|
@@ -1153,31 +1478,81 @@ function LayerRow({
|
|
|
1153
1478
|
activeDragState = { sourceId: node.id, draggedIds };
|
|
1154
1479
|
};
|
|
1155
1480
|
|
|
1481
|
+
// A dragover that is rejected (any early-return path below) must clear a
|
|
1482
|
+
// stale indicator that a PRIOR dragover on this same row left behind — e.g.
|
|
1483
|
+
// hovering near the row edge changes the placement from "inside" to
|
|
1484
|
+
// "before", which canMoveLayer may now reject even though the previous
|
|
1485
|
+
// placement was accepted. Without this, the indicator line/ring lingers on
|
|
1486
|
+
// a row that no longer has a valid drop target.
|
|
1487
|
+
const clearStaleIndicatorForThisRow = () => {
|
|
1488
|
+
if (activeDropIntent?.targetId === node.id) activeDropIntent = null;
|
|
1489
|
+
if (activeDropPlacement !== null) onDropIndicatorChange(null);
|
|
1490
|
+
clearSpringLoadTimer();
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1156
1493
|
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
|
1157
|
-
if (!onMoveLayer || !
|
|
1494
|
+
if (!onMoveLayer || !anchorEligible) {
|
|
1495
|
+
clearStaleIndicatorForThisRow();
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1158
1498
|
// dataTransfer.getData() always returns "" during dragover per spec.
|
|
1159
1499
|
// Read from the module-level activeDragState set in handleDragStart instead.
|
|
1160
|
-
if (!activeDragState)
|
|
1500
|
+
if (!activeDragState) {
|
|
1501
|
+
clearStaleIndicatorForThisRow();
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1161
1504
|
const { sourceId, draggedIds } = activeDragState;
|
|
1162
|
-
if (sourceId === node.id)
|
|
1505
|
+
if (sourceId === node.id) {
|
|
1506
|
+
clearStaleIndicatorForThisRow();
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1163
1509
|
const cleanedIds = draggedIds.filter(
|
|
1164
1510
|
(id) => id && id !== node.id && !id.startsWith("__"),
|
|
1165
1511
|
);
|
|
1166
|
-
if (cleanedIds.length === 0)
|
|
1512
|
+
if (cleanedIds.length === 0) {
|
|
1513
|
+
clearStaleIndicatorForThisRow();
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1167
1516
|
const intent = {
|
|
1168
1517
|
draggedIds: cleanedIds,
|
|
1169
1518
|
targetId: node.id,
|
|
1170
|
-
placement: dropPlacementForEvent(
|
|
1519
|
+
placement: dropPlacementForEvent(
|
|
1520
|
+
event,
|
|
1521
|
+
canDropInside,
|
|
1522
|
+
isExpandedWithChildren,
|
|
1523
|
+
),
|
|
1171
1524
|
} satisfies LayersPanelMoveIntent;
|
|
1172
|
-
if (canMoveLayer && !canMoveLayer(intent))
|
|
1525
|
+
if (canMoveLayer && !canMoveLayer(intent)) {
|
|
1526
|
+
clearStaleIndicatorForThisRow();
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1173
1529
|
event.preventDefault();
|
|
1174
1530
|
event.dataTransfer.dropEffect = "move";
|
|
1175
1531
|
activeDropIntent = intent;
|
|
1176
1532
|
onDropIndicatorChange(intent);
|
|
1533
|
+
|
|
1534
|
+
// L20 spring-loaded expand: sustained "inside" hover over a collapsed
|
|
1535
|
+
// container expands it after a delay so the user can drop into nested
|
|
1536
|
+
// children without a separate drop-then-redrag step. Only arm the timer
|
|
1537
|
+
// once per qualifying hover — if a timer is already pending for this row
|
|
1538
|
+
// we leave it running rather than resetting it on every dragover tick.
|
|
1539
|
+
if (
|
|
1540
|
+
intent.placement === "inside" &&
|
|
1541
|
+
hasChildren &&
|
|
1542
|
+
!isExpanded &&
|
|
1543
|
+
springLoadTimerRef.current === null
|
|
1544
|
+
) {
|
|
1545
|
+
springLoadTimerRef.current = setTimeout(() => {
|
|
1546
|
+
springLoadTimerRef.current = null;
|
|
1547
|
+
onToggleExpanded(node.id, true);
|
|
1548
|
+
}, SPRING_LOAD_DELAY_MS);
|
|
1549
|
+
} else if (intent.placement !== "inside" || !hasChildren || isExpanded) {
|
|
1550
|
+
clearSpringLoadTimer();
|
|
1551
|
+
}
|
|
1177
1552
|
};
|
|
1178
1553
|
|
|
1179
1554
|
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
|
|
1180
|
-
if (!onMoveLayer || !
|
|
1555
|
+
if (!onMoveLayer || !anchorEligible) {
|
|
1181
1556
|
onDropIndicatorChange(null);
|
|
1182
1557
|
return;
|
|
1183
1558
|
}
|
|
@@ -1216,7 +1591,11 @@ function LayerRow({
|
|
|
1216
1591
|
: ({
|
|
1217
1592
|
draggedIds: cleanedIds,
|
|
1218
1593
|
targetId: node.id,
|
|
1219
|
-
placement: dropPlacementForEvent(
|
|
1594
|
+
placement: dropPlacementForEvent(
|
|
1595
|
+
event,
|
|
1596
|
+
canDropInside,
|
|
1597
|
+
isExpandedWithChildren,
|
|
1598
|
+
),
|
|
1220
1599
|
} satisfies LayersPanelMoveIntent);
|
|
1221
1600
|
if (!canMoveLayer || canMoveLayer(intent)) {
|
|
1222
1601
|
onMoveLayer(intent);
|
|
@@ -1224,6 +1603,7 @@ function LayerRow({
|
|
|
1224
1603
|
}
|
|
1225
1604
|
activeDropIntent = null;
|
|
1226
1605
|
onDropIndicatorChange(null);
|
|
1606
|
+
clearSpringLoadTimer();
|
|
1227
1607
|
};
|
|
1228
1608
|
|
|
1229
1609
|
const showContextMenu =
|
|
@@ -1234,7 +1614,7 @@ function LayerRow({
|
|
|
1234
1614
|
<ContextMenu>
|
|
1235
1615
|
<ContextMenuTrigger asChild disabled={!showContextMenu}>
|
|
1236
1616
|
<div
|
|
1237
|
-
ref={
|
|
1617
|
+
ref={(element) => registerRowElement(row.rowKey, element)}
|
|
1238
1618
|
role="treeitem"
|
|
1239
1619
|
aria-expanded={hasChildren ? isExpanded : undefined}
|
|
1240
1620
|
aria-level={depth + 1}
|
|
@@ -1253,12 +1633,14 @@ function LayerRow({
|
|
|
1253
1633
|
return;
|
|
1254
1634
|
if (activeDropIntent?.targetId === node.id) activeDropIntent = null;
|
|
1255
1635
|
onDropIndicatorChange(null);
|
|
1636
|
+
clearSpringLoadTimer();
|
|
1256
1637
|
}}
|
|
1257
1638
|
onDrop={handleDrop}
|
|
1258
1639
|
onDragEnd={() => {
|
|
1259
1640
|
activeDragState = null;
|
|
1260
1641
|
activeDropIntent = null;
|
|
1261
1642
|
onDropIndicatorChange(null);
|
|
1643
|
+
clearSpringLoadTimer();
|
|
1262
1644
|
}}
|
|
1263
1645
|
onMouseEnter={() => onHoverLayer?.(node.id)}
|
|
1264
1646
|
onMouseLeave={() => onLeaveLayer?.(node.id)}
|
|
@@ -1303,7 +1685,7 @@ function LayerRow({
|
|
|
1303
1685
|
aria-label={isExpanded ? labels.collapse : labels.expand}
|
|
1304
1686
|
onClick={(event) => {
|
|
1305
1687
|
event.stopPropagation();
|
|
1306
|
-
onToggleExpanded(!isExpanded);
|
|
1688
|
+
onToggleExpanded(node.id, !isExpanded);
|
|
1307
1689
|
}}
|
|
1308
1690
|
>
|
|
1309
1691
|
{isExpanded ? (
|
|
@@ -1345,6 +1727,7 @@ function LayerRow({
|
|
|
1345
1727
|
value={renameDraft}
|
|
1346
1728
|
onClick={(event) => event.stopPropagation()}
|
|
1347
1729
|
onChange={(event) => onRenameDraftChange(event.target.value)}
|
|
1730
|
+
onFocus={(event) => event.currentTarget.select()}
|
|
1348
1731
|
onBlur={() => {
|
|
1349
1732
|
// Escape sets renameCancelledRef before blur fires; skip commit.
|
|
1350
1733
|
if (renameCancelledRef.current) {
|
|
@@ -1373,7 +1756,7 @@ function LayerRow({
|
|
|
1373
1756
|
event.preventDefault();
|
|
1374
1757
|
event.stopPropagation();
|
|
1375
1758
|
renameCancelledRef.current = true;
|
|
1376
|
-
onCancelRename();
|
|
1759
|
+
onCancelRename(node.id);
|
|
1377
1760
|
}
|
|
1378
1761
|
}}
|
|
1379
1762
|
className="h-6 min-w-0 flex-1 rounded-[4px] border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-panel-bg)] px-1.5 text-[12px] text-foreground outline-none"
|
|
@@ -1510,7 +1893,7 @@ function LayerRow({
|
|
|
1510
1893
|
) : null}
|
|
1511
1894
|
</ContextMenu>
|
|
1512
1895
|
);
|
|
1513
|
-
}
|
|
1896
|
+
});
|
|
1514
1897
|
|
|
1515
1898
|
function IconTooltipButton({
|
|
1516
1899
|
label,
|
|
@@ -2019,13 +2402,29 @@ function layerCanDropInside(
|
|
|
2019
2402
|
);
|
|
2020
2403
|
}
|
|
2021
2404
|
|
|
2022
|
-
|
|
2405
|
+
/**
|
|
2406
|
+
* L10: for an EXPANDED container that already has children, the bottom hover
|
|
2407
|
+
* zone sits (visually, in the panel) directly above that container's
|
|
2408
|
+
* first-listed child row — not above its next sibling. A plain "after"
|
|
2409
|
+
* placement there would target the position following the container's
|
|
2410
|
+
* ENTIRE subtree (anchor.end in applyMoveNodeEdit), which visually
|
|
2411
|
+
* contradicts the indicator's position between the container and its first
|
|
2412
|
+
* child. Resolve that zone to "inside" instead (still targeting the
|
|
2413
|
+
* container), which inserts at contentEnd — under the L5 reversed-order
|
|
2414
|
+
* convention that is exactly the container's first-panel-row / topmost-paint
|
|
2415
|
+
* child slot, matching what the indicator visually promises.
|
|
2416
|
+
*/
|
|
2417
|
+
export function dropPlacementForEvent(
|
|
2023
2418
|
event: DragEvent<HTMLDivElement>,
|
|
2024
2419
|
canDropInside: boolean,
|
|
2420
|
+
isExpandedWithChildren = false,
|
|
2025
2421
|
): LayersPanelMoveIntent["placement"] {
|
|
2026
2422
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
2027
2423
|
const offset = event.clientY - rect.top;
|
|
2028
2424
|
if (offset < rect.height * 0.3) return "before";
|
|
2029
|
-
if (offset > rect.height * 0.7)
|
|
2425
|
+
if (offset > rect.height * 0.7) {
|
|
2426
|
+
if (canDropInside && isExpandedWithChildren) return "inside";
|
|
2427
|
+
return "after";
|
|
2428
|
+
}
|
|
2030
2429
|
return canDropInside ? "inside" : "after";
|
|
2031
2430
|
}
|