@hyperframes/studio 0.7.38 → 0.7.39

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 (49) hide show
  1. package/dist/assets/index-0P10SwC_.css +1 -0
  2. package/dist/assets/{index-ClUipc8i.js → index-312a3Ceu.js} +1 -1
  3. package/dist/assets/{index-BLRTwY5l.js → index-B9YvRJz1.js} +194 -194
  4. package/dist/assets/{index-Ykq7ihge.js → index-CQHiecE7.js} +1 -1
  5. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  6. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  7. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  8. package/dist/index.d.ts +13 -0
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2171 -1458
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +7 -6
  14. package/src/components/StudioPreviewArea.tsx +8 -0
  15. package/src/components/StudioRightPanel.tsx +6 -0
  16. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  17. package/src/components/editor/DomEditOverlay.tsx +111 -108
  18. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  19. package/src/components/editor/PropertyPanel.test.ts +48 -0
  20. package/src/components/editor/PropertyPanel.tsx +9 -30
  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/domEditingTypes.ts +3 -0
  30. package/src/components/editor/marqueeCommit.ts +3 -2
  31. package/src/components/editor/propertyPanelHelpers.ts +10 -34
  32. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  33. package/src/components/editor/propertyPanelTypes.ts +2 -0
  34. package/src/components/editor/snapTargetCollection.ts +2 -3
  35. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  36. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  37. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  38. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  39. package/src/components/sidebar/AssetsTab.tsx +113 -15
  40. package/src/components/sidebar/BlocksTab.tsx +2 -1
  41. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  42. package/src/hooks/domSelectionTestHarness.ts +1 -0
  43. package/src/hooks/useCropMode.ts +91 -0
  44. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  45. package/src/player/components/ShortcutsPanel.tsx +9 -0
  46. package/src/player/store/playerStore.ts +14 -0
  47. package/dist/assets/index-DJaiR8T2.css +0 -1
  48. package/dist/chunk-JND3XUJL.js.map +0 -1
  49. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
@@ -1,5 +1,4 @@
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";
@@ -21,6 +20,12 @@ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures
21
20
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
22
21
  import { GridOverlay } from "./GridOverlay";
23
22
  import type { GestureRecordingState } from "./GestureRecordControl";
23
+ import { DomEditCropHandles } from "./DomEditCropHandles";
24
+ import { DomEditRotateHandle } from "./DomEditRotateHandle";
25
+ import { hugRectForElement } from "./domEditOverlayCrop";
26
+ import { useCropOverlay } from "../../hooks/useCropMode";
27
+ import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
28
+ import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
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,49 +172,18 @@ 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
+
177
+ const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
178
+ selection,
179
+ groupCount: groupSelections.length,
180
+ cropMode,
181
+ onCropModeChange,
182
+ overlayRect,
226
183
  });
184
+ // Inset crops draw their own outline child; other clip shapes keep the raw mirror.
185
+ const boxClipPath = hasCropInsets ? undefined : selectionShapeStyles.clipPath;
186
+ const boxChromeClass = resolveBoxChromeClass(Boolean(cropOutlineInsetPx), boxClipPath);
227
187
 
228
188
  // Off-canvas element indicators — dashed outlines for elements positioned
229
189
  // outside the composition bounds so users can find them.
@@ -251,8 +211,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
251
211
  if (!isElementComputedVisible(item.element)) continue;
252
212
  // Groups use their members' union (where they actually render), so a group
253
213
  // 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);
214
+ // wrapper box. Crop-hug the result so an inset crop that keeps the visible
215
+ // part on-canvas doesn't flag the element either.
216
+ const base = groupAwareOverlayRect(overlay, iframe, item.element);
217
+ const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
256
218
  if (!r) continue;
257
219
  // Any edge crossing the composition border → gray-zone indicator (the
258
220
  // in-canvas portion is clipped away below, so only the sliver shows).
@@ -325,6 +287,11 @@ export const DomEditOverlay = memo(function DomEditOverlay({
325
287
 
326
288
  const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
327
289
  if (!allowCanvasMovement) return;
290
+ if (cropMode) {
291
+ event.preventDefault();
292
+ event.stopPropagation();
293
+ return;
294
+ }
328
295
  if (suppressNextOverlayMouseDownRef.current) {
329
296
  suppressNextOverlayMouseDownRef.current = false;
330
297
  suppressNextBoxMouseDownRef.current = false;
@@ -348,6 +315,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
348
315
  // fallow-ignore-next-line complexity
349
316
  const handleOverlayPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
350
317
  if (!allowCanvasMovement || event.button !== 0) return;
318
+ if (cropMode) {
319
+ // Reaching here = click outside the element (crop UI swallows its own) — exit crop mode.
320
+ event.preventDefault();
321
+ event.stopPropagation();
322
+ onCropModeChange?.(false);
323
+ return;
324
+ }
351
325
  if (event.shiftKey) {
352
326
  // Use the already-updated hover selection rather than re-resolving async
353
327
  const candidate = hoverSelectionRef.current;
@@ -395,8 +369,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
395
369
  }
396
370
  };
397
371
 
372
+ // Selection re-resolves (and the box re-keys) on every click, so native
373
+ // dblclick never fires on the box — detect double-click by pointerdown
374
+ // timestamp (a no-move drag gesture suppresses the click event entirely).
375
+ const lastBoxPointerDownAtRef = useRef(0);
398
376
  const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
399
377
  if (!allowCanvasMovement) return;
378
+ if (cropMode) {
379
+ event.preventDefault();
380
+ event.stopPropagation();
381
+ return;
382
+ }
400
383
  if (gestureRef.current || groupGestureRef.current) return;
401
384
  if (suppressNextBoxClickRef.current) {
402
385
  suppressNextBoxClickRef.current = false;
@@ -426,22 +409,17 @@ export const DomEditOverlay = memo(function DomEditOverlay({
426
409
  }
427
410
  onPointerDown={handleOverlayPointerDown}
428
411
  onMouseDown={handleOverlayMouseDown}
429
- onPointerMove={marquee.onPointerMove}
412
+ onPointerMove={cropMode ? undefined : marquee.onPointerMove}
430
413
  onPointerLeave={() => onCanvasPointerLeaveRef.current()}
431
- onPointerUp={marquee.onPointerUp}
432
- onPointerCancel={marquee.onPointerCancel}
414
+ onPointerUp={cropMode ? undefined : marquee.onPointerUp}
415
+ onPointerCancel={cropMode ? undefined : marquee.onPointerCancel}
433
416
  >
434
417
  {hoverSelection && hoverRect && compRect.width > 0 && (
435
418
  <div
436
419
  aria-hidden="true"
437
420
  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
- }}
421
+ className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
422
+ style={hugRectForElement(hoverRect, hoverSelection.element)}
445
423
  />
446
424
  )}
447
425
  {hasGroupSelection && groupOverlayItems.length > 1 && groupBounds && compRect.width > 0 && (
@@ -450,7 +428,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
450
428
  <div
451
429
  key={item.key}
452
430
  aria-hidden="true"
453
- className="pointer-events-none absolute rounded-xl border border-studio-accent/70 bg-studio-accent/[0.03]"
431
+ className="pointer-events-none absolute rounded-xl border border-studio-accent/70"
454
432
  style={{
455
433
  left: item.rect.left,
456
434
  top: item.rect.top,
@@ -461,7 +439,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
461
439
  ))}
462
440
  <div
463
441
  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)]"
442
+ className="pointer-events-auto absolute rounded-xl border border-studio-accent shadow-[0_0_0_1px_rgba(60,230,172,0.3)]"
465
443
  style={{
466
444
  left: groupBounds.left,
467
445
  top: groupBounds.top,
@@ -480,49 +458,49 @@ export const DomEditOverlay = memo(function DomEditOverlay({
480
458
  )}
481
459
  {!hasGroupSelection && selection && overlayRect && compRect.width > 0 && (
482
460
  <>
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%)",
461
+ {allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualRotation && (
462
+ <DomEditRotateHandle
463
+ overlayRect={overlayRect}
464
+ cropOutlineInsetPx={cropOutlineInsetPx}
465
+ onStartRotate={(e) => {
466
+ e.stopPropagation();
467
+ gestures.startGesture("rotate", e);
492
468
  }}
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>
469
+ />
507
470
  )}
508
471
  <div
509
472
  key={selectionKey}
510
473
  ref={boxRef}
511
474
  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`}
475
+ className={`pointer-events-auto absolute rounded-md ${boxChromeClass}`}
513
476
  style={{
514
477
  left: overlayRect.left,
515
478
  top: overlayRect.top,
516
479
  width: overlayRect.width,
517
480
  height: overlayRect.height,
518
- clipPath: selectionShapeStyles.clipPath,
481
+ clipPath: boxClipPath,
519
482
  cursor:
520
- allowCanvasMovement && selection.capabilities.canApplyManualOffset
483
+ allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualOffset
521
484
  ? "move"
522
485
  : "default",
523
486
  }}
524
487
  onPointerDown={(e) => {
488
+ if (cropMode) {
489
+ e.preventDefault();
490
+ e.stopPropagation();
491
+ return;
492
+ }
525
493
  if (!allowCanvasMovement || e.shiftKey) return;
494
+ const now = Date.now();
495
+ const isDoubleClick = now - lastBoxPointerDownAtRef.current < 400;
496
+ lastBoxPointerDownAtRef.current = now;
497
+ if (isDoubleClick && onCropModeChange && selection.capabilities.canCrop) {
498
+ lastBoxPointerDownAtRef.current = 0;
499
+ e.preventDefault();
500
+ e.stopPropagation();
501
+ onCropModeChange(true);
502
+ return;
503
+ }
526
504
  if (selection.capabilities.canApplyManualOffset) {
527
505
  gestures.startGesture("drag", e);
528
506
  return;
@@ -540,10 +518,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({
540
518
  onMouseDown={suppressBoxMouseDown}
541
519
  onClick={handleBoxClick}
542
520
  >
543
- {allowCanvasMovement && selection.capabilities.canApplyManualSize && (
521
+ {cropOutlineInsetPx && (
522
+ <div
523
+ className="pointer-events-none absolute rounded-md border border-studio-accent/80 shadow-[0_0_0_1px_rgba(60,230,172,0.25)]"
524
+ style={{
525
+ left: cropOutlineInsetPx.left,
526
+ top: cropOutlineInsetPx.top,
527
+ right: cropOutlineInsetPx.right,
528
+ bottom: cropOutlineInsetPx.bottom,
529
+ }}
530
+ />
531
+ )}
532
+ {allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualSize && (
544
533
  <div
545
534
  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" }}
535
+ style={{
536
+ cursor: "se-resize",
537
+ touchAction: "none",
538
+ ...(cropOutlineInsetPx && {
539
+ right: cropOutlineInsetPx.right - 6,
540
+ bottom: cropOutlineInsetPx.bottom - 6,
541
+ }),
542
+ }}
547
543
  onPointerDown={(e) => {
548
544
  e.stopPropagation();
549
545
  gestures.startGesture("resize", e);
@@ -551,6 +547,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
551
547
  />
552
548
  )}
553
549
  </div>
550
+ {cropMode && (
551
+ <DomEditCropHandles
552
+ selection={selection}
553
+ overlayRect={overlayRect}
554
+ onStyleCommit={onStyleCommitRef.current}
555
+ />
556
+ )}
554
557
  </>
555
558
  )}
556
559
  {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,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ buildInsetClipPathSides,
3
4
  buildStrokeStyleUpdates,
4
5
  buildStrokeWidthStyleUpdates,
5
6
  getClipPathInsetPx,
@@ -7,6 +8,7 @@ import {
7
8
  inferBoxShadowPreset,
8
9
  inferClipPathPreset,
9
10
  normalizePanelPxValue,
11
+ parseInsetClipPathSides,
10
12
  setCssFilterFunctionPx,
11
13
  } from "./PropertyPanel";
12
14
 
@@ -49,6 +51,52 @@ describe("PropertyPanel style helpers", () => {
49
51
  expect(getClipPathInsetPx("circle(50% at 50% 50%)")).toBe(0);
50
52
  });
51
53
 
54
+ it("builds and parses 4-side inset clip paths without losing radius", () => {
55
+ expect(buildInsetClipPathSides({ top: 10, right: 20, bottom: 30, left: 40 }, 6)).toBe(
56
+ "inset(10px 20px 30px 40px round 6px)",
57
+ );
58
+ expect(parseInsetClipPathSides("inset(10px 20px 30px 40px round 6px)")).toEqual({
59
+ top: 10,
60
+ right: 20,
61
+ bottom: 30,
62
+ left: 40,
63
+ radius: 6,
64
+ });
65
+ });
66
+
67
+ it("emits the single-value inset form when all sides are equal", () => {
68
+ expect(buildInsetClipPathSides({ top: 12.5, right: 12.5, bottom: 12.5, left: 12.5 })).toBe(
69
+ "inset(12.5px)",
70
+ );
71
+ expect(parseInsetClipPathSides("inset(12.5px)")).toEqual({
72
+ top: 12.5,
73
+ right: 12.5,
74
+ bottom: 12.5,
75
+ left: 12.5,
76
+ radius: 0,
77
+ });
78
+ expect(getClipPathInsetPx("inset(12.5px 12.5px 12.5px 12.5px)")).toBe(12.5);
79
+ });
80
+
81
+ it("accepts CSS shorthand inset values and rejects unsupported clip paths", () => {
82
+ expect(parseInsetClipPathSides("inset(10px 20px)")).toEqual({
83
+ top: 10,
84
+ right: 20,
85
+ bottom: 10,
86
+ left: 20,
87
+ radius: 0,
88
+ });
89
+ expect(parseInsetClipPathSides("inset(10px 20px 30px)")).toEqual({
90
+ top: 10,
91
+ right: 20,
92
+ bottom: 30,
93
+ left: 20,
94
+ radius: 0,
95
+ });
96
+ expect(parseInsetClipPathSides("inset(10%)")).toBeNull();
97
+ expect(parseInsetClipPathSides("circle(50% at 50% 50%)")).toBeNull();
98
+ });
99
+
52
100
  it("keeps stroke width and style edits visually effective", () => {
53
101
  expect(buildStrokeWidthStyleUpdates("3px", "none")).toEqual([
54
102
  ["border-width", "3px"],
@@ -1,5 +1,5 @@
1
1
  import { memo, useEffect, useMemo, useRef, useState } from "react";
2
- import { Eye, Layers, Move } from "../../icons/SystemIcons";
2
+ import { Move } from "../../icons/SystemIcons";
3
3
  import { InspectorHeaderActions } from "./InspectorHeaderActions";
4
4
  import { useStudioShellContext } from "../../contexts/StudioContext";
5
5
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -27,9 +27,11 @@ import { usePlayerStore, liveTime } from "../../player";
27
27
  import { TimingSection } from "./propertyPanelTimingSection";
28
28
  import { type PropertyPanelProps } from "./propertyPanelHelpers";
29
29
  import { GestureRecordPanelButton } from "./GestureRecordControl";
30
+ import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
30
31
 
31
32
  // Re-export helpers that external consumers import from this module
32
33
  export {
34
+ buildInsetClipPathSides,
33
35
  buildStrokeStyleUpdates,
34
36
  buildStrokeWidthStyleUpdates,
35
37
  getCssFilterFunctionPx,
@@ -37,6 +39,7 @@ export {
37
39
  inferBoxShadowPreset,
38
40
  inferClipPathPreset,
39
41
  normalizePanelPxValue,
42
+ parseInsetClipPathSides,
40
43
  setCssFilterFunctionPx,
41
44
  } from "./propertyPanelHelpers";
42
45
 
@@ -94,6 +97,8 @@ export const PropertyPanel = memo(function PropertyPanel({
94
97
  recordingState,
95
98
  recordingDuration,
96
99
  onToggleRecording,
100
+ cropMode,
101
+ onCropModeChange,
97
102
  }: PropertyPanelProps) {
98
103
  const styles = element?.computedStyles ?? EMPTY_STYLES;
99
104
  const { showToast } = useStudioShellContext();
@@ -160,35 +165,7 @@ export const PropertyPanel = memo(function PropertyPanel({
160
165
  };
161
166
 
162
167
  if (!element) {
163
- return (
164
- <div className="flex h-full flex-col bg-neutral-900">
165
- <div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
166
- {multiSelectCount > 1 ? (
167
- <>
168
- <Layers size={18} className="mb-3 text-neutral-600" />
169
- <p className="text-sm font-medium text-neutral-200">
170
- {multiSelectCount} elements selected
171
- </p>
172
- <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
173
- Select a single element to edit its properties. Click an element in the preview or
174
- use the timeline layer panel.
175
- </p>
176
- </>
177
- ) : (
178
- <>
179
- <Eye size={18} className="mb-3 text-neutral-600" />
180
- <p className="text-sm font-medium text-neutral-200">
181
- Select an element in the preview.
182
- </p>
183
- <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
184
- The inspector is tuned for element edits with safer geometry controls, color
185
- picking, and cleaner grouped layer controls.
186
- </p>
187
- </>
188
- )}
189
- </div>
190
- </div>
191
- );
168
+ return <PropertyPanelEmptyState multiSelectCount={multiSelectCount} />;
192
169
  }
193
170
 
194
171
  const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset;
@@ -581,6 +558,8 @@ export const PropertyPanel = memo(function PropertyPanel({
581
558
  onSetStyle={onSetStyle}
582
559
  onImportAssets={onImportAssets}
583
560
  gsapBorderRadius={gsapBorderRadius}
561
+ cropMode={cropMode}
562
+ onCropModeChange={onCropModeChange}
584
563
  />
585
564
  )}
586
565
  </div>
@@ -0,0 +1,33 @@
1
+ import { Eye, Layers } from "../../icons/SystemIcons";
2
+
3
+ export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) {
4
+ return (
5
+ <div className="flex h-full flex-col bg-neutral-900">
6
+ <div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
7
+ {multiSelectCount > 1 ? (
8
+ <>
9
+ <Layers size={18} className="mb-3 text-neutral-600" />
10
+ <p className="text-sm font-medium text-neutral-200">
11
+ {multiSelectCount} elements selected
12
+ </p>
13
+ <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
14
+ Select a single element to edit its properties. Click an element in the preview or use
15
+ the timeline layer panel.
16
+ </p>
17
+ </>
18
+ ) : (
19
+ <>
20
+ <Eye size={18} className="mb-3 text-neutral-600" />
21
+ <p className="text-sm font-medium text-neutral-200">
22
+ Select an element in the preview.
23
+ </p>
24
+ <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
25
+ The inspector is tuned for element edits with safer geometry controls, color picking,
26
+ and cleaner grouped layer controls.
27
+ </p>
28
+ </>
29
+ )}
30
+ </div>
31
+ </div>
32
+ );
33
+ }
@@ -1,5 +1,5 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
- import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
2
+ import { Crop, MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
3
3
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
4
4
  import { usePlayerStore } from "../../player/store/playerStore";
5
5
 
@@ -39,6 +39,9 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
39
39
  const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
40
40
  const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
41
41
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
42
+ const cropAvailable = usePlayerStore((s) => s.cropAvailable);
43
+ const cropMode = usePlayerStore((s) => s.cropMode);
44
+ const setCropMode = usePlayerStore((s) => s.setCropMode);
42
45
  const popoverRef = useRef<HTMLDivElement>(null);
43
46
  const gridButtonRef = useRef<HTMLButtonElement>(null);
44
47
 
@@ -99,6 +102,22 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
99
102
  className="absolute top-2 right-2 z-50 flex items-center gap-1"
100
103
  onPointerDown={(e) => e.stopPropagation()}
101
104
  >
105
+ {cropAvailable && (
106
+ <button
107
+ type="button"
108
+ className={`rounded-md p-1.5 transition-colors ${
109
+ cropMode
110
+ ? "bg-studio-accent/20 text-studio-accent"
111
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
112
+ }`}
113
+ onClick={() => setCropMode(!cropMode)}
114
+ title={cropMode ? "Exit crop (Esc)" : "Crop selection"}
115
+ aria-label="Crop selection"
116
+ aria-pressed={cropMode}
117
+ >
118
+ <Crop size={16} weight={cropMode ? "fill" : "regular"} />
119
+ </button>
120
+ )}
102
121
  {motionPathCreateAvailable && (
103
122
  <button
104
123
  type="button"