@hyperframes/studio 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/assets/index-BEwJNmPo.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +137 -0
  11. package/src/components/renders/useRenderQueue.ts +193 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. package/src/player/lib/useMountEffect.ts +0 -10
@@ -3,15 +3,15 @@ export { Player } from "./components/Player";
3
3
  export { PlayerControls } from "./components/PlayerControls";
4
4
  export { Timeline } from "./components/Timeline";
5
5
  export { PreviewPanel } from "./components/PreviewPanel";
6
- export { AgentActivityTrack } from "./components/AgentActivityTrack";
7
- export type { AgentActivity } from "./components/AgentActivityTrack";
6
+ export { VideoThumbnail } from "./components/VideoThumbnail";
7
+ export { CompositionThumbnail } from "./components/CompositionThumbnail";
8
8
 
9
9
  // Hooks
10
10
  export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
11
11
 
12
12
  // Store
13
13
  export { usePlayerStore, liveTime } from "./store/playerStore";
14
- export type { TimelineElement, ActiveEdits } from "./store/playerStore";
14
+ export type { TimelineElement, ZoomMode } from "./store/playerStore";
15
15
 
16
16
  // Utils
17
17
  export { formatTime } from "./lib/time";
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { formatTime } from "./time";
3
+
4
+ describe("formatTime", () => {
5
+ it("formats zero seconds", () => {
6
+ expect(formatTime(0)).toBe("0:00");
7
+ });
8
+
9
+ it("formats seconds less than a minute", () => {
10
+ expect(formatTime(5)).toBe("0:05");
11
+ expect(formatTime(30)).toBe("0:30");
12
+ expect(formatTime(59)).toBe("0:59");
13
+ });
14
+
15
+ it("formats exact minutes", () => {
16
+ expect(formatTime(60)).toBe("1:00");
17
+ expect(formatTime(120)).toBe("2:00");
18
+ expect(formatTime(600)).toBe("10:00");
19
+ });
20
+
21
+ it("formats minutes and seconds", () => {
22
+ expect(formatTime(65)).toBe("1:05");
23
+ expect(formatTime(90)).toBe("1:30");
24
+ expect(formatTime(125)).toBe("2:05");
25
+ });
26
+
27
+ it("formats large values (over an hour)", () => {
28
+ expect(formatTime(3600)).toBe("60:00");
29
+ expect(formatTime(3661)).toBe("61:01");
30
+ expect(formatTime(7200)).toBe("120:00");
31
+ });
32
+
33
+ it("floors fractional seconds", () => {
34
+ expect(formatTime(0.9)).toBe("0:00");
35
+ expect(formatTime(1.5)).toBe("0:01");
36
+ expect(formatTime(59.99)).toBe("0:59");
37
+ expect(formatTime(60.5)).toBe("1:00");
38
+ });
39
+
40
+ it("pads single-digit seconds with leading zero", () => {
41
+ expect(formatTime(1)).toBe("0:01");
42
+ expect(formatTime(61)).toBe("1:01");
43
+ expect(formatTime(609)).toBe("10:09");
44
+ });
45
+
46
+ it("guards against negative values", () => {
47
+ expect(formatTime(-1)).toBe("0:00");
48
+ });
49
+
50
+ it("guards against NaN", () => {
51
+ expect(formatTime(NaN)).toBe("0:00");
52
+ });
53
+
54
+ it("guards against Infinity", () => {
55
+ expect(formatTime(Infinity)).toBe("0:00");
56
+ });
57
+ });
@@ -1,4 +1,5 @@
1
1
  export function formatTime(time: number): string {
2
+ if (!Number.isFinite(time) || time < 0) return "0:00";
2
3
  const mins = Math.floor(time / 60);
3
4
  const secs = Math.floor(time % 60);
4
5
  return `${mins}:${secs.toString().padStart(2, "0")}`;
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
3
+
4
+ describe("usePlayerStore", () => {
5
+ beforeEach(() => {
6
+ usePlayerStore.getState().reset();
7
+ });
8
+
9
+ describe("initial state", () => {
10
+ it("has correct defaults", () => {
11
+ const state = usePlayerStore.getState();
12
+ expect(state.isPlaying).toBe(false);
13
+ expect(state.currentTime).toBe(0);
14
+ expect(state.duration).toBe(0);
15
+ expect(state.timelineReady).toBe(false);
16
+ expect(state.elements).toEqual([]);
17
+ expect(state.selectedElementId).toBeNull();
18
+ expect(state.playbackRate).toBe(1);
19
+ expect(state.zoomMode).toBe("fit");
20
+ expect(state.pixelsPerSecond).toBe(100);
21
+ });
22
+ });
23
+
24
+ describe("setIsPlaying", () => {
25
+ it("sets isPlaying to true", () => {
26
+ usePlayerStore.getState().setIsPlaying(true);
27
+ expect(usePlayerStore.getState().isPlaying).toBe(true);
28
+ });
29
+
30
+ it("sets isPlaying to false", () => {
31
+ usePlayerStore.getState().setIsPlaying(true);
32
+ usePlayerStore.getState().setIsPlaying(false);
33
+ expect(usePlayerStore.getState().isPlaying).toBe(false);
34
+ });
35
+ });
36
+
37
+ describe("setCurrentTime", () => {
38
+ it("updates currentTime", () => {
39
+ usePlayerStore.getState().setCurrentTime(12.5);
40
+ expect(usePlayerStore.getState().currentTime).toBe(12.5);
41
+ });
42
+
43
+ it("accepts zero", () => {
44
+ usePlayerStore.getState().setCurrentTime(42);
45
+ usePlayerStore.getState().setCurrentTime(0);
46
+ expect(usePlayerStore.getState().currentTime).toBe(0);
47
+ });
48
+ });
49
+
50
+ describe("setDuration", () => {
51
+ it("updates duration", () => {
52
+ usePlayerStore.getState().setDuration(120);
53
+ expect(usePlayerStore.getState().duration).toBe(120);
54
+ });
55
+ });
56
+
57
+ describe("setPlaybackRate", () => {
58
+ it("updates playbackRate", () => {
59
+ usePlayerStore.getState().setPlaybackRate(2);
60
+ expect(usePlayerStore.getState().playbackRate).toBe(2);
61
+ });
62
+ });
63
+
64
+ describe("setTimelineReady", () => {
65
+ it("updates timelineReady", () => {
66
+ usePlayerStore.getState().setTimelineReady(true);
67
+ expect(usePlayerStore.getState().timelineReady).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe("setElements", () => {
72
+ it("sets the elements array", () => {
73
+ const elements: TimelineElement[] = [
74
+ { id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
75
+ {
76
+ id: "el-2",
77
+ tag: "video",
78
+ start: 2,
79
+ duration: 10,
80
+ track: 1,
81
+ src: "test.mp4",
82
+ },
83
+ ];
84
+ usePlayerStore.getState().setElements(elements);
85
+ expect(usePlayerStore.getState().elements).toEqual(elements);
86
+ expect(usePlayerStore.getState().elements).toHaveLength(2);
87
+ });
88
+
89
+ it("replaces existing elements", () => {
90
+ usePlayerStore
91
+ .getState()
92
+ .setElements([{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 }]);
93
+ usePlayerStore
94
+ .getState()
95
+ .setElements([{ id: "el-3", tag: "span", start: 1, duration: 3, track: 0 }]);
96
+ const elements = usePlayerStore.getState().elements;
97
+ expect(elements).toHaveLength(1);
98
+ expect(elements[0].id).toBe("el-3");
99
+ });
100
+ });
101
+
102
+ describe("setSelectedElementId", () => {
103
+ it("selects an element", () => {
104
+ usePlayerStore.getState().setSelectedElementId("el-1");
105
+ expect(usePlayerStore.getState().selectedElementId).toBe("el-1");
106
+ });
107
+
108
+ it("clears selection with null", () => {
109
+ usePlayerStore.getState().setSelectedElementId("el-1");
110
+ usePlayerStore.getState().setSelectedElementId(null);
111
+ expect(usePlayerStore.getState().selectedElementId).toBeNull();
112
+ });
113
+ });
114
+
115
+ describe("updateElementStart", () => {
116
+ it("updates the start time of a specific element", () => {
117
+ usePlayerStore.getState().setElements([
118
+ { id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
119
+ { id: "el-2", tag: "div", start: 5, duration: 5, track: 1 },
120
+ ]);
121
+ usePlayerStore.getState().updateElementStart("el-1", 3);
122
+ const elements = usePlayerStore.getState().elements;
123
+ expect(elements[0].start).toBe(3);
124
+ expect(elements[1].start).toBe(5); // unchanged
125
+ });
126
+
127
+ it("does not modify elements when id is not found", () => {
128
+ const original: TimelineElement[] = [
129
+ { id: "el-1", tag: "div", start: 0, duration: 5, track: 0 },
130
+ ];
131
+ usePlayerStore.getState().setElements(original);
132
+ usePlayerStore.getState().updateElementStart("nonexistent", 10);
133
+ expect(usePlayerStore.getState().elements[0].start).toBe(0);
134
+ });
135
+ });
136
+
137
+ describe("setZoomMode", () => {
138
+ it("changes zoom mode to manual", () => {
139
+ usePlayerStore.getState().setZoomMode("manual");
140
+ expect(usePlayerStore.getState().zoomMode).toBe("manual");
141
+ });
142
+
143
+ it("changes zoom mode back to fit", () => {
144
+ usePlayerStore.getState().setZoomMode("manual");
145
+ usePlayerStore.getState().setZoomMode("fit");
146
+ expect(usePlayerStore.getState().zoomMode).toBe("fit");
147
+ });
148
+ });
149
+
150
+ describe("setPixelsPerSecond", () => {
151
+ it("updates pixelsPerSecond", () => {
152
+ usePlayerStore.getState().setPixelsPerSecond(200);
153
+ expect(usePlayerStore.getState().pixelsPerSecond).toBe(200);
154
+ });
155
+
156
+ it("clamps to minimum of 10", () => {
157
+ usePlayerStore.getState().setPixelsPerSecond(5);
158
+ expect(usePlayerStore.getState().pixelsPerSecond).toBe(10);
159
+ });
160
+
161
+ it("clamps negative values to 10", () => {
162
+ usePlayerStore.getState().setPixelsPerSecond(-50);
163
+ expect(usePlayerStore.getState().pixelsPerSecond).toBe(10);
164
+ });
165
+ });
166
+
167
+ describe("reset", () => {
168
+ it("resets all state to defaults", () => {
169
+ // Mutate everything
170
+ const store = usePlayerStore.getState();
171
+ store.setIsPlaying(true);
172
+ store.setCurrentTime(42);
173
+ store.setDuration(120);
174
+ store.setTimelineReady(true);
175
+ store.setElements([{ id: "el-1", tag: "div", start: 0, duration: 5, track: 0 }]);
176
+ store.setSelectedElementId("el-1");
177
+
178
+ // Reset
179
+ usePlayerStore.getState().reset();
180
+
181
+ const state = usePlayerStore.getState();
182
+ expect(state.isPlaying).toBe(false);
183
+ expect(state.currentTime).toBe(0);
184
+ expect(state.duration).toBe(0);
185
+ expect(state.timelineReady).toBe(false);
186
+ expect(state.elements).toEqual([]);
187
+ expect(state.selectedElementId).toBeNull();
188
+ });
189
+
190
+ it("does not reset playbackRate, zoomMode, or pixelsPerSecond", () => {
191
+ const store = usePlayerStore.getState();
192
+ store.setPlaybackRate(2);
193
+ store.setZoomMode("manual");
194
+ store.setPixelsPerSecond(200);
195
+
196
+ usePlayerStore.getState().reset();
197
+
198
+ const state = usePlayerStore.getState();
199
+ // reset() only resets the fields explicitly listed in the reset function
200
+ expect(state.playbackRate).toBe(2);
201
+ expect(state.zoomMode).toBe("manual");
202
+ expect(state.pixelsPerSecond).toBe(200);
203
+ });
204
+ });
205
+ });
206
+
207
+ describe("liveTime", () => {
208
+ it("notifies subscribers with the current time", () => {
209
+ const listener = vi.fn();
210
+ const unsubscribe = liveTime.subscribe(listener);
211
+
212
+ liveTime.notify(5.5);
213
+ expect(listener).toHaveBeenCalledWith(5.5);
214
+ expect(listener).toHaveBeenCalledTimes(1);
215
+
216
+ liveTime.notify(10);
217
+ expect(listener).toHaveBeenCalledWith(10);
218
+ expect(listener).toHaveBeenCalledTimes(2);
219
+
220
+ unsubscribe();
221
+ });
222
+
223
+ it("supports multiple subscribers", () => {
224
+ const listener1 = vi.fn();
225
+ const listener2 = vi.fn();
226
+ const unsub1 = liveTime.subscribe(listener1);
227
+ const unsub2 = liveTime.subscribe(listener2);
228
+
229
+ liveTime.notify(3);
230
+ expect(listener1).toHaveBeenCalledWith(3);
231
+ expect(listener2).toHaveBeenCalledWith(3);
232
+
233
+ unsub1();
234
+ unsub2();
235
+ });
236
+
237
+ it("unsubscribe stops notifications", () => {
238
+ const listener = vi.fn();
239
+ const unsubscribe = liveTime.subscribe(listener);
240
+
241
+ liveTime.notify(1);
242
+ expect(listener).toHaveBeenCalledTimes(1);
243
+
244
+ unsubscribe();
245
+
246
+ liveTime.notify(2);
247
+ expect(listener).toHaveBeenCalledTimes(1); // not called again
248
+ });
249
+
250
+ it("unsubscribe returns true when listener existed", () => {
251
+ const listener = vi.fn();
252
+ const unsubscribe = liveTime.subscribe(listener);
253
+ // Set.delete returns boolean, our unsubscribe wraps it
254
+ const result = unsubscribe();
255
+ expect(result).toBe(true);
256
+ });
257
+
258
+ it("double unsubscribe returns false", () => {
259
+ const listener = vi.fn();
260
+ const unsubscribe = liveTime.subscribe(listener);
261
+ unsubscribe();
262
+ const result = unsubscribe();
263
+ expect(result).toBe(false);
264
+ });
265
+ });
@@ -11,16 +11,9 @@ export interface TimelineElement {
11
11
  volume?: number;
12
12
  /** Path from data-composition-src — identifies sub-composition elements */
13
13
  compositionSrc?: string;
14
- /** Agent that created/last edited this element */
15
- agentId?: string;
16
- /** Agent's color for ownership visualization */
17
- agentColor?: string;
18
14
  }
19
15
 
20
- /** Map of elementId agentColor for clips currently being edited */
21
- export interface ActiveEdits {
22
- [elementId: string]: { agentId: string; agentColor: string };
23
- }
16
+ export type ZoomMode = "fit" | "manual";
24
17
 
25
18
  interface PlayerState {
26
19
  isPlaying: boolean;
@@ -29,9 +22,15 @@ interface PlayerState {
29
22
  timelineReady: boolean;
30
23
  elements: TimelineElement[];
31
24
  selectedElementId: string | null;
32
- /** Clips currently being edited by agents — for glow animation */
33
- activeEdits: ActiveEdits;
34
25
  playbackRate: number;
26
+ /** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses pixelsPerSecond */
27
+ zoomMode: ZoomMode;
28
+ /** Pixels per second when in manual zoom mode */
29
+ pixelsPerSecond: number;
30
+ /** Edit range selection */
31
+ editRangeStart: number | null;
32
+ editRangeEnd: number | null;
33
+ editMode: boolean;
35
34
 
36
35
  setIsPlaying: (playing: boolean) => void;
37
36
  setCurrentTime: (time: number) => void;
@@ -40,8 +39,17 @@ interface PlayerState {
40
39
  setTimelineReady: (ready: boolean) => void;
41
40
  setElements: (elements: TimelineElement[]) => void;
42
41
  setSelectedElementId: (id: string | null) => void;
43
- setActiveEdits: (edits: ActiveEdits) => void;
42
+ setEditRange: (start: number | null, end: number | null) => void;
43
+ setEditMode: (active: boolean) => void;
44
44
  updateElementStart: (elementId: string, newStart: number) => void;
45
+ updateElementDuration: (elementId: string, newDuration: number) => void;
46
+ updateElementTrack: (elementId: string, newTrack: number) => void;
47
+ updateElement: (
48
+ elementId: string,
49
+ updates: Partial<Pick<TimelineElement, "start" | "duration" | "track">>,
50
+ ) => void;
51
+ setZoomMode: (mode: ZoomMode) => void;
52
+ setPixelsPerSecond: (pps: number) => void;
45
53
  reset: () => void;
46
54
  }
47
55
 
@@ -65,21 +73,42 @@ export const usePlayerStore = create<PlayerState>((set) => ({
65
73
  timelineReady: false,
66
74
  elements: [],
67
75
  selectedElementId: null,
68
- activeEdits: {},
69
76
  playbackRate: 1,
77
+ zoomMode: "fit",
78
+ pixelsPerSecond: 100,
79
+ editRangeStart: null,
80
+ editRangeEnd: null,
81
+ editMode: false,
70
82
 
71
83
  setIsPlaying: (playing) => set({ isPlaying: playing }),
72
84
  setPlaybackRate: (rate) => set({ playbackRate: rate }),
73
- setCurrentTime: (time) => set({ currentTime: time }),
74
- setDuration: (duration) => set({ duration }),
85
+ setZoomMode: (mode) => set({ zoomMode: mode }),
86
+ setPixelsPerSecond: (pps) => set({ pixelsPerSecond: Math.max(10, pps) }),
87
+ setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
88
+ setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
75
89
  setTimelineReady: (ready) => set({ timelineReady: ready }),
76
90
  setElements: (elements) => set({ elements }),
77
91
  setSelectedElementId: (id) => set({ selectedElementId: id }),
78
- setActiveEdits: (edits) => set({ activeEdits: edits }),
92
+ setEditRange: (start, end) => set({ editRangeStart: start, editRangeEnd: end }),
93
+ setEditMode: (active) => set({ editMode: active, editRangeStart: null, editRangeEnd: null }),
79
94
  updateElementStart: (elementId, newStart) =>
80
95
  set((state) => ({
81
96
  elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)),
82
97
  })),
98
+ updateElementDuration: (elementId, newDuration) =>
99
+ set((state) => ({
100
+ elements: state.elements.map((el) =>
101
+ el.id === elementId ? { ...el, duration: newDuration } : el,
102
+ ),
103
+ })),
104
+ updateElementTrack: (elementId, newTrack) =>
105
+ set((state) => ({
106
+ elements: state.elements.map((el) => (el.id === elementId ? { ...el, track: newTrack } : el)),
107
+ })),
108
+ updateElement: (elementId, updates) =>
109
+ set((state) => ({
110
+ elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)),
111
+ })),
83
112
  reset: () =>
84
113
  set({
85
114
  isPlaying: false,
@@ -88,6 +117,5 @@ export const usePlayerStore = create<PlayerState>((set) => ({
88
117
  timelineReady: false,
89
118
  elements: [],
90
119
  selectedElementId: null,
91
- activeEdits: {},
92
120
  }),
93
121
  }));
@@ -0,0 +1,164 @@
1
+ /**
2
+ * HTML Editor — Utility functions for parsing and manipulating HyperFrame HTML source.
3
+ */
4
+
5
+ /**
6
+ * Parse a CSS inline style string into a key-value map.
7
+ * e.g. "opacity: 0.5; transform: matrix(1,0,0,1,0,0)" →
8
+ * { opacity: "0.5", transform: "matrix(1,0,0,1,0,0)" }
9
+ */
10
+ export function parseStyleString(style: string): Record<string, string> {
11
+ const result: Record<string, string> = {};
12
+ for (const decl of style.split(";")) {
13
+ const colonIdx = decl.indexOf(":");
14
+ if (colonIdx < 0) continue;
15
+ const key = decl.slice(0, colonIdx).trim();
16
+ const value = decl.slice(colonIdx + 1).trim();
17
+ if (key && value) result[key] = value;
18
+ }
19
+ return result;
20
+ }
21
+
22
+ /**
23
+ * Merge `newStyles` into an opening tag string's `style` attribute.
24
+ * - New values win over existing ones.
25
+ * - If no `style` attribute is present, one is added before the closing `>`.
26
+ */
27
+ export function mergeStyleIntoTag(tag: string, newStyles: string): string {
28
+ if (!newStyles.trim()) return tag;
29
+
30
+ const incoming = parseStyleString(newStyles);
31
+
32
+ // Match style="..." or style='...' — handle multi-line attrs via dotall-like trick
33
+ const styleAttrRe = /style=(["'])([\s\S]*?)\1/;
34
+ const match = tag.match(styleAttrRe);
35
+
36
+ if (match) {
37
+ const quote = match[1];
38
+ const existing = parseStyleString(match[2]);
39
+ const merged = { ...existing, ...incoming };
40
+ const serialized = Object.entries(merged)
41
+ .map(([k, v]) => `${k}: ${v}`)
42
+ .join("; ");
43
+ return tag.replace(styleAttrRe, `style=${quote}${serialized}${quote}`);
44
+ }
45
+
46
+ // No style attribute — insert one before the closing `>`
47
+ const serialized = Object.entries(incoming)
48
+ .map(([k, v]) => `${k}: ${v}`)
49
+ .join("; ");
50
+ // Handle self-closing tags (`/>`) and regular closing (`>`)
51
+ return tag.replace(/(\/?>)$/, ` style="${serialized}"$1`);
52
+ }
53
+
54
+ /**
55
+ * Find the full element block (opening tag through closing tag) in the source.
56
+ * Uses quote-aware scanning to handle attributes containing >.
57
+ * Uses depth counting to handle nested same-name tags.
58
+ */
59
+ export function findElementBlock(
60
+ html: string,
61
+ elementId: string,
62
+ ): {
63
+ start: number;
64
+ end: number;
65
+ openTag: string;
66
+ tagName: string;
67
+ indent: string;
68
+ innerContent: string;
69
+ isSelfClosing: boolean;
70
+ } | null {
71
+ let idIdx = html.indexOf(`id="${elementId}"`);
72
+ if (idIdx < 0) idIdx = html.indexOf(`id='${elementId}'`);
73
+ if (idIdx < 0) return null;
74
+
75
+ // Walk backward to find < and capture indent
76
+ let tagStart = idIdx;
77
+ while (tagStart > 0 && html[tagStart] !== "<") tagStart--;
78
+
79
+ let indentStart = tagStart;
80
+ while (indentStart > 0 && html[indentStart - 1] !== "\n") indentStart--;
81
+ const indent = html.slice(indentStart, tagStart);
82
+
83
+ // Walk forward from id to find the closing > of the opening tag
84
+ let tagEnd = idIdx;
85
+ let inQuote: string | null = null;
86
+ while (tagEnd < html.length) {
87
+ const ch = html[tagEnd];
88
+ if (inQuote) {
89
+ if (ch === inQuote) inQuote = null;
90
+ } else {
91
+ if (ch === '"' || ch === "'") inQuote = ch;
92
+ if (ch === ">") {
93
+ tagEnd++;
94
+ break;
95
+ }
96
+ }
97
+ tagEnd++;
98
+ }
99
+
100
+ const openTag = html.slice(tagStart, tagEnd);
101
+ const tagNameMatch = openTag.match(/^<([a-z][a-z0-9]*)/i);
102
+ if (!tagNameMatch) return null;
103
+
104
+ const tagName = tagNameMatch[1];
105
+ const isSelfClosing =
106
+ openTag.trimEnd().endsWith("/>") ||
107
+ ["img", "br", "hr", "input", "meta", "link", "source"].includes(tagName.toLowerCase());
108
+
109
+ if (isSelfClosing) {
110
+ return {
111
+ start: tagStart,
112
+ end: tagStart + openTag.length,
113
+ openTag,
114
+ tagName,
115
+ indent: /^[\t ]*$/.test(indent) ? indent : "",
116
+ innerContent: "",
117
+ isSelfClosing: true,
118
+ };
119
+ }
120
+
121
+ // Find matching closing tag using depth counting
122
+ const closeTag = `</${tagName.toLowerCase()}>`;
123
+ const openPattern = `<${tagName.toLowerCase()}`;
124
+ let depth = 0;
125
+ let pos = tagStart;
126
+ const lower = html.toLowerCase();
127
+
128
+ while (pos < html.length) {
129
+ if (lower.startsWith("<!--", pos)) {
130
+ const commentEnd = lower.indexOf("-->", pos + 4);
131
+ pos = commentEnd < 0 ? html.length : commentEnd + 3;
132
+ continue;
133
+ }
134
+
135
+ if (lower.startsWith(openPattern, pos) && /[\s>/]/.test(html[pos + openPattern.length] || "")) {
136
+ depth++;
137
+ pos += openPattern.length;
138
+ continue;
139
+ }
140
+
141
+ if (lower.startsWith(closeTag, pos)) {
142
+ depth--;
143
+ if (depth === 0) {
144
+ const end = pos + closeTag.length;
145
+ const innerContent = html.slice(tagStart + openTag.length, pos);
146
+ return {
147
+ start: tagStart,
148
+ end,
149
+ openTag,
150
+ tagName,
151
+ indent: /^[\t ]*$/.test(indent) ? indent : "",
152
+ innerContent,
153
+ isSelfClosing: false,
154
+ };
155
+ }
156
+ pos += closeTag.length;
157
+ continue;
158
+ }
159
+
160
+ pos++;
161
+ }
162
+
163
+ return null;
164
+ }