@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/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-hYc4aP7M.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-D1JDq7Gg.css">
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.2",
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.2",
36
- "@hyperframes/player": "0.6.2"
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.2"
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(true);
104
- const toggleTimelineVisibility = useCallback(() => setTimelineVisible((v) => !v), []);
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 selectedTimelineElement = useMemo(
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
- {designPanelActive ? (
158
+ {rightPanelTab === "layers" ? (
159
+ <LayersPanel />
160
+ ) : designPanelActive ? (
147
161
  <PropertyPanel
148
162
  projectId={projectId}
149
163
  assets={assets}
@@ -1,6 +1,11 @@
1
- import { describe, expect, it } from "vitest";
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: content,
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
- [content, filePath, language, readOnly],
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
  });