@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.
Files changed (102) hide show
  1. package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/chunk-BA66NM4L.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +47 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +4432 -3045
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +9 -7
  14. package/src/components/StudioPreviewArea.tsx +14 -0
  15. package/src/components/StudioRightPanel.tsx +9 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +140 -151
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +73 -0
  20. package/src/components/editor/PropertyPanel.tsx +42 -37
  21. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  22. package/src/components/editor/SnapToolbar.tsx +20 -1
  23. package/src/components/editor/clipPathHelpers.ts +113 -0
  24. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  25. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  27. package/src/components/editor/domEditOverlayShape.ts +39 -0
  28. package/src/components/editor/domEditing.test.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +8 -1
  30. package/src/components/editor/domEditingTypes.ts +3 -0
  31. package/src/components/editor/manualEditsDom.ts +1 -18
  32. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  33. package/src/components/editor/marqueeCommit.ts +3 -2
  34. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  35. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  36. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  37. package/src/components/editor/propertyPanelHelpers.ts +21 -34
  38. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  39. package/src/components/editor/propertyPanelTypes.ts +3 -0
  40. package/src/components/editor/snapTargetCollection.ts +2 -3
  41. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  43. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  44. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  45. package/src/components/sidebar/AssetsTab.tsx +113 -15
  46. package/src/components/sidebar/BlocksTab.tsx +2 -1
  47. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  48. package/src/contexts/TimelineEditContext.tsx +1 -0
  49. package/src/hooks/domSelectionTestHarness.ts +1 -0
  50. package/src/hooks/gsapDragCommit.test.ts +56 -0
  51. package/src/hooks/gsapDragCommit.ts +41 -109
  52. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  53. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  54. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  55. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  56. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  58. package/src/hooks/timelineEditingHelpers.ts +17 -5
  59. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  60. package/src/hooks/timelineTrackVisibility.ts +371 -0
  61. package/src/hooks/useCropMode.ts +91 -0
  62. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  63. package/src/hooks/useRenderClipContent.test.ts +68 -2
  64. package/src/hooks/useRenderClipContent.ts +5 -6
  65. package/src/hooks/useTimelineEditing.ts +35 -30
  66. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  67. package/src/icons/SystemIcons.tsx +12 -4
  68. package/src/player/components/AudioWaveform.tsx +11 -8
  69. package/src/player/components/CompositionThumbnail.tsx +14 -11
  70. package/src/player/components/PlayheadIndicator.tsx +21 -11
  71. package/src/player/components/ShortcutsPanel.tsx +9 -0
  72. package/src/player/components/Timeline.test.ts +105 -5
  73. package/src/player/components/Timeline.tsx +37 -47
  74. package/src/player/components/TimelineCanvas.tsx +275 -256
  75. package/src/player/components/TimelineClip.test.tsx +105 -0
  76. package/src/player/components/TimelineClip.tsx +44 -57
  77. package/src/player/components/TimelineRuler.tsx +17 -23
  78. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  79. package/src/player/components/TimelineTypes.ts +18 -0
  80. package/src/player/components/VideoThumbnail.tsx +16 -13
  81. package/src/player/components/timelineCallbacks.ts +1 -0
  82. package/src/player/components/timelineIcons.tsx +2 -44
  83. package/src/player/components/timelineLayout.ts +18 -22
  84. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  85. package/src/player/components/timelineTheme.test.ts +21 -4
  86. package/src/player/components/timelineTheme.ts +15 -15
  87. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  88. package/src/player/components/useTimelineActiveClips.ts +125 -0
  89. package/src/player/components/useTimelinePlayhead.ts +6 -0
  90. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  91. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  92. package/src/player/lib/time.test.ts +1 -0
  93. package/src/player/lib/timelineDOM.test.ts +48 -1
  94. package/src/player/lib/timelineDOM.ts +7 -0
  95. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  96. package/src/player/store/playerStore.ts +19 -1
  97. package/src/styles/studio.css +108 -0
  98. package/src/utils/sourcePatcher.test.ts +11 -0
  99. package/src/utils/sourcePatcher.ts +6 -4
  100. package/dist/assets/index-DJaiR8T2.css +0 -1
  101. package/dist/chunk-JND3XUJL.js.map +0 -1
  102. /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 { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
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
- // fallow-ignore-next-line complexity
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 [compRect, setCompRect] = useState({
185
- left: 0,
186
- top: 0,
187
- width: 0,
188
- height: 0,
189
- scaleX: 1,
190
- scaleY: 1,
191
- });
192
- useMountEffect(() => {
193
- let frame = 0;
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
- // fallow-ignore-next-line complexity
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
- const iframe = iframeRef.current;
235
- const overlay = overlayRef.current;
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 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
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 bg-studio-accent/[0.03]"
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 bg-studio-accent/5 shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
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
- <div
485
- className="pointer-events-none absolute"
486
- style={{
487
- left: overlayRect.left + overlayRect.width / 2,
488
- top: overlayRect.top - 34,
489
- width: 28,
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 ${selectionShapeStyles.clipPath ? "shadow-[inset_0_0_0_2px_rgba(60,230,172,0.6)]" : "border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"} bg-studio-accent/5`}
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: selectionShapeStyles.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
- {allowCanvasMovement && selection.capabilities.canApplyManualSize && (
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={{ cursor: "se-resize", touchAction: "none" }}
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
+ });