@agent-native/core 0.85.6 → 0.85.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
- package/corpus/core/src/collab/presence.ts +63 -3
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
- package/corpus/templates/design/AGENTS.md +33 -1
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
- package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
- package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
- package/corpus/templates/design/actions/connect-localhost.ts +25 -18
- package/corpus/templates/design/actions/export-pdf.ts +8 -1
- package/corpus/templates/design/actions/export-zip.ts +10 -1
- package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
- package/corpus/templates/design/actions/insert-asset.ts +190 -7
- package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
- package/corpus/templates/design/actions/present-design-variants.ts +2 -2
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
- package/corpus/templates/design/actions/run-design-audit.ts +7 -4
- package/corpus/templates/design/actions/write-local-file.ts +47 -22
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
- package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
- package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
- package/corpus/templates/design/app/components/design/types.ts +16 -0
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +18 -0
- package/corpus/templates/design/app/lib/design-import.ts +95 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
- package/corpus/templates/design/server/lib/design-export.ts +43 -1
- package/corpus/templates/design/shared/board-file.ts +25 -5
- package/corpus/templates/design/shared/canvas-math.ts +754 -9
- package/corpus/templates/design/shared/code-layer.ts +304 -26
- package/corpus/templates/design/shared/color-utils.ts +84 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
- package/corpus/templates/design/shared/motion-compiler.ts +75 -31
- package/corpus/templates/design/shared/motion-timeline.ts +335 -0
- package/corpus/templates/design/shared/pen-path.ts +200 -23
- package/dist/client/use-pinch-zoom.d.ts.map +1 -1
- package/dist/client/use-pinch-zoom.js +76 -11
- package/dist/client/use-pinch-zoom.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/presence.d.ts.map +1 -1
- package/dist/collab/presence.js +54 -3
- package/dist/collab/presence.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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]);
|
|
@@ -1436,8 +1621,39 @@ export function MultiScreenCanvas({
|
|
|
1436
1621
|
}, [selectedDraftIds]);
|
|
1437
1622
|
|
|
1438
1623
|
useEffect(() => {
|
|
1624
|
+
// zoomRef.current is the canvas's own last-known zoom, kept in sync
|
|
1625
|
+
// synchronously by every internal zoom path (wheel/pinch commitView,
|
|
1626
|
+
// fit-to-screen) *before* those paths call onZoomChange. So if it
|
|
1627
|
+
// already matches the incoming prop, this change originated from our own
|
|
1628
|
+
// gesture round-tripping back through a controlled `zoom` prop — that
|
|
1629
|
+
// path already applied its own (cursor-anchored) pan compensation, and
|
|
1630
|
+
// redoing it here with a surface-center anchor would double-shift pan.
|
|
1631
|
+
// Only compensate when this is a genuinely external change (toolbar
|
|
1632
|
+
// buttons, keyboard shortcuts) that never touched zoomRef/panRef.
|
|
1633
|
+
const previousZoom = zoomRef.current;
|
|
1634
|
+
if (zoom === previousZoom) return;
|
|
1635
|
+
// External zoom changes otherwise anchor at world origin (0,0) since
|
|
1636
|
+
// only canvasZoom is updated here — content visibly jumps diagonally
|
|
1637
|
+
// instead of zooming in place. Mirror the wheel/pinch cursor-anchored
|
|
1638
|
+
// compensation using the surface's own center as the anchor, since
|
|
1639
|
+
// there's no cursor position for a toolbar/keyboard-driven zoom change.
|
|
1640
|
+
const rect = surfaceRef.current?.getBoundingClientRect();
|
|
1641
|
+
const cursor = rect
|
|
1642
|
+
? { x: rect.width / 2, y: rect.height / 2 }
|
|
1643
|
+
: { x: 0, y: 0 };
|
|
1644
|
+
const nextPan = getPanForZoomToCursor({
|
|
1645
|
+
pan: panRef.current,
|
|
1646
|
+
cursor,
|
|
1647
|
+
oldZoom: previousZoom,
|
|
1648
|
+
nextZoom: zoom,
|
|
1649
|
+
});
|
|
1650
|
+
panRef.current = nextPan;
|
|
1651
|
+
setPan(nextPan);
|
|
1439
1652
|
setCanvasZoom(zoom);
|
|
1440
1653
|
zoomRef.current = zoom;
|
|
1654
|
+
// P18: an externally-driven zoom change (toolbar/keyboard) also moves
|
|
1655
|
+
// the canvas-space mapping the pen ghost preview was computed from.
|
|
1656
|
+
recomputePenPointerForViewChangeRef.current();
|
|
1441
1657
|
}, [zoom]);
|
|
1442
1658
|
|
|
1443
1659
|
useEffect(() => {
|
|
@@ -1703,8 +1919,12 @@ export function MultiScreenCanvas({
|
|
|
1703
1919
|
|
|
1704
1920
|
const postHitTestPreviewClear = (targetId: string | null | undefined) => {
|
|
1705
1921
|
if (!targetId) return;
|
|
1922
|
+
const targetScreen = screensRef.current.find((s) => s.id === targetId);
|
|
1923
|
+
const iframeId = targetScreen
|
|
1924
|
+
? getActiveScreenIframeId(targetScreen)
|
|
1925
|
+
: targetId;
|
|
1706
1926
|
const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
1707
|
-
`[data-screen-iframe-id="${CSS.escape(
|
|
1927
|
+
`[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
|
|
1708
1928
|
);
|
|
1709
1929
|
targetIframe?.contentWindow?.postMessage(
|
|
1710
1930
|
{ type: "agent-native:hit-test-preview-clear" },
|
|
@@ -1736,6 +1956,9 @@ export function MultiScreenCanvas({
|
|
|
1736
1956
|
clearCrossScreenDropGuide();
|
|
1737
1957
|
crossScreenTargetRef.current = null;
|
|
1738
1958
|
crossScreenDragMsgRef.current = null;
|
|
1959
|
+
// Drop the timeout-fallback cache so a future, unrelated drag session
|
|
1960
|
+
// never shows a guide left over from this one.
|
|
1961
|
+
crossScreenLastHitResultRef.current.clear();
|
|
1739
1962
|
};
|
|
1740
1963
|
|
|
1741
1964
|
const runHitTest = (
|
|
@@ -1748,8 +1971,9 @@ export function MultiScreenCanvas({
|
|
|
1748
1971
|
);
|
|
1749
1972
|
if (!targetScreen) return Promise.resolve({});
|
|
1750
1973
|
const targetGeometry = candidate.geometry;
|
|
1974
|
+
const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
|
|
1751
1975
|
const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
1752
|
-
`[data-screen-iframe-id="${
|
|
1976
|
+
`[data-screen-iframe-id="${targetIframeId}"]`,
|
|
1753
1977
|
);
|
|
1754
1978
|
const targetContentWindow = targetIframe?.contentWindow;
|
|
1755
1979
|
if (!targetContentWindow) return Promise.resolve({});
|
|
@@ -1769,20 +1993,28 @@ export function MultiScreenCanvas({
|
|
|
1769
1993
|
return new Promise((resolve) => {
|
|
1770
1994
|
const timer = window.setTimeout(() => {
|
|
1771
1995
|
window.removeEventListener("message", hitListener);
|
|
1772
|
-
|
|
1773
|
-
|
|
1996
|
+
// Fall back to the last successful result for this target instead
|
|
1997
|
+
// of resolving empty — a single slow reply (bridge script briefly
|
|
1998
|
+
// busy, iframe mid-navigation, etc.) shouldn't make the drop guide
|
|
1999
|
+
// flicker away and immediately reappear on the next hit-test.
|
|
2000
|
+
resolve(crossScreenLastHitResultRef.current.get(candidate.id) ?? {});
|
|
2001
|
+
}, 250);
|
|
1774
2002
|
|
|
1775
2003
|
const hitListener = (ev: MessageEvent) => {
|
|
1776
2004
|
if (
|
|
1777
2005
|
!ev.data ||
|
|
1778
2006
|
ev.data.type !== "agent-native:hit-test-result" ||
|
|
1779
|
-
ev.data.correlationId !== correlationId
|
|
2007
|
+
ev.data.correlationId !== correlationId ||
|
|
2008
|
+
// Require the reply to actually come from the iframe we asked,
|
|
2009
|
+
// not just any window that happens to observe/guess the
|
|
2010
|
+
// correlationId and reply with a matching payload shape.
|
|
2011
|
+
ev.source !== targetContentWindow
|
|
1780
2012
|
) {
|
|
1781
2013
|
return;
|
|
1782
2014
|
}
|
|
1783
2015
|
window.clearTimeout(timer);
|
|
1784
2016
|
window.removeEventListener("message", hitListener);
|
|
1785
|
-
|
|
2017
|
+
const result: CrossScreenHitTestResult = {
|
|
1786
2018
|
anchorNodeId:
|
|
1787
2019
|
typeof ev.data.anchorNodeId === "string"
|
|
1788
2020
|
? ev.data.anchorNodeId
|
|
@@ -1799,7 +2031,9 @@ export function MultiScreenCanvas({
|
|
|
1799
2031
|
anchorRect: isCrossScreenHitTestAnchorRect(ev.data.anchorRect)
|
|
1800
2032
|
? ev.data.anchorRect
|
|
1801
2033
|
: undefined,
|
|
1802
|
-
}
|
|
2034
|
+
};
|
|
2035
|
+
crossScreenLastHitResultRef.current.set(candidate.id, result);
|
|
2036
|
+
resolve(result);
|
|
1803
2037
|
};
|
|
1804
2038
|
window.addEventListener("message", hitListener);
|
|
1805
2039
|
|
|
@@ -1828,8 +2062,9 @@ export function MultiScreenCanvas({
|
|
|
1828
2062
|
(s) => s.id === candidate.id,
|
|
1829
2063
|
);
|
|
1830
2064
|
if (!targetScreen) return null;
|
|
2065
|
+
const targetIframeId = CSS.escape(getActiveScreenIframeId(targetScreen));
|
|
1831
2066
|
const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
1832
|
-
`[data-screen-iframe-id="${
|
|
2067
|
+
`[data-screen-iframe-id="${targetIframeId}"]`,
|
|
1833
2068
|
);
|
|
1834
2069
|
const targetViewportWidth =
|
|
1835
2070
|
targetIframe?.clientWidth || getResolvedMetadata(targetScreen).width;
|
|
@@ -1978,6 +2213,31 @@ export function MultiScreenCanvas({
|
|
|
1978
2213
|
if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
|
|
1979
2214
|
return;
|
|
1980
2215
|
}
|
|
2216
|
+
// Resolve which of our own embedded design-preview iframes actually
|
|
2217
|
+
// posted this message — postMessage's origin/source aren't otherwise
|
|
2218
|
+
// checked here, so without this any window (including a spoofed one)
|
|
2219
|
+
// could claim to be a screen. Same pattern as
|
|
2220
|
+
// handleEmbeddedWheelMessage below. Bind to the matched iframe element
|
|
2221
|
+
// itself rather than trusting the payload: a compromised/owned preview
|
|
2222
|
+
// iframe can put any `screenId` it wants in the message body, so the
|
|
2223
|
+
// sender's true screen identity must come from the DOM, not the data.
|
|
2224
|
+
const surfaceForSourceCheck = surfaceRef.current;
|
|
2225
|
+
const sourcePreviewIframe = surfaceForSourceCheck
|
|
2226
|
+
? Array.from(
|
|
2227
|
+
surfaceForSourceCheck.querySelectorAll<HTMLIFrameElement>(
|
|
2228
|
+
"iframe[data-design-preview-iframe]",
|
|
2229
|
+
),
|
|
2230
|
+
).find((iframe) => iframe.contentWindow === event.source)
|
|
2231
|
+
: undefined;
|
|
2232
|
+
if (!sourcePreviewIframe) return;
|
|
2233
|
+
// The board's DesignCanvas renders with `boardSurface` set, which
|
|
2234
|
+
// deliberately omits `data-screen-iframe-id` (see DesignCanvas.tsx), so
|
|
2235
|
+
// an unset attribute here means the message came from the board
|
|
2236
|
+
// surface iframe rather than a per-screen one.
|
|
2237
|
+
const domScreenId =
|
|
2238
|
+
sourcePreviewIframe.getAttribute("data-screen-iframe-id") ??
|
|
2239
|
+
boardFileId ??
|
|
2240
|
+
undefined;
|
|
1981
2241
|
const msg = event.data as {
|
|
1982
2242
|
type: string;
|
|
1983
2243
|
phase: "start" | "move" | "end" | "cancel";
|
|
@@ -2012,12 +2272,17 @@ export function MultiScreenCanvas({
|
|
|
2012
2272
|
return;
|
|
2013
2273
|
}
|
|
2014
2274
|
|
|
2015
|
-
//
|
|
2016
|
-
//
|
|
2275
|
+
// Attribute the drag to the DOM-verified source iframe's own screen id,
|
|
2276
|
+
// never the payload's claimed `msg.screenId` — the message body is
|
|
2277
|
+
// authored by the (sandboxed but same-origin-capable) preview iframe
|
|
2278
|
+
// content and must not be trusted to identify itself. Fall back to
|
|
2279
|
+
// activeId only when the matched iframe unexpectedly has no resolvable
|
|
2280
|
+
// screen id (e.g. a stale/unknown screen), matching prior behavior for
|
|
2281
|
+
// that edge case.
|
|
2017
2282
|
const sourceScreenId =
|
|
2018
|
-
|
|
2019
|
-
(
|
|
2020
|
-
?
|
|
2283
|
+
domScreenId &&
|
|
2284
|
+
(domScreenId === boardFileId || frameGeometryRef.current[domScreenId])
|
|
2285
|
+
? domScreenId
|
|
2021
2286
|
: activeId;
|
|
2022
2287
|
if (!sourceScreenId) {
|
|
2023
2288
|
// Always clear visual state (ghost + highlight) when we have no active
|
|
@@ -2042,6 +2307,23 @@ export function MultiScreenCanvas({
|
|
|
2042
2307
|
surfaceRef.current,
|
|
2043
2308
|
);
|
|
2044
2309
|
let didCleanup = false;
|
|
2310
|
+
const cancelPendingParentDrag = () => {
|
|
2311
|
+
if (crossScreenMoveRafRef.current !== null) {
|
|
2312
|
+
window.cancelAnimationFrame(crossScreenMoveRafRef.current);
|
|
2313
|
+
crossScreenMoveRafRef.current = null;
|
|
2314
|
+
}
|
|
2315
|
+
crossScreenPendingMoveRef.current = null;
|
|
2316
|
+
};
|
|
2317
|
+
const flushPendingParentDrag = () => {
|
|
2318
|
+
crossScreenMoveRafRef.current = null;
|
|
2319
|
+
const pending = crossScreenPendingMoveRef.current;
|
|
2320
|
+
crossScreenPendingMoveRef.current = null;
|
|
2321
|
+
if (!pending) return;
|
|
2322
|
+
updateCrossScreenTargetFromBoardPoint(
|
|
2323
|
+
pending.boardPoint,
|
|
2324
|
+
pending.sourceScreenId,
|
|
2325
|
+
);
|
|
2326
|
+
};
|
|
2045
2327
|
const activateParentDrag = (ev: MouseEvent) => {
|
|
2046
2328
|
ev.preventDefault();
|
|
2047
2329
|
updateCrossScreenTargetFromBoardPoint(
|
|
@@ -2050,9 +2332,26 @@ export function MultiScreenCanvas({
|
|
|
2050
2332
|
);
|
|
2051
2333
|
};
|
|
2052
2334
|
const handleParentMouseMove = (ev: MouseEvent) => {
|
|
2053
|
-
|
|
2335
|
+
ev.preventDefault();
|
|
2336
|
+
// Each drop-guide update is a postMessage round-trip to the target
|
|
2337
|
+
// iframe (see requestCrossScreenDropGuide/runHitTest). Coalesce
|
|
2338
|
+
// rapid mousemove events down to one hit-test per animation frame
|
|
2339
|
+
// instead of firing one per raw event.
|
|
2340
|
+
crossScreenPendingMoveRef.current = {
|
|
2341
|
+
boardPoint: getCanvasPoint(ev.clientX, ev.clientY),
|
|
2342
|
+
sourceScreenId,
|
|
2343
|
+
};
|
|
2344
|
+
if (crossScreenMoveRafRef.current === null) {
|
|
2345
|
+
crossScreenMoveRafRef.current = window.requestAnimationFrame(
|
|
2346
|
+
flushPendingParentDrag,
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2054
2349
|
};
|
|
2055
2350
|
const handleParentMouseUp = (ev: MouseEvent) => {
|
|
2351
|
+
// Flush synchronously with the true final pointer position on
|
|
2352
|
+
// release — don't wait for a throttled rAF that may not run
|
|
2353
|
+
// before finalizeCrossScreenDrop reads crossScreenTargetRef.
|
|
2354
|
+
cancelPendingParentDrag();
|
|
2056
2355
|
activateParentDrag(ev);
|
|
2057
2356
|
const candidate = crossScreenTargetRef.current;
|
|
2058
2357
|
const payload = crossScreenDragMsgRef.current ?? {
|
|
@@ -2071,11 +2370,13 @@ export function MultiScreenCanvas({
|
|
|
2071
2370
|
);
|
|
2072
2371
|
};
|
|
2073
2372
|
const handleParentWindowBlur = () => {
|
|
2373
|
+
cancelPendingParentDrag();
|
|
2074
2374
|
clearCrossScreenDrag();
|
|
2075
2375
|
};
|
|
2076
2376
|
const cleanup = () => {
|
|
2077
2377
|
if (didCleanup) return;
|
|
2078
2378
|
didCleanup = true;
|
|
2379
|
+
cancelPendingParentDrag();
|
|
2079
2380
|
window.removeEventListener("mousemove", handleParentMouseMove, true);
|
|
2080
2381
|
window.removeEventListener("mouseup", handleParentMouseUp, true);
|
|
2081
2382
|
window.removeEventListener("blur", handleParentWindowBlur, true);
|
|
@@ -2265,24 +2566,57 @@ export function MultiScreenCanvas({
|
|
|
2265
2566
|
surfaceRef.current,
|
|
2266
2567
|
);
|
|
2267
2568
|
let lastMouseEvent: MouseEvent | null = null;
|
|
2569
|
+
// rAF-coalesce raw mousemove: a drag/pan/marquee gesture can fire many
|
|
2570
|
+
// mousemove events per frame, but the handler recomputes snap/geometry
|
|
2571
|
+
// and commits React state — doing that per-event (rather than per
|
|
2572
|
+
// frame) is the dominant cost during a drag (see PF15 in perf report).
|
|
2573
|
+
// We keep only the latest event and flush it once per animation frame
|
|
2574
|
+
// (latest-wins). Flushing is forced synchronously before mouseup/blur
|
|
2575
|
+
// so the gesture always ends on the true final pointer position.
|
|
2576
|
+
let pendingMoveFrame: number | null = null;
|
|
2577
|
+
const flushPendingMove = () => {
|
|
2578
|
+
if (pendingMoveFrame !== null) {
|
|
2579
|
+
window.cancelAnimationFrame(pendingMoveFrame);
|
|
2580
|
+
pendingMoveFrame = null;
|
|
2581
|
+
}
|
|
2582
|
+
if (lastMouseEvent) {
|
|
2583
|
+
handleMouseMove(lastMouseEvent);
|
|
2584
|
+
}
|
|
2585
|
+
};
|
|
2268
2586
|
const move = (ev: MouseEvent) => {
|
|
2269
2587
|
lastMouseEvent = ev;
|
|
2270
2588
|
ev.preventDefault();
|
|
2271
|
-
|
|
2589
|
+
if (pendingMoveFrame !== null) return;
|
|
2590
|
+
pendingMoveFrame = window.requestAnimationFrame(() => {
|
|
2591
|
+
pendingMoveFrame = null;
|
|
2592
|
+
if (lastMouseEvent) handleMouseMove(lastMouseEvent);
|
|
2593
|
+
});
|
|
2272
2594
|
};
|
|
2273
2595
|
const up = (ev: MouseEvent) => {
|
|
2274
2596
|
lastMouseEvent = ev;
|
|
2275
2597
|
ev.preventDefault();
|
|
2598
|
+
// Flush any coalesced move first so the final drop position reflects
|
|
2599
|
+
// this exact event, then run the up handler with it.
|
|
2600
|
+
flushPendingMove();
|
|
2276
2601
|
handleMouseUp(ev);
|
|
2277
2602
|
};
|
|
2278
2603
|
const cleanupOnBlur = () => {
|
|
2279
2604
|
if (handleCancel) {
|
|
2605
|
+
if (pendingMoveFrame !== null) {
|
|
2606
|
+
window.cancelAnimationFrame(pendingMoveFrame);
|
|
2607
|
+
pendingMoveFrame = null;
|
|
2608
|
+
}
|
|
2280
2609
|
handleCancel();
|
|
2281
2610
|
return;
|
|
2282
2611
|
}
|
|
2612
|
+
flushPendingMove();
|
|
2283
2613
|
handleMouseUp(lastMouseEvent ?? new MouseEvent("mouseup"));
|
|
2284
2614
|
};
|
|
2285
2615
|
dragCleanup.current = () => {
|
|
2616
|
+
if (pendingMoveFrame !== null) {
|
|
2617
|
+
window.cancelAnimationFrame(pendingMoveFrame);
|
|
2618
|
+
pendingMoveFrame = null;
|
|
2619
|
+
}
|
|
2286
2620
|
window.removeEventListener("mousemove", move);
|
|
2287
2621
|
window.removeEventListener("mouseup", up);
|
|
2288
2622
|
window.removeEventListener("blur", cleanupOnBlur);
|
|
@@ -2298,8 +2632,12 @@ export function MultiScreenCanvas({
|
|
|
2298
2632
|
|
|
2299
2633
|
const requestSelectableElementInfos = useCallback(
|
|
2300
2634
|
(screenId: string): Promise<ElementInfo[]> => {
|
|
2635
|
+
const targetScreen = screensRef.current.find((s) => s.id === screenId);
|
|
2636
|
+
const iframeId = targetScreen
|
|
2637
|
+
? getActiveScreenIframeId(targetScreen)
|
|
2638
|
+
: screenId;
|
|
2301
2639
|
const targetIframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
2302
|
-
`[data-screen-iframe-id="${CSS.escape(
|
|
2640
|
+
`[data-screen-iframe-id="${CSS.escape(iframeId)}"]`,
|
|
2303
2641
|
);
|
|
2304
2642
|
const targetContentWindow = targetIframe?.contentWindow;
|
|
2305
2643
|
if (!targetContentWindow) return Promise.resolve([]);
|
|
@@ -2315,7 +2653,11 @@ export function MultiScreenCanvas({
|
|
|
2315
2653
|
if (
|
|
2316
2654
|
!event.data ||
|
|
2317
2655
|
event.data.type !== "agent-native:selectable-rects-result" ||
|
|
2318
|
-
event.data.correlationId !== correlationId
|
|
2656
|
+
event.data.correlationId !== correlationId ||
|
|
2657
|
+
// Require the reply to actually come from the iframe we asked,
|
|
2658
|
+
// not just any window that happens to observe/guess the
|
|
2659
|
+
// correlationId and reply with a matching payload shape.
|
|
2660
|
+
event.source !== targetContentWindow
|
|
2319
2661
|
) {
|
|
2320
2662
|
return;
|
|
2321
2663
|
}
|
|
@@ -2347,58 +2689,76 @@ export function MultiScreenCanvas({
|
|
|
2347
2689
|
[],
|
|
2348
2690
|
);
|
|
2349
2691
|
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
info,
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2692
|
+
/** Collects marquee-selectable layer candidates. Each screen requires an
|
|
2693
|
+
* async postMessage round-trip into its iframe (requestSelectableElementInfos),
|
|
2694
|
+
* so collecting for every screen on the board unconditionally at marquee
|
|
2695
|
+
* mousedown (PF20) is expensive for boards with many screens — most of
|
|
2696
|
+
* which the marquee rect will never touch. `screenIds`, when given, scopes
|
|
2697
|
+
* collection to just those frame entries (plus the board, which spans the
|
|
2698
|
+
* whole surface so it's included whenever explicitly requested); omit it
|
|
2699
|
+
* to collect every screen. */
|
|
2700
|
+
const collectLayerMarqueeCandidates = useCallback(
|
|
2701
|
+
async (screenIds?: Set<string>) => {
|
|
2702
|
+
const frameEntries = getCurrentFrameEntries().filter(
|
|
2703
|
+
(entry) => !screenIds || screenIds.has(entry.id),
|
|
2704
|
+
);
|
|
2705
|
+
const frameCandidates = await Promise.all(
|
|
2706
|
+
frameEntries.map(async (entry) => {
|
|
2707
|
+
const screen = screensRef.current.find(
|
|
2708
|
+
(item) => item.id === entry.id,
|
|
2709
|
+
);
|
|
2710
|
+
if (!screen) return [] as CanvasLayerMarqueeCandidate[];
|
|
2711
|
+
const iframe = surfaceRef.current?.querySelector<HTMLIFrameElement>(
|
|
2712
|
+
`[data-screen-iframe-id="${CSS.escape(getActiveScreenIframeId(screen))}"]`,
|
|
2713
|
+
);
|
|
2714
|
+
const metadata = getResolvedMetadata(screen);
|
|
2715
|
+
const viewportWidth = iframe?.clientWidth || metadata.width;
|
|
2716
|
+
const viewportHeight = iframe?.clientHeight || metadata.height;
|
|
2717
|
+
const scaleX = entry.geometry.width / Math.max(1, viewportWidth);
|
|
2718
|
+
const scaleY = entry.geometry.height / Math.max(1, viewportHeight);
|
|
2719
|
+
const infos = await requestSelectableElementInfos(entry.id);
|
|
2720
|
+
return infos.map((info) => ({
|
|
2721
|
+
screenId: entry.id,
|
|
2722
|
+
info,
|
|
2723
|
+
geometry: {
|
|
2724
|
+
x: entry.geometry.x + info.boundingRect.x * scaleX,
|
|
2725
|
+
y: entry.geometry.y + info.boundingRect.y * scaleY,
|
|
2726
|
+
width: info.boundingRect.width * scaleX,
|
|
2727
|
+
height: info.boundingRect.height * scaleY,
|
|
2728
|
+
},
|
|
2729
|
+
frameGeometry: entry.geometry,
|
|
2730
|
+
}));
|
|
2731
|
+
}),
|
|
2732
|
+
);
|
|
2733
|
+
const boardCandidates =
|
|
2734
|
+
boardFileId &&
|
|
2735
|
+
boardFrameGeometry &&
|
|
2736
|
+
(!screenIds || screenIds.has(boardFileId))
|
|
2737
|
+
? await (async () => {
|
|
2738
|
+
const infos = await requestSelectableElementInfos(boardFileId);
|
|
2739
|
+
return infos.map((info) => ({
|
|
2740
|
+
screenId: boardFileId,
|
|
2741
|
+
info,
|
|
2742
|
+
geometry: {
|
|
2743
|
+
x: boardFrameGeometry.x + info.boundingRect.x,
|
|
2744
|
+
y: boardFrameGeometry.y + info.boundingRect.y,
|
|
2745
|
+
width: info.boundingRect.width,
|
|
2746
|
+
height: info.boundingRect.height,
|
|
2747
|
+
},
|
|
2748
|
+
frameGeometry: boardFrameGeometry,
|
|
2749
|
+
}));
|
|
2750
|
+
})()
|
|
2751
|
+
: [];
|
|
2752
|
+
return [...frameCandidates.flat(), ...boardCandidates];
|
|
2753
|
+
},
|
|
2754
|
+
[
|
|
2755
|
+
boardFileId,
|
|
2756
|
+
boardFrameGeometry,
|
|
2757
|
+
getCurrentFrameEntries,
|
|
2758
|
+
getResolvedMetadata,
|
|
2759
|
+
requestSelectableElementInfos,
|
|
2760
|
+
],
|
|
2761
|
+
);
|
|
2402
2762
|
|
|
2403
2763
|
const scheduleFeedbackClear = useCallback(() => {
|
|
2404
2764
|
if (feedbackTimerRef.current !== null) {
|
|
@@ -2437,11 +2797,20 @@ export function MultiScreenCanvas({
|
|
|
2437
2797
|
window.innerHeight - TRANSFORM_BADGE_EDGE_PADDING
|
|
2438
2798
|
? clientY + TRANSFORM_BADGE_OFFSET
|
|
2439
2799
|
: clientY - TRANSFORM_BADGE_HEIGHT - TRANSFORM_BADGE_OFFSET;
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2800
|
+
const nextX = clampNumber(preferredX, TRANSFORM_BADGE_EDGE_PADDING, maxX);
|
|
2801
|
+
const nextY = clampNumber(preferredY, TRANSFORM_BADGE_EDGE_PADDING, maxY);
|
|
2802
|
+
// Equality-bail: called from the rAF-coalesced mousemove handler, so
|
|
2803
|
+
// this runs at most once per frame already, but a steady drag (e.g.
|
|
2804
|
+
// pinned against a snap axis) can still repeat the identical badge
|
|
2805
|
+
// text/position — skip the setState in that case (PF15).
|
|
2806
|
+
setTransformBadge((current) =>
|
|
2807
|
+
current &&
|
|
2808
|
+
current.text === text &&
|
|
2809
|
+
current.x === nextX &&
|
|
2810
|
+
current.y === nextY
|
|
2811
|
+
? current
|
|
2812
|
+
: { text, x: nextX, y: nextY },
|
|
2813
|
+
);
|
|
2445
2814
|
},
|
|
2446
2815
|
[],
|
|
2447
2816
|
);
|
|
@@ -2529,6 +2898,7 @@ export function MultiScreenCanvas({
|
|
|
2529
2898
|
setMarquee(null);
|
|
2530
2899
|
setCreationPreview(null);
|
|
2531
2900
|
setAlignmentGuides([]);
|
|
2901
|
+
setEqualGapGuides([]);
|
|
2532
2902
|
setTransformBadge(null);
|
|
2533
2903
|
setDragCursor(null);
|
|
2534
2904
|
primitiveDropTargetRef.current = null;
|
|
@@ -2546,7 +2916,11 @@ export function MultiScreenCanvas({
|
|
|
2546
2916
|
|
|
2547
2917
|
if (state) {
|
|
2548
2918
|
cancelled = true;
|
|
2549
|
-
if (
|
|
2919
|
+
if (
|
|
2920
|
+
state.type === "move" ||
|
|
2921
|
+
state.type === "resize" ||
|
|
2922
|
+
state.type === "group-rotate"
|
|
2923
|
+
) {
|
|
2550
2924
|
updateFrameGeometry((current) =>
|
|
2551
2925
|
frameGeometryWithOverrides(current, state.originFrames),
|
|
2552
2926
|
);
|
|
@@ -2629,10 +3003,25 @@ export function MultiScreenCanvas({
|
|
|
2629
3003
|
y: state.originPan.y + ev.clientY - state.originClient.y,
|
|
2630
3004
|
};
|
|
2631
3005
|
panRef.current = nextPan;
|
|
2632
|
-
|
|
3006
|
+
// Mirror the wheel/pinch path (applyViewToDom): mutate the transform
|
|
3007
|
+
// directly during the gesture and only reconcile React state once the
|
|
3008
|
+
// gesture settles, so a mouse-pan produces zero re-renders per move.
|
|
3009
|
+
applyViewToDomRef.current();
|
|
3010
|
+
scheduleViewCommitRef.current();
|
|
2633
3011
|
};
|
|
2634
3012
|
|
|
2635
|
-
|
|
3013
|
+
const handlePanEnd = () => {
|
|
3014
|
+
// Ensure React state reflects the true final pan immediately on
|
|
3015
|
+
// release rather than waiting for the debounced commit timer.
|
|
3016
|
+
setPan(panRef.current);
|
|
3017
|
+
// P18: a middle-mouse-button pan while a pen path is active also
|
|
3018
|
+
// moves the canvas-space mapping the ghost preview was computed
|
|
3019
|
+
// from — resync it now that pan has settled.
|
|
3020
|
+
recomputePenPointerForViewChangeRef.current();
|
|
3021
|
+
finishDrag();
|
|
3022
|
+
};
|
|
3023
|
+
|
|
3024
|
+
installDragListeners(handleMouseMove, handlePanEnd);
|
|
2636
3025
|
},
|
|
2637
3026
|
[finishDrag, installDragListeners],
|
|
2638
3027
|
);
|
|
@@ -2644,14 +3033,25 @@ export function MultiScreenCanvas({
|
|
|
2644
3033
|
const originCanvas = getCanvasPoint(e.clientX, e.clientY);
|
|
2645
3034
|
let latestRect = normalizeRectFromPoints(originCanvas, originCanvas);
|
|
2646
3035
|
let layerCandidates: CanvasLayerMarqueeCandidate[] = [];
|
|
3036
|
+
// PF20: collecting selectable layer info for every screen on the board
|
|
3037
|
+
// requires one async postMessage round-trip per iframe. Doing that
|
|
3038
|
+
// eagerly for the whole board on marquee mousedown is wasted work for
|
|
3039
|
+
// any screen the marquee rect never reaches. Instead, lazily collect
|
|
3040
|
+
// only screens the rect currently intersects, growing the collected
|
|
3041
|
+
// set incrementally as the drag expands the rect. `collectingScreenIds`
|
|
3042
|
+
// guards against re-requesting a screen whose collection is already
|
|
3043
|
+
// in flight or done.
|
|
3044
|
+
const collectedScreenIds = new Set<string>();
|
|
3045
|
+
const collectingScreenIds = new Set<string>();
|
|
2647
3046
|
const reportLayerSelection = (rect: MarqueeRect) => {
|
|
2648
3047
|
const state = dragState.current;
|
|
2649
3048
|
if (!state || state.type !== "marquee") return;
|
|
3049
|
+
const chromeScale = chromeScaleFromZoom(zoomRef.current);
|
|
2650
3050
|
const selection = layerCandidates
|
|
2651
3051
|
.filter((candidate) =>
|
|
2652
3052
|
rotatedRectIntersects(
|
|
2653
3053
|
rect,
|
|
2654
|
-
getSelectableBounds(candidate.geometry),
|
|
3054
|
+
getSelectableBounds(candidate.geometry, chromeScale),
|
|
2655
3055
|
getFrameCenter(candidate.frameGeometry),
|
|
2656
3056
|
candidate.frameGeometry.rotation ?? 0,
|
|
2657
3057
|
),
|
|
@@ -2666,6 +3066,34 @@ export function MultiScreenCanvas({
|
|
|
2666
3066
|
shiftKey: state.additive,
|
|
2667
3067
|
});
|
|
2668
3068
|
};
|
|
3069
|
+
const collectForIntersectedScreens = (hitIds: string[]) => {
|
|
3070
|
+
const newIds = hitIds.filter(
|
|
3071
|
+
(id) => !collectedScreenIds.has(id) && !collectingScreenIds.has(id),
|
|
3072
|
+
);
|
|
3073
|
+
// The board spans the whole surface, so include it once anything
|
|
3074
|
+
// intersects (or immediately, for the initial zero-size rect) rather
|
|
3075
|
+
// than trying to hit-test its own — usually oversized — geometry.
|
|
3076
|
+
if (
|
|
3077
|
+
boardFileId &&
|
|
3078
|
+
boardFrameGeometry &&
|
|
3079
|
+
!collectedScreenIds.has(boardFileId) &&
|
|
3080
|
+
!collectingScreenIds.has(boardFileId)
|
|
3081
|
+
) {
|
|
3082
|
+
newIds.push(boardFileId);
|
|
3083
|
+
}
|
|
3084
|
+
if (newIds.length === 0) return;
|
|
3085
|
+
const requestIds = new Set(newIds);
|
|
3086
|
+
newIds.forEach((id) => collectingScreenIds.add(id));
|
|
3087
|
+
void collectLayerMarqueeCandidates(requestIds).then((candidates) => {
|
|
3088
|
+
newIds.forEach((id) => {
|
|
3089
|
+
collectingScreenIds.delete(id);
|
|
3090
|
+
collectedScreenIds.add(id);
|
|
3091
|
+
});
|
|
3092
|
+
if (dragState.current?.type !== "marquee") return;
|
|
3093
|
+
layerCandidates = [...layerCandidates, ...candidates];
|
|
3094
|
+
reportLayerSelection(latestRect);
|
|
3095
|
+
});
|
|
3096
|
+
};
|
|
2669
3097
|
dragState.current = {
|
|
2670
3098
|
type: "marquee",
|
|
2671
3099
|
originClient: { x: e.clientX, y: e.clientY },
|
|
@@ -2686,11 +3114,10 @@ export function MultiScreenCanvas({
|
|
|
2686
3114
|
});
|
|
2687
3115
|
}
|
|
2688
3116
|
setIsDragging(true);
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
});
|
|
3117
|
+
// Seed collection with whatever the zero-size origin rect already
|
|
3118
|
+
// touches (typically just the board, if present) so a click-without-
|
|
3119
|
+
// drag still reports a correct (likely empty) selection on mouseup.
|
|
3120
|
+
collectForIntersectedScreens([]);
|
|
2694
3121
|
|
|
2695
3122
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
2696
3123
|
const state = dragState.current;
|
|
@@ -2709,11 +3136,12 @@ export function MultiScreenCanvas({
|
|
|
2709
3136
|
}
|
|
2710
3137
|
setMarquee(rect);
|
|
2711
3138
|
|
|
3139
|
+
const chromeScale = chromeScaleFromZoom(zoomRef.current);
|
|
2712
3140
|
const hitIds = getCurrentFrameEntries()
|
|
2713
3141
|
.filter((entry) =>
|
|
2714
3142
|
rotatedRectIntersects(
|
|
2715
3143
|
rect,
|
|
2716
|
-
getSelectableBounds(entry.geometry),
|
|
3144
|
+
getSelectableBounds(entry.geometry, chromeScale),
|
|
2717
3145
|
getFrameCenter(entry.geometry),
|
|
2718
3146
|
entry.geometry.rotation ?? 0,
|
|
2719
3147
|
),
|
|
@@ -2723,21 +3151,23 @@ export function MultiScreenCanvas({
|
|
|
2723
3151
|
.filter((entry) =>
|
|
2724
3152
|
rotatedRectIntersects(
|
|
2725
3153
|
rect,
|
|
2726
|
-
getSelectableBounds(entry.geometry),
|
|
3154
|
+
getSelectableBounds(entry.geometry, chromeScale),
|
|
2727
3155
|
getFrameCenter(entry.geometry),
|
|
2728
3156
|
entry.geometry.rotation ?? 0,
|
|
2729
3157
|
),
|
|
2730
3158
|
)
|
|
2731
3159
|
.map((entry) => entry.id);
|
|
2732
3160
|
|
|
3161
|
+
collectForIntersectedScreens(hitIds);
|
|
3162
|
+
|
|
2733
3163
|
updateSelectedIds(() =>
|
|
2734
3164
|
state.additive
|
|
2735
|
-
?
|
|
3165
|
+
? xorMarqueeSelection(state.baseSelectedIds, hitIds)
|
|
2736
3166
|
: hitIds,
|
|
2737
3167
|
);
|
|
2738
3168
|
updateSelectedDraftIds(() =>
|
|
2739
3169
|
state.additive
|
|
2740
|
-
?
|
|
3170
|
+
? xorMarqueeSelection(state.baseSelectedDraftIds, hitDraftIds)
|
|
2741
3171
|
: hitDraftIds,
|
|
2742
3172
|
);
|
|
2743
3173
|
reportLayerSelection(rect);
|
|
@@ -2760,6 +3190,8 @@ export function MultiScreenCanvas({
|
|
|
2760
3190
|
installDragListeners(handleMouseMove, handleMouseUp);
|
|
2761
3191
|
},
|
|
2762
3192
|
[
|
|
3193
|
+
boardFileId,
|
|
3194
|
+
boardFrameGeometry,
|
|
2763
3195
|
collectLayerMarqueeCandidates,
|
|
2764
3196
|
finishDrag,
|
|
2765
3197
|
getCanvasPoint,
|
|
@@ -3029,15 +3461,24 @@ export function MultiScreenCanvas({
|
|
|
3029
3461
|
) => {
|
|
3030
3462
|
const rawPoint = getCanvasPoint(clientX, clientY);
|
|
3031
3463
|
const lastAnchor = path?.nodes[path.nodes.length - 1]?.point;
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3464
|
+
const constrainedPoint =
|
|
3465
|
+
shiftKey && lastAnchor
|
|
3466
|
+
? constrainPointTo45Degrees(lastAnchor, rawPoint)
|
|
3467
|
+
: rawPoint;
|
|
3468
|
+
// Light anchor snapping (P15): snap onto an existing anchor of the
|
|
3469
|
+
// path being drawn (so you can precisely re-hit a prior point), else
|
|
3470
|
+
// round to integer canvas px once zoomed to 100% or more.
|
|
3471
|
+
return snapPenAnchorPoint(constrainedPoint, path, {
|
|
3472
|
+
hitRadius: PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
|
|
3473
|
+
zoom: zoomRef.current,
|
|
3474
|
+
});
|
|
3035
3475
|
},
|
|
3036
3476
|
[getCanvasPoint],
|
|
3037
3477
|
);
|
|
3038
3478
|
|
|
3039
3479
|
const updatePenPointer = useCallback(
|
|
3040
3480
|
(clientX: number, clientY: number, shiftKey: boolean) => {
|
|
3481
|
+
lastPenClientPointRef.current = { clientX, clientY, shiftKey };
|
|
3041
3482
|
const path = activePenPathRef.current;
|
|
3042
3483
|
if (!path || path.closed) {
|
|
3043
3484
|
setPenPointer(null);
|
|
@@ -3061,35 +3502,54 @@ export function MultiScreenCanvas({
|
|
|
3061
3502
|
[getCanvasPoint, getPenAnchorPoint],
|
|
3062
3503
|
);
|
|
3063
3504
|
|
|
3505
|
+
// P18: a wheel pan/zoom gesture moves pan/zoom every animation frame
|
|
3506
|
+
// (applyViewToDom, mutated imperatively — see its comment) without any
|
|
3507
|
+
// mousemove event firing, so the pen ghost/ close-hover preview — derived
|
|
3508
|
+
// from screen->canvas conversion of the last known client point — goes
|
|
3509
|
+
// stale and visibly detaches from the cursor mid-gesture. Recompute it
|
|
3510
|
+
// from the remembered client point whenever pan/zoom changes.
|
|
3511
|
+
const recomputePenPointerForViewChange = useCallback(() => {
|
|
3512
|
+
const last = lastPenClientPointRef.current;
|
|
3513
|
+
if (!last || !activePenPathRef.current) return;
|
|
3514
|
+
updatePenPointer(last.clientX, last.clientY, last.shiftKey);
|
|
3515
|
+
}, [updatePenPointer]);
|
|
3516
|
+
recomputePenPointerForViewChangeRef.current =
|
|
3517
|
+
recomputePenPointerForViewChange;
|
|
3518
|
+
|
|
3064
3519
|
const beginPenNodeCreation = useCallback(
|
|
3065
3520
|
(e: React.MouseEvent) => {
|
|
3066
3521
|
if (e.button !== 0) return;
|
|
3067
3522
|
e.preventDefault();
|
|
3068
3523
|
e.stopPropagation();
|
|
3524
|
+
// Double (or further) click ends the path instead of adding another
|
|
3525
|
+
// duplicate coincident anchor at the same point (P7).
|
|
3526
|
+
if (e.detail > 1) {
|
|
3527
|
+
finishPenPath();
|
|
3528
|
+
return;
|
|
3529
|
+
}
|
|
3069
3530
|
suppressNextPick.current = true;
|
|
3070
3531
|
|
|
3071
3532
|
const pathBefore = activePenPathRef.current?.closed
|
|
3072
3533
|
? null
|
|
3073
3534
|
: activePenPathRef.current;
|
|
3074
3535
|
const rawPoint = getCanvasPoint(e.clientX, e.clientY);
|
|
3075
|
-
|
|
3536
|
+
const closing = Boolean(
|
|
3076
3537
|
pathBefore &&
|
|
3077
3538
|
isPenCloseTarget(
|
|
3078
3539
|
pathBefore,
|
|
3079
3540
|
rawPoint,
|
|
3080
3541
|
PEN_CLOSE_HIT_RADIUS_SCREEN_PX / (zoomRef.current / 100),
|
|
3081
|
-
)
|
|
3082
|
-
) {
|
|
3083
|
-
finishPenPath(closePenPath(pathBefore));
|
|
3084
|
-
return;
|
|
3085
|
-
}
|
|
3086
|
-
|
|
3087
|
-
const anchor = getPenAnchorPoint(
|
|
3088
|
-
e.clientX,
|
|
3089
|
-
e.clientY,
|
|
3090
|
-
e.shiftKey,
|
|
3091
|
-
pathBefore,
|
|
3542
|
+
),
|
|
3092
3543
|
);
|
|
3544
|
+
|
|
3545
|
+
// Figma defers the close commit to mouseup rather than closing
|
|
3546
|
+
// instantly on mousedown, so a drag on the closing click can shape
|
|
3547
|
+
// the closing segment's curve. Anchor the drag at the path's first
|
|
3548
|
+
// point (rather than the raw cursor position) so a click-only close
|
|
3549
|
+
// (no drag) still closes exactly on the start anchor.
|
|
3550
|
+
const anchor = closing
|
|
3551
|
+
? pathBefore!.nodes[0].point
|
|
3552
|
+
: getPenAnchorPoint(e.clientX, e.clientY, e.shiftKey, pathBefore);
|
|
3093
3553
|
const pathSnapshot = pathBefore ? clonePenPath(pathBefore) : null;
|
|
3094
3554
|
dragState.current = {
|
|
3095
3555
|
type: "pen-node",
|
|
@@ -3097,13 +3557,18 @@ export function MultiScreenCanvas({
|
|
|
3097
3557
|
anchor,
|
|
3098
3558
|
pathBefore: pathSnapshot,
|
|
3099
3559
|
hasMoved: false,
|
|
3560
|
+
closing,
|
|
3100
3561
|
};
|
|
3101
|
-
const initialPath =
|
|
3562
|
+
const initialPath = closing
|
|
3563
|
+
? (pathSnapshot as PenPath)
|
|
3564
|
+
: appendPenNode(pathSnapshot, createCornerNode(anchor));
|
|
3102
3565
|
activePenPathRef.current = initialPath;
|
|
3103
3566
|
setActivePenPath(initialPath);
|
|
3104
|
-
setPenGesturePreview(
|
|
3567
|
+
setPenGesturePreview(
|
|
3568
|
+
closing ? closePenPath(pathSnapshot as PenPath) : initialPath,
|
|
3569
|
+
);
|
|
3105
3570
|
setPenPointer(null);
|
|
3106
|
-
setPenCloseHover(
|
|
3571
|
+
setPenCloseHover(closing);
|
|
3107
3572
|
setIsDragging(true);
|
|
3108
3573
|
setDragCursor("crosshair");
|
|
3109
3574
|
|
|
@@ -3121,13 +3586,31 @@ export function MultiScreenCanvas({
|
|
|
3121
3586
|
}
|
|
3122
3587
|
|
|
3123
3588
|
const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3589
|
+
const handleOut = ev.shiftKey
|
|
3590
|
+
? constrainPointTo45Degrees(state.anchor, handlePoint)
|
|
3591
|
+
: handlePoint;
|
|
3592
|
+
|
|
3593
|
+
if (state.closing) {
|
|
3594
|
+
// Shape the closing segment: drag the first anchor's handleIn
|
|
3595
|
+
// (mirrored across the anchor from the drag point, matching how
|
|
3596
|
+
// every other smooth anchor's handles work) without appending a
|
|
3597
|
+
// new node — the path is still just previewed as closed.
|
|
3598
|
+
const closedPreviewPath = shapeClosingHandles(
|
|
3599
|
+
state.pathBefore as PenPath,
|
|
3600
|
+
state.hasMoved ? handleOut : null,
|
|
3601
|
+
);
|
|
3602
|
+
setPenGesturePreview(closedPreviewPath);
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3124
3606
|
const node = state.hasMoved
|
|
3125
|
-
? createSmoothNode(
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3607
|
+
? createSmoothNode(state.anchor, handleOut, {
|
|
3608
|
+
// Alt/Option while dragging a new anchor's handle breaks
|
|
3609
|
+
// symmetry into a cusp (P8): read the live event's altKey on
|
|
3610
|
+
// every move so toggling Alt mid-drag updates immediately,
|
|
3611
|
+
// rather than latching whatever it was when the drag started.
|
|
3612
|
+
breakSymmetry: ev.altKey,
|
|
3613
|
+
})
|
|
3131
3614
|
: createCornerNode(state.anchor);
|
|
3132
3615
|
const nextPath = appendPenNode(state.pathBefore, node);
|
|
3133
3616
|
activePenPathRef.current = nextPath;
|
|
@@ -3143,13 +3626,27 @@ export function MultiScreenCanvas({
|
|
|
3143
3626
|
}
|
|
3144
3627
|
|
|
3145
3628
|
const handlePoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3629
|
+
const handleOut = ev.shiftKey
|
|
3630
|
+
? constrainPointTo45Degrees(state.anchor, handlePoint)
|
|
3631
|
+
: handlePoint;
|
|
3632
|
+
|
|
3633
|
+
if (state.closing) {
|
|
3634
|
+
const closedPath = shapeClosingHandles(
|
|
3635
|
+
state.pathBefore as PenPath,
|
|
3636
|
+
state.hasMoved ? handleOut : null,
|
|
3637
|
+
);
|
|
3638
|
+
setPenGesturePreview(null);
|
|
3639
|
+
setPenPointer(null);
|
|
3640
|
+
setPenCloseHover(false);
|
|
3641
|
+
finishPenPath(closedPath);
|
|
3642
|
+
finishDrag();
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3146
3646
|
const node: PenNode = state.hasMoved
|
|
3147
|
-
? createSmoothNode(
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
? constrainPointTo45Degrees(state.anchor, handlePoint)
|
|
3151
|
-
: handlePoint,
|
|
3152
|
-
)
|
|
3647
|
+
? createSmoothNode(state.anchor, handleOut, {
|
|
3648
|
+
breakSymmetry: ev.altKey,
|
|
3649
|
+
})
|
|
3153
3650
|
: createCornerNode(state.anchor);
|
|
3154
3651
|
const nextPath = appendPenNode(state.pathBefore, node);
|
|
3155
3652
|
activePenPathRef.current = nextPath;
|
|
@@ -3200,14 +3697,12 @@ export function MultiScreenCanvas({
|
|
|
3200
3697
|
false,
|
|
3201
3698
|
);
|
|
3202
3699
|
const initialPoints =
|
|
3203
|
-
tool === "
|
|
3204
|
-
? [
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
]
|
|
3210
|
-
: undefined;
|
|
3700
|
+
tool === "line" || tool === "arrow"
|
|
3701
|
+
? [
|
|
3702
|
+
originCanvas,
|
|
3703
|
+
{ x: originCanvas.x + DRAFT_LINE_WIDTH, y: originCanvas.y },
|
|
3704
|
+
]
|
|
3705
|
+
: undefined;
|
|
3211
3706
|
dragState.current = {
|
|
3212
3707
|
type: "draft-create",
|
|
3213
3708
|
tool,
|
|
@@ -3223,7 +3718,7 @@ export function MultiScreenCanvas({
|
|
|
3223
3718
|
points: initialPoints,
|
|
3224
3719
|
});
|
|
3225
3720
|
setIsDragging(true);
|
|
3226
|
-
setDragCursor(
|
|
3721
|
+
setDragCursor("crosshair");
|
|
3227
3722
|
|
|
3228
3723
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
3229
3724
|
const state = dragState.current;
|
|
@@ -3239,22 +3734,15 @@ export function MultiScreenCanvas({
|
|
|
3239
3734
|
state.hasMoved = true;
|
|
3240
3735
|
}
|
|
3241
3736
|
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
tool,
|
|
3252
|
-
geometry: getPathGeometry(state.points),
|
|
3253
|
-
points: state.points,
|
|
3254
|
-
});
|
|
3255
|
-
return;
|
|
3256
|
-
}
|
|
3257
|
-
|
|
3737
|
+
const modifiers: DraftGeometryModifiers = {
|
|
3738
|
+
shiftKey: ev.shiftKey,
|
|
3739
|
+
altKey: ev.altKey,
|
|
3740
|
+
};
|
|
3741
|
+
const isLineTool = state.tool === "line" || state.tool === "arrow";
|
|
3742
|
+
const previewEnd =
|
|
3743
|
+
isLineTool && ev.shiftKey
|
|
3744
|
+
? constrainPointTo45Degrees(state.originCanvas, nextCanvas)
|
|
3745
|
+
: nextCanvas;
|
|
3258
3746
|
setCreationPreview({
|
|
3259
3747
|
tool,
|
|
3260
3748
|
geometry: getDraftPreviewGeometryForTool(
|
|
@@ -3262,11 +3750,9 @@ export function MultiScreenCanvas({
|
|
|
3262
3750
|
state.originCanvas,
|
|
3263
3751
|
nextCanvas,
|
|
3264
3752
|
state.hasMoved,
|
|
3753
|
+
modifiers,
|
|
3265
3754
|
),
|
|
3266
|
-
points:
|
|
3267
|
-
state.tool === "line" || state.tool === "arrow"
|
|
3268
|
-
? [state.originCanvas, nextCanvas]
|
|
3269
|
-
: undefined,
|
|
3755
|
+
points: isLineTool ? [state.originCanvas, previewEnd] : undefined,
|
|
3270
3756
|
});
|
|
3271
3757
|
};
|
|
3272
3758
|
|
|
@@ -3277,7 +3763,7 @@ export function MultiScreenCanvas({
|
|
|
3277
3763
|
return;
|
|
3278
3764
|
}
|
|
3279
3765
|
|
|
3280
|
-
|
|
3766
|
+
const endCanvas = getCanvasPoint(ev.clientX, ev.clientY);
|
|
3281
3767
|
const canvasMoved =
|
|
3282
3768
|
Math.hypot(
|
|
3283
3769
|
endCanvas.x - state.originCanvas.x,
|
|
@@ -3291,18 +3777,18 @@ export function MultiScreenCanvas({
|
|
|
3291
3777
|
) >= DRAG_THRESHOLD ||
|
|
3292
3778
|
canvasMoved;
|
|
3293
3779
|
state.hasMoved = releaseMoved;
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
finishDrag();
|
|
3299
|
-
return;
|
|
3300
|
-
}
|
|
3301
|
-
endCanvas = points[points.length - 1] ?? endCanvas;
|
|
3302
|
-
}
|
|
3780
|
+
const modifiers: DraftGeometryModifiers = {
|
|
3781
|
+
shiftKey: ev.shiftKey,
|
|
3782
|
+
altKey: ev.altKey,
|
|
3783
|
+
};
|
|
3303
3784
|
if (state.tool === "frame" && onCreateScreenFrame) {
|
|
3304
3785
|
onCreateScreenFrame(
|
|
3305
|
-
getDraftGeometryForTool(
|
|
3786
|
+
getDraftGeometryForTool(
|
|
3787
|
+
state.tool,
|
|
3788
|
+
state.originCanvas,
|
|
3789
|
+
endCanvas,
|
|
3790
|
+
modifiers,
|
|
3791
|
+
),
|
|
3306
3792
|
);
|
|
3307
3793
|
if (activeTool === undefined) {
|
|
3308
3794
|
setLocalActiveTool("move");
|
|
@@ -3315,22 +3801,15 @@ export function MultiScreenCanvas({
|
|
|
3315
3801
|
tool: state.tool,
|
|
3316
3802
|
start: state.originCanvas,
|
|
3317
3803
|
end: endCanvas,
|
|
3318
|
-
points,
|
|
3319
3804
|
moved: releaseMoved,
|
|
3320
3805
|
toolProps,
|
|
3806
|
+
modifiers,
|
|
3321
3807
|
});
|
|
3322
3808
|
commitDraftPrimitive(nextDraft, state.originFrameId);
|
|
3323
3809
|
if (activeTool === undefined) {
|
|
3324
3810
|
setLocalActiveTool("move");
|
|
3325
3811
|
}
|
|
3326
|
-
|
|
3327
|
-
if (activeTool === undefined) {
|
|
3328
|
-
setLocalActiveTool("pen");
|
|
3329
|
-
}
|
|
3330
|
-
onActiveToolChange?.("pen");
|
|
3331
|
-
} else {
|
|
3332
|
-
onActiveToolChange?.("move");
|
|
3333
|
-
}
|
|
3812
|
+
onActiveToolChange?.("move");
|
|
3334
3813
|
finishDrag();
|
|
3335
3814
|
};
|
|
3336
3815
|
|
|
@@ -3726,8 +4205,8 @@ export function MultiScreenCanvas({
|
|
|
3726
4205
|
const state = dragState.current;
|
|
3727
4206
|
if (!state || state.type !== "move") return;
|
|
3728
4207
|
const scale = zoomRef.current / 100;
|
|
3729
|
-
|
|
3730
|
-
|
|
4208
|
+
let dx = (ev.clientX - state.originClient.x) / scale;
|
|
4209
|
+
let dy = (ev.clientY - state.originClient.y) / scale;
|
|
3731
4210
|
if (
|
|
3732
4211
|
!state.hasMoved &&
|
|
3733
4212
|
Math.hypot(
|
|
@@ -3738,6 +4217,26 @@ export function MultiScreenCanvas({
|
|
|
3738
4217
|
state.hasMoved = true;
|
|
3739
4218
|
}
|
|
3740
4219
|
|
|
4220
|
+
// Never commit a live transform before the drag threshold is crossed:
|
|
4221
|
+
// otherwise 1-2px of click jitter nudges the frame, that nudge is
|
|
4222
|
+
// never reverted (mouseup below only restores origin when the whole
|
|
4223
|
+
// gesture never moved past threshold — see below), and the next drag
|
|
4224
|
+
// reads its origin from the already-nudged geometry.
|
|
4225
|
+
if (!state.hasMoved) return;
|
|
4226
|
+
|
|
4227
|
+
// Shift held mid-move (not at mousedown — that path is shift-click
|
|
4228
|
+
// multi-select and never reaches here, see the guard above) locks
|
|
4229
|
+
// movement to a single axis, matching Figma. Zero the smaller-
|
|
4230
|
+
// magnitude axis before snapping so snap candidates on the locked
|
|
4231
|
+
// axis can't reintroduce drift on it.
|
|
4232
|
+
if (ev.shiftKey) {
|
|
4233
|
+
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
4234
|
+
dy = 0;
|
|
4235
|
+
} else {
|
|
4236
|
+
dx = 0;
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
|
|
3741
4240
|
const movingEntries = state.targetIds.map((targetId) => ({
|
|
3742
4241
|
id: targetId,
|
|
3743
4242
|
geometry: {
|
|
@@ -3769,6 +4268,51 @@ export function MultiScreenCanvas({
|
|
|
3769
4268
|
});
|
|
3770
4269
|
setAlignmentGuides(snap.guides);
|
|
3771
4270
|
|
|
4271
|
+
// Smart-spacing guides (CV11) — only meaningful for a single moving
|
|
4272
|
+
// frame (matches Figma, which only shows equal-gap guides while
|
|
4273
|
+
// dragging one object, not a multi-select group).
|
|
4274
|
+
if (state.targetIds.length === 1) {
|
|
4275
|
+
const primaryId = state.targetIds[0];
|
|
4276
|
+
const movedGeometry = movingEntries.find(
|
|
4277
|
+
(entry) => entry.id === primaryId,
|
|
4278
|
+
)?.geometry;
|
|
4279
|
+
if (movedGeometry) {
|
|
4280
|
+
// Same screen-px-to-canvas-px conversion computeMoveSnap uses
|
|
4281
|
+
// for its own threshold, so the equal-gap tolerance also stays
|
|
4282
|
+
// a constant few screen pixels regardless of zoom level.
|
|
4283
|
+
const tolerance =
|
|
4284
|
+
EQUAL_GAP_TOLERANCE_SCREEN_PX /
|
|
4285
|
+
Math.max(0.01, zoomRef.current / 100);
|
|
4286
|
+
setEqualGapGuides(
|
|
4287
|
+
computeEqualGapGuides(
|
|
4288
|
+
{
|
|
4289
|
+
...movedGeometry,
|
|
4290
|
+
x: movedGeometry.x + snap.dx,
|
|
4291
|
+
y: movedGeometry.y + snap.dy,
|
|
4292
|
+
},
|
|
4293
|
+
stationaryEntries,
|
|
4294
|
+
{ toleranceCanvasPx: tolerance },
|
|
4295
|
+
),
|
|
4296
|
+
);
|
|
4297
|
+
}
|
|
4298
|
+
} else {
|
|
4299
|
+
setEqualGapGuides([]);
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
// Resize shows a W x H badge and rotate shows a degrees badge — move
|
|
4303
|
+
// was the one transform with no live feedback at all. Show the
|
|
4304
|
+
// primary frame's new (rounded) position, matching resize/rotate's
|
|
4305
|
+
// convention of displaying the current absolute value rather than a
|
|
4306
|
+
// delta.
|
|
4307
|
+
const primaryOrigin = state.originFrames[state.primaryId];
|
|
4308
|
+
if (primaryOrigin) {
|
|
4309
|
+
showTransformFeedback(
|
|
4310
|
+
`${Math.round(primaryOrigin.x + dx + snap.dx)}, ${Math.round(primaryOrigin.y + dy + snap.dy)}`,
|
|
4311
|
+
ev.clientX,
|
|
4312
|
+
ev.clientY,
|
|
4313
|
+
);
|
|
4314
|
+
}
|
|
4315
|
+
|
|
3772
4316
|
// When all dragged ids are committed primitive nodeIds (not screen
|
|
3773
4317
|
// frames), check for a container primitive drop target to highlight.
|
|
3774
4318
|
const currentFrameIds = Object.keys(frameGeometryRef.current);
|
|
@@ -3786,6 +4330,15 @@ export function MultiScreenCanvas({
|
|
|
3786
4330
|
const handleMouseUp = () => {
|
|
3787
4331
|
const state = dragState.current;
|
|
3788
4332
|
const dropTarget = primitiveDropTargetRef.current;
|
|
4333
|
+
if (state?.type === "move" && !state.hasMoved) {
|
|
4334
|
+
// Belt-and-braces: the live transform above already skips committing
|
|
4335
|
+
// until hasMoved, but restore origin here too in case any geometry
|
|
4336
|
+
// slipped through (e.g. a future code path that writes frameGeometry
|
|
4337
|
+
// directly) so a below-threshold click never leaves a phantom nudge.
|
|
4338
|
+
updateFrameGeometry((current) =>
|
|
4339
|
+
frameGeometryWithOverrides(current, state.originFrames),
|
|
4340
|
+
);
|
|
4341
|
+
}
|
|
3789
4342
|
if (state?.type === "move" && state.hasMoved) {
|
|
3790
4343
|
// If all dragged ids are committed primitive nodeIds (not screen
|
|
3791
4344
|
// frames), attempt a primitive reparent on drop.
|
|
@@ -3831,6 +4384,7 @@ export function MultiScreenCanvas({
|
|
|
3831
4384
|
installDragListeners,
|
|
3832
4385
|
onPick,
|
|
3833
4386
|
resolvePrimitiveScreenId,
|
|
4387
|
+
showTransformFeedback,
|
|
3834
4388
|
updateFrameGeometry,
|
|
3835
4389
|
updatePrimitiveDropTarget,
|
|
3836
4390
|
updateSelectedDraftIds,
|
|
@@ -3872,7 +4426,19 @@ export function MultiScreenCanvas({
|
|
|
3872
4426
|
hasMoved: false,
|
|
3873
4427
|
};
|
|
3874
4428
|
setIsDragging(true);
|
|
3875
|
-
|
|
4429
|
+
// Rotation-aware cursor: a static per-handle cursor is only correct
|
|
4430
|
+
// when the frame isn't rotated. For a single selected (possibly
|
|
4431
|
+
// rotated) frame, quantize the handle's rotated visual angle to the
|
|
4432
|
+
// nearest 45deg to pick the matching cursor; group resizes keep the
|
|
4433
|
+
// unrotated cursor, matching the group's own unrotated resize math.
|
|
4434
|
+
setDragCursor(
|
|
4435
|
+
originEntries.length === 1
|
|
4436
|
+
? getResizeCursorForHandle(
|
|
4437
|
+
handle,
|
|
4438
|
+
originEntries[0].geometry.rotation ?? 0,
|
|
4439
|
+
)
|
|
4440
|
+
: getResizeCursor(handle),
|
|
4441
|
+
);
|
|
3876
4442
|
|
|
3877
4443
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
3878
4444
|
const state = dragState.current;
|
|
@@ -3890,10 +4456,58 @@ export function MultiScreenCanvas({
|
|
|
3890
4456
|
state.hasMoved = true;
|
|
3891
4457
|
}
|
|
3892
4458
|
|
|
4459
|
+
// Skip committing any transform until the drag threshold is crossed —
|
|
4460
|
+
// see the matching comment in beginFrameDrag's move handler.
|
|
4461
|
+
if (!state.hasMoved) return;
|
|
4462
|
+
|
|
3893
4463
|
const originEntries = state.targetIds.map((targetId) => ({
|
|
3894
4464
|
id: targetId,
|
|
3895
4465
|
geometry: state.originFrames[targetId],
|
|
3896
4466
|
}));
|
|
4467
|
+
|
|
4468
|
+
// A single rotated frame needs rotation-aware resize math: the handle
|
|
4469
|
+
// follows the frame's own rotated axes (matching how the handles
|
|
4470
|
+
// render, rotated with the frame) and the opposite anchor edge/corner
|
|
4471
|
+
// stays fixed in WORLD space, not just in the unrotated local frame.
|
|
4472
|
+
// Multi-select group resize with rotated members keeps the prior
|
|
4473
|
+
// (unrotated-bounds) behavior — extending this to groups would need
|
|
4474
|
+
// per-member rotation handling around a shared group anchor, which is
|
|
4475
|
+
// a larger change than this fix covers.
|
|
4476
|
+
const singleRotatedFrame =
|
|
4477
|
+
originEntries.length === 1 &&
|
|
4478
|
+
(originEntries[0].geometry.rotation ?? 0)
|
|
4479
|
+
? originEntries[0]
|
|
4480
|
+
: null;
|
|
4481
|
+
|
|
4482
|
+
if (singleRotatedFrame) {
|
|
4483
|
+
const resizedGeometry = resizeRotatedFrameFromDelta(
|
|
4484
|
+
singleRotatedFrame.geometry,
|
|
4485
|
+
state.handle,
|
|
4486
|
+
dx,
|
|
4487
|
+
dy,
|
|
4488
|
+
{
|
|
4489
|
+
preserveAspectRatio: ev.shiftKey,
|
|
4490
|
+
resizeFromCenter: ev.altKey,
|
|
4491
|
+
minWidth: 1,
|
|
4492
|
+
minHeight: 1,
|
|
4493
|
+
},
|
|
4494
|
+
);
|
|
4495
|
+
updateFrameGeometry((current) => ({
|
|
4496
|
+
...current,
|
|
4497
|
+
[singleRotatedFrame.id]: {
|
|
4498
|
+
...state.originFrames[singleRotatedFrame.id],
|
|
4499
|
+
...resizedGeometry,
|
|
4500
|
+
},
|
|
4501
|
+
}));
|
|
4502
|
+
setAlignmentGuides([]);
|
|
4503
|
+
showTransformFeedback(
|
|
4504
|
+
`${Math.round(resizedGeometry.width)} x ${Math.round(resizedGeometry.height)}`,
|
|
4505
|
+
ev.clientX,
|
|
4506
|
+
ev.clientY,
|
|
4507
|
+
);
|
|
4508
|
+
return;
|
|
4509
|
+
}
|
|
4510
|
+
|
|
3897
4511
|
const resized = resizeFrameGroupFromDelta(
|
|
3898
4512
|
originEntries,
|
|
3899
4513
|
state.originBounds,
|
|
@@ -3917,6 +4531,11 @@ export function MultiScreenCanvas({
|
|
|
3917
4531
|
thresholdScreenPx: DEFAULT_SNAP_THRESHOLD_SCREEN_PX,
|
|
3918
4532
|
zoom: zoomRef.current,
|
|
3919
4533
|
bypass: ev.metaKey || ev.ctrlKey,
|
|
4534
|
+
// Snapping x and y independently can each pull toward a
|
|
4535
|
+
// different sibling edge, which would distort a shift-held
|
|
4536
|
+
// (aspect-locked) resize away from its ratio — see
|
|
4537
|
+
// computeAspectPreservingResizeSnap in canvas-math.ts.
|
|
4538
|
+
preserveAspectRatio: ev.shiftKey,
|
|
3920
4539
|
},
|
|
3921
4540
|
);
|
|
3922
4541
|
const resizedEntries = resizeFrameGroupToBounds(
|
|
@@ -3944,6 +4563,12 @@ export function MultiScreenCanvas({
|
|
|
3944
4563
|
|
|
3945
4564
|
const handleMouseUp = () => {
|
|
3946
4565
|
const state = dragState.current;
|
|
4566
|
+
if (state?.type === "resize" && !state.hasMoved) {
|
|
4567
|
+
// Belt-and-braces restore, matching the move handler.
|
|
4568
|
+
updateFrameGeometry((current) =>
|
|
4569
|
+
frameGeometryWithOverrides(current, state.originFrames),
|
|
4570
|
+
);
|
|
4571
|
+
}
|
|
3947
4572
|
if (state?.type === "resize" && state.hasMoved) {
|
|
3948
4573
|
const after = cloneFrameGeometryById(frameGeometryRef.current);
|
|
3949
4574
|
onGeometryCommitRef.current?.(
|
|
@@ -4020,6 +4645,10 @@ export function MultiScreenCanvas({
|
|
|
4020
4645
|
) {
|
|
4021
4646
|
state.hasMoved = true;
|
|
4022
4647
|
}
|
|
4648
|
+
// Skip committing any transform until the drag threshold is crossed —
|
|
4649
|
+
// see the matching comment in beginFrameDrag's move handler.
|
|
4650
|
+
if (!state.hasMoved) return;
|
|
4651
|
+
|
|
4023
4652
|
const pointer = getCanvasPoint(ev.clientX, ev.clientY);
|
|
4024
4653
|
const center = getFrameCenter(state.originFrame);
|
|
4025
4654
|
const raw =
|
|
@@ -4043,6 +4672,13 @@ export function MultiScreenCanvas({
|
|
|
4043
4672
|
|
|
4044
4673
|
const handleMouseUp = () => {
|
|
4045
4674
|
const state = dragState.current;
|
|
4675
|
+
if (state?.type === "rotate" && !state.hasMoved) {
|
|
4676
|
+
// Belt-and-braces restore, matching the move handler.
|
|
4677
|
+
updateFrameGeometry((current) => ({
|
|
4678
|
+
...current,
|
|
4679
|
+
[state.frameId]: { ...state.originFrame },
|
|
4680
|
+
}));
|
|
4681
|
+
}
|
|
4046
4682
|
if (state?.type === "rotate" && state.hasMoved) {
|
|
4047
4683
|
const after = cloneFrameGeometryById(frameGeometryRef.current);
|
|
4048
4684
|
onGeometryCommitRef.current?.(
|
|
@@ -4071,6 +4707,119 @@ export function MultiScreenCanvas({
|
|
|
4071
4707
|
],
|
|
4072
4708
|
);
|
|
4073
4709
|
|
|
4710
|
+
// Multi-selection rotate (CV14): rotates every currently-selected frame
|
|
4711
|
+
// together around the group's own center. Kept entirely separate from
|
|
4712
|
+
// beginRotate above — single-frame rotate is unaffected.
|
|
4713
|
+
const beginGroupRotate = useCallback(
|
|
4714
|
+
(e: React.MouseEvent) => {
|
|
4715
|
+
if (e.button !== 0) return;
|
|
4716
|
+
e.preventDefault();
|
|
4717
|
+
e.stopPropagation();
|
|
4718
|
+
suppressNextPick.current = true;
|
|
4719
|
+
|
|
4720
|
+
const targetIds = selectedIdsRef.current;
|
|
4721
|
+
if (targetIds.length < 2) return;
|
|
4722
|
+
const originEntries = getCurrentFrameEntries().filter((entry) =>
|
|
4723
|
+
targetIds.includes(entry.id),
|
|
4724
|
+
);
|
|
4725
|
+
if (originEntries.length < 2) return;
|
|
4726
|
+
const groupBounds = getFrameGroupBounds(originEntries);
|
|
4727
|
+
if (!groupBounds) return;
|
|
4728
|
+
const groupCenter = { x: groupBounds.centerX, y: groupBounds.centerY };
|
|
4729
|
+
|
|
4730
|
+
const pointer = getCanvasPoint(e.clientX, e.clientY);
|
|
4731
|
+
dragState.current = {
|
|
4732
|
+
type: "group-rotate",
|
|
4733
|
+
originClient: { x: e.clientX, y: e.clientY },
|
|
4734
|
+
originFrames: Object.fromEntries(
|
|
4735
|
+
originEntries.map((entry) => [entry.id, entry.geometry]),
|
|
4736
|
+
) as FrameGeometryById,
|
|
4737
|
+
targetIds: originEntries.map((entry) => entry.id),
|
|
4738
|
+
groupCenter,
|
|
4739
|
+
originPointerAngle: angleBetween(groupCenter, pointer),
|
|
4740
|
+
hasMoved: false,
|
|
4741
|
+
};
|
|
4742
|
+
setIsDragging(true);
|
|
4743
|
+
setDragCursor("grabbing");
|
|
4744
|
+
|
|
4745
|
+
const handleMouseMove = (ev: MouseEvent) => {
|
|
4746
|
+
const state = dragState.current;
|
|
4747
|
+
if (!state || state.type !== "group-rotate") return;
|
|
4748
|
+
if (
|
|
4749
|
+
!state.hasMoved &&
|
|
4750
|
+
Math.hypot(
|
|
4751
|
+
ev.clientX - state.originClient.x,
|
|
4752
|
+
ev.clientY - state.originClient.y,
|
|
4753
|
+
) >= DRAG_THRESHOLD
|
|
4754
|
+
) {
|
|
4755
|
+
state.hasMoved = true;
|
|
4756
|
+
}
|
|
4757
|
+
// Skip committing any transform until the drag threshold is crossed —
|
|
4758
|
+
// see the matching comment in beginFrameDrag's move handler.
|
|
4759
|
+
if (!state.hasMoved) return;
|
|
4760
|
+
|
|
4761
|
+
const pointer = getCanvasPoint(ev.clientX, ev.clientY);
|
|
4762
|
+
const currentAngle = angleBetween(state.groupCenter, pointer);
|
|
4763
|
+
const rawDelta = currentAngle - state.originPointerAngle;
|
|
4764
|
+
const delta = ev.shiftKey ? Math.round(rawDelta / 15) * 15 : rawDelta;
|
|
4765
|
+
|
|
4766
|
+
const originEntriesForRotate = state.targetIds.map((targetId) => ({
|
|
4767
|
+
id: targetId,
|
|
4768
|
+
geometry: state.originFrames[targetId],
|
|
4769
|
+
}));
|
|
4770
|
+
const rotated = rotateFrameGroupAroundCenter(
|
|
4771
|
+
originEntriesForRotate,
|
|
4772
|
+
state.groupCenter,
|
|
4773
|
+
delta,
|
|
4774
|
+
);
|
|
4775
|
+
updateFrameGeometry((current) => {
|
|
4776
|
+
const next = { ...current };
|
|
4777
|
+
rotated.forEach((entry) => {
|
|
4778
|
+
next[entry.id] = {
|
|
4779
|
+
...state.originFrames[entry.id],
|
|
4780
|
+
...entry.geometry,
|
|
4781
|
+
};
|
|
4782
|
+
});
|
|
4783
|
+
return next;
|
|
4784
|
+
});
|
|
4785
|
+
showTransformFeedback(
|
|
4786
|
+
`${Math.round(delta)}deg`,
|
|
4787
|
+
ev.clientX,
|
|
4788
|
+
ev.clientY,
|
|
4789
|
+
);
|
|
4790
|
+
};
|
|
4791
|
+
|
|
4792
|
+
const handleMouseUp = () => {
|
|
4793
|
+
const state = dragState.current;
|
|
4794
|
+
if (state?.type === "group-rotate" && !state.hasMoved) {
|
|
4795
|
+
// Belt-and-braces restore, matching the move handler.
|
|
4796
|
+
updateFrameGeometry((current) =>
|
|
4797
|
+
frameGeometryWithOverrides(current, state.originFrames),
|
|
4798
|
+
);
|
|
4799
|
+
}
|
|
4800
|
+
if (state?.type === "group-rotate" && state.hasMoved) {
|
|
4801
|
+
const after = cloneFrameGeometryById(frameGeometryRef.current);
|
|
4802
|
+
onGeometryCommitRef.current?.(
|
|
4803
|
+
frameGeometryWithOverrides(after, state.originFrames),
|
|
4804
|
+
after,
|
|
4805
|
+
);
|
|
4806
|
+
}
|
|
4807
|
+
suppressNextPick.current = true;
|
|
4808
|
+
finishDrag();
|
|
4809
|
+
};
|
|
4810
|
+
|
|
4811
|
+
installDragListeners(handleMouseMove, handleMouseUp);
|
|
4812
|
+
},
|
|
4813
|
+
[
|
|
4814
|
+
finishDrag,
|
|
4815
|
+
getCanvasPoint,
|
|
4816
|
+
getCurrentFrameEntries,
|
|
4817
|
+
installDragListeners,
|
|
4818
|
+
showTransformFeedback,
|
|
4819
|
+
updateFrameGeometry,
|
|
4820
|
+
],
|
|
4821
|
+
);
|
|
4822
|
+
|
|
4074
4823
|
const handleFrameClick = useCallback(
|
|
4075
4824
|
(id: string, e: React.MouseEvent<HTMLElement>) => {
|
|
4076
4825
|
e.stopPropagation();
|
|
@@ -4153,6 +4902,11 @@ export function MultiScreenCanvas({
|
|
|
4153
4902
|
canDuplicate: !!onDuplicate,
|
|
4154
4903
|
moved: false,
|
|
4155
4904
|
});
|
|
4905
|
+
// Mount the interaction shield and mute preview-iframe pointer events for
|
|
4906
|
+
// the duration of the gesture, same as every other drag — otherwise the
|
|
4907
|
+
// pointer freezes crossing a live embedded iframe and a release over a
|
|
4908
|
+
// screen never reaches handleMouseUp.
|
|
4909
|
+
setIsDragging(true);
|
|
4156
4910
|
|
|
4157
4911
|
const handleMouseMove = (ev: MouseEvent) => {
|
|
4158
4912
|
const dx = ev.clientX - origin.x;
|
|
@@ -4165,22 +4919,24 @@ export function MultiScreenCanvas({
|
|
|
4165
4919
|
y: rect ? ev.clientY - rect.top + 16 : ev.clientY,
|
|
4166
4920
|
width: previewWidth,
|
|
4167
4921
|
height: previewHeight,
|
|
4168
|
-
|
|
4922
|
+
// Live alt state, not just capability: if the user releases alt
|
|
4923
|
+
// mid-drag the preview should visibly fall back to its "not armed"
|
|
4924
|
+
// dashed/preview styling, matching that mouseup will then cancel
|
|
4925
|
+
// the duplicate instead of creating one (see handleMouseUp below).
|
|
4926
|
+
canDuplicate: !!onDuplicate && ev.altKey,
|
|
4169
4927
|
moved,
|
|
4170
4928
|
});
|
|
4171
4929
|
};
|
|
4172
4930
|
|
|
4173
4931
|
const cleanupDuplicateGesture = () => {
|
|
4174
4932
|
setDuplicatePreview(null);
|
|
4175
|
-
window.removeEventListener("mousemove", handleMouseMove);
|
|
4176
|
-
window.removeEventListener("mouseup", handleMouseUp);
|
|
4177
|
-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
4178
|
-
window.removeEventListener("blur", handleBlur);
|
|
4179
4933
|
duplicateCleanup.current = null;
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4934
|
+
// finishDrag clears isDragging, unmounts the shield, and — critically —
|
|
4935
|
+
// runs dragCleanup.current() to detach the window listeners installed
|
|
4936
|
+
// by installDragListeners and restore preview-iframe pointer events.
|
|
4937
|
+
// dragState.current was never set for this gesture, so finishDrag's
|
|
4938
|
+
// other resets (marquee/creation-preview/etc.) are no-ops here.
|
|
4939
|
+
finishDrag();
|
|
4184
4940
|
};
|
|
4185
4941
|
|
|
4186
4942
|
const handleMouseUp = (ev: MouseEvent) => {
|
|
@@ -4188,22 +4944,27 @@ export function MultiScreenCanvas({
|
|
|
4188
4944
|
Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >=
|
|
4189
4945
|
DUPLICATE_DRAG_THRESHOLD;
|
|
4190
4946
|
const mode = moved ? "alt-drag" : "alt-click";
|
|
4191
|
-
|
|
4192
|
-
|
|
4947
|
+
// Figma semantics: a plain alt-click (no drag) never duplicates —
|
|
4948
|
+
// only an actual alt-drag does. And alt is evaluated live: releasing
|
|
4949
|
+
// it before mouseup cancels the pending duplicate rather than
|
|
4950
|
+
// creating one anyway (this gesture never moves the original frame
|
|
4951
|
+
// during the drag — it only shows a floating ghost preview — so
|
|
4952
|
+
// "cancel" here means no-op, not handing off to a live move).
|
|
4953
|
+
const shouldDuplicate = moved && ev.altKey;
|
|
4954
|
+
|
|
4955
|
+
if (onDuplicate && shouldDuplicate) {
|
|
4193
4956
|
const dropCanvasPosition = canvasPointFromClient(
|
|
4194
4957
|
ev.clientX,
|
|
4195
4958
|
ev.clientY,
|
|
4196
4959
|
);
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
y: sourceFrame.geometry.y + SCREEN_GAP / 2,
|
|
4206
|
-
};
|
|
4960
|
+
// shouldDuplicate implies moved, so the drop position is always
|
|
4961
|
+
// relative to the pointer's offset into the source frame (the
|
|
4962
|
+
// "snap next to source" placement only applied to the old
|
|
4963
|
+
// zero-move alt-click case, which no longer duplicates at all).
|
|
4964
|
+
const canvasPosition = {
|
|
4965
|
+
x: dropCanvasPosition.x - pointerOffset.x,
|
|
4966
|
+
y: dropCanvasPosition.y - pointerOffset.y,
|
|
4967
|
+
};
|
|
4207
4968
|
onDuplicate(screen.id, {
|
|
4208
4969
|
mode,
|
|
4209
4970
|
screen,
|
|
@@ -4219,11 +4980,20 @@ export function MultiScreenCanvas({
|
|
|
4219
4980
|
};
|
|
4220
4981
|
|
|
4221
4982
|
duplicateCleanup.current = cleanupDuplicateGesture;
|
|
4222
|
-
|
|
4223
|
-
|
|
4224
|
-
|
|
4983
|
+
installDragListeners(
|
|
4984
|
+
handleMouseMove,
|
|
4985
|
+
handleMouseUp,
|
|
4986
|
+
cleanupDuplicateGesture,
|
|
4987
|
+
);
|
|
4225
4988
|
},
|
|
4226
|
-
[
|
|
4989
|
+
[
|
|
4990
|
+
canvasPointFromClient,
|
|
4991
|
+
finishDrag,
|
|
4992
|
+
getCurrentFrameEntries,
|
|
4993
|
+
installDragListeners,
|
|
4994
|
+
onDuplicate,
|
|
4995
|
+
onPick,
|
|
4996
|
+
],
|
|
4227
4997
|
);
|
|
4228
4998
|
|
|
4229
4999
|
const handleMouseDown = useCallback(
|
|
@@ -4298,6 +5068,20 @@ export function MultiScreenCanvas({
|
|
|
4298
5068
|
grid.style.backgroundPosition = `${p.x}px ${p.y}px`;
|
|
4299
5069
|
grid.style.backgroundSize = `${nextScale}px ${nextScale}px`;
|
|
4300
5070
|
}
|
|
5071
|
+
// A marquee-select drag can run concurrently with a wheel/trackpad pan
|
|
5072
|
+
// gesture (e.g. two-finger scroll while left-mouse-dragging a marquee).
|
|
5073
|
+
// Without this, the marquee overlay's position/size — computed from
|
|
5074
|
+
// React `pan`/`scale` in its inline style — would only catch up once the
|
|
5075
|
+
// gesture settles and scheduleViewCommit() re-renders, visibly lagging
|
|
5076
|
+
// behind the frames it's supposed to be selecting against.
|
|
5077
|
+
const marqueeOverlay = marqueeOverlayRef.current;
|
|
5078
|
+
const activeMarquee = marqueeRef.current;
|
|
5079
|
+
if (marqueeOverlay && activeMarquee) {
|
|
5080
|
+
marqueeOverlay.style.left = `${p.x + (SURFACE_PADDING + activeMarquee.x) * nextScale}px`;
|
|
5081
|
+
marqueeOverlay.style.top = `${p.y + (SURFACE_PADDING + activeMarquee.y) * nextScale}px`;
|
|
5082
|
+
marqueeOverlay.style.width = `${Math.max(1, activeMarquee.width * nextScale)}px`;
|
|
5083
|
+
marqueeOverlay.style.height = `${Math.max(1, activeMarquee.height * nextScale)}px`;
|
|
5084
|
+
}
|
|
4301
5085
|
}, []);
|
|
4302
5086
|
|
|
4303
5087
|
const startChromeSettle = useCallback(() => {
|
|
@@ -4319,7 +5103,11 @@ export function MultiScreenCanvas({
|
|
|
4319
5103
|
setCanvasZoom(zoomRef.current);
|
|
4320
5104
|
setPan(panRef.current);
|
|
4321
5105
|
onZoomChange?.(zoomRef.current);
|
|
4322
|
-
|
|
5106
|
+
// P18: the wheel/pinch gesture just settled (pan/zoom state is
|
|
5107
|
+
// reconciled into React here) — resync the pen ghost preview from the
|
|
5108
|
+
// last known cursor position now that the canvas-space mapping changed.
|
|
5109
|
+
recomputePenPointerForViewChange();
|
|
5110
|
+
}, [onZoomChange, recomputePenPointerForViewChange, startChromeSettle]);
|
|
4323
5111
|
|
|
4324
5112
|
// Debounced: only commit to React state once the gesture has been idle for a
|
|
4325
5113
|
// beat, so a continuous pinch produces zero re-renders until the user pauses.
|
|
@@ -4335,6 +5123,8 @@ export function MultiScreenCanvas({
|
|
|
4335
5123
|
},
|
|
4336
5124
|
[commitView],
|
|
4337
5125
|
);
|
|
5126
|
+
applyViewToDomRef.current = applyViewToDom;
|
|
5127
|
+
scheduleViewCommitRef.current = scheduleViewCommit;
|
|
4338
5128
|
|
|
4339
5129
|
const flushPendingWheelGesture = useCallback(() => {
|
|
4340
5130
|
wheelGestureFrameRef.current = null;
|
|
@@ -4462,6 +5252,15 @@ export function MultiScreenCanvas({
|
|
|
4462
5252
|
|
|
4463
5253
|
const handleWheelEvent = useCallback(
|
|
4464
5254
|
(event: WheelEvent) => {
|
|
5255
|
+
// DesignCanvas re-dispatches embedded-canvas-wheel messages as a
|
|
5256
|
+
// synthetic (non-isTrusted) WheelEvent on its own iframe element so its
|
|
5257
|
+
// own listeners can reuse one code path. That synthetic event bubbles
|
|
5258
|
+
// up through this surface's capture listener too, so without this guard
|
|
5259
|
+
// every embedded-screen wheel gesture gets processed twice: once via
|
|
5260
|
+
// the postMessage handler below, and again here from the re-dispatch.
|
|
5261
|
+
// Real user wheel input is always isTrusted, so this only filters out
|
|
5262
|
+
// the synthetic replay.
|
|
5263
|
+
if (!event.isTrusted) return;
|
|
4465
5264
|
event.preventDefault();
|
|
4466
5265
|
event.stopPropagation();
|
|
4467
5266
|
enqueueWheelGestureFromClient({
|
|
@@ -4663,6 +5462,19 @@ export function MultiScreenCanvas({
|
|
|
4663
5462
|
|
|
4664
5463
|
const primaryKey = event.metaKey || event.ctrlKey;
|
|
4665
5464
|
if (primaryKey && !event.shiftKey && event.key.toLowerCase() === "z") {
|
|
5465
|
+
// While a pen-node drag is actively in progress (mouse down, still
|
|
5466
|
+
// dragging the handle for the anchor being placed), the path in
|
|
5467
|
+
// activePenPathRef already includes that in-progress node — undoing
|
|
5468
|
+
// here would pop it out from under the live drag, then the drag's
|
|
5469
|
+
// own mousemove/mouseup handlers would immediately re-add it from
|
|
5470
|
+
// their closed-over `state`, producing a no-op flicker. Ignore the
|
|
5471
|
+
// shortcut until the drag settles (mouseup/cancel).
|
|
5472
|
+
if (dragState.current?.type === "pen-node") {
|
|
5473
|
+
event.preventDefault();
|
|
5474
|
+
event.stopPropagation();
|
|
5475
|
+
event.stopImmediatePropagation();
|
|
5476
|
+
return;
|
|
5477
|
+
}
|
|
4666
5478
|
event.preventDefault();
|
|
4667
5479
|
event.stopPropagation();
|
|
4668
5480
|
event.stopImmediatePropagation();
|
|
@@ -4684,7 +5496,12 @@ export function MultiScreenCanvas({
|
|
|
4684
5496
|
event.preventDefault();
|
|
4685
5497
|
event.stopPropagation();
|
|
4686
5498
|
event.stopImmediatePropagation();
|
|
4687
|
-
|
|
5499
|
+
// Figma: Escape ends the path in progress and keeps what's drawn so
|
|
5500
|
+
// far (no data loss), rather than discarding the whole path.
|
|
5501
|
+
// finishPenPath already falls back to a discard for a path with
|
|
5502
|
+
// fewer than 2 nodes (P16), where there's nothing meaningful to
|
|
5503
|
+
// commit.
|
|
5504
|
+
finishPenPath(path);
|
|
4688
5505
|
return;
|
|
4689
5506
|
}
|
|
4690
5507
|
|
|
@@ -4700,15 +5517,28 @@ export function MultiScreenCanvas({
|
|
|
4700
5517
|
return () => {
|
|
4701
5518
|
window.removeEventListener("keydown", handleKeyDown, true);
|
|
4702
5519
|
};
|
|
4703
|
-
}, [
|
|
5520
|
+
}, [finishPenPath, undoActivePenPathSegment]);
|
|
4704
5521
|
|
|
4705
5522
|
useEffect(() => {
|
|
4706
5523
|
const tool = normalizeCanvasTool(activeTool ?? localActiveTool);
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
5524
|
+
// Switching tools mid-path (toolbar click or a single-letter shortcut
|
|
5525
|
+
// reaching the editor's handlers) used to silently discard the path in
|
|
5526
|
+
// progress. Figma commits it instead — finishPenPath already discards
|
|
5527
|
+
// for a sub-2-node path (P16), so this only ever loses genuinely empty
|
|
5528
|
+
// in-progress state.
|
|
5529
|
+
if (tool !== "pen") finishPenPath();
|
|
5530
|
+
}, [activeTool, finishPenPath, localActiveTool]);
|
|
5531
|
+
|
|
5532
|
+
// Cmd+D / Ctrl+D: duplicate every selected frame (not just the first) with
|
|
5533
|
+
// a visible offset so each copy doesn't land exactly on top of its
|
|
5534
|
+
// original (Figma-style behaviour). `onDuplicate` is fire-and-forget
|
|
5535
|
+
// (`(id, request) => void`) and the actual new file id is only known
|
|
5536
|
+
// asynchronously by the caller (DesignEditor's createFileMutation
|
|
5537
|
+
// onSuccess), so this component has no id to add to its own selection
|
|
5538
|
+
// state. Known limitation: after a multi-duplicate, selection isn't
|
|
5539
|
+
// reprogrammed to the new copies (the caller instead makes its own last
|
|
5540
|
+
// duplicate the active file) — promoting the duplicates to the new
|
|
5541
|
+
// selection would require `onDuplicate` to return/callback the created id.
|
|
4712
5542
|
useEffect(() => {
|
|
4713
5543
|
if (!onDuplicate) return;
|
|
4714
5544
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
@@ -4720,31 +5550,39 @@ export function MultiScreenCanvas({
|
|
|
4720
5550
|
) {
|
|
4721
5551
|
return;
|
|
4722
5552
|
}
|
|
5553
|
+
// Always suppress the browser default (bookmark dialog) — but leave
|
|
5554
|
+
// propagation intact until we know a frame is duplicable here, so the
|
|
5555
|
+
// global hotkey hook can still duplicate non-frame layer selections.
|
|
5556
|
+
event.preventDefault();
|
|
4723
5557
|
// Only act on frame IDs — filter out canvas primitives (sub-elements).
|
|
4724
5558
|
const frameIds = selectedIdsRef.current.filter(
|
|
4725
5559
|
(id) => frameGeometryRef.current[id],
|
|
4726
5560
|
);
|
|
4727
|
-
|
|
4728
|
-
if (!targetId) return;
|
|
4729
|
-
const screen = screens.find((s) => s.id === targetId);
|
|
4730
|
-
if (!screen) return;
|
|
4731
|
-
const sourceGeometry = frameGeometryRef.current[targetId];
|
|
4732
|
-
if (!sourceGeometry) return;
|
|
4733
|
-
event.preventDefault();
|
|
5561
|
+
if (frameIds.length === 0) return;
|
|
4734
5562
|
event.stopPropagation();
|
|
4735
5563
|
event.stopImmediatePropagation();
|
|
4736
|
-
//
|
|
4737
|
-
//
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
5564
|
+
// Duplicate every selected frame, not just the first — each duplicate
|
|
5565
|
+
// is offset relative to its OWN source geometry (not chained off the
|
|
5566
|
+
// previous duplicate), mirroring how a multi-select alt-drag would
|
|
5567
|
+
// offset each frame independently.
|
|
5568
|
+
for (const targetId of frameIds) {
|
|
5569
|
+
const screen = screens.find((s) => s.id === targetId);
|
|
5570
|
+
if (!screen) continue;
|
|
5571
|
+
const sourceGeometry = frameGeometryRef.current[targetId];
|
|
5572
|
+
if (!sourceGeometry) continue;
|
|
5573
|
+
// Offset the duplicate by one grid gap to the right (and slightly down)
|
|
5574
|
+
// so it is visually distinct from the original, mirroring Figma's behaviour.
|
|
5575
|
+
const canvasPosition = {
|
|
5576
|
+
x: sourceGeometry.x + sourceGeometry.width + SCREEN_GAP,
|
|
5577
|
+
y: sourceGeometry.y,
|
|
5578
|
+
};
|
|
5579
|
+
onDuplicate(targetId, {
|
|
5580
|
+
mode: "alt-click",
|
|
5581
|
+
screen,
|
|
5582
|
+
canvasPosition,
|
|
5583
|
+
dropCanvasPosition: canvasPosition,
|
|
5584
|
+
});
|
|
5585
|
+
}
|
|
4748
5586
|
};
|
|
4749
5587
|
|
|
4750
5588
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
@@ -4785,29 +5623,43 @@ export function MultiScreenCanvas({
|
|
|
4785
5623
|
: effectiveTool === "hand"
|
|
4786
5624
|
? "grab"
|
|
4787
5625
|
: "default";
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
5626
|
+
// PF19: canvasFrames (and everything derived from it below) used to be
|
|
5627
|
+
// plain per-render recomputation over `screens`/`frameGeometry`, which are
|
|
5628
|
+
// large arrays/maps for boards with many screens. Memoize so a render that
|
|
5629
|
+
// doesn't touch screens/geometry (e.g. a hover-only or unrelated state
|
|
5630
|
+
// update) doesn't re-walk and re-allocate the whole frame list.
|
|
5631
|
+
const canvasFrames = useMemo(
|
|
5632
|
+
() =>
|
|
5633
|
+
screens.map((screen, index) => {
|
|
5634
|
+
const metadata = getResolvedMetadata(screen);
|
|
5635
|
+
return {
|
|
5636
|
+
screen,
|
|
5637
|
+
metadata,
|
|
5638
|
+
geometry:
|
|
5639
|
+
frameGeometry[screen.id] ??
|
|
5640
|
+
getInitialFrameGeometry(index, metadata),
|
|
5641
|
+
};
|
|
5642
|
+
}),
|
|
5643
|
+
[frameGeometry, getResolvedMetadata, screens],
|
|
5644
|
+
);
|
|
5645
|
+
const topScreenId = useMemo(
|
|
5646
|
+
() =>
|
|
5647
|
+
selectedIds.find((id) =>
|
|
5648
|
+
canvasFrames.some(({ screen }) => screen.id === id),
|
|
5649
|
+
) ??
|
|
5650
|
+
(activeId && canvasFrames.some(({ screen }) => screen.id === activeId)
|
|
5651
|
+
? activeId
|
|
5652
|
+
: canvasFrames[0]?.screen.id),
|
|
5653
|
+
[activeId, canvasFrames, selectedIds],
|
|
5654
|
+
);
|
|
5655
|
+
// PF16: previously this reordered canvasFrames so the top screen's keyed
|
|
5656
|
+
// entry moved to the end of the array. Since screens are keyed by
|
|
5657
|
+
// screen.id, moving a key's position in a mapped list makes React move
|
|
5658
|
+
// that DOM node (iframe) to match — which reloads the iframe's document,
|
|
5659
|
+
// producing a visible white flash every time selection changes. zIndex
|
|
5660
|
+
// (boosted for the top screen in Screen's root style, see
|
|
5661
|
+
// TOP_SCREEN_Z_BOOST) already expresses "paint above its siblings" without
|
|
5662
|
+
// needing to touch DOM order, so render canvasFrames in stable order.
|
|
4811
5663
|
const screenContentById = useMemo(() => {
|
|
4812
5664
|
if (!renderScreenContent) return new Map<string, ReactNode>();
|
|
4813
5665
|
return new Map(
|
|
@@ -4822,21 +5674,41 @@ export function MultiScreenCanvas({
|
|
|
4822
5674
|
}),
|
|
4823
5675
|
);
|
|
4824
5676
|
}, [frameGeometry, getResolvedMetadata, renderScreenContent, screens]);
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
5677
|
+
// PF19: filters/maps over canvasFrames + a getFrameGroupBounds pass — cheap
|
|
5678
|
+
// for a handful of screens, but this runs on every render (hover, hint
|
|
5679
|
+
// text, unrelated state), not just selection changes. Memoize keyed on the
|
|
5680
|
+
// actual selection + frame list so unrelated renders reuse the prior arrays
|
|
5681
|
+
// (and downstream consumers like SelectionBox/GroupSelectionBox, which take
|
|
5682
|
+
// these by reference, skip re-rendering too).
|
|
5683
|
+
const selectedFrameEntries = useMemo(
|
|
5684
|
+
() =>
|
|
5685
|
+
canvasFrames
|
|
5686
|
+
.filter(({ screen }) => selectedIdSet.has(screen.id))
|
|
5687
|
+
.map(({ screen, geometry }) => ({ id: screen.id, geometry })),
|
|
5688
|
+
[canvasFrames, selectedIdSet],
|
|
5689
|
+
);
|
|
5690
|
+
const selectedGroupBounds = useMemo(
|
|
5691
|
+
() =>
|
|
5692
|
+
selectedFrameEntries.length > 1
|
|
5693
|
+
? getFrameGroupBounds(selectedFrameEntries)
|
|
5694
|
+
: null,
|
|
5695
|
+
[selectedFrameEntries],
|
|
5696
|
+
);
|
|
4832
5697
|
const hasGroupSelection = !!selectedGroupBounds;
|
|
4833
|
-
const selectedDraftEntries =
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
5698
|
+
const selectedDraftEntries = useMemo(
|
|
5699
|
+
() =>
|
|
5700
|
+
draftPrimitives
|
|
5701
|
+
.filter((draft) => selectedDraftIdSet.has(draft.id))
|
|
5702
|
+
.map((draft) => ({ id: draft.id, geometry: draft.geometry })),
|
|
5703
|
+
[draftPrimitives, selectedDraftIdSet],
|
|
5704
|
+
);
|
|
5705
|
+
const selectedDraftGroupBounds = useMemo(
|
|
5706
|
+
() =>
|
|
5707
|
+
selectedDraftEntries.length > 1
|
|
5708
|
+
? getFrameGroupBounds(selectedDraftEntries)
|
|
5709
|
+
: null,
|
|
5710
|
+
[selectedDraftEntries],
|
|
5711
|
+
);
|
|
4840
5712
|
const singleSelectedFrame =
|
|
4841
5713
|
selectedFrameEntries.length === 1 && !selectedGroupBounds
|
|
4842
5714
|
? selectedFrameEntries[0]
|
|
@@ -4993,7 +5865,7 @@ export function MultiScreenCanvas({
|
|
|
4993
5865
|
);
|
|
4994
5866
|
})()}
|
|
4995
5867
|
|
|
4996
|
-
{
|
|
5868
|
+
{canvasFrames.map(({ screen, metadata, geometry }) => {
|
|
4997
5869
|
return (
|
|
4998
5870
|
<Screen
|
|
4999
5871
|
key={screen.id}
|
|
@@ -5002,6 +5874,7 @@ export function MultiScreenCanvas({
|
|
|
5002
5874
|
geometry={geometry}
|
|
5003
5875
|
screenContent={screenContentById.get(screen.id)}
|
|
5004
5876
|
isActive={screen.id === activeId}
|
|
5877
|
+
isTopScreen={screen.id === topScreenId}
|
|
5005
5878
|
isSelected={selectedIdSet.has(screen.id)}
|
|
5006
5879
|
showFullView={fullViewIdSet.has(screen.id)}
|
|
5007
5880
|
isDirectlyHovered={screen.id === directlyHoveredScreenId}
|
|
@@ -5022,16 +5895,13 @@ export function MultiScreenCanvas({
|
|
|
5022
5895
|
onStartResize={beginResize}
|
|
5023
5896
|
onStartRotate={beginRotate}
|
|
5024
5897
|
onStartDuplicateGesture={beginDuplicateGesture}
|
|
5025
|
-
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
|
|
5033
|
-
: undefined
|
|
5034
|
-
}
|
|
5898
|
+
// Pass the id-first callbacks straight through (PF18): Screen
|
|
5899
|
+
// itself binds screen.id when it calls these, so every screen
|
|
5900
|
+
// instance gets the exact same stable function reference here
|
|
5901
|
+
// instead of a fresh per-screen closure allocated on every
|
|
5902
|
+
// MultiScreenCanvas render, which used to defeat memo(Screen).
|
|
5903
|
+
onAddBreakpoint={onAddBreakpoint}
|
|
5904
|
+
onActiveBreakpointChange={onActiveBreakpointChange}
|
|
5035
5905
|
/>
|
|
5036
5906
|
);
|
|
5037
5907
|
})}
|
|
@@ -5089,7 +5959,11 @@ export function MultiScreenCanvas({
|
|
|
5089
5959
|
: null}
|
|
5090
5960
|
|
|
5091
5961
|
{displayedPenPath ? (
|
|
5092
|
-
<PenPathOverlay
|
|
5962
|
+
<PenPathOverlay
|
|
5963
|
+
path={displayedPenPath}
|
|
5964
|
+
closeHover={penCloseHover}
|
|
5965
|
+
chromeScale={chromeScale}
|
|
5966
|
+
/>
|
|
5093
5967
|
) : null}
|
|
5094
5968
|
|
|
5095
5969
|
{singleSelectedFrame ? (
|
|
@@ -5126,6 +6000,7 @@ export function MultiScreenCanvas({
|
|
|
5126
6000
|
chromeScale={chromeScale}
|
|
5127
6001
|
chromeSettling={chromeSettling}
|
|
5128
6002
|
onStartResize={beginGroupResize}
|
|
6003
|
+
onStartRotate={beginGroupRotate}
|
|
5129
6004
|
/>
|
|
5130
6005
|
) : null}
|
|
5131
6006
|
|
|
@@ -5159,8 +6034,63 @@ export function MultiScreenCanvas({
|
|
|
5159
6034
|
}
|
|
5160
6035
|
/>
|
|
5161
6036
|
))}
|
|
6037
|
+
|
|
6038
|
+
{/* Smart-spacing guides (CV11): highlight both equal-sized gaps
|
|
6039
|
+
around the moving frame, with one label showing the shared
|
|
6040
|
+
distance. */}
|
|
6041
|
+
{equalGapGuides.map((guide, index) =>
|
|
6042
|
+
guide.bands.map((band, bandIndex) => (
|
|
6043
|
+
<span
|
|
6044
|
+
key={`equal-gap-${guide.orientation}-${index}-${bandIndex}`}
|
|
6045
|
+
className="pointer-events-none absolute z-30 bg-[var(--design-editor-accent-color)]/25"
|
|
6046
|
+
style={
|
|
6047
|
+
guide.orientation === "vertical"
|
|
6048
|
+
? {
|
|
6049
|
+
left: SURFACE_PADDING + band.gapStart,
|
|
6050
|
+
top: SURFACE_PADDING + band.crossStart,
|
|
6051
|
+
width: Math.max(1, band.gapEnd - band.gapStart),
|
|
6052
|
+
height: Math.max(1, band.crossEnd - band.crossStart),
|
|
6053
|
+
}
|
|
6054
|
+
: {
|
|
6055
|
+
left: SURFACE_PADDING + band.crossStart,
|
|
6056
|
+
top: SURFACE_PADDING + band.gapStart,
|
|
6057
|
+
width: Math.max(1, band.crossEnd - band.crossStart),
|
|
6058
|
+
height: Math.max(1, band.gapEnd - band.gapStart),
|
|
6059
|
+
}
|
|
6060
|
+
}
|
|
6061
|
+
/>
|
|
6062
|
+
)),
|
|
6063
|
+
)}
|
|
5162
6064
|
</div>
|
|
5163
6065
|
|
|
6066
|
+
{/* Equal-gap distance labels render outside the pan/scale-transformed
|
|
6067
|
+
world container (same reasoning as the marquee/duplicate-preview
|
|
6068
|
+
overlays above it) so they need the explicit
|
|
6069
|
+
pan + (SURFACE_PADDING + canvasCoord) * scale conversion instead of
|
|
6070
|
+
the raw canvas coordinates the bands above use inside that
|
|
6071
|
+
container. */}
|
|
6072
|
+
{equalGapGuides.map((guide, index) => {
|
|
6073
|
+
const band = guide.bands[0];
|
|
6074
|
+
const crossMid = (band.crossStart + band.crossEnd) / 2;
|
|
6075
|
+
const gapMid = (band.gapStart + band.gapEnd) / 2;
|
|
6076
|
+
const labelCanvasPoint =
|
|
6077
|
+
guide.orientation === "vertical"
|
|
6078
|
+
? { x: gapMid, y: crossMid }
|
|
6079
|
+
: { x: crossMid, y: gapMid };
|
|
6080
|
+
return (
|
|
6081
|
+
<span
|
|
6082
|
+
key={`equal-gap-label-${guide.orientation}-${index}`}
|
|
6083
|
+
className="pointer-events-none absolute z-40 -translate-x-1/2 -translate-y-1/2 rounded bg-[var(--design-editor-accent-color)] px-1 py-0.5 text-[10px] font-medium leading-none text-[var(--design-editor-accent-contrast-color)] shadow-sm"
|
|
6084
|
+
style={{
|
|
6085
|
+
left: pan.x + (SURFACE_PADDING + labelCanvasPoint.x) * scale,
|
|
6086
|
+
top: pan.y + (SURFACE_PADDING + labelCanvasPoint.y) * scale,
|
|
6087
|
+
}}
|
|
6088
|
+
>
|
|
6089
|
+
{Math.round(guide.gap)}
|
|
6090
|
+
</span>
|
|
6091
|
+
);
|
|
6092
|
+
})}
|
|
6093
|
+
|
|
5164
6094
|
{penActive || creationToolActive ? (
|
|
5165
6095
|
<div
|
|
5166
6096
|
data-canvas-creation-shield
|
|
@@ -5171,11 +6101,18 @@ export function MultiScreenCanvas({
|
|
|
5171
6101
|
|
|
5172
6102
|
{marquee ? (
|
|
5173
6103
|
<span
|
|
6104
|
+
ref={marqueeOverlayRef}
|
|
5174
6105
|
className="pointer-events-none absolute z-40 border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-selection-color)]"
|
|
5175
6106
|
style={{
|
|
5176
6107
|
// Convert canvas-space marquee to surface-space so this overlay
|
|
5177
6108
|
// is never clipped or hidden by the canvas transform container.
|
|
5178
6109
|
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
6110
|
+
// NOTE: this uses React `pan`/`scale`, which only update on the
|
|
6111
|
+
// debounced view-commit — during an active wheel/pinch gesture
|
|
6112
|
+
// (e.g. two-finger pan while marquee-dragging) applyViewToDom
|
|
6113
|
+
// keeps this element's position in sync imperatively via
|
|
6114
|
+
// marqueeOverlayRef, the same way it already does for the world
|
|
6115
|
+
// transform and pixel grid.
|
|
5179
6116
|
left: pan.x + (SURFACE_PADDING + marquee.x) * scale,
|
|
5180
6117
|
top: pan.y + (SURFACE_PADDING + marquee.y) * scale,
|
|
5181
6118
|
width: Math.max(1, marquee.width * scale),
|
|
@@ -5288,7 +6225,7 @@ export function MultiScreenCanvas({
|
|
|
5288
6225
|
) : null}
|
|
5289
6226
|
</div>
|
|
5290
6227
|
);
|
|
5291
|
-
}
|
|
6228
|
+
});
|
|
5292
6229
|
|
|
5293
6230
|
function DraftPrimitiveLayer({
|
|
5294
6231
|
draft,
|
|
@@ -5374,6 +6311,7 @@ function DraftPrimitiveLayer({
|
|
|
5374
6311
|
showRotate={false}
|
|
5375
6312
|
chromeScale={chromeScale}
|
|
5376
6313
|
chromeSettling={chromeSettling}
|
|
6314
|
+
rotationDeg={draft.geometry.rotation ?? 0}
|
|
5377
6315
|
onStartResize={(handle, event) =>
|
|
5378
6316
|
onStartResize(draft.id, handle, event)
|
|
5379
6317
|
}
|
|
@@ -5512,12 +6450,27 @@ function DraftPrimitiveContent({
|
|
|
5512
6450
|
function PenPathOverlay({
|
|
5513
6451
|
path,
|
|
5514
6452
|
closeHover,
|
|
6453
|
+
chromeScale,
|
|
5515
6454
|
}: {
|
|
5516
6455
|
path: PenPath;
|
|
5517
6456
|
closeHover: boolean;
|
|
6457
|
+
chromeScale: number;
|
|
5518
6458
|
}) {
|
|
5519
6459
|
const geometry = getPenPathGeometry(path);
|
|
5520
6460
|
const pathData = serializePenPath(path);
|
|
6461
|
+
// PenPathOverlay lives inside the pan/zoom-scaled world container, so raw
|
|
6462
|
+
// px sizes here would shrink to specks at low zoom and blow up into blobs
|
|
6463
|
+
// at high zoom. Scale every screen-space size (anchor/handle boxes,
|
|
6464
|
+
// stroke widths) by chromeScale (= 1 / zoomScale) the same way
|
|
6465
|
+
// SelectionBox's resize/rotate handles do, so they stay a constant size
|
|
6466
|
+
// on screen regardless of canvas zoom.
|
|
6467
|
+
const anchorSize = 8 * chromeScale;
|
|
6468
|
+
const handleSize = 6 * chromeScale;
|
|
6469
|
+
const anchorBorderWidth = Math.max(1, chromeScale);
|
|
6470
|
+
const handleBorderWidth = Math.max(1, chromeScale);
|
|
6471
|
+
const outlineStrokeWidth = 5 * chromeScale;
|
|
6472
|
+
const strokeWidth = 2 * chromeScale;
|
|
6473
|
+
const handleLineStrokeWidth = Math.max(1, chromeScale);
|
|
5521
6474
|
return (
|
|
5522
6475
|
<div
|
|
5523
6476
|
data-pen-path-overlay
|
|
@@ -5543,7 +6496,7 @@ function PenPathOverlay({
|
|
|
5543
6496
|
y2={node.handleIn.y}
|
|
5544
6497
|
stroke="var(--design-editor-accent-color)"
|
|
5545
6498
|
strokeDasharray="3 3"
|
|
5546
|
-
strokeWidth={
|
|
6499
|
+
strokeWidth={handleLineStrokeWidth}
|
|
5547
6500
|
/>
|
|
5548
6501
|
) : null}
|
|
5549
6502
|
{node.handleOut ? (
|
|
@@ -5554,7 +6507,7 @@ function PenPathOverlay({
|
|
|
5554
6507
|
y2={node.handleOut.y}
|
|
5555
6508
|
stroke="var(--design-editor-accent-color)"
|
|
5556
6509
|
strokeDasharray="3 3"
|
|
5557
|
-
strokeWidth={
|
|
6510
|
+
strokeWidth={handleLineStrokeWidth}
|
|
5558
6511
|
/>
|
|
5559
6512
|
) : null}
|
|
5560
6513
|
</g>
|
|
@@ -5565,7 +6518,7 @@ function PenPathOverlay({
|
|
|
5565
6518
|
stroke="rgba(255,255,255,0.95)"
|
|
5566
6519
|
strokeLinecap="round"
|
|
5567
6520
|
strokeLinejoin="round"
|
|
5568
|
-
strokeWidth={
|
|
6521
|
+
strokeWidth={outlineStrokeWidth}
|
|
5569
6522
|
/>
|
|
5570
6523
|
<path
|
|
5571
6524
|
d={pathData}
|
|
@@ -5573,7 +6526,7 @@ function PenPathOverlay({
|
|
|
5573
6526
|
stroke="var(--design-editor-accent-color)"
|
|
5574
6527
|
strokeLinecap="round"
|
|
5575
6528
|
strokeLinejoin="round"
|
|
5576
|
-
strokeWidth={
|
|
6529
|
+
strokeWidth={strokeWidth}
|
|
5577
6530
|
/>
|
|
5578
6531
|
</svg>
|
|
5579
6532
|
{path.nodes.map((node, index) => (
|
|
@@ -5581,14 +6534,17 @@ function PenPathOverlay({
|
|
|
5581
6534
|
key={`anchor-${index}`}
|
|
5582
6535
|
data-pen-anchor
|
|
5583
6536
|
className={cn(
|
|
5584
|
-
"absolute
|
|
6537
|
+
"absolute rounded-[2px] border shadow-sm",
|
|
5585
6538
|
index === 0 && closeHover
|
|
5586
6539
|
? "scale-125 border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)] ring-4 ring-[var(--design-editor-selection-color)]"
|
|
5587
6540
|
: "border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-contrast-color)]",
|
|
5588
6541
|
)}
|
|
5589
6542
|
style={{
|
|
5590
|
-
left: node.point.x - geometry.x -
|
|
5591
|
-
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,
|
|
5592
6548
|
}}
|
|
5593
6549
|
/>
|
|
5594
6550
|
))}
|
|
@@ -5599,10 +6555,13 @@ function PenPathOverlay({
|
|
|
5599
6555
|
<span
|
|
5600
6556
|
key={`handle-${index}-${handleIndex}`}
|
|
5601
6557
|
data-pen-handle
|
|
5602
|
-
className="absolute
|
|
6558
|
+
className="absolute rounded-full border border-[var(--design-editor-accent-color)] bg-background shadow-sm"
|
|
5603
6559
|
style={{
|
|
5604
|
-
left: handle.x - geometry.x -
|
|
5605
|
-
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,
|
|
5606
6565
|
}}
|
|
5607
6566
|
/>
|
|
5608
6567
|
)),
|
|
@@ -5630,12 +6589,57 @@ function nextBreakpointWidth(existing: number[]): number | undefined {
|
|
|
5630
6589
|
return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
|
|
5631
6590
|
}
|
|
5632
6591
|
|
|
6592
|
+
/**
|
|
6593
|
+
* The `data-screen-iframe-id` DOM attribute value for a screen's PRIMARY
|
|
6594
|
+
* iframe. Breakpoint sub-frames (see BreakpointPreviewRow) get their own
|
|
6595
|
+
* distinct suffixed id via `getBreakpointIframeId` so `querySelector`
|
|
6596
|
+
* (which always returns the first DOM match) can't silently collide two
|
|
6597
|
+
* different iframes onto the same id.
|
|
6598
|
+
*/
|
|
6599
|
+
export function getPrimaryIframeId(screenId: string): string {
|
|
6600
|
+
return screenId;
|
|
6601
|
+
}
|
|
6602
|
+
|
|
6603
|
+
/** The `data-screen-iframe-id` value for one specific breakpoint sub-frame. */
|
|
6604
|
+
export function getBreakpointIframeId(
|
|
6605
|
+
screenId: string,
|
|
6606
|
+
widthPx: number,
|
|
6607
|
+
): string {
|
|
6608
|
+
return `${screenId}::bp-${widthPx}`;
|
|
6609
|
+
}
|
|
6610
|
+
|
|
6611
|
+
/**
|
|
6612
|
+
* Resolves which iframe DOM id actually represents `screen` right now for
|
|
6613
|
+
* hit-test / drag / wheel bridge lookups: the currently active breakpoint
|
|
6614
|
+
* sub-frame if one is selected, otherwise the primary iframe. Every
|
|
6615
|
+
* `querySelector('[data-screen-iframe-id="…"]')` call site that resolves a
|
|
6616
|
+
* screen id to a live iframe must go through this so an active breakpoint
|
|
6617
|
+
* scope actually targets the frame the user is looking at, instead of
|
|
6618
|
+
* always silently resolving to the primary frame regardless of which one is
|
|
6619
|
+
* selected.
|
|
6620
|
+
*/
|
|
6621
|
+
export function getActiveScreenIframeId(screen: {
|
|
6622
|
+
id: string;
|
|
6623
|
+
activeBreakpointWidth?: number;
|
|
6624
|
+
breakpointWidths?: number[];
|
|
6625
|
+
}): string {
|
|
6626
|
+
const activeWidth = screen.activeBreakpointWidth;
|
|
6627
|
+
if (
|
|
6628
|
+
activeWidth !== undefined &&
|
|
6629
|
+
screen.breakpointWidths?.includes(activeWidth)
|
|
6630
|
+
) {
|
|
6631
|
+
return getBreakpointIframeId(screen.id, activeWidth);
|
|
6632
|
+
}
|
|
6633
|
+
return getPrimaryIframeId(screen.id);
|
|
6634
|
+
}
|
|
6635
|
+
|
|
5633
6636
|
interface ScreenProps {
|
|
5634
6637
|
screen: ScreenFile;
|
|
5635
6638
|
metadata: ResolvedScreenMetadata;
|
|
5636
6639
|
geometry: FrameGeometry;
|
|
5637
6640
|
isActive: boolean;
|
|
5638
6641
|
isSelected: boolean;
|
|
6642
|
+
isTopScreen: boolean;
|
|
5639
6643
|
showFullView: boolean;
|
|
5640
6644
|
isDirectlyHovered: boolean;
|
|
5641
6645
|
hasHoveredChild: boolean;
|
|
@@ -5661,8 +6665,17 @@ interface ScreenProps {
|
|
|
5661
6665
|
display: string,
|
|
5662
6666
|
e: React.MouseEvent<HTMLElement>,
|
|
5663
6667
|
) => void;
|
|
5664
|
-
|
|
5665
|
-
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;
|
|
5666
6679
|
}
|
|
5667
6680
|
|
|
5668
6681
|
const Screen = memo(function Screen({
|
|
@@ -5671,6 +6684,7 @@ const Screen = memo(function Screen({
|
|
|
5671
6684
|
geometry,
|
|
5672
6685
|
isActive,
|
|
5673
6686
|
isSelected,
|
|
6687
|
+
isTopScreen,
|
|
5674
6688
|
showFullView,
|
|
5675
6689
|
isDirectlyHovered,
|
|
5676
6690
|
hasHoveredChild,
|
|
@@ -5760,7 +6774,9 @@ const Screen = memo(function Screen({
|
|
|
5760
6774
|
? `rotate(${geometry.rotation}deg)`
|
|
5761
6775
|
: undefined,
|
|
5762
6776
|
transformOrigin: `${geometry.width / 2}px ${frameLabelHeight + geometry.height / 2}px`,
|
|
5763
|
-
zIndex:
|
|
6777
|
+
zIndex: isTopScreen
|
|
6778
|
+
? (geometry.z ?? 0) + TOP_SCREEN_Z_BOOST
|
|
6779
|
+
: geometry.z,
|
|
5764
6780
|
}}
|
|
5765
6781
|
>
|
|
5766
6782
|
<div
|
|
@@ -5784,6 +6800,11 @@ const Screen = memo(function Screen({
|
|
|
5784
6800
|
if (e.button !== 0) return;
|
|
5785
6801
|
if (penActive || creationToolActive) return;
|
|
5786
6802
|
if (e.altKey) {
|
|
6803
|
+
// Matches the data-screen-card mousedown handler below: without
|
|
6804
|
+
// this, a trailing click after the alt-drag/duplicate gesture
|
|
6805
|
+
// ends falls through to this row's onClick and steals selection
|
|
6806
|
+
// away from the newly created duplicate.
|
|
6807
|
+
suppressNextClick.current = true;
|
|
5787
6808
|
onStartDuplicateGesture(screen, display, e);
|
|
5788
6809
|
return;
|
|
5789
6810
|
}
|
|
@@ -6008,6 +7029,7 @@ const Screen = memo(function Screen({
|
|
|
6008
7029
|
showRotate
|
|
6009
7030
|
chromeScale={chromeScale}
|
|
6010
7031
|
chromeSettling={chromeSettling}
|
|
7032
|
+
rotationDeg={geometry.rotation ?? 0}
|
|
6011
7033
|
onStartResize={(handle, e) => onStartResize(screen.id, handle, e)}
|
|
6012
7034
|
onStartRotate={(e) => onStartRotate(screen.id, e)}
|
|
6013
7035
|
/>
|
|
@@ -6028,8 +7050,16 @@ const Screen = memo(function Screen({
|
|
|
6028
7050
|
penActive={penActive}
|
|
6029
7051
|
creationToolActive={creationToolActive}
|
|
6030
7052
|
chromeScale={chromeScale}
|
|
6031
|
-
onActiveBreakpointChange={
|
|
6032
|
-
|
|
7053
|
+
onActiveBreakpointChange={
|
|
7054
|
+
onActiveBreakpointChange
|
|
7055
|
+
? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
|
|
7056
|
+
: undefined
|
|
7057
|
+
}
|
|
7058
|
+
onAddBreakpoint={
|
|
7059
|
+
onAddBreakpoint
|
|
7060
|
+
? (widthPx) => onAddBreakpoint(screen.id, widthPx)
|
|
7061
|
+
: undefined
|
|
7062
|
+
}
|
|
6033
7063
|
/>
|
|
6034
7064
|
) : null}
|
|
6035
7065
|
</div>
|
|
@@ -6044,6 +7074,7 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
|
|
|
6044
7074
|
sameFrameGeometry(prev.geometry, next.geometry) &&
|
|
6045
7075
|
prev.isActive === next.isActive &&
|
|
6046
7076
|
prev.isSelected === next.isSelected &&
|
|
7077
|
+
prev.isTopScreen === next.isTopScreen &&
|
|
6047
7078
|
prev.showFullView === next.showFullView &&
|
|
6048
7079
|
prev.isDirectlyHovered === next.isDirectlyHovered &&
|
|
6049
7080
|
prev.hasHoveredChild === next.hasHoveredChild &&
|
|
@@ -6059,7 +7090,13 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
|
|
|
6059
7090
|
prev.onStartFrameDrag === next.onStartFrameDrag &&
|
|
6060
7091
|
prev.onStartResize === next.onStartResize &&
|
|
6061
7092
|
prev.onStartRotate === next.onStartRotate &&
|
|
6062
|
-
prev.onStartDuplicateGesture === next.onStartDuplicateGesture
|
|
7093
|
+
prev.onStartDuplicateGesture === next.onStartDuplicateGesture &&
|
|
7094
|
+
// Now id-first (screenId, widthPx) callbacks passed straight through
|
|
7095
|
+
// from MultiScreenCanvas's own props (PF18) instead of a fresh
|
|
7096
|
+
// per-screen arrow allocated in the render loop, so these are expected
|
|
7097
|
+
// to be referentially stable across renders and are safe to compare.
|
|
7098
|
+
prev.onAddBreakpoint === next.onAddBreakpoint &&
|
|
7099
|
+
prev.onActiveBreakpointChange === next.onActiveBreakpointChange
|
|
6063
7100
|
);
|
|
6064
7101
|
}
|
|
6065
7102
|
|
|
@@ -6087,7 +7124,9 @@ function BreakpointPreviewRow({
|
|
|
6087
7124
|
* The primary screen's srcdoc with the lightweight hit-test responder already
|
|
6088
7125
|
* injected (memoised in the parent Screen component). Passed down so
|
|
6089
7126
|
* breakpoint sub-iframes carry the same responder and can be found via
|
|
6090
|
-
* [data-screen-iframe-id]
|
|
7127
|
+
* their own distinct [data-screen-iframe-id] (see getBreakpointIframeId)
|
|
7128
|
+
* by the cross-screen drop-into-container handler when that breakpoint is
|
|
7129
|
+
* the active edit scope (see getActiveScreenIframeId).
|
|
6091
7130
|
*/
|
|
6092
7131
|
srcdocWithHitTest: string;
|
|
6093
7132
|
activeBreakpointWidth: number | undefined;
|
|
@@ -6178,7 +7217,19 @@ function BreakpointPreviewRow({
|
|
|
6178
7217
|
style={{ width: frameWidth, height: frameHeight }}
|
|
6179
7218
|
>
|
|
6180
7219
|
<iframe
|
|
6181
|
-
|
|
7220
|
+
// Distinct id per breakpoint sub-frame — the primary iframe
|
|
7221
|
+
// above uses the bare screen id, so without a suffix here
|
|
7222
|
+
// every breakpoint sub-frame's iframe would share the exact
|
|
7223
|
+
// same [data-screen-iframe-id] as the primary, and
|
|
7224
|
+
// querySelector (which returns only the first DOM match)
|
|
7225
|
+
// would always resolve hit-test/drag/wheel bridge lookups to
|
|
7226
|
+
// the primary frame regardless of which breakpoint is
|
|
7227
|
+
// active. getActiveScreenIframeId resolves the correct one
|
|
7228
|
+
// to query at lookup time.
|
|
7229
|
+
data-screen-iframe-id={getBreakpointIframeId(
|
|
7230
|
+
screen.id,
|
|
7231
|
+
widthPx,
|
|
7232
|
+
)}
|
|
6182
7233
|
src={previewUrl}
|
|
6183
7234
|
srcDoc={previewUrl ? undefined : srcdocWithHitTest}
|
|
6184
7235
|
sandbox="allow-scripts"
|
|
@@ -6245,11 +7296,16 @@ function GroupSelectionBox({
|
|
|
6245
7296
|
chromeScale,
|
|
6246
7297
|
chromeSettling,
|
|
6247
7298
|
onStartResize,
|
|
7299
|
+
onStartRotate,
|
|
6248
7300
|
}: {
|
|
6249
7301
|
bounds: NonNullable<ReturnType<typeof getFrameGroupBounds>>;
|
|
6250
7302
|
chromeScale: number;
|
|
6251
7303
|
chromeSettling: boolean;
|
|
6252
7304
|
onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
|
|
7305
|
+
/** Multi-selection rotate (CV14). Omit to keep the previous behavior (no
|
|
7306
|
+
* rotate handle shown) — used by callers whose selection kind doesn't
|
|
7307
|
+
* support group rotate yet (e.g. draft primitives). */
|
|
7308
|
+
onStartRotate?: (e: React.MouseEvent) => void;
|
|
6253
7309
|
}) {
|
|
6254
7310
|
return (
|
|
6255
7311
|
<SelectionBox
|
|
@@ -6261,10 +7317,10 @@ function GroupSelectionBox({
|
|
|
6261
7317
|
}}
|
|
6262
7318
|
chromeScale={chromeScale}
|
|
6263
7319
|
chromeSettling={chromeSettling}
|
|
6264
|
-
showRotate={
|
|
7320
|
+
showRotate={!!onStartRotate}
|
|
6265
7321
|
filled
|
|
6266
7322
|
onStartResize={onStartResize}
|
|
6267
|
-
onStartRotate={() => {}}
|
|
7323
|
+
onStartRotate={onStartRotate ?? (() => {})}
|
|
6268
7324
|
/>
|
|
6269
7325
|
);
|
|
6270
7326
|
}
|
|
@@ -6360,6 +7416,7 @@ function SelectionBox({
|
|
|
6360
7416
|
showRotate={showRotate}
|
|
6361
7417
|
chromeScale={chromeScale}
|
|
6362
7418
|
chromeSettling={chromeSettling}
|
|
7419
|
+
rotationDeg={geometry.rotation ?? 0}
|
|
6363
7420
|
onStartResize={onStartResize}
|
|
6364
7421
|
onStartRotate={onStartRotate}
|
|
6365
7422
|
/>
|
|
@@ -6374,6 +7431,7 @@ function ResizeHandles({
|
|
|
6374
7431
|
showRotate = true,
|
|
6375
7432
|
chromeScale = 1,
|
|
6376
7433
|
chromeSettling = false,
|
|
7434
|
+
rotationDeg = 0,
|
|
6377
7435
|
onStartResize,
|
|
6378
7436
|
onStartRotate,
|
|
6379
7437
|
}: {
|
|
@@ -6383,6 +7441,10 @@ function ResizeHandles({
|
|
|
6383
7441
|
showRotate?: boolean;
|
|
6384
7442
|
chromeScale?: number;
|
|
6385
7443
|
chromeSettling?: boolean;
|
|
7444
|
+
/** The frame's own rotation, so hover cursors match the handle's rotated
|
|
7445
|
+
* visual direction instead of a static unrotated cursor (CSS `cursor` is
|
|
7446
|
+
* never itself rotated by a transform on the element). */
|
|
7447
|
+
rotationDeg?: number;
|
|
6386
7448
|
onStartResize: (handle: ResizeHandle, e: React.MouseEvent) => void;
|
|
6387
7449
|
onStartRotate: (e: React.MouseEvent) => void;
|
|
6388
7450
|
}) {
|
|
@@ -6410,7 +7472,7 @@ function ResizeHandles({
|
|
|
6410
7472
|
className={edgeHandleClass}
|
|
6411
7473
|
style={edgeHandleStyle(
|
|
6412
7474
|
config.handle,
|
|
6413
|
-
config.
|
|
7475
|
+
getResizeCursorForHandle(config.handle, rotationDeg),
|
|
6414
7476
|
chromeScale,
|
|
6415
7477
|
chromeSettling,
|
|
6416
7478
|
)}
|
|
@@ -6424,7 +7486,7 @@ function ResizeHandles({
|
|
|
6424
7486
|
className={visibleHandleClass}
|
|
6425
7487
|
style={cornerHandleStyle(
|
|
6426
7488
|
config.handle,
|
|
6427
|
-
config.
|
|
7489
|
+
getResizeCursorForHandle(config.handle, rotationDeg),
|
|
6428
7490
|
chromeScale,
|
|
6429
7491
|
chromeSettling,
|
|
6430
7492
|
)}
|
|
@@ -6660,6 +7722,29 @@ function dedupeIds(ids: string[]) {
|
|
|
6660
7722
|
return [...new Set(ids)];
|
|
6661
7723
|
}
|
|
6662
7724
|
|
|
7725
|
+
/** Matches the render-time `chromeScale = scale > 0 ? 1 / scale : 1` used to
|
|
7726
|
+
* keep on-screen chrome (labels, handles) a constant pixel size regardless
|
|
7727
|
+
* of canvas zoom. Callbacks that hit-test against the rendered label band
|
|
7728
|
+
* (e.g. marquee selection) must use the same conversion from the live zoom
|
|
7729
|
+
* ref instead of a fixed constant. */
|
|
7730
|
+
function chromeScaleFromZoom(zoom: number) {
|
|
7731
|
+
const scale = zoom / 100;
|
|
7732
|
+
return scale > 0 ? 1 / scale : 1;
|
|
7733
|
+
}
|
|
7734
|
+
|
|
7735
|
+
/** Shift-marquee selection combine, matching Figma: items currently swept by
|
|
7736
|
+
* the marquee toggle relative to the selection the gesture started with —
|
|
7737
|
+
* already-selected items under the marquee are deselected, not re-added.
|
|
7738
|
+
* Items outside the base selection AND not currently under the marquee are
|
|
7739
|
+
* left untouched. A plain union (the previous behavior) can only ever grow
|
|
7740
|
+
* the selection, so it never lets a shift-marquee deselect anything. */
|
|
7741
|
+
function xorMarqueeSelection(baseIds: string[], hitIds: string[]) {
|
|
7742
|
+
const hitSet = new Set(hitIds);
|
|
7743
|
+
const kept = baseIds.filter((id) => !hitSet.has(id));
|
|
7744
|
+
const added = hitIds.filter((id) => !baseIds.includes(id));
|
|
7745
|
+
return dedupeIds([...kept, ...added]);
|
|
7746
|
+
}
|
|
7747
|
+
|
|
6663
7748
|
function sameIds(a: string[], b: string[]) {
|
|
6664
7749
|
return a.length === b.length && a.every((id, index) => id === b[index]);
|
|
6665
7750
|
}
|
|
@@ -6759,17 +7844,6 @@ function normalizeRectFromPoints(start: Point, end: Point): MarqueeRect {
|
|
|
6759
7844
|
};
|
|
6760
7845
|
}
|
|
6761
7846
|
|
|
6762
|
-
function rectIntersects(rect: MarqueeRect, bounds: BoundsRect) {
|
|
6763
|
-
const right = rect.x + rect.width;
|
|
6764
|
-
const bottom = rect.y + rect.height;
|
|
6765
|
-
return (
|
|
6766
|
-
rect.x <= bounds.right &&
|
|
6767
|
-
right >= bounds.left &&
|
|
6768
|
-
rect.y <= bounds.bottom &&
|
|
6769
|
-
bottom >= bounds.top
|
|
6770
|
-
);
|
|
6771
|
-
}
|
|
6772
|
-
|
|
6773
7847
|
function rectContainsPoint(bounds: BoundsRect, point: Point) {
|
|
6774
7848
|
return (
|
|
6775
7849
|
point.x >= bounds.left &&
|
|
@@ -6800,63 +7874,6 @@ function rotatePointAroundCenter(
|
|
|
6800
7874
|
};
|
|
6801
7875
|
}
|
|
6802
7876
|
|
|
6803
|
-
/** Test whether a marquee rect (in canvas space) intersects an OBB described by
|
|
6804
|
-
* `bounds` (unrotated AABB) that has been rotated `degrees` around `center`.
|
|
6805
|
-
* We map the four marquee corners into the frame's local space and check
|
|
6806
|
-
* whether any corner is inside the bounds; we also check the reverse (any
|
|
6807
|
-
* frame corner inside the marquee) to handle the case where the marquee is
|
|
6808
|
-
* entirely contained within the rotated frame. */
|
|
6809
|
-
function rotatedRectIntersects(
|
|
6810
|
-
rect: MarqueeRect,
|
|
6811
|
-
bounds: BoundsRect,
|
|
6812
|
-
center: Point,
|
|
6813
|
-
degrees: number,
|
|
6814
|
-
): boolean {
|
|
6815
|
-
if (!degrees) {
|
|
6816
|
-
return rectIntersects(rect, bounds);
|
|
6817
|
-
}
|
|
6818
|
-
// Four corners of the marquee rect in canvas space.
|
|
6819
|
-
const marqueeCorners: Point[] = [
|
|
6820
|
-
{ x: rect.x, y: rect.y },
|
|
6821
|
-
{ x: rect.x + rect.width, y: rect.y },
|
|
6822
|
-
{ x: rect.x + rect.width, y: rect.y + rect.height },
|
|
6823
|
-
{ x: rect.x, y: rect.y + rect.height },
|
|
6824
|
-
];
|
|
6825
|
-
// Test: any marquee corner inside the unrotated frame bounds?
|
|
6826
|
-
for (const corner of marqueeCorners) {
|
|
6827
|
-
const local = rotatePointAroundCenter(corner, center, degrees);
|
|
6828
|
-
if (rectContainsPoint(bounds, local)) return true;
|
|
6829
|
-
}
|
|
6830
|
-
// Four corners of the unrotated frame in canvas space (rotate them outward).
|
|
6831
|
-
const frameCorners: Point[] = [
|
|
6832
|
-
{ x: bounds.left, y: bounds.top },
|
|
6833
|
-
{ x: bounds.right, y: bounds.top },
|
|
6834
|
-
{ x: bounds.right, y: bounds.bottom },
|
|
6835
|
-
{ x: bounds.left, y: bounds.bottom },
|
|
6836
|
-
];
|
|
6837
|
-
const rad = (degrees * Math.PI) / 180;
|
|
6838
|
-
const cos = Math.cos(rad);
|
|
6839
|
-
const sin = Math.sin(rad);
|
|
6840
|
-
const marqueeRight = rect.x + rect.width;
|
|
6841
|
-
const marqueeBottom = rect.y + rect.height;
|
|
6842
|
-
// Test: any rotated frame corner inside the marquee rect?
|
|
6843
|
-
for (const fc of frameCorners) {
|
|
6844
|
-
const dx = fc.x - center.x;
|
|
6845
|
-
const dy = fc.y - center.y;
|
|
6846
|
-
const wx = center.x + dx * cos - dy * sin;
|
|
6847
|
-
const wy = center.y + dx * sin + dy * cos;
|
|
6848
|
-
if (
|
|
6849
|
-
wx >= rect.x &&
|
|
6850
|
-
wx <= marqueeRight &&
|
|
6851
|
-
wy >= rect.y &&
|
|
6852
|
-
wy <= marqueeBottom
|
|
6853
|
-
) {
|
|
6854
|
-
return true;
|
|
6855
|
-
}
|
|
6856
|
-
}
|
|
6857
|
-
return false;
|
|
6858
|
-
}
|
|
6859
|
-
|
|
6860
7877
|
function sameFrameGeometry(a: FrameGeometry, b: FrameGeometry) {
|
|
6861
7878
|
return (
|
|
6862
7879
|
a.x === b.x &&
|
|
@@ -6912,25 +7929,36 @@ export function getDraftPreviewGeometryForTool(
|
|
|
6912
7929
|
start: Point,
|
|
6913
7930
|
end: Point,
|
|
6914
7931
|
hasMoved: boolean,
|
|
7932
|
+
modifiers?: DraftGeometryModifiers,
|
|
6915
7933
|
): FrameGeometry {
|
|
6916
|
-
|
|
6917
|
-
|
|
7934
|
+
// Note: "pen" is a valid DraftCreationTool value for board-surface/cursor
|
|
7935
|
+
// purposes (see getDraftCreationTool), but the pen tool never actually
|
|
7936
|
+
// drives this creation-drag geometry path — beginPenNodeCreation handles
|
|
7937
|
+
// pen gestures entirely separately via activePenPath/PenNodeDragState.
|
|
7938
|
+
if (tool === "line" || tool === "arrow") {
|
|
7939
|
+
return getDraftGeometryForTool(tool, start, end, modifiers);
|
|
6918
7940
|
}
|
|
6919
7941
|
|
|
6920
7942
|
if (!hasMoved) {
|
|
6921
7943
|
return { x: start.x, y: start.y, width: 0, height: 0 };
|
|
6922
7944
|
}
|
|
6923
7945
|
|
|
6924
|
-
return getDraftGeometryForTool(tool, start, end);
|
|
7946
|
+
return getDraftGeometryForTool(tool, start, end, modifiers);
|
|
6925
7947
|
}
|
|
6926
7948
|
|
|
6927
7949
|
function getDraftGeometryForTool(
|
|
6928
7950
|
tool: DraftCreationTool,
|
|
6929
7951
|
start: Point,
|
|
6930
7952
|
end: Point,
|
|
7953
|
+
modifiers?: DraftGeometryModifiers,
|
|
6931
7954
|
): FrameGeometry {
|
|
6932
|
-
if (tool === "
|
|
6933
|
-
|
|
7955
|
+
if (tool === "line" || tool === "arrow") {
|
|
7956
|
+
// Shift constrains the line/arrow to 45deg increments, reusing the same
|
|
7957
|
+
// helper the pen tool already uses for constrained segments.
|
|
7958
|
+
const effectiveEnd = modifiers?.shiftKey
|
|
7959
|
+
? constrainPointTo45Degrees(start, end)
|
|
7960
|
+
: end;
|
|
7961
|
+
return getPathGeometry([start, effectiveEnd]);
|
|
6934
7962
|
}
|
|
6935
7963
|
const options =
|
|
6936
7964
|
tool === "frame"
|
|
@@ -6953,7 +7981,11 @@ function getDraftGeometryForTool(
|
|
|
6953
7981
|
defaultWidth: DRAFT_RECT_WIDTH,
|
|
6954
7982
|
defaultHeight: DRAFT_RECT_HEIGHT,
|
|
6955
7983
|
};
|
|
6956
|
-
return getDraftGeometryFromPoints(start, end,
|
|
7984
|
+
return getDraftGeometryFromPoints(start, end, {
|
|
7985
|
+
...options,
|
|
7986
|
+
square: modifiers?.shiftKey,
|
|
7987
|
+
fromCenter: modifiers?.altKey,
|
|
7988
|
+
});
|
|
6957
7989
|
}
|
|
6958
7990
|
|
|
6959
7991
|
function getPathGeometry(points: readonly Point[]): FrameGeometry {
|
|
@@ -6983,35 +8015,19 @@ function createDraftPrimitive({
|
|
|
6983
8015
|
tool,
|
|
6984
8016
|
start,
|
|
6985
8017
|
end,
|
|
6986
|
-
points,
|
|
6987
8018
|
moved,
|
|
6988
8019
|
toolProps,
|
|
8020
|
+
modifiers,
|
|
6989
8021
|
}: DraftPrimitiveInput): DraftPrimitive {
|
|
6990
8022
|
const id = createDraftId(tool);
|
|
6991
8023
|
const geometry = moved
|
|
6992
|
-
? getDraftGeometryForTool(tool, start, end)
|
|
6993
|
-
: getDraftGeometryForTool(tool, start, start);
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
start,
|
|
7000
|
-
{ x: start.x + 64, y: start.y + 24 },
|
|
7001
|
-
{ x: start.x + 128, y: start.y },
|
|
7002
|
-
];
|
|
7003
|
-
return createPenDraftPrimitive(
|
|
7004
|
-
{
|
|
7005
|
-
nodes: pathPoints.map(createCornerNode),
|
|
7006
|
-
closed: false,
|
|
7007
|
-
},
|
|
7008
|
-
{
|
|
7009
|
-
id,
|
|
7010
|
-
stroke: toolProps?.stroke,
|
|
7011
|
-
strokeWidth: toolProps?.strokeWidth,
|
|
7012
|
-
},
|
|
7013
|
-
);
|
|
7014
|
-
}
|
|
8024
|
+
? getDraftGeometryForTool(tool, start, end, modifiers)
|
|
8025
|
+
: getDraftGeometryForTool(tool, start, start, modifiers);
|
|
8026
|
+
// Note: pen never reaches createDraftPrimitive — beginPenNodeCreation
|
|
8027
|
+
// commits pen paths directly via createPenDraftPrimitive (see
|
|
8028
|
+
// finishPenPath). The old fallback here (a hardcoded 3-point zigzag
|
|
8029
|
+
// placeholder path used when no freehand samples existed) was unreachable
|
|
8030
|
+
// dead code and has been removed.
|
|
7015
8031
|
if (tool === "text") {
|
|
7016
8032
|
return {
|
|
7017
8033
|
id,
|
|
@@ -7024,8 +8040,11 @@ function createDraftPrimitive({
|
|
|
7024
8040
|
};
|
|
7025
8041
|
}
|
|
7026
8042
|
if (tool === "line" || tool === "arrow") {
|
|
8043
|
+
const effectiveEnd = modifiers?.shiftKey
|
|
8044
|
+
? constrainPointTo45Degrees(start, end)
|
|
8045
|
+
: end;
|
|
7027
8046
|
const pathPoints = moved
|
|
7028
|
-
? [start,
|
|
8047
|
+
? [start, effectiveEnd]
|
|
7029
8048
|
: [start, { x: start.x + DRAFT_LINE_WIDTH, y: start.y }];
|
|
7030
8049
|
return {
|
|
7031
8050
|
id,
|
|
@@ -7051,6 +8070,31 @@ function createDraftPrimitive({
|
|
|
7051
8070
|
};
|
|
7052
8071
|
}
|
|
7053
8072
|
|
|
8073
|
+
/**
|
|
8074
|
+
* Builds the closed preview/commit path for a click-or-drag close gesture
|
|
8075
|
+
* (P6). Closing on the first anchor is deferred to mouseup so a drag can
|
|
8076
|
+
* shape the closing segment's curve: dragging sets the first anchor's
|
|
8077
|
+
* handleIn (mirrored across that anchor, like every other smooth-node
|
|
8078
|
+
* handle) so the curve eases into the start point instead of always
|
|
8079
|
+
* closing with a hard straight/corner segment.
|
|
8080
|
+
*/
|
|
8081
|
+
function shapeClosingHandles(
|
|
8082
|
+
pathBefore: PenPath,
|
|
8083
|
+
dragPoint: Point | null,
|
|
8084
|
+
): PenPath {
|
|
8085
|
+
const closed = closePenPath(pathBefore);
|
|
8086
|
+
if (!dragPoint || closed.nodes.length === 0) return closed;
|
|
8087
|
+
|
|
8088
|
+
const first = closed.nodes[0];
|
|
8089
|
+
const handleIn = {
|
|
8090
|
+
x: first.point.x - (dragPoint.x - first.point.x),
|
|
8091
|
+
y: first.point.y - (dragPoint.y - first.point.y),
|
|
8092
|
+
};
|
|
8093
|
+
const nodes = closed.nodes.slice();
|
|
8094
|
+
nodes[0] = { ...first, handleIn };
|
|
8095
|
+
return { nodes, closed: true };
|
|
8096
|
+
}
|
|
8097
|
+
|
|
7054
8098
|
function createPenDraftPrimitive(
|
|
7055
8099
|
path: PenPath,
|
|
7056
8100
|
{
|
|
@@ -7301,10 +8345,19 @@ function angleBetween(center: Point, point: Point) {
|
|
|
7301
8345
|
return (Math.atan2(point.y - center.y, point.x - center.x) * 180) / Math.PI;
|
|
7302
8346
|
}
|
|
7303
8347
|
|
|
7304
|
-
function getSelectableBounds(
|
|
8348
|
+
function getSelectableBounds(
|
|
8349
|
+
geometry: FrameGeometry,
|
|
8350
|
+
chromeScale = 1,
|
|
8351
|
+
): BoundsRect {
|
|
7305
8352
|
return {
|
|
7306
8353
|
left: geometry.x,
|
|
7307
|
-
|
|
8354
|
+
// The rendered frame-label band is FRAME_LABEL_HEIGHT * chromeScale (see
|
|
8355
|
+
// frameLabelHeight in the Screen renderer) — chromeScale grows as you
|
|
8356
|
+
// zoom out (chromeScale = 1 / scale) so the label stays a constant
|
|
8357
|
+
// on-screen size. Marquee/layer hit-testing must match that rendered
|
|
8358
|
+
// band, not a fixed canvas-space height, or the clickable label area
|
|
8359
|
+
// drifts out of sync with what's drawn at any zoom other than 100%.
|
|
8360
|
+
top: geometry.y - FRAME_LABEL_HEIGHT * chromeScale,
|
|
7308
8361
|
right: geometry.x + geometry.width,
|
|
7309
8362
|
bottom: geometry.y + geometry.height,
|
|
7310
8363
|
};
|
|
@@ -7510,12 +8563,44 @@ function hashString(s: string): string {
|
|
|
7510
8563
|
export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
|
|
7511
8564
|
const PRIMITIVE_PARSE_CACHE_MAX = 64;
|
|
7512
8565
|
|
|
8566
|
+
/** First-level identity cache: most callers (e.g. drag/marquee mousemove
|
|
8567
|
+
* handlers) call parsePrimitivesFromScreen repeatedly per frame against the
|
|
8568
|
+
* *same* ScreenFile object — `screen.content` hasn't changed at all between
|
|
8569
|
+
* calls. Hashing the full content string on every call to build the
|
|
8570
|
+
* second-level cache key is wasted work in that case (PF17). Keep one most-
|
|
8571
|
+
* recent `{content, result}` pair per screen id and skip straight past the
|
|
8572
|
+
* hash when the content reference is unchanged. Only falls through to the
|
|
8573
|
+
* hash-keyed Map (and a real re-parse) when content has actually changed. */
|
|
8574
|
+
const primitiveParseIdentityCache = new Map<
|
|
8575
|
+
string,
|
|
8576
|
+
{ content: string; result: ParsedScreenPrimitive[] }
|
|
8577
|
+
>();
|
|
8578
|
+
|
|
8579
|
+
/** Test-only escape hatch: clears both cache levels. Production code never
|
|
8580
|
+
* needs to call this — the identity cache self-invalidates whenever
|
|
8581
|
+
* `screen.content` changes, and the hash-keyed cache is bounded/LRU'd. */
|
|
8582
|
+
export function __clearPrimitiveParseCachesForTests() {
|
|
8583
|
+
primitiveParseCache.clear();
|
|
8584
|
+
primitiveParseIdentityCache.clear();
|
|
8585
|
+
}
|
|
8586
|
+
|
|
7513
8587
|
export function parsePrimitivesFromScreen(
|
|
7514
8588
|
screen: ScreenFile,
|
|
7515
8589
|
): ParsedScreenPrimitive[] {
|
|
8590
|
+
const identityEntry = primitiveParseIdentityCache.get(screen.id);
|
|
8591
|
+
if (identityEntry && identityEntry.content === screen.content) {
|
|
8592
|
+
return identityEntry.result;
|
|
8593
|
+
}
|
|
8594
|
+
|
|
7516
8595
|
const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
|
|
7517
8596
|
const cached = primitiveParseCache.get(cacheKey);
|
|
7518
|
-
if (cached)
|
|
8597
|
+
if (cached) {
|
|
8598
|
+
primitiveParseIdentityCache.set(screen.id, {
|
|
8599
|
+
content: screen.content,
|
|
8600
|
+
result: cached,
|
|
8601
|
+
});
|
|
8602
|
+
return cached;
|
|
8603
|
+
}
|
|
7519
8604
|
|
|
7520
8605
|
const result: ParsedScreenPrimitive[] = [];
|
|
7521
8606
|
if (typeof DOMParser === "undefined" || !screen.content) {
|
|
@@ -7589,6 +8674,10 @@ export function parsePrimitivesFromScreen(
|
|
|
7589
8674
|
if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
|
|
7590
8675
|
}
|
|
7591
8676
|
primitiveParseCache.set(cacheKey, result);
|
|
8677
|
+
primitiveParseIdentityCache.set(screen.id, {
|
|
8678
|
+
content: screen.content,
|
|
8679
|
+
result,
|
|
8680
|
+
});
|
|
7592
8681
|
return result;
|
|
7593
8682
|
}
|
|
7594
8683
|
|