@hyperframes/studio 0.6.2 → 0.6.4
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-DMJCfYoN.css +1 -0
- package/dist/assets/index-DsnMQhJc.js +117 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +14 -18
- package/src/components/StudioRightPanel.tsx +15 -1
- package/src/components/editor/DomEditOverlay.test.ts +165 -1
- package/src/components/editor/DomEditOverlay.tsx +0 -1
- package/src/components/editor/LayersPanel.tsx +289 -0
- package/src/components/editor/SourceEditor.tsx +20 -3
- package/src/components/editor/propertyPanelPrimitives.tsx +24 -5
- package/src/components/editor/propertyPanelStyleSections.tsx +6 -6
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -10
- package/src/components/nle/NLELayout.tsx +4 -2
- package/src/components/nle/NLEPreview.tsx +250 -30
- package/src/components/nle/previewZoom.test.ts +118 -0
- package/src/components/nle/previewZoom.ts +84 -0
- package/src/components/renders/RenderQueue.tsx +2 -2
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/hooks/useConsoleErrorCapture.ts +3 -3
- package/src/hooks/useDomEditSession.ts +1 -0
- package/src/hooks/useDomSelection.ts +27 -6
- package/src/hooks/usePanelLayout.ts +8 -2
- package/src/player/hooks/useTimelinePlayer.ts +11 -1
- package/src/player/store/playerStore.ts +18 -2
- package/src/utils/studioHelpers.ts +1 -1
- package/src/utils/studioUiPreferences.test.ts +49 -0
- package/src/utils/studioUiPreferences.ts +84 -0
- package/dist/assets/index-D1JDq7Gg.css +0 -1
- package/dist/assets/index-hYc4aP7M.js +0 -117
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-DsnMQhJc.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DMJCfYoN.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/core": "0.6.
|
|
36
|
-
"@hyperframes/player": "0.6.
|
|
35
|
+
"@hyperframes/core": "0.6.4",
|
|
36
|
+
"@hyperframes/player": "0.6.4"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "19",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.6.
|
|
50
|
+
"@hyperframes/producer": "0.6.4"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { useManifestPersistence } from "./hooks/useManifestPersistence";
|
|
|
13
13
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
14
14
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
|
15
15
|
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
|
16
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "./utils/studioUiPreferences";
|
|
16
17
|
import { useCaptionDetection } from "./hooks/useCaptionDetection";
|
|
17
18
|
import { useRenderClipContent } from "./hooks/useRenderClipContent";
|
|
18
19
|
import { useConsoleErrorCapture } from "./hooks/useConsoleErrorCapture";
|
|
@@ -26,7 +27,6 @@ import {
|
|
|
26
27
|
STUDIO_MOTION_PANEL_ENABLED,
|
|
27
28
|
} from "./components/editor/manualEditingAvailability";
|
|
28
29
|
import { getStudioMotionForSelection } from "./components/editor/studioMotion";
|
|
29
|
-
import { getTimelineElementKey, isTimelineElementActiveAtTime } from "./utils/timelineInspector";
|
|
30
30
|
import type { DomEditSelection } from "./components/editor/domEditing";
|
|
31
31
|
import { AskAgentModal } from "./components/AskAgentModal";
|
|
32
32
|
import { StudioHeader } from "./components/StudioHeader";
|
|
@@ -79,7 +79,6 @@ export function StudioApp() {
|
|
|
79
79
|
const captionSync = useCaptionSync(projectId);
|
|
80
80
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
81
81
|
const timelineElements = usePlayerStore((s) => s.elements);
|
|
82
|
-
const selectedTimelineElementId = usePlayerStore((s) => s.selectedElementId);
|
|
83
82
|
const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId);
|
|
84
83
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
85
84
|
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
@@ -100,8 +99,15 @@ export function StudioApp() {
|
|
|
100
99
|
window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300);
|
|
101
100
|
}, []);
|
|
102
101
|
|
|
103
|
-
const [timelineVisible, setTimelineVisible] = useState(
|
|
104
|
-
|
|
102
|
+
const [timelineVisible, setTimelineVisible] = useState(
|
|
103
|
+
() => readStudioUiPreferences().timelineVisible ?? true,
|
|
104
|
+
);
|
|
105
|
+
const toggleTimelineVisibility = useCallback(() => {
|
|
106
|
+
setTimelineVisible((v) => {
|
|
107
|
+
writeStudioUiPreferences({ timelineVisible: !v });
|
|
108
|
+
return !v;
|
|
109
|
+
});
|
|
110
|
+
}, []);
|
|
105
111
|
const { appToast, showToast } = useToast();
|
|
106
112
|
const panelLayout = usePanelLayout();
|
|
107
113
|
const editHistory = usePersistentEditHistory({ projectId });
|
|
@@ -284,27 +290,17 @@ export function StudioApp() {
|
|
|
284
290
|
domEditSession.domEditSelection,
|
|
285
291
|
)
|
|
286
292
|
: null;
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
selectedTimelineElementId
|
|
290
|
-
? (timelineElements.find((el) => getTimelineElementKey(el) === selectedTimelineElementId) ??
|
|
291
|
-
null)
|
|
292
|
-
: null,
|
|
293
|
-
[selectedTimelineElementId, timelineElements],
|
|
294
|
-
);
|
|
293
|
+
const layersPanelActive =
|
|
294
|
+
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers";
|
|
295
295
|
const designPanelActive =
|
|
296
296
|
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "design";
|
|
297
297
|
const motionPanelActive =
|
|
298
298
|
STUDIO_INSPECTOR_PANELS_ENABLED &&
|
|
299
299
|
STUDIO_MOTION_PANEL_ENABLED &&
|
|
300
300
|
panelLayout.rightPanelTab === "motion";
|
|
301
|
-
const inspectorPanelActive = designPanelActive || motionPanelActive;
|
|
301
|
+
const inspectorPanelActive = layersPanelActive || designPanelActive || motionPanelActive;
|
|
302
302
|
const shouldShowSelectedDomBounds =
|
|
303
|
-
inspectorPanelActive &&
|
|
304
|
-
!panelLayout.rightCollapsed &&
|
|
305
|
-
!isPlaying &&
|
|
306
|
-
(!selectedTimelineElement ||
|
|
307
|
-
isTimelineElementActiveAtTime(currentTime, selectedTimelineElement));
|
|
303
|
+
inspectorPanelActive && !panelLayout.rightCollapsed && !isPlaying;
|
|
308
304
|
const inspectorButtonActive =
|
|
309
305
|
STUDIO_INSPECTOR_PANELS_ENABLED && !panelLayout.rightCollapsed && inspectorPanelActive;
|
|
310
306
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PropertyPanel } from "./editor/PropertyPanel";
|
|
2
2
|
import { MotionPanel } from "./editor/MotionPanel";
|
|
3
|
+
import { LayersPanel } from "./editor/LayersPanel";
|
|
3
4
|
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
|
|
4
5
|
import { RenderQueue } from "./renders/RenderQueue";
|
|
5
6
|
import type { RenderJob } from "./renders/useRenderQueue";
|
|
@@ -115,6 +116,17 @@ export function StudioRightPanel({
|
|
|
115
116
|
>
|
|
116
117
|
Design
|
|
117
118
|
</button>
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={() => setRightPanelTab("layers")}
|
|
122
|
+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
|
123
|
+
rightPanelTab === "layers"
|
|
124
|
+
? "bg-neutral-800 text-white"
|
|
125
|
+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
|
126
|
+
}`}
|
|
127
|
+
>
|
|
128
|
+
Layers
|
|
129
|
+
</button>
|
|
118
130
|
{STUDIO_MOTION_PANEL_ENABLED && (
|
|
119
131
|
<button
|
|
120
132
|
type="button"
|
|
@@ -143,7 +155,9 @@ export function StudioRightPanel({
|
|
|
143
155
|
</button>
|
|
144
156
|
</div>
|
|
145
157
|
<div className="min-h-0 flex-1">
|
|
146
|
-
{
|
|
158
|
+
{rightPanelTab === "layers" ? (
|
|
159
|
+
<LayersPanel />
|
|
160
|
+
) : designPanelActive ? (
|
|
147
161
|
<PropertyPanel
|
|
148
162
|
projectId={projectId}
|
|
149
163
|
assets={assets}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
6
|
import { Window } from "happy-dom";
|
|
3
7
|
import {
|
|
8
|
+
DomEditOverlay,
|
|
4
9
|
filterNestedDomEditGroupItems,
|
|
5
10
|
focusDomEditOverlayElement,
|
|
6
11
|
hasDomEditRotationChanged,
|
|
@@ -9,6 +14,75 @@ import {
|
|
|
9
14
|
resolveDomEditResizeGesture,
|
|
10
15
|
resolveDomEditRotationGesture,
|
|
11
16
|
} from "./DomEditOverlay";
|
|
17
|
+
import type { DomEditSelection } from "./domEditing";
|
|
18
|
+
|
|
19
|
+
// React 19 warns unless the test environment opts into act().
|
|
20
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
|
|
22
|
+
vi.mock("./useDomEditOverlayGestures", () => ({
|
|
23
|
+
createDomEditOverlayGestureHandlers: () => ({
|
|
24
|
+
startGesture: () => true,
|
|
25
|
+
startGroupDrag: () => {},
|
|
26
|
+
onPointerMove: () => {},
|
|
27
|
+
onPointerUp: () => {},
|
|
28
|
+
clearPointerState: () => {},
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("./useDomEditOverlayRects", async () => {
|
|
33
|
+
const React = await import("react");
|
|
34
|
+
const { rectsEqual } = await import("./domEditOverlayGeometry");
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
useDomEditOverlayRects: () => {
|
|
38
|
+
const [overlayRect, setOverlayRectState] = React.useState(null);
|
|
39
|
+
const overlayRectRef = React.useRef(null);
|
|
40
|
+
const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]);
|
|
41
|
+
const groupOverlayItemsRef = React.useRef([]);
|
|
42
|
+
|
|
43
|
+
const setOverlayRect = (next: unknown) => {
|
|
44
|
+
if (rectsEqual(overlayRectRef.current, next)) return;
|
|
45
|
+
overlayRectRef.current = next;
|
|
46
|
+
setOverlayRectState(next);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const setGroupOverlayItems = (next: unknown[]) => {
|
|
50
|
+
groupOverlayItemsRef.current = next;
|
|
51
|
+
setGroupOverlayItemsState(next);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
overlayRect,
|
|
56
|
+
overlayRectRef,
|
|
57
|
+
setOverlayRect,
|
|
58
|
+
hoverRect: null,
|
|
59
|
+
hoverRectRef: { current: null },
|
|
60
|
+
setHoverRect: () => {},
|
|
61
|
+
groupOverlayItems,
|
|
62
|
+
groupOverlayItemsRef,
|
|
63
|
+
setGroupOverlayItems,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
vi.mock("./domEditOverlayGeometry", async () => {
|
|
70
|
+
const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
|
|
71
|
+
"./domEditOverlayGeometry",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
...actual,
|
|
76
|
+
toOverlayRect: () => ({
|
|
77
|
+
left: 24,
|
|
78
|
+
top: 36,
|
|
79
|
+
width: 180,
|
|
80
|
+
height: 72,
|
|
81
|
+
editScaleX: 1,
|
|
82
|
+
editScaleY: 1,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
12
86
|
|
|
13
87
|
describe("focusDomEditOverlayElement", () => {
|
|
14
88
|
it("focuses the canvas overlay without scrolling", () => {
|
|
@@ -21,6 +95,96 @@ describe("focusDomEditOverlayElement", () => {
|
|
|
21
95
|
});
|
|
22
96
|
});
|
|
23
97
|
|
|
98
|
+
describe("DomEditOverlay", () => {
|
|
99
|
+
it("renders selected bounds right after clicking a movable selection", () => {
|
|
100
|
+
const host = document.createElement("div");
|
|
101
|
+
document.body.append(host);
|
|
102
|
+
const root = createRoot(host);
|
|
103
|
+
const selection: DomEditSelection = {
|
|
104
|
+
element: document.createElement("div"),
|
|
105
|
+
id: "hero-title",
|
|
106
|
+
selector: ".hero-title",
|
|
107
|
+
selectorIndex: 0,
|
|
108
|
+
sourceFile: "index.html",
|
|
109
|
+
tagName: "div",
|
|
110
|
+
label: "Hero Title",
|
|
111
|
+
textContent: "Hello",
|
|
112
|
+
textFields: [],
|
|
113
|
+
capabilities: {
|
|
114
|
+
canEditText: true,
|
|
115
|
+
canEditLayout: true,
|
|
116
|
+
canApplyManualOffset: true,
|
|
117
|
+
canApplyManualSize: false,
|
|
118
|
+
canApplyManualRotation: false,
|
|
119
|
+
canAdjustOpacity: true,
|
|
120
|
+
canAdjustFill: true,
|
|
121
|
+
canAdjustBorderRadius: true,
|
|
122
|
+
canAdjustStroke: true,
|
|
123
|
+
canAdjustShadow: true,
|
|
124
|
+
canAdjustZIndex: true,
|
|
125
|
+
},
|
|
126
|
+
computedStyle: {
|
|
127
|
+
display: "block",
|
|
128
|
+
position: "absolute",
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
let currentSelection: DomEditSelection | null = null;
|
|
133
|
+
const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
|
|
134
|
+
const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
|
|
135
|
+
HTMLDivElement.prototype.setPointerCapture = () => {};
|
|
136
|
+
|
|
137
|
+
function Harness() {
|
|
138
|
+
const [selected, setSelected] = React.useState<DomEditSelection | null>(null);
|
|
139
|
+
currentSelection = selected;
|
|
140
|
+
|
|
141
|
+
return React.createElement(DomEditOverlay, {
|
|
142
|
+
iframeRef,
|
|
143
|
+
activeCompositionPath: null,
|
|
144
|
+
selection: selected,
|
|
145
|
+
hoverSelection: null,
|
|
146
|
+
groupSelections: [],
|
|
147
|
+
onCanvasMouseDown: () => {},
|
|
148
|
+
onCanvasPointerMove: () => selection,
|
|
149
|
+
onCanvasPointerLeave: () => {},
|
|
150
|
+
onSelectionChange: (next: DomEditSelection) => setSelected(next),
|
|
151
|
+
onBlockedMove: () => {},
|
|
152
|
+
onPathOffsetCommit: () => {},
|
|
153
|
+
onGroupPathOffsetCommit: () => {},
|
|
154
|
+
onBoxSizeCommit: () => {},
|
|
155
|
+
onRotationCommit: () => {},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
act(() => {
|
|
160
|
+
root.render(React.createElement(Harness));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
|
|
164
|
+
expect(overlay).toBeTruthy();
|
|
165
|
+
|
|
166
|
+
act(() => {
|
|
167
|
+
overlay.dispatchEvent(
|
|
168
|
+
new PointerEvent("pointerdown", {
|
|
169
|
+
bubbles: true,
|
|
170
|
+
button: 0,
|
|
171
|
+
clientX: 120,
|
|
172
|
+
clientY: 80,
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(currentSelection).toBe(selection);
|
|
178
|
+
expect(host.querySelector('[data-dom-edit-selection-box="true"]')).toBeTruthy();
|
|
179
|
+
|
|
180
|
+
act(() => {
|
|
181
|
+
root.unmount();
|
|
182
|
+
});
|
|
183
|
+
HTMLDivElement.prototype.setPointerCapture = originalPointerCapture;
|
|
184
|
+
host.remove();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
24
188
|
describe("resolveDomEditCoordinateScale", () => {
|
|
25
189
|
it("uses the top-level preview scale when no source boundary dimensions are available", () => {
|
|
26
190
|
expect(
|
|
@@ -223,7 +223,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
223
223
|
|
|
224
224
|
suppressNextOverlayMouseDownRef.current = true;
|
|
225
225
|
selectionRef.current = candidate;
|
|
226
|
-
overlayRectRef.current = candidateRect;
|
|
227
226
|
setOverlayRect(candidateRect);
|
|
228
227
|
const didStartGesture = gestures.startGesture("drag", event, {
|
|
229
228
|
selection: candidate,
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { memo, useState, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
collectDomEditLayerItems,
|
|
4
|
+
getDomEditLayerKey,
|
|
5
|
+
resolveDomEditSelection,
|
|
6
|
+
type DomEditLayerItem,
|
|
7
|
+
} from "./domEditing";
|
|
8
|
+
import { useStudioContext } from "../../contexts/StudioContext";
|
|
9
|
+
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
10
|
+
import { usePlayerStore } from "../../player";
|
|
11
|
+
import { findMatchingTimelineElementId } from "../../utils/studioHelpers";
|
|
12
|
+
import { Layers } from "../../icons/SystemIcons";
|
|
13
|
+
|
|
14
|
+
const TAG_ICONS: Record<string, string> = {
|
|
15
|
+
video: "Vi",
|
|
16
|
+
audio: "Au",
|
|
17
|
+
img: "Im",
|
|
18
|
+
svg: "Sv",
|
|
19
|
+
canvas: "Cn",
|
|
20
|
+
div: "Di",
|
|
21
|
+
section: "Se",
|
|
22
|
+
span: "Sp",
|
|
23
|
+
p: "P",
|
|
24
|
+
h1: "H1",
|
|
25
|
+
h2: "H2",
|
|
26
|
+
h3: "H3",
|
|
27
|
+
h4: "H4",
|
|
28
|
+
h5: "H5",
|
|
29
|
+
h6: "H6",
|
|
30
|
+
a: "A",
|
|
31
|
+
button: "Bt",
|
|
32
|
+
ul: "Ul",
|
|
33
|
+
ol: "Ol",
|
|
34
|
+
li: "Li",
|
|
35
|
+
style: "St",
|
|
36
|
+
template: "Te",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function getTagBadge(tagName: string): string {
|
|
40
|
+
return TAG_ICONS[tagName] ?? tagName.slice(0, 2).toUpperCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isCompositionHost(el: HTMLElement): boolean {
|
|
44
|
+
return el.hasAttribute("data-composition-src") || el.hasAttribute("data-composition-file");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface CollapsedState {
|
|
48
|
+
[key: string]: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const LayersPanel = memo(function LayersPanel() {
|
|
52
|
+
const { previewIframeRef, activeCompPath, refreshKey, compositionLoading, timelineElements } =
|
|
53
|
+
useStudioContext();
|
|
54
|
+
const { domEditSelection, applyDomSelection, updateDomEditHoverSelection } = useDomEditContext();
|
|
55
|
+
|
|
56
|
+
const [layers, setLayers] = useState<DomEditLayerItem[]>([]);
|
|
57
|
+
const [collapsed, setCollapsed] = useState<CollapsedState>({});
|
|
58
|
+
const prevDocVersionRef = useRef(0);
|
|
59
|
+
|
|
60
|
+
const isMasterView = !activeCompPath || activeCompPath === "index.html";
|
|
61
|
+
|
|
62
|
+
const collectLayers = useCallback(() => {
|
|
63
|
+
const iframe = previewIframeRef.current;
|
|
64
|
+
if (!iframe) return;
|
|
65
|
+
let doc: Document | null = null;
|
|
66
|
+
try {
|
|
67
|
+
doc = iframe.contentDocument;
|
|
68
|
+
} catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!doc) return;
|
|
72
|
+
|
|
73
|
+
const root =
|
|
74
|
+
doc.querySelector<HTMLElement>("[data-composition-id]") ?? doc.documentElement ?? null;
|
|
75
|
+
if (!root) return;
|
|
76
|
+
|
|
77
|
+
const items = collectDomEditLayerItems(root, {
|
|
78
|
+
activeCompositionPath: activeCompPath,
|
|
79
|
+
isMasterView,
|
|
80
|
+
});
|
|
81
|
+
setLayers(items);
|
|
82
|
+
}, [previewIframeRef, activeCompPath, isMasterView]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
collectLayers();
|
|
86
|
+
}, [collectLayers, refreshKey]);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const iframe = previewIframeRef.current;
|
|
90
|
+
if (!iframe) return;
|
|
91
|
+
const handleLoad = () => {
|
|
92
|
+
prevDocVersionRef.current += 1;
|
|
93
|
+
collectLayers();
|
|
94
|
+
};
|
|
95
|
+
iframe.addEventListener("load", handleLoad);
|
|
96
|
+
return () => iframe.removeEventListener("load", handleLoad);
|
|
97
|
+
}, [previewIframeRef, collectLayers]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!compositionLoading) {
|
|
101
|
+
const timer = setTimeout(collectLayers, 100);
|
|
102
|
+
return () => clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}, [compositionLoading, collectLayers]);
|
|
105
|
+
|
|
106
|
+
const resolveSelection = useCallback(
|
|
107
|
+
(layer: DomEditLayerItem) =>
|
|
108
|
+
resolveDomEditSelection(layer.element, {
|
|
109
|
+
activeCompositionPath: activeCompPath,
|
|
110
|
+
isMasterView,
|
|
111
|
+
preferClipAncestor: false,
|
|
112
|
+
}),
|
|
113
|
+
[activeCompPath, isMasterView],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const seekToLayer = useCallback(
|
|
117
|
+
(layer: DomEditLayerItem) => {
|
|
118
|
+
const selection = resolveSelection(layer);
|
|
119
|
+
if (!selection) return;
|
|
120
|
+
|
|
121
|
+
let matchedId = findMatchingTimelineElementId(selection, timelineElements);
|
|
122
|
+
|
|
123
|
+
// No direct match — walk up DOM ancestors to find the nearest element
|
|
124
|
+
// that has a timeline entry (e.g. a child of scene1 seeks to scene1.start)
|
|
125
|
+
if (!matchedId) {
|
|
126
|
+
const sourceFile = selection.sourceFile ?? "index.html";
|
|
127
|
+
let ancestor = layer.element.parentElement;
|
|
128
|
+
while (ancestor && !matchedId) {
|
|
129
|
+
const elId = ancestor.id;
|
|
130
|
+
if (elId) {
|
|
131
|
+
const found = timelineElements.find(
|
|
132
|
+
(e) => e.domId === elId && (e.sourceFile ?? "index.html") === sourceFile,
|
|
133
|
+
);
|
|
134
|
+
if (found) matchedId = found.key ?? found.id;
|
|
135
|
+
}
|
|
136
|
+
ancestor = ancestor.parentElement;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (matchedId) {
|
|
141
|
+
const el = timelineElements.find((e) => (e.key ?? e.id) === matchedId);
|
|
142
|
+
if (el) {
|
|
143
|
+
usePlayerStore.getState().requestSeek(el.start + el.duration / 2);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
[resolveSelection, timelineElements],
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const handleSelectLayer = useCallback(
|
|
151
|
+
(layer: DomEditLayerItem) => {
|
|
152
|
+
const selection = resolveSelection(layer);
|
|
153
|
+
if (!selection) return;
|
|
154
|
+
applyDomSelection(selection);
|
|
155
|
+
seekToLayer(layer);
|
|
156
|
+
},
|
|
157
|
+
[resolveSelection, applyDomSelection, seekToLayer],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const handleLayerHover = useCallback(
|
|
161
|
+
(layer: DomEditLayerItem | null) => {
|
|
162
|
+
if (!layer) {
|
|
163
|
+
updateDomEditHoverSelection(null);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const selection = resolveSelection(layer);
|
|
167
|
+
updateDomEditHoverSelection(selection);
|
|
168
|
+
},
|
|
169
|
+
[resolveSelection, updateDomEditHoverSelection],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const toggleCollapse = useCallback((key: string, e: React.MouseEvent) => {
|
|
173
|
+
e.stopPropagation();
|
|
174
|
+
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
|
|
178
|
+
|
|
179
|
+
const visibleLayers = getVisibleLayers(layers, collapsed);
|
|
180
|
+
|
|
181
|
+
if (layers.length === 0) {
|
|
182
|
+
return (
|
|
183
|
+
<div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
|
|
184
|
+
<Layers size={18} className="mb-3 text-neutral-600" />
|
|
185
|
+
<p className="text-sm font-medium text-neutral-200">No layers</p>
|
|
186
|
+
<p className="mt-1 text-xs text-neutral-500">Load a composition to see its element tree</p>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div
|
|
193
|
+
className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900"
|
|
194
|
+
onPointerLeave={() => handleLayerHover(null)}
|
|
195
|
+
>
|
|
196
|
+
<div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
|
|
197
|
+
{layers.length} layer{layers.length === 1 ? "" : "s"}
|
|
198
|
+
</div>
|
|
199
|
+
<div className="min-h-0 flex-1 overflow-y-auto py-1">
|
|
200
|
+
{visibleLayers.map((layer) => {
|
|
201
|
+
const selected = layer.key === selectedKey;
|
|
202
|
+
const isCollapsed = collapsed[layer.key] ?? false;
|
|
203
|
+
const hasChildren = layer.childCount > 0;
|
|
204
|
+
const isCompHost = isCompositionHost(layer.element);
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div
|
|
208
|
+
key={layer.key}
|
|
209
|
+
role="button"
|
|
210
|
+
tabIndex={0}
|
|
211
|
+
onClick={() => handleSelectLayer(layer)}
|
|
212
|
+
onPointerEnter={() => handleLayerHover(layer)}
|
|
213
|
+
onKeyDown={(e) => {
|
|
214
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
handleSelectLayer(layer);
|
|
217
|
+
}
|
|
218
|
+
}}
|
|
219
|
+
className={`group flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left transition-colors ${
|
|
220
|
+
selected
|
|
221
|
+
? "bg-studio-accent/14 text-studio-accent"
|
|
222
|
+
: "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
|
|
223
|
+
}`}
|
|
224
|
+
style={{ paddingLeft: 8 + layer.depth * 16 }}
|
|
225
|
+
>
|
|
226
|
+
{hasChildren ? (
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
onClick={(e) => toggleCollapse(layer.key, e)}
|
|
230
|
+
className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300"
|
|
231
|
+
>
|
|
232
|
+
<svg
|
|
233
|
+
width="8"
|
|
234
|
+
height="8"
|
|
235
|
+
viewBox="0 0 8 8"
|
|
236
|
+
fill="currentColor"
|
|
237
|
+
className={`transition-transform ${isCollapsed ? "" : "rotate-90"}`}
|
|
238
|
+
>
|
|
239
|
+
<path d="M2 1l4 3-4 3z" />
|
|
240
|
+
</svg>
|
|
241
|
+
</button>
|
|
242
|
+
) : (
|
|
243
|
+
<span className="w-4 flex-shrink-0" />
|
|
244
|
+
)}
|
|
245
|
+
<span
|
|
246
|
+
className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
|
|
247
|
+
selected
|
|
248
|
+
? "bg-studio-accent/18 text-studio-accent"
|
|
249
|
+
: isCompHost
|
|
250
|
+
? "bg-blue-900/40 text-blue-400"
|
|
251
|
+
: "bg-neutral-800 text-neutral-500"
|
|
252
|
+
}`}
|
|
253
|
+
>
|
|
254
|
+
{getTagBadge(layer.tagName)}
|
|
255
|
+
</span>
|
|
256
|
+
<span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
|
|
257
|
+
{hasChildren && (
|
|
258
|
+
<span className="text-[9px] tabular-nums text-neutral-600">{layer.childCount}</span>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
})}
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
function getVisibleLayers(
|
|
269
|
+
layers: DomEditLayerItem[],
|
|
270
|
+
collapsed: CollapsedState,
|
|
271
|
+
): DomEditLayerItem[] {
|
|
272
|
+
if (Object.keys(collapsed).length === 0) return layers;
|
|
273
|
+
|
|
274
|
+
const result: DomEditLayerItem[] = [];
|
|
275
|
+
let skipDepth = -1;
|
|
276
|
+
|
|
277
|
+
for (const layer of layers) {
|
|
278
|
+
if (skipDepth >= 0 && layer.depth > skipDepth) continue;
|
|
279
|
+
skipDepth = -1;
|
|
280
|
+
|
|
281
|
+
result.push(layer);
|
|
282
|
+
|
|
283
|
+
if (collapsed[layer.key] && layer.childCount > 0) {
|
|
284
|
+
skipDepth = layer.depth;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useCallback, memo } from "react";
|
|
1
|
+
import { useRef, useCallback, useEffect, memo } from "react";
|
|
2
2
|
import {
|
|
3
3
|
EditorView,
|
|
4
4
|
keymap,
|
|
@@ -69,6 +69,9 @@ export const SourceEditor = memo(function SourceEditor({
|
|
|
69
69
|
const onChangeRef = useRef(onChange);
|
|
70
70
|
onChangeRef.current = onChange;
|
|
71
71
|
|
|
72
|
+
const contentRef = useRef(content);
|
|
73
|
+
contentRef.current = content;
|
|
74
|
+
|
|
72
75
|
const mountEditor = useCallback(
|
|
73
76
|
(node: HTMLDivElement | null) => {
|
|
74
77
|
if (editorRef.current) {
|
|
@@ -87,7 +90,7 @@ export const SourceEditor = memo(function SourceEditor({
|
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
const state = EditorState.create({
|
|
90
|
-
doc:
|
|
93
|
+
doc: contentRef.current,
|
|
91
94
|
extensions: [
|
|
92
95
|
lineNumbers(),
|
|
93
96
|
highlightActiveLine(),
|
|
@@ -112,8 +115,22 @@ export const SourceEditor = memo(function SourceEditor({
|
|
|
112
115
|
|
|
113
116
|
editorRef.current = new EditorView({ state, parent: node });
|
|
114
117
|
},
|
|
115
|
-
[
|
|
118
|
+
[filePath, language, readOnly],
|
|
116
119
|
);
|
|
117
120
|
|
|
121
|
+
// Sync external content changes into the editor without recreating it.
|
|
122
|
+
// Only applies when the new content differs from the current document
|
|
123
|
+
// (e.g. file switch or server refresh), not on every keystroke.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const view = editorRef.current;
|
|
126
|
+
if (!view) return;
|
|
127
|
+
const current = view.state.doc.toString();
|
|
128
|
+
if (current !== content) {
|
|
129
|
+
view.dispatch({
|
|
130
|
+
changes: { from: 0, to: current.length, insert: content },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}, [content]);
|
|
134
|
+
|
|
118
135
|
return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
|
|
119
136
|
});
|