@agent-native/core 0.85.5 → 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.
Files changed (86) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +1 -1
  5. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  6. package/corpus/core/src/collab/presence.ts +63 -3
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  8. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  9. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  10. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  11. package/corpus/templates/design/AGENTS.md +40 -4
  12. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  14. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  15. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  16. package/corpus/templates/design/actions/edit-design.ts +5 -3
  17. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  18. package/corpus/templates/design/actions/export-zip.ts +10 -1
  19. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  20. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  21. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  22. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  23. package/corpus/templates/design/actions/present-design-variants.ts +12 -8
  24. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  25. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  26. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  28. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  29. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  30. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
  32. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  33. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  34. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  35. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  36. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  37. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  38. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  39. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  40. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  41. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  42. package/corpus/templates/design/app/components/design/types.ts +16 -0
  43. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  44. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  45. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  46. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  47. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  48. package/corpus/templates/design/app/i18n-data.ts +18 -0
  49. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  50. package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
  51. package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  56. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  57. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  58. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  59. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  60. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  61. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  62. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  63. package/corpus/templates/design/shared/board-file.ts +25 -5
  64. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  65. package/corpus/templates/design/shared/code-layer.ts +304 -26
  66. package/corpus/templates/design/shared/color-utils.ts +84 -0
  67. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  68. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  69. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  70. package/corpus/templates/design/shared/pen-path.ts +200 -23
  71. package/dist/client/AssistantChat.js +1 -1
  72. package/dist/client/AssistantChat.js.map +1 -1
  73. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  74. package/dist/client/use-pinch-zoom.js +76 -11
  75. package/dist/client/use-pinch-zoom.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/presence.d.ts.map +1 -1
  79. package/dist/collab/presence.js +54 -3
  80. package/dist/collab/presence.js.map +1 -1
  81. package/dist/collab/routes.d.ts +1 -1
  82. package/dist/collab/struct-routes.d.ts +1 -1
  83. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  84. package/dist/observability/routes.d.ts +2 -2
  85. package/dist/secrets/routes.d.ts +3 -3
  86. package/package.json +1 -1
@@ -19,6 +19,10 @@ export interface FrameGeometry {
19
19
  y: number;
20
20
  width: number;
21
21
  height: number;
22
+ /** Rotation in degrees, matching a CSS `rotate(deg)` applied around the
23
+ * frame's own center. Optional — most geometry helpers here ignore it
24
+ * unless documented otherwise (see the rotation-aware helpers below). */
25
+ rotation?: number;
22
26
  }
23
27
 
24
28
  export interface FrameEntry {
@@ -61,6 +65,15 @@ export interface CanvasSnapOptions {
61
65
  bypass?: boolean;
62
66
  }
63
67
 
68
+ export interface ResizeSnapOptions extends CanvasSnapOptions {
69
+ /** When set, only the single closest-matching axis snap is applied and the
70
+ * other axis is rescaled to match, so a shift-held aspect-ratio resize
71
+ * never gets distorted by independently snapping both axes to different
72
+ * sibling edges. Pass the frame's aspect ratio (width / height) *before*
73
+ * this resize's own aspect-preserving delta was applied. */
74
+ preserveAspectRatio?: boolean;
75
+ }
76
+
64
77
  export interface ResizeFrameOptions {
65
78
  preserveAspectRatio?: boolean;
66
79
  resizeFromCenter?: boolean;
@@ -78,6 +91,13 @@ export interface DraftGeometryOptions {
78
91
  minHeight?: number;
79
92
  defaultWidth?: number;
80
93
  defaultHeight?: number;
94
+ /** Constrain to equal width/height (square/circle) while drawing — the
95
+ * larger of the two dragged dimensions wins, matching Figma's shift-drag
96
+ * behavior for rect/ellipse tools. */
97
+ square?: boolean;
98
+ /** Draw outward from `start` in both directions (start is the shape's
99
+ * center) instead of from one corner to the opposite corner. */
100
+ fromCenter?: boolean;
81
101
  }
82
102
 
83
103
  export interface AlignmentGuide {
@@ -87,6 +107,35 @@ export interface AlignmentGuide {
87
107
  end: number;
88
108
  }
89
109
 
110
+ /** One equal-spacing "gap band" — the empty space between the moving frame
111
+ * and one neighboring stationary frame, on a given axis. `crossStart`/
112
+ * `crossEnd` are the band's extent on the OTHER axis (e.g. for a
113
+ * horizontal gap, that's the vertical span the tick marks/label should
114
+ * draw across), matching how `AlignmentGuide.start`/`end` describe a
115
+ * guide line's own extent. */
116
+ export interface DistanceGuideBand {
117
+ gapStart: number;
118
+ gapEnd: number;
119
+ crossStart: number;
120
+ crossEnd: number;
121
+ }
122
+
123
+ /** A pair of equal-sized gaps around the moving frame — Figma's "smart
124
+ * spacing" indicator: dragging a frame so it's evenly spaced between two
125
+ * neighbors (or continues an existing rhythm of equally spaced siblings)
126
+ * highlights both gaps with the shared distance. */
127
+ export interface EqualGapGuide {
128
+ orientation: "vertical" | "horizontal";
129
+ /** The shared gap size, in canvas units, both bands agree on. */
130
+ gap: number;
131
+ bands: [DistanceGuideBand, DistanceGuideBand];
132
+ }
133
+
134
+ export interface EqualGapOptions {
135
+ /** How close two gaps must be (in canvas units) to count as "equal". */
136
+ toleranceCanvasPx?: number;
137
+ }
138
+
90
139
  export interface RotateFrameMetadata {
91
140
  id: string;
92
141
  geometry: FrameGeometry;
@@ -177,6 +226,22 @@ export const DEFAULT_ASSIGNED_REGION_HEIGHT = 1024;
177
226
  export const DEFAULT_ASSIGNED_REGION_GAP = 320;
178
227
  export const DEFAULT_ASSIGNED_REGION_MAX_COLUMNS = 3;
179
228
 
229
+ /**
230
+ * Zoom range for the MultiScreenCanvas overview surface (wheel/pinch zoom,
231
+ * toolbar/keyboard zoom, pixel-grid threshold). Exported so every zoom-clamp
232
+ * in MultiScreenCanvas.tsx reads from one place instead of a locally
233
+ * redeclared magic number.
234
+ *
235
+ * NOTE: DesignCanvas's own single-screen pinch-zoom currently clamps to a
236
+ * different range (10–500) — that's a separate, pre-existing surface with
237
+ * its own zoom semantics (it also supports device-frame previews at fixed
238
+ * scales) and reconciling the two ranges is intentionally left as a
239
+ * follow-up rather than done here, since DesignCanvas.tsx is out of scope
240
+ * for this fix.
241
+ */
242
+ export const DEFAULT_CANVAS_MIN_ZOOM = 2;
243
+ export const DEFAULT_CANVAS_MAX_ZOOM = 800;
244
+
180
245
  export function screenToCanvasPoint(
181
246
  point: CanvasPoint,
182
247
  camera: CanvasCamera,
@@ -285,6 +350,41 @@ export function getRotatedFrameAngle(
285
350
  };
286
351
  }
287
352
 
353
+ /**
354
+ * Rotates a group of frames together around a single shared pivot (the
355
+ * group's own center), for multi-selection rotate: each frame's own
356
+ * rotation increases by `deltaDegrees` (so it keeps spinning around its own
357
+ * center visually), AND its center orbits `groupCenter` by the same delta,
358
+ * so the whole selection rotates rigidly as one unit rather than each frame
359
+ * spinning in place where it already sits.
360
+ *
361
+ * `frames` must carry each frame's ORIGINAL (drag-start) geometry — this is
362
+ * a pure function of the origin snapshot and the total delta so far, not an
363
+ * incremental transform, matching the convention `resizeFrameGroupFromDelta`
364
+ * already uses for group resize.
365
+ */
366
+ export function rotateFrameGroupAroundCenter(
367
+ frames: FrameEntry[],
368
+ groupCenter: CanvasPoint,
369
+ deltaDegrees: number,
370
+ ): FrameEntry[] {
371
+ return frames.map((frame) => {
372
+ const bounds = getFrameBounds(frame.geometry);
373
+ const originCenter = { x: bounds.centerX, y: bounds.centerY };
374
+ const nextCenter = rotatePoint(originCenter, groupCenter, deltaDegrees);
375
+ const nextRotation = (frame.geometry.rotation ?? 0) + deltaDegrees;
376
+ return {
377
+ id: frame.id,
378
+ geometry: {
379
+ ...frame.geometry,
380
+ x: nextCenter.x - frame.geometry.width / 2,
381
+ y: nextCenter.y - frame.geometry.height / 2,
382
+ rotation: nextRotation,
383
+ },
384
+ };
385
+ });
386
+ }
387
+
288
388
  export function getFrameBounds(geometry: FrameGeometry): FrameBounds {
289
389
  const width = geometry.width;
290
390
  const height = geometry.height;
@@ -449,12 +549,52 @@ export function getDraftGeometryFromPoints(
449
549
  minHeight = 1,
450
550
  defaultWidth,
451
551
  defaultHeight,
552
+ square = false,
553
+ fromCenter = false,
452
554
  }: DraftGeometryOptions = {},
453
555
  ): FrameGeometry {
454
- const rawWidth = Math.abs(end.x - start.x);
455
- const rawHeight = Math.abs(end.y - start.y);
456
- const width = Math.max(rawWidth || defaultWidth || 0, minWidth);
457
- const height = Math.max(rawHeight || defaultHeight || 0, minHeight);
556
+ // When drawing from the center, `end` marks one edge/corner of the shape
557
+ // rather than the opposite corner from `start` — the pointer's distance
558
+ // from center is a HALF-extent, so the full width/height is double the
559
+ // raw drag distance.
560
+ const centerMultiplier = fromCenter ? 2 : 1;
561
+ let rawWidth = Math.abs(end.x - start.x) * centerMultiplier;
562
+ let rawHeight = Math.abs(end.y - start.y) * centerMultiplier;
563
+
564
+ if (square) {
565
+ // Figma's shift-drag: the larger dragged dimension wins, both axes match
566
+ // it. Preserve each axis's own drag direction (handled below via
567
+ // drawingLeft/drawingUp) — only the magnitude is unified here.
568
+ const side = Math.max(rawWidth, rawHeight);
569
+ rawWidth = side;
570
+ rawHeight = side;
571
+ }
572
+
573
+ let width = Math.max(rawWidth || defaultWidth || 0, minWidth);
574
+ let height = Math.max(rawHeight || defaultHeight || 0, minHeight);
575
+
576
+ if (square && width !== height) {
577
+ // Zero-drag (a plain click before any movement) falls through to
578
+ // defaultWidth/defaultHeight and minWidth/minHeight independently, which
579
+ // can disagree even though rawWidth/rawHeight were already unified
580
+ // above. Re-unify using the larger side so a square/circle click-to-
581
+ // place still starts out square instead of using mismatched defaults.
582
+ const side = Math.max(width, height);
583
+ width = side;
584
+ height = side;
585
+ }
586
+
587
+ if (fromCenter) {
588
+ // `start` is the shape's center — grow outward symmetrically in both
589
+ // directions instead of anchoring one corner at `start`.
590
+ return {
591
+ x: start.x - width / 2,
592
+ y: start.y - height / 2,
593
+ width,
594
+ height,
595
+ };
596
+ }
597
+
458
598
  const drawingLeft = end.x < start.x;
459
599
  const drawingUp = end.y < start.y;
460
600
 
@@ -493,13 +633,16 @@ export function computeMoveSnap(
493
633
  let bestX: SnapCandidate | null = null;
494
634
  let bestY: SnapCandidate | null = null;
495
635
  const threshold = getCanvasSnapThreshold(options);
636
+ // Use the rotated (world-space) AABB rather than the unrotated local
637
+ // bounds, so a rotated frame snaps by its visual silhouette instead of an
638
+ // AABB that doesn't match anything on screen.
496
639
  const stationaryBounds = stationary.map((entry) => ({
497
640
  ...entry,
498
- bounds: getFrameBounds(entry.geometry),
641
+ bounds: getRotatedFrameAABB(entry.geometry),
499
642
  }));
500
643
 
501
644
  for (const entry of moving) {
502
- const movingBounds = getFrameBounds(entry.geometry);
645
+ const movingBounds = getRotatedFrameAABB(entry.geometry);
503
646
  for (const stationaryEntry of stationaryBounds) {
504
647
  bestX = getBestCandidate(
505
648
  bestX,
@@ -529,6 +672,168 @@ export function computeMoveSnap(
529
672
  };
530
673
  }
531
674
 
675
+ /**
676
+ * Figma-style "smart spacing" guides (CV11): when a single moving frame sits
677
+ * between two stationary neighbors with matching gaps on either side — or
678
+ * continues that same gap on just one side — highlight both gaps with their
679
+ * shared distance. This is purely a *display* aid (unlike computeMoveSnap,
680
+ * it never adjusts the frame's position) so callers should compute it
681
+ * independently of, and after, any snap offset has already been applied to
682
+ * the moving frame's geometry.
683
+ *
684
+ * Only single-frame moves are supported (matching how Figma's own equal-gap
685
+ * guides only appear while dragging one object) — pass the already-resolved
686
+ * single moving frame, not a multi-select group.
687
+ */
688
+ export function computeEqualGapGuides(
689
+ moving: FrameGeometry,
690
+ stationary: FrameEntry[],
691
+ { toleranceCanvasPx = 1 }: EqualGapOptions = {},
692
+ ): EqualGapGuide[] {
693
+ const movingBounds = getRotatedFrameAABB(moving);
694
+ const guides: EqualGapGuide[] = [];
695
+
696
+ const horizontal = collectAxisGapCandidates("x", movingBounds, stationary);
697
+ guides.push(...pairUpEqualGaps("vertical", horizontal, toleranceCanvasPx));
698
+
699
+ const vertical = collectAxisGapCandidates("y", movingBounds, stationary);
700
+ guides.push(...pairUpEqualGaps("horizontal", vertical, toleranceCanvasPx));
701
+
702
+ return guides;
703
+ }
704
+
705
+ interface GapCandidate {
706
+ /** "before" = stationary frame is to the left/above the moving frame;
707
+ * "after" = to the right/below. */
708
+ side: "before" | "after";
709
+ gap: number;
710
+ gapStart: number;
711
+ gapEnd: number;
712
+ crossStart: number;
713
+ crossEnd: number;
714
+ }
715
+
716
+ function collectAxisGapCandidates(
717
+ axis: "x" | "y",
718
+ movingBounds: FrameBounds,
719
+ stationary: FrameEntry[],
720
+ ): GapCandidate[] {
721
+ const candidates: GapCandidate[] = [];
722
+ for (const entry of stationary) {
723
+ const bounds = getRotatedFrameAABB(entry.geometry);
724
+ // Only frames that overlap the moving frame's extent on the OTHER axis
725
+ // produce a meaningful "gap between them" — otherwise the empty space
726
+ // isn't really a corridor connecting the two shapes.
727
+ const crossOverlaps =
728
+ axis === "x"
729
+ ? bounds.top < movingBounds.bottom && bounds.bottom > movingBounds.top
730
+ : bounds.left < movingBounds.right && bounds.right > movingBounds.left;
731
+ if (!crossOverlaps) continue;
732
+
733
+ const crossStart =
734
+ axis === "x"
735
+ ? Math.max(bounds.top, movingBounds.top)
736
+ : Math.max(bounds.left, movingBounds.left);
737
+ const crossEnd =
738
+ axis === "x"
739
+ ? Math.min(bounds.bottom, movingBounds.bottom)
740
+ : Math.min(bounds.right, movingBounds.right);
741
+
742
+ if (axis === "x") {
743
+ if (bounds.right <= movingBounds.left) {
744
+ candidates.push({
745
+ side: "before",
746
+ gap: movingBounds.left - bounds.right,
747
+ gapStart: bounds.right,
748
+ gapEnd: movingBounds.left,
749
+ crossStart,
750
+ crossEnd,
751
+ });
752
+ } else if (bounds.left >= movingBounds.right) {
753
+ candidates.push({
754
+ side: "after",
755
+ gap: bounds.left - movingBounds.right,
756
+ gapStart: movingBounds.right,
757
+ gapEnd: bounds.left,
758
+ crossStart,
759
+ crossEnd,
760
+ });
761
+ }
762
+ } else {
763
+ if (bounds.bottom <= movingBounds.top) {
764
+ candidates.push({
765
+ side: "before",
766
+ gap: movingBounds.top - bounds.bottom,
767
+ gapStart: bounds.bottom,
768
+ gapEnd: movingBounds.top,
769
+ crossStart,
770
+ crossEnd,
771
+ });
772
+ } else if (bounds.top >= movingBounds.bottom) {
773
+ candidates.push({
774
+ side: "after",
775
+ gap: bounds.top - movingBounds.bottom,
776
+ gapStart: movingBounds.bottom,
777
+ gapEnd: bounds.top,
778
+ crossStart,
779
+ crossEnd,
780
+ });
781
+ }
782
+ }
783
+ }
784
+ return candidates;
785
+ }
786
+
787
+ function pairUpEqualGaps(
788
+ orientation: "vertical" | "horizontal",
789
+ candidates: GapCandidate[],
790
+ toleranceCanvasPx: number,
791
+ ): EqualGapGuide[] {
792
+ const before = candidates.filter((c) => c.side === "before");
793
+ const after = candidates.filter((c) => c.side === "after");
794
+ const guides: EqualGapGuide[] = [];
795
+
796
+ // Closest gap on each side is the one a user is most likely dragging
797
+ // toward, so only pair the single closest "before" candidate against the
798
+ // single closest "after" candidate — otherwise a busy canvas would surface
799
+ // every combinatorial pair of same-ish gaps at once.
800
+ const closestBefore = before.reduce<GapCandidate | null>(
801
+ (best, c) => (!best || c.gap < best.gap ? c : best),
802
+ null,
803
+ );
804
+ const closestAfter = after.reduce<GapCandidate | null>(
805
+ (best, c) => (!best || c.gap < best.gap ? c : best),
806
+ null,
807
+ );
808
+
809
+ if (
810
+ closestBefore &&
811
+ closestAfter &&
812
+ Math.abs(closestBefore.gap - closestAfter.gap) <= toleranceCanvasPx
813
+ ) {
814
+ guides.push({
815
+ orientation,
816
+ gap: (closestBefore.gap + closestAfter.gap) / 2,
817
+ bands: [
818
+ {
819
+ gapStart: closestBefore.gapStart,
820
+ gapEnd: closestBefore.gapEnd,
821
+ crossStart: closestBefore.crossStart,
822
+ crossEnd: closestBefore.crossEnd,
823
+ },
824
+ {
825
+ gapStart: closestAfter.gapStart,
826
+ gapEnd: closestAfter.gapEnd,
827
+ crossStart: closestAfter.crossStart,
828
+ crossEnd: closestAfter.crossEnd,
829
+ },
830
+ ],
831
+ });
832
+ }
833
+
834
+ return guides;
835
+ }
836
+
532
837
  export function resizeFrameFromDelta(
533
838
  origin: FrameGeometry,
534
839
  handle: ResizeHandle,
@@ -652,19 +957,368 @@ export function resizeFrameGroupToBounds(
652
957
  }));
653
958
  }
654
959
 
960
+ /**
961
+ * Rotates `point` around `center` by `degrees` in the *forward* direction —
962
+ * i.e. the same direction as a CSS `transform: rotate(degrees deg)` applied
963
+ * to an element whose `transform-origin` is `center`. Use this to map a
964
+ * frame-local (unrotated) point into world space.
965
+ *
966
+ * Pass `-degrees` to do the inverse mapping (world space into a frame's local
967
+ * unrotated space).
968
+ */
969
+ export function rotatePoint(
970
+ point: CanvasPoint,
971
+ center: CanvasPoint,
972
+ degrees: number,
973
+ ): CanvasPoint {
974
+ if (!degrees) return point;
975
+ const rad = (degrees * Math.PI) / 180;
976
+ const dx = point.x - center.x;
977
+ const dy = point.y - center.y;
978
+ const cos = Math.cos(rad);
979
+ const sin = Math.sin(rad);
980
+ return {
981
+ x: center.x + dx * cos - dy * sin,
982
+ y: center.y + dx * sin + dy * cos,
983
+ };
984
+ }
985
+
986
+ /** Rotates a free vector (no translation component) by `degrees`. Use this to
987
+ * map a world-space pointer delta into a rotated frame's local axes (pass
988
+ * `-degrees`), or a local delta back into world space (pass `+degrees`). */
989
+ export function rotateVector(
990
+ vector: CanvasPoint,
991
+ degrees: number,
992
+ ): CanvasPoint {
993
+ if (!degrees) return vector;
994
+ const rad = (degrees * Math.PI) / 180;
995
+ const cos = Math.cos(rad);
996
+ const sin = Math.sin(rad);
997
+ return {
998
+ x: vector.x * cos - vector.y * sin,
999
+ y: vector.x * sin + vector.y * cos,
1000
+ };
1001
+ }
1002
+
1003
+ /** Returns the four corners of `geometry`'s rotated bounding box, in world
1004
+ * space, in top-left/top-right/bottom-right/bottom-left order. */
1005
+ export function getRotatedFrameCorners(
1006
+ geometry: FrameGeometry,
1007
+ ): [CanvasPoint, CanvasPoint, CanvasPoint, CanvasPoint] {
1008
+ const bounds = getFrameBounds(geometry);
1009
+ const center = { x: bounds.centerX, y: bounds.centerY };
1010
+ const degrees = geometry.rotation ?? 0;
1011
+ const corners: CanvasPoint[] = [
1012
+ { x: bounds.left, y: bounds.top },
1013
+ { x: bounds.right, y: bounds.top },
1014
+ { x: bounds.right, y: bounds.bottom },
1015
+ { x: bounds.left, y: bounds.bottom },
1016
+ ];
1017
+ return corners.map((corner) => rotatePoint(corner, center, degrees)) as [
1018
+ CanvasPoint,
1019
+ CanvasPoint,
1020
+ CanvasPoint,
1021
+ CanvasPoint,
1022
+ ];
1023
+ }
1024
+
1025
+ /** Returns the axis-aligned bounding box that encloses `geometry` after its
1026
+ * rotation is applied — i.e. the world-space AABB of the rotated rect,
1027
+ * rather than the unrotated local rect. Frames with no rotation return their
1028
+ * own bounds unchanged. Use this for snap-candidate generation and marquee
1029
+ * hit-testing against rotated frames instead of the unrotated `FrameBounds`. */
1030
+ export function getRotatedFrameAABB(geometry: FrameGeometry): FrameBounds {
1031
+ const degrees = geometry.rotation ?? 0;
1032
+ if (!degrees) return getFrameBounds(geometry);
1033
+ const corners = getRotatedFrameCorners(geometry);
1034
+ const xs = corners.map((corner) => corner.x);
1035
+ const ys = corners.map((corner) => corner.y);
1036
+ const left = Math.min(...xs);
1037
+ const top = Math.min(...ys);
1038
+ const right = Math.max(...xs);
1039
+ const bottom = Math.max(...ys);
1040
+ return getFrameBounds({
1041
+ x: left,
1042
+ y: top,
1043
+ width: right - left,
1044
+ height: bottom - top,
1045
+ });
1046
+ }
1047
+
1048
+ /** An axis-aligned rectangle in `{x, y, width, height}` form, as used by a
1049
+ * marquee-selection drag rect. */
1050
+ export interface AxisRect {
1051
+ x: number;
1052
+ y: number;
1053
+ width: number;
1054
+ height: number;
1055
+ }
1056
+
1057
+ /** An axis-aligned bounds rect in `{left, top, right, bottom}` form. */
1058
+ export interface AxisBounds {
1059
+ left: number;
1060
+ top: number;
1061
+ right: number;
1062
+ bottom: number;
1063
+ }
1064
+
1065
+ /**
1066
+ * Tests whether an axis-aligned rect (e.g. a marquee-selection drag rect)
1067
+ * intersects `bounds` (an unrotated rect) after `bounds` is rotated by
1068
+ * `degrees` around `center`.
1069
+ *
1070
+ * `center` defaults to the center of `bounds` itself — the common case of a
1071
+ * single rotated frame. Pass an explicit `center` when `bounds` describes a
1072
+ * child element that rotates rigidly with an ancestor frame around the
1073
+ * frame's own center rather than its own (e.g. layer-marquee hit-testing
1074
+ * against an element inside a rotated screen frame).
1075
+ *
1076
+ * A corner-containment check alone (only asking "is a corner of A inside B,
1077
+ * or a corner of B inside A") misses cases where the two rects cross like a
1078
+ * plus/hash sign — each one's edges pierce through the other without either
1079
+ * shape's corners landing inside the other, e.g. a thin marquee crossing the
1080
+ * middle of a thin rotated frame. This uses the Separating Axis Theorem
1081
+ * (SAT): two convex polygons do NOT intersect if and only if there exists an
1082
+ * axis (from either polygon's edge normals) onto which their projections
1083
+ * don't overlap. For an axis-aligned rect vs. a rotated rect there are
1084
+ * exactly 4 candidate axes to test — the rect's own x/y axes, and the
1085
+ * rotated rect's two (perpendicular) edge directions.
1086
+ */
1087
+ export function rotatedRectIntersects(
1088
+ rect: AxisRect,
1089
+ bounds: AxisBounds,
1090
+ center: CanvasPoint = {
1091
+ x: (bounds.left + bounds.right) / 2,
1092
+ y: (bounds.top + bounds.bottom) / 2,
1093
+ },
1094
+ degrees = 0,
1095
+ ): boolean {
1096
+ const rectCorners: CanvasPoint[] = [
1097
+ { x: rect.x, y: rect.y },
1098
+ { x: rect.x + rect.width, y: rect.y },
1099
+ { x: rect.x + rect.width, y: rect.y + rect.height },
1100
+ { x: rect.x, y: rect.y + rect.height },
1101
+ ];
1102
+
1103
+ if (!degrees) {
1104
+ return (
1105
+ rect.x <= bounds.right &&
1106
+ rect.x + rect.width >= bounds.left &&
1107
+ rect.y <= bounds.bottom &&
1108
+ rect.y + rect.height >= bounds.top
1109
+ );
1110
+ }
1111
+
1112
+ const boundsCorners: CanvasPoint[] = [
1113
+ { x: bounds.left, y: bounds.top },
1114
+ { x: bounds.right, y: bounds.top },
1115
+ { x: bounds.right, y: bounds.bottom },
1116
+ { x: bounds.left, y: bounds.bottom },
1117
+ ].map((corner) => rotatePoint(corner, center, degrees));
1118
+ const rad = (degrees * Math.PI) / 180;
1119
+ // The rotated rect's two perpendicular edge directions, plus the axis
1120
+ // rect's own x/y axes, are the full set of SAT candidate axes for two
1121
+ // rectangles (each rectangle only contributes 2 unique edge normals).
1122
+ const axes: CanvasPoint[] = [
1123
+ { x: 1, y: 0 },
1124
+ { x: 0, y: 1 },
1125
+ { x: Math.cos(rad), y: Math.sin(rad) },
1126
+ { x: -Math.sin(rad), y: Math.cos(rad) },
1127
+ ];
1128
+
1129
+ return axes.every((axis) =>
1130
+ projectionsOverlap(rectCorners, boundsCorners, axis),
1131
+ );
1132
+ }
1133
+
1134
+ function projectionsOverlap(
1135
+ a: readonly CanvasPoint[],
1136
+ b: readonly CanvasPoint[],
1137
+ axis: CanvasPoint,
1138
+ ): boolean {
1139
+ const projectionA = a.map((point) => point.x * axis.x + point.y * axis.y);
1140
+ const projectionB = b.map((point) => point.x * axis.x + point.y * axis.y);
1141
+ const minA = Math.min(...projectionA);
1142
+ const maxA = Math.max(...projectionA);
1143
+ const minB = Math.min(...projectionB);
1144
+ const maxB = Math.max(...projectionB);
1145
+ return minA <= maxB && minB <= maxA;
1146
+ }
1147
+
1148
+ /**
1149
+ * Resize-aware transform for a single rotated frame, so dragging a resize
1150
+ * handle behaves the way it looks: the handle direction follows the frame's
1151
+ * own rotated axes, and the opposite anchor edge/corner stays visually fixed
1152
+ * in world space (not just in unrotated local space).
1153
+ *
1154
+ * `worldDx`/`worldDy` are the raw pointer delta in world (canvas) space, the
1155
+ * same values `resizeFrameFromDelta` normally takes directly. This wrapper:
1156
+ * 1. Rotates the world delta into the frame's local (unrotated) axes.
1157
+ * 2. Runs the existing unrotated `resizeFrameFromDelta` in that local space.
1158
+ * 3. Re-anchors the result so the corner/edge the handle keeps fixed in
1159
+ * local space also stays fixed in world space, by translating the new
1160
+ * geometry so its own rotation (around its own new center) reproduces the
1161
+ * original world-space anchor point.
1162
+ *
1163
+ * For `origin.rotation` falsy this is identical to calling
1164
+ * `resizeFrameFromDelta` directly.
1165
+ */
1166
+ export function resizeRotatedFrameFromDelta(
1167
+ origin: FrameGeometry,
1168
+ handle: ResizeHandle,
1169
+ worldDx: number,
1170
+ worldDy: number,
1171
+ options: ResizeFrameOptions = {},
1172
+ ): FrameGeometry {
1173
+ const degrees = origin.rotation ?? 0;
1174
+ if (!degrees) {
1175
+ return resizeFrameFromDelta(origin, handle, worldDx, worldDy, options);
1176
+ }
1177
+
1178
+ const originCenter = {
1179
+ x: origin.x + origin.width / 2,
1180
+ y: origin.y + origin.height / 2,
1181
+ };
1182
+ // Alt/option resize (`resizeFromCenter`) already grows the frame
1183
+ // symmetrically about its own center in LOCAL space (resizeFrameFromDelta
1184
+ // below keeps the center fixed on both axes for that mode). To keep that
1185
+ // center fixed in WORLD space too — matching Figma, where alt-resizing a
1186
+ // rotated frame grows around its visual center rather than pivoting off an
1187
+ // opposite corner — the re-anchor step below must use the center as its
1188
+ // fixed point instead of the handle's opposite corner/edge. Using the
1189
+ // center as the anchor is a no-op for the rotation (a point rotates around
1190
+ // itself unchanged), so it stays put in world space by construction.
1191
+ // Non-center (default) resizes keep anchoring on the opposite corner/edge
1192
+ // so that world-fixed-anchor behavior is unchanged.
1193
+ const anchorLocalBefore = options.resizeFromCenter
1194
+ ? originCenter
1195
+ : getResizeAnchorPoint(origin, handle);
1196
+ const anchorWorld = rotatePoint(anchorLocalBefore, originCenter, degrees);
1197
+
1198
+ // Map the world-space pointer delta into the frame's own unrotated axes so
1199
+ // dragging "outward along the handle" behaves the same regardless of how
1200
+ // the frame is rotated.
1201
+ const localDelta = rotateVector({ x: worldDx, y: worldDy }, -degrees);
1202
+
1203
+ const resizedLocal = resizeFrameFromDelta(
1204
+ { ...origin, rotation: undefined },
1205
+ handle,
1206
+ localDelta.x,
1207
+ localDelta.y,
1208
+ options,
1209
+ );
1210
+
1211
+ const centerAfterLocal = {
1212
+ x: resizedLocal.x + resizedLocal.width / 2,
1213
+ y: resizedLocal.y + resizedLocal.height / 2,
1214
+ };
1215
+ const anchorLocalAfter = options.resizeFromCenter
1216
+ ? centerAfterLocal
1217
+ : getResizeAnchorPoint(resizedLocal, handle);
1218
+ const anchorWorldIfUntranslated = rotatePoint(
1219
+ anchorLocalAfter,
1220
+ centerAfterLocal,
1221
+ degrees,
1222
+ );
1223
+ const translation = {
1224
+ x: anchorWorld.x - anchorWorldIfUntranslated.x,
1225
+ y: anchorWorld.y - anchorWorldIfUntranslated.y,
1226
+ };
1227
+
1228
+ return {
1229
+ ...resizedLocal,
1230
+ x: resizedLocal.x + translation.x,
1231
+ y: resizedLocal.y + translation.y,
1232
+ rotation: degrees,
1233
+ };
1234
+ }
1235
+
1236
+ /** The local (unrotated) point that a given resize handle keeps fixed: the
1237
+ * edge/corner opposite the handle, or the center of an axis the handle
1238
+ * doesn't affect at all (e.g. "n" leaves the horizontal axis untouched). */
1239
+ function getResizeAnchorPoint(
1240
+ geometry: FrameGeometry,
1241
+ handle: ResizeHandle,
1242
+ ): CanvasPoint {
1243
+ const bounds = getFrameBounds(geometry);
1244
+ const x = handleAffectsWest(handle)
1245
+ ? bounds.right
1246
+ : handleAffectsEast(handle)
1247
+ ? bounds.left
1248
+ : bounds.centerX;
1249
+ const y = handleAffectsNorth(handle)
1250
+ ? bounds.bottom
1251
+ : handleAffectsSouth(handle)
1252
+ ? bounds.top
1253
+ : bounds.centerY;
1254
+ return { x, y };
1255
+ }
1256
+
1257
+ /** Unrotated visual angle of each resize handle, in degrees, matching CSS
1258
+ * cursor convention (0 = east/right, increasing clockwise since canvas y
1259
+ * grows downward) — "e" points right, "se" points down-right, etc. */
1260
+ const RESIZE_HANDLE_ANGLES: Record<ResizeHandle, number> = {
1261
+ e: 0,
1262
+ se: 45,
1263
+ s: 90,
1264
+ sw: 135,
1265
+ w: 180,
1266
+ nw: 225,
1267
+ n: 270,
1268
+ ne: 315,
1269
+ };
1270
+
1271
+ const RESIZE_CURSOR_BY_QUADRANT = [
1272
+ "ew-resize",
1273
+ "nwse-resize",
1274
+ "ns-resize",
1275
+ "nesw-resize",
1276
+ ] as const;
1277
+
1278
+ /**
1279
+ * Returns the resize cursor for `handle` on a frame rotated by `rotationDeg`
1280
+ * degrees, so the cursor always matches how the handle actually looks and
1281
+ * moves on screen instead of a static per-handle cursor that's only correct
1282
+ * when the frame isn't rotated.
1283
+ *
1284
+ * Cursor CSS only offers 4 distinct resize cursors, each valid across a pair
1285
+ * of opposite directions (`ew-resize` covers both due east and due west).
1286
+ * This adds the handle's own unrotated angle to the frame's rotation and
1287
+ * quantizes to the nearest 45 degrees to pick which of the 4 to use.
1288
+ */
1289
+ export function getResizeCursorForHandle(
1290
+ handle: ResizeHandle,
1291
+ rotationDeg = 0,
1292
+ ): string {
1293
+ const angle = RESIZE_HANDLE_ANGLES[handle] + rotationDeg;
1294
+ const normalized = ((angle % 360) + 360) % 360;
1295
+ const quantized = Math.round(normalized / 45) % 8;
1296
+ return RESIZE_CURSOR_BY_QUADRANT[quantized % 4];
1297
+ }
1298
+
655
1299
  export function computeResizeSnap(
656
1300
  frame: FrameGeometry,
657
1301
  stationary: FrameEntry[],
658
1302
  handle: ResizeHandle,
659
- options: CanvasSnapOptions,
1303
+ options: ResizeSnapOptions,
660
1304
  ) {
661
1305
  if (options.bypass) {
662
1306
  return { frame, guides: [] as AlignmentGuide[] };
663
1307
  }
664
1308
 
1309
+ const threshold = getCanvasSnapThreshold(options);
1310
+
1311
+ if (options.preserveAspectRatio) {
1312
+ return computeAspectPreservingResizeSnap(
1313
+ frame,
1314
+ stationary,
1315
+ handle,
1316
+ threshold,
1317
+ );
1318
+ }
1319
+
665
1320
  let nextFrame = frame;
666
1321
  const guides: AlignmentGuide[] = [];
667
- const threshold = getCanvasSnapThreshold(options);
668
1322
 
669
1323
  if (handleAffectsWest(handle) || handleAffectsEast(handle)) {
670
1324
  const candidate = getResizeSnapCandidate(
@@ -707,6 +1361,95 @@ export function computeResizeSnap(
707
1361
  return { frame: nextFrame, guides };
708
1362
  }
709
1363
 
1364
+ /**
1365
+ * Aspect-ratio-safe variant of the independent-axis snap above. Snapping x
1366
+ * and y independently can each pull toward a different sibling edge, which
1367
+ * distorts a shift-held (aspect-locked) resize away from its ratio. Instead:
1368
+ * evaluate both axes' snap candidates, apply only the single closest one,
1369
+ * then rescale the other axis from `frame`'s own aspect ratio so the shape
1370
+ * stays locked to it.
1371
+ */
1372
+ function computeAspectPreservingResizeSnap(
1373
+ frame: FrameGeometry,
1374
+ stationary: FrameEntry[],
1375
+ handle: ResizeHandle,
1376
+ threshold: number,
1377
+ ) {
1378
+ const ratio = frame.width / Math.max(1, frame.height);
1379
+ const xCandidate =
1380
+ handleAffectsWest(handle) || handleAffectsEast(handle)
1381
+ ? getResizeSnapCandidate("x", frame, stationary, handle, threshold)
1382
+ : null;
1383
+ const yCandidate =
1384
+ handleAffectsNorth(handle) || handleAffectsSouth(handle)
1385
+ ? getResizeSnapCandidate("y", frame, stationary, handle, threshold)
1386
+ : null;
1387
+
1388
+ if (!xCandidate && !yCandidate) {
1389
+ return { frame, guides: [] as AlignmentGuide[] };
1390
+ }
1391
+
1392
+ const useX =
1393
+ !yCandidate || (xCandidate && xCandidate.distance <= yCandidate.distance);
1394
+
1395
+ const affectsVertical =
1396
+ handleAffectsNorth(handle) || handleAffectsSouth(handle);
1397
+ const affectsHorizontal =
1398
+ handleAffectsWest(handle) || handleAffectsEast(handle);
1399
+
1400
+ if (useX && xCandidate) {
1401
+ const snappedX = applyResizeSnapOffset(
1402
+ frame,
1403
+ handle,
1404
+ "x",
1405
+ xCandidate.offset,
1406
+ );
1407
+ const nextHeight = snappedX.width / ratio;
1408
+ // Matches resizeFrameFromDelta's own convention: when a handle that
1409
+ // doesn't touch the vertical axis (e.g. "e") grows height only because
1410
+ // aspect-ratio derives it, that growth is centered vertically rather
1411
+ // than anchored to the original y.
1412
+ const rescaled = {
1413
+ ...snappedX,
1414
+ height: nextHeight,
1415
+ y: getResizedAxisStart(
1416
+ frame.y,
1417
+ frame.height,
1418
+ nextHeight,
1419
+ handleAffectsNorth(handle),
1420
+ handleAffectsSouth(handle),
1421
+ !affectsVertical && nextHeight !== frame.height,
1422
+ ),
1423
+ };
1424
+ return { frame: rescaled, guides: [xCandidate.guide] };
1425
+ }
1426
+
1427
+ if (yCandidate) {
1428
+ const snappedY = applyResizeSnapOffset(
1429
+ frame,
1430
+ handle,
1431
+ "y",
1432
+ yCandidate.offset,
1433
+ );
1434
+ const nextWidth = snappedY.height * ratio;
1435
+ const rescaled = {
1436
+ ...snappedY,
1437
+ width: nextWidth,
1438
+ x: getResizedAxisStart(
1439
+ frame.x,
1440
+ frame.width,
1441
+ nextWidth,
1442
+ handleAffectsWest(handle),
1443
+ handleAffectsEast(handle),
1444
+ !affectsHorizontal && nextWidth !== frame.width,
1445
+ ),
1446
+ };
1447
+ return { frame: rescaled, guides: [yCandidate.guide] };
1448
+ }
1449
+
1450
+ return { frame, guides: [] as AlignmentGuide[] };
1451
+ }
1452
+
710
1453
  function getResizedAxisStart(
711
1454
  originStart: number,
712
1455
  originSize: number,
@@ -841,7 +1584,9 @@ function getResizeSnapCandidate(
841
1584
  : frameBounds.bottom;
842
1585
 
843
1586
  return stationary.reduce<SnapCandidate | null>((best, entry) => {
844
- const stationaryBounds = getFrameBounds(entry.geometry);
1587
+ // Rotated (world-space) AABB, not the unrotated local bounds, so
1588
+ // resizing snaps against a rotated sibling's visual silhouette.
1589
+ const stationaryBounds = getRotatedFrameAABB(entry.geometry);
845
1590
  const targetValues =
846
1591
  axis === "x"
847
1592
  ? [