@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
@@ -50,6 +50,9 @@ export const CURATED_STYLE_PROPERTIES = [
50
50
  export interface DomEditCapabilities {
51
51
  canSelect: boolean;
52
52
  canEditStyles: boolean;
53
+ /** Can take a non-destructive `clip-path: inset()` crop — broader than
54
+ * canEditStyles (a sub-composition host is croppable from the parent view). */
55
+ canCrop: boolean;
53
56
  /** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */
54
57
  canMove: boolean;
55
58
  /** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */
@@ -33,6 +33,7 @@ import {
33
33
  import { roundRotationAngle } from "./manualEditsParsing";
34
34
  import { applyStudioMotionFromDom } from "./studioMotion";
35
35
  import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
36
+ import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
36
37
 
37
38
  /* ── Gesture tracking ─────────────────────────────────────────────── */
38
39
  let studioManualEditGestureId = 0;
@@ -162,24 +163,6 @@ export function restoreInlineDisplay(element: HTMLElement): void {
162
163
  }
163
164
 
164
165
  /* ── Translate helpers ────────────────────────────────────────────── */
165
- function splitTopLevelWhitespace(value: string): string[] {
166
- const parts: string[] = [];
167
- let depth = 0;
168
- let current = "";
169
- for (const char of value.trim()) {
170
- if (char === "(") depth += 1;
171
- if (char === ")") depth = Math.max(0, depth - 1);
172
- if (/\s/.test(char) && depth === 0) {
173
- if (current) parts.push(current);
174
- current = "";
175
- } else {
176
- current += char;
177
- }
178
- }
179
- if (current) parts.push(current);
180
- return parts;
181
- }
182
-
183
166
  function composeTranslateValue(element: HTMLElement, x: string, y: string): string {
184
167
  const original = element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR)?.trim();
185
168
  if (!original || original === "none") return `${x} ${y}`;
@@ -0,0 +1,18 @@
1
+ export function splitTopLevelWhitespace(value: string): string[] {
2
+ const parts: string[] = [];
3
+ let depth = 0;
4
+ // fallow-ignore-next-line code-duplication
5
+ let current = "";
6
+ for (const char of value.trim()) {
7
+ if (char === "(") depth += 1;
8
+ if (char === ")") depth = Math.max(0, depth - 1);
9
+ if (/\s/.test(char) && depth === 0) {
10
+ if (current) parts.push(current);
11
+ current = "";
12
+ } else {
13
+ current += char;
14
+ }
15
+ }
16
+ if (current) parts.push(current);
17
+ return parts;
18
+ }
@@ -1,10 +1,11 @@
1
+ // fallow-ignore-file code-duplication
1
2
  import { useCallback, useRef, useState } from "react";
2
3
  import type { DomEditSelection } from "./domEditing";
3
4
  import { collectDomEditLayerItems, resolveDomEditSelection } from "./domEditingLayers";
4
5
  import { isElementComputedVisible } from "./domEditingElement";
5
6
  import { coversComposition } from "../../utils/studioPreviewHelpers";
6
7
  import { rectsOverlap, type Rect } from "../../utils/marqueeGeometry";
7
- import { toOverlayRect } from "./domEditOverlayGeometry";
8
+ import { toVisibleOverlayRect } from "./domEditOverlayGeometry";
8
9
 
9
10
  interface MarqueeState {
10
11
  startX: number;
@@ -57,7 +58,7 @@ function collectMarqueeHits(
57
58
  const el = item.element;
58
59
  if (!isElementComputedVisible(el)) continue;
59
60
  if (coversComposition(el.getBoundingClientRect(), viewport)) continue;
60
- const overlayRect = toOverlayRect(overlayEl, iframe, el);
61
+ const overlayRect = toVisibleOverlayRect(overlayEl, iframe, el);
61
62
  if (!overlayRect) continue;
62
63
  const r: Rect = {
63
64
  left: overlayRect.left,
@@ -0,0 +1,74 @@
1
+ import type React from "react";
2
+ import type { OffCanvasRect } from "./OffCanvasIndicators";
3
+ import { hugRectForElement } from "./domEditOverlayCrop";
4
+ import { groupAwareOverlayRect } from "./domEditOverlayGeometry";
5
+ import { isElementComputedVisible } from "./domEditingElement";
6
+ import { collectDomEditLayerItems } from "./domEditingLayers";
7
+
8
+ function rounded(value: number): number {
9
+ return Math.round(value * 100) / 100;
10
+ }
11
+
12
+ function offCanvasSignature(rects: OffCanvasRect[]): string {
13
+ return rects
14
+ .map(
15
+ (rect) =>
16
+ `${rect.key}:${rounded(rect.left)},${rounded(rect.top)},${rounded(rect.width)},${rounded(rect.height)}`,
17
+ )
18
+ .join("|");
19
+ }
20
+
21
+ // fallow-ignore-next-line complexity
22
+ export function recomputeOffCanvasIndicators(
23
+ iframe: HTMLIFrameElement,
24
+ overlay: HTMLDivElement,
25
+ doc: Document | null | undefined,
26
+ comp: { left: number; top: number; width: number; height: number },
27
+ activeCompositionPath: string | null,
28
+ sigRef: React.MutableRefObject<string>,
29
+ elementsRef: React.MutableRefObject<Map<string, HTMLElement>>,
30
+ setRects: (rects: OffCanvasRect[]) => void,
31
+ ): void {
32
+ if (comp.width <= 0 || !doc) {
33
+ sigRef.current = "";
34
+ elementsRef.current = new Map();
35
+ setRects([]);
36
+ return;
37
+ }
38
+
39
+ const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
40
+ const acp = activeCompositionPath ?? "index.html";
41
+ const items = collectDomEditLayerItems(root, {
42
+ activeCompositionPath: acp,
43
+ isMasterView: !acp || acp === "index.html",
44
+ });
45
+ const rects: OffCanvasRect[] = [];
46
+ const elMap = new Map<string, HTMLElement>();
47
+ for (const item of items) {
48
+ if (!isElementComputedVisible(item.element)) continue;
49
+ // Groups use their members' union (where they actually render), so a group
50
+ // whose members sit inside the canvas isn't flagged off-canvas by a stale
51
+ // wrapper box. Crop-hug the result so an inset crop that keeps the visible
52
+ // part on-canvas doesn't flag the element either.
53
+ const base = groupAwareOverlayRect(overlay, iframe, item.element);
54
+ const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null;
55
+ if (!r) continue;
56
+ // Any edge crossing the composition border → gray-zone indicator (the
57
+ // in-canvas portion is clipped away below, so only the sliver shows).
58
+ const extendsOutsideComp =
59
+ r.left < comp.left ||
60
+ r.left + r.width > comp.left + comp.width ||
61
+ r.top < comp.top ||
62
+ r.top + r.height > comp.top + comp.height;
63
+ if (extendsOutsideComp) {
64
+ rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
65
+ elMap.set(item.key, item.element);
66
+ }
67
+ }
68
+
69
+ const nextSig = offCanvasSignature(rects);
70
+ if (nextSig === sigRef.current) return;
71
+ sigRef.current = nextSig;
72
+ elementsRef.current = elMap;
73
+ setRects(rects);
74
+ }
@@ -0,0 +1,154 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { describe, expect, it } from "vitest";
6
+ import { DomEditOverlay } from "./DomEditOverlay";
7
+
8
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
+
10
+ const INDICATOR = '[aria-label="Select off-canvas element index.html:headline:0"]';
11
+
12
+ function domRect(left: number, top: number, width: number, height: number): DOMRect {
13
+ return {
14
+ left,
15
+ top,
16
+ right: left + width,
17
+ bottom: top + height,
18
+ width,
19
+ height,
20
+ x: left,
21
+ y: top,
22
+ toJSON: () => ({}),
23
+ };
24
+ }
25
+
26
+ async function flushAnimationFrames(): Promise<void> {
27
+ await new Promise<void>((resolve) => {
28
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
29
+ });
30
+ }
31
+
32
+ interface OverlayHarness {
33
+ host: HTMLElement;
34
+ movedElement: HTMLElement;
35
+ cleanup: () => void;
36
+ }
37
+
38
+ // Mount DomEditOverlay over an iframe whose #headline sits at `initialLeft`, with a
39
+ // getBoundingClientRect stub that reads the element's live inline geometry (so a
40
+ // style mutation moves it) and reports the composition/overlay as 800x450 at origin.
41
+ function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
42
+ const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
43
+ const host = document.createElement("div");
44
+ document.body.append(host);
45
+ const root: Root = createRoot(host);
46
+ const iframe = document.createElement("iframe");
47
+ document.body.append(iframe);
48
+ const doc = iframe.contentDocument;
49
+ if (!doc) throw new Error("Expected iframe content document");
50
+
51
+ doc.body.innerHTML = `
52
+ <div data-composition-id="root" data-width="800" data-height="450">
53
+ <div id="headline" style="position:absolute; left:${initialLeft}px; top:40px; width:100px; height:40px;">Headline</div>
54
+ </div>
55
+ `;
56
+ const movedElement = doc.getElementById("headline");
57
+ if (!movedElement) throw new Error("Expected test element");
58
+
59
+ Element.prototype.getBoundingClientRect = function (): DOMRect {
60
+ if (this === movedElement) {
61
+ return domRect(
62
+ Number.parseFloat(movedElement.style.left),
63
+ Number.parseFloat(movedElement.style.top),
64
+ Number.parseFloat(movedElement.style.width),
65
+ Number.parseFloat(movedElement.style.height),
66
+ );
67
+ }
68
+ return domRect(0, 0, 800, 450);
69
+ };
70
+
71
+ act(() => {
72
+ root.render(
73
+ <DomEditOverlay
74
+ iframeRef={{ current: iframe }}
75
+ activeCompositionPath={null}
76
+ selection={null}
77
+ hoverSelection={null}
78
+ groupSelections={[]}
79
+ onCanvasMouseDown={() => {}}
80
+ onCanvasPointerMove={() => Promise.resolve(null)}
81
+ onCanvasPointerLeave={() => {}}
82
+ onSelectionChange={() => {}}
83
+ onBlockedMove={() => {}}
84
+ onPathOffsetCommit={() => {}}
85
+ onGroupPathOffsetCommit={() => {}}
86
+ onBoxSizeCommit={() => {}}
87
+ onRotationCommit={() => {}}
88
+ />,
89
+ );
90
+ });
91
+
92
+ return {
93
+ host,
94
+ movedElement,
95
+ cleanup: () => {
96
+ act(() => root.unmount());
97
+ Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
98
+ iframe.remove();
99
+ host.remove();
100
+ },
101
+ };
102
+ }
103
+
104
+ describe("off-canvas indicator refresh", () => {
105
+ it("removes the indicator when an off-canvas element moves in-canvas (off->on)", async () => {
106
+ const h = mountOverlayWithHeadline(760);
107
+ try {
108
+ await act(async () => {
109
+ await flushAnimationFrames();
110
+ });
111
+ expect(h.host.querySelector(INDICATOR)).toBeTruthy();
112
+
113
+ act(() => {
114
+ h.movedElement.style.left = "120px";
115
+ });
116
+ await act(async () => {
117
+ await Promise.resolve();
118
+ await flushAnimationFrames();
119
+ });
120
+
121
+ expect(h.host.querySelector(INDICATOR)).toBeNull();
122
+ } finally {
123
+ h.cleanup();
124
+ }
125
+ });
126
+
127
+ it("tracks the indicator to the new position when it stays off-canvas (off->off)", async () => {
128
+ const h = mountOverlayWithHeadline(760);
129
+ try {
130
+ await act(async () => {
131
+ await flushAnimationFrames();
132
+ });
133
+ const before = h.host.querySelector(INDICATOR);
134
+ expect(before).toBeTruthy();
135
+ const leftBefore = (before!.parentElement as HTMLElement).style.left;
136
+
137
+ // Move further off-canvas (still outside the 800px-wide composition).
138
+ act(() => {
139
+ h.movedElement.style.left = "1200px";
140
+ });
141
+ await act(async () => {
142
+ await Promise.resolve();
143
+ await flushAnimationFrames();
144
+ });
145
+
146
+ const after = h.host.querySelector(INDICATOR);
147
+ expect(after).toBeTruthy();
148
+ const leftAfter = (after!.parentElement as HTMLElement).style.left;
149
+ expect(leftAfter).not.toEqual(leftBefore);
150
+ } finally {
151
+ h.cleanup();
152
+ }
153
+ });
154
+ });
@@ -0,0 +1,99 @@
1
+ import type React from "react";
2
+ import type { OffCanvasRect } from "./OffCanvasIndicators";
3
+ import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry";
4
+
5
+ interface OffCanvasIndicatorRefreshOptions {
6
+ iframeRef: React.RefObject<HTMLIFrameElement | null>;
7
+ overlayRef: React.RefObject<HTMLDivElement | null>;
8
+ compRectRef: React.MutableRefObject<{ left: number; top: number; width: number; height: number }>;
9
+ activeCompositionPathRef: React.MutableRefObject<string | null>;
10
+ dirtyRef: React.MutableRefObject<boolean>;
11
+ sigRef: React.MutableRefObject<string>;
12
+ observerRef: React.MutableRefObject<MutationObserver | null>;
13
+ observedDocRef: React.MutableRefObject<Document | null>;
14
+ elementsRef: React.MutableRefObject<Map<string, HTMLElement>>;
15
+ setRects: (rects: OffCanvasRect[]) => void;
16
+ }
17
+
18
+ function compSignature(comp: { left: number; top: number; width: number; height: number }): string {
19
+ return `${Math.round(comp.left)}:${Math.round(comp.top)}:${Math.round(comp.width)}:${Math.round(comp.height)}`;
20
+ }
21
+
22
+ function clearIndicators(options: OffCanvasIndicatorRefreshOptions): void {
23
+ options.dirtyRef.current = false;
24
+ options.sigRef.current = "";
25
+ options.elementsRef.current = new Map();
26
+ options.setRects([]);
27
+ }
28
+
29
+ function observeDoc(doc: Document, markDirty: () => void): MutationObserver | null {
30
+ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
31
+ if (!Observer) return null;
32
+ const observer = new Observer(markDirty);
33
+ observer.observe(doc.documentElement, {
34
+ attributes: true,
35
+ // data-hidden is included explicitly: hiding an element writes data-hidden, and
36
+ // although the runtime honoring also writes display:"none" (a style mutation we'd
37
+ // catch anyway), keying on the attribute directly makes the coupling robust to any
38
+ // future throttling of that runtime sync.
39
+ attributeFilter: ["style", "class", "transform", "width", "height", "data-hidden"],
40
+ childList: true,
41
+ subtree: true,
42
+ });
43
+ return observer;
44
+ }
45
+
46
+ export function startOffCanvasIndicatorRefresh(
47
+ options: OffCanvasIndicatorRefreshOptions,
48
+ ): () => void {
49
+ let frame = 0;
50
+ let lastCompSig = "";
51
+ const markDirty = () => {
52
+ options.dirtyRef.current = true;
53
+ };
54
+ const attachObserver = (doc: Document | null) => {
55
+ options.observerRef.current?.disconnect();
56
+ options.observerRef.current = doc?.documentElement ? observeDoc(doc, markDirty) : null;
57
+ options.observedDocRef.current = doc;
58
+ options.sigRef.current = "";
59
+ };
60
+ const update = () => {
61
+ frame = requestAnimationFrame(update);
62
+ const iframe = options.iframeRef.current;
63
+ const overlayEl = options.overlayRef.current;
64
+ const doc = iframe?.contentDocument ?? null;
65
+ if (doc !== options.observedDocRef.current) {
66
+ attachObserver(doc);
67
+ markDirty();
68
+ }
69
+ const comp = options.compRectRef.current;
70
+ const nextCompSig = compSignature(comp);
71
+ if (nextCompSig !== lastCompSig) {
72
+ lastCompSig = nextCompSig;
73
+ markDirty();
74
+ }
75
+ if (!iframe || !overlayEl) {
76
+ if (options.dirtyRef.current) clearIndicators(options);
77
+ return;
78
+ }
79
+ if (!options.dirtyRef.current) return;
80
+ options.dirtyRef.current = false;
81
+ recomputeOffCanvasIndicators(
82
+ iframe,
83
+ overlayEl,
84
+ doc,
85
+ comp,
86
+ options.activeCompositionPathRef.current,
87
+ options.sigRef,
88
+ options.elementsRef,
89
+ options.setRects,
90
+ );
91
+ };
92
+ frame = requestAnimationFrame(update);
93
+ return () => {
94
+ cancelAnimationFrame(frame);
95
+ options.observerRef.current?.disconnect();
96
+ options.observerRef.current = null;
97
+ options.observedDocRef.current = null;
98
+ };
99
+ }
@@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
2
2
  import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
5
+ import type { TimelineElement } from "../../player";
5
6
  import { roundToCenti } from "../../utils/rounding";
6
7
 
7
8
  export type {
@@ -18,6 +19,16 @@ export function stripQueryAndHash(value: string): string {
18
19
  return value.slice(0, Math.min(queryIndex, hashIndex));
19
20
  }
20
21
 
22
+ export function isSelectedElementHidden(
23
+ elements: readonly TimelineElement[],
24
+ selectedElementId: string | null,
25
+ ): boolean {
26
+ if (!selectedElementId) return false;
27
+ return (
28
+ elements.find((element) => (element.key ?? element.id) === selectedElementId)?.hidden === true
29
+ );
30
+ }
31
+
21
32
  /* ------------------------------------------------------------------ */
22
33
  /* Font types & constants (shared by font and section modules) */
23
34
  /* ------------------------------------------------------------------ */
@@ -160,6 +171,16 @@ const BOX_SHADOW_PRESETS = {
160
171
 
161
172
  export type BoxShadowPreset = keyof typeof BOX_SHADOW_PRESETS | "custom";
162
173
 
174
+ export {
175
+ buildClipPathValue,
176
+ buildInsetClipPathSides,
177
+ buildInsetClipPathValue,
178
+ getClipPathInsetPx,
179
+ inferClipPathPreset,
180
+ parseInsetClipPathSides,
181
+ type ClipPathInsetSides,
182
+ } from "./clipPathHelpers";
183
+
163
184
  /* ------------------------------------------------------------------ */
164
185
  /* Shared types */
165
186
  /* ------------------------------------------------------------------ */
@@ -314,23 +335,6 @@ export function buildBoxShadowPresetValue(
314
335
  return BOX_SHADOW_PRESETS[preset];
315
336
  }
316
337
 
317
- export function inferClipPathPreset(
318
- value: string | undefined,
319
- ): "none" | "inset" | "circle" | "custom" {
320
- const normalized = value?.trim();
321
- if (!normalized || normalized === "none") return "none";
322
- if (/^inset\(/i.test(normalized)) return "inset";
323
- if (/^circle\(/i.test(normalized)) return "circle";
324
- return "custom";
325
- }
326
-
327
- export function getClipPathInsetPx(value: string | undefined): number {
328
- const match = /^inset\(\s*(-?\d+(?:\.\d+)?)px\b/i.exec(value?.trim() ?? "");
329
- if (!match) return 0;
330
- const parsed = Number.parseFloat(match[1]);
331
- return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
332
- }
333
-
334
338
  export function buildStrokeWidthStyleUpdates(
335
339
  nextWidth: string,
336
340
  currentBorderStyle: string | undefined,
@@ -359,23 +363,6 @@ export function buildStrokeStyleUpdates(
359
363
  return updates;
360
364
  }
361
365
 
362
- export function buildClipPathValue(
363
- preset: "none" | "inset" | "circle" | "custom",
364
- radiusValue: number,
365
- fallback: string | undefined,
366
- ) {
367
- if (preset === "custom") return fallback?.trim() || "none";
368
- if (preset === "circle") return "circle(50% at 50% 50%)";
369
- if (preset === "inset") {
370
- return `inset(0 round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
371
- }
372
- return "none";
373
- }
374
-
375
- export function buildInsetClipPathValue(insetPx: number, radiusValue: number): string {
376
- return `inset(${formatNumericValue(Math.max(0, insetPx))}px round ${formatNumericValue(Math.max(0, radiusValue))}px)`;
377
- }
378
-
379
366
  export function adjustNumericToken(
380
367
  value: string,
381
368
  direction: 1 | -1,
@@ -1,10 +1,11 @@
1
1
  import { useEffect, useState } from "react";
2
- import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
2
+ import { Eye, Layers, Palette, Scissors, Settings, Square, Zap } from "../../icons/SystemIcons";
3
3
  import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
4
4
  import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
5
5
  import {
6
6
  buildBoxShadowPresetValue,
7
7
  buildClipPathValue,
8
+ buildInsetClipPathSides,
8
9
  buildInsetClipPathValue,
9
10
  buildStrokeStyleUpdates,
10
11
  buildStrokeWidthStyleUpdates,
@@ -17,10 +18,12 @@ import {
17
18
  inferClipPathPreset,
18
19
  LABEL,
19
20
  normalizePanelPxValue,
21
+ parseInsetClipPathSides,
20
22
  parseNumericValue,
21
23
  parsePxMetricValue,
22
24
  RESPONSIVE_GRID,
23
25
  setCssFilterFunctionPx,
26
+ type ClipPathInsetSides,
24
27
  type BoxShadowPreset,
25
28
  } from "./propertyPanelHelpers";
26
29
  import {
@@ -35,6 +38,7 @@ import { ColorField } from "./propertyPanelColor";
35
38
  import { GradientField, ImageFillField } from "./propertyPanelFill";
36
39
  import { BorderRadiusEditor } from "./BorderRadiusEditor";
37
40
 
41
+ // fallow-ignore-next-line complexity
38
42
  export function StyleSections({
39
43
  projectId,
40
44
  element,
@@ -43,6 +47,8 @@ export function StyleSections({
43
47
  onSetStyle,
44
48
  onImportAssets,
45
49
  gsapBorderRadius,
50
+ cropMode = false,
51
+ onCropModeChange,
46
52
  }: {
47
53
  projectId: string;
48
54
  element: DomEditSelection;
@@ -51,6 +57,8 @@ export function StyleSections({
51
57
  onSetStyle: (prop: string, value: string) => void | Promise<void>;
52
58
  onImportAssets?: (files: FileList) => Promise<string[]>;
53
59
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
60
+ cropMode?: boolean;
61
+ onCropModeChange?: (active: boolean) => void;
54
62
  }) {
55
63
  const styleEditingDisabled = !element.capabilities.canEditStyles;
56
64
  const isFlex = styles.display === "flex" || styles.display === "inline-flex";
@@ -86,7 +94,16 @@ export function StyleSections({
86
94
  const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
87
95
  const clipPathValue = styles["clip-path"] || "none";
88
96
  const clipPathPreset = inferClipPathPreset(clipPathValue);
97
+ const parsedClipInsets = parseInsetClipPathSides(clipPathValue);
89
98
  const clipInsetValue = getClipPathInsetPx(clipPathValue);
99
+ const clipInsetSides = parsedClipInsets ?? {
100
+ top: clipInsetValue,
101
+ right: clipInsetValue,
102
+ bottom: clipInsetValue,
103
+ left: clipInsetValue,
104
+ radius: radiusValue,
105
+ };
106
+ const showClipInsetSides = clipPathPreset === "inset" || parsedClipInsets != null;
90
107
  const backgroundImage = styles["background-image"] ?? "none";
91
108
  const hasTextControls = isTextEditableSelection(element);
92
109
 
@@ -117,6 +134,19 @@ export function StyleSections({
117
134
  }
118
135
  };
119
136
 
137
+ const commitClipInsetSide = (side: keyof ClipPathInsetSides, nextValue: string) => {
138
+ const next = parsePxMetricValue(nextValue);
139
+ if (next == null) return;
140
+ const sides: ClipPathInsetSides = {
141
+ top: clipInsetSides.top,
142
+ right: clipInsetSides.right,
143
+ bottom: clipInsetSides.bottom,
144
+ left: clipInsetSides.left,
145
+ };
146
+ sides[side] = next;
147
+ onSetStyle("clip-path", buildInsetClipPathSides(sides, clipInsetSides.radius));
148
+ };
149
+
120
150
  return (
121
151
  <>
122
152
  {isFlex && (
@@ -344,6 +374,50 @@ export function StyleSections({
344
374
  }
345
375
  />
346
376
  </div>
377
+ {showClipInsetSides && (
378
+ <div className="grid gap-2">
379
+ <div className="grid grid-cols-4 gap-2">
380
+ <MetricField
381
+ label="T"
382
+ value={formatPxMetricValue(clipInsetSides.top)}
383
+ disabled={styleEditingDisabled}
384
+ onCommit={(next) => commitClipInsetSide("top", next)}
385
+ />
386
+ <MetricField
387
+ label="R"
388
+ value={formatPxMetricValue(clipInsetSides.right)}
389
+ disabled={styleEditingDisabled}
390
+ onCommit={(next) => commitClipInsetSide("right", next)}
391
+ />
392
+ <MetricField
393
+ label="B"
394
+ value={formatPxMetricValue(clipInsetSides.bottom)}
395
+ disabled={styleEditingDisabled}
396
+ onCommit={(next) => commitClipInsetSide("bottom", next)}
397
+ />
398
+ <MetricField
399
+ label="L"
400
+ value={formatPxMetricValue(clipInsetSides.left)}
401
+ disabled={styleEditingDisabled}
402
+ onCommit={(next) => commitClipInsetSide("left", next)}
403
+ />
404
+ </div>
405
+ </div>
406
+ )}
407
+ <button
408
+ type="button"
409
+ disabled={styleEditingDisabled || !onCropModeChange}
410
+ aria-pressed={cropMode}
411
+ onClick={() => onCropModeChange?.(!cropMode)}
412
+ className={`inline-flex h-8 w-fit items-center gap-1.5 rounded-md border px-2 text-[11px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
413
+ cropMode
414
+ ? "border-studio-accent/60 bg-studio-accent/15 text-studio-accent"
415
+ : "border-panel-border bg-panel-input text-panel-text-2 hover:text-white"
416
+ }`}
417
+ >
418
+ <Scissors size={13} />
419
+ Crop
420
+ </button>
347
421
  </div>
348
422
  </Section>
349
423
 
@@ -52,6 +52,7 @@ export interface PropertyPanelProps {
52
52
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
53
53
  onRemoveTextField: (fieldKey: string) => void;
54
54
  onAskAgent: () => void;
55
+ onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
55
56
  onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
56
57
  fontAssets?: ImportedFontAsset[];
57
58
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
@@ -108,4 +109,6 @@ export interface PropertyPanelProps {
108
109
  recordingState?: "idle" | "recording" | "preview";
109
110
  recordingDuration?: number;
110
111
  onToggleRecording?: () => void;
112
+ cropMode?: boolean;
113
+ onCropModeChange?: (active: boolean) => void;
111
114
  }
@@ -1,9 +1,8 @@
1
- // fallow-ignore-file unused-file
2
1
  // fallow-ignore-file code-duplication
3
2
  import type { DomEditSelection } from "./domEditing";
4
3
  import {
5
4
  isElementVisibleForOverlay,
6
- toOverlayRect,
5
+ toVisibleOverlayRect,
7
6
  type OverlayRect,
8
7
  } from "./domEditOverlayGeometry";
9
8
  import {
@@ -106,7 +105,7 @@ export function collectSnapContext(input: {
106
105
  id: string;
107
106
  }> = [];
108
107
  for (let i = 0; i < elements.length; i++) {
109
- const rect = toOverlayRect(input.overlayEl, input.iframe, elements[i]);
108
+ const rect = toVisibleOverlayRect(input.overlayEl, input.iframe, elements[i]);
110
109
  if (rect) entries.push({ rect, id: `snap-target-${i}` });
111
110
  }
112
111