@agent-native/core 0.85.6 → 0.85.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  5. package/corpus/core/src/collab/presence.ts +63 -3
  6. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  7. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  8. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  9. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  10. package/corpus/templates/design/AGENTS.md +33 -1
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  12. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  13. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  14. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  15. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  16. package/corpus/templates/design/actions/export-zip.ts +10 -1
  17. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  18. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  19. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  20. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  21. package/corpus/templates/design/actions/present-design-variants.ts +2 -2
  22. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  23. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  24. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  25. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  26. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  27. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  28. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  29. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
  30. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  32. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  33. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  34. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  35. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  36. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  37. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  38. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  39. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  40. package/corpus/templates/design/app/components/design/types.ts +16 -0
  41. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  42. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  43. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  44. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/design/app/i18n-data.ts +18 -0
  46. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  47. package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
  48. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  56. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  57. package/corpus/templates/design/shared/board-file.ts +25 -5
  58. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  59. package/corpus/templates/design/shared/code-layer.ts +304 -26
  60. package/corpus/templates/design/shared/color-utils.ts +84 -0
  61. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  62. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  63. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  64. package/corpus/templates/design/shared/pen-path.ts +200 -23
  65. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  66. package/dist/client/use-pinch-zoom.js +76 -11
  67. package/dist/client/use-pinch-zoom.js.map +1 -1
  68. package/dist/collab/awareness.d.ts +2 -2
  69. package/dist/collab/awareness.d.ts.map +1 -1
  70. package/dist/collab/presence.d.ts.map +1 -1
  71. package/dist/collab/presence.js +54 -3
  72. package/dist/collab/presence.js.map +1 -1
  73. package/dist/collab/routes.d.ts +2 -2
  74. package/dist/collab/struct-routes.d.ts +1 -1
  75. package/dist/observability/routes.d.ts +3 -3
  76. package/dist/progress/routes.d.ts +1 -1
  77. package/dist/resources/handlers.d.ts +2 -2
  78. package/dist/secrets/routes.d.ts +3 -3
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. 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]);
@@ -1436,8 +1621,39 @@ export function MultiScreenCanvas({
1436
1621
  }, [selectedDraftIds]);
1437
1622
 
1438
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);
1439
1652
  setCanvasZoom(zoom);
1440
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();
1441
1657
  }, [zoom]);
1442
1658
 
1443
1659
  useEffect(() => {
@@ -1703,8 +1919,12 @@ export function MultiScreenCanvas({
1703
1919
 
1704
1920
  const postHitTestPreviewClear = (targetId: string | null | undefined) => {
1705
1921
  if (!targetId) return;
1922
+ const targetScreen = screensRef.current.find((s) => s.id === targetId);
1923
+ const iframeId = targetScreen
1924
+ ? getActiveScreenIframeId(targetScreen)
1925
+ : targetId;
1706
1926
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1707
- `[data-screen-iframe-id="${CSS.escape(targetId)}"]`,
1927
+ `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
1708
1928
  );
1709
1929
  targetIframe?.contentWindow?.postMessage(
1710
1930
  { type: "agent-native:hit-test-preview-clear" },
@@ -1736,6 +1956,9 @@ export function MultiScreenCanvas({
1736
1956
  clearCrossScreenDropGuide();
1737
1957
  crossScreenTargetRef.current = null;
1738
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();
1739
1962
  };
1740
1963
 
1741
1964
  const runHitTest = (
@@ -1748,8 +1971,9 @@ export function MultiScreenCanvas({
1748
1971
  );
1749
1972
  if (!targetScreen) return Promise.resolve({});
1750
1973
  const targetGeometry = candidate.geometry;
1974
+ const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
1751
1975
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1752
- `[data-screen-iframe-id="${CSS.escape(candidate.id)}"]`,
1976
+ `[data-screen-iframe-id="${targetIframeId}"]`,
1753
1977
  );
1754
1978
  const targetContentWindow = targetIframe?.contentWindow;
1755
1979
  if (!targetContentWindow) return Promise.resolve({});
@@ -1769,20 +1993,28 @@ export function MultiScreenCanvas({
1769
1993
  return new Promise((resolve) => {
1770
1994
  const timer = window.setTimeout(() => {
1771
1995
  window.removeEventListener("message", hitListener);
1772
- resolve({});
1773
- }, 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);
1774
2002
 
1775
2003
  const hitListener = (ev: MessageEvent) => {
1776
2004
  if (
1777
2005
  !ev.data ||
1778
2006
  ev.data.type !== "agent-native:hit-test-result" ||
1779
- 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
1780
2012
  ) {
1781
2013
  return;
1782
2014
  }
1783
2015
  window.clearTimeout(timer);
1784
2016
  window.removeEventListener("message", hitListener);
1785
- resolve({
2017
+ const result: CrossScreenHitTestResult = {
1786
2018
  anchorNodeId:
1787
2019
  typeof ev.data.anchorNodeId === "string"
1788
2020
  ? ev.data.anchorNodeId
@@ -1799,7 +2031,9 @@ export function MultiScreenCanvas({
1799
2031
  anchorRect: isCrossScreenHitTestAnchorRect(ev.data.anchorRect)
1800
2032
  ? ev.data.anchorRect
1801
2033
  : undefined,
1802
- });
2034
+ };
2035
+ crossScreenLastHitResultRef.current.set(candidate.id, result);
2036
+ resolve(result);
1803
2037
  };
1804
2038
  window.addEventListener("message", hitListener);
1805
2039
 
@@ -1828,8 +2062,9 @@ export function MultiScreenCanvas({
1828
2062
  (s) => s.id === candidate.id,
1829
2063
  );
1830
2064
  if (!targetScreen) return null;
2065
+ const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
1831
2066
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
1832
- `[data-screen-iframe-id="${CSS.escape(candidate.id)}"]`,
2067
+ `[data-screen-iframe-id="${targetIframeId}"]`,
1833
2068
  );
1834
2069
  const targetViewportWidth =
1835
2070
  targetIframe?.clientWidth || getResolvedMetadata(targetScreen).width;
@@ -1978,6 +2213,31 @@ export function MultiScreenCanvas({
1978
2213
  if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
1979
2214
  return;
1980
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;
1981
2241
  const msg = event.data as {
1982
2242
  type: string;
1983
2243
  phase: "start" | "move" | "end" | "cancel";
@@ -2012,12 +2272,17 @@ export function MultiScreenCanvas({
2012
2272
  return;
2013
2273
  }
2014
2274
 
2015
- // Prefer the iframe-supplied source id. In overview, activeId can point
2016
- // 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.
2017
2282
  const sourceScreenId =
2018
- msg.screenId &&
2019
- (msg.screenId === boardFileId || frameGeometryRef.current[msg.screenId])
2020
- ? msg.screenId
2283
+ domScreenId &&
2284
+ (domScreenId === boardFileId || frameGeometryRef.current[domScreenId])
2285
+ ? domScreenId
2021
2286
  : activeId;
2022
2287
  if (!sourceScreenId) {
2023
2288
  // Always clear visual state (ghost + highlight) when we have no active
@@ -2042,6 +2307,23 @@ export function MultiScreenCanvas({
2042
2307
  surfaceRef.current,
2043
2308
  );
2044
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
+ };
2045
2327
  const activateParentDrag = (ev: MouseEvent) => {
2046
2328
  ev.preventDefault();
2047
2329
  updateCrossScreenTargetFromBoardPoint(
@@ -2050,9 +2332,26 @@ export function MultiScreenCanvas({
2050
2332
  );
2051
2333
  };
2052
2334
  const handleParentMouseMove = (ev: MouseEvent) => {
2053
- 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
+ }
2054
2349
  };
2055
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();
2056
2355
  activateParentDrag(ev);
2057
2356
  const candidate = crossScreenTargetRef.current;
2058
2357
  const payload = crossScreenDragMsgRef.current ?? {
@@ -2071,11 +2370,13 @@ export function MultiScreenCanvas({
2071
2370
  );
2072
2371
  };
2073
2372
  const handleParentWindowBlur = () => {
2373
+ cancelPendingParentDrag();
2074
2374
  clearCrossScreenDrag();
2075
2375
  };
2076
2376
  const cleanup = () => {
2077
2377
  if (didCleanup) return;
2078
2378
  didCleanup = true;
2379
+ cancelPendingParentDrag();
2079
2380
  window.removeEventListener("mousemove", handleParentMouseMove, true);
2080
2381
  window.removeEventListener("mouseup", handleParentMouseUp, true);
2081
2382
  window.removeEventListener("blur", handleParentWindowBlur, true);
@@ -2265,24 +2566,57 @@ export function MultiScreenCanvas({
2265
2566
  surfaceRef.current,
2266
2567
  );
2267
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
+ };
2268
2586
  const move = (ev: MouseEvent) => {
2269
2587
  lastMouseEvent = ev;
2270
2588
  ev.preventDefault();
2271
- handleMouseMove(ev);
2589
+ if (pendingMoveFrame !== null) return;
2590
+ pendingMoveFrame = window.requestAnimationFrame(() => {
2591
+ pendingMoveFrame = null;
2592
+ if (lastMouseEvent) handleMouseMove(lastMouseEvent);
2593
+ });
2272
2594
  };
2273
2595
  const up = (ev: MouseEvent) => {
2274
2596
  lastMouseEvent = ev;
2275
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();
2276
2601
  handleMouseUp(ev);
2277
2602
  };
2278
2603
  const cleanupOnBlur = () => {
2279
2604
  if (handleCancel) {
2605
+ if (pendingMoveFrame !== null) {
2606
+ window.cancelAnimationFrame(pendingMoveFrame);
2607
+ pendingMoveFrame = null;
2608
+ }
2280
2609
  handleCancel();
2281
2610
  return;
2282
2611
  }
2612
+ flushPendingMove();
2283
2613
  handleMouseUp(lastMouseEvent ?? new MouseEvent("mouseup"));
2284
2614
  };
2285
2615
  dragCleanup.current = () => {
2616
+ if (pendingMoveFrame !== null) {
2617
+ window.cancelAnimationFrame(pendingMoveFrame);
2618
+ pendingMoveFrame = null;
2619
+ }
2286
2620
  window.removeEventListener("mousemove", move);
2287
2621
  window.removeEventListener("mouseup", up);
2288
2622
  window.removeEventListener("blur", cleanupOnBlur);
@@ -2298,8 +2632,12 @@ export function MultiScreenCanvas({
2298
2632
 
2299
2633
  const requestSelectableElementInfos = useCallback(
2300
2634
  (screenId: string): Promise<ElementInfo[]> => {
2635
+ const targetScreen = screensRef.current.find((s) => s.id === screenId);
2636
+ const iframeId = targetScreen
2637
+ ? getActiveScreenIframeId(targetScreen)
2638
+ : screenId;
2301
2639
  const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2302
- `[data-screen-iframe-id="${CSS.escape(screenId)}"]`,
2640
+ `[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
2303
2641
  );
2304
2642
  const targetContentWindow = targetIframe?.contentWindow;
2305
2643
  if (!targetContentWindow) return Promise.resolve([]);
@@ -2315,7 +2653,11 @@ export function MultiScreenCanvas({
2315
2653
  if (
2316
2654
  !event.data ||
2317
2655
  event.data.type !== "agent-native:selectable-rects-result" ||
2318
- 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
2319
2661
  ) {
2320
2662
  return;
2321
2663
  }
@@ -2347,58 +2689,76 @@ export function MultiScreenCanvas({
2347
2689
  [],
2348
2690
  );
2349
2691
 
2350
- const collectLayerMarqueeCandidates = useCallback(async () => {
2351
- const frameCandidates = await Promise.all(
2352
- getCurrentFrameEntries().map(async (entry) => {
2353
- const screen = screensRef.current.find((item) => item.id === entry.id);
2354
- if (!screen) return [] as CanvasLayerMarqueeCandidate[];
2355
- const iframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
2356
- `[data-screen-iframe-id="${CSS.escape(entry.id)}"]`,
2357
- );
2358
- const metadata = getResolvedMetadata(screen);
2359
- const viewportWidth = iframe?.clientWidth || metadata.width;
2360
- const viewportHeight = iframe?.clientHeight || metadata.height;
2361
- const scaleX = entry.geometry.width / Math.max(1, viewportWidth);
2362
- const scaleY = entry.geometry.height / Math.max(1, viewportHeight);
2363
- const infos = await requestSelectableElementInfos(entry.id);
2364
- return infos.map((info) => ({
2365
- screenId: entry.id,
2366
- info,
2367
- geometry: {
2368
- x: entry.geometry.x + info.boundingRect.x * scaleX,
2369
- y: entry.geometry.y + info.boundingRect.y * scaleY,
2370
- width: info.boundingRect.width * scaleX,
2371
- height: info.boundingRect.height * scaleY,
2372
- },
2373
- frameGeometry: entry.geometry,
2374
- }));
2375
- }),
2376
- );
2377
- const boardCandidates =
2378
- boardFileId && boardFrameGeometry
2379
- ? await (async () => {
2380
- const infos = await requestSelectableElementInfos(boardFileId);
2381
- return infos.map((info) => ({
2382
- screenId: boardFileId,
2383
- info,
2384
- geometry: {
2385
- x: boardFrameGeometry.x + info.boundingRect.x,
2386
- y: boardFrameGeometry.y + info.boundingRect.y,
2387
- width: info.boundingRect.width,
2388
- height: info.boundingRect.height,
2389
- },
2390
- frameGeometry: boardFrameGeometry,
2391
- }));
2392
- })()
2393
- : [];
2394
- return [...frameCandidates.flat(), ...boardCandidates];
2395
- }, [
2396
- boardFileId,
2397
- boardFrameGeometry,
2398
- getCurrentFrameEntries,
2399
- getResolvedMetadata,
2400
- requestSelectableElementInfos,
2401
- ]);
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
+ );
2402
2762
 
2403
2763
  const scheduleFeedbackClear = useCallback(() => {
2404
2764
  if (feedbackTimerRef.current !== null) {
@@ -2437,11 +2797,20 @@ export function MultiScreenCanvas({
2437
2797
  window.innerHeight - TRANSFORM_BADGE_EDGE_PADDING
2438
2798
  ? clientY + TRANSFORM_BADGE_OFFSET
2439
2799
  : clientY - TRANSFORM_BADGE_HEIGHT - TRANSFORM_BADGE_OFFSET;
2440
- setTransformBadge({
2441
- text,
2442
- x: clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX),
2443
- y: clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY),
2444
- });
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
+ );
2445
2814
  },
2446
2815
  [],
2447
2816
  );
@@ -2529,6 +2898,7 @@ export function MultiScreenCanvas({
2529
2898
  setMarquee(null);
2530
2899
  setCreationPreview(null);
2531
2900
  setAlignmentGuides([]);
2901
+ setEqualGapGuides([]);
2532
2902
  setTransformBadge(null);
2533
2903
  setDragCursor(null);
2534
2904
  primitiveDropTargetRef.current = null;
@@ -2546,7 +2916,11 @@ export function MultiScreenCanvas({
2546
2916
 
2547
2917
  if (state) {
2548
2918
  cancelled = true;
2549
- if (state.type === "move" || state.type === "resize") {
2919
+ if (
2920
+ state.type === "move" ||
2921
+ state.type === "resize" ||
2922
+ state.type === "group-rotate"
2923
+ ) {
2550
2924
  updateFrameGeometry((current) =>
2551
2925
  frameGeometryWithOverrides(current, state.originFrames),
2552
2926
  );
@@ -2629,10 +3003,25 @@ export function MultiScreenCanvas({
2629
3003
  y: state.originPan.y + ev.clientY - state.originClient.y,
2630
3004
  };
2631
3005
  panRef.current = nextPan;
2632
- 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();
2633
3011
  };
2634
3012
 
2635
- 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);
2636
3025
  },
2637
3026
  [finishDrag, installDragListeners],
2638
3027
  );
@@ -2644,14 +3033,25 @@ export function MultiScreenCanvas({
2644
3033
  const originCanvas = getCanvasPoint(e.clientX, e.clientY);
2645
3034
  let latestRect = normalizeRectFromPoints(originCanvas, originCanvas);
2646
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>();
2647
3046
  const reportLayerSelection = (rect: MarqueeRect) => {
2648
3047
  const state = dragState.current;
2649
3048
  if (!state || state.type !== "marquee") return;
3049
+ const chromeScale = chromeScaleFromZoom(zoomRef.current);
2650
3050
  const selection = layerCandidates
2651
3051
  .filter((candidate) =>
2652
3052
  rotatedRectIntersects(
2653
3053
  rect,
2654
- getSelectableBounds(candidate.geometry),
3054
+ getSelectableBounds(candidate.geometry, chromeScale),
2655
3055
  getFrameCenter(candidate.frameGeometry),
2656
3056
  candidate.frameGeometry.rotation ?? 0,
2657
3057
  ),
@@ -2666,6 +3066,34 @@ export function MultiScreenCanvas({
2666
3066
  shiftKey: state.additive,
2667
3067
  });
2668
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
+ };
2669
3097
  dragState.current = {
2670
3098
  type: "marquee",
2671
3099
  originClient: { x: e.clientX, y: e.clientY },
@@ -2686,11 +3114,10 @@ export function MultiScreenCanvas({
2686
3114
  });
2687
3115
  }
2688
3116
  setIsDragging(true);
2689
- void collectLayerMarqueeCandidates().then((candidates) => {
2690
- if (dragState.current?.type !== "marquee") return;
2691
- layerCandidates = candidates;
2692
- reportLayerSelection(latestRect);
2693
- });
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([]);
2694
3121
 
2695
3122
  const handleMouseMove = (ev: MouseEvent) => {
2696
3123
  const state = dragState.current;
@@ -2709,11 +3136,12 @@ export function MultiScreenCanvas({
2709
3136
  }
2710
3137
  setMarquee(rect);
2711
3138
 
3139
+ const chromeScale = chromeScaleFromZoom(zoomRef.current);
2712
3140
  const hitIds = getCurrentFrameEntries()
2713
3141
  .filter((entry) =>
2714
3142
  rotatedRectIntersects(
2715
3143
  rect,
2716
- getSelectableBounds(entry.geometry),
3144
+ getSelectableBounds(entry.geometry, chromeScale),
2717
3145
  getFrameCenter(entry.geometry),
2718
3146
  entry.geometry.rotation ?? 0,
2719
3147
  ),
@@ -2723,21 +3151,23 @@ export function MultiScreenCanvas({
2723
3151
  .filter((entry) =>
2724
3152
  rotatedRectIntersects(
2725
3153
  rect,
2726
- getSelectableBounds(entry.geometry),
3154
+ getSelectableBounds(entry.geometry, chromeScale),
2727
3155
  getFrameCenter(entry.geometry),
2728
3156
  entry.geometry.rotation ?? 0,
2729
3157
  ),
2730
3158
  )
2731
3159
  .map((entry) => entry.id);
2732
3160
 
3161
+ collectForIntersectedScreens(hitIds);
3162
+
2733
3163
  updateSelectedIds(() =>
2734
3164
  state.additive
2735
- ? dedupeIds([...state.baseSelectedIds, ...hitIds])
3165
+ ? xorMarqueeSelection(state.baseSelectedIds, hitIds)
2736
3166
  : hitIds,
2737
3167
  );
2738
3168
  updateSelectedDraftIds(() =>
2739
3169
  state.additive
2740
- ? dedupeIds([...state.baseSelectedDraftIds, ...hitDraftIds])
3170
+ ? xorMarqueeSelection(state.baseSelectedDraftIds, hitDraftIds)
2741
3171
  : hitDraftIds,
2742
3172
  );
2743
3173
  reportLayerSelection(rect);
@@ -2760,6 +3190,8 @@ export function MultiScreenCanvas({
2760
3190
  installDragListeners(handleMouseMove, handleMouseUp);
2761
3191
  },
2762
3192
  [
3193
+ boardFileId,
3194
+ boardFrameGeometry,
2763
3195
  collectLayerMarqueeCandidates,
2764
3196
  finishDrag,
2765
3197
  getCanvasPoint,
@@ -3029,15 +3461,24 @@ export function MultiScreenCanvas({
3029
3461
  ) => {
3030
3462
  const rawPoint = getCanvasPoint(clientX, clientY);
3031
3463
  const lastAnchor = path?.nodes[path.nodes.length - 1]?.point;
3032
- return shiftKey && lastAnchor
3033
- ? constrainPointTo45Degrees(lastAnchor, rawPoint)
3034
- : 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
+ });
3035
3475
  },
3036
3476
  [getCanvasPoint],
3037
3477
  );
3038
3478
 
3039
3479
  const updatePenPointer = useCallback(
3040
3480
  (clientX: number, clientY: number, shiftKey: boolean) => {
3481
+ lastPenClientPointRef.current = { clientX, clientY, shiftKey };
3041
3482
  const path = activePenPathRef.current;
3042
3483
  if (!path || path.closed) {
3043
3484
  setPenPointer(null);
@@ -3061,35 +3502,54 @@ export function MultiScreenCanvas({
3061
3502
  [getCanvasPoint, getPenAnchorPoint],
3062
3503
  );
3063
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
+
3064
3519
  const beginPenNodeCreation = useCallback(
3065
3520
  (e: React.MouseEvent) => {
3066
3521
  if (e.button !== 0) return;
3067
3522
  e.preventDefault();
3068
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
+ }
3069
3530
  suppressNextPick.current = true;
3070
3531
 
3071
3532
  const pathBefore = activePenPathRef.current?.closed
3072
3533
  ? null
3073
3534
  : activePenPathRef.current;
3074
3535
  const rawPoint = getCanvasPoint(e.clientX, e.clientY);
3075
- if (
3536
+ const closing = Boolean(
3076
3537
  pathBefore &&
3077
3538
  isPenCloseTarget(
3078
3539
  pathBefore,
3079
3540
  rawPoint,
3080
3541
  PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
3081
- )
3082
- ) {
3083
- finishPenPath(closePenPath(pathBefore));
3084
- return;
3085
- }
3086
-
3087
- const anchor = getPenAnchorPoint(
3088
- e.clientX,
3089
- e.clientY,
3090
- e.shiftKey,
3091
- pathBefore,
3542
+ ),
3092
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);
3093
3553
  const pathSnapshot = pathBefore ? clonePenPath(pathBefore) : null;
3094
3554
  dragState.current = {
3095
3555
  type: "pen-node",
@@ -3097,13 +3557,18 @@ export function MultiScreenCanvas({
3097
3557
  anchor,
3098
3558
  pathBefore: pathSnapshot,
3099
3559
  hasMoved: false,
3560
+ closing,
3100
3561
  };
3101
- const initialPath = appendPenNode(pathSnapshot, createCornerNode(anchor));
3562
+ const initialPath = closing
3563
+ ? (pathSnapshot as PenPath)
3564
+ : appendPenNode(pathSnapshot, createCornerNode(anchor));
3102
3565
  activePenPathRef.current = initialPath;
3103
3566
  setActivePenPath(initialPath);
3104
- setPenGesturePreview(initialPath);
3567
+ setPenGesturePreview(
3568
+ closing ? closePenPath(pathSnapshot as PenPath) : initialPath,
3569
+ );
3105
3570
  setPenPointer(null);
3106
- setPenCloseHover(false);
3571
+ setPenCloseHover(closing);
3107
3572
  setIsDragging(true);
3108
3573
  setDragCursor("crosshair");
3109
3574
 
@@ -3121,13 +3586,31 @@ export function MultiScreenCanvas({
3121
3586
  }
3122
3587
 
3123
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
+
3124
3606
  const node = state.hasMoved
3125
- ? createSmoothNode(
3126
- state.anchor,
3127
- ev.shiftKey
3128
- ? constrainPointTo45Degrees(state.anchor, handlePoint)
3129
- : handlePoint,
3130
- )
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
+ })
3131
3614
  : createCornerNode(state.anchor);
3132
3615
  const nextPath = appendPenNode(state.pathBefore, node);
3133
3616
  activePenPathRef.current = nextPath;
@@ -3143,13 +3626,27 @@ export function MultiScreenCanvas({
3143
3626
  }
3144
3627
 
3145
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
+
3146
3646
  const node: PenNode = state.hasMoved
3147
- ? createSmoothNode(
3148
- state.anchor,
3149
- ev.shiftKey
3150
- ? constrainPointTo45Degrees(state.anchor, handlePoint)
3151
- : handlePoint,
3152
- )
3647
+ ? createSmoothNode(state.anchor, handleOut, {
3648
+ breakSymmetry: ev.altKey,
3649
+ })
3153
3650
  : createCornerNode(state.anchor);
3154
3651
  const nextPath = appendPenNode(state.pathBefore, node);
3155
3652
  activePenPathRef.current = nextPath;
@@ -3200,14 +3697,12 @@ export function MultiScreenCanvas({
3200
3697
  false,
3201
3698
  );
3202
3699
  const initialPoints =
3203
- tool === "pen"
3204
- ? [originCanvas]
3205
- : tool === "line" || tool === "arrow"
3206
- ? [
3207
- originCanvas,
3208
- { x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
3209
- ]
3210
- : undefined;
3700
+ tool === "line" || tool === "arrow"
3701
+ ? [
3702
+ originCanvas,
3703
+ { x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
3704
+ ]
3705
+ : undefined;
3211
3706
  dragState.current = {
3212
3707
  type: "draft-create",
3213
3708
  tool,
@@ -3223,7 +3718,7 @@ export function MultiScreenCanvas({
3223
3718
  points: initialPoints,
3224
3719
  });
3225
3720
  setIsDragging(true);
3226
- setDragCursor(tool === "pen" ? "crosshair" : "crosshair");
3721
+ setDragCursor("crosshair");
3227
3722
 
3228
3723
  const handleMouseMove = (ev: MouseEvent) => {
3229
3724
  const state = dragState.current;
@@ -3239,22 +3734,15 @@ export function MultiScreenCanvas({
3239
3734
  state.hasMoved = true;
3240
3735
  }
3241
3736
 
3242
- if (state.tool === "pen") {
3243
- const minDistance =
3244
- PEN_SAMPLE_DISTANCE_SCREEN_PX / (zoomRef.current / 100);
3245
- state.points = appendPolylinePoint(
3246
- state.points,
3247
- nextCanvas,
3248
- minDistance,
3249
- );
3250
- setCreationPreview({
3251
- tool,
3252
- geometry: getPathGeometry(state.points),
3253
- points: state.points,
3254
- });
3255
- return;
3256
- }
3257
-
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;
3258
3746
  setCreationPreview({
3259
3747
  tool,
3260
3748
  geometry: getDraftPreviewGeometryForTool(
@@ -3262,11 +3750,9 @@ export function MultiScreenCanvas({
3262
3750
  state.originCanvas,
3263
3751
  nextCanvas,
3264
3752
  state.hasMoved,
3753
+ modifiers,
3265
3754
  ),
3266
- points:
3267
- state.tool === "line" || state.tool === "arrow"
3268
- ? [state.originCanvas, nextCanvas]
3269
- : undefined,
3755
+ points: isLineTool ? [state.originCanvas, previewEnd] : undefined,
3270
3756
  });
3271
3757
  };
3272
3758
 
@@ -3277,7 +3763,7 @@ export function MultiScreenCanvas({
3277
3763
  return;
3278
3764
  }
3279
3765
 
3280
- let endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
3766
+ const endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
3281
3767
  const canvasMoved =
3282
3768
  Math.hypot(
3283
3769
  endCanvas.x - state.originCanvas.x,
@@ -3291,18 +3777,18 @@ export function MultiScreenCanvas({
3291
3777
  ) >= DRAG_THRESHOLD ||
3292
3778
  canvasMoved;
3293
3779
  state.hasMoved = releaseMoved;
3294
- let points = state.tool === "pen" ? state.points : undefined;
3295
- if (state.tool === "pen") {
3296
- points = appendPolylinePoint(state.points, endCanvas, 0);
3297
- if (!releaseMoved || points.length < 2) {
3298
- finishDrag();
3299
- return;
3300
- }
3301
- endCanvas = points[points.length - 1] ?? endCanvas;
3302
- }
3780
+ const modifiers: DraftGeometryModifiers = {
3781
+ shiftKey: ev.shiftKey,
3782
+ altKey: ev.altKey,
3783
+ };
3303
3784
  if (state.tool === "frame" && onCreateScreenFrame) {
3304
3785
  onCreateScreenFrame(
3305
- getDraftGeometryForTool(state.tool, state.originCanvas, endCanvas),
3786
+ getDraftGeometryForTool(
3787
+ state.tool,
3788
+ state.originCanvas,
3789
+ endCanvas,
3790
+ modifiers,
3791
+ ),
3306
3792
  );
3307
3793
  if (activeTool === undefined) {
3308
3794
  setLocalActiveTool("move");
@@ -3315,22 +3801,15 @@ export function MultiScreenCanvas({
3315
3801
  tool: state.tool,
3316
3802
  start: state.originCanvas,
3317
3803
  end: endCanvas,
3318
- points,
3319
3804
  moved: releaseMoved,
3320
3805
  toolProps,
3806
+ modifiers,
3321
3807
  });
3322
3808
  commitDraftPrimitive(nextDraft, state.originFrameId);
3323
3809
  if (activeTool === undefined) {
3324
3810
  setLocalActiveTool("move");
3325
3811
  }
3326
- if (state.tool === "pen") {
3327
- if (activeTool === undefined) {
3328
- setLocalActiveTool("pen");
3329
- }
3330
- onActiveToolChange?.("pen");
3331
- } else {
3332
- onActiveToolChange?.("move");
3333
- }
3812
+ onActiveToolChange?.("move");
3334
3813
  finishDrag();
3335
3814
  };
3336
3815
 
@@ -3726,8 +4205,8 @@ export function MultiScreenCanvas({
3726
4205
  const state = dragState.current;
3727
4206
  if (!state || state.type !== "move") return;
3728
4207
  const scale = zoomRef.current / 100;
3729
- const dx = (ev.clientX - state.originClient.x) / scale;
3730
- 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;
3731
4210
  if (
3732
4211
  !state.hasMoved &&
3733
4212
  Math.hypot(
@@ -3738,6 +4217,26 @@ export function MultiScreenCanvas({
3738
4217
  state.hasMoved = true;
3739
4218
  }
3740
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
+
3741
4240
  const movingEntries = state.targetIds.map((targetId) => ({
3742
4241
  id: targetId,
3743
4242
  geometry: {
@@ -3769,6 +4268,51 @@ export function MultiScreenCanvas({
3769
4268
  });
3770
4269
  setAlignmentGuides(snap.guides);
3771
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
+
3772
4316
  // When all dragged ids are committed primitive nodeIds (not screen
3773
4317
  // frames), check for a container primitive drop target to highlight.
3774
4318
  const currentFrameIds = Object.keys(frameGeometryRef.current);
@@ -3786,6 +4330,15 @@ export function MultiScreenCanvas({
3786
4330
  const handleMouseUp = () => {
3787
4331
  const state = dragState.current;
3788
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
+ }
3789
4342
  if (state?.type === "move" && state.hasMoved) {
3790
4343
  // If all dragged ids are committed primitive nodeIds (not screen
3791
4344
  // frames), attempt a primitive reparent on drop.
@@ -3831,6 +4384,7 @@ export function MultiScreenCanvas({
3831
4384
  installDragListeners,
3832
4385
  onPick,
3833
4386
  resolvePrimitiveScreenId,
4387
+ showTransformFeedback,
3834
4388
  updateFrameGeometry,
3835
4389
  updatePrimitiveDropTarget,
3836
4390
  updateSelectedDraftIds,
@@ -3872,7 +4426,19 @@ export function MultiScreenCanvas({
3872
4426
  hasMoved: false,
3873
4427
  };
3874
4428
  setIsDragging(true);
3875
- 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
+ );
3876
4442
 
3877
4443
  const handleMouseMove = (ev: MouseEvent) => {
3878
4444
  const state = dragState.current;
@@ -3890,10 +4456,58 @@ export function MultiScreenCanvas({
3890
4456
  state.hasMoved = true;
3891
4457
  }
3892
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
+
3893
4463
  const originEntries = state.targetIds.map((targetId) => ({
3894
4464
  id: targetId,
3895
4465
  geometry: state.originFrames[targetId],
3896
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
+
3897
4511
  const resized = resizeFrameGroupFromDelta(
3898
4512
  originEntries,
3899
4513
  state.originBounds,
@@ -3917,6 +4531,11 @@ export function MultiScreenCanvas({
3917
4531
  thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
3918
4532
  zoom: zoomRef.current,
3919
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,
3920
4539
  },
3921
4540
  );
3922
4541
  const resizedEntries = resizeFrameGroupToBounds(
@@ -3944,6 +4563,12 @@ export function MultiScreenCanvas({
3944
4563
 
3945
4564
  const handleMouseUp = () => {
3946
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
+ }
3947
4572
  if (state?.type === "resize" && state.hasMoved) {
3948
4573
  const after = cloneFrameGeometryById(frameGeometryRef.current);
3949
4574
  onGeometryCommitRef.current?.(
@@ -4020,6 +4645,10 @@ export function MultiScreenCanvas({
4020
4645
  ) {
4021
4646
  state.hasMoved = true;
4022
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
+
4023
4652
  const pointer = getCanvasPoint(ev.clientX, ev.clientY);
4024
4653
  const center = getFrameCenter(state.originFrame);
4025
4654
  const raw =
@@ -4043,6 +4672,13 @@ export function MultiScreenCanvas({
4043
4672
 
4044
4673
  const handleMouseUp = () => {
4045
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
+ }
4046
4682
  if (state?.type === "rotate" && state.hasMoved) {
4047
4683
  const after = cloneFrameGeometryById(frameGeometryRef.current);
4048
4684
  onGeometryCommitRef.current?.(
@@ -4071,6 +4707,119 @@ export function MultiScreenCanvas({
4071
4707
  ],
4072
4708
  );
4073
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
+
4074
4823
  const handleFrameClick = useCallback(
4075
4824
  (id: string, e: React.MouseEvent<HTMLElement>) => {
4076
4825
  e.stopPropagation();
@@ -4153,6 +4902,11 @@ export function MultiScreenCanvas({
4153
4902
  canDuplicate: !!onDuplicate,
4154
4903
  moved: false,
4155
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);
4156
4910
 
4157
4911
  const handleMouseMove = (ev: MouseEvent) => {
4158
4912
  const dx = ev.clientX - origin.x;
@@ -4165,22 +4919,24 @@ export function MultiScreenCanvas({
4165
4919
  y: rect ? ev.clientY - rect.top + 16 : ev.clientY,
4166
4920
  width: previewWidth,
4167
4921
  height: previewHeight,
4168
- 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,
4169
4927
  moved,
4170
4928
  });
4171
4929
  };
4172
4930
 
4173
4931
  const cleanupDuplicateGesture = () => {
4174
4932
  setDuplicatePreview(null);
4175
- window.removeEventListener("mousemove", handleMouseMove);
4176
- window.removeEventListener("mouseup", handleMouseUp);
4177
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
4178
- window.removeEventListener("blur", handleBlur);
4179
4933
  duplicateCleanup.current = null;
4180
- };
4181
-
4182
- const handleBlur = () => {
4183
- 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();
4184
4940
  };
4185
4941
 
4186
4942
  const handleMouseUp = (ev: MouseEvent) => {
@@ -4188,22 +4944,27 @@ export function MultiScreenCanvas({
4188
4944
  Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
4189
4945
  DUPLICATE_DRAG_THRESHOLD;
4190
4946
  const mode = moved ? "alt-drag" : "alt-click";
4191
-
4192
- 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) {
4193
4956
  const dropCanvasPosition = canvasPointFromClient(
4194
4957
  ev.clientX,
4195
4958
  ev.clientY,
4196
4959
  );
4197
- const canvasPosition =
4198
- moved || !sourceFrame
4199
- ? {
4200
- x: dropCanvasPosition.x - pointerOffset.x,
4201
- y: dropCanvasPosition.y - pointerOffset.y,
4202
- }
4203
- : {
4204
- x: sourceFrame.geometry.x + SCREEN_GAP / 2,
4205
- y: sourceFrame.geometry.y + SCREEN_GAP / 2,
4206
- };
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
+ };
4207
4968
  onDuplicate(screen.id, {
4208
4969
  mode,
4209
4970
  screen,
@@ -4219,11 +4980,20 @@ export function MultiScreenCanvas({
4219
4980
  };
4220
4981
 
4221
4982
  duplicateCleanup.current = cleanupDuplicateGesture;
4222
- window.addEventListener("mousemove", handleMouseMove);
4223
- window.addEventListener("mouseup", handleMouseUp);
4224
- window.addEventListener("blur", handleBlur);
4983
+ installDragListeners(
4984
+ handleMouseMove,
4985
+ handleMouseUp,
4986
+ cleanupDuplicateGesture,
4987
+ );
4225
4988
  },
4226
- [canvasPointFromClient, getCurrentFrameEntries, onDuplicate, onPick],
4989
+ [
4990
+ canvasPointFromClient,
4991
+ finishDrag,
4992
+ getCurrentFrameEntries,
4993
+ installDragListeners,
4994
+ onDuplicate,
4995
+ onPick,
4996
+ ],
4227
4997
  );
4228
4998
 
4229
4999
  const handleMouseDown = useCallback(
@@ -4298,6 +5068,20 @@ export function MultiScreenCanvas({
4298
5068
  grid.style.backgroundPosition = `${p.x}px ${p.y}px`;
4299
5069
  grid.style.backgroundSize = `${nextScale}px ${nextScale}px`;
4300
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
+ }
4301
5085
  }, []);
4302
5086
 
4303
5087
  const startChromeSettle = useCallback(() => {
@@ -4319,7 +5103,11 @@ export function MultiScreenCanvas({
4319
5103
  setCanvasZoom(zoomRef.current);
4320
5104
  setPan(panRef.current);
4321
5105
  onZoomChange?.(zoomRef.current);
4322
- }, [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]);
4323
5111
 
4324
5112
  // Debounced: only commit to React state once the gesture has been idle for a
4325
5113
  // beat, so a continuous pinch produces zero re-renders until the user pauses.
@@ -4335,6 +5123,8 @@ export function MultiScreenCanvas({
4335
5123
  },
4336
5124
  [commitView],
4337
5125
  );
5126
+ applyViewToDomRef.current = applyViewToDom;
5127
+ scheduleViewCommitRef.current = scheduleViewCommit;
4338
5128
 
4339
5129
  const flushPendingWheelGesture = useCallback(() => {
4340
5130
  wheelGestureFrameRef.current = null;
@@ -4462,6 +5252,15 @@ export function MultiScreenCanvas({
4462
5252
 
4463
5253
  const handleWheelEvent = useCallback(
4464
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;
4465
5264
  event.preventDefault();
4466
5265
  event.stopPropagation();
4467
5266
  enqueueWheelGestureFromClient({
@@ -4663,6 +5462,19 @@ export function MultiScreenCanvas({
4663
5462
 
4664
5463
  const primaryKey = event.metaKey || event.ctrlKey;
4665
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
+ }
4666
5478
  event.preventDefault();
4667
5479
  event.stopPropagation();
4668
5480
  event.stopImmediatePropagation();
@@ -4684,7 +5496,12 @@ export function MultiScreenCanvas({
4684
5496
  event.preventDefault();
4685
5497
  event.stopPropagation();
4686
5498
  event.stopImmediatePropagation();
4687
- 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);
4688
5505
  return;
4689
5506
  }
4690
5507
 
@@ -4700,15 +5517,28 @@ export function MultiScreenCanvas({
4700
5517
  return () => {
4701
5518
  window.removeEventListener("keydown", handleKeyDown, true);
4702
5519
  };
4703
- }, [clearActivePenPath, finishPenPath, undoActivePenPathSegment]);
5520
+ }, [finishPenPath, undoActivePenPathSegment]);
4704
5521
 
4705
5522
  useEffect(() => {
4706
5523
  const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
4707
- if (tool !== "pen") clearActivePenPath();
4708
- }, [activeTool, clearActivePenPath, localActiveTool]);
4709
-
4710
- // Cmd+D / Ctrl+D: duplicate the selected frame with a visible offset so the
4711
- // 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.
4712
5542
  useEffect(() => {
4713
5543
  if (!onDuplicate) return;
4714
5544
  const handleKeyDown = (event: KeyboardEvent) => {
@@ -4720,31 +5550,39 @@ export function MultiScreenCanvas({
4720
5550
  ) {
4721
5551
  return;
4722
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();
4723
5557
  // Only act on frame IDs — filter out canvas primitives (sub-elements).
4724
5558
  const frameIds = selectedIdsRef.current.filter(
4725
5559
  (id) => frameGeometryRef.current[id],
4726
5560
  );
4727
- const targetId = frameIds[0];
4728
- if (!targetId) return;
4729
- const screen = screens.find((s) => s.id === targetId);
4730
- if (!screen) return;
4731
- const sourceGeometry = frameGeometryRef.current[targetId];
4732
- if (!sourceGeometry) return;
4733
- event.preventDefault();
5561
+ if (frameIds.length === 0) return;
4734
5562
  event.stopPropagation();
4735
5563
  event.stopImmediatePropagation();
4736
- // Offset the duplicate by one grid gap to the right (and slightly down)
4737
- // so it is visually distinct from the original, mirroring Figma's behaviour.
4738
- const canvasPosition = {
4739
- x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
4740
- y: sourceGeometry.y,
4741
- };
4742
- onDuplicate(targetId, {
4743
- mode: "alt-click",
4744
- screen,
4745
- canvasPosition,
4746
- dropCanvasPosition: canvasPosition,
4747
- });
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
+ }
4748
5586
  };
4749
5587
 
4750
5588
  window.addEventListener("keydown", handleKeyDown, true);
@@ -4785,29 +5623,43 @@ export function MultiScreenCanvas({
4785
5623
  : effectiveTool === "hand"
4786
5624
  ? "grab"
4787
5625
  : "default";
4788
- const canvasFrames = screens.map((screen, index) => {
4789
- const metadata = getResolvedMetadata(screen);
4790
- return {
4791
- screen,
4792
- metadata,
4793
- geometry:
4794
- frameGeometry[screen.id] ?? getInitialFrameGeometry(index, metadata),
4795
- };
4796
- });
4797
- const topScreenId =
4798
- selectedIds.find((id) =>
4799
- canvasFrames.some(({ screen }) => screen.id === id),
4800
- ) ??
4801
- (activeId && canvasFrames.some(({ screen }) => screen.id === activeId)
4802
- ? activeId
4803
- : canvasFrames[0]?.screen.id);
4804
- const renderCanvasFrames =
4805
- topScreenId && canvasFrames.some(({ screen }) => screen.id === topScreenId)
4806
- ? [
4807
- ...canvasFrames.filter(({ screen }) => screen.id !== topScreenId),
4808
- ...canvasFrames.filter(({ screen }) => screen.id === topScreenId),
4809
- ]
4810
- : 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.
4811
5663
  const screenContentById = useMemo(() => {
4812
5664
  if (!renderScreenContent) return new Map<string, ReactNode>();
4813
5665
  return new Map(
@@ -4822,21 +5674,41 @@ export function MultiScreenCanvas({
4822
5674
  }),
4823
5675
  );
4824
5676
  }, [frameGeometry, getResolvedMetadata, renderScreenContent, screens]);
4825
- const selectedFrameEntries = canvasFrames
4826
- .filter(({ screen }) => selectedIdSet.has(screen.id))
4827
- .map(({ screen, geometry }) => ({ id: screen.id, geometry }));
4828
- const selectedGroupBounds =
4829
- selectedFrameEntries.length > 1
4830
- ? getFrameGroupBounds(selectedFrameEntries)
4831
- : 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
+ );
4832
5697
  const hasGroupSelection = !!selectedGroupBounds;
4833
- const selectedDraftEntries = draftPrimitives
4834
- .filter((draft) => selectedDraftIdSet.has(draft.id))
4835
- .map((draft) => ({ id: draft.id, geometry: draft.geometry }));
4836
- const selectedDraftGroupBounds =
4837
- selectedDraftEntries.length > 1
4838
- ? getFrameGroupBounds(selectedDraftEntries)
4839
- : 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
+ );
4840
5712
  const singleSelectedFrame =
4841
5713
  selectedFrameEntries.length === 1 && !selectedGroupBounds
4842
5714
  ? selectedFrameEntries[0]
@@ -4993,7 +5865,7 @@ export function MultiScreenCanvas({
4993
5865
  );
4994
5866
  })()}
4995
5867
 
4996
- {renderCanvasFrames.map(({ screen, metadata, geometry }) => {
5868
+ {canvasFrames.map(({ screen, metadata, geometry }) => {
4997
5869
  return (
4998
5870
  <Screen
4999
5871
  key={screen.id}
@@ -5002,6 +5874,7 @@ export function MultiScreenCanvas({
5002
5874
  geometry={geometry}
5003
5875
  screenContent={screenContentById.get(screen.id)}
5004
5876
  isActive={screen.id === activeId}
5877
+ isTopScreen={screen.id === topScreenId}
5005
5878
  isSelected={selectedIdSet.has(screen.id)}
5006
5879
  showFullView={fullViewIdSet.has(screen.id)}
5007
5880
  isDirectlyHovered={screen.id === directlyHoveredScreenId}
@@ -5022,16 +5895,13 @@ export function MultiScreenCanvas({
5022
5895
  onStartResize={beginResize}
5023
5896
  onStartRotate={beginRotate}
5024
5897
  onStartDuplicateGesture={beginDuplicateGesture}
5025
- onAddBreakpoint={
5026
- onAddBreakpoint
5027
- ? (widthPx) => onAddBreakpoint(screen.id, widthPx)
5028
- : undefined
5029
- }
5030
- onActiveBreakpointChange={
5031
- onActiveBreakpointChange
5032
- ? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
5033
- : undefined
5034
- }
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}
5035
5905
  />
5036
5906
  );
5037
5907
  })}
@@ -5089,7 +5959,11 @@ export function MultiScreenCanvas({
5089
5959
  : null}
5090
5960
 
5091
5961
  {displayedPenPath ? (
5092
- <PenPathOverlay path={displayedPenPath} closeHover={penCloseHover} />
5962
+ <PenPathOverlay
5963
+ path={displayedPenPath}
5964
+ closeHover={penCloseHover}
5965
+ chromeScale={chromeScale}
5966
+ />
5093
5967
  ) : null}
5094
5968
 
5095
5969
  {singleSelectedFrame ? (
@@ -5126,6 +6000,7 @@ export function MultiScreenCanvas({
5126
6000
  chromeScale={chromeScale}
5127
6001
  chromeSettling={chromeSettling}
5128
6002
  onStartResize={beginGroupResize}
6003
+ onStartRotate={beginGroupRotate}
5129
6004
  />
5130
6005
  ) : null}
5131
6006
 
@@ -5159,8 +6034,63 @@ export function MultiScreenCanvas({
5159
6034
  }
5160
6035
  />
5161
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
+ )}
5162
6064
  </div>
5163
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
+
5164
6094
  {penActive || creationToolActive ? (
5165
6095
  <div
5166
6096
  data-canvas-creation-shield
@@ -5171,11 +6101,18 @@ export function MultiScreenCanvas({
5171
6101
 
5172
6102
  {marquee ? (
5173
6103
  <span
6104
+ ref={marqueeOverlayRef}
5174
6105
  className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
5175
6106
  style={{
5176
6107
  // Convert canvas-space marquee to surface-space so this overlay
5177
6108
  // is never clipped or hidden by the canvas transform container.
5178
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.
5179
6116
  left: pan.x + (SURFACE_PADDING + marquee.x) * scale,
5180
6117
  top: pan.y + (SURFACE_PADDING + marquee.y) * scale,
5181
6118
  width: Math.max(1, marquee.width * scale),
@@ -5288,7 +6225,7 @@ export function MultiScreenCanvas({
5288
6225
  ) : null}
5289
6226
  </div>
5290
6227
  );
5291
- }
6228
+ });
5292
6229
 
5293
6230
  function DraftPrimitiveLayer({
5294
6231
  draft,
@@ -5374,6 +6311,7 @@ function DraftPrimitiveLayer({
5374
6311
  showRotate={false}
5375
6312
  chromeScale={chromeScale}
5376
6313
  chromeSettling={chromeSettling}
6314
+ rotationDeg={draft.geometry.rotation ?? 0}
5377
6315
  onStartResize={(handle, event) =>
5378
6316
  onStartResize(draft.id, handle, event)
5379
6317
  }
@@ -5512,12 +6450,27 @@ function DraftPrimitiveContent({
5512
6450
  function PenPathOverlay({
5513
6451
  path,
5514
6452
  closeHover,
6453
+ chromeScale,
5515
6454
  }: {
5516
6455
  path: PenPath;
5517
6456
  closeHover: boolean;
6457
+ chromeScale: number;
5518
6458
  }) {
5519
6459
  const geometry = getPenPathGeometry(path);
5520
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);
5521
6474
  return (
5522
6475
  <div
5523
6476
  data-pen-path-overlay
@@ -5543,7 +6496,7 @@ function PenPathOverlay({
5543
6496
  y2={node.handleIn.y}
5544
6497
  stroke="var(--design-editor-accent-color)"
5545
6498
  strokeDasharray="3 3"
5546
- strokeWidth={1}
6499
+ strokeWidth={handleLineStrokeWidth}
5547
6500
  />
5548
6501
  ) : null}
5549
6502
  {node.handleOut ? (
@@ -5554,7 +6507,7 @@ function PenPathOverlay({
5554
6507
  y2={node.handleOut.y}
5555
6508
  stroke="var(--design-editor-accent-color)"
5556
6509
  strokeDasharray="3 3"
5557
- strokeWidth={1}
6510
+ strokeWidth={handleLineStrokeWidth}
5558
6511
  />
5559
6512
  ) : null}
5560
6513
  </g>
@@ -5565,7 +6518,7 @@ function PenPathOverlay({
5565
6518
  stroke="rgba(255,255,255,0.95)"
5566
6519
  strokeLinecap="round"
5567
6520
  strokeLinejoin="round"
5568
- strokeWidth={5}
6521
+ strokeWidth={outlineStrokeWidth}
5569
6522
  />
5570
6523
  <path
5571
6524
  d={pathData}
@@ -5573,7 +6526,7 @@ function PenPathOverlay({
5573
6526
  stroke="var(--design-editor-accent-color)"
5574
6527
  strokeLinecap="round"
5575
6528
  strokeLinejoin="round"
5576
- strokeWidth={2}
6529
+ strokeWidth={strokeWidth}
5577
6530
  />
5578
6531
  </svg>
5579
6532
  {path.nodes.map((node, index) => (
@@ -5581,14 +6534,17 @@ function PenPathOverlay({
5581
6534
  key={`anchor-${index}`}
5582
6535
  data-pen-anchor
5583
6536
  className={cn(
5584
- "absolute size-2 rounded-[2px] border shadow-sm",
6537
+ "absolute rounded-[2px] border shadow-sm",
5585
6538
  index === 0 && closeHover
5586
6539
  ? "scale-125 border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)] ring-4 ring-[var(--design-editor-selection-color)]"
5587
6540
  : "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]",
5588
6541
  )}
5589
6542
  style={{
5590
- left: node.point.x - geometry.x - 4,
5591
- 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,
5592
6548
  }}
5593
6549
  />
5594
6550
  ))}
@@ -5599,10 +6555,13 @@ function PenPathOverlay({
5599
6555
  <span
5600
6556
  key={`handle-${index}-${handleIndex}`}
5601
6557
  data-pen-handle
5602
- 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"
5603
6559
  style={{
5604
- left: handle.x - geometry.x - 3,
5605
- 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,
5606
6565
  }}
5607
6566
  />
5608
6567
  )),
@@ -5630,12 +6589,57 @@ function nextBreakpointWidth(existing: number[]): number | undefined {
5630
6589
  return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
5631
6590
  }
5632
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
+
5633
6636
  interface ScreenProps {
5634
6637
  screen: ScreenFile;
5635
6638
  metadata: ResolvedScreenMetadata;
5636
6639
  geometry: FrameGeometry;
5637
6640
  isActive: boolean;
5638
6641
  isSelected: boolean;
6642
+ isTopScreen: boolean;
5639
6643
  showFullView: boolean;
5640
6644
  isDirectlyHovered: boolean;
5641
6645
  hasHoveredChild: boolean;
@@ -5661,8 +6665,17 @@ interface ScreenProps {
5661
6665
  display: string,
5662
6666
  e: React.MouseEvent<HTMLElement>,
5663
6667
  ) => void;
5664
- onAddBreakpoint?: (widthPx: number) => void;
5665
- 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;
5666
6679
  }
5667
6680
 
5668
6681
  const Screen = memo(function Screen({
@@ -5671,6 +6684,7 @@ const Screen = memo(function Screen({
5671
6684
  geometry,
5672
6685
  isActive,
5673
6686
  isSelected,
6687
+ isTopScreen,
5674
6688
  showFullView,
5675
6689
  isDirectlyHovered,
5676
6690
  hasHoveredChild,
@@ -5760,7 +6774,9 @@ const Screen = memo(function Screen({
5760
6774
  ? `rotate(${geometry.rotation}deg)`
5761
6775
  : undefined,
5762
6776
  transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
5763
- zIndex: geometry.z,
6777
+ zIndex: isTopScreen
6778
+ ? (geometry.z ?? 0) + TOP_SCREEN_Z_BOOST
6779
+ : geometry.z,
5764
6780
  }}
5765
6781
  >
5766
6782
  <div
@@ -5784,6 +6800,11 @@ const Screen = memo(function Screen({
5784
6800
  if (e.button !== 0) return;
5785
6801
  if (penActive || creationToolActive) return;
5786
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;
5787
6808
  onStartDuplicateGesture(screen, display, e);
5788
6809
  return;
5789
6810
  }
@@ -6008,6 +7029,7 @@ const Screen = memo(function Screen({
6008
7029
  showRotate
6009
7030
  chromeScale={chromeScale}
6010
7031
  chromeSettling={chromeSettling}
7032
+ rotationDeg={geometry.rotation ?? 0}
6011
7033
  onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
6012
7034
  onStartRotate={(e) => onStartRotate(screen.id, e)}
6013
7035
  />
@@ -6028,8 +7050,16 @@ const Screen = memo(function Screen({
6028
7050
  penActive={penActive}
6029
7051
  creationToolActive={creationToolActive}
6030
7052
  chromeScale={chromeScale}
6031
- onActiveBreakpointChange={onActiveBreakpointChange}
6032
- 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
+ }
6033
7063
  />
6034
7064
  ) : null}
6035
7065
  </div>
@@ -6044,6 +7074,7 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
6044
7074
  sameFrameGeometry(prev.geometry, next.geometry) &&
6045
7075
  prev.isActive === next.isActive &&
6046
7076
  prev.isSelected === next.isSelected &&
7077
+ prev.isTopScreen === next.isTopScreen &&
6047
7078
  prev.showFullView === next.showFullView &&
6048
7079
  prev.isDirectlyHovered === next.isDirectlyHovered &&
6049
7080
  prev.hasHoveredChild === next.hasHoveredChild &&
@@ -6059,7 +7090,13 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
6059
7090
  prev.onStartFrameDrag === next.onStartFrameDrag &&
6060
7091
  prev.onStartResize === next.onStartResize &&
6061
7092
  prev.onStartRotate === next.onStartRotate &&
6062
- 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
6063
7100
  );
6064
7101
  }
6065
7102
 
@@ -6087,7 +7124,9 @@ function BreakpointPreviewRow({
6087
7124
  * The primary screen's srcdoc with the lightweight hit-test responder already
6088
7125
  * injected (memoised in the parent Screen component). Passed down so
6089
7126
  * breakpoint sub-iframes carry the same responder and can be found via
6090
- * [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).
6091
7130
  */
6092
7131
  srcdocWithHitTest: string;
6093
7132
  activeBreakpointWidth: number | undefined;
@@ -6178,7 +7217,19 @@ function BreakpointPreviewRow({
6178
7217
  style={{ width: frameWidth, height: frameHeight }}
6179
7218
  >
6180
7219
  <iframe
6181
- 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
+ )}
6182
7233
  src={previewUrl}
6183
7234
  srcDoc={previewUrl ? undefined : srcdocWithHitTest}
6184
7235
  sandbox="allow-scripts"
@@ -6245,11 +7296,16 @@ function GroupSelectionBox({
6245
7296
  chromeScale,
6246
7297
  chromeSettling,
6247
7298
  onStartResize,
7299
+ onStartRotate,
6248
7300
  }: {
6249
7301
  bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
6250
7302
  chromeScale: number;
6251
7303
  chromeSettling: boolean;
6252
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;
6253
7309
  }) {
6254
7310
  return (
6255
7311
  <SelectionBox
@@ -6261,10 +7317,10 @@ function GroupSelectionBox({
6261
7317
  }}
6262
7318
  chromeScale={chromeScale}
6263
7319
  chromeSettling={chromeSettling}
6264
- showRotate={false}
7320
+ showRotate={!!onStartRotate}
6265
7321
  filled
6266
7322
  onStartResize={onStartResize}
6267
- onStartRotate={() => {}}
7323
+ onStartRotate={onStartRotate ?? (() => {})}
6268
7324
  />
6269
7325
  );
6270
7326
  }
@@ -6360,6 +7416,7 @@ function SelectionBox({
6360
7416
  showRotate={showRotate}
6361
7417
  chromeScale={chromeScale}
6362
7418
  chromeSettling={chromeSettling}
7419
+ rotationDeg={geometry.rotation ?? 0}
6363
7420
  onStartResize={onStartResize}
6364
7421
  onStartRotate={onStartRotate}
6365
7422
  />
@@ -6374,6 +7431,7 @@ function ResizeHandles({
6374
7431
  showRotate = true,
6375
7432
  chromeScale = 1,
6376
7433
  chromeSettling = false,
7434
+ rotationDeg = 0,
6377
7435
  onStartResize,
6378
7436
  onStartRotate,
6379
7437
  }: {
@@ -6383,6 +7441,10 @@ function ResizeHandles({
6383
7441
  showRotate?: boolean;
6384
7442
  chromeScale?: number;
6385
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;
6386
7448
  onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
6387
7449
  onStartRotate: (e: React.MouseEvent) => void;
6388
7450
  }) {
@@ -6410,7 +7472,7 @@ function ResizeHandles({
6410
7472
  className={edgeHandleClass}
6411
7473
  style={edgeHandleStyle(
6412
7474
  config.handle,
6413
- config.cursor,
7475
+ getResizeCursorForHandle(config.handle, rotationDeg),
6414
7476
  chromeScale,
6415
7477
  chromeSettling,
6416
7478
  )}
@@ -6424,7 +7486,7 @@ function ResizeHandles({
6424
7486
  className={visibleHandleClass}
6425
7487
  style={cornerHandleStyle(
6426
7488
  config.handle,
6427
- config.cursor,
7489
+ getResizeCursorForHandle(config.handle, rotationDeg),
6428
7490
  chromeScale,
6429
7491
  chromeSettling,
6430
7492
  )}
@@ -6660,6 +7722,29 @@ function dedupeIds(ids: string[]) {
6660
7722
  return [...new Set(ids)];
6661
7723
  }
6662
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
+
6663
7748
  function sameIds(a: string[], b: string[]) {
6664
7749
  return a.length === b.length && a.every((id, index) => id === b[index]);
6665
7750
  }
@@ -6759,17 +7844,6 @@ function normalizeRectFromPoints(start: Point, end: Point): MarqueeRect {
6759
7844
  };
6760
7845
  }
6761
7846
 
6762
- function rectIntersects(rect: MarqueeRect, bounds: BoundsRect) {
6763
- const right = rect.x + rect.width;
6764
- const bottom = rect.y + rect.height;
6765
- return (
6766
- rect.x <= bounds.right &&
6767
- right >= bounds.left &&
6768
- rect.y <= bounds.bottom &&
6769
- bottom >= bounds.top
6770
- );
6771
- }
6772
-
6773
7847
  function rectContainsPoint(bounds: BoundsRect, point: Point) {
6774
7848
  return (
6775
7849
  point.x >= bounds.left &&
@@ -6800,63 +7874,6 @@ function rotatePointAroundCenter(
6800
7874
  };
6801
7875
  }
6802
7876
 
6803
- /** Test whether a marquee rect (in canvas space) intersects an OBB described by
6804
- * `bounds` (unrotated AABB) that has been rotated `degrees` around `center`.
6805
- * We map the four marquee corners into the frame's local space and check
6806
- * whether any corner is inside the bounds; we also check the reverse (any
6807
- * frame corner inside the marquee) to handle the case where the marquee is
6808
- * entirely contained within the rotated frame. */
6809
- function rotatedRectIntersects(
6810
- rect: MarqueeRect,
6811
- bounds: BoundsRect,
6812
- center: Point,
6813
- degrees: number,
6814
- ): boolean {
6815
- if (!degrees) {
6816
- return rectIntersects(rect, bounds);
6817
- }
6818
- // Four corners of the marquee rect in canvas space.
6819
- const marqueeCorners: Point[] = [
6820
- { x: rect.x, y: rect.y },
6821
- { x: rect.x + rect.width, y: rect.y },
6822
- { x: rect.x + rect.width, y: rect.y + rect.height },
6823
- { x: rect.x, y: rect.y + rect.height },
6824
- ];
6825
- // Test: any marquee corner inside the unrotated frame bounds?
6826
- for (const corner of marqueeCorners) {
6827
- const local = rotatePointAroundCenter(corner, center, degrees);
6828
- if (rectContainsPoint(bounds, local)) return true;
6829
- }
6830
- // Four corners of the unrotated frame in canvas space (rotate them outward).
6831
- const frameCorners: Point[] = [
6832
- { x: bounds.left, y: bounds.top },
6833
- { x: bounds.right, y: bounds.top },
6834
- { x: bounds.right, y: bounds.bottom },
6835
- { x: bounds.left, y: bounds.bottom },
6836
- ];
6837
- const rad = (degrees * Math.PI) / 180;
6838
- const cos = Math.cos(rad);
6839
- const sin = Math.sin(rad);
6840
- const marqueeRight = rect.x + rect.width;
6841
- const marqueeBottom = rect.y + rect.height;
6842
- // Test: any rotated frame corner inside the marquee rect?
6843
- for (const fc of frameCorners) {
6844
- const dx = fc.x - center.x;
6845
- const dy = fc.y - center.y;
6846
- const wx = center.x + dx * cos - dy * sin;
6847
- const wy = center.y + dx * sin + dy * cos;
6848
- if (
6849
- wx >= rect.x &&
6850
- wx <= marqueeRight &&
6851
- wy >= rect.y &&
6852
- wy <= marqueeBottom
6853
- ) {
6854
- return true;
6855
- }
6856
- }
6857
- return false;
6858
- }
6859
-
6860
7877
  function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
6861
7878
  return (
6862
7879
  a.x === b.x &&
@@ -6912,25 +7929,36 @@ export function getDraftPreviewGeometryForTool(
6912
7929
  start: Point,
6913
7930
  end: Point,
6914
7931
  hasMoved: boolean,
7932
+ modifiers?: DraftGeometryModifiers,
6915
7933
  ): FrameGeometry {
6916
- if (tool === "pen" || tool === "line" || tool === "arrow") {
6917
- 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);
6918
7940
  }
6919
7941
 
6920
7942
  if (!hasMoved) {
6921
7943
  return { x: start.x, y: start.y, width: 0, height: 0 };
6922
7944
  }
6923
7945
 
6924
- return getDraftGeometryForTool(tool, start, end);
7946
+ return getDraftGeometryForTool(tool, start, end, modifiers);
6925
7947
  }
6926
7948
 
6927
7949
  function getDraftGeometryForTool(
6928
7950
  tool: DraftCreationTool,
6929
7951
  start: Point,
6930
7952
  end: Point,
7953
+ modifiers?: DraftGeometryModifiers,
6931
7954
  ): FrameGeometry {
6932
- if (tool === "pen" || tool === "line" || tool === "arrow") {
6933
- 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]);
6934
7962
  }
6935
7963
  const options =
6936
7964
  tool === "frame"
@@ -6953,7 +7981,11 @@ function getDraftGeometryForTool(
6953
7981
  defaultWidth: DRAFT_RECT_WIDTH,
6954
7982
  defaultHeight: DRAFT_RECT_HEIGHT,
6955
7983
  };
6956
- return getDraftGeometryFromPoints(start, end, options);
7984
+ return getDraftGeometryFromPoints(start, end, {
7985
+ ...options,
7986
+ square: modifiers?.shiftKey,
7987
+ fromCenter: modifiers?.altKey,
7988
+ });
6957
7989
  }
6958
7990
 
6959
7991
  function getPathGeometry(points: readonly Point[]): FrameGeometry {
@@ -6983,35 +8015,19 @@ function createDraftPrimitive({
6983
8015
  tool,
6984
8016
  start,
6985
8017
  end,
6986
- points,
6987
8018
  moved,
6988
8019
  toolProps,
8020
+ modifiers,
6989
8021
  }: DraftPrimitiveInput): DraftPrimitive {
6990
8022
  const id = createDraftId(tool);
6991
8023
  const geometry = moved
6992
- ? getDraftGeometryForTool(tool, start, end)
6993
- : getDraftGeometryForTool(tool, start, start);
6994
- if (tool === "pen") {
6995
- const pathPoints =
6996
- points && points.length > 1
6997
- ? points
6998
- : [
6999
- start,
7000
- { x: start.x + 64, y: start.y + 24 },
7001
- { x: start.x + 128, y: start.y },
7002
- ];
7003
- return createPenDraftPrimitive(
7004
- {
7005
- nodes: pathPoints.map(createCornerNode),
7006
- closed: false,
7007
- },
7008
- {
7009
- id,
7010
- stroke: toolProps?.stroke,
7011
- strokeWidth: toolProps?.strokeWidth,
7012
- },
7013
- );
7014
- }
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.
7015
8031
  if (tool === "text") {
7016
8032
  return {
7017
8033
  id,
@@ -7024,8 +8040,11 @@ function createDraftPrimitive({
7024
8040
  };
7025
8041
  }
7026
8042
  if (tool === "line" || tool === "arrow") {
8043
+ const effectiveEnd = modifiers?.shiftKey
8044
+ ? constrainPointTo45Degrees(start, end)
8045
+ : end;
7027
8046
  const pathPoints = moved
7028
- ? [start, end]
8047
+ ? [start, effectiveEnd]
7029
8048
  : [start, { x: start.x + DRAFT_LINE_WIDTH, y: start.y }];
7030
8049
  return {
7031
8050
  id,
@@ -7051,6 +8070,31 @@ function createDraftPrimitive({
7051
8070
  };
7052
8071
  }
7053
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
+
7054
8098
  function createPenDraftPrimitive(
7055
8099
  path: PenPath,
7056
8100
  {
@@ -7301,10 +8345,19 @@ function angleBetween(center: Point, point: Point) {
7301
8345
  return (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI;
7302
8346
  }
7303
8347
 
7304
- function getSelectableBounds(geometry: FrameGeometry): BoundsRect {
8348
+ function getSelectableBounds(
8349
+ geometry: FrameGeometry,
8350
+ chromeScale = 1,
8351
+ ): BoundsRect {
7305
8352
  return {
7306
8353
  left: geometry.x,
7307
- 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,
7308
8361
  right: geometry.x + geometry.width,
7309
8362
  bottom: geometry.y + geometry.height,
7310
8363
  };
@@ -7510,12 +8563,44 @@ function hashString(s: string): string {
7510
8563
  export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
7511
8564
  const PRIMITIVE_PARSE_CACHE_MAX = 64;
7512
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
+
7513
8587
  export function parsePrimitivesFromScreen(
7514
8588
  screen: ScreenFile,
7515
8589
  ): ParsedScreenPrimitive[] {
8590
+ const identityEntry = primitiveParseIdentityCache.get(screen.id);
8591
+ if (identityEntry && identityEntry.content === screen.content) {
8592
+ return identityEntry.result;
8593
+ }
8594
+
7516
8595
  const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
7517
8596
  const cached = primitiveParseCache.get(cacheKey);
7518
- if (cached) return cached;
8597
+ if (cached) {
8598
+ primitiveParseIdentityCache.set(screen.id, {
8599
+ content: screen.content,
8600
+ result: cached,
8601
+ });
8602
+ return cached;
8603
+ }
7519
8604
 
7520
8605
  const result: ParsedScreenPrimitive[] = [];
7521
8606
  if (typeof DOMParser === "undefined" || !screen.content) {
@@ -7589,6 +8674,10 @@ export function parsePrimitivesFromScreen(
7589
8674
  if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
7590
8675
  }
7591
8676
  primitiveParseCache.set(cacheKey, result);
8677
+ primitiveParseIdentityCache.set(screen.id, {
8678
+ content: screen.content,
8679
+ result,
8680
+ });
7592
8681
  return result;
7593
8682
  }
7594
8683