@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
@@ -4,16 +4,25 @@ import {
4
4
  DEFAULT_ASSIGNED_REGION_HEIGHT,
5
5
  DEFAULT_ASSIGNED_REGION_MAX_COLUMNS,
6
6
  DEFAULT_ASSIGNED_REGION_WIDTH,
7
+ DEFAULT_CANVAS_MAX_ZOOM,
8
+ DEFAULT_CANVAS_MIN_ZOOM,
7
9
  DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
8
- appendPolylinePoint,
10
+ computeEqualGapGuides,
9
11
  computeMoveSnap,
10
12
  computeResizeSnap,
13
+ type DistanceGuideBand,
14
+ type EqualGapGuide,
11
15
  getDraftGeometryFromPoints,
12
16
  getFrameGroupBounds,
13
17
  getNudgeDelta,
14
18
  getPanForZoomToCursor,
19
+ getResizeCursorForHandle,
20
+ getRotatedFrameAABB,
15
21
  resizeFrameGroupFromDelta,
16
22
  resizeFrameGroupToBounds,
23
+ resizeRotatedFrameFromDelta,
24
+ rotateFrameGroupAroundCenter,
25
+ rotatedRectIntersects,
17
26
  screenToCanvasPoint,
18
27
  type ArrowNudgeKey,
19
28
  } from "@shared/canvas-math";
@@ -28,6 +37,7 @@ import {
28
37
  isPenCloseTarget,
29
38
  scalePenPathToGeometry,
30
39
  serializePenPath,
40
+ snapPenAnchorPoint,
31
41
  translatePenPath,
32
42
  type PenNode,
33
43
  type PenPath,
@@ -406,6 +416,9 @@ const SCREEN_GAP = 56;
406
416
  export const SURFACE_PADDING = 240;
407
417
  const DUPLICATE_DRAG_THRESHOLD = 6;
408
418
  const DRAG_THRESHOLD = 3;
419
+ /** How close two gaps must be (in screen px, converted to canvas px at the
420
+ * live zoom) to count as "equal" for the smart-spacing guides (CV11). */
421
+ const EQUAL_GAP_TOLERANCE_SCREEN_PX = 2;
409
422
  const FRAME_LABEL_HEIGHT = 28;
410
423
  const FRAME_HEADER_BUTTON_OUTSIDE_WIDTH = 260;
411
424
  const FRAME_HEADER_BUTTON_RESERVE = 116;
@@ -414,8 +427,21 @@ const TRANSFORM_BADGE_EDGE_PADDING = 8;
414
427
  const TRANSFORM_BADGE_HEIGHT = 28;
415
428
  const TRANSFORM_BADGE_MIN_WIDTH = 64;
416
429
  const TRANSFORM_BADGE_MAX_WIDTH = 180;
417
- const MIN_ZOOM = 2;
418
- const MAX_ZOOM = 800;
430
+ // Additive zIndex boost for the current "top" screen (selected, else active,
431
+ // else the first frame — see topScreenId). Screens are keyed by screen.id in
432
+ // stable DOM order (see PF16): reordering the top screen's key to the end of
433
+ // the array to win the paint stacking order forced React to move that
434
+ // iframe's DOM node, which reloads its document (a visible white flash).
435
+ // zIndex alone can express "renders above its siblings" without touching DOM
436
+ // order, as long as the boost is large enough to beat any real geometry.z
437
+ // (frame z-order is a small per-design integer) while staying well under the
438
+ // reserved resize-handle stacking range (999_999+).
439
+ const TOP_SCREEN_Z_BOOST = 100_000;
440
+ // Shared with canvas-math.ts (DEFAULT_CANVAS_MIN_ZOOM/DEFAULT_CANVAS_MAX_ZOOM)
441
+ // so this surface's zoom clamp lives in one place instead of being
442
+ // redeclared locally and drifting from the shared constant.
443
+ const MIN_ZOOM = DEFAULT_CANVAS_MIN_ZOOM;
444
+ const MAX_ZOOM = DEFAULT_CANVAS_MAX_ZOOM;
419
445
  const ZOOM_SENSITIVITY = 0.01;
420
446
  const MAX_WHEEL_ZOOM_DELTA = 120;
421
447
  const MAX_WHEEL_PAN_DELTA = 140;
@@ -722,7 +748,6 @@ const DRAFT_TEXT_WIDTH = 180;
722
748
  const DRAFT_TEXT_HEIGHT = 48;
723
749
  const DRAFT_LINE_WIDTH = 160;
724
750
  const DRAFT_PATH_MIN_SIZE = 12;
725
- const PEN_SAMPLE_DISTANCE_SCREEN_PX = 5;
726
751
  const PEN_CLOSE_HIT_RADIUS_SCREEN_PX = 10;
727
752
 
728
753
  interface ResolvedScreenMetadata {
@@ -851,6 +876,59 @@ function isCrossScreenHitTestAnchorRect(
851
876
  );
852
877
  }
853
878
 
879
+ // Snap/guide recompute runs every rAF-coalesced mousemove during a drag and
880
+ // always returns a freshly-allocated array, so referential-equality bail in
881
+ // setState never fires even when the guides are unchanged frame-to-frame
882
+ // (e.g. holding steady mid-drag, or dragging along an axis with no new
883
+ // alignment). Shallow field-compare avoids the wasted re-render (PF15).
884
+ function alignmentGuidesEqual(a: AlignmentGuide[], b: AlignmentGuide[]) {
885
+ if (a === b) return true;
886
+ if (a.length !== b.length) return false;
887
+ for (let i = 0; i < a.length; i++) {
888
+ const x = a[i];
889
+ const y = b[i];
890
+ if (
891
+ x.orientation !== y.orientation ||
892
+ x.position !== y.position ||
893
+ x.start !== y.start ||
894
+ x.end !== y.end
895
+ ) {
896
+ return false;
897
+ }
898
+ }
899
+ return true;
900
+ }
901
+
902
+ function distanceGuideBandEqual(
903
+ a: DistanceGuideBand,
904
+ b: DistanceGuideBand,
905
+ ): boolean {
906
+ return (
907
+ a.gapStart === b.gapStart &&
908
+ a.gapEnd === b.gapEnd &&
909
+ a.crossStart === b.crossStart &&
910
+ a.crossEnd === b.crossEnd
911
+ );
912
+ }
913
+
914
+ function equalGapGuidesEqual(a: EqualGapGuide[], b: EqualGapGuide[]) {
915
+ if (a === b) return true;
916
+ if (a.length !== b.length) return false;
917
+ for (let i = 0; i < a.length; i++) {
918
+ const x = a[i];
919
+ const y = b[i];
920
+ if (
921
+ x.orientation !== y.orientation ||
922
+ x.gap !== y.gap ||
923
+ !distanceGuideBandEqual(x.bands[0], y.bands[0]) ||
924
+ !distanceGuideBandEqual(x.bands[1], y.bands[1])
925
+ ) {
926
+ return false;
927
+ }
928
+ }
929
+ return true;
930
+ }
931
+
854
932
  export function getCrossScreenDropGuideForHitTest(args: {
855
933
  hit: CrossScreenHitTestResult;
856
934
  targetGeometry: FrameGeometry;
@@ -963,13 +1041,21 @@ interface DraftPrimitive {
963
1041
 
964
1042
  type DraftPrimitiveById = Record<string, DraftPrimitive>;
965
1043
 
1044
+ /** Live keyboard modifiers for shape-drawing tools: shift constrains rect/
1045
+ * ellipse to a square/circle (and lines/arrows to 45deg increments); alt
1046
+ * draws outward from the start point as the shape's center. */
1047
+ interface DraftGeometryModifiers {
1048
+ shiftKey?: boolean;
1049
+ altKey?: boolean;
1050
+ }
1051
+
966
1052
  interface DraftPrimitiveInput {
967
1053
  tool: DraftCreationTool;
968
1054
  start: Point;
969
1055
  end: Point;
970
- points?: Point[];
971
1056
  moved: boolean;
972
1057
  toolProps?: CanvasToolProps;
1058
+ modifiers?: DraftGeometryModifiers;
973
1059
  }
974
1060
 
975
1061
  interface MarqueeRect {
@@ -1017,6 +1103,21 @@ interface RotateDragState {
1017
1103
  hasMoved: boolean;
1018
1104
  }
1019
1105
 
1106
+ /** Multi-selection rotate (CV14): rotates every selected frame together
1107
+ * around the group's own center, using rotateFrameGroupAroundCenter. Kept
1108
+ * as a separate drag-state type from the single-frame RotateDragState above
1109
+ * (rather than extending it to optionally hold multiple ids) so the
1110
+ * existing, already-correct single-frame rotate path is never touched. */
1111
+ interface GroupRotateDragState {
1112
+ type: "group-rotate";
1113
+ originClient: Point;
1114
+ originFrames: FrameGeometryById;
1115
+ targetIds: string[];
1116
+ groupCenter: Point;
1117
+ originPointerAngle: number;
1118
+ hasMoved: boolean;
1119
+ }
1120
+
1020
1121
  interface MarqueeDragState {
1021
1122
  type: "marquee";
1022
1123
  originClient: Point;
@@ -1068,6 +1169,14 @@ interface PenNodeDragState {
1068
1169
  anchor: Point;
1069
1170
  pathBefore: PenPath | null;
1070
1171
  hasMoved: boolean;
1172
+ /**
1173
+ * True when this drag started on the close-hit-target (the path's first
1174
+ * anchor) rather than adding a new node. Figma defers the close commit
1175
+ * until mouseup so a drag on the closing click can shape the closing
1176
+ * segment's curve (the first anchor's handleIn) instead of the click
1177
+ * being an instant, undraggable straight-line close.
1178
+ */
1179
+ closing?: boolean;
1071
1180
  }
1072
1181
 
1073
1182
  interface DraftCreationPreview {
@@ -1080,6 +1189,7 @@ type DragState =
1080
1189
  | MoveDragState
1081
1190
  | ResizeDragState
1082
1191
  | RotateDragState
1192
+ | GroupRotateDragState
1083
1193
  | MarqueeDragState
1084
1194
  | PanDragState
1085
1195
  | DraftMoveDragState
@@ -1101,7 +1211,14 @@ type PendingWheelGesture =
1101
1211
  deltaY: number;
1102
1212
  };
1103
1213
 
1104
- export function MultiScreenCanvas({
1214
+ // PF8: DesignEditor re-renders on nearly every interaction (drag, hover,
1215
+ // zoom, selection). All call-site props are stabilized in DesignEditor.tsx
1216
+ // (useMemo/useCallback hoists, cached getLayerSelectorsForFile, hoisted
1217
+ // renderScreenContent, hoisted board-surface callbacks — see the "PF8"
1218
+ // comments near those definitions), so a default shallow comparator is
1219
+ // sufficient and preferred: it will correctly re-render whenever a real
1220
+ // data/callback identity changes instead of silently masking one.
1221
+ export const MultiScreenCanvas = memo(function MultiScreenCanvas({
1105
1222
  screens,
1106
1223
  zoom,
1107
1224
  activeId,
@@ -1189,6 +1306,16 @@ export function MultiScreenCanvas({
1189
1306
  );
1190
1307
  const [penPointer, setPenPointer] = useState<Point | null>(null);
1191
1308
  const [penCloseHover, setPenCloseHover] = useState(false);
1309
+ // Last raw client point the pen ghost/close-hover preview was computed
1310
+ // from (P18). A wheel pan/zoom gesture mutates pan/zoom every animation
1311
+ // frame via applyViewToDom without the mouse itself moving, so the
1312
+ // screen->canvas mapping used for the ghost segment goes stale unless we
1313
+ // re-derive it from this remembered client point after each such change.
1314
+ const lastPenClientPointRef = useRef<{
1315
+ clientX: number;
1316
+ clientY: number;
1317
+ shiftKey: boolean;
1318
+ } | null>(null);
1192
1319
  const [localActiveTool, setLocalActiveTool] =
1193
1320
  useState<MultiScreenCanvasTool>("move");
1194
1321
  const [selectedIds, setSelectedIds] = useState<string[]>(
@@ -1203,7 +1330,25 @@ export function MultiScreenCanvas({
1203
1330
  const [isDragging, setIsDragging] = useState(false);
1204
1331
  const [isPanning, setIsPanning] = useState(false);
1205
1332
  const [marquee, setMarquee] = useState<MarqueeRect | null>(null);
1206
- const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
1333
+ const marqueeRef = useRef<MarqueeRect | null>(marquee);
1334
+ const [alignmentGuides, setAlignmentGuidesRaw] = useState<AlignmentGuide[]>(
1335
+ [],
1336
+ );
1337
+ const [equalGapGuides, setEqualGapGuidesRaw] = useState<EqualGapGuide[]>([]);
1338
+ // Guides are recomputed into a brand-new array on every rAF-coalesced
1339
+ // mousemove during a drag; without a value-equality bail, React commits a
1340
+ // state update (and a re-render) every frame even when the guides drawn on
1341
+ // screen haven't actually changed (PF15). Bail before calling setState.
1342
+ const setAlignmentGuides = useCallback((next: AlignmentGuide[]) => {
1343
+ setAlignmentGuidesRaw((current) =>
1344
+ alignmentGuidesEqual(current, next) ? current : next,
1345
+ );
1346
+ }, []);
1347
+ const setEqualGapGuides = useCallback((next: EqualGapGuide[]) => {
1348
+ setEqualGapGuidesRaw((current) =>
1349
+ equalGapGuidesEqual(current, next) ? current : next,
1350
+ );
1351
+ }, []);
1207
1352
  const [duplicatePreview, setDuplicatePreview] =
1208
1353
  useState<DuplicatePreview | null>(null);
1209
1354
  const [transformBadge, setTransformBadge] = useState<TransformBadge | null>(
@@ -1254,17 +1399,47 @@ export function MultiScreenCanvas({
1254
1399
  const crossScreenLastBoardPointRef = useRef<{ x: number; y: number } | null>(
1255
1400
  null,
1256
1401
  );
1402
+ /** rAF handle for throttling drop-guide hit-tests during the parent-window
1403
+ * mousemove fallback drag (see activateParentDrag) — a hit-test is a
1404
+ * postMessage round-trip to the target iframe, so firing one per raw
1405
+ * mousemove event (which can be dozens per frame) floods the message
1406
+ * channel for no visual benefit beyond one update per animation frame. */
1407
+ const crossScreenMoveRafRef = useRef<number | null>(null);
1408
+ const crossScreenPendingMoveRef = useRef<{
1409
+ boardPoint: Point;
1410
+ sourceScreenId: string;
1411
+ } | null>(null);
1412
+ /** Last successful hit-test result per target screen id, so a timed-out
1413
+ * request (bridge script briefly busy, iframe still loading, etc.) can
1414
+ * fall back to the previous guide instead of resolving empty and making
1415
+ * the drop guide flicker away every time a single hit-test is slow. */
1416
+ const crossScreenLastHitResultRef = useRef<
1417
+ Map<string, CrossScreenHitTestResult>
1418
+ >(new Map());
1257
1419
  const onCrossScreenElementDropRef = useRef(onCrossScreenElementDrop);
1258
1420
  const onBoardDrawPrimitiveRef = useRef(onBoardDrawPrimitive);
1259
1421
  // Ref wrapper for finishDrag so callbacks declared before finishDrag can
1260
1422
  // reference it via the ref without hitting the const TDZ.
1261
1423
  const finishDragRef = useRef<() => void>(() => {});
1424
+ // Ref wrappers for applyViewToDom/scheduleViewCommit (defined later, near
1425
+ // the wheel/pinch gesture path) so beginPan — declared earlier — can reuse
1426
+ // the same imperative-transform-during-gesture pattern without a TDZ error.
1427
+ const applyViewToDomRef = useRef<() => void>(() => {});
1428
+ const scheduleViewCommitRef = useRef<
1429
+ (options?: { settleChrome?: boolean }) => void
1430
+ >(() => {});
1431
+ // Ref wrapper for recomputePenPointerForViewChange (P18, defined later
1432
+ // near updatePenPointer) so the external `zoom` prop sync effect —
1433
+ // declared earlier — can resync the pen ghost preview after an
1434
+ // externally-driven (toolbar/keyboard) zoom change without a TDZ error.
1435
+ const recomputePenPointerForViewChangeRef = useRef<() => void>(() => {});
1262
1436
  const suppressNextPick = useRef(false);
1263
1437
  const feedbackTimerRef = useRef<number | null>(null);
1264
1438
  const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
1265
1439
  const wheelGestureFrameRef = useRef<number | null>(null);
1266
1440
  const worldRef = useRef<HTMLDivElement>(null);
1267
1441
  const pixelGridRef = useRef<HTMLDivElement>(null);
1442
+ const marqueeOverlayRef = useRef<HTMLSpanElement>(null);
1268
1443
  const viewCommitTimerRef = useRef<number | null>(null);
1269
1444
  const pendingChromeSettleRef = useRef(false);
1270
1445
  const chromeSettleTimerRef = useRef<number | null>(null);
@@ -1327,12 +1502,18 @@ export function MultiScreenCanvas({
1327
1502
 
1328
1503
  const updateFrameGeometry = useCallback(
1329
1504
  (updater: (current: FrameGeometryById) => FrameGeometryById) => {
1330
- setFrameGeometry((current) => {
1331
- const next = updater(current);
1332
- frameGeometryRef.current = next;
1333
- onGeometryChangeRef.current?.(next);
1334
- return next;
1335
- });
1505
+ // Compute the next value from the ref (kept in sync below and by the
1506
+ // frameGeometry-mirroring effect) and call the onGeometryChange side
1507
+ // effect *after* setFrameGeometry, not inside the updater passed to
1508
+ // it. React (especially StrictMode, which double-invokes state
1509
+ // updaters to surface impure updates) may call an updater function
1510
+ // more than once per commit — doing the ref write + external callback
1511
+ // inside it would double-fire onGeometryChange for a single logical
1512
+ // geometry change.
1513
+ const next = updater(frameGeometryRef.current);
1514
+ frameGeometryRef.current = next;
1515
+ setFrameGeometry(next);
1516
+ onGeometryChangeRef.current?.(next);
1336
1517
  },
1337
1518
  [],
1338
1519
  );
@@ -1387,6 +1568,10 @@ export function MultiScreenCanvas({
1387
1568
  panRef.current = pan;
1388
1569
  }, [pan]);
1389
1570
 
1571
+ useEffect(() => {
1572
+ marqueeRef.current = marquee;
1573
+ }, [marquee]);
1574
+
1390
1575
  useEffect(() => {
1391
1576
  zoomRef.current = canvasZoom;
1392
1577
  }, [canvasZoom]);
@@ -1400,14 +1585,32 @@ export function MultiScreenCanvas({
1400
1585
  onSelectionChangeRef.current = onSelectionChange;
1401
1586
  }, [onSelectionChange]);
1402
1587
 
1588
+ // Selection is dual-controlled: it is synced FROM the `selectedScreenIds`
1589
+ // prop (see the prop-sync effect below) and CHANGES are reported back to the
1590
+ // parent. Reporting a selection that merely mirrors the prop would round-trip
1591
+ // through the parent (which re-derives/filters the ids) and, when two screens
1592
+ // are created back-to-back, ping-pong the selection between them forever
1593
+ // ("Maximum update depth exceeded" → the editor appears to refresh). Track
1594
+ // the last prop-driven selection and only report genuine, local (user-driven)
1595
+ // divergences from it.
1596
+ const propSyncedSelectionRef = useRef<string[] | null>(null);
1597
+ const isEchoOfPropSelection = useCallback(
1598
+ (ids: string[]) =>
1599
+ propSyncedSelectionRef.current !== null &&
1600
+ sameIds(ids, propSyncedSelectionRef.current),
1601
+ [],
1602
+ );
1603
+
1403
1604
  useEffect(() => {
1404
1605
  selectedIdsRef.current = selectedIds;
1606
+ if (isEchoOfPropSelection(selectedIds)) return;
1405
1607
  onSelectionChangeRef.current?.(selectedIds);
1406
- }, [selectedIds]);
1608
+ }, [isEchoOfPropSelection, selectedIds]);
1407
1609
 
1408
1610
  useEffect(() => {
1611
+ if (isEchoOfPropSelection(selectedIds)) return;
1409
1612
  onScreenSelectionChange?.(selectedIds);
1410
- }, [onScreenSelectionChange, selectedIds]);
1613
+ }, [isEchoOfPropSelection, onScreenSelectionChange, selectedIds]);
1411
1614
 
1412
1615
  useEffect(() => {
1413
1616
  draftPrimitivesRef.current = draftPrimitives;
@@ -1418,8 +1621,39 @@ export function MultiScreenCanvas({
1418
1621
  }, [selectedDraftIds]);
1419
1622
 
1420
1623
  useEffect(() => {
1624
+ // zoomRef.current is the canvas's own last-known zoom, kept in sync
1625
+ // synchronously by every internal zoom path (wheel/pinch commitView,
1626
+ // fit-to-screen) *before* those paths call onZoomChange. So if it
1627
+ // already matches the incoming prop, this change originated from our own
1628
+ // gesture round-tripping back through a controlled `zoom` prop — that
1629
+ // path already applied its own (cursor-anchored) pan compensation, and
1630
+ // redoing it here with a surface-center anchor would double-shift pan.
1631
+ // Only compensate when this is a genuinely external change (toolbar
1632
+ // buttons, keyboard shortcuts) that never touched zoomRef/panRef.
1633
+ const previousZoom = zoomRef.current;
1634
+ if (zoom === previousZoom) return;
1635
+ // External zoom changes otherwise anchor at world origin (0,0) since
1636
+ // only canvasZoom is updated here — content visibly jumps diagonally
1637
+ // instead of zooming in place. Mirror the wheel/pinch cursor-anchored
1638
+ // compensation using the surface's own center as the anchor, since
1639
+ // there's no cursor position for a toolbar/keyboard-driven zoom change.
1640
+ const rect = surfaceRef.current?.getBoundingClientRect();
1641
+ const cursor = rect
1642
+ ? { x: rect.width / 2, y: rect.height / 2 }
1643
+ : { x: 0, y: 0 };
1644
+ const nextPan = getPanForZoomToCursor({
1645
+ pan: panRef.current,
1646
+ cursor,
1647
+ oldZoom: previousZoom,
1648
+ nextZoom: zoom,
1649
+ });
1650
+ panRef.current = nextPan;
1651
+ setPan(nextPan);
1421
1652
  setCanvasZoom(zoom);
1422
1653
  zoomRef.current = zoom;
1654
+ // P18: an externally-driven zoom change (toolbar/keyboard) also moves
1655
+ // the canvas-space mapping the pen ghost preview was computed from.
1656
+ recomputePenPointerForViewChangeRef.current();
1423
1657
  }, [zoom]);
1424
1658
 
1425
1659
  useEffect(() => {
@@ -1488,6 +1722,9 @@ export function MultiScreenCanvas({
1488
1722
 
1489
1723
  useEffect(() => {
1490
1724
  if (!selectedScreenIds) return;
1725
+ // Remember the selection we're pushing in from the parent so the report
1726
+ // effects above can recognise (and not echo back) the resulting change.
1727
+ propSyncedSelectionRef.current = selectedScreenIds;
1491
1728
  updateSelectedIds(() => selectedScreenIds);
1492
1729
  }, [screens, selectedScreenIds, updateSelectedIds]);
1493
1730
 
@@ -1682,8 +1919,12 @@ export function MultiScreenCanvas({
1682
1919
 
1683
1920
  const postHitTestPreviewClear = (targetId: string | null | undefined) => {
1684
1921
  if (!targetId) return;
1922
+ const targetScreen = screensRef.current.find((s) => s.id === targetId);
1923
+ const iframeId = targetScreen
1924
+ ? getActiveScreenIframeId(targetScreen)
1925
+ : targetId;
1685
1926
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1686
- `[data-screen-iframe-id="${CSS.escape(targetId)}"]`,
1927
+ `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
1687
1928
  );
1688
1929
  targetIframe?.contentWindow?.postMessage(
1689
1930
  { type: "agent-native:hit-test-preview-clear" },
@@ -1715,6 +1956,9 @@ export function MultiScreenCanvas({
1715
1956
  clearCrossScreenDropGuide();
1716
1957
  crossScreenTargetRef.current = null;
1717
1958
  crossScreenDragMsgRef.current = null;
1959
+ // Drop the timeout-fallback cache so a future, unrelated drag session
1960
+ // never shows a guide left over from this one.
1961
+ crossScreenLastHitResultRef.current.clear();
1718
1962
  };
1719
1963
 
1720
1964
  const runHitTest = (
@@ -1727,8 +1971,9 @@ export function MultiScreenCanvas({
1727
1971
  );
1728
1972
  if (!targetScreen) return Promise.resolve({});
1729
1973
  const targetGeometry = candidate.geometry;
1974
+ const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
1730
1975
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1731
- `[data-screen-iframe-id="${CSS.escape(candidate.id)}"]`,
1976
+ `[data-screen-iframe-id="${targetIframeId}"]`,
1732
1977
  );
1733
1978
  const targetContentWindow = targetIframe?.contentWindow;
1734
1979
  if (!targetContentWindow) return Promise.resolve({});
@@ -1748,20 +1993,28 @@ export function MultiScreenCanvas({
1748
1993
  return new Promise((resolve) => {
1749
1994
  const timer = window.setTimeout(() => {
1750
1995
  window.removeEventListener("message", hitListener);
1751
- resolve({});
1752
- }, 50);
1996
+ // Fall back to the last successful result for this target instead
1997
+ // of resolving empty — a single slow reply (bridge script briefly
1998
+ // busy, iframe mid-navigation, etc.) shouldn't make the drop guide
1999
+ // flicker away and immediately reappear on the next hit-test.
2000
+ resolve(crossScreenLastHitResultRef.current.get(candidate.id) ?? {});
2001
+ }, 250);
1753
2002
 
1754
2003
  const hitListener = (ev: MessageEvent) => {
1755
2004
  if (
1756
2005
  !ev.data ||
1757
2006
  ev.data.type !== "agent-native:hit-test-result" ||
1758
- ev.data.correlationId !== correlationId
2007
+ ev.data.correlationId !== correlationId ||
2008
+ // Require the reply to actually come from the iframe we asked,
2009
+ // not just any window that happens to observe/guess the
2010
+ // correlationId and reply with a matching payload shape.
2011
+ ev.source !== targetContentWindow
1759
2012
  ) {
1760
2013
  return;
1761
2014
  }
1762
2015
  window.clearTimeout(timer);
1763
2016
  window.removeEventListener("message", hitListener);
1764
- resolve({
2017
+ const result: CrossScreenHitTestResult = {
1765
2018
  anchorNodeId:
1766
2019
  typeof ev.data.anchorNodeId === "string"
1767
2020
  ? ev.data.anchorNodeId
@@ -1778,7 +2031,9 @@ export function MultiScreenCanvas({
1778
2031
  anchorRect: isCrossScreenHitTestAnchorRect(ev.data.anchorRect)
1779
2032
  ? ev.data.anchorRect
1780
2033
  : undefined,
1781
- });
2034
+ };
2035
+ crossScreenLastHitResultRef.current.set(candidate.id, result);
2036
+ resolve(result);
1782
2037
  };
1783
2038
  window.addEventListener("message", hitListener);
1784
2039
 
@@ -1807,8 +2062,9 @@ export function MultiScreenCanvas({
1807
2062
  (s) => s.id === candidate.id,
1808
2063
  );
1809
2064
  if (!targetScreen) return null;
2065
+ const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
1810
2066
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1811
- `[data-screen-iframe-id="${CSS.escape(candidate.id)}"]`,
2067
+ `[data-screen-iframe-id="${targetIframeId}"]`,
1812
2068
  );
1813
2069
  const targetViewportWidth =
1814
2070
  targetIframe?.clientWidth || getResolvedMetadata(targetScreen).width;
@@ -1957,6 +2213,31 @@ export function MultiScreenCanvas({
1957
2213
  if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
1958
2214
  return;
1959
2215
  }
2216
+ // Resolve which of our own embedded design-preview iframes actually
2217
+ // posted this message — postMessage's origin/source aren't otherwise
2218
+ // checked here, so without this any window (including a spoofed one)
2219
+ // could claim to be a screen. Same pattern as
2220
+ // handleEmbeddedWheelMessage below. Bind to the matched iframe element
2221
+ // itself rather than trusting the payload: a compromised/owned preview
2222
+ // iframe can put any `screenId` it wants in the message body, so the
2223
+ // sender's true screen identity must come from the DOM, not the data.
2224
+ const surfaceForSourceCheck = surfaceRef.current;
2225
+ const sourcePreviewIframe = surfaceForSourceCheck
2226
+ ? Array.from(
2227
+ surfaceForSourceCheck.querySelectorAll<HTMLIFrameElement>(
2228
+ "iframe[data-design-preview-iframe]",
2229
+ ),
2230
+ ).find((iframe) => iframe.contentWindow === event.source)
2231
+ : undefined;
2232
+ if (!sourcePreviewIframe) return;
2233
+ // The board's DesignCanvas renders with `boardSurface` set, which
2234
+ // deliberately omits `data-screen-iframe-id` (see DesignCanvas.tsx), so
2235
+ // an unset attribute here means the message came from the board
2236
+ // surface iframe rather than a per-screen one.
2237
+ const domScreenId =
2238
+ sourcePreviewIframe.getAttribute("data-screen-iframe-id") ??
2239
+ boardFileId ??
2240
+ undefined;
1960
2241
  const msg = event.data as {
1961
2242
  type: string;
1962
2243
  phase: "start" | "move" | "end" | "cancel";
@@ -1991,12 +2272,17 @@ export function MultiScreenCanvas({
1991
2272
  return;
1992
2273
  }
1993
2274
 
1994
- // Prefer the iframe-supplied source id. In overview, activeId can point
1995
- // at a different screen than the layer currently being dragged.
2275
+ // Attribute the drag to the DOM-verified source iframe's own screen id,
2276
+ // never the payload's claimed `msg.screenId` the message body is
2277
+ // authored by the (sandboxed but same-origin-capable) preview iframe
2278
+ // content and must not be trusted to identify itself. Fall back to
2279
+ // activeId only when the matched iframe unexpectedly has no resolvable
2280
+ // screen id (e.g. a stale/unknown screen), matching prior behavior for
2281
+ // that edge case.
1996
2282
  const sourceScreenId =
1997
- msg.screenId &&
1998
- (msg.screenId === boardFileId || frameGeometryRef.current[msg.screenId])
1999
- ? msg.screenId
2283
+ domScreenId &&
2284
+ (domScreenId === boardFileId || frameGeometryRef.current[domScreenId])
2285
+ ? domScreenId
2000
2286
  : activeId;
2001
2287
  if (!sourceScreenId) {
2002
2288
  // Always clear visual state (ghost + highlight) when we have no active
@@ -2021,6 +2307,23 @@ export function MultiScreenCanvas({
2021
2307
  surfaceRef.current,
2022
2308
  );
2023
2309
  let didCleanup = false;
2310
+ const cancelPendingParentDrag = () => {
2311
+ if (crossScreenMoveRafRef.current !== null) {
2312
+ window.cancelAnimationFrame(crossScreenMoveRafRef.current);
2313
+ crossScreenMoveRafRef.current = null;
2314
+ }
2315
+ crossScreenPendingMoveRef.current = null;
2316
+ };
2317
+ const flushPendingParentDrag = () => {
2318
+ crossScreenMoveRafRef.current = null;
2319
+ const pending = crossScreenPendingMoveRef.current;
2320
+ crossScreenPendingMoveRef.current = null;
2321
+ if (!pending) return;
2322
+ updateCrossScreenTargetFromBoardPoint(
2323
+ pending.boardPoint,
2324
+ pending.sourceScreenId,
2325
+ );
2326
+ };
2024
2327
  const activateParentDrag = (ev: MouseEvent) => {
2025
2328
  ev.preventDefault();
2026
2329
  updateCrossScreenTargetFromBoardPoint(
@@ -2029,9 +2332,26 @@ export function MultiScreenCanvas({
2029
2332
  );
2030
2333
  };
2031
2334
  const handleParentMouseMove = (ev: MouseEvent) => {
2032
- activateParentDrag(ev);
2335
+ ev.preventDefault();
2336
+ // Each drop-guide update is a postMessage round-trip to the target
2337
+ // iframe (see requestCrossScreenDropGuide/runHitTest). Coalesce
2338
+ // rapid mousemove events down to one hit-test per animation frame
2339
+ // instead of firing one per raw event.
2340
+ crossScreenPendingMoveRef.current = {
2341
+ boardPoint: getCanvasPoint(ev.clientX, ev.clientY),
2342
+ sourceScreenId,
2343
+ };
2344
+ if (crossScreenMoveRafRef.current === null) {
2345
+ crossScreenMoveRafRef.current = window.requestAnimationFrame(
2346
+ flushPendingParentDrag,
2347
+ );
2348
+ }
2033
2349
  };
2034
2350
  const handleParentMouseUp = (ev: MouseEvent) => {
2351
+ // Flush synchronously with the true final pointer position on
2352
+ // release — don't wait for a throttled rAF that may not run
2353
+ // before finalizeCrossScreenDrop reads crossScreenTargetRef.
2354
+ cancelPendingParentDrag();
2035
2355
  activateParentDrag(ev);
2036
2356
  const candidate = crossScreenTargetRef.current;
2037
2357
  const payload = crossScreenDragMsgRef.current ?? {
@@ -2050,11 +2370,13 @@ export function MultiScreenCanvas({
2050
2370
  );
2051
2371
  };
2052
2372
  const handleParentWindowBlur = () => {
2373
+ cancelPendingParentDrag();
2053
2374
  clearCrossScreenDrag();
2054
2375
  };
2055
2376
  const cleanup = () => {
2056
2377
  if (didCleanup) return;
2057
2378
  didCleanup = true;
2379
+ cancelPendingParentDrag();
2058
2380
  window.removeEventListener("mousemove", handleParentMouseMove, true);
2059
2381
  window.removeEventListener("mouseup", handleParentMouseUp, true);
2060
2382
  window.removeEventListener("blur", handleParentWindowBlur, true);
@@ -2244,24 +2566,57 @@ export function MultiScreenCanvas({
2244
2566
  surfaceRef.current,
2245
2567
  );
2246
2568
  let lastMouseEvent: MouseEvent | null = null;
2569
+ // rAF-coalesce raw mousemove: a drag/pan/marquee gesture can fire many
2570
+ // mousemove events per frame, but the handler recomputes snap/geometry
2571
+ // and commits React state — doing that per-event (rather than per
2572
+ // frame) is the dominant cost during a drag (see PF15 in perf report).
2573
+ // We keep only the latest event and flush it once per animation frame
2574
+ // (latest-wins). Flushing is forced synchronously before mouseup/blur
2575
+ // so the gesture always ends on the true final pointer position.
2576
+ let pendingMoveFrame: number | null = null;
2577
+ const flushPendingMove = () => {
2578
+ if (pendingMoveFrame !== null) {
2579
+ window.cancelAnimationFrame(pendingMoveFrame);
2580
+ pendingMoveFrame = null;
2581
+ }
2582
+ if (lastMouseEvent) {
2583
+ handleMouseMove(lastMouseEvent);
2584
+ }
2585
+ };
2247
2586
  const move = (ev: MouseEvent) => {
2248
2587
  lastMouseEvent = ev;
2249
2588
  ev.preventDefault();
2250
- handleMouseMove(ev);
2589
+ if (pendingMoveFrame !== null) return;
2590
+ pendingMoveFrame = window.requestAnimationFrame(() => {
2591
+ pendingMoveFrame = null;
2592
+ if (lastMouseEvent) handleMouseMove(lastMouseEvent);
2593
+ });
2251
2594
  };
2252
2595
  const up = (ev: MouseEvent) => {
2253
2596
  lastMouseEvent = ev;
2254
2597
  ev.preventDefault();
2598
+ // Flush any coalesced move first so the final drop position reflects
2599
+ // this exact event, then run the up handler with it.
2600
+ flushPendingMove();
2255
2601
  handleMouseUp(ev);
2256
2602
  };
2257
2603
  const cleanupOnBlur = () => {
2258
2604
  if (handleCancel) {
2605
+ if (pendingMoveFrame !== null) {
2606
+ window.cancelAnimationFrame(pendingMoveFrame);
2607
+ pendingMoveFrame = null;
2608
+ }
2259
2609
  handleCancel();
2260
2610
  return;
2261
2611
  }
2612
+ flushPendingMove();
2262
2613
  handleMouseUp(lastMouseEvent ?? new MouseEvent("mouseup"));
2263
2614
  };
2264
2615
  dragCleanup.current = () => {
2616
+ if (pendingMoveFrame !== null) {
2617
+ window.cancelAnimationFrame(pendingMoveFrame);
2618
+ pendingMoveFrame = null;
2619
+ }
2265
2620
  window.removeEventListener("mousemove", move);
2266
2621
  window.removeEventListener("mouseup", up);
2267
2622
  window.removeEventListener("blur", cleanupOnBlur);
@@ -2277,8 +2632,12 @@ export function MultiScreenCanvas({
2277
2632
 
2278
2633
  const requestSelectableElementInfos = useCallback(
2279
2634
  (screenId: string): Promise<ElementInfo[]> => {
2635
+ const targetScreen = screensRef.current.find((s) => s.id === screenId);
2636
+ const iframeId = targetScreen
2637
+ ? getActiveScreenIframeId(targetScreen)
2638
+ : screenId;
2280
2639
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2281
- `[data-screen-iframe-id="${CSS.escape(screenId)}"]`,
2640
+ `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
2282
2641
  );
2283
2642
  const targetContentWindow = targetIframe?.contentWindow;
2284
2643
  if (!targetContentWindow) return Promise.resolve([]);
@@ -2294,7 +2653,11 @@ export function MultiScreenCanvas({
2294
2653
  if (
2295
2654
  !event.data ||
2296
2655
  event.data.type !== "agent-native:selectable-rects-result" ||
2297
- event.data.correlationId !== correlationId
2656
+ event.data.correlationId !== correlationId ||
2657
+ // Require the reply to actually come from the iframe we asked,
2658
+ // not just any window that happens to observe/guess the
2659
+ // correlationId and reply with a matching payload shape.
2660
+ event.source !== targetContentWindow
2298
2661
  ) {
2299
2662
  return;
2300
2663
  }
@@ -2326,58 +2689,76 @@ export function MultiScreenCanvas({
2326
2689
  [],
2327
2690
  );
2328
2691
 
2329
- const collectLayerMarqueeCandidates = useCallback(async () => {
2330
- const frameCandidates = await Promise.all(
2331
- getCurrentFrameEntries().map(async (entry) => {
2332
- const screen = screensRef.current.find((item) => item.id === entry.id);
2333
- if (!screen) return [] as CanvasLayerMarqueeCandidate[];
2334
- const iframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2335
- `[data-screen-iframe-id="${CSS.escape(entry.id)}"]`,
2336
- );
2337
- const metadata = getResolvedMetadata(screen);
2338
- const viewportWidth = iframe?.clientWidth || metadata.width;
2339
- const viewportHeight = iframe?.clientHeight || metadata.height;
2340
- const scaleX = entry.geometry.width / Math.max(1, viewportWidth);
2341
- const scaleY = entry.geometry.height / Math.max(1, viewportHeight);
2342
- const infos = await requestSelectableElementInfos(entry.id);
2343
- return infos.map((info) => ({
2344
- screenId: entry.id,
2345
- info,
2346
- geometry: {
2347
- x: entry.geometry.x + info.boundingRect.x * scaleX,
2348
- y: entry.geometry.y + info.boundingRect.y * scaleY,
2349
- width: info.boundingRect.width * scaleX,
2350
- height: info.boundingRect.height * scaleY,
2351
- },
2352
- frameGeometry: entry.geometry,
2353
- }));
2354
- }),
2355
- );
2356
- const boardCandidates =
2357
- boardFileId && boardFrameGeometry
2358
- ? await (async () => {
2359
- const infos = await requestSelectableElementInfos(boardFileId);
2360
- return infos.map((info) => ({
2361
- screenId: boardFileId,
2362
- info,
2363
- geometry: {
2364
- x: boardFrameGeometry.x + info.boundingRect.x,
2365
- y: boardFrameGeometry.y + info.boundingRect.y,
2366
- width: info.boundingRect.width,
2367
- height: info.boundingRect.height,
2368
- },
2369
- frameGeometry: boardFrameGeometry,
2370
- }));
2371
- })()
2372
- : [];
2373
- return [...frameCandidates.flat(), ...boardCandidates];
2374
- }, [
2375
- boardFileId,
2376
- boardFrameGeometry,
2377
- getCurrentFrameEntries,
2378
- getResolvedMetadata,
2379
- requestSelectableElementInfos,
2380
- ]);
2692
+ /** Collects marquee-selectable layer candidates. Each screen requires an
2693
+ * async postMessage round-trip into its iframe (requestSelectableElementInfos),
2694
+ * so collecting for every screen on the board unconditionally at marquee
2695
+ * mousedown (PF20) is expensive for boards with many screens — most of
2696
+ * which the marquee rect will never touch. `screenIds`, when given, scopes
2697
+ * collection to just those frame entries (plus the board, which spans the
2698
+ * whole surface so it's included whenever explicitly requested); omit it
2699
+ * to collect every screen. */
2700
+ const collectLayerMarqueeCandidates = useCallback(
2701
+ async (screenIds?: Set<string>) => {
2702
+ const frameEntries = getCurrentFrameEntries().filter(
2703
+ (entry) => !screenIds || screenIds.has(entry.id),
2704
+ );
2705
+ const frameCandidates = await Promise.all(
2706
+ frameEntries.map(async (entry) => {
2707
+ const screen = screensRef.current.find(
2708
+ (item) => item.id === entry.id,
2709
+ );
2710
+ if (!screen) return [] as CanvasLayerMarqueeCandidate[];
2711
+ const iframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2712
+ `[data-screen-iframe-id="${CSS.escape(getActiveScreenIframeId(screen))}"]`,
2713
+ );
2714
+ const metadata = getResolvedMetadata(screen);
2715
+ const viewportWidth = iframe?.clientWidth || metadata.width;
2716
+ const viewportHeight = iframe?.clientHeight || metadata.height;
2717
+ const scaleX = entry.geometry.width / Math.max(1, viewportWidth);
2718
+ const scaleY = entry.geometry.height / Math.max(1, viewportHeight);
2719
+ const infos = await requestSelectableElementInfos(entry.id);
2720
+ return infos.map((info) => ({
2721
+ screenId: entry.id,
2722
+ info,
2723
+ geometry: {
2724
+ x: entry.geometry.x + info.boundingRect.x * scaleX,
2725
+ y: entry.geometry.y + info.boundingRect.y * scaleY,
2726
+ width: info.boundingRect.width * scaleX,
2727
+ height: info.boundingRect.height * scaleY,
2728
+ },
2729
+ frameGeometry: entry.geometry,
2730
+ }));
2731
+ }),
2732
+ );
2733
+ const boardCandidates =
2734
+ boardFileId &&
2735
+ boardFrameGeometry &&
2736
+ (!screenIds || screenIds.has(boardFileId))
2737
+ ? await (async () => {
2738
+ const infos = await requestSelectableElementInfos(boardFileId);
2739
+ return infos.map((info) => ({
2740
+ screenId: boardFileId,
2741
+ info,
2742
+ geometry: {
2743
+ x: boardFrameGeometry.x + info.boundingRect.x,
2744
+ y: boardFrameGeometry.y + info.boundingRect.y,
2745
+ width: info.boundingRect.width,
2746
+ height: info.boundingRect.height,
2747
+ },
2748
+ frameGeometry: boardFrameGeometry,
2749
+ }));
2750
+ })()
2751
+ : [];
2752
+ return [...frameCandidates.flat(), ...boardCandidates];
2753
+ },
2754
+ [
2755
+ boardFileId,
2756
+ boardFrameGeometry,
2757
+ getCurrentFrameEntries,
2758
+ getResolvedMetadata,
2759
+ requestSelectableElementInfos,
2760
+ ],
2761
+ );
2381
2762
 
2382
2763
  const scheduleFeedbackClear = useCallback(() => {
2383
2764
  if (feedbackTimerRef.current !== null) {
@@ -2416,11 +2797,20 @@ export function MultiScreenCanvas({
2416
2797
  window.innerHeight - TRANSFORM_BADGE_EDGE_PADDING
2417
2798
  ? clientY + TRANSFORM_BADGE_OFFSET
2418
2799
  : clientY - TRANSFORM_BADGE_HEIGHT - TRANSFORM_BADGE_OFFSET;
2419
- setTransformBadge({
2420
- text,
2421
- x: clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX),
2422
- y: clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY),
2423
- });
2800
+ const nextX = clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX);
2801
+ const nextY = clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY);
2802
+ // Equality-bail: called from the rAF-coalesced mousemove handler, so
2803
+ // this runs at most once per frame already, but a steady drag (e.g.
2804
+ // pinned against a snap axis) can still repeat the identical badge
2805
+ // text/position — skip the setState in that case (PF15).
2806
+ setTransformBadge((current) =>
2807
+ current &&
2808
+ current.text === text &&
2809
+ current.x === nextX &&
2810
+ current.y === nextY
2811
+ ? current
2812
+ : { text, x: nextX, y: nextY },
2813
+ );
2424
2814
  },
2425
2815
  [],
2426
2816
  );
@@ -2508,6 +2898,7 @@ export function MultiScreenCanvas({
2508
2898
  setMarquee(null);
2509
2899
  setCreationPreview(null);
2510
2900
  setAlignmentGuides([]);
2901
+ setEqualGapGuides([]);
2511
2902
  setTransformBadge(null);
2512
2903
  setDragCursor(null);
2513
2904
  primitiveDropTargetRef.current = null;
@@ -2525,7 +2916,11 @@ export function MultiScreenCanvas({
2525
2916
 
2526
2917
  if (state) {
2527
2918
  cancelled = true;
2528
- if (state.type === "move" || state.type === "resize") {
2919
+ if (
2920
+ state.type === "move" ||
2921
+ state.type === "resize" ||
2922
+ state.type === "group-rotate"
2923
+ ) {
2529
2924
  updateFrameGeometry((current) =>
2530
2925
  frameGeometryWithOverrides(current, state.originFrames),
2531
2926
  );
@@ -2608,10 +3003,25 @@ export function MultiScreenCanvas({
2608
3003
  y: state.originPan.y + ev.clientY - state.originClient.y,
2609
3004
  };
2610
3005
  panRef.current = nextPan;
2611
- setPan(nextPan);
3006
+ // Mirror the wheel/pinch path (applyViewToDom): mutate the transform
3007
+ // directly during the gesture and only reconcile React state once the
3008
+ // gesture settles, so a mouse-pan produces zero re-renders per move.
3009
+ applyViewToDomRef.current();
3010
+ scheduleViewCommitRef.current();
2612
3011
  };
2613
3012
 
2614
- installDragListeners(handleMouseMove, finishDrag);
3013
+ const handlePanEnd = () => {
3014
+ // Ensure React state reflects the true final pan immediately on
3015
+ // release rather than waiting for the debounced commit timer.
3016
+ setPan(panRef.current);
3017
+ // P18: a middle-mouse-button pan while a pen path is active also
3018
+ // moves the canvas-space mapping the ghost preview was computed
3019
+ // from — resync it now that pan has settled.
3020
+ recomputePenPointerForViewChangeRef.current();
3021
+ finishDrag();
3022
+ };
3023
+
3024
+ installDragListeners(handleMouseMove, handlePanEnd);
2615
3025
  },
2616
3026
  [finishDrag, installDragListeners],
2617
3027
  );
@@ -2623,14 +3033,25 @@ export function MultiScreenCanvas({
2623
3033
  const originCanvas = getCanvasPoint(e.clientX, e.clientY);
2624
3034
  let latestRect = normalizeRectFromPoints(originCanvas, originCanvas);
2625
3035
  let layerCandidates: CanvasLayerMarqueeCandidate[] = [];
3036
+ // PF20: collecting selectable layer info for every screen on the board
3037
+ // requires one async postMessage round-trip per iframe. Doing that
3038
+ // eagerly for the whole board on marquee mousedown is wasted work for
3039
+ // any screen the marquee rect never reaches. Instead, lazily collect
3040
+ // only screens the rect currently intersects, growing the collected
3041
+ // set incrementally as the drag expands the rect. `collectingScreenIds`
3042
+ // guards against re-requesting a screen whose collection is already
3043
+ // in flight or done.
3044
+ const collectedScreenIds = new Set<string>();
3045
+ const collectingScreenIds = new Set<string>();
2626
3046
  const reportLayerSelection = (rect: MarqueeRect) => {
2627
3047
  const state = dragState.current;
2628
3048
  if (!state || state.type !== "marquee") return;
3049
+ const chromeScale = chromeScaleFromZoom(zoomRef.current);
2629
3050
  const selection = layerCandidates
2630
3051
  .filter((candidate) =>
2631
3052
  rotatedRectIntersects(
2632
3053
  rect,
2633
- getSelectableBounds(candidate.geometry),
3054
+ getSelectableBounds(candidate.geometry, chromeScale),
2634
3055
  getFrameCenter(candidate.frameGeometry),
2635
3056
  candidate.frameGeometry.rotation ?? 0,
2636
3057
  ),
@@ -2645,6 +3066,34 @@ export function MultiScreenCanvas({
2645
3066
  shiftKey: state.additive,
2646
3067
  });
2647
3068
  };
3069
+ const collectForIntersectedScreens = (hitIds: string[]) => {
3070
+ const newIds = hitIds.filter(
3071
+ (id) => !collectedScreenIds.has(id) && !collectingScreenIds.has(id),
3072
+ );
3073
+ // The board spans the whole surface, so include it once anything
3074
+ // intersects (or immediately, for the initial zero-size rect) rather
3075
+ // than trying to hit-test its own — usually oversized — geometry.
3076
+ if (
3077
+ boardFileId &&
3078
+ boardFrameGeometry &&
3079
+ !collectedScreenIds.has(boardFileId) &&
3080
+ !collectingScreenIds.has(boardFileId)
3081
+ ) {
3082
+ newIds.push(boardFileId);
3083
+ }
3084
+ if (newIds.length === 0) return;
3085
+ const requestIds = new Set(newIds);
3086
+ newIds.forEach((id) => collectingScreenIds.add(id));
3087
+ void collectLayerMarqueeCandidates(requestIds).then((candidates) => {
3088
+ newIds.forEach((id) => {
3089
+ collectingScreenIds.delete(id);
3090
+ collectedScreenIds.add(id);
3091
+ });
3092
+ if (dragState.current?.type !== "marquee") return;
3093
+ layerCandidates = [...layerCandidates, ...candidates];
3094
+ reportLayerSelection(latestRect);
3095
+ });
3096
+ };
2648
3097
  dragState.current = {
2649
3098
  type: "marquee",
2650
3099
  originClient: { x: e.clientX, y: e.clientY },
@@ -2665,11 +3114,10 @@ export function MultiScreenCanvas({
2665
3114
  });
2666
3115
  }
2667
3116
  setIsDragging(true);
2668
- void collectLayerMarqueeCandidates().then((candidates) => {
2669
- if (dragState.current?.type !== "marquee") return;
2670
- layerCandidates = candidates;
2671
- reportLayerSelection(latestRect);
2672
- });
3117
+ // Seed collection with whatever the zero-size origin rect already
3118
+ // touches (typically just the board, if present) so a click-without-
3119
+ // drag still reports a correct (likely empty) selection on mouseup.
3120
+ collectForIntersectedScreens([]);
2673
3121
 
2674
3122
  const handleMouseMove = (ev: MouseEvent) => {
2675
3123
  const state = dragState.current;
@@ -2688,11 +3136,12 @@ export function MultiScreenCanvas({
2688
3136
  }
2689
3137
  setMarquee(rect);
2690
3138
 
3139
+ const chromeScale = chromeScaleFromZoom(zoomRef.current);
2691
3140
  const hitIds = getCurrentFrameEntries()
2692
3141
  .filter((entry) =>
2693
3142
  rotatedRectIntersects(
2694
3143
  rect,
2695
- getSelectableBounds(entry.geometry),
3144
+ getSelectableBounds(entry.geometry, chromeScale),
2696
3145
  getFrameCenter(entry.geometry),
2697
3146
  entry.geometry.rotation ?? 0,
2698
3147
  ),
@@ -2702,21 +3151,23 @@ export function MultiScreenCanvas({
2702
3151
  .filter((entry) =>
2703
3152
  rotatedRectIntersects(
2704
3153
  rect,
2705
- getSelectableBounds(entry.geometry),
3154
+ getSelectableBounds(entry.geometry, chromeScale),
2706
3155
  getFrameCenter(entry.geometry),
2707
3156
  entry.geometry.rotation ?? 0,
2708
3157
  ),
2709
3158
  )
2710
3159
  .map((entry) => entry.id);
2711
3160
 
3161
+ collectForIntersectedScreens(hitIds);
3162
+
2712
3163
  updateSelectedIds(() =>
2713
3164
  state.additive
2714
- ? dedupeIds([...state.baseSelectedIds, ...hitIds])
3165
+ ? xorMarqueeSelection(state.baseSelectedIds, hitIds)
2715
3166
  : hitIds,
2716
3167
  );
2717
3168
  updateSelectedDraftIds(() =>
2718
3169
  state.additive
2719
- ? dedupeIds([...state.baseSelectedDraftIds, ...hitDraftIds])
3170
+ ? xorMarqueeSelection(state.baseSelectedDraftIds, hitDraftIds)
2720
3171
  : hitDraftIds,
2721
3172
  );
2722
3173
  reportLayerSelection(rect);
@@ -2739,6 +3190,8 @@ export function MultiScreenCanvas({
2739
3190
  installDragListeners(handleMouseMove, handleMouseUp);
2740
3191
  },
2741
3192
  [
3193
+ boardFileId,
3194
+ boardFrameGeometry,
2742
3195
  collectLayerMarqueeCandidates,
2743
3196
  finishDrag,
2744
3197
  getCanvasPoint,
@@ -3008,15 +3461,24 @@ export function MultiScreenCanvas({
3008
3461
  ) => {
3009
3462
  const rawPoint = getCanvasPoint(clientX, clientY);
3010
3463
  const lastAnchor = path?.nodes[path.nodes.length - 1]?.point;
3011
- return shiftKey && lastAnchor
3012
- ? constrainPointTo45Degrees(lastAnchor, rawPoint)
3013
- : rawPoint;
3464
+ const constrainedPoint =
3465
+ shiftKey && lastAnchor
3466
+ ? constrainPointTo45Degrees(lastAnchor, rawPoint)
3467
+ : rawPoint;
3468
+ // Light anchor snapping (P15): snap onto an existing anchor of the
3469
+ // path being drawn (so you can precisely re-hit a prior point), else
3470
+ // round to integer canvas px once zoomed to 100% or more.
3471
+ return snapPenAnchorPoint(constrainedPoint, path, {
3472
+ hitRadius: PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
3473
+ zoom: zoomRef.current,
3474
+ });
3014
3475
  },
3015
3476
  [getCanvasPoint],
3016
3477
  );
3017
3478
 
3018
3479
  const updatePenPointer = useCallback(
3019
3480
  (clientX: number, clientY: number, shiftKey: boolean) => {
3481
+ lastPenClientPointRef.current = { clientX, clientY, shiftKey };
3020
3482
  const path = activePenPathRef.current;
3021
3483
  if (!path || path.closed) {
3022
3484
  setPenPointer(null);
@@ -3040,35 +3502,54 @@ export function MultiScreenCanvas({
3040
3502
  [getCanvasPoint, getPenAnchorPoint],
3041
3503
  );
3042
3504
 
3505
+ // P18: a wheel pan/zoom gesture moves pan/zoom every animation frame
3506
+ // (applyViewToDom, mutated imperatively — see its comment) without any
3507
+ // mousemove event firing, so the pen ghost/ close-hover preview — derived
3508
+ // from screen->canvas conversion of the last known client point — goes
3509
+ // stale and visibly detaches from the cursor mid-gesture. Recompute it
3510
+ // from the remembered client point whenever pan/zoom changes.
3511
+ const recomputePenPointerForViewChange = useCallback(() => {
3512
+ const last = lastPenClientPointRef.current;
3513
+ if (!last || !activePenPathRef.current) return;
3514
+ updatePenPointer(last.clientX, last.clientY, last.shiftKey);
3515
+ }, [updatePenPointer]);
3516
+ recomputePenPointerForViewChangeRef.current =
3517
+ recomputePenPointerForViewChange;
3518
+
3043
3519
  const beginPenNodeCreation = useCallback(
3044
3520
  (e: React.MouseEvent) => {
3045
3521
  if (e.button !== 0) return;
3046
3522
  e.preventDefault();
3047
3523
  e.stopPropagation();
3524
+ // Double (or further) click ends the path instead of adding another
3525
+ // duplicate coincident anchor at the same point (P7).
3526
+ if (e.detail > 1) {
3527
+ finishPenPath();
3528
+ return;
3529
+ }
3048
3530
  suppressNextPick.current = true;
3049
3531
 
3050
3532
  const pathBefore = activePenPathRef.current?.closed
3051
3533
  ? null
3052
3534
  : activePenPathRef.current;
3053
3535
  const rawPoint = getCanvasPoint(e.clientX, e.clientY);
3054
- if (
3536
+ const closing = Boolean(
3055
3537
  pathBefore &&
3056
3538
  isPenCloseTarget(
3057
3539
  pathBefore,
3058
3540
  rawPoint,
3059
3541
  PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
3060
- )
3061
- ) {
3062
- finishPenPath(closePenPath(pathBefore));
3063
- return;
3064
- }
3065
-
3066
- const anchor = getPenAnchorPoint(
3067
- e.clientX,
3068
- e.clientY,
3069
- e.shiftKey,
3070
- pathBefore,
3542
+ ),
3071
3543
  );
3544
+
3545
+ // Figma defers the close commit to mouseup rather than closing
3546
+ // instantly on mousedown, so a drag on the closing click can shape
3547
+ // the closing segment's curve. Anchor the drag at the path's first
3548
+ // point (rather than the raw cursor position) so a click-only close
3549
+ // (no drag) still closes exactly on the start anchor.
3550
+ const anchor = closing
3551
+ ? pathBefore!.nodes[0].point
3552
+ : getPenAnchorPoint(e.clientX, e.clientY, e.shiftKey, pathBefore);
3072
3553
  const pathSnapshot = pathBefore ? clonePenPath(pathBefore) : null;
3073
3554
  dragState.current = {
3074
3555
  type: "pen-node",
@@ -3076,13 +3557,18 @@ export function MultiScreenCanvas({
3076
3557
  anchor,
3077
3558
  pathBefore: pathSnapshot,
3078
3559
  hasMoved: false,
3560
+ closing,
3079
3561
  };
3080
- const initialPath = appendPenNode(pathSnapshot, createCornerNode(anchor));
3562
+ const initialPath = closing
3563
+ ? (pathSnapshot as PenPath)
3564
+ : appendPenNode(pathSnapshot, createCornerNode(anchor));
3081
3565
  activePenPathRef.current = initialPath;
3082
3566
  setActivePenPath(initialPath);
3083
- setPenGesturePreview(initialPath);
3567
+ setPenGesturePreview(
3568
+ closing ? closePenPath(pathSnapshot as PenPath) : initialPath,
3569
+ );
3084
3570
  setPenPointer(null);
3085
- setPenCloseHover(false);
3571
+ setPenCloseHover(closing);
3086
3572
  setIsDragging(true);
3087
3573
  setDragCursor("crosshair");
3088
3574
 
@@ -3100,13 +3586,31 @@ export function MultiScreenCanvas({
3100
3586
  }
3101
3587
 
3102
3588
  const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
3589
+ const handleOut = ev.shiftKey
3590
+ ? constrainPointTo45Degrees(state.anchor, handlePoint)
3591
+ : handlePoint;
3592
+
3593
+ if (state.closing) {
3594
+ // Shape the closing segment: drag the first anchor's handleIn
3595
+ // (mirrored across the anchor from the drag point, matching how
3596
+ // every other smooth anchor's handles work) without appending a
3597
+ // new node — the path is still just previewed as closed.
3598
+ const closedPreviewPath = shapeClosingHandles(
3599
+ state.pathBefore as PenPath,
3600
+ state.hasMoved ? handleOut : null,
3601
+ );
3602
+ setPenGesturePreview(closedPreviewPath);
3603
+ return;
3604
+ }
3605
+
3103
3606
  const node = state.hasMoved
3104
- ? createSmoothNode(
3105
- state.anchor,
3106
- ev.shiftKey
3107
- ? constrainPointTo45Degrees(state.anchor, handlePoint)
3108
- : handlePoint,
3109
- )
3607
+ ? createSmoothNode(state.anchor, handleOut, {
3608
+ // Alt/Option while dragging a new anchor's handle breaks
3609
+ // symmetry into a cusp (P8): read the live event's altKey on
3610
+ // every move so toggling Alt mid-drag updates immediately,
3611
+ // rather than latching whatever it was when the drag started.
3612
+ breakSymmetry: ev.altKey,
3613
+ })
3110
3614
  : createCornerNode(state.anchor);
3111
3615
  const nextPath = appendPenNode(state.pathBefore, node);
3112
3616
  activePenPathRef.current = nextPath;
@@ -3122,13 +3626,27 @@ export function MultiScreenCanvas({
3122
3626
  }
3123
3627
 
3124
3628
  const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
3629
+ const handleOut = ev.shiftKey
3630
+ ? constrainPointTo45Degrees(state.anchor, handlePoint)
3631
+ : handlePoint;
3632
+
3633
+ if (state.closing) {
3634
+ const closedPath = shapeClosingHandles(
3635
+ state.pathBefore as PenPath,
3636
+ state.hasMoved ? handleOut : null,
3637
+ );
3638
+ setPenGesturePreview(null);
3639
+ setPenPointer(null);
3640
+ setPenCloseHover(false);
3641
+ finishPenPath(closedPath);
3642
+ finishDrag();
3643
+ return;
3644
+ }
3645
+
3125
3646
  const node: PenNode = state.hasMoved
3126
- ? createSmoothNode(
3127
- state.anchor,
3128
- ev.shiftKey
3129
- ? constrainPointTo45Degrees(state.anchor, handlePoint)
3130
- : handlePoint,
3131
- )
3647
+ ? createSmoothNode(state.anchor, handleOut, {
3648
+ breakSymmetry: ev.altKey,
3649
+ })
3132
3650
  : createCornerNode(state.anchor);
3133
3651
  const nextPath = appendPenNode(state.pathBefore, node);
3134
3652
  activePenPathRef.current = nextPath;
@@ -3179,14 +3697,12 @@ export function MultiScreenCanvas({
3179
3697
  false,
3180
3698
  );
3181
3699
  const initialPoints =
3182
- tool === "pen"
3183
- ? [originCanvas]
3184
- : tool === "line" || tool === "arrow"
3185
- ? [
3186
- originCanvas,
3187
- { x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
3188
- ]
3189
- : undefined;
3700
+ tool === "line" || tool === "arrow"
3701
+ ? [
3702
+ originCanvas,
3703
+ { x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
3704
+ ]
3705
+ : undefined;
3190
3706
  dragState.current = {
3191
3707
  type: "draft-create",
3192
3708
  tool,
@@ -3202,7 +3718,7 @@ export function MultiScreenCanvas({
3202
3718
  points: initialPoints,
3203
3719
  });
3204
3720
  setIsDragging(true);
3205
- setDragCursor(tool === "pen" ? "crosshair" : "crosshair");
3721
+ setDragCursor("crosshair");
3206
3722
 
3207
3723
  const handleMouseMove = (ev: MouseEvent) => {
3208
3724
  const state = dragState.current;
@@ -3218,22 +3734,15 @@ export function MultiScreenCanvas({
3218
3734
  state.hasMoved = true;
3219
3735
  }
3220
3736
 
3221
- if (state.tool === "pen") {
3222
- const minDistance =
3223
- PEN_SAMPLE_DISTANCE_SCREEN_PX / (zoomRef.current / 100);
3224
- state.points = appendPolylinePoint(
3225
- state.points,
3226
- nextCanvas,
3227
- minDistance,
3228
- );
3229
- setCreationPreview({
3230
- tool,
3231
- geometry: getPathGeometry(state.points),
3232
- points: state.points,
3233
- });
3234
- return;
3235
- }
3236
-
3737
+ const modifiers: DraftGeometryModifiers = {
3738
+ shiftKey: ev.shiftKey,
3739
+ altKey: ev.altKey,
3740
+ };
3741
+ const isLineTool = state.tool === "line" || state.tool === "arrow";
3742
+ const previewEnd =
3743
+ isLineTool && ev.shiftKey
3744
+ ? constrainPointTo45Degrees(state.originCanvas, nextCanvas)
3745
+ : nextCanvas;
3237
3746
  setCreationPreview({
3238
3747
  tool,
3239
3748
  geometry: getDraftPreviewGeometryForTool(
@@ -3241,11 +3750,9 @@ export function MultiScreenCanvas({
3241
3750
  state.originCanvas,
3242
3751
  nextCanvas,
3243
3752
  state.hasMoved,
3753
+ modifiers,
3244
3754
  ),
3245
- points:
3246
- state.tool === "line" || state.tool === "arrow"
3247
- ? [state.originCanvas, nextCanvas]
3248
- : undefined,
3755
+ points: isLineTool ? [state.originCanvas, previewEnd] : undefined,
3249
3756
  });
3250
3757
  };
3251
3758
 
@@ -3256,7 +3763,7 @@ export function MultiScreenCanvas({
3256
3763
  return;
3257
3764
  }
3258
3765
 
3259
- let endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
3766
+ const endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
3260
3767
  const canvasMoved =
3261
3768
  Math.hypot(
3262
3769
  endCanvas.x - state.originCanvas.x,
@@ -3270,18 +3777,18 @@ export function MultiScreenCanvas({
3270
3777
  ) >= DRAG_THRESHOLD ||
3271
3778
  canvasMoved;
3272
3779
  state.hasMoved = releaseMoved;
3273
- let points = state.tool === "pen" ? state.points : undefined;
3274
- if (state.tool === "pen") {
3275
- points = appendPolylinePoint(state.points, endCanvas, 0);
3276
- if (!releaseMoved || points.length < 2) {
3277
- finishDrag();
3278
- return;
3279
- }
3280
- endCanvas = points[points.length - 1] ?? endCanvas;
3281
- }
3780
+ const modifiers: DraftGeometryModifiers = {
3781
+ shiftKey: ev.shiftKey,
3782
+ altKey: ev.altKey,
3783
+ };
3282
3784
  if (state.tool === "frame" && onCreateScreenFrame) {
3283
3785
  onCreateScreenFrame(
3284
- getDraftGeometryForTool(state.tool, state.originCanvas, endCanvas),
3786
+ getDraftGeometryForTool(
3787
+ state.tool,
3788
+ state.originCanvas,
3789
+ endCanvas,
3790
+ modifiers,
3791
+ ),
3285
3792
  );
3286
3793
  if (activeTool === undefined) {
3287
3794
  setLocalActiveTool("move");
@@ -3294,22 +3801,15 @@ export function MultiScreenCanvas({
3294
3801
  tool: state.tool,
3295
3802
  start: state.originCanvas,
3296
3803
  end: endCanvas,
3297
- points,
3298
3804
  moved: releaseMoved,
3299
3805
  toolProps,
3806
+ modifiers,
3300
3807
  });
3301
3808
  commitDraftPrimitive(nextDraft, state.originFrameId);
3302
3809
  if (activeTool === undefined) {
3303
3810
  setLocalActiveTool("move");
3304
3811
  }
3305
- if (state.tool === "pen") {
3306
- if (activeTool === undefined) {
3307
- setLocalActiveTool("pen");
3308
- }
3309
- onActiveToolChange?.("pen");
3310
- } else {
3311
- onActiveToolChange?.("move");
3312
- }
3812
+ onActiveToolChange?.("move");
3313
3813
  finishDrag();
3314
3814
  };
3315
3815
 
@@ -3705,8 +4205,8 @@ export function MultiScreenCanvas({
3705
4205
  const state = dragState.current;
3706
4206
  if (!state || state.type !== "move") return;
3707
4207
  const scale = zoomRef.current / 100;
3708
- const dx = (ev.clientX - state.originClient.x) / scale;
3709
- const dy = (ev.clientY - state.originClient.y) / scale;
4208
+ let dx = (ev.clientX - state.originClient.x) / scale;
4209
+ let dy = (ev.clientY - state.originClient.y) / scale;
3710
4210
  if (
3711
4211
  !state.hasMoved &&
3712
4212
  Math.hypot(
@@ -3717,6 +4217,26 @@ export function MultiScreenCanvas({
3717
4217
  state.hasMoved = true;
3718
4218
  }
3719
4219
 
4220
+ // Never commit a live transform before the drag threshold is crossed:
4221
+ // otherwise 1-2px of click jitter nudges the frame, that nudge is
4222
+ // never reverted (mouseup below only restores origin when the whole
4223
+ // gesture never moved past threshold — see below), and the next drag
4224
+ // reads its origin from the already-nudged geometry.
4225
+ if (!state.hasMoved) return;
4226
+
4227
+ // Shift held mid-move (not at mousedown — that path is shift-click
4228
+ // multi-select and never reaches here, see the guard above) locks
4229
+ // movement to a single axis, matching Figma. Zero the smaller-
4230
+ // magnitude axis before snapping so snap candidates on the locked
4231
+ // axis can't reintroduce drift on it.
4232
+ if (ev.shiftKey) {
4233
+ if (Math.abs(dx) >= Math.abs(dy)) {
4234
+ dy = 0;
4235
+ } else {
4236
+ dx = 0;
4237
+ }
4238
+ }
4239
+
3720
4240
  const movingEntries = state.targetIds.map((targetId) => ({
3721
4241
  id: targetId,
3722
4242
  geometry: {
@@ -3748,6 +4268,51 @@ export function MultiScreenCanvas({
3748
4268
  });
3749
4269
  setAlignmentGuides(snap.guides);
3750
4270
 
4271
+ // Smart-spacing guides (CV11) — only meaningful for a single moving
4272
+ // frame (matches Figma, which only shows equal-gap guides while
4273
+ // dragging one object, not a multi-select group).
4274
+ if (state.targetIds.length === 1) {
4275
+ const primaryId = state.targetIds[0];
4276
+ const movedGeometry = movingEntries.find(
4277
+ (entry) => entry.id === primaryId,
4278
+ )?.geometry;
4279
+ if (movedGeometry) {
4280
+ // Same screen-px-to-canvas-px conversion computeMoveSnap uses
4281
+ // for its own threshold, so the equal-gap tolerance also stays
4282
+ // a constant few screen pixels regardless of zoom level.
4283
+ const tolerance =
4284
+ EQUAL_GAP_TOLERANCE_SCREEN_PX /
4285
+ Math.max(0.01, zoomRef.current / 100);
4286
+ setEqualGapGuides(
4287
+ computeEqualGapGuides(
4288
+ {
4289
+ ...movedGeometry,
4290
+ x: movedGeometry.x + snap.dx,
4291
+ y: movedGeometry.y + snap.dy,
4292
+ },
4293
+ stationaryEntries,
4294
+ { toleranceCanvasPx: tolerance },
4295
+ ),
4296
+ );
4297
+ }
4298
+ } else {
4299
+ setEqualGapGuides([]);
4300
+ }
4301
+
4302
+ // Resize shows a W x H badge and rotate shows a degrees badge — move
4303
+ // was the one transform with no live feedback at all. Show the
4304
+ // primary frame's new (rounded) position, matching resize/rotate's
4305
+ // convention of displaying the current absolute value rather than a
4306
+ // delta.
4307
+ const primaryOrigin = state.originFrames[state.primaryId];
4308
+ if (primaryOrigin) {
4309
+ showTransformFeedback(
4310
+ `${Math.round(primaryOrigin.x + dx + snap.dx)}, ${Math.round(primaryOrigin.y + dy + snap.dy)}`,
4311
+ ev.clientX,
4312
+ ev.clientY,
4313
+ );
4314
+ }
4315
+
3751
4316
  // When all dragged ids are committed primitive nodeIds (not screen
3752
4317
  // frames), check for a container primitive drop target to highlight.
3753
4318
  const currentFrameIds = Object.keys(frameGeometryRef.current);
@@ -3765,6 +4330,15 @@ export function MultiScreenCanvas({
3765
4330
  const handleMouseUp = () => {
3766
4331
  const state = dragState.current;
3767
4332
  const dropTarget = primitiveDropTargetRef.current;
4333
+ if (state?.type === "move" && !state.hasMoved) {
4334
+ // Belt-and-braces: the live transform above already skips committing
4335
+ // until hasMoved, but restore origin here too in case any geometry
4336
+ // slipped through (e.g. a future code path that writes frameGeometry
4337
+ // directly) so a below-threshold click never leaves a phantom nudge.
4338
+ updateFrameGeometry((current) =>
4339
+ frameGeometryWithOverrides(current, state.originFrames),
4340
+ );
4341
+ }
3768
4342
  if (state?.type === "move" && state.hasMoved) {
3769
4343
  // If all dragged ids are committed primitive nodeIds (not screen
3770
4344
  // frames), attempt a primitive reparent on drop.
@@ -3810,6 +4384,7 @@ export function MultiScreenCanvas({
3810
4384
  installDragListeners,
3811
4385
  onPick,
3812
4386
  resolvePrimitiveScreenId,
4387
+ showTransformFeedback,
3813
4388
  updateFrameGeometry,
3814
4389
  updatePrimitiveDropTarget,
3815
4390
  updateSelectedDraftIds,
@@ -3851,7 +4426,19 @@ export function MultiScreenCanvas({
3851
4426
  hasMoved: false,
3852
4427
  };
3853
4428
  setIsDragging(true);
3854
- setDragCursor(getResizeCursor(handle));
4429
+ // Rotation-aware cursor: a static per-handle cursor is only correct
4430
+ // when the frame isn't rotated. For a single selected (possibly
4431
+ // rotated) frame, quantize the handle's rotated visual angle to the
4432
+ // nearest 45deg to pick the matching cursor; group resizes keep the
4433
+ // unrotated cursor, matching the group's own unrotated resize math.
4434
+ setDragCursor(
4435
+ originEntries.length === 1
4436
+ ? getResizeCursorForHandle(
4437
+ handle,
4438
+ originEntries[0].geometry.rotation ?? 0,
4439
+ )
4440
+ : getResizeCursor(handle),
4441
+ );
3855
4442
 
3856
4443
  const handleMouseMove = (ev: MouseEvent) => {
3857
4444
  const state = dragState.current;
@@ -3869,10 +4456,58 @@ export function MultiScreenCanvas({
3869
4456
  state.hasMoved = true;
3870
4457
  }
3871
4458
 
4459
+ // Skip committing any transform until the drag threshold is crossed —
4460
+ // see the matching comment in beginFrameDrag's move handler.
4461
+ if (!state.hasMoved) return;
4462
+
3872
4463
  const originEntries = state.targetIds.map((targetId) => ({
3873
4464
  id: targetId,
3874
4465
  geometry: state.originFrames[targetId],
3875
4466
  }));
4467
+
4468
+ // A single rotated frame needs rotation-aware resize math: the handle
4469
+ // follows the frame's own rotated axes (matching how the handles
4470
+ // render, rotated with the frame) and the opposite anchor edge/corner
4471
+ // stays fixed in WORLD space, not just in the unrotated local frame.
4472
+ // Multi-select group resize with rotated members keeps the prior
4473
+ // (unrotated-bounds) behavior — extending this to groups would need
4474
+ // per-member rotation handling around a shared group anchor, which is
4475
+ // a larger change than this fix covers.
4476
+ const singleRotatedFrame =
4477
+ originEntries.length === 1 &&
4478
+ (originEntries[0].geometry.rotation ?? 0)
4479
+ ? originEntries[0]
4480
+ : null;
4481
+
4482
+ if (singleRotatedFrame) {
4483
+ const resizedGeometry = resizeRotatedFrameFromDelta(
4484
+ singleRotatedFrame.geometry,
4485
+ state.handle,
4486
+ dx,
4487
+ dy,
4488
+ {
4489
+ preserveAspectRatio: ev.shiftKey,
4490
+ resizeFromCenter: ev.altKey,
4491
+ minWidth: 1,
4492
+ minHeight: 1,
4493
+ },
4494
+ );
4495
+ updateFrameGeometry((current) => ({
4496
+ ...current,
4497
+ [singleRotatedFrame.id]: {
4498
+ ...state.originFrames[singleRotatedFrame.id],
4499
+ ...resizedGeometry,
4500
+ },
4501
+ }));
4502
+ setAlignmentGuides([]);
4503
+ showTransformFeedback(
4504
+ `${Math.round(resizedGeometry.width)} x ${Math.round(resizedGeometry.height)}`,
4505
+ ev.clientX,
4506
+ ev.clientY,
4507
+ );
4508
+ return;
4509
+ }
4510
+
3876
4511
  const resized = resizeFrameGroupFromDelta(
3877
4512
  originEntries,
3878
4513
  state.originBounds,
@@ -3896,6 +4531,11 @@ export function MultiScreenCanvas({
3896
4531
  thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
3897
4532
  zoom: zoomRef.current,
3898
4533
  bypass: ev.metaKey || ev.ctrlKey,
4534
+ // Snapping x and y independently can each pull toward a
4535
+ // different sibling edge, which would distort a shift-held
4536
+ // (aspect-locked) resize away from its ratio — see
4537
+ // computeAspectPreservingResizeSnap in canvas-math.ts.
4538
+ preserveAspectRatio: ev.shiftKey,
3899
4539
  },
3900
4540
  );
3901
4541
  const resizedEntries = resizeFrameGroupToBounds(
@@ -3923,6 +4563,12 @@ export function MultiScreenCanvas({
3923
4563
 
3924
4564
  const handleMouseUp = () => {
3925
4565
  const state = dragState.current;
4566
+ if (state?.type === "resize" && !state.hasMoved) {
4567
+ // Belt-and-braces restore, matching the move handler.
4568
+ updateFrameGeometry((current) =>
4569
+ frameGeometryWithOverrides(current, state.originFrames),
4570
+ );
4571
+ }
3926
4572
  if (state?.type === "resize" && state.hasMoved) {
3927
4573
  const after = cloneFrameGeometryById(frameGeometryRef.current);
3928
4574
  onGeometryCommitRef.current?.(
@@ -3999,6 +4645,10 @@ export function MultiScreenCanvas({
3999
4645
  ) {
4000
4646
  state.hasMoved = true;
4001
4647
  }
4648
+ // Skip committing any transform until the drag threshold is crossed —
4649
+ // see the matching comment in beginFrameDrag's move handler.
4650
+ if (!state.hasMoved) return;
4651
+
4002
4652
  const pointer = getCanvasPoint(ev.clientX, ev.clientY);
4003
4653
  const center = getFrameCenter(state.originFrame);
4004
4654
  const raw =
@@ -4022,6 +4672,13 @@ export function MultiScreenCanvas({
4022
4672
 
4023
4673
  const handleMouseUp = () => {
4024
4674
  const state = dragState.current;
4675
+ if (state?.type === "rotate" && !state.hasMoved) {
4676
+ // Belt-and-braces restore, matching the move handler.
4677
+ updateFrameGeometry((current) => ({
4678
+ ...current,
4679
+ [state.frameId]: { ...state.originFrame },
4680
+ }));
4681
+ }
4025
4682
  if (state?.type === "rotate" && state.hasMoved) {
4026
4683
  const after = cloneFrameGeometryById(frameGeometryRef.current);
4027
4684
  onGeometryCommitRef.current?.(
@@ -4050,6 +4707,119 @@ export function MultiScreenCanvas({
4050
4707
  ],
4051
4708
  );
4052
4709
 
4710
+ // Multi-selection rotate (CV14): rotates every currently-selected frame
4711
+ // together around the group's own center. Kept entirely separate from
4712
+ // beginRotate above — single-frame rotate is unaffected.
4713
+ const beginGroupRotate = useCallback(
4714
+ (e: React.MouseEvent) => {
4715
+ if (e.button !== 0) return;
4716
+ e.preventDefault();
4717
+ e.stopPropagation();
4718
+ suppressNextPick.current = true;
4719
+
4720
+ const targetIds = selectedIdsRef.current;
4721
+ if (targetIds.length < 2) return;
4722
+ const originEntries = getCurrentFrameEntries().filter((entry) =>
4723
+ targetIds.includes(entry.id),
4724
+ );
4725
+ if (originEntries.length < 2) return;
4726
+ const groupBounds = getFrameGroupBounds(originEntries);
4727
+ if (!groupBounds) return;
4728
+ const groupCenter = { x: groupBounds.centerX, y: groupBounds.centerY };
4729
+
4730
+ const pointer = getCanvasPoint(e.clientX, e.clientY);
4731
+ dragState.current = {
4732
+ type: "group-rotate",
4733
+ originClient: { x: e.clientX, y: e.clientY },
4734
+ originFrames: Object.fromEntries(
4735
+ originEntries.map((entry) => [entry.id, entry.geometry]),
4736
+ ) as FrameGeometryById,
4737
+ targetIds: originEntries.map((entry) => entry.id),
4738
+ groupCenter,
4739
+ originPointerAngle: angleBetween(groupCenter, pointer),
4740
+ hasMoved: false,
4741
+ };
4742
+ setIsDragging(true);
4743
+ setDragCursor("grabbing");
4744
+
4745
+ const handleMouseMove = (ev: MouseEvent) => {
4746
+ const state = dragState.current;
4747
+ if (!state || state.type !== "group-rotate") return;
4748
+ if (
4749
+ !state.hasMoved &&
4750
+ Math.hypot(
4751
+ ev.clientX - state.originClient.x,
4752
+ ev.clientY - state.originClient.y,
4753
+ ) >= DRAG_THRESHOLD
4754
+ ) {
4755
+ state.hasMoved = true;
4756
+ }
4757
+ // Skip committing any transform until the drag threshold is crossed —
4758
+ // see the matching comment in beginFrameDrag's move handler.
4759
+ if (!state.hasMoved) return;
4760
+
4761
+ const pointer = getCanvasPoint(ev.clientX, ev.clientY);
4762
+ const currentAngle = angleBetween(state.groupCenter, pointer);
4763
+ const rawDelta = currentAngle - state.originPointerAngle;
4764
+ const delta = ev.shiftKey ? Math.round(rawDelta / 15) * 15 : rawDelta;
4765
+
4766
+ const originEntriesForRotate = state.targetIds.map((targetId) => ({
4767
+ id: targetId,
4768
+ geometry: state.originFrames[targetId],
4769
+ }));
4770
+ const rotated = rotateFrameGroupAroundCenter(
4771
+ originEntriesForRotate,
4772
+ state.groupCenter,
4773
+ delta,
4774
+ );
4775
+ updateFrameGeometry((current) => {
4776
+ const next = { ...current };
4777
+ rotated.forEach((entry) => {
4778
+ next[entry.id] = {
4779
+ ...state.originFrames[entry.id],
4780
+ ...entry.geometry,
4781
+ };
4782
+ });
4783
+ return next;
4784
+ });
4785
+ showTransformFeedback(
4786
+ `${Math.round(delta)}deg`,
4787
+ ev.clientX,
4788
+ ev.clientY,
4789
+ );
4790
+ };
4791
+
4792
+ const handleMouseUp = () => {
4793
+ const state = dragState.current;
4794
+ if (state?.type === "group-rotate" && !state.hasMoved) {
4795
+ // Belt-and-braces restore, matching the move handler.
4796
+ updateFrameGeometry((current) =>
4797
+ frameGeometryWithOverrides(current, state.originFrames),
4798
+ );
4799
+ }
4800
+ if (state?.type === "group-rotate" && state.hasMoved) {
4801
+ const after = cloneFrameGeometryById(frameGeometryRef.current);
4802
+ onGeometryCommitRef.current?.(
4803
+ frameGeometryWithOverrides(after, state.originFrames),
4804
+ after,
4805
+ );
4806
+ }
4807
+ suppressNextPick.current = true;
4808
+ finishDrag();
4809
+ };
4810
+
4811
+ installDragListeners(handleMouseMove, handleMouseUp);
4812
+ },
4813
+ [
4814
+ finishDrag,
4815
+ getCanvasPoint,
4816
+ getCurrentFrameEntries,
4817
+ installDragListeners,
4818
+ showTransformFeedback,
4819
+ updateFrameGeometry,
4820
+ ],
4821
+ );
4822
+
4053
4823
  const handleFrameClick = useCallback(
4054
4824
  (id: string, e: React.MouseEvent<HTMLElement>) => {
4055
4825
  e.stopPropagation();
@@ -4132,6 +4902,11 @@ export function MultiScreenCanvas({
4132
4902
  canDuplicate: !!onDuplicate,
4133
4903
  moved: false,
4134
4904
  });
4905
+ // Mount the interaction shield and mute preview-iframe pointer events for
4906
+ // the duration of the gesture, same as every other drag — otherwise the
4907
+ // pointer freezes crossing a live embedded iframe and a release over a
4908
+ // screen never reaches handleMouseUp.
4909
+ setIsDragging(true);
4135
4910
 
4136
4911
  const handleMouseMove = (ev: MouseEvent) => {
4137
4912
  const dx = ev.clientX - origin.x;
@@ -4144,22 +4919,24 @@ export function MultiScreenCanvas({
4144
4919
  y: rect ? ev.clientY - rect.top + 16 : ev.clientY,
4145
4920
  width: previewWidth,
4146
4921
  height: previewHeight,
4147
- canDuplicate: !!onDuplicate,
4922
+ // Live alt state, not just capability: if the user releases alt
4923
+ // mid-drag the preview should visibly fall back to its "not armed"
4924
+ // dashed/preview styling, matching that mouseup will then cancel
4925
+ // the duplicate instead of creating one (see handleMouseUp below).
4926
+ canDuplicate: !!onDuplicate && ev.altKey,
4148
4927
  moved,
4149
4928
  });
4150
4929
  };
4151
4930
 
4152
4931
  const cleanupDuplicateGesture = () => {
4153
4932
  setDuplicatePreview(null);
4154
- window.removeEventListener("mousemove", handleMouseMove);
4155
- window.removeEventListener("mouseup", handleMouseUp);
4156
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
4157
- window.removeEventListener("blur", handleBlur);
4158
4933
  duplicateCleanup.current = null;
4159
- };
4160
-
4161
- const handleBlur = () => {
4162
- cleanupDuplicateGesture();
4934
+ // finishDrag clears isDragging, unmounts the shield, and — critically —
4935
+ // runs dragCleanup.current() to detach the window listeners installed
4936
+ // by installDragListeners and restore preview-iframe pointer events.
4937
+ // dragState.current was never set for this gesture, so finishDrag's
4938
+ // other resets (marquee/creation-preview/etc.) are no-ops here.
4939
+ finishDrag();
4163
4940
  };
4164
4941
 
4165
4942
  const handleMouseUp = (ev: MouseEvent) => {
@@ -4167,22 +4944,27 @@ export function MultiScreenCanvas({
4167
4944
  Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
4168
4945
  DUPLICATE_DRAG_THRESHOLD;
4169
4946
  const mode = moved ? "alt-drag" : "alt-click";
4170
-
4171
- if (onDuplicate) {
4947
+ // Figma semantics: a plain alt-click (no drag) never duplicates —
4948
+ // only an actual alt-drag does. And alt is evaluated live: releasing
4949
+ // it before mouseup cancels the pending duplicate rather than
4950
+ // creating one anyway (this gesture never moves the original frame
4951
+ // during the drag — it only shows a floating ghost preview — so
4952
+ // "cancel" here means no-op, not handing off to a live move).
4953
+ const shouldDuplicate = moved && ev.altKey;
4954
+
4955
+ if (onDuplicate && shouldDuplicate) {
4172
4956
  const dropCanvasPosition = canvasPointFromClient(
4173
4957
  ev.clientX,
4174
4958
  ev.clientY,
4175
4959
  );
4176
- const canvasPosition =
4177
- moved || !sourceFrame
4178
- ? {
4179
- x: dropCanvasPosition.x - pointerOffset.x,
4180
- y: dropCanvasPosition.y - pointerOffset.y,
4181
- }
4182
- : {
4183
- x: sourceFrame.geometry.x + SCREEN_GAP / 2,
4184
- y: sourceFrame.geometry.y + SCREEN_GAP / 2,
4185
- };
4960
+ // shouldDuplicate implies moved, so the drop position is always
4961
+ // relative to the pointer's offset into the source frame (the
4962
+ // "snap next to source" placement only applied to the old
4963
+ // zero-move alt-click case, which no longer duplicates at all).
4964
+ const canvasPosition = {
4965
+ x: dropCanvasPosition.x - pointerOffset.x,
4966
+ y: dropCanvasPosition.y - pointerOffset.y,
4967
+ };
4186
4968
  onDuplicate(screen.id, {
4187
4969
  mode,
4188
4970
  screen,
@@ -4198,11 +4980,20 @@ export function MultiScreenCanvas({
4198
4980
  };
4199
4981
 
4200
4982
  duplicateCleanup.current = cleanupDuplicateGesture;
4201
- window.addEventListener("mousemove", handleMouseMove);
4202
- window.addEventListener("mouseup", handleMouseUp);
4203
- window.addEventListener("blur", handleBlur);
4983
+ installDragListeners(
4984
+ handleMouseMove,
4985
+ handleMouseUp,
4986
+ cleanupDuplicateGesture,
4987
+ );
4204
4988
  },
4205
- [canvasPointFromClient, getCurrentFrameEntries, onDuplicate, onPick],
4989
+ [
4990
+ canvasPointFromClient,
4991
+ finishDrag,
4992
+ getCurrentFrameEntries,
4993
+ installDragListeners,
4994
+ onDuplicate,
4995
+ onPick,
4996
+ ],
4206
4997
  );
4207
4998
 
4208
4999
  const handleMouseDown = useCallback(
@@ -4277,6 +5068,20 @@ export function MultiScreenCanvas({
4277
5068
  grid.style.backgroundPosition = `${p.x}px ${p.y}px`;
4278
5069
  grid.style.backgroundSize = `${nextScale}px ${nextScale}px`;
4279
5070
  }
5071
+ // A marquee-select drag can run concurrently with a wheel/trackpad pan
5072
+ // gesture (e.g. two-finger scroll while left-mouse-dragging a marquee).
5073
+ // Without this, the marquee overlay's position/size — computed from
5074
+ // React `pan`/`scale` in its inline style — would only catch up once the
5075
+ // gesture settles and scheduleViewCommit() re-renders, visibly lagging
5076
+ // behind the frames it's supposed to be selecting against.
5077
+ const marqueeOverlay = marqueeOverlayRef.current;
5078
+ const activeMarquee = marqueeRef.current;
5079
+ if (marqueeOverlay && activeMarquee) {
5080
+ marqueeOverlay.style.left = `${p.x + (SURFACE_PADDING + activeMarquee.x) * nextScale}px`;
5081
+ marqueeOverlay.style.top = `${p.y + (SURFACE_PADDING + activeMarquee.y) * nextScale}px`;
5082
+ marqueeOverlay.style.width = `${Math.max(1, activeMarquee.width * nextScale)}px`;
5083
+ marqueeOverlay.style.height = `${Math.max(1, activeMarquee.height * nextScale)}px`;
5084
+ }
4280
5085
  }, []);
4281
5086
 
4282
5087
  const startChromeSettle = useCallback(() => {
@@ -4298,7 +5103,11 @@ export function MultiScreenCanvas({
4298
5103
  setCanvasZoom(zoomRef.current);
4299
5104
  setPan(panRef.current);
4300
5105
  onZoomChange?.(zoomRef.current);
4301
- }, [onZoomChange, startChromeSettle]);
5106
+ // P18: the wheel/pinch gesture just settled (pan/zoom state is
5107
+ // reconciled into React here) — resync the pen ghost preview from the
5108
+ // last known cursor position now that the canvas-space mapping changed.
5109
+ recomputePenPointerForViewChange();
5110
+ }, [onZoomChange, recomputePenPointerForViewChange, startChromeSettle]);
4302
5111
 
4303
5112
  // Debounced: only commit to React state once the gesture has been idle for a
4304
5113
  // beat, so a continuous pinch produces zero re-renders until the user pauses.
@@ -4314,6 +5123,8 @@ export function MultiScreenCanvas({
4314
5123
  },
4315
5124
  [commitView],
4316
5125
  );
5126
+ applyViewToDomRef.current = applyViewToDom;
5127
+ scheduleViewCommitRef.current = scheduleViewCommit;
4317
5128
 
4318
5129
  const flushPendingWheelGesture = useCallback(() => {
4319
5130
  wheelGestureFrameRef.current = null;
@@ -4441,6 +5252,15 @@ export function MultiScreenCanvas({
4441
5252
 
4442
5253
  const handleWheelEvent = useCallback(
4443
5254
  (event: WheelEvent) => {
5255
+ // DesignCanvas re-dispatches embedded-canvas-wheel messages as a
5256
+ // synthetic (non-isTrusted) WheelEvent on its own iframe element so its
5257
+ // own listeners can reuse one code path. That synthetic event bubbles
5258
+ // up through this surface's capture listener too, so without this guard
5259
+ // every embedded-screen wheel gesture gets processed twice: once via
5260
+ // the postMessage handler below, and again here from the re-dispatch.
5261
+ // Real user wheel input is always isTrusted, so this only filters out
5262
+ // the synthetic replay.
5263
+ if (!event.isTrusted) return;
4444
5264
  event.preventDefault();
4445
5265
  event.stopPropagation();
4446
5266
  enqueueWheelGestureFromClient({
@@ -4642,6 +5462,19 @@ export function MultiScreenCanvas({
4642
5462
 
4643
5463
  const primaryKey = event.metaKey || event.ctrlKey;
4644
5464
  if (primaryKey && !event.shiftKey && event.key.toLowerCase() === "z") {
5465
+ // While a pen-node drag is actively in progress (mouse down, still
5466
+ // dragging the handle for the anchor being placed), the path in
5467
+ // activePenPathRef already includes that in-progress node — undoing
5468
+ // here would pop it out from under the live drag, then the drag's
5469
+ // own mousemove/mouseup handlers would immediately re-add it from
5470
+ // their closed-over `state`, producing a no-op flicker. Ignore the
5471
+ // shortcut until the drag settles (mouseup/cancel).
5472
+ if (dragState.current?.type === "pen-node") {
5473
+ event.preventDefault();
5474
+ event.stopPropagation();
5475
+ event.stopImmediatePropagation();
5476
+ return;
5477
+ }
4645
5478
  event.preventDefault();
4646
5479
  event.stopPropagation();
4647
5480
  event.stopImmediatePropagation();
@@ -4663,7 +5496,12 @@ export function MultiScreenCanvas({
4663
5496
  event.preventDefault();
4664
5497
  event.stopPropagation();
4665
5498
  event.stopImmediatePropagation();
4666
- clearActivePenPath();
5499
+ // Figma: Escape ends the path in progress and keeps what's drawn so
5500
+ // far (no data loss), rather than discarding the whole path.
5501
+ // finishPenPath already falls back to a discard for a path with
5502
+ // fewer than 2 nodes (P16), where there's nothing meaningful to
5503
+ // commit.
5504
+ finishPenPath(path);
4667
5505
  return;
4668
5506
  }
4669
5507
 
@@ -4679,15 +5517,28 @@ export function MultiScreenCanvas({
4679
5517
  return () => {
4680
5518
  window.removeEventListener("keydown", handleKeyDown, true);
4681
5519
  };
4682
- }, [clearActivePenPath, finishPenPath, undoActivePenPathSegment]);
5520
+ }, [finishPenPath, undoActivePenPathSegment]);
4683
5521
 
4684
5522
  useEffect(() => {
4685
5523
  const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
4686
- if (tool !== "pen") clearActivePenPath();
4687
- }, [activeTool, clearActivePenPath, localActiveTool]);
4688
-
4689
- // Cmd+D / Ctrl+D: duplicate the selected frame with a visible offset so the
4690
- // copy doesn't land exactly on top of the original (Figma-style behaviour).
5524
+ // Switching tools mid-path (toolbar click or a single-letter shortcut
5525
+ // reaching the editor's handlers) used to silently discard the path in
5526
+ // progress. Figma commits it instead — finishPenPath already discards
5527
+ // for a sub-2-node path (P16), so this only ever loses genuinely empty
5528
+ // in-progress state.
5529
+ if (tool !== "pen") finishPenPath();
5530
+ }, [activeTool, finishPenPath, localActiveTool]);
5531
+
5532
+ // Cmd+D / Ctrl+D: duplicate every selected frame (not just the first) with
5533
+ // a visible offset so each copy doesn't land exactly on top of its
5534
+ // original (Figma-style behaviour). `onDuplicate` is fire-and-forget
5535
+ // (`(id, request) => void`) and the actual new file id is only known
5536
+ // asynchronously by the caller (DesignEditor's createFileMutation
5537
+ // onSuccess), so this component has no id to add to its own selection
5538
+ // state. Known limitation: after a multi-duplicate, selection isn't
5539
+ // reprogrammed to the new copies (the caller instead makes its own last
5540
+ // duplicate the active file) — promoting the duplicates to the new
5541
+ // selection would require `onDuplicate` to return/callback the created id.
4691
5542
  useEffect(() => {
4692
5543
  if (!onDuplicate) return;
4693
5544
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -4699,31 +5550,39 @@ export function MultiScreenCanvas({
4699
5550
  ) {
4700
5551
  return;
4701
5552
  }
5553
+ // Always suppress the browser default (bookmark dialog) — but leave
5554
+ // propagation intact until we know a frame is duplicable here, so the
5555
+ // global hotkey hook can still duplicate non-frame layer selections.
5556
+ event.preventDefault();
4702
5557
  // Only act on frame IDs — filter out canvas primitives (sub-elements).
4703
5558
  const frameIds = selectedIdsRef.current.filter(
4704
5559
  (id) => frameGeometryRef.current[id],
4705
5560
  );
4706
- const targetId = frameIds[0];
4707
- if (!targetId) return;
4708
- const screen = screens.find((s) => s.id === targetId);
4709
- if (!screen) return;
4710
- const sourceGeometry = frameGeometryRef.current[targetId];
4711
- if (!sourceGeometry) return;
4712
- event.preventDefault();
5561
+ if (frameIds.length === 0) return;
4713
5562
  event.stopPropagation();
4714
5563
  event.stopImmediatePropagation();
4715
- // Offset the duplicate by one grid gap to the right (and slightly down)
4716
- // so it is visually distinct from the original, mirroring Figma's behaviour.
4717
- const canvasPosition = {
4718
- x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
4719
- y: sourceGeometry.y,
4720
- };
4721
- onDuplicate(targetId, {
4722
- mode: "alt-click",
4723
- screen,
4724
- canvasPosition,
4725
- dropCanvasPosition: canvasPosition,
4726
- });
5564
+ // Duplicate every selected frame, not just the first each duplicate
5565
+ // is offset relative to its OWN source geometry (not chained off the
5566
+ // previous duplicate), mirroring how a multi-select alt-drag would
5567
+ // offset each frame independently.
5568
+ for (const targetId of frameIds) {
5569
+ const screen = screens.find((s) => s.id === targetId);
5570
+ if (!screen) continue;
5571
+ const sourceGeometry = frameGeometryRef.current[targetId];
5572
+ if (!sourceGeometry) continue;
5573
+ // Offset the duplicate by one grid gap to the right (and slightly down)
5574
+ // so it is visually distinct from the original, mirroring Figma's behaviour.
5575
+ const canvasPosition = {
5576
+ x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
5577
+ y: sourceGeometry.y,
5578
+ };
5579
+ onDuplicate(targetId, {
5580
+ mode: "alt-click",
5581
+ screen,
5582
+ canvasPosition,
5583
+ dropCanvasPosition: canvasPosition,
5584
+ });
5585
+ }
4727
5586
  };
4728
5587
 
4729
5588
  window.addEventListener("keydown", handleKeyDown, true);
@@ -4764,29 +5623,43 @@ export function MultiScreenCanvas({
4764
5623
  : effectiveTool === "hand"
4765
5624
  ? "grab"
4766
5625
  : "default";
4767
- const canvasFrames = screens.map((screen, index) => {
4768
- const metadata = getResolvedMetadata(screen);
4769
- return {
4770
- screen,
4771
- metadata,
4772
- geometry:
4773
- frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata),
4774
- };
4775
- });
4776
- const topScreenId =
4777
- selectedIds.find((id) =>
4778
- canvasFrames.some(({ screen }) => screen.id === id),
4779
- ) ??
4780
- (activeId && canvasFrames.some(({ screen }) => screen.id === activeId)
4781
- ? activeId
4782
- : canvasFrames[0]?.screen.id);
4783
- const renderCanvasFrames =
4784
- topScreenId && canvasFrames.some(({ screen }) => screen.id === topScreenId)
4785
- ? [
4786
- ...canvasFrames.filter(({ screen }) => screen.id !== topScreenId),
4787
- ...canvasFrames.filter(({ screen }) => screen.id === topScreenId),
4788
- ]
4789
- : canvasFrames;
5626
+ // PF19: canvasFrames (and everything derived from it below) used to be
5627
+ // plain per-render recomputation over `screens`/`frameGeometry`, which are
5628
+ // large arrays/maps for boards with many screens. Memoize so a render that
5629
+ // doesn't touch screens/geometry (e.g. a hover-only or unrelated state
5630
+ // update) doesn't re-walk and re-allocate the whole frame list.
5631
+ const canvasFrames = useMemo(
5632
+ () =>
5633
+ screens.map((screen, index) => {
5634
+ const metadata = getResolvedMetadata(screen);
5635
+ return {
5636
+ screen,
5637
+ metadata,
5638
+ geometry:
5639
+ frameGeometry[screen.id] ??
5640
+ getInitialFrameGeometry(index, metadata),
5641
+ };
5642
+ }),
5643
+ [frameGeometry, getResolvedMetadata, screens],
5644
+ );
5645
+ const topScreenId = useMemo(
5646
+ () =>
5647
+ selectedIds.find((id) =>
5648
+ canvasFrames.some(({ screen }) => screen.id === id),
5649
+ ) ??
5650
+ (activeId && canvasFrames.some(({ screen }) => screen.id === activeId)
5651
+ ? activeId
5652
+ : canvasFrames[0]?.screen.id),
5653
+ [activeId, canvasFrames, selectedIds],
5654
+ );
5655
+ // PF16: previously this reordered canvasFrames so the top screen's keyed
5656
+ // entry moved to the end of the array. Since screens are keyed by
5657
+ // screen.id, moving a key's position in a mapped list makes React move
5658
+ // that DOM node (iframe) to match — which reloads the iframe's document,
5659
+ // producing a visible white flash every time selection changes. zIndex
5660
+ // (boosted for the top screen in Screen's root style, see
5661
+ // TOP_SCREEN_Z_BOOST) already expresses "paint above its siblings" without
5662
+ // needing to touch DOM order, so render canvasFrames in stable order.
4790
5663
  const screenContentById = useMemo(() => {
4791
5664
  if (!renderScreenContent) return new Map<string, ReactNode>();
4792
5665
  return new Map(
@@ -4801,21 +5674,41 @@ export function MultiScreenCanvas({
4801
5674
  }),
4802
5675
  );
4803
5676
  }, [frameGeometry, getResolvedMetadata, renderScreenContent, screens]);
4804
- const selectedFrameEntries = canvasFrames
4805
- .filter(({ screen }) => selectedIdSet.has(screen.id))
4806
- .map(({ screen, geometry }) => ({ id: screen.id, geometry }));
4807
- const selectedGroupBounds =
4808
- selectedFrameEntries.length > 1
4809
- ? getFrameGroupBounds(selectedFrameEntries)
4810
- : null;
5677
+ // PF19: filters/maps over canvasFrames + a getFrameGroupBounds pass — cheap
5678
+ // for a handful of screens, but this runs on every render (hover, hint
5679
+ // text, unrelated state), not just selection changes. Memoize keyed on the
5680
+ // actual selection + frame list so unrelated renders reuse the prior arrays
5681
+ // (and downstream consumers like SelectionBox/GroupSelectionBox, which take
5682
+ // these by reference, skip re-rendering too).
5683
+ const selectedFrameEntries = useMemo(
5684
+ () =>
5685
+ canvasFrames
5686
+ .filter(({ screen }) => selectedIdSet.has(screen.id))
5687
+ .map(({ screen, geometry }) => ({ id: screen.id, geometry })),
5688
+ [canvasFrames, selectedIdSet],
5689
+ );
5690
+ const selectedGroupBounds = useMemo(
5691
+ () =>
5692
+ selectedFrameEntries.length > 1
5693
+ ? getFrameGroupBounds(selectedFrameEntries)
5694
+ : null,
5695
+ [selectedFrameEntries],
5696
+ );
4811
5697
  const hasGroupSelection = !!selectedGroupBounds;
4812
- const selectedDraftEntries = draftPrimitives
4813
- .filter((draft) => selectedDraftIdSet.has(draft.id))
4814
- .map((draft) => ({ id: draft.id, geometry: draft.geometry }));
4815
- const selectedDraftGroupBounds =
4816
- selectedDraftEntries.length > 1
4817
- ? getFrameGroupBounds(selectedDraftEntries)
4818
- : null;
5698
+ const selectedDraftEntries = useMemo(
5699
+ () =>
5700
+ draftPrimitives
5701
+ .filter((draft) => selectedDraftIdSet.has(draft.id))
5702
+ .map((draft) => ({ id: draft.id, geometry: draft.geometry })),
5703
+ [draftPrimitives, selectedDraftIdSet],
5704
+ );
5705
+ const selectedDraftGroupBounds = useMemo(
5706
+ () =>
5707
+ selectedDraftEntries.length > 1
5708
+ ? getFrameGroupBounds(selectedDraftEntries)
5709
+ : null,
5710
+ [selectedDraftEntries],
5711
+ );
4819
5712
  const singleSelectedFrame =
4820
5713
  selectedFrameEntries.length === 1 && !selectedGroupBounds
4821
5714
  ? selectedFrameEntries[0]
@@ -4972,7 +5865,7 @@ export function MultiScreenCanvas({
4972
5865
  );
4973
5866
  })()}
4974
5867
 
4975
- {renderCanvasFrames.map(({ screen, metadata, geometry }) => {
5868
+ {canvasFrames.map(({ screen, metadata, geometry }) => {
4976
5869
  return (
4977
5870
  <Screen
4978
5871
  key={screen.id}
@@ -4981,6 +5874,7 @@ export function MultiScreenCanvas({
4981
5874
  geometry={geometry}
4982
5875
  screenContent={screenContentById.get(screen.id)}
4983
5876
  isActive={screen.id === activeId}
5877
+ isTopScreen={screen.id === topScreenId}
4984
5878
  isSelected={selectedIdSet.has(screen.id)}
4985
5879
  showFullView={fullViewIdSet.has(screen.id)}
4986
5880
  isDirectlyHovered={screen.id === directlyHoveredScreenId}
@@ -5001,16 +5895,13 @@ export function MultiScreenCanvas({
5001
5895
  onStartResize={beginResize}
5002
5896
  onStartRotate={beginRotate}
5003
5897
  onStartDuplicateGesture={beginDuplicateGesture}
5004
- onAddBreakpoint={
5005
- onAddBreakpoint
5006
- ? (widthPx) => onAddBreakpoint(screen.id, widthPx)
5007
- : undefined
5008
- }
5009
- onActiveBreakpointChange={
5010
- onActiveBreakpointChange
5011
- ? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
5012
- : undefined
5013
- }
5898
+ // Pass the id-first callbacks straight through (PF18): Screen
5899
+ // itself binds screen.id when it calls these, so every screen
5900
+ // instance gets the exact same stable function reference here
5901
+ // instead of a fresh per-screen closure allocated on every
5902
+ // MultiScreenCanvas render, which used to defeat memo(Screen).
5903
+ onAddBreakpoint={onAddBreakpoint}
5904
+ onActiveBreakpointChange={onActiveBreakpointChange}
5014
5905
  />
5015
5906
  );
5016
5907
  })}
@@ -5068,7 +5959,11 @@ export function MultiScreenCanvas({
5068
5959
  : null}
5069
5960
 
5070
5961
  {displayedPenPath ? (
5071
- <PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
5962
+ <PenPathOverlay
5963
+ path={displayedPenPath}
5964
+ closeHover={penCloseHover}
5965
+ chromeScale={chromeScale}
5966
+ />
5072
5967
  ) : null}
5073
5968
 
5074
5969
  {singleSelectedFrame ? (
@@ -5105,6 +6000,7 @@ export function MultiScreenCanvas({
5105
6000
  chromeScale={chromeScale}
5106
6001
  chromeSettling={chromeSettling}
5107
6002
  onStartResize={beginGroupResize}
6003
+ onStartRotate={beginGroupRotate}
5108
6004
  />
5109
6005
  ) : null}
5110
6006
 
@@ -5138,8 +6034,63 @@ export function MultiScreenCanvas({
5138
6034
  }
5139
6035
  />
5140
6036
  ))}
6037
+
6038
+ {/* Smart-spacing guides (CV11): highlight both equal-sized gaps
6039
+ around the moving frame, with one label showing the shared
6040
+ distance. */}
6041
+ {equalGapGuides.map((guide, index) =>
6042
+ guide.bands.map((band, bandIndex) => (
6043
+ <span
6044
+ key={`equal-gap-${guide.orientation}-${index}-${bandIndex}`}
6045
+ className="pointer-events-none absolute z-30 bg-[var(--design-editor-accent-color)]/25"
6046
+ style={
6047
+ guide.orientation === "vertical"
6048
+ ? {
6049
+ left: SURFACE_PADDING + band.gapStart,
6050
+ top: SURFACE_PADDING + band.crossStart,
6051
+ width: Math.max(1, band.gapEnd - band.gapStart),
6052
+ height: Math.max(1, band.crossEnd - band.crossStart),
6053
+ }
6054
+ : {
6055
+ left: SURFACE_PADDING + band.crossStart,
6056
+ top: SURFACE_PADDING + band.gapStart,
6057
+ width: Math.max(1, band.crossEnd - band.crossStart),
6058
+ height: Math.max(1, band.gapEnd - band.gapStart),
6059
+ }
6060
+ }
6061
+ />
6062
+ )),
6063
+ )}
5141
6064
  </div>
5142
6065
 
6066
+ {/* Equal-gap distance labels render outside the pan/scale-transformed
6067
+ world container (same reasoning as the marquee/duplicate-preview
6068
+ overlays above it) so they need the explicit
6069
+ pan + (SURFACE_PADDING + canvasCoord) * scale conversion instead of
6070
+ the raw canvas coordinates the bands above use inside that
6071
+ container. */}
6072
+ {equalGapGuides.map((guide, index) => {
6073
+ const band = guide.bands[0];
6074
+ const crossMid = (band.crossStart + band.crossEnd) / 2;
6075
+ const gapMid = (band.gapStart + band.gapEnd) / 2;
6076
+ const labelCanvasPoint =
6077
+ guide.orientation === "vertical"
6078
+ ? { x: gapMid, y: crossMid }
6079
+ : { x: crossMid, y: gapMid };
6080
+ return (
6081
+ <span
6082
+ key={`equal-gap-label-${guide.orientation}-${index}`}
6083
+ className="pointer-events-none absolute z-40 -translate-x-1/2 -translate-y-1/2 rounded bg-[var(--design-editor-accent-color)] px-1 py-0.5 text-[10px] font-medium leading-none text-[var(--design-editor-accent-contrast-color)] shadow-sm"
6084
+ style={{
6085
+ left: pan.x + (SURFACE_PADDING + labelCanvasPoint.x) * scale,
6086
+ top: pan.y + (SURFACE_PADDING + labelCanvasPoint.y) * scale,
6087
+ }}
6088
+ >
6089
+ {Math.round(guide.gap)}
6090
+ </span>
6091
+ );
6092
+ })}
6093
+
5143
6094
  {penActive || creationToolActive ? (
5144
6095
  <div
5145
6096
  data-canvas-creation-shield
@@ -5150,11 +6101,18 @@ export function MultiScreenCanvas({
5150
6101
 
5151
6102
  {marquee ? (
5152
6103
  <span
6104
+ ref={marqueeOverlayRef}
5153
6105
  className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
5154
6106
  style={{
5155
6107
  // Convert canvas-space marquee to surface-space so this overlay
5156
6108
  // is never clipped or hidden by the canvas transform container.
5157
6109
  // Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
6110
+ // NOTE: this uses React `pan`/`scale`, which only update on the
6111
+ // debounced view-commit — during an active wheel/pinch gesture
6112
+ // (e.g. two-finger pan while marquee-dragging) applyViewToDom
6113
+ // keeps this element's position in sync imperatively via
6114
+ // marqueeOverlayRef, the same way it already does for the world
6115
+ // transform and pixel grid.
5158
6116
  left: pan.x + (SURFACE_PADDING + marquee.x) * scale,
5159
6117
  top: pan.y + (SURFACE_PADDING + marquee.y) * scale,
5160
6118
  width: Math.max(1, marquee.width * scale),
@@ -5267,7 +6225,7 @@ export function MultiScreenCanvas({
5267
6225
  ) : null}
5268
6226
  </div>
5269
6227
  );
5270
- }
6228
+ });
5271
6229
 
5272
6230
  function DraftPrimitiveLayer({
5273
6231
  draft,
@@ -5353,6 +6311,7 @@ function DraftPrimitiveLayer({
5353
6311
  showRotate={false}
5354
6312
  chromeScale={chromeScale}
5355
6313
  chromeSettling={chromeSettling}
6314
+ rotationDeg={draft.geometry.rotation ?? 0}
5356
6315
  onStartResize={(handle, event) =>
5357
6316
  onStartResize(draft.id, handle, event)
5358
6317
  }
@@ -5491,12 +6450,27 @@ function DraftPrimitiveContent({
5491
6450
  function PenPathOverlay({
5492
6451
  path,
5493
6452
  closeHover,
6453
+ chromeScale,
5494
6454
  }: {
5495
6455
  path: PenPath;
5496
6456
  closeHover: boolean;
6457
+ chromeScale: number;
5497
6458
  }) {
5498
6459
  const geometry = getPenPathGeometry(path);
5499
6460
  const pathData = serializePenPath(path);
6461
+ // PenPathOverlay lives inside the pan/zoom-scaled world container, so raw
6462
+ // px sizes here would shrink to specks at low zoom and blow up into blobs
6463
+ // at high zoom. Scale every screen-space size (anchor/handle boxes,
6464
+ // stroke widths) by chromeScale (= 1 / zoomScale) the same way
6465
+ // SelectionBox's resize/rotate handles do, so they stay a constant size
6466
+ // on screen regardless of canvas zoom.
6467
+ const anchorSize = 8 * chromeScale;
6468
+ const handleSize = 6 * chromeScale;
6469
+ const anchorBorderWidth = Math.max(1, chromeScale);
6470
+ const handleBorderWidth = Math.max(1, chromeScale);
6471
+ const outlineStrokeWidth = 5 * chromeScale;
6472
+ const strokeWidth = 2 * chromeScale;
6473
+ const handleLineStrokeWidth = Math.max(1, chromeScale);
5500
6474
  return (
5501
6475
  <div
5502
6476
  data-pen-path-overlay
@@ -5522,7 +6496,7 @@ function PenPathOverlay({
5522
6496
  y2={node.handleIn.y}
5523
6497
  stroke="var(--design-editor-accent-color)"
5524
6498
  strokeDasharray="3 3"
5525
- strokeWidth={1}
6499
+ strokeWidth={handleLineStrokeWidth}
5526
6500
  />
5527
6501
  ) : null}
5528
6502
  {node.handleOut ? (
@@ -5533,7 +6507,7 @@ function PenPathOverlay({
5533
6507
  y2={node.handleOut.y}
5534
6508
  stroke="var(--design-editor-accent-color)"
5535
6509
  strokeDasharray="3 3"
5536
- strokeWidth={1}
6510
+ strokeWidth={handleLineStrokeWidth}
5537
6511
  />
5538
6512
  ) : null}
5539
6513
  </g>
@@ -5544,7 +6518,7 @@ function PenPathOverlay({
5544
6518
  stroke="rgba(255,255,255,0.95)"
5545
6519
  strokeLinecap="round"
5546
6520
  strokeLinejoin="round"
5547
- strokeWidth={5}
6521
+ strokeWidth={outlineStrokeWidth}
5548
6522
  />
5549
6523
  <path
5550
6524
  d={pathData}
@@ -5552,7 +6526,7 @@ function PenPathOverlay({
5552
6526
  stroke="var(--design-editor-accent-color)"
5553
6527
  strokeLinecap="round"
5554
6528
  strokeLinejoin="round"
5555
- strokeWidth={2}
6529
+ strokeWidth={strokeWidth}
5556
6530
  />
5557
6531
  </svg>
5558
6532
  {path.nodes.map((node, index) => (
@@ -5560,14 +6534,17 @@ function PenPathOverlay({
5560
6534
  key={`anchor-${index}`}
5561
6535
  data-pen-anchor
5562
6536
  className={cn(
5563
- "absolute size-2 rounded-[2px] border shadow-sm",
6537
+ "absolute rounded-[2px] border shadow-sm",
5564
6538
  index === 0 && closeHover
5565
6539
  ? "scale-125 border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)] ring-4 ring-[var(--design-editor-selection-color)]"
5566
6540
  : "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]",
5567
6541
  )}
5568
6542
  style={{
5569
- left: node.point.x - geometry.x - 4,
5570
- top: node.point.y - geometry.y - 4,
6543
+ left: node.point.x - geometry.x - anchorSize / 2,
6544
+ top: node.point.y - geometry.y - anchorSize / 2,
6545
+ width: anchorSize,
6546
+ height: anchorSize,
6547
+ borderWidth: anchorBorderWidth,
5571
6548
  }}
5572
6549
  />
5573
6550
  ))}
@@ -5578,10 +6555,13 @@ function PenPathOverlay({
5578
6555
  <span
5579
6556
  key={`handle-${index}-${handleIndex}`}
5580
6557
  data-pen-handle
5581
- className="absolute size-1.5 rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
6558
+ className="absolute rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
5582
6559
  style={{
5583
- left: handle.x - geometry.x - 3,
5584
- top: handle.y - geometry.y - 3,
6560
+ left: handle.x - geometry.x - handleSize / 2,
6561
+ top: handle.y - geometry.y - handleSize / 2,
6562
+ width: handleSize,
6563
+ height: handleSize,
6564
+ borderWidth: handleBorderWidth,
5585
6565
  }}
5586
6566
  />
5587
6567
  )),
@@ -5609,12 +6589,57 @@ function nextBreakpointWidth(existing: number[]): number | undefined {
5609
6589
  return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
5610
6590
  }
5611
6591
 
6592
+ /**
6593
+ * The `data-screen-iframe-id` DOM attribute value for a screen's PRIMARY
6594
+ * iframe. Breakpoint sub-frames (see BreakpointPreviewRow) get their own
6595
+ * distinct suffixed id via `getBreakpointIframeId` so `querySelector`
6596
+ * (which always returns the first DOM match) can't silently collide two
6597
+ * different iframes onto the same id.
6598
+ */
6599
+ export function getPrimaryIframeId(screenId: string): string {
6600
+ return screenId;
6601
+ }
6602
+
6603
+ /** The `data-screen-iframe-id` value for one specific breakpoint sub-frame. */
6604
+ export function getBreakpointIframeId(
6605
+ screenId: string,
6606
+ widthPx: number,
6607
+ ): string {
6608
+ return `${screenId}::bp-${widthPx}`;
6609
+ }
6610
+
6611
+ /**
6612
+ * Resolves which iframe DOM id actually represents `screen` right now for
6613
+ * hit-test / drag / wheel bridge lookups: the currently active breakpoint
6614
+ * sub-frame if one is selected, otherwise the primary iframe. Every
6615
+ * `querySelector('[data-screen-iframe-id="…"]')` call site that resolves a
6616
+ * screen id to a live iframe must go through this so an active breakpoint
6617
+ * scope actually targets the frame the user is looking at, instead of
6618
+ * always silently resolving to the primary frame regardless of which one is
6619
+ * selected.
6620
+ */
6621
+ export function getActiveScreenIframeId(screen: {
6622
+ id: string;
6623
+ activeBreakpointWidth?: number;
6624
+ breakpointWidths?: number[];
6625
+ }): string {
6626
+ const activeWidth = screen.activeBreakpointWidth;
6627
+ if (
6628
+ activeWidth !== undefined &&
6629
+ screen.breakpointWidths?.includes(activeWidth)
6630
+ ) {
6631
+ return getBreakpointIframeId(screen.id, activeWidth);
6632
+ }
6633
+ return getPrimaryIframeId(screen.id);
6634
+ }
6635
+
5612
6636
  interface ScreenProps {
5613
6637
  screen: ScreenFile;
5614
6638
  metadata: ResolvedScreenMetadata;
5615
6639
  geometry: FrameGeometry;
5616
6640
  isActive: boolean;
5617
6641
  isSelected: boolean;
6642
+ isTopScreen: boolean;
5618
6643
  showFullView: boolean;
5619
6644
  isDirectlyHovered: boolean;
5620
6645
  hasHoveredChild: boolean;
@@ -5640,8 +6665,17 @@ interface ScreenProps {
5640
6665
  display: string,
5641
6666
  e: React.MouseEvent<HTMLElement>,
5642
6667
  ) => void;
5643
- onAddBreakpoint?: (widthPx: number) => void;
5644
- onActiveBreakpointChange?: (widthPx: number | undefined) => void;
6668
+ // Id-first (screenId, widthPx) shape, same as MultiScreenCanvas's own
6669
+ // onAddBreakpoint/onActiveBreakpointChange props (PF18): Screen binds
6670
+ // screen.id itself when calling these, so the parent can pass the same
6671
+ // stable function reference for every screen instead of allocating a new
6672
+ // per-screen `(widthPx) => onAddBreakpoint(screen.id, widthPx)` closure on
6673
+ // every render, which defeated memo(Screen) for every screen every time.
6674
+ onAddBreakpoint?: (screenId: string, widthPx: number) => void;
6675
+ onActiveBreakpointChange?: (
6676
+ screenId: string,
6677
+ widthPx: number | undefined,
6678
+ ) => void;
5645
6679
  }
5646
6680
 
5647
6681
  const Screen = memo(function Screen({
@@ -5650,6 +6684,7 @@ const Screen = memo(function Screen({
5650
6684
  geometry,
5651
6685
  isActive,
5652
6686
  isSelected,
6687
+ isTopScreen,
5653
6688
  showFullView,
5654
6689
  isDirectlyHovered,
5655
6690
  hasHoveredChild,
@@ -5739,7 +6774,9 @@ const Screen = memo(function Screen({
5739
6774
  ? `rotate(${geometry.rotation}deg)`
5740
6775
  : undefined,
5741
6776
  transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
5742
- zIndex: geometry.z,
6777
+ zIndex: isTopScreen
6778
+ ? (geometry.z ?? 0) + TOP_SCREEN_Z_BOOST
6779
+ : geometry.z,
5743
6780
  }}
5744
6781
  >
5745
6782
  <div
@@ -5763,6 +6800,11 @@ const Screen = memo(function Screen({
5763
6800
  if (e.button !== 0) return;
5764
6801
  if (penActive || creationToolActive) return;
5765
6802
  if (e.altKey) {
6803
+ // Matches the data-screen-card mousedown handler below: without
6804
+ // this, a trailing click after the alt-drag/duplicate gesture
6805
+ // ends falls through to this row's onClick and steals selection
6806
+ // away from the newly created duplicate.
6807
+ suppressNextClick.current = true;
5766
6808
  onStartDuplicateGesture(screen, display, e);
5767
6809
  return;
5768
6810
  }
@@ -5987,6 +7029,7 @@ const Screen = memo(function Screen({
5987
7029
  showRotate
5988
7030
  chromeScale={chromeScale}
5989
7031
  chromeSettling={chromeSettling}
7032
+ rotationDeg={geometry.rotation ?? 0}
5990
7033
  onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
5991
7034
  onStartRotate={(e) => onStartRotate(screen.id, e)}
5992
7035
  />
@@ -6007,8 +7050,16 @@ const Screen = memo(function Screen({
6007
7050
  penActive={penActive}
6008
7051
  creationToolActive={creationToolActive}
6009
7052
  chromeScale={chromeScale}
6010
- onActiveBreakpointChange={onActiveBreakpointChange}
6011
- onAddBreakpoint={onAddBreakpoint}
7053
+ onActiveBreakpointChange={
7054
+ onActiveBreakpointChange
7055
+ ? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
7056
+ : undefined
7057
+ }
7058
+ onAddBreakpoint={
7059
+ onAddBreakpoint
7060
+ ? (widthPx) => onAddBreakpoint(screen.id, widthPx)
7061
+ : undefined
7062
+ }
6012
7063
  />
6013
7064
  ) : null}
6014
7065
  </div>
@@ -6023,6 +7074,7 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
6023
7074
  sameFrameGeometry(prev.geometry, next.geometry) &&
6024
7075
  prev.isActive === next.isActive &&
6025
7076
  prev.isSelected === next.isSelected &&
7077
+ prev.isTopScreen === next.isTopScreen &&
6026
7078
  prev.showFullView === next.showFullView &&
6027
7079
  prev.isDirectlyHovered === next.isDirectlyHovered &&
6028
7080
  prev.hasHoveredChild === next.hasHoveredChild &&
@@ -6038,7 +7090,13 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
6038
7090
  prev.onStartFrameDrag === next.onStartFrameDrag &&
6039
7091
  prev.onStartResize === next.onStartResize &&
6040
7092
  prev.onStartRotate === next.onStartRotate &&
6041
- prev.onStartDuplicateGesture === next.onStartDuplicateGesture
7093
+ prev.onStartDuplicateGesture === next.onStartDuplicateGesture &&
7094
+ // Now id-first (screenId, widthPx) callbacks passed straight through
7095
+ // from MultiScreenCanvas's own props (PF18) instead of a fresh
7096
+ // per-screen arrow allocated in the render loop, so these are expected
7097
+ // to be referentially stable across renders and are safe to compare.
7098
+ prev.onAddBreakpoint === next.onAddBreakpoint &&
7099
+ prev.onActiveBreakpointChange === next.onActiveBreakpointChange
6042
7100
  );
6043
7101
  }
6044
7102
 
@@ -6066,7 +7124,9 @@ function BreakpointPreviewRow({
6066
7124
  * The primary screen's srcdoc with the lightweight hit-test responder already
6067
7125
  * injected (memoised in the parent Screen component). Passed down so
6068
7126
  * breakpoint sub-iframes carry the same responder and can be found via
6069
- * [data-screen-iframe-id] by the cross-screen drop-into-container handler.
7127
+ * their own distinct [data-screen-iframe-id] (see getBreakpointIframeId)
7128
+ * by the cross-screen drop-into-container handler when that breakpoint is
7129
+ * the active edit scope (see getActiveScreenIframeId).
6070
7130
  */
6071
7131
  srcdocWithHitTest: string;
6072
7132
  activeBreakpointWidth: number | undefined;
@@ -6157,7 +7217,19 @@ function BreakpointPreviewRow({
6157
7217
  style={{ width: frameWidth, height: frameHeight }}
6158
7218
  >
6159
7219
  <iframe
6160
- data-screen-iframe-id={screen.id}
7220
+ // Distinct id per breakpoint sub-frame — the primary iframe
7221
+ // above uses the bare screen id, so without a suffix here
7222
+ // every breakpoint sub-frame's iframe would share the exact
7223
+ // same [data-screen-iframe-id] as the primary, and
7224
+ // querySelector (which returns only the first DOM match)
7225
+ // would always resolve hit-test/drag/wheel bridge lookups to
7226
+ // the primary frame regardless of which breakpoint is
7227
+ // active. getActiveScreenIframeId resolves the correct one
7228
+ // to query at lookup time.
7229
+ data-screen-iframe-id={getBreakpointIframeId(
7230
+ screen.id,
7231
+ widthPx,
7232
+ )}
6161
7233
  src={previewUrl}
6162
7234
  srcDoc={previewUrl ? undefined : srcdocWithHitTest}
6163
7235
  sandbox="allow-scripts"
@@ -6224,11 +7296,16 @@ function GroupSelectionBox({
6224
7296
  chromeScale,
6225
7297
  chromeSettling,
6226
7298
  onStartResize,
7299
+ onStartRotate,
6227
7300
  }: {
6228
7301
  bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
6229
7302
  chromeScale: number;
6230
7303
  chromeSettling: boolean;
6231
7304
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
7305
+ /** Multi-selection rotate (CV14). Omit to keep the previous behavior (no
7306
+ * rotate handle shown) — used by callers whose selection kind doesn't
7307
+ * support group rotate yet (e.g. draft primitives). */
7308
+ onStartRotate?: (e: React.MouseEvent) => void;
6232
7309
  }) {
6233
7310
  return (
6234
7311
  <SelectionBox
@@ -6240,10 +7317,10 @@ function GroupSelectionBox({
6240
7317
  }}
6241
7318
  chromeScale={chromeScale}
6242
7319
  chromeSettling={chromeSettling}
6243
- showRotate={false}
7320
+ showRotate={!!onStartRotate}
6244
7321
  filled
6245
7322
  onStartResize={onStartResize}
6246
- onStartRotate={() => {}}
7323
+ onStartRotate={onStartRotate ?? (() => {})}
6247
7324
  />
6248
7325
  );
6249
7326
  }
@@ -6339,6 +7416,7 @@ function SelectionBox({
6339
7416
  showRotate={showRotate}
6340
7417
  chromeScale={chromeScale}
6341
7418
  chromeSettling={chromeSettling}
7419
+ rotationDeg={geometry.rotation ?? 0}
6342
7420
  onStartResize={onStartResize}
6343
7421
  onStartRotate={onStartRotate}
6344
7422
  />
@@ -6353,6 +7431,7 @@ function ResizeHandles({
6353
7431
  showRotate = true,
6354
7432
  chromeScale = 1,
6355
7433
  chromeSettling = false,
7434
+ rotationDeg = 0,
6356
7435
  onStartResize,
6357
7436
  onStartRotate,
6358
7437
  }: {
@@ -6362,6 +7441,10 @@ function ResizeHandles({
6362
7441
  showRotate?: boolean;
6363
7442
  chromeScale?: number;
6364
7443
  chromeSettling?: boolean;
7444
+ /** The frame's own rotation, so hover cursors match the handle's rotated
7445
+ * visual direction instead of a static unrotated cursor (CSS `cursor` is
7446
+ * never itself rotated by a transform on the element). */
7447
+ rotationDeg?: number;
6365
7448
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
6366
7449
  onStartRotate: (e: React.MouseEvent) => void;
6367
7450
  }) {
@@ -6389,7 +7472,7 @@ function ResizeHandles({
6389
7472
  className={edgeHandleClass}
6390
7473
  style={edgeHandleStyle(
6391
7474
  config.handle,
6392
- config.cursor,
7475
+ getResizeCursorForHandle(config.handle, rotationDeg),
6393
7476
  chromeScale,
6394
7477
  chromeSettling,
6395
7478
  )}
@@ -6403,7 +7486,7 @@ function ResizeHandles({
6403
7486
  className={visibleHandleClass}
6404
7487
  style={cornerHandleStyle(
6405
7488
  config.handle,
6406
- config.cursor,
7489
+ getResizeCursorForHandle(config.handle, rotationDeg),
6407
7490
  chromeScale,
6408
7491
  chromeSettling,
6409
7492
  )}
@@ -6639,6 +7722,29 @@ function dedupeIds(ids: string[]) {
6639
7722
  return [...new Set(ids)];
6640
7723
  }
6641
7724
 
7725
+ /** Matches the render-time `chromeScale = scale > 0 ? 1 / scale : 1` used to
7726
+ * keep on-screen chrome (labels, handles) a constant pixel size regardless
7727
+ * of canvas zoom. Callbacks that hit-test against the rendered label band
7728
+ * (e.g. marquee selection) must use the same conversion from the live zoom
7729
+ * ref instead of a fixed constant. */
7730
+ function chromeScaleFromZoom(zoom: number) {
7731
+ const scale = zoom / 100;
7732
+ return scale > 0 ? 1 / scale : 1;
7733
+ }
7734
+
7735
+ /** Shift-marquee selection combine, matching Figma: items currently swept by
7736
+ * the marquee toggle relative to the selection the gesture started with —
7737
+ * already-selected items under the marquee are deselected, not re-added.
7738
+ * Items outside the base selection AND not currently under the marquee are
7739
+ * left untouched. A plain union (the previous behavior) can only ever grow
7740
+ * the selection, so it never lets a shift-marquee deselect anything. */
7741
+ function xorMarqueeSelection(baseIds: string[], hitIds: string[]) {
7742
+ const hitSet = new Set(hitIds);
7743
+ const kept = baseIds.filter((id) => !hitSet.has(id));
7744
+ const added = hitIds.filter((id) => !baseIds.includes(id));
7745
+ return dedupeIds([...kept, ...added]);
7746
+ }
7747
+
6642
7748
  function sameIds(a: string[], b: string[]) {
6643
7749
  return a.length === b.length && a.every((id, index) => id === b[index]);
6644
7750
  }
@@ -6738,17 +7844,6 @@ function normalizeRectFromPoints(start: Point, end: Point): MarqueeRect {
6738
7844
  };
6739
7845
  }
6740
7846
 
6741
- function rectIntersects(rect: MarqueeRect, bounds: BoundsRect) {
6742
- const right = rect.x + rect.width;
6743
- const bottom = rect.y + rect.height;
6744
- return (
6745
- rect.x <= bounds.right &&
6746
- right >= bounds.left &&
6747
- rect.y <= bounds.bottom &&
6748
- bottom >= bounds.top
6749
- );
6750
- }
6751
-
6752
7847
  function rectContainsPoint(bounds: BoundsRect, point: Point) {
6753
7848
  return (
6754
7849
  point.x >= bounds.left &&
@@ -6779,63 +7874,6 @@ function rotatePointAroundCenter(
6779
7874
  };
6780
7875
  }
6781
7876
 
6782
- /** Test whether a marquee rect (in canvas space) intersects an OBB described by
6783
- * `bounds` (unrotated AABB) that has been rotated `degrees` around `center`.
6784
- * We map the four marquee corners into the frame's local space and check
6785
- * whether any corner is inside the bounds; we also check the reverse (any
6786
- * frame corner inside the marquee) to handle the case where the marquee is
6787
- * entirely contained within the rotated frame. */
6788
- function rotatedRectIntersects(
6789
- rect: MarqueeRect,
6790
- bounds: BoundsRect,
6791
- center: Point,
6792
- degrees: number,
6793
- ): boolean {
6794
- if (!degrees) {
6795
- return rectIntersects(rect, bounds);
6796
- }
6797
- // Four corners of the marquee rect in canvas space.
6798
- const marqueeCorners: Point[] = [
6799
- { x: rect.x, y: rect.y },
6800
- { x: rect.x + rect.width, y: rect.y },
6801
- { x: rect.x + rect.width, y: rect.y + rect.height },
6802
- { x: rect.x, y: rect.y + rect.height },
6803
- ];
6804
- // Test: any marquee corner inside the unrotated frame bounds?
6805
- for (const corner of marqueeCorners) {
6806
- const local = rotatePointAroundCenter(corner, center, degrees);
6807
- if (rectContainsPoint(bounds, local)) return true;
6808
- }
6809
- // Four corners of the unrotated frame in canvas space (rotate them outward).
6810
- const frameCorners: Point[] = [
6811
- { x: bounds.left, y: bounds.top },
6812
- { x: bounds.right, y: bounds.top },
6813
- { x: bounds.right, y: bounds.bottom },
6814
- { x: bounds.left, y: bounds.bottom },
6815
- ];
6816
- const rad = (degrees * Math.PI) / 180;
6817
- const cos = Math.cos(rad);
6818
- const sin = Math.sin(rad);
6819
- const marqueeRight = rect.x + rect.width;
6820
- const marqueeBottom = rect.y + rect.height;
6821
- // Test: any rotated frame corner inside the marquee rect?
6822
- for (const fc of frameCorners) {
6823
- const dx = fc.x - center.x;
6824
- const dy = fc.y - center.y;
6825
- const wx = center.x + dx * cos - dy * sin;
6826
- const wy = center.y + dx * sin + dy * cos;
6827
- if (
6828
- wx >= rect.x &&
6829
- wx <= marqueeRight &&
6830
- wy >= rect.y &&
6831
- wy <= marqueeBottom
6832
- ) {
6833
- return true;
6834
- }
6835
- }
6836
- return false;
6837
- }
6838
-
6839
7877
  function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
6840
7878
  return (
6841
7879
  a.x === b.x &&
@@ -6891,25 +7929,36 @@ export function getDraftPreviewGeometryForTool(
6891
7929
  start: Point,
6892
7930
  end: Point,
6893
7931
  hasMoved: boolean,
7932
+ modifiers?: DraftGeometryModifiers,
6894
7933
  ): FrameGeometry {
6895
- if (tool === "pen" || tool === "line" || tool === "arrow") {
6896
- return getDraftGeometryForTool(tool, start, end);
7934
+ // Note: "pen" is a valid DraftCreationTool value for board-surface/cursor
7935
+ // purposes (see getDraftCreationTool), but the pen tool never actually
7936
+ // drives this creation-drag geometry path — beginPenNodeCreation handles
7937
+ // pen gestures entirely separately via activePenPath/PenNodeDragState.
7938
+ if (tool === "line" || tool === "arrow") {
7939
+ return getDraftGeometryForTool(tool, start, end, modifiers);
6897
7940
  }
6898
7941
 
6899
7942
  if (!hasMoved) {
6900
7943
  return { x: start.x, y: start.y, width: 0, height: 0 };
6901
7944
  }
6902
7945
 
6903
- return getDraftGeometryForTool(tool, start, end);
7946
+ return getDraftGeometryForTool(tool, start, end, modifiers);
6904
7947
  }
6905
7948
 
6906
7949
  function getDraftGeometryForTool(
6907
7950
  tool: DraftCreationTool,
6908
7951
  start: Point,
6909
7952
  end: Point,
7953
+ modifiers?: DraftGeometryModifiers,
6910
7954
  ): FrameGeometry {
6911
- if (tool === "pen" || tool === "line" || tool === "arrow") {
6912
- return getPathGeometry([start, end]);
7955
+ if (tool === "line" || tool === "arrow") {
7956
+ // Shift constrains the line/arrow to 45deg increments, reusing the same
7957
+ // helper the pen tool already uses for constrained segments.
7958
+ const effectiveEnd = modifiers?.shiftKey
7959
+ ? constrainPointTo45Degrees(start, end)
7960
+ : end;
7961
+ return getPathGeometry([start, effectiveEnd]);
6913
7962
  }
6914
7963
  const options =
6915
7964
  tool === "frame"
@@ -6932,7 +7981,11 @@ function getDraftGeometryForTool(
6932
7981
  defaultWidth: DRAFT_RECT_WIDTH,
6933
7982
  defaultHeight: DRAFT_RECT_HEIGHT,
6934
7983
  };
6935
- return getDraftGeometryFromPoints(start, end, options);
7984
+ return getDraftGeometryFromPoints(start, end, {
7985
+ ...options,
7986
+ square: modifiers?.shiftKey,
7987
+ fromCenter: modifiers?.altKey,
7988
+ });
6936
7989
  }
6937
7990
 
6938
7991
  function getPathGeometry(points: readonly Point[]): FrameGeometry {
@@ -6962,35 +8015,19 @@ function createDraftPrimitive({
6962
8015
  tool,
6963
8016
  start,
6964
8017
  end,
6965
- points,
6966
8018
  moved,
6967
8019
  toolProps,
8020
+ modifiers,
6968
8021
  }: DraftPrimitiveInput): DraftPrimitive {
6969
8022
  const id = createDraftId(tool);
6970
8023
  const geometry = moved
6971
- ? getDraftGeometryForTool(tool, start, end)
6972
- : getDraftGeometryForTool(tool, start, start);
6973
- if (tool === "pen") {
6974
- const pathPoints =
6975
- points && points.length > 1
6976
- ? points
6977
- : [
6978
- start,
6979
- { x: start.x + 64, y: start.y + 24 },
6980
- { x: start.x + 128, y: start.y },
6981
- ];
6982
- return createPenDraftPrimitive(
6983
- {
6984
- nodes: pathPoints.map(createCornerNode),
6985
- closed: false,
6986
- },
6987
- {
6988
- id,
6989
- stroke: toolProps?.stroke,
6990
- strokeWidth: toolProps?.strokeWidth,
6991
- },
6992
- );
6993
- }
8024
+ ? getDraftGeometryForTool(tool, start, end, modifiers)
8025
+ : getDraftGeometryForTool(tool, start, start, modifiers);
8026
+ // Note: pen never reaches createDraftPrimitive — beginPenNodeCreation
8027
+ // commits pen paths directly via createPenDraftPrimitive (see
8028
+ // finishPenPath). The old fallback here (a hardcoded 3-point zigzag
8029
+ // placeholder path used when no freehand samples existed) was unreachable
8030
+ // dead code and has been removed.
6994
8031
  if (tool === "text") {
6995
8032
  return {
6996
8033
  id,
@@ -7003,8 +8040,11 @@ function createDraftPrimitive({
7003
8040
  };
7004
8041
  }
7005
8042
  if (tool === "line" || tool === "arrow") {
8043
+ const effectiveEnd = modifiers?.shiftKey
8044
+ ? constrainPointTo45Degrees(start, end)
8045
+ : end;
7006
8046
  const pathPoints = moved
7007
- ? [start, end]
8047
+ ? [start, effectiveEnd]
7008
8048
  : [start, { x: start.x + DRAFT_LINE_WIDTH, y: start.y }];
7009
8049
  return {
7010
8050
  id,
@@ -7030,6 +8070,31 @@ function createDraftPrimitive({
7030
8070
  };
7031
8071
  }
7032
8072
 
8073
+ /**
8074
+ * Builds the closed preview/commit path for a click-or-drag close gesture
8075
+ * (P6). Closing on the first anchor is deferred to mouseup so a drag can
8076
+ * shape the closing segment's curve: dragging sets the first anchor's
8077
+ * handleIn (mirrored across that anchor, like every other smooth-node
8078
+ * handle) so the curve eases into the start point instead of always
8079
+ * closing with a hard straight/corner segment.
8080
+ */
8081
+ function shapeClosingHandles(
8082
+ pathBefore: PenPath,
8083
+ dragPoint: Point | null,
8084
+ ): PenPath {
8085
+ const closed = closePenPath(pathBefore);
8086
+ if (!dragPoint || closed.nodes.length === 0) return closed;
8087
+
8088
+ const first = closed.nodes[0];
8089
+ const handleIn = {
8090
+ x: first.point.x - (dragPoint.x - first.point.x),
8091
+ y: first.point.y - (dragPoint.y - first.point.y),
8092
+ };
8093
+ const nodes = closed.nodes.slice();
8094
+ nodes[0] = { ...first, handleIn };
8095
+ return { nodes, closed: true };
8096
+ }
8097
+
7033
8098
  function createPenDraftPrimitive(
7034
8099
  path: PenPath,
7035
8100
  {
@@ -7280,10 +8345,19 @@ function angleBetween(center: Point, point: Point) {
7280
8345
  return (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI;
7281
8346
  }
7282
8347
 
7283
- function getSelectableBounds(geometry: FrameGeometry): BoundsRect {
8348
+ function getSelectableBounds(
8349
+ geometry: FrameGeometry,
8350
+ chromeScale = 1,
8351
+ ): BoundsRect {
7284
8352
  return {
7285
8353
  left: geometry.x,
7286
- top: geometry.y - FRAME_LABEL_HEIGHT,
8354
+ // The rendered frame-label band is FRAME_LABEL_HEIGHT * chromeScale (see
8355
+ // frameLabelHeight in the Screen renderer) — chromeScale grows as you
8356
+ // zoom out (chromeScale = 1 / scale) so the label stays a constant
8357
+ // on-screen size. Marquee/layer hit-testing must match that rendered
8358
+ // band, not a fixed canvas-space height, or the clickable label area
8359
+ // drifts out of sync with what's drawn at any zoom other than 100%.
8360
+ top: geometry.y - FRAME_LABEL_HEIGHT * chromeScale,
7287
8361
  right: geometry.x + geometry.width,
7288
8362
  bottom: geometry.y + geometry.height,
7289
8363
  };
@@ -7489,12 +8563,44 @@ function hashString(s: string): string {
7489
8563
  export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
7490
8564
  const PRIMITIVE_PARSE_CACHE_MAX = 64;
7491
8565
 
8566
+ /** First-level identity cache: most callers (e.g. drag/marquee mousemove
8567
+ * handlers) call parsePrimitivesFromScreen repeatedly per frame against the
8568
+ * *same* ScreenFile object — `screen.content` hasn't changed at all between
8569
+ * calls. Hashing the full content string on every call to build the
8570
+ * second-level cache key is wasted work in that case (PF17). Keep one most-
8571
+ * recent `{content, result}` pair per screen id and skip straight past the
8572
+ * hash when the content reference is unchanged. Only falls through to the
8573
+ * hash-keyed Map (and a real re-parse) when content has actually changed. */
8574
+ const primitiveParseIdentityCache = new Map<
8575
+ string,
8576
+ { content: string; result: ParsedScreenPrimitive[] }
8577
+ >();
8578
+
8579
+ /** Test-only escape hatch: clears both cache levels. Production code never
8580
+ * needs to call this — the identity cache self-invalidates whenever
8581
+ * `screen.content` changes, and the hash-keyed cache is bounded/LRU'd. */
8582
+ export function __clearPrimitiveParseCachesForTests() {
8583
+ primitiveParseCache.clear();
8584
+ primitiveParseIdentityCache.clear();
8585
+ }
8586
+
7492
8587
  export function parsePrimitivesFromScreen(
7493
8588
  screen: ScreenFile,
7494
8589
  ): ParsedScreenPrimitive[] {
8590
+ const identityEntry = primitiveParseIdentityCache.get(screen.id);
8591
+ if (identityEntry && identityEntry.content === screen.content) {
8592
+ return identityEntry.result;
8593
+ }
8594
+
7495
8595
  const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
7496
8596
  const cached = primitiveParseCache.get(cacheKey);
7497
- if (cached) return cached;
8597
+ if (cached) {
8598
+ primitiveParseIdentityCache.set(screen.id, {
8599
+ content: screen.content,
8600
+ result: cached,
8601
+ });
8602
+ return cached;
8603
+ }
7498
8604
 
7499
8605
  const result: ParsedScreenPrimitive[] = [];
7500
8606
  if (typeof DOMParser === "undefined" || !screen.content) {
@@ -7568,6 +8674,10 @@ export function parsePrimitivesFromScreen(
7568
8674
  if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
7569
8675
  }
7570
8676
  primitiveParseCache.set(cacheKey, result);
8677
+ primitiveParseIdentityCache.set(screen.id, {
8678
+ content: screen.content,
8679
+ result,
8680
+ });
7571
8681
  return result;
7572
8682
  }
7573
8683