@hyperframes/studio 0.7.38 → 0.7.40
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/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
- package/dist/chunk-BA66NM4L.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +47 -29
- package/dist/index.html +2 -2
- package/dist/index.js +4432 -3045
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -7
- package/src/components/StudioPreviewArea.tsx +14 -0
- package/src/components/StudioRightPanel.tsx +9 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +140 -151
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +73 -0
- package/src/components/editor/PropertyPanel.tsx +42 -37
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +21 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +3 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +19 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
2
|
-
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
2
|
import { type DomEditSelection } from "./domEditing";
|
|
4
3
|
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
|
5
4
|
import { useMarqueeGestures } from "./marqueeCommit";
|
|
6
5
|
import { MarqueeOverlay } from "./MarqueeOverlay";
|
|
7
|
-
import {
|
|
8
|
-
import { collectDomEditLayerItems } from "./domEditingLayers";
|
|
9
|
-
import { isElementComputedVisible } from "./domEditingElement";
|
|
6
|
+
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
|
|
10
7
|
import {
|
|
11
8
|
type BlockedMoveState,
|
|
12
9
|
type DomEditGroupPathOffsetCommit,
|
|
@@ -21,6 +18,14 @@ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures
|
|
|
21
18
|
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
|
22
19
|
import { GridOverlay } from "./GridOverlay";
|
|
23
20
|
import type { GestureRecordingState } from "./GestureRecordControl";
|
|
21
|
+
import { DomEditCropHandles } from "./DomEditCropHandles";
|
|
22
|
+
import { DomEditRotateHandle } from "./DomEditRotateHandle";
|
|
23
|
+
import { hugRectForElement } from "./domEditOverlayCrop";
|
|
24
|
+
import { useCropOverlay } from "../../hooks/useCropMode";
|
|
25
|
+
import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
|
|
26
|
+
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
27
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
28
|
+
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
|
24
29
|
|
|
25
30
|
// Re-exports for external consumers — preserving existing import paths.
|
|
26
31
|
export {
|
|
@@ -69,6 +74,9 @@ interface DomEditOverlayProps {
|
|
|
69
74
|
next: { width: number; height: number },
|
|
70
75
|
) => Promise<void> | void;
|
|
71
76
|
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
|
77
|
+
onStyleCommit?: (property: string, value: string) => Promise<void> | void;
|
|
78
|
+
cropMode?: boolean;
|
|
79
|
+
onCropModeChange?: (active: boolean) => void;
|
|
72
80
|
gridVisible?: boolean;
|
|
73
81
|
gridSpacing?: number;
|
|
74
82
|
recordingState?: GestureRecordingState;
|
|
@@ -96,6 +104,9 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
96
104
|
onGroupPathOffsetCommit,
|
|
97
105
|
onBoxSizeCommit,
|
|
98
106
|
onRotationCommit,
|
|
107
|
+
onStyleCommit,
|
|
108
|
+
cropMode = false,
|
|
109
|
+
onCropModeChange,
|
|
99
110
|
onMarqueeSelect,
|
|
100
111
|
}: DomEditOverlayProps) {
|
|
101
112
|
const overlayRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -103,29 +114,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
103
114
|
const onMarqueeSelectRef = useRef(onMarqueeSelect);
|
|
104
115
|
onMarqueeSelectRef.current = onMarqueeSelect;
|
|
105
116
|
|
|
106
|
-
|
|
107
|
-
const selectionShapeStyles = (() => {
|
|
108
|
-
const fallback = {
|
|
109
|
-
borderRadius: 8 as string | number,
|
|
110
|
-
clipPath: undefined as string | undefined,
|
|
111
|
-
};
|
|
112
|
-
if (!selection?.element) return fallback;
|
|
113
|
-
try {
|
|
114
|
-
const tag = selection.element.tagName.toLowerCase();
|
|
115
|
-
if (tag === "svg" || tag === "img" || tag === "video" || tag === "canvas") return fallback;
|
|
116
|
-
const win = selection.element.ownerDocument.defaultView;
|
|
117
|
-
if (!win) return fallback;
|
|
118
|
-
const cs = win.getComputedStyle(selection.element);
|
|
119
|
-
const br = cs.borderRadius;
|
|
120
|
-
const cp = cs.clipPath;
|
|
121
|
-
return {
|
|
122
|
-
borderRadius: br && br !== "0px" ? br : 4,
|
|
123
|
-
clipPath: cp && cp !== "none" ? cp : undefined,
|
|
124
|
-
};
|
|
125
|
-
} catch {
|
|
126
|
-
return fallback;
|
|
127
|
-
}
|
|
128
|
-
})();
|
|
117
|
+
const selectionShapeStyles = readDomEditSelectionShapeStyles(selection);
|
|
129
118
|
const gestureRef = useRef<GestureState | null>(null);
|
|
130
119
|
const groupGestureRef = useRef<GroupGestureState | null>(null);
|
|
131
120
|
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
|
|
@@ -151,6 +140,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
151
140
|
onBoxSizeCommitRef.current = onBoxSizeCommit;
|
|
152
141
|
const onRotationCommitRef = useRef(onRotationCommit);
|
|
153
142
|
onRotationCommitRef.current = onRotationCommit;
|
|
143
|
+
const onStyleCommitRef = useRef(onStyleCommit);
|
|
144
|
+
onStyleCommitRef.current = onStyleCommit;
|
|
154
145
|
const onBlockedMoveRef = useRef(onBlockedMove);
|
|
155
146
|
onBlockedMoveRef.current = onBlockedMove;
|
|
156
147
|
const onManualDragStartRef = useRef(onManualDragStart);
|
|
@@ -181,97 +172,54 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
181
172
|
rafPausedRef,
|
|
182
173
|
});
|
|
183
174
|
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
// fallow-ignore-next-line complexity
|
|
195
|
-
const update = () => {
|
|
196
|
-
frame = requestAnimationFrame(update);
|
|
197
|
-
const iframe = iframeRef.current;
|
|
198
|
-
const overlayEl = overlayRef.current;
|
|
199
|
-
if (!iframe || !overlayEl) return;
|
|
200
|
-
const iRect = iframe.getBoundingClientRect();
|
|
201
|
-
const oRect = overlayEl.getBoundingClientRect();
|
|
202
|
-
const left = iRect.left - oRect.left;
|
|
203
|
-
const top = iRect.top - oRect.top;
|
|
204
|
-
if (iRect.width <= 0 || iRect.height <= 0) return;
|
|
205
|
-
const doc = iframe.contentDocument;
|
|
206
|
-
const root = doc?.querySelector<HTMLElement>("[data-composition-id]") ?? doc?.documentElement;
|
|
207
|
-
const dw = Number.parseFloat(root?.getAttribute("data-width") ?? "");
|
|
208
|
-
const dh = Number.parseFloat(root?.getAttribute("data-height") ?? "");
|
|
209
|
-
const scaleX = dw > 0 ? iRect.width / dw : 1;
|
|
210
|
-
const scaleY = dh > 0 ? iRect.height / dh : 1;
|
|
211
|
-
setCompRect((prev) => {
|
|
212
|
-
if (
|
|
213
|
-
Math.abs(prev.left - left) < 0.5 &&
|
|
214
|
-
Math.abs(prev.top - top) < 0.5 &&
|
|
215
|
-
Math.abs(prev.width - iRect.width) < 0.5 &&
|
|
216
|
-
Math.abs(prev.height - iRect.height) < 0.5 &&
|
|
217
|
-
Math.abs(prev.scaleX - scaleX) < 0.001 &&
|
|
218
|
-
Math.abs(prev.scaleY - scaleY) < 0.001
|
|
219
|
-
)
|
|
220
|
-
return prev;
|
|
221
|
-
return { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY };
|
|
222
|
-
});
|
|
223
|
-
};
|
|
224
|
-
frame = requestAnimationFrame(update);
|
|
225
|
-
return () => cancelAnimationFrame(frame);
|
|
175
|
+
const compRect = useDomEditCompositionRect({ iframeRef, overlayRef });
|
|
176
|
+
const compRectRef = useRef(compRect);
|
|
177
|
+
compRectRef.current = compRect;
|
|
178
|
+
|
|
179
|
+
const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
|
|
180
|
+
selection,
|
|
181
|
+
groupCount: groupSelections.length,
|
|
182
|
+
cropMode,
|
|
183
|
+
onCropModeChange,
|
|
184
|
+
overlayRect,
|
|
226
185
|
});
|
|
186
|
+
// Inset crops draw their own outline child; other clip shapes keep the raw mirror.
|
|
187
|
+
const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath;
|
|
188
|
+
const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath);
|
|
227
189
|
|
|
228
190
|
// Off-canvas element indicators — dashed outlines for elements positioned
|
|
229
191
|
// outside the composition bounds so users can find them.
|
|
230
192
|
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
231
193
|
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
|
|
232
|
-
|
|
194
|
+
const offCanvasDirtyRef = useRef(true);
|
|
195
|
+
const offCanvasSigRef = useRef("");
|
|
196
|
+
const offCanvasObserverRef = useRef<MutationObserver | null>(null);
|
|
197
|
+
const offCanvasObservedDocRef = useRef<Document | null>(null);
|
|
198
|
+
|
|
199
|
+
// Positions depend on live iframe layout, not selection — the selected-element
|
|
200
|
+
// suppression is a render-time filter, so selection/groupSelections stay out
|
|
201
|
+
// of the geometry walk.
|
|
202
|
+
useMountEffect(() =>
|
|
203
|
+
startOffCanvasIndicatorRefresh({
|
|
204
|
+
iframeRef,
|
|
205
|
+
overlayRef,
|
|
206
|
+
compRectRef,
|
|
207
|
+
activeCompositionPathRef,
|
|
208
|
+
dirtyRef: offCanvasDirtyRef,
|
|
209
|
+
sigRef: offCanvasSigRef,
|
|
210
|
+
observerRef: offCanvasObserverRef,
|
|
211
|
+
observedDocRef: offCanvasObservedDocRef,
|
|
212
|
+
elementsRef: offCanvasElementsRef,
|
|
213
|
+
setRects: setOffCanvasRects,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Switching compositions may not swap the iframe document (so the observer's
|
|
218
|
+
// doc-swap detection wouldn't fire) yet changes which elements are off-canvas.
|
|
219
|
+
// Force a recompute explicitly on comp change.
|
|
233
220
|
useEffect(() => {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (!iframe || !overlay || compRect.width <= 0) {
|
|
237
|
-
setOffCanvasRects([]);
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
const doc = iframe.contentDocument;
|
|
241
|
-
if (!doc) return;
|
|
242
|
-
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
|
|
243
|
-
const acp = activeCompositionPath ?? "index.html";
|
|
244
|
-
const items = collectDomEditLayerItems(root, {
|
|
245
|
-
activeCompositionPath: acp,
|
|
246
|
-
isMasterView: !acp || acp === "index.html",
|
|
247
|
-
});
|
|
248
|
-
const rects: typeof offCanvasRects = [];
|
|
249
|
-
const elMap = new Map<string, HTMLElement>();
|
|
250
|
-
for (const item of items) {
|
|
251
|
-
if (!isElementComputedVisible(item.element)) continue;
|
|
252
|
-
// Groups use their members' union (where they actually render), so a group
|
|
253
|
-
// whose members sit inside the canvas isn't flagged off-canvas by a stale
|
|
254
|
-
// wrapper box.
|
|
255
|
-
const r = groupAwareOverlayRect(overlay, iframe, item.element);
|
|
256
|
-
if (!r) continue;
|
|
257
|
-
// Any edge crossing the composition border → gray-zone indicator (the
|
|
258
|
-
// in-canvas portion is clipped away below, so only the sliver shows).
|
|
259
|
-
const extendsOutsideComp =
|
|
260
|
-
r.left < compRect.left ||
|
|
261
|
-
r.left + r.width > compRect.left + compRect.width ||
|
|
262
|
-
r.top < compRect.top ||
|
|
263
|
-
r.top + r.height > compRect.top + compRect.height;
|
|
264
|
-
if (extendsOutsideComp) {
|
|
265
|
-
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
|
|
266
|
-
elMap.set(item.key, item.element);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
offCanvasElementsRef.current = elMap;
|
|
270
|
-
setOffCanvasRects(rects);
|
|
271
|
-
// Positions depend on layout, not selection — the selected-element
|
|
272
|
-
// suppression is a render-time filter, so selection/groupSelections stay
|
|
273
|
-
// out of the deps to avoid re-walking geometry on each selection change.
|
|
274
|
-
}, [iframeRef, compRect, activeCompositionPath]);
|
|
221
|
+
offCanvasDirtyRef.current = true;
|
|
222
|
+
}, [activeCompositionPath]);
|
|
275
223
|
|
|
276
224
|
const gestures = createDomEditOverlayGestureHandlers({
|
|
277
225
|
overlayRef,
|
|
@@ -325,6 +273,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
325
273
|
|
|
326
274
|
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
327
275
|
if (!allowCanvasMovement) return;
|
|
276
|
+
if (cropMode) {
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
event.stopPropagation();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
328
281
|
if (suppressNextOverlayMouseDownRef.current) {
|
|
329
282
|
suppressNextOverlayMouseDownRef.current = false;
|
|
330
283
|
suppressNextBoxMouseDownRef.current = false;
|
|
@@ -348,6 +301,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
348
301
|
// fallow-ignore-next-line complexity
|
|
349
302
|
const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
|
350
303
|
if (!allowCanvasMovement || event.button !== 0) return;
|
|
304
|
+
if (cropMode) {
|
|
305
|
+
// Reaching here = click outside the element (crop UI swallows its own) — exit crop mode.
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
event.stopPropagation();
|
|
308
|
+
onCropModeChange?.(false);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
351
311
|
if (event.shiftKey) {
|
|
352
312
|
// Use the already-updated hover selection rather than re-resolving async
|
|
353
313
|
const candidate = hoverSelectionRef.current;
|
|
@@ -395,8 +355,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
395
355
|
}
|
|
396
356
|
};
|
|
397
357
|
|
|
358
|
+
// Selection re-resolves (and the box re-keys) on every click, so native
|
|
359
|
+
// dblclick never fires on the box — detect double-click by pointerdown
|
|
360
|
+
// timestamp (a no-move drag gesture suppresses the click event entirely).
|
|
361
|
+
const lastBoxPointerDownAtRef = useRef(0);
|
|
398
362
|
const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
399
363
|
if (!allowCanvasMovement) return;
|
|
364
|
+
if (cropMode) {
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
event.stopPropagation();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
400
369
|
if (gestureRef.current || groupGestureRef.current) return;
|
|
401
370
|
if (suppressNextBoxClickRef.current) {
|
|
402
371
|
suppressNextBoxClickRef.current = false;
|
|
@@ -426,22 +395,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
426
395
|
}
|
|
427
396
|
onPointerDown={handleOverlayPointerDown}
|
|
428
397
|
onMouseDown={handleOverlayMouseDown}
|
|
429
|
-
onPointerMove={marquee.onPointerMove}
|
|
398
|
+
onPointerMove={cropMode ? undefined : marquee.onPointerMove}
|
|
430
399
|
onPointerLeave={() => onCanvasPointerLeaveRef.current()}
|
|
431
|
-
onPointerUp={marquee.onPointerUp}
|
|
432
|
-
onPointerCancel={marquee.onPointerCancel}
|
|
400
|
+
onPointerUp={cropMode ? undefined : marquee.onPointerUp}
|
|
401
|
+
onPointerCancel={cropMode ? undefined : marquee.onPointerCancel}
|
|
433
402
|
>
|
|
434
403
|
{hoverSelection && hoverRect && compRect.width > 0 && (
|
|
435
404
|
<div
|
|
436
405
|
aria-hidden="true"
|
|
437
406
|
data-dom-edit-hover-box="true"
|
|
438
|
-
className="pointer-events-none absolute rounded-md border border-studio-accent/80
|
|
439
|
-
style={
|
|
440
|
-
left: hoverRect.left,
|
|
441
|
-
top: hoverRect.top,
|
|
442
|
-
width: hoverRect.width,
|
|
443
|
-
height: hoverRect.height,
|
|
444
|
-
}}
|
|
407
|
+
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
|
408
|
+
style={hugRectForElement(hoverRect, hoverSelection.element)}
|
|
445
409
|
/>
|
|
446
410
|
)}
|
|
447
411
|
{hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
|
|
@@ -450,7 +414,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
450
414
|
<div
|
|
451
415
|
key={item.key}
|
|
452
416
|
aria-hidden="true"
|
|
453
|
-
className="pointer-events-none absolute rounded-xl border border-studio-accent/70
|
|
417
|
+
className="pointer-events-none absolute rounded-xl border border-studio-accent/70"
|
|
454
418
|
style={{
|
|
455
419
|
left: item.rect.left,
|
|
456
420
|
top: item.rect.top,
|
|
@@ -461,7 +425,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
461
425
|
))}
|
|
462
426
|
<div
|
|
463
427
|
data-dom-edit-selection-box="true"
|
|
464
|
-
className="pointer-events-auto absolute rounded-xl border border-studio-accent
|
|
428
|
+
className="pointer-events-auto absolute rounded-xl border border-studio-accent shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
|
|
465
429
|
style={{
|
|
466
430
|
left: groupBounds.left,
|
|
467
431
|
top: groupBounds.top,
|
|
@@ -480,49 +444,49 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
480
444
|
)}
|
|
481
445
|
{!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
|
|
482
446
|
<>
|
|
483
|
-
{allowCanvasMovement && selection.capabilities.canApplyManualRotation && (
|
|
484
|
-
<
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
height: 34,
|
|
491
|
-
transform: "translateX(-50%)",
|
|
447
|
+
{allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualRotation && (
|
|
448
|
+
<DomEditRotateHandle
|
|
449
|
+
overlayRect={overlayRect}
|
|
450
|
+
cropOutlineInsetPx={cropOutlineInsetPx}
|
|
451
|
+
onStartRotate={(e) => {
|
|
452
|
+
e.stopPropagation();
|
|
453
|
+
gestures.startGesture("rotate", e);
|
|
492
454
|
}}
|
|
493
|
-
|
|
494
|
-
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
|
|
495
|
-
<button
|
|
496
|
-
type="button"
|
|
497
|
-
className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
|
|
498
|
-
style={{ cursor: "grab", touchAction: "none" }}
|
|
499
|
-
title="Rotate"
|
|
500
|
-
aria-label="Rotate selection"
|
|
501
|
-
onPointerDown={(e) => {
|
|
502
|
-
e.stopPropagation();
|
|
503
|
-
gestures.startGesture("rotate", e);
|
|
504
|
-
}}
|
|
505
|
-
/>
|
|
506
|
-
</div>
|
|
455
|
+
/>
|
|
507
456
|
)}
|
|
508
457
|
<div
|
|
509
458
|
key={selectionKey}
|
|
510
459
|
ref={boxRef}
|
|
511
460
|
data-dom-edit-selection-box="true"
|
|
512
|
-
className={`pointer-events-auto absolute rounded-md ${
|
|
461
|
+
className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
|
|
513
462
|
style={{
|
|
514
463
|
left: overlayRect.left,
|
|
515
464
|
top: overlayRect.top,
|
|
516
465
|
width: overlayRect.width,
|
|
517
466
|
height: overlayRect.height,
|
|
518
|
-
clipPath:
|
|
467
|
+
clipPath: boxClipPath,
|
|
519
468
|
cursor:
|
|
520
|
-
allowCanvasMovement && selection.capabilities.canApplyManualOffset
|
|
469
|
+
allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualOffset
|
|
521
470
|
? "move"
|
|
522
471
|
: "default",
|
|
523
472
|
}}
|
|
524
473
|
onPointerDown={(e) => {
|
|
474
|
+
if (cropMode) {
|
|
475
|
+
e.preventDefault();
|
|
476
|
+
e.stopPropagation();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
525
479
|
if (!allowCanvasMovement || e.shiftKey) return;
|
|
480
|
+
const now = Date.now();
|
|
481
|
+
const isDoubleClick = now - lastBoxPointerDownAtRef.current < 400;
|
|
482
|
+
lastBoxPointerDownAtRef.current = now;
|
|
483
|
+
if (isDoubleClick && onCropModeChange && selection.capabilities.canCrop) {
|
|
484
|
+
lastBoxPointerDownAtRef.current = 0;
|
|
485
|
+
e.preventDefault();
|
|
486
|
+
e.stopPropagation();
|
|
487
|
+
onCropModeChange(true);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
526
490
|
if (selection.capabilities.canApplyManualOffset) {
|
|
527
491
|
gestures.startGesture("drag", e);
|
|
528
492
|
return;
|
|
@@ -540,10 +504,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
540
504
|
onMouseDown={suppressBoxMouseDown}
|
|
541
505
|
onClick={handleBoxClick}
|
|
542
506
|
>
|
|
543
|
-
{
|
|
507
|
+
{cropOutlineInsetPx && (
|
|
508
|
+
<div
|
|
509
|
+
className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
|
|
510
|
+
style={{
|
|
511
|
+
left: cropOutlineInsetPx.left,
|
|
512
|
+
top: cropOutlineInsetPx.top,
|
|
513
|
+
right: cropOutlineInsetPx.right,
|
|
514
|
+
bottom: cropOutlineInsetPx.bottom,
|
|
515
|
+
}}
|
|
516
|
+
/>
|
|
517
|
+
)}
|
|
518
|
+
{allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualSize && (
|
|
544
519
|
<div
|
|
545
520
|
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
|
|
546
|
-
style={{
|
|
521
|
+
style={{
|
|
522
|
+
cursor: "se-resize",
|
|
523
|
+
touchAction: "none",
|
|
524
|
+
...(cropOutlineInsetPx && {
|
|
525
|
+
right: cropOutlineInsetPx.right - 6,
|
|
526
|
+
bottom: cropOutlineInsetPx.bottom - 6,
|
|
527
|
+
}),
|
|
528
|
+
}}
|
|
547
529
|
onPointerDown={(e) => {
|
|
548
530
|
e.stopPropagation();
|
|
549
531
|
gestures.startGesture("resize", e);
|
|
@@ -551,6 +533,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
551
533
|
/>
|
|
552
534
|
)}
|
|
553
535
|
</div>
|
|
536
|
+
{cropMode && (
|
|
537
|
+
<DomEditCropHandles
|
|
538
|
+
selection={selection}
|
|
539
|
+
overlayRect={overlayRect}
|
|
540
|
+
onStyleCommit={onStyleCommitRef.current}
|
|
541
|
+
/>
|
|
542
|
+
)}
|
|
554
543
|
</>
|
|
555
544
|
)}
|
|
556
545
|
{childRects.length > 0 &&
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PointerEvent as ReactPointerEvent } from "react";
|
|
2
|
+
import type { OverlayRect } from "./domEditOverlayGeometry";
|
|
3
|
+
|
|
4
|
+
/** Rotate grab-handle above the selection. Anchors to the crop outline when
|
|
5
|
+
* the element is cropped so it stays next to what's visible on screen. */
|
|
6
|
+
export function DomEditRotateHandle({
|
|
7
|
+
overlayRect,
|
|
8
|
+
cropOutlineInsetPx,
|
|
9
|
+
onStartRotate,
|
|
10
|
+
}: {
|
|
11
|
+
overlayRect: OverlayRect;
|
|
12
|
+
cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number };
|
|
13
|
+
onStartRotate: (e: ReactPointerEvent<HTMLButtonElement>) => void;
|
|
14
|
+
}) {
|
|
15
|
+
const inset = cropOutlineInsetPx ?? { top: 0, right: 0, bottom: 0, left: 0 };
|
|
16
|
+
const visibleLeft = overlayRect.left + inset.left;
|
|
17
|
+
const visibleWidth = Math.max(0, overlayRect.width - inset.left - inset.right);
|
|
18
|
+
const visibleTop = overlayRect.top + inset.top;
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className="pointer-events-none absolute"
|
|
22
|
+
style={{
|
|
23
|
+
left: visibleLeft + visibleWidth / 2,
|
|
24
|
+
top: visibleTop - 34,
|
|
25
|
+
width: 28,
|
|
26
|
+
height: 34,
|
|
27
|
+
transform: "translateX(-50%)",
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
|
|
34
|
+
style={{ cursor: "grab", touchAction: "none" }}
|
|
35
|
+
title="Rotate"
|
|
36
|
+
aria-label="Rotate selection"
|
|
37
|
+
onPointerDown={onStartRotate}
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../../player";
|
|
2
3
|
import {
|
|
4
|
+
buildInsetClipPathSides,
|
|
3
5
|
buildStrokeStyleUpdates,
|
|
4
6
|
buildStrokeWidthStyleUpdates,
|
|
5
7
|
getClipPathInsetPx,
|
|
@@ -7,8 +9,10 @@ import {
|
|
|
7
9
|
inferBoxShadowPreset,
|
|
8
10
|
inferClipPathPreset,
|
|
9
11
|
normalizePanelPxValue,
|
|
12
|
+
parseInsetClipPathSides,
|
|
10
13
|
setCssFilterFunctionPx,
|
|
11
14
|
} from "./PropertyPanel";
|
|
15
|
+
import { isSelectedElementHidden } from "./propertyPanelHelpers";
|
|
12
16
|
|
|
13
17
|
describe("PropertyPanel style helpers", () => {
|
|
14
18
|
it("normalizes bounded pixel values without accepting incompatible units", () => {
|
|
@@ -49,6 +53,52 @@ describe("PropertyPanel style helpers", () => {
|
|
|
49
53
|
expect(getClipPathInsetPx("circle(50% at 50% 50%)")).toBe(0);
|
|
50
54
|
});
|
|
51
55
|
|
|
56
|
+
it("builds and parses 4-side inset clip paths without losing radius", () => {
|
|
57
|
+
expect(buildInsetClipPathSides({ top: 10, right: 20, bottom: 30, left: 40 }, 6)).toBe(
|
|
58
|
+
"inset(10px 20px 30px 40px round 6px)",
|
|
59
|
+
);
|
|
60
|
+
expect(parseInsetClipPathSides("inset(10px 20px 30px 40px round 6px)")).toEqual({
|
|
61
|
+
top: 10,
|
|
62
|
+
right: 20,
|
|
63
|
+
bottom: 30,
|
|
64
|
+
left: 40,
|
|
65
|
+
radius: 6,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("emits the single-value inset form when all sides are equal", () => {
|
|
70
|
+
expect(buildInsetClipPathSides({ top: 12.5, right: 12.5, bottom: 12.5, left: 12.5 })).toBe(
|
|
71
|
+
"inset(12.5px)",
|
|
72
|
+
);
|
|
73
|
+
expect(parseInsetClipPathSides("inset(12.5px)")).toEqual({
|
|
74
|
+
top: 12.5,
|
|
75
|
+
right: 12.5,
|
|
76
|
+
bottom: 12.5,
|
|
77
|
+
left: 12.5,
|
|
78
|
+
radius: 0,
|
|
79
|
+
});
|
|
80
|
+
expect(getClipPathInsetPx("inset(12.5px 12.5px 12.5px 12.5px)")).toBe(12.5);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("accepts CSS shorthand inset values and rejects unsupported clip paths", () => {
|
|
84
|
+
expect(parseInsetClipPathSides("inset(10px 20px)")).toEqual({
|
|
85
|
+
top: 10,
|
|
86
|
+
right: 20,
|
|
87
|
+
bottom: 10,
|
|
88
|
+
left: 20,
|
|
89
|
+
radius: 0,
|
|
90
|
+
});
|
|
91
|
+
expect(parseInsetClipPathSides("inset(10px 20px 30px)")).toEqual({
|
|
92
|
+
top: 10,
|
|
93
|
+
right: 20,
|
|
94
|
+
bottom: 30,
|
|
95
|
+
left: 20,
|
|
96
|
+
radius: 0,
|
|
97
|
+
});
|
|
98
|
+
expect(parseInsetClipPathSides("inset(10%)")).toBeNull();
|
|
99
|
+
expect(parseInsetClipPathSides("circle(50% at 50% 50%)")).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
52
102
|
it("keeps stroke width and style edits visually effective", () => {
|
|
53
103
|
expect(buildStrokeWidthStyleUpdates("3px", "none")).toEqual([
|
|
54
104
|
["border-width", "3px"],
|
|
@@ -65,3 +115,26 @@ describe("PropertyPanel style helpers", () => {
|
|
|
65
115
|
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
|
|
66
116
|
});
|
|
67
117
|
});
|
|
118
|
+
|
|
119
|
+
describe("isSelectedElementHidden", () => {
|
|
120
|
+
it("reads hidden state by selected timeline id or key", () => {
|
|
121
|
+
const elements: TimelineElement[] = [
|
|
122
|
+
{ id: "visible", tag: "div", start: 0, duration: 1, track: 0 },
|
|
123
|
+
{ id: "hidden", tag: "div", start: 0, duration: 1, track: 0, hidden: true },
|
|
124
|
+
{
|
|
125
|
+
id: "keyed-hidden",
|
|
126
|
+
key: "scene.html:#keyed-hidden",
|
|
127
|
+
tag: "div",
|
|
128
|
+
start: 0,
|
|
129
|
+
duration: 1,
|
|
130
|
+
track: 0,
|
|
131
|
+
hidden: true,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
expect(isSelectedElementHidden(elements, null)).toBe(false);
|
|
136
|
+
expect(isSelectedElementHidden(elements, "visible")).toBe(false);
|
|
137
|
+
expect(isSelectedElementHidden(elements, "hidden")).toBe(true);
|
|
138
|
+
expect(isSelectedElementHidden(elements, "scene.html:#keyed-hidden")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|