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