@hyperframes/studio 0.8.106 → 0.8.108
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-dl6K6ABp.js → hyperframes-player-BakGew45.js} +1 -1
- package/dist/assets/{index-ooaZZhKf.js → index-C5dyUHwk.js} +1 -1
- package/dist/assets/{index-CMiYVGv5.js → index-C842tcVa.js} +203 -208
- package/dist/assets/{index-DKT5NF1Y.js → index-CPe7d_wO.js} +1 -1
- package/dist/assets/{index-lrT-gfHa.css → index-ncVJ5IVu.css} +1 -1
- package/dist/chunk-6LUNLTHR.js +611 -0
- package/dist/chunk-6LUNLTHR.js.map +1 -0
- package/dist/{chunk-ICTZJPQQ.js → chunk-AOWVEVEU.js} +3 -494
- package/dist/chunk-AOWVEVEU.js.map +1 -0
- package/dist/{chunk-46XP7J3B.js → chunk-PQFEBX32.js} +5 -3
- package/dist/chunk-PQFEBX32.js.map +1 -0
- package/dist/{domEditingLayers-AQBDFNUC.js → domEditingLayers-JQPQGGT7.js} +3 -3
- package/dist/index.d.ts +377 -12
- package/dist/index.html +2 -2
- package/dist/index.js +13445 -11874
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +8 -8
- package/src/App.tsx +6 -3
- package/src/agentToolsExport.test.ts +8 -0
- package/src/captions/keyboard.test.ts +6 -0
- package/src/components/EditorShell.tsx +2 -0
- package/src/components/StudioRightPanels.tsx +2 -0
- package/src/components/TimelineToolbar.test.tsx +27 -0
- package/src/components/TimelineToolbar.tsx +2 -5
- package/src/components/editor/CropPresetBar.tsx +69 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +44 -0
- package/src/components/editor/DomEditCropHandles.tsx +123 -95
- package/src/components/editor/DomEditOverlay.tsx +12 -18
- package/src/components/editor/PropertyPanel.tsx +1 -14
- package/src/components/editor/PropertyPanelFlat.tsx +3 -6
- package/src/components/editor/SnapToolbar.test.tsx +32 -1
- package/src/components/editor/SnapToolbar.tsx +3 -4
- package/src/components/editor/carveOutput.ts +54 -0
- package/src/components/editor/cropPresetStore.ts +33 -0
- package/src/components/editor/cropPresets.test.ts +41 -0
- package/src/components/editor/cropPresets.ts +24 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +5 -2
- package/src/components/editor/domEditingLayers.test.ts +21 -0
- package/src/components/editor/domEditingLayers.ts +2 -0
- package/src/components/editor/dragUndoPaint.test.ts +71 -0
- package/src/components/editor/fontAssets.test.ts +12 -22
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +96 -33
- package/src/components/editor/gsapAnimatesProperty.ts +3 -0
- package/src/components/editor/manualEdits.test.ts +4 -3
- package/src/components/editor/manualEditsDom.ts +28 -5
- package/src/components/editor/manualEditsDomPatches.ts +5 -1
- package/src/components/editor/manualOffsetDrag.ts +15 -1
- package/src/components/editor/mediaAudioEdits.test.ts +271 -0
- package/src/components/editor/mediaAudioEdits.ts +282 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +32 -4
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +3 -1
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +290 -87
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/editor/useDomEditNudge.test.tsx +134 -2
- package/src/components/editor/useDomEditNudge.ts +46 -23
- package/src/components/editor/useDomEditOverlayGestures.ts +17 -6
- package/src/components/editor/useFxCarve.ts +3 -61
- package/src/components/editor/useFxCarveGrouping.test.ts +13 -0
- package/src/components/editor/useFxCarveGrouping.ts +8 -3
- package/src/components/nle/AudioMeterStrip.test.tsx +53 -0
- package/src/components/nle/AudioMeterStrip.tsx +60 -2
- package/src/components/nle/useTimelineEditCallbacks.ts +4 -0
- package/src/components/panels/SlideshowPanel.test.ts +27 -0
- package/src/components/panels/SlideshowPanel.tsx +12 -7
- package/src/contexts/DomEditContext.test.tsx +62 -0
- package/src/contexts/DomEditContext.tsx +94 -77
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/canvasQuickSaves.test.tsx +72 -7
- package/src/hooks/domEditCommitTypes.ts +10 -0
- package/src/hooks/domEditPersistFailure.ts +12 -1
- package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
- package/src/hooks/domStyleCommit.ts +2 -8
- package/src/hooks/gsapDragCommit.ts +3 -8
- package/src/hooks/gsapDragPositionCommit.ts +6 -1
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
- package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
- package/src/hooks/gsapResizeIntercept.test.ts +3 -11
- package/src/hooks/gsapResizeIntercept.ts +6 -2
- package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
- package/src/hooks/inspectorSaveReload.test.tsx +107 -0
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
- package/src/hooks/useAppHotkeys.test.ts +17 -0
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
- package/src/hooks/useAppHotkeys.ts +3 -3
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
- package/src/hooks/useDomEditAttributeCommits.ts +71 -29
- package/src/hooks/useDomEditCommits.test.tsx +172 -92
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
- package/src/hooks/useDomEditPersist.ts +28 -13
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -3
- package/src/hooks/useDomEditTextCommits.ts +4 -8
- package/src/hooks/useDomGeometryCommits.ts +11 -2
- package/src/hooks/useEditHistoryActions.paint.test.tsx +163 -3
- package/src/hooks/useEditHistoryActions.ts +13 -4
- package/src/hooks/useElementPicker.test.ts +62 -13
- package/src/hooks/useElementPicker.ts +10 -2
- package/src/hooks/useFreezeFrame.test.ts +56 -0
- package/src/hooks/useFreezeFrame.ts +121 -0
- package/src/hooks/useGsapAwareEditing.ts +6 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useInlineTextEdit.test.tsx +27 -0
- package/src/hooks/useInlineTextEdit.ts +9 -7
- package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
- package/src/hooks/useKeyframeKeyboard.ts +2 -8
- package/src/hooks/usePersistentEditHistory.test.ts +9 -6
- package/src/hooks/usePersistentEditHistory.ts +1 -1
- package/src/hooks/usePreviewPersistence.ts +6 -0
- package/src/hooks/useRazorSplit.ts +14 -1
- package/src/hooks/useRenderClipContent.test.ts +102 -0
- package/src/hooks/useRenderClipContent.ts +30 -23
- package/src/hooks/useTimelineEditing.ts +2 -2
- package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
- package/src/index.ts +8 -0
- package/src/player/components/AudibleVideoClipContent.tsx +29 -0
- package/src/player/components/ClipBadges.test.tsx +82 -0
- package/src/player/components/ClipBadges.tsx +67 -0
- package/src/player/components/ClipContextMenu.tsx +22 -0
- package/src/player/components/ClipPeakMarks.test.tsx +41 -0
- package/src/player/components/ClipPeakMarks.tsx +71 -0
- package/src/player/components/Timeline.test.ts +0 -21
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineClip.tsx +2 -0
- package/src/player/components/TimelineFxButton.test.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +37 -0
- package/src/player/components/TimelineTrackHeader.tsx +12 -25
- package/src/player/components/clipMenuAudioItems.test.tsx +125 -0
- package/src/player/components/clipMenuAudioItems.tsx +108 -0
- package/src/player/components/clipMenuDuck.test.ts +117 -0
- package/src/player/components/clipMenuDuck.ts +142 -0
- package/src/player/components/clipMenuNormalize.test.ts +95 -0
- package/src/player/components/clipMenuNormalize.ts +96 -0
- package/src/player/components/clipMenuToolItems.test.tsx +139 -0
- package/src/player/components/clipMenuToolItems.tsx +235 -0
- package/src/player/components/clipPeakRuns.test.ts +38 -0
- package/src/player/components/clipPeakRuns.ts +59 -0
- package/src/player/components/clipToolAttrs.test.ts +161 -0
- package/src/player/components/clipToolAttrs.ts +187 -0
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineLayout.ts +0 -28
- package/src/player/components/useClipToolState.ts +92 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
- package/src/player/lib/playbackShortcuts.ts +3 -7
- package/src/player/lib/timelineElementHelpers.test.ts +16 -0
- package/src/player/lib/timelineElementHelpers.ts +25 -2
- package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
- package/src/player/lib/timelineViewportBudgets.ts +4 -0
- package/src/timelineChromeExports.test.tsx +7 -0
- package/src/utils/audioMeterMath.test.ts +37 -0
- package/src/utils/audioMeterMath.ts +9 -3
- package/src/utils/exportPeakCeiling.ts +1 -0
- package/src/utils/gsapSoftReload.ts +3 -2
- package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
- package/src/utils/gsapUndoRestore.test.ts +145 -1
- package/src/utils/gsapUndoRestore.ts +70 -21
- package/src/utils/previewRelativePath.ts +19 -0
- package/src/utils/sourcePatcher.ts +2 -2
- package/src/utils/studioFontHelpers.ts +2 -23
- package/src/utils/studioHelpers.test.ts +30 -0
- package/src/utils/studioHelpers.ts +18 -4
- package/src/utils/studioPendingEdits.test.ts +129 -0
- package/src/utils/studioPendingEdits.ts +61 -18
- package/src/utils/timelineInspector.test.ts +33 -2
- package/src/utils/timelineInspector.ts +16 -4
- package/src/utils/typingTarget.test.ts +13 -0
- package/src/utils/typingTarget.ts +5 -2
- package/src/webmcp/useStudioAgentTools.ts +3 -1
- package/dist/chunk-46XP7J3B.js.map +0 -1
- package/dist/chunk-DI4LCJHL.js +0 -102
- package/dist/chunk-DI4LCJHL.js.map +0 -1
- package/dist/chunk-ICTZJPQQ.js.map +0 -1
- /package/dist/{domEditingLayers-AQBDFNUC.js.map → domEditingLayers-JQPQGGT7.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.108",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/parsers": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.108",
|
|
64
|
+
"@hyperframes/parsers": "0.8.108",
|
|
65
|
+
"@hyperframes/player": "0.8.108",
|
|
66
|
+
"@hyperframes/sdk": "0.8.108",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.108",
|
|
68
|
+
"@hyperframes/shader-transitions": "0.8.108"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.108"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -30,6 +30,7 @@ import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
|
|
|
30
30
|
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
|
|
31
31
|
import { useBlockHandlers } from "./hooks/useBlockHandlers";
|
|
32
32
|
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
|
33
|
+
import { trackedStudioEdit } from "./utils/studioPendingEdits";
|
|
33
34
|
import { useClipboard } from "./hooks/useClipboard";
|
|
34
35
|
import { deleteSelectedKeyframes } from "./hooks/timelineEditingHelpers";
|
|
35
36
|
import { useCaptionDetection } from "./hooks/useCaptionDetection";
|
|
@@ -256,7 +257,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
256
257
|
showToast,
|
|
257
258
|
syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
|
|
258
259
|
showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow,
|
|
259
|
-
|
|
260
|
+
settlePendingEdits: previewPersistence.settlePendingEdits,
|
|
260
261
|
handleCopy,
|
|
261
262
|
handlePaste,
|
|
262
263
|
handleCut,
|
|
@@ -315,9 +316,10 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
315
316
|
readOnlyPreview,
|
|
316
317
|
});
|
|
317
318
|
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
|
318
|
-
|
|
319
|
+
const { handleDomZIndexReorderCommit: zCommit, handleDomEditElementDelete: del } = domEditSession;
|
|
320
|
+
handleDomZIndexReorderCommitRef.current = trackedStudioEdit(zCommit);
|
|
319
321
|
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
|
320
|
-
handleDomEditElementDeleteRef.current =
|
|
322
|
+
handleDomEditElementDeleteRef.current = trackedStudioEdit(del);
|
|
321
323
|
resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
|
|
322
324
|
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
|
|
323
325
|
deleteSelectedKeyframesRef.current = () => deleteSelectedKeyframes(domEditSession);
|
|
@@ -550,6 +552,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
550
552
|
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
|
551
553
|
handleRazorSplit={timelineEditing.handleRazorSplit}
|
|
552
554
|
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
|
555
|
+
handleFreezeFrame={timelineEditing.handleFreezeFrame}
|
|
553
556
|
onCopyClip={handleCopy}
|
|
554
557
|
onPasteClip={handlePaste}
|
|
555
558
|
onDuplicateClip={handleDuplicate}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { buildStudioTools, collectStudioLookScene } from "@hyperframes/studio";
|
|
3
|
+
|
|
4
|
+
it("exports Studio's agent tools for a host's own edit session", () => {
|
|
5
|
+
const names = buildStudioTools({ current: {} as never }).map((tool) => tool.name);
|
|
6
|
+
expect(names).toEqual(expect.arrayContaining(["studio_look", "studio_seek"]));
|
|
7
|
+
expect(typeof collectStudioLookScene).toBe("function");
|
|
8
|
+
});
|
|
@@ -53,4 +53,10 @@ describe("shouldHandleCaptionNudgeKey", () => {
|
|
|
53
53
|
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), div)).toBe(true);
|
|
54
54
|
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowUp"), null)).toBe(true);
|
|
55
55
|
});
|
|
56
|
+
|
|
57
|
+
it.each(["video", "audio"])("leaves the arrows to a focused <%s controls>", (tag) => {
|
|
58
|
+
const player = document.createElement(tag);
|
|
59
|
+
player.setAttribute("controls", "");
|
|
60
|
+
expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"), player)).toBe(false);
|
|
61
|
+
});
|
|
56
62
|
});
|
|
@@ -103,6 +103,7 @@ export function EditorShell({
|
|
|
103
103
|
handleTimelineElementSplit,
|
|
104
104
|
handleRazorSplit,
|
|
105
105
|
handleRazorSplitAll,
|
|
106
|
+
handleFreezeFrame,
|
|
106
107
|
onCopyClip,
|
|
107
108
|
onPasteClip,
|
|
108
109
|
onDuplicateClip,
|
|
@@ -163,6 +164,7 @@ export function EditorShell({
|
|
|
163
164
|
handleTimelineElementSplit,
|
|
164
165
|
handleRazorSplit,
|
|
165
166
|
handleRazorSplitAll,
|
|
167
|
+
handleFreezeFrame,
|
|
166
168
|
});
|
|
167
169
|
|
|
168
170
|
return (
|
|
@@ -62,6 +62,7 @@ export function StudioRightPanels({
|
|
|
62
62
|
handleDomAttributeQuietCommit,
|
|
63
63
|
handleDomHtmlAttributeCommit,
|
|
64
64
|
handleDomAttributesCommit,
|
|
65
|
+
handleDomAttributeBatchCommit,
|
|
65
66
|
handleDomPathOffsetCommit,
|
|
66
67
|
handleDomBoxSizeCommit,
|
|
67
68
|
handleDomRotationCommit,
|
|
@@ -210,6 +211,7 @@ export function StudioRightPanels({
|
|
|
210
211
|
onSetStyle={handleDomStyleCommit}
|
|
211
212
|
onSetAttribute={handleDomAttributeCommit}
|
|
212
213
|
onSetAttributes={handleDomAttributesCommit}
|
|
214
|
+
onSetAttributeBatch={handleDomAttributeBatchCommit}
|
|
213
215
|
onSetAttributeLive={setAttributeWhileDragging}
|
|
214
216
|
onSetAttributeQuiet={handleDomAttributeQuietCommit}
|
|
215
217
|
onApplyColorGradingScope={handleApplyColorGradingScope}
|
|
@@ -211,6 +211,33 @@ describe("TimelineToolbar — keyframes on audio tracks", () => {
|
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
213
|
|
|
214
|
+
describe("TimelineToolbar snap key", () => {
|
|
215
|
+
let root: ReturnType<typeof createRoot> | null = null;
|
|
216
|
+
const snapBefore = usePlayerStore.getState().timelineSnapEnabled;
|
|
217
|
+
afterEach(() => {
|
|
218
|
+
act(() => root?.unmount());
|
|
219
|
+
root = null;
|
|
220
|
+
usePlayerStore.getState().setTimelineSnapEnabled(snapBefore);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("N toggles snapping, but not while a picker that owns typing has focus", () => {
|
|
224
|
+
root = renderToolbar().root;
|
|
225
|
+
const pressN = (target: EventTarget) =>
|
|
226
|
+
act(() => {
|
|
227
|
+
target.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true }));
|
|
228
|
+
});
|
|
229
|
+
const before = usePlayerStore.getState().timelineSnapEnabled;
|
|
230
|
+
const picker = document.createElement("button");
|
|
231
|
+
picker.setAttribute("role", "combobox");
|
|
232
|
+
document.body.append(picker);
|
|
233
|
+
|
|
234
|
+
pressN(picker);
|
|
235
|
+
expect(usePlayerStore.getState().timelineSnapEnabled).toBe(before);
|
|
236
|
+
pressN(document.body);
|
|
237
|
+
expect(usePlayerStore.getState().timelineSnapEnabled).toBe(!before);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
214
241
|
describe("TimelineToolbar Fit", () => {
|
|
215
242
|
it("shows Fit as a named icon and says whether fit is on", () => {
|
|
216
243
|
const { host, root } = renderToolbar();
|
|
@@ -33,6 +33,7 @@ import { canSplitElement } from "../utils/timelineElementSplit";
|
|
|
33
33
|
import { useAudioMetersVisible } from "../utils/audioMeterVisibility";
|
|
34
34
|
import { useProjectHasAudio } from "../utils/audioMeterMath";
|
|
35
35
|
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
|
|
36
|
+
import { isTypingTarget } from "../utils/typingTarget";
|
|
36
37
|
|
|
37
38
|
interface DomEditSessionSlice extends EnableKeyframesSession {
|
|
38
39
|
domEditSelection: DomEditSelection | null;
|
|
@@ -196,14 +197,10 @@ export function TimelineToolbar({
|
|
|
196
197
|
// "N" toggles timeline snapping (industry convention: Resolve/FCP).
|
|
197
198
|
// Skip when typing in an input/contenteditable.
|
|
198
199
|
useEffect(() => {
|
|
199
|
-
// fallow-ignore-next-line complexity
|
|
200
200
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
201
201
|
if (e.key !== "n" && e.key !== "N") return;
|
|
202
202
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
203
|
-
|
|
204
|
-
if (target?.isContentEditable) return;
|
|
205
|
-
const tag = target?.tagName?.toLowerCase() ?? "";
|
|
206
|
-
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
|
203
|
+
if (isTypingTarget(e.target)) return;
|
|
207
204
|
const store = usePlayerStore.getState();
|
|
208
205
|
store.setTimelineSnapEnabled(!store.timelineSnapEnabled);
|
|
209
206
|
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { ClipPathInsetSides } from "./clipPathHelpers";
|
|
3
|
+
import { CROP_ASPECT_PRESETS, centredCropInsets } from "./cropPresets";
|
|
4
|
+
|
|
5
|
+
interface CropPresetBarProps {
|
|
6
|
+
left: number;
|
|
7
|
+
top: number;
|
|
8
|
+
elementWidth: number;
|
|
9
|
+
elementHeight: number;
|
|
10
|
+
/** Centred insets for a ratio, or `null` to remove the crop. */
|
|
11
|
+
onApply: (insets: ClipPathInsetSides | null) => void;
|
|
12
|
+
onDone: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CHIP =
|
|
16
|
+
"rounded-sm border border-white/20 px-1.5 py-0.5 text-[10px] text-white/85 hover:bg-white/10";
|
|
17
|
+
|
|
18
|
+
/** Free · aspect presets · Reset · Done, floating above the element being cropped. */
|
|
19
|
+
export function CropPresetBar({
|
|
20
|
+
left,
|
|
21
|
+
top,
|
|
22
|
+
elementWidth,
|
|
23
|
+
elementHeight,
|
|
24
|
+
onApply,
|
|
25
|
+
onDone,
|
|
26
|
+
}: CropPresetBarProps) {
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
29
|
+
if (event.key === "Escape") onDone();
|
|
30
|
+
};
|
|
31
|
+
window.addEventListener("keydown", onKeyDown);
|
|
32
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
33
|
+
}, [onDone]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
role="toolbar"
|
|
38
|
+
aria-label="Crop presets"
|
|
39
|
+
data-dom-edit-crop-bar="true"
|
|
40
|
+
className="pointer-events-auto absolute z-20 flex gap-1 rounded-md border border-white/15 bg-neutral-900/95 p-1 shadow-lg"
|
|
41
|
+
style={{ left, top: Math.max(4, top - 34), transform: "translateX(-50%)" }}
|
|
42
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
43
|
+
>
|
|
44
|
+
<button type="button" className={CHIP} title="Drag the edges freely">
|
|
45
|
+
Free
|
|
46
|
+
</button>
|
|
47
|
+
{CROP_ASPECT_PRESETS.map((preset) => (
|
|
48
|
+
<button
|
|
49
|
+
key={preset.label}
|
|
50
|
+
type="button"
|
|
51
|
+
className={CHIP}
|
|
52
|
+
onClick={() => onApply(centredCropInsets(elementWidth, elementHeight, preset.ratio))}
|
|
53
|
+
>
|
|
54
|
+
{preset.label}
|
|
55
|
+
</button>
|
|
56
|
+
))}
|
|
57
|
+
<button type="button" className={CHIP} onClick={() => onApply(null)}>
|
|
58
|
+
Reset
|
|
59
|
+
</button>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
className="rounded-sm border border-studio-accent bg-studio-accent px-1.5 py-0.5 text-[10px] font-semibold text-black"
|
|
63
|
+
onClick={onDone}
|
|
64
|
+
>
|
|
65
|
+
Done
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -6,6 +6,7 @@ import type { DomEditSelection } from "./domEditing";
|
|
|
6
6
|
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
7
7
|
import { DomEditCropHandles } from "./DomEditCropHandles";
|
|
8
8
|
import { isElementCropLifted } from "./domEditOverlayCrop";
|
|
9
|
+
import { useCropPresetBarStore } from "./cropPresetStore";
|
|
9
10
|
|
|
10
11
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
12
|
|
|
@@ -272,3 +273,46 @@ describe("DomEditCropHandles leaves the corner resize dots free", () => {
|
|
|
272
273
|
expect(handles().map((h) => h.label)).toEqual(["Crop top", "Crop bottom"]);
|
|
273
274
|
});
|
|
274
275
|
});
|
|
276
|
+
|
|
277
|
+
describe("DomEditCropHandles preset bar", () => {
|
|
278
|
+
const click = (label: string) => {
|
|
279
|
+
const button = Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(
|
|
280
|
+
(b) => b.textContent === label,
|
|
281
|
+
);
|
|
282
|
+
act(() => button?.click());
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
afterEach(() => useCropPresetBarStore.getState().close());
|
|
286
|
+
|
|
287
|
+
it("shows only for the clip the menu opened it for", () => {
|
|
288
|
+
const a = makeEl("a", "");
|
|
289
|
+
useCropPresetBarStore.getState().open({ id: "other" });
|
|
290
|
+
render(a);
|
|
291
|
+
expect(document.querySelector("[data-dom-edit-crop-bar]")).toBeNull();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("commits a centred 1:1 crop, then Reset removes the clip-path", async () => {
|
|
295
|
+
const a = makeEl("a", "");
|
|
296
|
+
const commits: string[] = [];
|
|
297
|
+
useCropPresetBarStore.getState().open({ id: "a" });
|
|
298
|
+
const { root } = render(a, (_property, value) => {
|
|
299
|
+
commits.push(value);
|
|
300
|
+
});
|
|
301
|
+
click("1:1");
|
|
302
|
+
await act(async () => {});
|
|
303
|
+
expect(commits[0]).toBe("inset(0px 50px 0px 50px)");
|
|
304
|
+
click("Reset");
|
|
305
|
+
await act(async () => {});
|
|
306
|
+
expect(commits[1]).toBe("");
|
|
307
|
+
act(() => root.unmount());
|
|
308
|
+
expect(a.style.getPropertyValue("clip-path")).toBe("");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("Done closes the bar", () => {
|
|
312
|
+
const a = makeEl("a", "");
|
|
313
|
+
useCropPresetBarStore.getState().open({ id: "a" });
|
|
314
|
+
render(a);
|
|
315
|
+
click("Done");
|
|
316
|
+
expect(useCropPresetBarStore.getState().openFor).toBeNull();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
} from "./domEditOverlayCrop";
|
|
16
16
|
import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
|
|
17
17
|
import { readCropFollowingResize } from "./cropResize";
|
|
18
|
+
import { isCropBarTarget, useCropPresetBarStore } from "./cropPresetStore";
|
|
19
|
+
import { CropPresetBar } from "./CropPresetBar";
|
|
18
20
|
|
|
19
21
|
interface CropGestureState {
|
|
20
22
|
edge: CropEdge | "move";
|
|
@@ -119,6 +121,9 @@ export function DomEditCropHandles({
|
|
|
119
121
|
return { element, croppable: parsed !== null, insets: { top, right, bottom, left } };
|
|
120
122
|
};
|
|
121
123
|
const [state, setState] = useState(() => cropStateFor(selection.element));
|
|
124
|
+
const presetBarTarget = useCropPresetBarStore((s) => s.openFor);
|
|
125
|
+
const closePresetBar = useCropPresetBarStore((s) => s.close);
|
|
126
|
+
const showPresetBar = isCropBarTarget(selection.element, presetBarTarget);
|
|
122
127
|
|
|
123
128
|
// Re-sync when the selection targets a different element (reselect, or an
|
|
124
129
|
// undo/redo that re-keys the node).
|
|
@@ -223,10 +228,21 @@ export function DomEditCropHandles({
|
|
|
223
228
|
// the lift keeps it hidden while selected. A drag that ends where it started saves nothing.
|
|
224
229
|
const value = buildInsetClipPathSides(gesture.insets, gesture.radius);
|
|
225
230
|
if (value === buildInsetClipPathSides(gesture.startInsets, gesture.radius)) return;
|
|
231
|
+
commitClipPath(value);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const commitClipPath = (value: string) => {
|
|
226
235
|
const commit = onStyleCommit?.("clip-path", value);
|
|
227
236
|
void Promise.resolve(commit).catch(() => undefined);
|
|
228
237
|
};
|
|
229
238
|
|
|
239
|
+
const applyPresetInsets = (insets: ClipPathInsetSides | null) => {
|
|
240
|
+
const next = insets ?? { top: 0, right: 0, bottom: 0, left: 0 };
|
|
241
|
+
setState((prev) => ({ ...prev, insets: next }));
|
|
242
|
+
const radius = readCropFollowingResize(selection.element)?.radius ?? 0;
|
|
243
|
+
commitClipPath(insets === null ? "" : buildInsetClipPathSides(next, radius));
|
|
244
|
+
};
|
|
245
|
+
|
|
230
246
|
const cancelCropGesture = (event: ReactPointerEvent<HTMLElement>) => {
|
|
231
247
|
const gesture = endCropGesture(event);
|
|
232
248
|
if (!gesture) return;
|
|
@@ -239,119 +255,131 @@ export function DomEditCropHandles({
|
|
|
239
255
|
if (!state.croppable) return null;
|
|
240
256
|
|
|
241
257
|
return (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
>
|
|
253
|
-
{/* Dim the cropped-away area whenever the element is cropped and selected,
|
|
254
|
-
so the hidden content is visible (ghosted) without dragging. Clipped to
|
|
255
|
-
the element's own (rotated) box. */}
|
|
256
|
-
{hasCrop && (
|
|
257
|
-
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
258
|
-
<div
|
|
259
|
-
className="absolute"
|
|
260
|
-
style={{
|
|
261
|
-
left: cropRect.left,
|
|
262
|
-
top: cropRect.top,
|
|
263
|
-
width: cropRect.width,
|
|
264
|
-
height: cropRect.height,
|
|
265
|
-
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
|
|
266
|
-
}}
|
|
267
|
-
/>
|
|
268
|
-
</div>
|
|
258
|
+
<>
|
|
259
|
+
{showPresetBar && onStyleCommit && (
|
|
260
|
+
<CropPresetBar
|
|
261
|
+
left={frame.left + frame.width / 2}
|
|
262
|
+
top={frame.top}
|
|
263
|
+
elementWidth={width}
|
|
264
|
+
elementHeight={height}
|
|
265
|
+
onApply={applyPresetInsets}
|
|
266
|
+
onDone={closePresetBar}
|
|
267
|
+
/>
|
|
269
268
|
)}
|
|
270
|
-
{/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid
|
|
271
|
-
shown while dragging. */}
|
|
272
269
|
<div
|
|
273
|
-
|
|
274
|
-
className="pointer-events-none absolute
|
|
270
|
+
data-dom-edit-crop-frame="true"
|
|
271
|
+
className="pointer-events-none absolute"
|
|
275
272
|
style={{
|
|
276
|
-
left:
|
|
277
|
-
top:
|
|
278
|
-
width:
|
|
279
|
-
height:
|
|
273
|
+
left: frame.left,
|
|
274
|
+
top: frame.top,
|
|
275
|
+
width: frame.width,
|
|
276
|
+
height: frame.height,
|
|
277
|
+
transform: frame.angleDeg !== 0 ? `rotate(${frame.angleDeg}deg)` : undefined,
|
|
280
278
|
}}
|
|
281
279
|
>
|
|
282
|
-
{
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
<div
|
|
288
|
-
|
|
280
|
+
{/* Dim the cropped-away area whenever the element is cropped and selected,
|
|
281
|
+
so the hidden content is visible (ghosted) without dragging. Clipped to
|
|
282
|
+
the element's own (rotated) box. */}
|
|
283
|
+
{hasCrop && (
|
|
284
|
+
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
285
|
+
<div
|
|
286
|
+
className="absolute"
|
|
287
|
+
style={{
|
|
288
|
+
left: cropRect.left,
|
|
289
|
+
top: cropRect.top,
|
|
290
|
+
width: cropRect.width,
|
|
291
|
+
height: cropRect.height,
|
|
292
|
+
boxShadow: "0 0 0 100000px rgba(8, 8, 12, 0.6)",
|
|
293
|
+
}}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
289
296
|
)}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<button
|
|
296
|
-
type="button"
|
|
297
|
-
aria-label="Reposition crop"
|
|
298
|
-
title="Reposition crop"
|
|
299
|
-
data-dom-edit-crop-handle="true"
|
|
300
|
-
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)]"
|
|
297
|
+
{/* Dashed clip outline on the crop boundary, with a rule-of-thirds grid
|
|
298
|
+
shown while dragging. */}
|
|
299
|
+
<div
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
className="pointer-events-none absolute border border-dashed border-studio-accent"
|
|
301
302
|
style={{
|
|
302
|
-
left: cropRect.left
|
|
303
|
-
top: cropRect.top
|
|
304
|
-
width:
|
|
305
|
-
height:
|
|
306
|
-
cursor: "move",
|
|
307
|
-
touchAction: "none",
|
|
303
|
+
left: cropRect.left,
|
|
304
|
+
top: cropRect.top,
|
|
305
|
+
width: cropRect.width,
|
|
306
|
+
height: cropRect.height,
|
|
308
307
|
}}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (!layout) return null;
|
|
324
|
-
const revealed = dragging || hasCrop || hotEdge === edge;
|
|
325
|
-
return (
|
|
308
|
+
>
|
|
309
|
+
{dragging && (
|
|
310
|
+
<>
|
|
311
|
+
<div className="absolute inset-y-0 left-1/3 w-px bg-studio-accent/40" />
|
|
312
|
+
<div className="absolute inset-y-0 left-2/3 w-px bg-studio-accent/40" />
|
|
313
|
+
<div className="absolute inset-x-0 top-1/3 h-px bg-studio-accent/40" />
|
|
314
|
+
<div className="absolute inset-x-0 top-2/3 h-px bg-studio-accent/40" />
|
|
315
|
+
</>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
{/* Reposition handle — a center circle shown only once cropped. Drag it to
|
|
319
|
+
pan the crop window (which part of the element shows) without resizing
|
|
320
|
+
the crop. It's a small, discrete target, so a body drag still MOVES. */}
|
|
321
|
+
{hasCrop && repositionSize > 0 && (
|
|
326
322
|
<button
|
|
327
|
-
key={edge}
|
|
328
323
|
type="button"
|
|
329
|
-
aria-label=
|
|
330
|
-
title="
|
|
324
|
+
aria-label="Reposition crop"
|
|
325
|
+
title="Reposition crop"
|
|
331
326
|
data-dom-edit-crop-handle="true"
|
|
332
|
-
className="pointer-events-auto absolute
|
|
327
|
+
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)]"
|
|
333
328
|
style={{
|
|
334
|
-
|
|
335
|
-
|
|
329
|
+
left: cropRect.left + (cropRect.width - repositionSize) / 2,
|
|
330
|
+
top: cropRect.top + (cropRect.height - repositionSize) / 2,
|
|
331
|
+
width: repositionSize,
|
|
332
|
+
height: repositionSize,
|
|
333
|
+
cursor: "move",
|
|
336
334
|
touchAction: "none",
|
|
337
335
|
}}
|
|
338
|
-
|
|
339
|
-
onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
|
|
340
|
-
onPointerDown={(event) => startCropGesture(edge, event)}
|
|
336
|
+
onPointerDown={(event) => startCropGesture("move", event)}
|
|
341
337
|
onPointerMove={updateCropGesture}
|
|
342
338
|
onPointerUp={finishCropGesture}
|
|
343
339
|
onPointerCancel={cancelCropGesture}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
340
|
+
/>
|
|
341
|
+
)}
|
|
342
|
+
{/* Edge handles — drag a side to crop it. Positioned just OUTSIDE the crop
|
|
343
|
+
edge (via edgeHandleLayout) so they never overlap the element body:
|
|
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. */}
|
|
348
|
+
{EDGES.map((edge) => {
|
|
349
|
+
const layout = edgeHandleLayout(edge, cropRect);
|
|
350
|
+
if (!layout) return null;
|
|
351
|
+
const revealed = dragging || hasCrop || hotEdge === edge;
|
|
352
|
+
return (
|
|
353
|
+
<button
|
|
354
|
+
key={edge}
|
|
355
|
+
type="button"
|
|
356
|
+
aria-label={`Crop ${edge}`}
|
|
357
|
+
title="Crop"
|
|
358
|
+
data-dom-edit-crop-handle="true"
|
|
359
|
+
className="pointer-events-auto absolute flex items-center justify-center border-0 bg-transparent p-0"
|
|
347
360
|
style={{
|
|
348
|
-
...layout.
|
|
349
|
-
|
|
361
|
+
...layout.hit,
|
|
362
|
+
cursor: layout.cursor,
|
|
363
|
+
touchAction: "none",
|
|
350
364
|
}}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
365
|
+
onPointerEnter={() => setHotEdge(edge)}
|
|
366
|
+
onPointerLeave={() => setHotEdge((prev) => (prev === edge ? null : prev))}
|
|
367
|
+
onPointerDown={(event) => startCropGesture(edge, event)}
|
|
368
|
+
onPointerMove={updateCropGesture}
|
|
369
|
+
onPointerUp={finishCropGesture}
|
|
370
|
+
onPointerCancel={cancelCropGesture}
|
|
371
|
+
>
|
|
372
|
+
<span
|
|
373
|
+
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"
|
|
374
|
+
style={{
|
|
375
|
+
...layout.pill,
|
|
376
|
+
opacity: revealed ? 1 : 0,
|
|
377
|
+
}}
|
|
378
|
+
/>
|
|
379
|
+
</button>
|
|
380
|
+
);
|
|
381
|
+
})}
|
|
382
|
+
</div>
|
|
383
|
+
</>
|
|
356
384
|
);
|
|
357
385
|
}
|
|
@@ -32,7 +32,7 @@ import { CanvasContextMenu } from "./CanvasContextMenu";
|
|
|
32
32
|
import { useInlineTextEditing } from "./useInlineTextEditing";
|
|
33
33
|
import { usePreviewReadOnly } from "./previewReadOnlyContext";
|
|
34
34
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
35
|
-
import {
|
|
35
|
+
import { countStudioManualEditSave as counted } from "./manualEditsDom";
|
|
36
36
|
import type { ZOrderAction, ZOrderPatch } from "./canvasContextMenuZOrder";
|
|
37
37
|
import { getPreviewTargetFromPointer } from "../../utils/studioPreviewHelpers";
|
|
38
38
|
import { logSelect } from "../../utils/selectDebug";
|
|
@@ -195,33 +195,27 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
195
195
|
onTextEditingChangeRef.current?.(true);
|
|
196
196
|
return () => onTextEditingChangeRef.current?.(false);
|
|
197
197
|
}, [inlineText.editing]);
|
|
198
|
-
// Each canvas save is counted
|
|
198
|
+
// Each canvas save is counted, so a reload requested before it settles loads again.
|
|
199
199
|
const onPathOffsetCommitRef = useRef(onPathOffsetCommit);
|
|
200
|
-
onPathOffsetCommitRef.current = (sel, ...rest) =>
|
|
201
|
-
|
|
202
|
-
return onPathOffsetCommit(sel, ...rest);
|
|
203
|
-
};
|
|
200
|
+
onPathOffsetCommitRef.current = (sel, ...rest) =>
|
|
201
|
+
counted(sel.element, () => onPathOffsetCommit(sel, ...rest));
|
|
204
202
|
const onGroupPathOffsetCommitRef = useRef(onGroupPathOffsetCommit);
|
|
205
203
|
onGroupPathOffsetCommitRef.current = (updates) => {
|
|
206
|
-
|
|
207
|
-
return
|
|
204
|
+
const save = () => onGroupPathOffsetCommit(updates);
|
|
205
|
+
return updates[0] ? counted(updates[0].selection.element, save) : save();
|
|
208
206
|
};
|
|
209
207
|
const onBoxSizeCommitRef = useRef(onBoxSizeCommit);
|
|
210
|
-
onBoxSizeCommitRef.current = (sel, ...rest) =>
|
|
211
|
-
|
|
212
|
-
return onBoxSizeCommit(sel, ...rest);
|
|
213
|
-
};
|
|
208
|
+
onBoxSizeCommitRef.current = (sel, ...rest) =>
|
|
209
|
+
counted(sel.element, () => onBoxSizeCommit(sel, ...rest));
|
|
214
210
|
const onRotationCommitRef = useRef(onRotationCommit);
|
|
215
|
-
onRotationCommitRef.current = (sel, next) =>
|
|
216
|
-
|
|
217
|
-
return onRotationCommit(sel, next);
|
|
218
|
-
};
|
|
211
|
+
onRotationCommitRef.current = (sel, next) =>
|
|
212
|
+
counted(sel.element, () => onRotationCommit(sel, next));
|
|
219
213
|
const onStyleCommitRef = useRef(onStyleCommit);
|
|
220
214
|
onStyleCommitRef.current =
|
|
221
215
|
onStyleCommit &&
|
|
222
216
|
((property, value) => {
|
|
223
|
-
|
|
224
|
-
return
|
|
217
|
+
const save = () => onStyleCommit(property, value);
|
|
218
|
+
return selectionRef.current ? counted(selectionRef.current.element, save) : save();
|
|
225
219
|
});
|
|
226
220
|
const onBlockedMoveRef = useRef(onBlockedMove);
|
|
227
221
|
onBlockedMoveRef.current = onBlockedMove;
|