@hyperframes/studio 0.5.0-alpha.8 → 0.5.0
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/hyperframes-player-CoI5h1xv.js +353 -0
- package/dist/assets/index-BKjcNNNd.css +1 -0
- package/dist/assets/index-CqiisJmo.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +208 -1436
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/LintModal.tsx +4 -3
- package/src/components/editor/PropertyPanel.tsx +206 -2462
- package/src/components/nle/NLELayout.tsx +47 -17
- package/src/components/nle/NLEPreview.tsx +9 -50
- package/src/components/sidebar/AssetsTab.tsx +4 -3
- package/src/components/sidebar/CompositionsTab.test.ts +1 -16
- package/src/components/sidebar/CompositionsTab.tsx +45 -117
- package/src/components/sidebar/LeftSidebar.tsx +55 -34
- package/src/components/ui/HyperframesLoader.tsx +104 -0
- package/src/components/ui/index.ts +2 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -42
- package/src/player/components/EditModal.tsx +20 -5
- package/src/player/components/Player.tsx +129 -28
- package/src/player/components/PlayerControls.tsx +117 -49
- package/src/player/components/Timeline.test.ts +0 -12
- package/src/player/components/Timeline.tsx +25 -52
- package/src/player/components/TimelineClip.tsx +9 -21
- package/src/player/components/timelineEditing.test.ts +4 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineTheme.test.ts +19 -0
- package/src/player/components/timelineTheme.ts +8 -4
- package/src/player/hooks/useTimelinePlayer.test.ts +219 -1
- package/src/player/hooks/useTimelinePlayer.ts +487 -106
- package/src/player/lib/time.test.ts +29 -1
- package/src/player/lib/time.ts +26 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +6 -1
- package/src/styles/studio.css +112 -0
- package/src/utils/frameCapture.test.ts +26 -0
- package/src/utils/frameCapture.ts +40 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/src/utils/sourcePatcher.test.ts +1 -128
- package/src/utils/sourcePatcher.ts +18 -130
- package/src/utils/timelineAssetDrop.test.ts +11 -31
- package/src/utils/timelineAssetDrop.ts +2 -22
- package/dist/assets/hyperframes-player-vibA20NC.js +0 -198
- package/dist/assets/index-0Zt0t13W.css +0 -1
- package/dist/assets/index-C9f5eif8.js +0 -105
- package/src/components/editor/DomEditOverlay.tsx +0 -442
- package/src/components/editor/colorValue.test.ts +0 -82
- package/src/components/editor/colorValue.ts +0 -175
- package/src/components/editor/domEditing.test.ts +0 -537
- package/src/components/editor/domEditing.ts +0 -762
- package/src/components/editor/floatingPanel.test.ts +0 -34
- package/src/components/editor/floatingPanel.ts +0 -54
- package/src/components/editor/fontAssets.ts +0 -32
- package/src/components/editor/fontCatalog.ts +0 -126
- package/src/components/editor/gradientValue.test.ts +0 -89
- package/src/components/editor/gradientValue.ts +0 -445
- package/src/player/components/CompositionThumbnail.test.ts +0 -19
- package/src/utils/clipboard.test.ts +0 -88
- package/src/utils/clipboard.ts +0 -57
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatTime } from "./time";
|
|
2
|
+
import { formatFrameTime, frameToSeconds, secondsToFrame, stepFrameTime, formatTime } from "./time";
|
|
3
3
|
|
|
4
4
|
describe("formatTime", () => {
|
|
5
5
|
it("formats zero seconds", () => {
|
|
@@ -55,3 +55,31 @@ describe("formatTime", () => {
|
|
|
55
55
|
expect(formatTime(Infinity)).toBe("0:00");
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
describe("frame helpers", () => {
|
|
60
|
+
it("converts seconds to frames at the Studio preview rate", () => {
|
|
61
|
+
expect(secondsToFrame(0)).toBe(0);
|
|
62
|
+
expect(secondsToFrame(1)).toBe(30);
|
|
63
|
+
expect(secondsToFrame(1.5)).toBe(45);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("converts frames to seconds at the Studio preview rate", () => {
|
|
67
|
+
expect(frameToSeconds(0)).toBe(0);
|
|
68
|
+
expect(frameToSeconds(30)).toBe(1);
|
|
69
|
+
expect(frameToSeconds(45)).toBe(1.5);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("formats current and total frame display", () => {
|
|
73
|
+
expect(formatFrameTime(1, 5)).toBe("30f / 150f");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("steps from a truncated runtime time by integer frame index", () => {
|
|
77
|
+
expect(stepFrameTime(0.0333333, 1)).toBe(2 / 30);
|
|
78
|
+
expect(stepFrameTime(0.0666666, 1)).toBe(3 / 30);
|
|
79
|
+
expect(stepFrameTime(0.0666666, -1)).toBe(1 / 30);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("clamps frame stepping at zero", () => {
|
|
83
|
+
expect(stepFrameTime(0, -1)).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/player/lib/time.ts
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
|
+
export const STUDIO_PREVIEW_FPS = 30;
|
|
2
|
+
|
|
1
3
|
export function formatTime(time: number): string {
|
|
2
4
|
if (!Number.isFinite(time) || time < 0) return "0:00";
|
|
3
5
|
const mins = Math.floor(time / 60);
|
|
4
6
|
const secs = Math.floor(time % 60);
|
|
5
7
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
6
8
|
}
|
|
9
|
+
|
|
10
|
+
export function secondsToFrame(time: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
11
|
+
if (!Number.isFinite(time) || time <= 0) return 0;
|
|
12
|
+
if (!Number.isFinite(fps) || fps <= 0) return 0;
|
|
13
|
+
return Math.round(time * fps);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function frameToSeconds(frame: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
17
|
+
if (!Number.isFinite(frame) || frame <= 0) return 0;
|
|
18
|
+
if (!Number.isFinite(fps) || fps <= 0) return 0;
|
|
19
|
+
return frame / fps;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function stepFrameTime(time: number, deltaFrames: number, fps = STUDIO_PREVIEW_FPS): number {
|
|
23
|
+
const currentFrame = secondsToFrame(time, fps);
|
|
24
|
+
const nextFrame = Math.max(0, currentFrame + deltaFrames);
|
|
25
|
+
return frameToSeconds(nextFrame, fps);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatFrameTime(time: number, duration: number, fps = STUDIO_PREVIEW_FPS): string {
|
|
29
|
+
const currentFrame = secondsToFrame(time, fps);
|
|
30
|
+
const totalFrames = secondsToFrame(duration, fps);
|
|
31
|
+
return `${currentFrame}f / ${totalFrames}f`;
|
|
32
|
+
}
|
|
@@ -16,6 +16,7 @@ describe("usePlayerStore", () => {
|
|
|
16
16
|
expect(state.elements).toEqual([]);
|
|
17
17
|
expect(state.selectedElementId).toBeNull();
|
|
18
18
|
expect(state.playbackRate).toBe(1);
|
|
19
|
+
expect(state.loopEnabled).toBe(false);
|
|
19
20
|
expect(state.zoomMode).toBe("fit");
|
|
20
21
|
expect(state.manualZoomPercent).toBe(100);
|
|
21
22
|
});
|
|
@@ -61,6 +62,13 @@ describe("usePlayerStore", () => {
|
|
|
61
62
|
});
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
describe("setLoopEnabled", () => {
|
|
66
|
+
it("updates loopEnabled", () => {
|
|
67
|
+
usePlayerStore.getState().setLoopEnabled(true);
|
|
68
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
64
72
|
describe("setTimelineReady", () => {
|
|
65
73
|
it("updates timelineReady", () => {
|
|
66
74
|
usePlayerStore.getState().setTimelineReady(true);
|
|
@@ -205,9 +213,10 @@ describe("usePlayerStore", () => {
|
|
|
205
213
|
expect(state.selectedElementId).toBeNull();
|
|
206
214
|
});
|
|
207
215
|
|
|
208
|
-
it("does not reset playbackRate, zoomMode, or manualZoomPercent", () => {
|
|
216
|
+
it("does not reset playbackRate, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
209
217
|
const store = usePlayerStore.getState();
|
|
210
218
|
store.setPlaybackRate(2);
|
|
219
|
+
store.setLoopEnabled(true);
|
|
211
220
|
store.setZoomMode("manual");
|
|
212
221
|
store.setManualZoomPercent(200);
|
|
213
222
|
|
|
@@ -216,6 +225,7 @@ describe("usePlayerStore", () => {
|
|
|
216
225
|
const state = usePlayerStore.getState();
|
|
217
226
|
// reset() only resets the fields explicitly listed in the reset function
|
|
218
227
|
expect(state.playbackRate).toBe(2);
|
|
228
|
+
expect(state.loopEnabled).toBe(true);
|
|
219
229
|
expect(state.zoomMode).toBe("manual");
|
|
220
230
|
expect(state.manualZoomPercent).toBe(200);
|
|
221
231
|
});
|
|
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|
|
2
2
|
|
|
3
3
|
export interface TimelineElement {
|
|
4
4
|
id: string;
|
|
5
|
+
label?: string;
|
|
5
6
|
key?: string;
|
|
6
7
|
tag: string;
|
|
7
8
|
start: number;
|
|
@@ -34,6 +35,7 @@ interface PlayerState {
|
|
|
34
35
|
elements: TimelineElement[];
|
|
35
36
|
selectedElementId: string | null;
|
|
36
37
|
playbackRate: number;
|
|
38
|
+
loopEnabled: boolean;
|
|
37
39
|
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
|
|
38
40
|
zoomMode: ZoomMode;
|
|
39
41
|
/** Timeline zoom percent relative to the fit width when in manual mode */
|
|
@@ -43,6 +45,7 @@ interface PlayerState {
|
|
|
43
45
|
setCurrentTime: (time: number) => void;
|
|
44
46
|
setDuration: (duration: number) => void;
|
|
45
47
|
setPlaybackRate: (rate: number) => void;
|
|
48
|
+
setLoopEnabled: (enabled: boolean) => void;
|
|
46
49
|
setTimelineReady: (ready: boolean) => void;
|
|
47
50
|
setElements: (elements: TimelineElement[]) => void;
|
|
48
51
|
setSelectedElementId: (id: string | null) => void;
|
|
@@ -76,11 +79,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
76
79
|
elements: [],
|
|
77
80
|
selectedElementId: null,
|
|
78
81
|
playbackRate: 1,
|
|
82
|
+
loopEnabled: false,
|
|
79
83
|
zoomMode: "fit",
|
|
80
84
|
manualZoomPercent: 100,
|
|
81
85
|
|
|
82
86
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
|
83
87
|
setPlaybackRate: (rate) => set({ playbackRate: rate }),
|
|
88
|
+
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
84
89
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
85
90
|
setManualZoomPercent: (percent) =>
|
|
86
91
|
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
|
@@ -96,7 +101,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
96
101
|
),
|
|
97
102
|
})),
|
|
98
103
|
// Resets project-specific state when switching compositions.
|
|
99
|
-
// playbackRate, zoomMode, and manualZoomPercent are intentionally preserved
|
|
104
|
+
// playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
|
|
100
105
|
// because they are user preferences that should survive project switches.
|
|
101
106
|
reset: () =>
|
|
102
107
|
set({
|
package/src/styles/studio.css
CHANGED
|
@@ -49,3 +49,115 @@ body {
|
|
|
49
49
|
.cm-editor.cm-focused {
|
|
50
50
|
outline: none;
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* HyperFrames brand loader. Shared by preview overlays that need a calm,
|
|
55
|
+
* branded loading state instead of a generic spinner.
|
|
56
|
+
*/
|
|
57
|
+
.hf-loader {
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
gap: 0.75rem;
|
|
63
|
+
width: min(34rem, 100%);
|
|
64
|
+
padding: 1.5rem;
|
|
65
|
+
box-sizing: border-box;
|
|
66
|
+
text-align: center;
|
|
67
|
+
cursor: default;
|
|
68
|
+
user-select: none;
|
|
69
|
+
-webkit-user-select: none;
|
|
70
|
+
-webkit-user-drag: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.hf-frame {
|
|
74
|
+
display: grid;
|
|
75
|
+
place-items: center;
|
|
76
|
+
width: 100%;
|
|
77
|
+
height: 100%;
|
|
78
|
+
min-height: 12rem;
|
|
79
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
80
|
+
background: rgba(0, 0, 0, 0.52);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.hf-loader-mark-frame {
|
|
84
|
+
display: grid;
|
|
85
|
+
place-items: center;
|
|
86
|
+
overflow: visible;
|
|
87
|
+
transform-origin: 50% 50%;
|
|
88
|
+
user-select: none;
|
|
89
|
+
-webkit-user-select: none;
|
|
90
|
+
-webkit-user-drag: none;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.hf-loader-mark {
|
|
94
|
+
display: block;
|
|
95
|
+
overflow: visible;
|
|
96
|
+
filter: drop-shadow(0 0 7px rgba(79, 219, 94, 0.2));
|
|
97
|
+
user-select: none;
|
|
98
|
+
-webkit-user-select: none;
|
|
99
|
+
-webkit-user-drag: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.hf-loader-title {
|
|
103
|
+
font-family:
|
|
104
|
+
Inter,
|
|
105
|
+
-apple-system,
|
|
106
|
+
BlinkMacSystemFont,
|
|
107
|
+
"Segoe UI",
|
|
108
|
+
sans-serif;
|
|
109
|
+
font-size: 1rem;
|
|
110
|
+
font-weight: 600;
|
|
111
|
+
letter-spacing: 0;
|
|
112
|
+
color: var(--hf-heading, #f4f4f5);
|
|
113
|
+
max-width: 100%;
|
|
114
|
+
overflow: hidden;
|
|
115
|
+
text-overflow: ellipsis;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.hf-loader-detail {
|
|
120
|
+
max-width: 32rem;
|
|
121
|
+
min-height: 2.5rem;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
color: var(--hf-text-secondary, rgba(244, 244, 245, 0.68));
|
|
124
|
+
font-family:
|
|
125
|
+
Inter,
|
|
126
|
+
-apple-system,
|
|
127
|
+
BlinkMacSystemFont,
|
|
128
|
+
"Segoe UI",
|
|
129
|
+
sans-serif;
|
|
130
|
+
font-size: 0.82rem;
|
|
131
|
+
line-height: 1.6;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.hf-loader-mono {
|
|
135
|
+
width: min(36rem, 100%);
|
|
136
|
+
min-height: 1.5rem;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
text-overflow: ellipsis;
|
|
139
|
+
white-space: nowrap;
|
|
140
|
+
color: var(--hf-text-tertiary, rgba(244, 244, 245, 0.46));
|
|
141
|
+
font-family: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
|
|
142
|
+
font-size: 0.75rem;
|
|
143
|
+
letter-spacing: 0;
|
|
144
|
+
font-variant-numeric: tabular-nums;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.hf-loader-progress {
|
|
148
|
+
width: min(18rem, 72vw);
|
|
149
|
+
height: 0.375rem;
|
|
150
|
+
overflow: hidden;
|
|
151
|
+
border-radius: 999px;
|
|
152
|
+
background: rgba(255, 255, 255, 0.1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.hf-loader-progress__fill {
|
|
156
|
+
width: 100%;
|
|
157
|
+
height: 100%;
|
|
158
|
+
transform: scaleX(0);
|
|
159
|
+
transform-origin: left center;
|
|
160
|
+
border-radius: inherit;
|
|
161
|
+
background: linear-gradient(90deg, #06e3fa, #4fdb5e);
|
|
162
|
+
transition: transform 160ms ease;
|
|
163
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "./frameCapture";
|
|
3
|
+
|
|
4
|
+
describe("frame capture utilities", () => {
|
|
5
|
+
it("builds a PNG capture URL for the master composition", () => {
|
|
6
|
+
vi.useFakeTimers();
|
|
7
|
+
vi.setSystemTime(new Date("2026-04-29T12:00:00Z"));
|
|
8
|
+
|
|
9
|
+
expect(
|
|
10
|
+
buildFrameCaptureUrl({
|
|
11
|
+
projectId: "demo project",
|
|
12
|
+
compositionPath: null,
|
|
13
|
+
currentTime: 1.23456,
|
|
14
|
+
origin: "http://localhost:5194",
|
|
15
|
+
}),
|
|
16
|
+
).toBe(
|
|
17
|
+
"http://localhost:5194/api/projects/demo%20project/thumbnail/index.html?t=1.235&format=png&v=1777464000000",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
vi.useRealTimers();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("builds a safe filename from a nested composition path", () => {
|
|
24
|
+
expect(buildFrameCaptureFilename("compositions/intro.html", 2.5)).toBe("intro-2-500s.png");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { buildProjectApiPath } from "./projectRouting";
|
|
2
|
+
|
|
3
|
+
export interface FrameCaptureRequest {
|
|
4
|
+
projectId: string;
|
|
5
|
+
compositionPath: string | null;
|
|
6
|
+
currentTime: number;
|
|
7
|
+
origin?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeCompositionPath(compositionPath: string | null): string {
|
|
11
|
+
return compositionPath && compositionPath !== "master" ? compositionPath : "index.html";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildFrameCaptureUrl({
|
|
15
|
+
projectId,
|
|
16
|
+
compositionPath,
|
|
17
|
+
currentTime,
|
|
18
|
+
origin = window.location.origin,
|
|
19
|
+
}: FrameCaptureRequest): string {
|
|
20
|
+
const compPath = normalizeCompositionPath(compositionPath);
|
|
21
|
+
const url = new URL(
|
|
22
|
+
buildProjectApiPath(projectId, `/thumbnail/${encodeURIComponent(compPath)}`),
|
|
23
|
+
origin,
|
|
24
|
+
);
|
|
25
|
+
url.searchParams.set("t", Math.max(0, currentTime).toFixed(3));
|
|
26
|
+
url.searchParams.set("format", "png");
|
|
27
|
+
url.searchParams.set("v", String(Date.now()));
|
|
28
|
+
return url.toString();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildFrameCaptureFilename(compositionPath: string | null, currentTime: number) {
|
|
32
|
+
const compPath = normalizeCompositionPath(compositionPath);
|
|
33
|
+
const base =
|
|
34
|
+
compPath
|
|
35
|
+
.split("/")
|
|
36
|
+
.pop()
|
|
37
|
+
?.replace(/\.html$/i, "") || "frame";
|
|
38
|
+
const frameTime = Math.max(0, currentTime).toFixed(3).replace(".", "-");
|
|
39
|
+
return `${base}-${frameTime}s.png`;
|
|
40
|
+
}
|
package/src/utils/mediaTypes.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
|
2
2
|
export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
3
3
|
export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
4
|
-
export const FONT_EXT = /\.(woff|woff2|ttf|
|
|
4
|
+
export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i;
|
|
5
5
|
export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
|
6
6
|
|
|
7
7
|
export function isMediaFile(path: string): boolean {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { buildFrameCaptureUrl } from "./frameCapture";
|
|
3
|
+
import {
|
|
4
|
+
buildProjectApiPath,
|
|
5
|
+
buildProjectHash,
|
|
6
|
+
encodeProjectId,
|
|
7
|
+
parseProjectIdFromHash,
|
|
8
|
+
} from "./projectRouting";
|
|
9
|
+
|
|
10
|
+
describe("project routing utilities", () => {
|
|
11
|
+
it("decodes project ids from hash routes before building capture URLs", () => {
|
|
12
|
+
vi.useFakeTimers();
|
|
13
|
+
vi.setSystemTime(new Date("2026-05-01T12:00:00Z"));
|
|
14
|
+
|
|
15
|
+
const projectId = parseProjectIdFromHash("#project/Notion%20Showcase");
|
|
16
|
+
|
|
17
|
+
expect(projectId).toBe("Notion Showcase");
|
|
18
|
+
expect(
|
|
19
|
+
buildFrameCaptureUrl({
|
|
20
|
+
projectId: projectId ?? "",
|
|
21
|
+
compositionPath: null,
|
|
22
|
+
currentTime: 1.809,
|
|
23
|
+
origin: "http://localhost:3002",
|
|
24
|
+
}),
|
|
25
|
+
).toBe(
|
|
26
|
+
"http://localhost:3002/api/projects/Notion%20Showcase/thumbnail/index.html?t=1.809&format=png&v=1777636800000",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
vi.useRealTimers();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("accepts legacy raw-space hash routes", () => {
|
|
33
|
+
expect(parseProjectIdFromHash("#project/Notion Showcase")).toBe("Notion Showcase");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("decodes reserved characters when the hash route is encoded", () => {
|
|
37
|
+
expect(parseProjectIdFromHash("#project/Launch%20%231%3F%20v2")).toBe("Launch #1? v2");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not throw on malformed percent escapes in hash routes", () => {
|
|
41
|
+
expect(parseProjectIdFromHash("#project/Broken%ZZName")).toBe("Broken%ZZName");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ignores non-project hash routes", () => {
|
|
45
|
+
expect(parseProjectIdFromHash("")).toBeNull();
|
|
46
|
+
expect(parseProjectIdFromHash("#settings")).toBeNull();
|
|
47
|
+
expect(parseProjectIdFromHash("#project/")).toBeNull();
|
|
48
|
+
expect(parseProjectIdFromHash("#project/foo/bar")).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("encodes project ids when writing hash routes", () => {
|
|
52
|
+
expect(buildProjectHash("Notion Showcase")).toBe("#project/Notion%20Showcase");
|
|
53
|
+
expect(buildProjectHash("Notion%20Showcase")).toBe("#project/Notion%2520Showcase");
|
|
54
|
+
expect(buildProjectHash("Launch #1? v2")).toBe("#project/Launch%20%231%3F%20v2");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("round-trips unicode project ids through hash routes", () => {
|
|
58
|
+
const hash = buildProjectHash("Mañana demo");
|
|
59
|
+
|
|
60
|
+
expect(hash).toBe("#project/Ma%C3%B1ana%20demo");
|
|
61
|
+
expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("encodes project ids as one API path segment", () => {
|
|
65
|
+
expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
|
|
66
|
+
expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
|
|
67
|
+
expect(encodeProjectId("Launch #1? v2")).toBe("Launch%20%231%3F%20v2");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("builds API paths without double encoding decoded project ids", () => {
|
|
71
|
+
expect(buildProjectApiPath("Notion Showcase", "/thumbnail/index.html")).toBe(
|
|
72
|
+
"/api/projects/Notion%20Showcase/thumbnail/index.html",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps literal percent signs safe in API paths", () => {
|
|
77
|
+
expect(buildProjectApiPath("Percent%20Name", "/preview")).toBe(
|
|
78
|
+
"/api/projects/Percent%2520Name/preview",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps unicode project ids safe in API paths", () => {
|
|
83
|
+
expect(buildProjectApiPath("Mañana demo", "/preview")).toBe(
|
|
84
|
+
"/api/projects/Ma%C3%B1ana%20demo/preview",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const PROJECT_HASH_PREFIX = "#project/";
|
|
2
|
+
|
|
3
|
+
export function encodeProjectId(projectId: string): string {
|
|
4
|
+
return encodeURIComponent(projectId);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function buildProjectHash(projectId: string): string {
|
|
8
|
+
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseProjectIdFromHash(hash: string): string | null {
|
|
12
|
+
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
|
|
13
|
+
|
|
14
|
+
const encodedProjectId = hash.slice(PROJECT_HASH_PREFIX.length);
|
|
15
|
+
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
return decodeURIComponent(encodedProjectId);
|
|
19
|
+
} catch {
|
|
20
|
+
return encodedProjectId;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildProjectApiPath(projectId: string, suffix = ""): string {
|
|
25
|
+
const normalizedSuffix = suffix && !suffix.startsWith("/") ? `/${suffix}` : suffix;
|
|
26
|
+
return `/api/projects/${encodeProjectId(projectId)}${normalizedSuffix}`;
|
|
27
|
+
}
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
applyPatch,
|
|
4
|
-
applyPatchByTarget,
|
|
5
|
-
readAttributeByTarget,
|
|
6
|
-
readTagSnippetByTarget,
|
|
7
|
-
type PatchOperation,
|
|
8
|
-
} from "./sourcePatcher";
|
|
2
|
+
import { applyPatchByTarget, readAttributeByTarget, type PatchOperation } from "./sourcePatcher";
|
|
9
3
|
|
|
10
4
|
describe("applyPatchByTarget", () => {
|
|
11
5
|
it("updates a composition host by data-composition-id selector", () => {
|
|
@@ -35,64 +29,6 @@ describe("applyPatchByTarget", () => {
|
|
|
35
29
|
);
|
|
36
30
|
});
|
|
37
31
|
|
|
38
|
-
it("patches inline move styles by target", () => {
|
|
39
|
-
const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
|
|
40
|
-
|
|
41
|
-
const withLeft = applyPatchByTarget(
|
|
42
|
-
html,
|
|
43
|
-
{ id: "card" },
|
|
44
|
-
{ type: "inline-style", property: "left", value: "160px" },
|
|
45
|
-
);
|
|
46
|
-
const withTop = applyPatchByTarget(
|
|
47
|
-
withLeft,
|
|
48
|
-
{ id: "card" },
|
|
49
|
-
{ type: "inline-style", property: "top", value: "140px" },
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
expect(withTop).toContain('style="position: absolute; left: 160px; top: 140px"');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("patches inline resize styles by target", () => {
|
|
56
|
-
const html = `<div id="card" style="position: absolute; width: 380px; height: 196px"></div>`;
|
|
57
|
-
|
|
58
|
-
const withWidth = applyPatchByTarget(
|
|
59
|
-
html,
|
|
60
|
-
{ id: "card" },
|
|
61
|
-
{ type: "inline-style", property: "width", value: "420px" },
|
|
62
|
-
);
|
|
63
|
-
const withHeight = applyPatchByTarget(
|
|
64
|
-
withWidth,
|
|
65
|
-
{ id: "card" },
|
|
66
|
-
{ type: "inline-style", property: "height", value: "220px" },
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
expect(withHeight).toContain('style="position: absolute; width: 420px; height: 220px"');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("escapes quoted CSS urls inside double-quoted style attributes", () => {
|
|
73
|
-
const html = `<div id="card" style="position: absolute; opacity: 1"></div>`;
|
|
74
|
-
|
|
75
|
-
const withBackground = applyPatchByTarget(
|
|
76
|
-
html,
|
|
77
|
-
{ id: "card" },
|
|
78
|
-
{
|
|
79
|
-
type: "inline-style",
|
|
80
|
-
property: "background-image",
|
|
81
|
-
value: `url("../ChatGPT Image Apr 22, 2026.png")`,
|
|
82
|
-
},
|
|
83
|
-
);
|
|
84
|
-
const withRadius = applyPatchByTarget(
|
|
85
|
-
withBackground,
|
|
86
|
-
{ id: "card" },
|
|
87
|
-
{ type: "inline-style", property: "border-radius", value: "12px" },
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
expect(withRadius).toContain(
|
|
91
|
-
"background-image: url("../ChatGPT Image Apr 22, 2026.png")",
|
|
92
|
-
);
|
|
93
|
-
expect(withRadius).toContain("border-radius: 12px");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
32
|
it("updates media timing attributes by selector", () => {
|
|
97
33
|
const html = `<video class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></video>`;
|
|
98
34
|
|
|
@@ -126,69 +62,6 @@ describe("applyPatchByTarget", () => {
|
|
|
126
62
|
expect(readAttributeByTarget(html, { selector: ".hero" }, "duration")).toBe("1.4");
|
|
127
63
|
});
|
|
128
64
|
|
|
129
|
-
it("reads the matched tag snippet by target", () => {
|
|
130
|
-
const html = `<section id="hero" class="card clip" style="left: 120px; top: 180px"></section>`;
|
|
131
|
-
|
|
132
|
-
expect(readTagSnippetByTarget(html, { id: "hero" })).toBe(
|
|
133
|
-
`<section id="hero" class="card clip" style="left: 120px; top: 180px"`,
|
|
134
|
-
);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("patches and reads single-quoted attributes and styles", () => {
|
|
138
|
-
const html =
|
|
139
|
-
"<section id='hero' class='card clip' data-start='0.2' style='left: 120px; top: 180px'></section>";
|
|
140
|
-
|
|
141
|
-
const moved = applyPatchByTarget(
|
|
142
|
-
html,
|
|
143
|
-
{ id: "hero" },
|
|
144
|
-
{ type: "inline-style", property: "left", value: "160px" },
|
|
145
|
-
);
|
|
146
|
-
const updated = applyPatchByTarget(
|
|
147
|
-
moved,
|
|
148
|
-
{ id: "hero" },
|
|
149
|
-
{ type: "attribute", property: "start", value: "0.4" },
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
expect(updated).toContain(`style='left: 160px; top: 180px'`);
|
|
153
|
-
expect(updated).toContain(`data-start="0.4"`);
|
|
154
|
-
expect(readAttributeByTarget(updated, { id: "hero" }, "start")).toBe("0.4");
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("replaces the full text body of a nested element by id", () => {
|
|
158
|
-
const html =
|
|
159
|
-
'<div id="panel"><strong>Headline</strong><span>Supporting copy</span></div><p>Outside</p>';
|
|
160
|
-
|
|
161
|
-
const patched = applyPatch(html, "panel", {
|
|
162
|
-
type: "text-content",
|
|
163
|
-
property: "text",
|
|
164
|
-
value: "<strong>New headline</strong><span>New supporting copy</span>",
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
expect(patched).toContain(
|
|
168
|
-
'<div id="panel"><strong>New headline</strong><span>New supporting copy</span></div>',
|
|
169
|
-
);
|
|
170
|
-
expect(patched).toContain("<p>Outside</p>");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("does not stop at the first child closing tag when patching nested text", () => {
|
|
174
|
-
const html =
|
|
175
|
-
'<section id="card"><div><strong>Headline</strong></div><div>Copy</div></section><p>Outside</p>';
|
|
176
|
-
|
|
177
|
-
const patched = applyPatchByTarget(
|
|
178
|
-
html,
|
|
179
|
-
{ id: "card" },
|
|
180
|
-
{
|
|
181
|
-
type: "text-content",
|
|
182
|
-
property: "text",
|
|
183
|
-
value: "<strong>New headline</strong>",
|
|
184
|
-
},
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
expect(patched).toBe(
|
|
188
|
-
'<section id="card"><strong>New headline</strong></section><p>Outside</p>',
|
|
189
|
-
);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
65
|
it("patches the correct duplicate selector occurrence", () => {
|
|
193
66
|
const html = [
|
|
194
67
|
`<div class="headline clip" data-start="0"></div>`,
|