@hyperframes/studio 0.7.38 → 0.7.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
- package/dist/chunk-BA66NM4L.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +47 -29
- package/dist/index.html +2 -2
- package/dist/index.js +4432 -3045
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -7
- package/src/components/StudioPreviewArea.tsx +14 -0
- package/src/components/StudioRightPanel.tsx +9 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +140 -151
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +73 -0
- package/src/components/editor/PropertyPanel.tsx +42 -37
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +21 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +3 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +19 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|