@hyperframes/studio 0.7.55 → 0.7.57
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/dist/assets/hyperframes-player--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +126 -125
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +53 -2
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +260 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +56 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +23 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +148 -18
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +42 -2
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -3,7 +3,11 @@ import React, { act } from "react";
|
|
|
3
3
|
import { createRoot, type Root } from "react-dom/client";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
6
|
+
import { resolveZIndexEntries } from "../nle/PreviewOverlays";
|
|
7
|
+
import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
|
|
8
|
+
import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
|
|
6
9
|
import { CanvasContextMenu } from "./CanvasContextMenu";
|
|
10
|
+
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
|
7
11
|
import type { DomEditSelection } from "./domEditing";
|
|
8
12
|
|
|
9
13
|
installReactActEnvironment();
|
|
@@ -24,7 +28,8 @@ afterEach(() => {
|
|
|
24
28
|
|
|
25
29
|
function renderMenu(props: {
|
|
26
30
|
selection: DomEditSelection;
|
|
27
|
-
onApplyZIndex?: () => void;
|
|
31
|
+
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
|
|
32
|
+
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
|
|
28
33
|
onDelete?: (selection: DomEditSelection) => void;
|
|
29
34
|
}) {
|
|
30
35
|
root = createRoot(host);
|
|
@@ -36,6 +41,7 @@ function renderMenu(props: {
|
|
|
36
41
|
selection: props.selection,
|
|
37
42
|
onClose: () => {},
|
|
38
43
|
onApplyZIndex: props.onApplyZIndex,
|
|
44
|
+
onZOrderCrossed: props.onZOrderCrossed,
|
|
39
45
|
onDelete: props.onDelete,
|
|
40
46
|
}),
|
|
41
47
|
);
|
|
@@ -113,3 +119,148 @@ describe("CanvasContextMenu — handler gating", () => {
|
|
|
113
119
|
expect(document.body.querySelector(".border-t")).toBeNull();
|
|
114
120
|
});
|
|
115
121
|
});
|
|
122
|
+
|
|
123
|
+
// ── Menu z-action → commit path (wired the way PreviewOverlays wires the app) ──
|
|
124
|
+
|
|
125
|
+
function pressMenuItem(label: string) {
|
|
126
|
+
const button = zOrderButtons().find((b) => b.textContent === label);
|
|
127
|
+
expect(button).toBeDefined();
|
|
128
|
+
act(() => {
|
|
129
|
+
button!.dispatchEvent(
|
|
130
|
+
new PointerEvent("pointerdown", { bubbles: true, cancelable: true, button: 0 }),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Target (static, earlier in DOM) below an equal-z sibling — z action must renumber. */
|
|
136
|
+
function makeStaticFamily() {
|
|
137
|
+
const parent = document.createElement("div");
|
|
138
|
+
const target = document.createElement("div");
|
|
139
|
+
target.id = "target";
|
|
140
|
+
// In happy-dom an unset computed position is "" (not "static"), which would
|
|
141
|
+
// skip the commit hook's static-position injection; declare it explicitly so
|
|
142
|
+
// the test exercises the browser default.
|
|
143
|
+
target.style.position = "static";
|
|
144
|
+
const other = document.createElement("div");
|
|
145
|
+
other.id = "other";
|
|
146
|
+
parent.append(target, other);
|
|
147
|
+
document.body.append(parent);
|
|
148
|
+
return { parent, target, other };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface CapturedBatchCall {
|
|
152
|
+
batches: DomEditPatchBatch[];
|
|
153
|
+
options: { label: string; coalesceKey: string };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Mount the REAL commit hook (persist layer mocked at commitDomEditPatchBatches). */
|
|
157
|
+
function renderCommitHook(captured: CapturedBatchCall[]) {
|
|
158
|
+
type Commit = ReturnType<typeof useElementLifecycleOps>["handleDomZIndexReorderCommit"];
|
|
159
|
+
let commit: Commit | undefined;
|
|
160
|
+
function Harness() {
|
|
161
|
+
({ handleDomZIndexReorderCommit: commit } = useElementLifecycleOps({
|
|
162
|
+
activeCompPath: "index.html",
|
|
163
|
+
showToast: vi.fn(),
|
|
164
|
+
writeProjectFile: vi.fn(async () => {}),
|
|
165
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
166
|
+
editHistory: { recordEdit: vi.fn(async () => {}) },
|
|
167
|
+
projectIdRef: { current: null },
|
|
168
|
+
reloadPreview: vi.fn(),
|
|
169
|
+
clearDomSelection: vi.fn(),
|
|
170
|
+
commitDomEditPatchBatches: async (batches, options) => {
|
|
171
|
+
captured.push({ batches, options });
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
const hookHost = document.createElement("div");
|
|
177
|
+
document.body.append(hookHost);
|
|
178
|
+
const hookRoot = createRoot(hookHost);
|
|
179
|
+
act(() => hookRoot.render(<Harness />));
|
|
180
|
+
return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
describe("CanvasContextMenu — z-action commit path", () => {
|
|
184
|
+
it("never mutates live styles itself and persists the position patch for a static element", async () => {
|
|
185
|
+
const { target } = makeStaticFamily();
|
|
186
|
+
const selection = makeSelection("Target", target);
|
|
187
|
+
const captured: CapturedBatchCall[] = [];
|
|
188
|
+
const { commit, cleanup } = renderCommitHook(captured);
|
|
189
|
+
|
|
190
|
+
// Wire onApplyZIndex the way the app does (PreviewOverlays → the commit
|
|
191
|
+
// hook), asserting the menu has NOT touched the DOM when it fires — the
|
|
192
|
+
// hook must capture true pre-change styles for its rollback.
|
|
193
|
+
const stylesAtApply: Array<{ zIndex: string; position: string }> = [];
|
|
194
|
+
renderMenu({
|
|
195
|
+
selection,
|
|
196
|
+
onApplyZIndex: (patches, action) => {
|
|
197
|
+
stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
|
|
198
|
+
const { entries } = resolveZIndexEntries(selection, patches);
|
|
199
|
+
void commit(entries, undefined, action);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await act(async () => pressMenuItem("Bring forward"));
|
|
204
|
+
|
|
205
|
+
// The menu left the element pristine; only the commit hook wrote styles.
|
|
206
|
+
expect(stylesAtApply).toEqual([{ zIndex: "", position: "static" }]);
|
|
207
|
+
expect(target.style.zIndex).toBe("1");
|
|
208
|
+
expect(target.style.position).toBe("relative");
|
|
209
|
+
|
|
210
|
+
// The persisted payload carries BOTH the z-index and the injected position,
|
|
211
|
+
// so the reorder survives the post-commit reloadPreview().
|
|
212
|
+
expect(captured).toHaveLength(1);
|
|
213
|
+
const targetPatch = captured[0]?.batches
|
|
214
|
+
.flatMap((batch) => batch.patches)
|
|
215
|
+
.find((patch) => patch.target.id === "target");
|
|
216
|
+
expect(targetPatch?.operations).toEqual(
|
|
217
|
+
expect.arrayContaining([
|
|
218
|
+
{ type: "inline-style", property: "z-index", value: "1" },
|
|
219
|
+
{ type: "inline-style", property: "position", value: "relative" },
|
|
220
|
+
]),
|
|
221
|
+
);
|
|
222
|
+
// F7: the action kind is part of the default undo coalesce key, so two
|
|
223
|
+
// different menu actions never merge into one undo step.
|
|
224
|
+
expect(captured[0]?.options.coalesceKey).toContain("bring-forward");
|
|
225
|
+
|
|
226
|
+
cleanup();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("reports the crossed sibling to onZOrderCrossed for a forward step (resolved pre-mutation)", async () => {
|
|
230
|
+
// target (earlier in DOM) and other are tied — bring-forward steps over
|
|
231
|
+
// `other`, and the flash callback must receive exactly that element, after
|
|
232
|
+
// onApplyZIndex ran (call order lets the host measure post-commit rects).
|
|
233
|
+
const { target, other } = makeStaticFamily();
|
|
234
|
+
const selection = makeSelection("Target", target);
|
|
235
|
+
const calls: Array<{ kind: string; crossed?: HTMLElement }> = [];
|
|
236
|
+
|
|
237
|
+
renderMenu({
|
|
238
|
+
selection,
|
|
239
|
+
onApplyZIndex: () => calls.push({ kind: "apply" }),
|
|
240
|
+
onZOrderCrossed: (crossed, action) => {
|
|
241
|
+
expect(action).toBe("bring-forward");
|
|
242
|
+
calls.push({ kind: "crossed", crossed });
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await act(async () => pressMenuItem("Bring forward"));
|
|
247
|
+
|
|
248
|
+
expect(calls.map((c) => c.kind)).toEqual(["apply", "crossed"]);
|
|
249
|
+
expect(calls[1]?.crossed).toBe(other);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("does not call onZOrderCrossed for bring-to-front", async () => {
|
|
253
|
+
const { target } = makeStaticFamily();
|
|
254
|
+
const onZOrderCrossed = vi.fn();
|
|
255
|
+
|
|
256
|
+
renderMenu({
|
|
257
|
+
selection: makeSelection("Target", target),
|
|
258
|
+
onApplyZIndex: vi.fn(),
|
|
259
|
+
onZOrderCrossed,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
await act(async () => pressMenuItem("Bring to front"));
|
|
263
|
+
|
|
264
|
+
expect(onZOrderCrossed).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
* useContextMenuDismiss.
|
|
8
8
|
*
|
|
9
9
|
* ── Wiring (z-order persistence) ─────────────────────────────────────────────
|
|
10
|
-
* Z-index changes are
|
|
10
|
+
* Z-index changes are resolved against the live iframe DOM via
|
|
11
11
|
* `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
|
|
12
12
|
* moving a target past an equal-z sibling can require renumbering the affected
|
|
13
|
-
* set). The patches are surfaced through the `onApplyZIndex` prop
|
|
13
|
+
* set). The patches are surfaced through the `onApplyZIndex` prop; the menu
|
|
14
|
+
* itself never mutates element styles — handleDomZIndexReorderCommit applies
|
|
15
|
+
* the live z-index (and injects position when needed) in the same synchronous
|
|
16
|
+
* flow, and captures the TRUE prior styles for its failure rollback.
|
|
14
17
|
*
|
|
15
18
|
* The prop MUST be wired at the call site to route through the full persist
|
|
16
19
|
* path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
|
|
@@ -26,7 +29,9 @@ import type { DomEditSelection } from "./domEditing";
|
|
|
26
29
|
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
|
|
27
30
|
import {
|
|
28
31
|
isZOrderActionEnabled,
|
|
32
|
+
resolveCrossedNeighbor,
|
|
29
33
|
resolveZOrderChange,
|
|
34
|
+
type ZOrderAction,
|
|
30
35
|
type ZOrderPatch,
|
|
31
36
|
} from "./canvasContextMenuZOrder";
|
|
32
37
|
|
|
@@ -38,12 +43,22 @@ interface CanvasContextMenuProps {
|
|
|
38
43
|
selection: DomEditSelection;
|
|
39
44
|
onClose: () => void;
|
|
40
45
|
/**
|
|
41
|
-
* Called with the resolved z-order patch list
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
46
|
+
* Called with the resolved z-order patch list and the menu action that
|
|
47
|
+
* produced it (the action feeds the undo coalesce key, so two DIFFERENT
|
|
48
|
+
* actions never merge into one undo step). Each patch is an
|
|
49
|
+
* { element, zIndex } pair (the target and, when a renumber is needed,
|
|
50
|
+
* affected siblings). The menu does NOT touch the live DOM — wire to
|
|
51
|
+
* handleDomZIndexReorderCommit, which applies the live styles itself
|
|
52
|
+
* (see module-level wiring comment).
|
|
45
53
|
*/
|
|
46
|
-
onApplyZIndex?: (patches: ZOrderPatch[]) => void;
|
|
54
|
+
onApplyZIndex?: (patches: ZOrderPatch[], action: ZOrderAction) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Called after a successful bring-forward / send-backward with the sibling
|
|
57
|
+
* the target stepped over (resolved from the SAME pre-mutation state as the
|
|
58
|
+
* patches), so the host can flash a highlight on it in the studio overlay.
|
|
59
|
+
* Never called for front/back or no-op actions.
|
|
60
|
+
*/
|
|
61
|
+
onZOrderCrossed?: (crossed: HTMLElement, action: ZOrderAction) => void;
|
|
47
62
|
/**
|
|
48
63
|
* Delete the selected element. Wire to handleDomEditElementDelete from
|
|
49
64
|
* useDomEditActionsContext — same path as the Delete/Backspace hotkey.
|
|
@@ -68,6 +83,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
68
83
|
selection,
|
|
69
84
|
onClose,
|
|
70
85
|
onApplyZIndex,
|
|
86
|
+
onZOrderCrossed,
|
|
71
87
|
onDelete,
|
|
72
88
|
}: CanvasContextMenuProps) {
|
|
73
89
|
const menuRef = useContextMenuDismiss(onClose);
|
|
@@ -93,20 +109,19 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
|
93
109
|
const el = selection.element;
|
|
94
110
|
|
|
95
111
|
function handleZAction(action: ZAction) {
|
|
96
|
-
// No persist handler → do NOT touch the live iframe DOM. An optimistic
|
|
97
|
-
// write with nothing to persist just reverts on the next reload.
|
|
98
112
|
if (!onApplyZIndex) return;
|
|
99
113
|
const patches = resolveZOrderChange(el, action);
|
|
100
114
|
if (patches === null) return;
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
onApplyZIndex(patches);
|
|
115
|
+
// Resolve the crossed neighbor BEFORE the commit path mutates live styles —
|
|
116
|
+
// both resolvers must read the same pre-change render order.
|
|
117
|
+
const crossed = onZOrderCrossed ? resolveCrossedNeighbor(el, action) : null;
|
|
118
|
+
// Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
|
|
119
|
+
// live z-index (and injects position:relative for static elements) in the
|
|
120
|
+
// same synchronous flow, so feedback is still instant — and it must read
|
|
121
|
+
// the PRE-change styles itself, both to capture true rollback values and
|
|
122
|
+
// to detect a static position that needs persisting.
|
|
123
|
+
onApplyZIndex(patches, action);
|
|
124
|
+
if (crossed && onZOrderCrossed) onZOrderCrossed(crossed, action);
|
|
110
125
|
onClose();
|
|
111
126
|
}
|
|
112
127
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import React, { act } from "react";
|
|
3
3
|
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { DomEditSelection } from "./domEditing";
|
|
6
6
|
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
7
7
|
import { DomEditCropHandles } from "./DomEditCropHandles";
|
|
@@ -33,7 +33,10 @@ function makeEl(id: string, clip: string): HTMLElement {
|
|
|
33
33
|
return el;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function render(
|
|
36
|
+
function render(
|
|
37
|
+
el: HTMLElement,
|
|
38
|
+
onStyleCommit: (property: string, value: string) => Promise<void> | void = () => undefined,
|
|
39
|
+
): { root: Root; rerender: (next: HTMLElement) => void } {
|
|
37
40
|
const host = document.createElement("div");
|
|
38
41
|
document.body.append(host);
|
|
39
42
|
const root = createRoot(host);
|
|
@@ -43,7 +46,7 @@ function render(el: HTMLElement): { root: Root; rerender: (next: HTMLElement) =>
|
|
|
43
46
|
<DomEditCropHandles
|
|
44
47
|
selection={selectionFor(target)}
|
|
45
48
|
overlayRect={overlayRect}
|
|
46
|
-
onStyleCommit={
|
|
49
|
+
onStyleCommit={onStyleCommit}
|
|
47
50
|
/>,
|
|
48
51
|
);
|
|
49
52
|
});
|
|
@@ -83,4 +86,72 @@ describe("DomEditCropHandles clip lift/restore", () => {
|
|
|
83
86
|
act(() => root.unmount());
|
|
84
87
|
expect(a.style.getPropertyValue("clip-path")).toBe("circle(50% at 50% 50%)");
|
|
85
88
|
});
|
|
89
|
+
|
|
90
|
+
it("re-lifts synchronously after the commit path re-applies the cropped value", async () => {
|
|
91
|
+
const a = makeEl("a", "inset(10px)");
|
|
92
|
+
let resolveCommit: (() => void) | undefined;
|
|
93
|
+
const pendingCommit = new Promise<void>((resolve) => {
|
|
94
|
+
resolveCommit = resolve;
|
|
95
|
+
});
|
|
96
|
+
const onStyleCommit = vi.fn((property: string, value: string) => {
|
|
97
|
+
a.style.setProperty(property, value);
|
|
98
|
+
return pendingCommit;
|
|
99
|
+
});
|
|
100
|
+
const { root } = render(a, onStyleCommit);
|
|
101
|
+
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
|
|
102
|
+
expect(handle).toBeTruthy();
|
|
103
|
+
|
|
104
|
+
act(() =>
|
|
105
|
+
handle!.dispatchEvent(
|
|
106
|
+
new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 100 }),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
act(() =>
|
|
110
|
+
handle!.dispatchEvent(
|
|
111
|
+
new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 80 }),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
act(() =>
|
|
115
|
+
handle!.dispatchEvent(
|
|
116
|
+
new PointerEvent("pointerup", { bubbles: true, pointerId: 1, clientX: 80 }),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(onStyleCommit).toHaveBeenCalledWith("clip-path", "inset(10px 30px 10px 10px)");
|
|
121
|
+
expect(a.style.getPropertyValue("clip-path")).toBe("none");
|
|
122
|
+
resolveCommit?.();
|
|
123
|
+
await act(async () => pendingCommit);
|
|
124
|
+
act(() => root.unmount());
|
|
125
|
+
expect(a.style.getPropertyValue("clip-path")).toBe("inset(10px 30px 10px 10px)");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("re-lifts when the crop commit rejects", async () => {
|
|
129
|
+
const a = makeEl("a", "inset(10px)");
|
|
130
|
+
const onStyleCommit = vi.fn((property: string, value: string) => {
|
|
131
|
+
a.style.setProperty(property, value);
|
|
132
|
+
return Promise.reject(new Error("persist failed"));
|
|
133
|
+
});
|
|
134
|
+
const { root } = render(a, onStyleCommit);
|
|
135
|
+
const handle = document.querySelector<HTMLButtonElement>('[aria-label="Crop right"]');
|
|
136
|
+
|
|
137
|
+
act(() =>
|
|
138
|
+
handle!.dispatchEvent(
|
|
139
|
+
new PointerEvent("pointerdown", { bubbles: true, pointerId: 2, clientX: 100 }),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
act(() =>
|
|
143
|
+
handle!.dispatchEvent(
|
|
144
|
+
new PointerEvent("pointermove", { bubbles: true, pointerId: 2, clientX: 80 }),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
await act(async () => {
|
|
148
|
+
handle!.dispatchEvent(
|
|
149
|
+
new PointerEvent("pointerup", { bubbles: true, pointerId: 2, clientX: 80 }),
|
|
150
|
+
);
|
|
151
|
+
await Promise.resolve();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(a.style.getPropertyValue("clip-path")).toBe("none");
|
|
155
|
+
act(() => root.unmount());
|
|
156
|
+
});
|
|
86
157
|
});
|
|
@@ -31,12 +31,16 @@ interface DomEditCropHandlesProps {
|
|
|
31
31
|
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
34
|
+
// Hit-strip size (px) for an edge crop handle: THICKNESS extends outward from
|
|
35
|
+
// the crop edge (flush against it, never over the element body, so a body
|
|
36
|
+
// drag always MOVES), LENGTH runs along the edge. The visible pill is smaller
|
|
37
|
+
// and centered inside the strip.
|
|
38
|
+
const EDGE_HIT_THICKNESS = 12;
|
|
39
|
+
const EDGE_HIT_LENGTH = 32;
|
|
37
40
|
|
|
38
|
-
/** Place an edge handle just OUTSIDE the given crop edge
|
|
39
|
-
* fully past the boundary). Keeps the element body free
|
|
41
|
+
/** Place an edge handle's hit strip just OUTSIDE the given crop edge
|
|
42
|
+
* (translate pushes it fully past the boundary). Keeps the element body free
|
|
43
|
+
* for moving. Corners stay free for the selection's own resize handles. */
|
|
40
44
|
function edgeHandlePlacement(
|
|
41
45
|
edge: CropEdge,
|
|
42
46
|
rect: { left: number; top: number; width: number; height: number },
|
|
@@ -44,27 +48,36 @@ function edgeHandlePlacement(
|
|
|
44
48
|
const cx = rect.left + rect.width / 2;
|
|
45
49
|
const cy = rect.top + rect.height / 2;
|
|
46
50
|
if (edge === "top") {
|
|
47
|
-
return { left: cx, top: rect.top
|
|
51
|
+
return { left: cx, top: rect.top, transform: "translate(-50%, -100%)" };
|
|
48
52
|
}
|
|
49
53
|
if (edge === "bottom") {
|
|
50
|
-
return {
|
|
51
|
-
left: cx,
|
|
52
|
-
top: rect.top + rect.height + EDGE_HANDLE_GAP,
|
|
53
|
-
transform: "translate(-50%, 0)",
|
|
54
|
-
};
|
|
54
|
+
return { left: cx, top: rect.top + rect.height, transform: "translate(-50%, 0)" };
|
|
55
55
|
}
|
|
56
56
|
if (edge === "left") {
|
|
57
|
-
return { left: rect.left
|
|
57
|
+
return { left: rect.left, top: cy, transform: "translate(-100%, -50%)" };
|
|
58
58
|
}
|
|
59
|
-
return {
|
|
60
|
-
left: rect.left + rect.width + EDGE_HANDLE_GAP,
|
|
61
|
-
top: cy,
|
|
62
|
-
transform: "translate(0, -50%)",
|
|
63
|
-
};
|
|
59
|
+
return { left: rect.left + rect.width, top: cy, transform: "translate(0, -50%)" };
|
|
64
60
|
}
|
|
65
61
|
|
|
66
62
|
const EDGES: CropEdge[] = ["top", "right", "bottom", "left"];
|
|
67
63
|
|
|
64
|
+
/** Hit-strip + pill dimensions for an edge handle, keyed on its orientation. */
|
|
65
|
+
function edgeHandleMetrics(vertical: boolean): {
|
|
66
|
+
hitWidth: number;
|
|
67
|
+
hitHeight: number;
|
|
68
|
+
cursor: string;
|
|
69
|
+
pillWidth: number;
|
|
70
|
+
pillHeight: number;
|
|
71
|
+
} {
|
|
72
|
+
return {
|
|
73
|
+
hitWidth: vertical ? EDGE_HIT_THICKNESS : EDGE_HIT_LENGTH,
|
|
74
|
+
hitHeight: vertical ? EDGE_HIT_LENGTH : EDGE_HIT_THICKNESS,
|
|
75
|
+
cursor: vertical ? "ew-resize" : "ns-resize",
|
|
76
|
+
pillWidth: vertical ? 4 : 24,
|
|
77
|
+
pillHeight: vertical ? 24 : 4,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
/**
|
|
69
82
|
* Always-on crop, integrated with the selection (no crop "mode"): while a
|
|
70
83
|
* croppable element is selected its clip is lifted so the FULL content shows and
|
|
@@ -83,6 +96,7 @@ export function DomEditCropHandles({
|
|
|
83
96
|
}: DomEditCropHandlesProps) {
|
|
84
97
|
const gestureRef = useRef<CropGestureState | null>(null);
|
|
85
98
|
const [dragging, setDragging] = useState(false);
|
|
99
|
+
const [hotEdge, setHotEdge] = useState<CropEdge | null>(null);
|
|
86
100
|
// readElementCropInsets returns null for a clip this tool can't represent
|
|
87
101
|
// (circle/polygon/non-px inset): the crop UI must fully stand down for that
|
|
88
102
|
// element — no lift, no handles — or select+deselect replaces the authored
|
|
@@ -209,10 +223,9 @@ export function DomEditCropHandles({
|
|
|
209
223
|
setDragging(false);
|
|
210
224
|
if (!gesture.didMove) return;
|
|
211
225
|
// Commit to the file. The commit path re-applies the value to the live
|
|
212
|
-
// element, so re-lift
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// is handled rather than left unhandled).
|
|
226
|
+
// element synchronously, so re-lift in the same turn to keep showing the full
|
|
227
|
+
// content + dim while selected. Re-lift again on rejection so a failed commit
|
|
228
|
+
// still restores crop-mode presentation without an unhandled rejection.
|
|
216
229
|
const el = selection.element;
|
|
217
230
|
const reLift = () => {
|
|
218
231
|
if (liftedRef.current) el.style.setProperty("clip-path", "none");
|
|
@@ -223,12 +236,16 @@ export function DomEditCropHandles({
|
|
|
223
236
|
state.insets.right > 0 ||
|
|
224
237
|
state.insets.bottom > 0 ||
|
|
225
238
|
state.insets.left > 0;
|
|
226
|
-
|
|
239
|
+
const commit = onStyleCommit?.("clip-path", committedValue);
|
|
240
|
+
// handleDomStyleCommit applies the persisted value to the live element
|
|
241
|
+
// synchronously before its first await. Restore the crop-mode lift in this
|
|
242
|
+
// same turn so the browser never paints that intermediate cropped state.
|
|
243
|
+
reLift();
|
|
244
|
+
void Promise.resolve(commit).then(() => {
|
|
227
245
|
// Only a landed commit makes the rebuilt inset the restore value; a
|
|
228
246
|
// failed one keeps restoring the pre-lift clip. Store the value itself —
|
|
229
247
|
// by deselect time, render state describes the next selection.
|
|
230
248
|
committedClipRef.current = cropped ? committedValue : "";
|
|
231
|
-
reLift();
|
|
232
249
|
}, reLift);
|
|
233
250
|
};
|
|
234
251
|
|
|
@@ -304,6 +321,7 @@ export function DomEditCropHandles({
|
|
|
304
321
|
<button
|
|
305
322
|
type="button"
|
|
306
323
|
aria-label="Reposition crop"
|
|
324
|
+
title="Reposition crop"
|
|
307
325
|
data-dom-edit-crop-handle="true"
|
|
308
326
|
className="pointer-events-auto absolute rounded-full border-2 border-studio-accent bg-studio-accent/30 shadow-[0_0_0_1px_rgba(0,0,0,0.4)]"
|
|
309
327
|
style={{
|
|
@@ -323,31 +341,48 @@ export function DomEditCropHandles({
|
|
|
323
341
|
)}
|
|
324
342
|
{/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
|
|
325
343
|
edge (via edgeHandlePlacement) so they never overlap the element body:
|
|
326
|
-
dragging the body always MOVES, only a handle crops.
|
|
344
|
+
dragging the body always MOVES, only a handle crops. The pill is
|
|
345
|
+
hover-revealed (or shown while dragging / once a crop exists) so the
|
|
346
|
+
resting selection chrome stays uncluttered; the hit strip is always
|
|
347
|
+
live, and the title names the affordance. */}
|
|
327
348
|
{EDGES.map((edge) => {
|
|
328
349
|
const vertical = edge === "left" || edge === "right";
|
|
329
350
|
const place = edgeHandlePlacement(edge, cropRect);
|
|
351
|
+
const revealed = dragging || hasCrop || hotEdge === edge;
|
|
352
|
+
const m = edgeHandleMetrics(vertical);
|
|
330
353
|
return (
|
|
331
354
|
<button
|
|
332
355
|
key={edge}
|
|
333
356
|
type="button"
|
|
334
357
|
aria-label={`Crop ${edge}`}
|
|
358
|
+
title="Crop"
|
|
335
359
|
data-dom-edit-crop-handle="true"
|
|
336
|
-
className="pointer-events-auto absolute
|
|
360
|
+
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
|
|
337
361
|
style={{
|
|
338
362
|
left: place.left,
|
|
339
363
|
top: place.top,
|
|
340
|
-
width:
|
|
341
|
-
height:
|
|
364
|
+
width: m.hitWidth,
|
|
365
|
+
height: m.hitHeight,
|
|
342
366
|
transform: place.transform,
|
|
343
|
-
cursor:
|
|
367
|
+
cursor: m.cursor,
|
|
344
368
|
touchAction: "none",
|
|
345
369
|
}}
|
|
370
|
+
onPointerEnter={() => setHotEdge(edge)}
|
|
371
|
+
onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
|
|
346
372
|
onPointerDown={(event) => startCropGesture(edge, event)}
|
|
347
373
|
onPointerMove={updateCropGesture}
|
|
348
374
|
onPointerUp={finishCropGesture}
|
|
349
375
|
onPointerCancel={cancelCropGesture}
|
|
350
|
-
|
|
376
|
+
>
|
|
377
|
+
<span
|
|
378
|
+
className="pointer-events-none rounded-full bg-studio-accent/90 shadow-[0_0_0_1px_rgba(0,0,0,0.4)] transition-opacity duration-100"
|
|
379
|
+
style={{
|
|
380
|
+
width: m.pillWidth,
|
|
381
|
+
height: m.pillHeight,
|
|
382
|
+
opacity: revealed ? 1 : 0,
|
|
383
|
+
}}
|
|
384
|
+
/>
|
|
385
|
+
</button>
|
|
351
386
|
);
|
|
352
387
|
})}
|
|
353
388
|
</div>
|