@hyperframes/studio 0.7.39 → 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-312a3Ceu.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-B9YvRJz1.js → index-B2Utv-2b.js} +195 -195
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-CQHiecE7.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
- package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
- package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +34 -29
- package/dist/index.html +2 -2
- package/dist/index.js +2749 -2075
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -1
- package/src/components/StudioPreviewArea.tsx +6 -0
- package/src/components/StudioRightPanel.tsx +3 -0
- package/src/components/editor/DomEditOverlay.tsx +33 -47
- package/src/components/editor/PropertyPanel.test.ts +25 -0
- package/src/components/editor/PropertyPanel.tsx +33 -7
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- 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 +11 -0
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/contexts/TimelineEditContext.tsx +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/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/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 +5 -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-0P10SwC_.css +0 -1
- /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.40",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"gsap": "^3.13.0",
|
|
47
47
|
"marked": "^14.1.4",
|
|
48
48
|
"mediabunny": "^1.45.3",
|
|
49
|
-
"@hyperframes/
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/sdk": "0.7.
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
49
|
+
"@hyperframes/parsers": "0.7.40",
|
|
50
|
+
"@hyperframes/studio-server": "0.7.40",
|
|
51
|
+
"@hyperframes/sdk": "0.7.40",
|
|
52
|
+
"@hyperframes/core": "0.7.40",
|
|
53
|
+
"@hyperframes/player": "0.7.40"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/react": "19",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"vite": "^6.4.2",
|
|
66
66
|
"vitest": "^3.2.4",
|
|
67
67
|
"zustand": "^5.0.0",
|
|
68
|
-
"@hyperframes/producer": "0.7.
|
|
68
|
+
"@hyperframes/producer": "0.7.40"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -360,7 +360,6 @@ export function StudioApp() {
|
|
|
360
360
|
resetErrors: resetConsoleErrors,
|
|
361
361
|
} = useConsoleErrorCapture(previewIframe);
|
|
362
362
|
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
|
|
363
|
-
// Gesture recording
|
|
364
363
|
const handleToggleRecordingRef = useRef<() => void>(() => {});
|
|
365
364
|
const domEditSessionRef = useRef(domEditSession);
|
|
366
365
|
domEditSessionRef.current = domEditSession;
|
|
@@ -531,6 +530,7 @@ export function StudioApp() {
|
|
|
531
530
|
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
|
|
532
531
|
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
|
|
533
532
|
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
|
|
533
|
+
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
|
|
534
534
|
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
|
|
535
535
|
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
|
536
536
|
handleRazorSplit={timelineEditing.handleRazorSplit}
|
|
@@ -572,6 +572,7 @@ export function StudioApp() {
|
|
|
572
572
|
domEditSaveTimestampRef={domEditSaveTimestampRef}
|
|
573
573
|
recordEdit={editHistory.recordEdit}
|
|
574
574
|
{...cropModeProps}
|
|
575
|
+
onToggleElementHidden={timelineEditing.handleToggleElementHidden}
|
|
575
576
|
/>
|
|
576
577
|
)}
|
|
577
578
|
</div>
|
|
@@ -58,6 +58,7 @@ export interface StudioPreviewAreaProps {
|
|
|
58
58
|
element: TimelineElement,
|
|
59
59
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
60
60
|
) => Promise<void> | void;
|
|
61
|
+
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
|
|
61
62
|
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
|
|
62
63
|
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
63
64
|
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
|
|
@@ -85,6 +86,7 @@ export function StudioPreviewArea({
|
|
|
85
86
|
handleTimelineFileDrop,
|
|
86
87
|
handleTimelineElementMove,
|
|
87
88
|
handleTimelineElementResize,
|
|
89
|
+
handleToggleTrackHidden,
|
|
88
90
|
handleBlockedTimelineEdit,
|
|
89
91
|
handleTimelineElementSplit,
|
|
90
92
|
handleRazorSplit,
|
|
@@ -165,6 +167,7 @@ export function StudioPreviewArea({
|
|
|
165
167
|
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
|
|
166
168
|
// anim in the keyframe's property group, falling back to the first keyframed one.
|
|
167
169
|
const resolveKeyframeTarget = useCallback(
|
|
170
|
+
// fallow-ignore-next-line complexity
|
|
168
171
|
(pct: number): { animId: string; tweenPct: number } | null => {
|
|
169
172
|
const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? "");
|
|
170
173
|
const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
|
|
@@ -182,6 +185,7 @@ export function StudioPreviewArea({
|
|
|
182
185
|
() => ({
|
|
183
186
|
onMoveElement: handleTimelineElementMove,
|
|
184
187
|
onResizeElement: handleTimelineElementResize,
|
|
188
|
+
onToggleTrackHidden: handleToggleTrackHidden,
|
|
185
189
|
onBlockedEditAttempt: handleBlockedTimelineEdit,
|
|
186
190
|
onSplitElement: handleTimelineElementSplit,
|
|
187
191
|
onRazorSplit: handleRazorSplit,
|
|
@@ -210,6 +214,7 @@ export function StudioPreviewArea({
|
|
|
210
214
|
// drop past the boundary (last keyframe past the end, first before the start)
|
|
211
215
|
// resizes the tween — position/duration grow so the dragged keyframe lands at
|
|
212
216
|
// the drop while every other keyframe keeps its absolute time (value+ease too).
|
|
217
|
+
// fallow-ignore-next-line complexity
|
|
213
218
|
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
|
|
214
219
|
const target = resolveKeyframeTarget(fromClipPct);
|
|
215
220
|
const sel = domEditSelection;
|
|
@@ -280,6 +285,7 @@ export function StudioPreviewArea({
|
|
|
280
285
|
[
|
|
281
286
|
handleTimelineElementMove,
|
|
282
287
|
handleTimelineElementResize,
|
|
288
|
+
handleToggleTrackHidden,
|
|
283
289
|
handleBlockedTimelineEdit,
|
|
284
290
|
handleTimelineElementSplit,
|
|
285
291
|
handleRazorSplit,
|
|
@@ -62,6 +62,7 @@ export interface StudioRightPanelProps {
|
|
|
62
62
|
kind: EditHistoryKind;
|
|
63
63
|
files: Record<string, { before: string; after: string }>;
|
|
64
64
|
}) => Promise<void>;
|
|
65
|
+
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// fallow-ignore-next-line complexity
|
|
@@ -78,6 +79,7 @@ export function StudioRightPanel({
|
|
|
78
79
|
reloadPreview,
|
|
79
80
|
domEditSaveTimestampRef,
|
|
80
81
|
recordEdit,
|
|
82
|
+
onToggleElementHidden,
|
|
81
83
|
}: StudioRightPanelProps) {
|
|
82
84
|
const {
|
|
83
85
|
rightWidth,
|
|
@@ -350,6 +352,7 @@ export function StudioRightPanel({
|
|
|
350
352
|
multiSelectCount={domEditGroupSelections.length}
|
|
351
353
|
copiedAgentPrompt={copiedAgentPrompt}
|
|
352
354
|
onClearSelection={clearDomSelection}
|
|
355
|
+
onToggleElementHidden={onToggleElementHidden}
|
|
353
356
|
onUngroup={handleUngroupSelection}
|
|
354
357
|
onSetStyle={handleDomStyleCommit}
|
|
355
358
|
onSetAttribute={handleDomAttributeCommit}
|
|
@@ -3,9 +3,7 @@ import { type DomEditSelection } from "./domEditing";
|
|
|
3
3
|
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
|
4
4
|
import { useMarqueeGestures } from "./marqueeCommit";
|
|
5
5
|
import { MarqueeOverlay } from "./MarqueeOverlay";
|
|
6
|
-
import {
|
|
7
|
-
import { collectDomEditLayerItems } from "./domEditingLayers";
|
|
8
|
-
import { isElementComputedVisible } from "./domEditingElement";
|
|
6
|
+
import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
|
|
9
7
|
import {
|
|
10
8
|
type BlockedMoveState,
|
|
11
9
|
type DomEditGroupPathOffsetCommit,
|
|
@@ -26,6 +24,8 @@ import { hugRectForElement } from "./domEditOverlayCrop";
|
|
|
26
24
|
import { useCropOverlay } from "../../hooks/useCropMode";
|
|
27
25
|
import { readDomEditSelectionShapeStyles, resolveBoxChromeClass } from "./domEditOverlayShape";
|
|
28
26
|
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
27
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
28
|
+
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
|
29
29
|
|
|
30
30
|
// Re-exports for external consumers — preserving existing import paths.
|
|
31
31
|
export {
|
|
@@ -173,6 +173,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
const compRect = useDomEditCompositionRect({ iframeRef, overlayRef });
|
|
176
|
+
const compRectRef = useRef(compRect);
|
|
177
|
+
compRectRef.current = compRect;
|
|
176
178
|
|
|
177
179
|
const { hasCropInsets, cropOutlineInsetPx } = useCropOverlay({
|
|
178
180
|
selection,
|
|
@@ -189,51 +191,35 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
189
191
|
// outside the composition bounds so users can find them.
|
|
190
192
|
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
191
193
|
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
|
|
192
|
-
|
|
194
|
+
const offCanvasDirtyRef = useRef(true);
|
|
195
|
+
const offCanvasSigRef = useRef("");
|
|
196
|
+
const offCanvasObserverRef = useRef<MutationObserver | null>(null);
|
|
197
|
+
const offCanvasObservedDocRef = useRef<Document | null>(null);
|
|
198
|
+
|
|
199
|
+
// Positions depend on live iframe layout, not selection — the selected-element
|
|
200
|
+
// suppression is a render-time filter, so selection/groupSelections stay out
|
|
201
|
+
// of the geometry walk.
|
|
202
|
+
useMountEffect(() =>
|
|
203
|
+
startOffCanvasIndicatorRefresh({
|
|
204
|
+
iframeRef,
|
|
205
|
+
overlayRef,
|
|
206
|
+
compRectRef,
|
|
207
|
+
activeCompositionPathRef,
|
|
208
|
+
dirtyRef: offCanvasDirtyRef,
|
|
209
|
+
sigRef: offCanvasSigRef,
|
|
210
|
+
observerRef: offCanvasObserverRef,
|
|
211
|
+
observedDocRef: offCanvasObservedDocRef,
|
|
212
|
+
elementsRef: offCanvasElementsRef,
|
|
213
|
+
setRects: setOffCanvasRects,
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Switching compositions may not swap the iframe document (so the observer's
|
|
218
|
+
// doc-swap detection wouldn't fire) yet changes which elements are off-canvas.
|
|
219
|
+
// Force a recompute explicitly on comp change.
|
|
193
220
|
useEffect(() => {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (!iframe || !overlay || compRect.width <= 0) {
|
|
197
|
-
setOffCanvasRects([]);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
const doc = iframe.contentDocument;
|
|
201
|
-
if (!doc) return;
|
|
202
|
-
const root = doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.body;
|
|
203
|
-
const acp = activeCompositionPath ?? "index.html";
|
|
204
|
-
const items = collectDomEditLayerItems(root, {
|
|
205
|
-
activeCompositionPath: acp,
|
|
206
|
-
isMasterView: !acp || acp === "index.html",
|
|
207
|
-
});
|
|
208
|
-
const rects: typeof offCanvasRects = [];
|
|
209
|
-
const elMap = new Map<string, HTMLElement>();
|
|
210
|
-
for (const item of items) {
|
|
211
|
-
if (!isElementComputedVisible(item.element)) continue;
|
|
212
|
-
// Groups use their members' union (where they actually render), so a group
|
|
213
|
-
// whose members sit inside the canvas isn't flagged off-canvas by a stale
|
|
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;
|
|
218
|
-
if (!r) continue;
|
|
219
|
-
// Any edge crossing the composition border → gray-zone indicator (the
|
|
220
|
-
// in-canvas portion is clipped away below, so only the sliver shows).
|
|
221
|
-
const extendsOutsideComp =
|
|
222
|
-
r.left < compRect.left ||
|
|
223
|
-
r.left + r.width > compRect.left + compRect.width ||
|
|
224
|
-
r.top < compRect.top ||
|
|
225
|
-
r.top + r.height > compRect.top + compRect.height;
|
|
226
|
-
if (extendsOutsideComp) {
|
|
227
|
-
rects.push({ key: item.key, left: r.left, top: r.top, width: r.width, height: r.height });
|
|
228
|
-
elMap.set(item.key, item.element);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
offCanvasElementsRef.current = elMap;
|
|
232
|
-
setOffCanvasRects(rects);
|
|
233
|
-
// Positions depend on layout, not selection — the selected-element
|
|
234
|
-
// suppression is a render-time filter, so selection/groupSelections stay
|
|
235
|
-
// out of the deps to avoid re-walking geometry on each selection change.
|
|
236
|
-
}, [iframeRef, compRect, activeCompositionPath]);
|
|
221
|
+
offCanvasDirtyRef.current = true;
|
|
222
|
+
}, [activeCompositionPath]);
|
|
237
223
|
|
|
238
224
|
const gestures = createDomEditOverlayGestureHandlers({
|
|
239
225
|
overlayRef,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../../player";
|
|
2
3
|
import {
|
|
3
4
|
buildInsetClipPathSides,
|
|
4
5
|
buildStrokeStyleUpdates,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
parseInsetClipPathSides,
|
|
12
13
|
setCssFilterFunctionPx,
|
|
13
14
|
} from "./PropertyPanel";
|
|
15
|
+
import { isSelectedElementHidden } from "./propertyPanelHelpers";
|
|
14
16
|
|
|
15
17
|
describe("PropertyPanel style helpers", () => {
|
|
16
18
|
it("normalizes bounded pixel values without accepting incompatible units", () => {
|
|
@@ -113,3 +115,26 @@ describe("PropertyPanel style helpers", () => {
|
|
|
113
115
|
expect(buildStrokeStyleUpdates("solid", "4px")).toEqual([["border-style", "solid"]]);
|
|
114
116
|
});
|
|
115
117
|
});
|
|
118
|
+
|
|
119
|
+
describe("isSelectedElementHidden", () => {
|
|
120
|
+
it("reads hidden state by selected timeline id or key", () => {
|
|
121
|
+
const elements: TimelineElement[] = [
|
|
122
|
+
{ id: "visible", tag: "div", start: 0, duration: 1, track: 0 },
|
|
123
|
+
{ id: "hidden", tag: "div", start: 0, duration: 1, track: 0, hidden: true },
|
|
124
|
+
{
|
|
125
|
+
id: "keyed-hidden",
|
|
126
|
+
key: "scene.html:#keyed-hidden",
|
|
127
|
+
tag: "div",
|
|
128
|
+
start: 0,
|
|
129
|
+
duration: 1,
|
|
130
|
+
track: 0,
|
|
131
|
+
hidden: true,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
expect(isSelectedElementHidden(elements, null)).toBe(false);
|
|
136
|
+
expect(isSelectedElementHidden(elements, "visible")).toBe(false);
|
|
137
|
+
expect(isSelectedElementHidden(elements, "hidden")).toBe(true);
|
|
138
|
+
expect(isSelectedElementHidden(elements, "scene.html:#keyed-hidden")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { Move } from "../../icons/SystemIcons";
|
|
3
|
+
import { Eye, EyeSlash } from "@phosphor-icons/react";
|
|
3
4
|
import { InspectorHeaderActions } from "./InspectorHeaderActions";
|
|
4
5
|
import { useStudioShellContext } from "../../contexts/StudioContext";
|
|
5
6
|
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
RESPONSIVE_GRID,
|
|
11
12
|
readGsapRuntimeValuesForPanel,
|
|
12
13
|
readGsapBorderRadiusForPanel,
|
|
14
|
+
isSelectedElementHidden,
|
|
13
15
|
} from "./propertyPanelHelpers";
|
|
14
16
|
import { MetricField, Section } from "./propertyPanelPrimitives";
|
|
15
17
|
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
|
@@ -67,6 +69,7 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
67
69
|
onAddTextField,
|
|
68
70
|
onRemoveTextField,
|
|
69
71
|
onAskAgent: _onAskAgent,
|
|
72
|
+
onToggleElementHidden,
|
|
70
73
|
onImportAssets,
|
|
71
74
|
fontAssets = [],
|
|
72
75
|
onImportFonts,
|
|
@@ -106,6 +109,10 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
106
109
|
const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
107
110
|
const storeTime = usePlayerStore((s) => s.currentTime);
|
|
108
111
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
112
|
+
const timelineElements = usePlayerStore((s) => s.elements);
|
|
113
|
+
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
|
114
|
+
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
|
|
115
|
+
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
|
|
109
116
|
const liveTimeRef = useRef(storeTime);
|
|
110
117
|
const [, forceRender] = useState(0);
|
|
111
118
|
useEffect(() => {
|
|
@@ -288,13 +295,32 @@ export const PropertyPanel = memo(function PropertyPanel({
|
|
|
288
295
|
</div>
|
|
289
296
|
<div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
|
|
290
297
|
</div>
|
|
291
|
-
<
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
+
<div className="flex items-center gap-1">
|
|
299
|
+
{selectedElementId && onToggleElementHidden && (
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
aria-label={visibilityToggleLabel}
|
|
303
|
+
title={visibilityToggleLabel}
|
|
304
|
+
onClick={() => {
|
|
305
|
+
void onToggleElementHidden(selectedElementId, !selectedElementHidden);
|
|
306
|
+
}}
|
|
307
|
+
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
|
|
308
|
+
>
|
|
309
|
+
{selectedElementHidden ? (
|
|
310
|
+
<EyeSlash size={13} weight="bold" aria-hidden="true" />
|
|
311
|
+
) : (
|
|
312
|
+
<Eye size={13} weight="bold" aria-hidden="true" />
|
|
313
|
+
)}
|
|
314
|
+
</button>
|
|
315
|
+
)}
|
|
316
|
+
<InspectorHeaderActions
|
|
317
|
+
element={element}
|
|
318
|
+
copied={clipboardCopied}
|
|
319
|
+
onCopy={handleCopyElementInfo}
|
|
320
|
+
onClear={onClearSelection}
|
|
321
|
+
onUngroup={onUngroup}
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
298
324
|
</div>
|
|
299
325
|
</div>
|
|
300
326
|
<div className="flex-1 overflow-y-auto">
|
|
@@ -28,6 +28,7 @@ export function isElementComputedVisible(el: HTMLElement): boolean {
|
|
|
28
28
|
|
|
29
29
|
const VISUAL_LEAF_TAGS = new Set(["img", "video", "canvas", "svg", "audio"]);
|
|
30
30
|
|
|
31
|
+
// fallow-ignore-next-line complexity
|
|
31
32
|
function hasVisualPresence(el: HTMLElement): boolean {
|
|
32
33
|
const win = el.ownerDocument.defaultView;
|
|
33
34
|
if (!win) return false;
|
|
@@ -236,9 +237,13 @@ export function isLargeRasterDomEditSelection(
|
|
|
236
237
|
|
|
237
238
|
// ─── Element finders ──────────────────────────────────────────────────────────
|
|
238
239
|
|
|
240
|
+
type FindElementSelection = Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex"> & {
|
|
241
|
+
sourceFile?: string;
|
|
242
|
+
};
|
|
243
|
+
|
|
239
244
|
export function findElementForSelection(
|
|
240
245
|
doc: Document,
|
|
241
|
-
selection:
|
|
246
|
+
selection: FindElementSelection,
|
|
242
247
|
activeCompositionPath: string | null = null,
|
|
243
248
|
): HTMLElement | null {
|
|
244
249
|
if (selection.hfId) {
|
|
@@ -259,6 +264,7 @@ export function findElementForSelection(
|
|
|
259
264
|
|
|
260
265
|
if (!selection.selector) return null;
|
|
261
266
|
|
|
267
|
+
// fallow-ignore-next-line code-duplication
|
|
262
268
|
if (selection.selector.startsWith(".") && selection.selectorIndex != null) {
|
|
263
269
|
const matches = querySelectorAllSafely(doc, selection.selector).filter(
|
|
264
270
|
(candidate): candidate is HTMLElement =>
|
|
@@ -270,6 +276,7 @@ export function findElementForSelection(
|
|
|
270
276
|
return matches[selection.selectorIndex] ?? null;
|
|
271
277
|
}
|
|
272
278
|
|
|
279
|
+
// fallow-ignore-next-line code-duplication
|
|
273
280
|
const matches = querySelectorAllSafely(doc, selection.selector).filter(
|
|
274
281
|
(candidate): candidate is HTMLElement =>
|
|
275
282
|
isHtmlElement(candidate) &&
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
});
|