@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.
- package/dist/assets/index-BEwJNmPo.js +92 -0
- package/dist/assets/index-BnvciBdD.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +10 -4
- package/src/App.tsx +744 -271
- package/src/components/editor/FileTree.tsx +186 -32
- package/src/components/editor/SourceEditor.tsx +3 -1
- package/src/components/nle/NLELayout.tsx +125 -23
- package/src/components/renders/RenderQueue.tsx +123 -0
- package/src/components/renders/RenderQueueItem.tsx +137 -0
- package/src/components/renders/useRenderQueue.ts +193 -0
- package/src/components/sidebar/AssetsTab.tsx +360 -0
- package/src/components/sidebar/CompositionsTab.tsx +227 -0
- package/src/components/sidebar/LeftSidebar.tsx +102 -0
- package/src/components/ui/ExpandOnHover.tsx +194 -0
- package/src/hooks/useCodeEditor.ts +1 -1
- package/src/hooks/useElementPicker.ts +5 -1
- package/src/index.ts +10 -2
- package/src/player/components/AudioWaveform.tsx +168 -0
- package/src/player/components/CompositionThumbnail.tsx +140 -0
- package/src/player/components/EditModal.tsx +165 -0
- package/src/player/components/Player.tsx +6 -5
- package/src/player/components/PlayerControls.tsx +78 -39
- package/src/player/components/Timeline.test.ts +110 -0
- package/src/player/components/Timeline.tsx +537 -260
- package/src/player/components/TimelineClip.tsx +80 -0
- package/src/player/components/VideoThumbnail.tsx +196 -0
- package/src/player/hooks/useTimelinePlayer.ts +404 -112
- package/src/player/index.ts +3 -3
- package/src/player/lib/time.test.ts +57 -0
- package/src/player/lib/time.ts +1 -0
- package/src/player/store/playerStore.test.ts +265 -0
- package/src/player/store/playerStore.ts +44 -16
- package/src/utils/htmlEditor.ts +164 -0
- package/dist/assets/index-Df6fO-S6.js +0 -78
- package/dist/assets/index-KoBceNoU.css +0 -1
- package/src/player/components/AgentActivityTrack.tsx +0 -93
- package/src/player/lib/useMountEffect.ts +0 -10
package/src/player/index.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
export
|
|
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,
|
|
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
|
+
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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
|
+
}
|