@hyperframes/studio 0.6.113 → 0.6.115
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-CNgCuEoe.js +425 -0
- package/dist/assets/{index-B8Qzwcpc.js → index-BIIWBkq6.js} +120 -116
- package/dist/assets/index-BvEhf7SS.css +1 -0
- package/dist/assets/{index-C0tFko2e.js → index-DjSzIHsE.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +5 -5
- package/src/App.tsx +4 -0
- package/src/components/StudioRightPanel.tsx +98 -4
- package/src/components/editor/LayersPanel.tsx +1 -1
- package/src/components/editor/domEditingElement.ts +1 -1
- package/src/components/nle/NLELayout.tsx +1 -1
- package/src/components/panels/SlideshowPanel.test.ts +520 -0
- package/src/components/panels/SlideshowPanel.tsx +472 -0
- package/src/components/panels/SlideshowSubPanels.tsx +507 -0
- package/src/components/panels/slideshowPanelHelpers.ts +232 -0
- package/src/hooks/useSlideshowPersist.ts +68 -0
- package/src/player/lib/timelineDOM.ts +2 -1
- package/src/player/lib/timelineElementHelpers.ts +5 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -3
- package/src/test-setup.ts +6 -0
- package/src/utils/htmlAttrSafety.ts +90 -0
- package/src/utils/sdkCutover.test.ts +51 -0
- package/src/utils/sdkCutover.ts +57 -4
- package/src/utils/sdkCutoverParity.test.ts +113 -0
- package/src/utils/setSlideshowManifest.test.ts +146 -0
- package/src/utils/setSlideshowManifest.ts +102 -0
- package/src/utils/studioHelpers.ts +2 -1
- package/dist/assets/hyperframes-player-BRjQ5a5_.js +0 -418
- package/dist/assets/index-DP8pPIk2.css +0 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure manifest-transform helpers for SlideshowPanel.
|
|
3
|
+
* No React, no side-effects — fully unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SlideshowManifest, SlideRef, SlideHotspot } from "@hyperframes/core/slideshow";
|
|
7
|
+
|
|
8
|
+
// ── Scene shape used by the panel UI ──────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface SceneInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
start: number;
|
|
14
|
+
duration: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Pure manifest transforms ───────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/** Toggle a scene in the main-line slide list. */
|
|
20
|
+
export function toggleMainLineSlide(
|
|
21
|
+
manifest: SlideshowManifest,
|
|
22
|
+
sceneId: string,
|
|
23
|
+
): SlideshowManifest {
|
|
24
|
+
const exists = manifest.slides.some((s) => s.sceneId === sceneId);
|
|
25
|
+
const slides: SlideRef[] = exists
|
|
26
|
+
? manifest.slides.filter((s) => s.sceneId !== sceneId)
|
|
27
|
+
: [...manifest.slides, { sceneId }];
|
|
28
|
+
return { ...manifest, slides };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// fallow-ignore-next-line complexity
|
|
32
|
+
/** Move a main-line slide up or down by one position. */
|
|
33
|
+
/** Swap the slide with `sceneId` one step up/down within a slide list. */
|
|
34
|
+
function swapSlide(slides: SlideRef[], sceneId: string, direction: "up" | "down"): SlideRef[] {
|
|
35
|
+
const idx = slides.findIndex((s) => s.sceneId === sceneId);
|
|
36
|
+
if (idx === -1) return slides;
|
|
37
|
+
const next = direction === "up" ? idx - 1 : idx + 1;
|
|
38
|
+
if (next < 0 || next >= slides.length) return slides;
|
|
39
|
+
const out = [...slides];
|
|
40
|
+
const a = out[idx];
|
|
41
|
+
const b = out[next];
|
|
42
|
+
if (!a || !b) return slides;
|
|
43
|
+
out[idx] = b;
|
|
44
|
+
out[next] = a;
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function reorderMainLineSlide(
|
|
49
|
+
manifest: SlideshowManifest,
|
|
50
|
+
sceneId: string,
|
|
51
|
+
direction: "up" | "down",
|
|
52
|
+
): SlideshowManifest {
|
|
53
|
+
return mapSlidesIn(manifest, undefined, (slides) => swapSlide(slides, sceneId, direction));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reorder a slide within a branch sequence (parallel to reorderMainLineSlide). */
|
|
57
|
+
export function reorderBranchSlide(
|
|
58
|
+
manifest: SlideshowManifest,
|
|
59
|
+
sequenceId: string,
|
|
60
|
+
sceneId: string,
|
|
61
|
+
direction: "up" | "down",
|
|
62
|
+
): SlideshowManifest {
|
|
63
|
+
return mapSlidesIn(manifest, sequenceId, (slides) => swapSlide(slides, sceneId, direction));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Apply fn to a branch's slide list (sequenceId) or the main line (undefined). */
|
|
67
|
+
function mapSlidesIn(
|
|
68
|
+
manifest: SlideshowManifest,
|
|
69
|
+
sequenceId: string | undefined,
|
|
70
|
+
fn: (slides: SlideRef[]) => SlideRef[],
|
|
71
|
+
): SlideshowManifest {
|
|
72
|
+
if (sequenceId === undefined) {
|
|
73
|
+
return { ...manifest, slides: fn(manifest.slides) };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...manifest,
|
|
77
|
+
slideSequences: (manifest.slideSequences ?? []).map((seq) =>
|
|
78
|
+
seq.id === sequenceId ? { ...seq, slides: fn(seq.slides) } : seq,
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Update notes on a main-line slide (adds slide entry if absent). */
|
|
84
|
+
export function setSlideNotes(
|
|
85
|
+
manifest: SlideshowManifest,
|
|
86
|
+
sceneId: string,
|
|
87
|
+
notes: string,
|
|
88
|
+
sequenceId?: string,
|
|
89
|
+
): SlideshowManifest {
|
|
90
|
+
return mapSlidesIn(manifest, sequenceId, (slides) => {
|
|
91
|
+
const exists = slides.some((s) => s.sceneId === sceneId);
|
|
92
|
+
if (exists) return slides.map((s) => (s.sceneId === sceneId ? { ...s, notes } : s));
|
|
93
|
+
return sequenceId === undefined ? [...slides, { sceneId, notes }] : slides;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Push a fragment hold-point time onto a main-line slide. Deduplicates + sorts. */
|
|
98
|
+
export function addFragment(
|
|
99
|
+
manifest: SlideshowManifest,
|
|
100
|
+
sceneId: string,
|
|
101
|
+
time: number,
|
|
102
|
+
sequenceId?: string,
|
|
103
|
+
): SlideshowManifest {
|
|
104
|
+
return mapSlidesIn(manifest, sequenceId, (slides) => {
|
|
105
|
+
const exists = slides.some((s) => s.sceneId === sceneId);
|
|
106
|
+
if (exists)
|
|
107
|
+
return slides.map((s) => {
|
|
108
|
+
if (s.sceneId !== sceneId) return s;
|
|
109
|
+
const frags = [...new Set([...(s.fragments ?? []), time])].sort((a, b) => a - b);
|
|
110
|
+
return { ...s, fragments: frags };
|
|
111
|
+
});
|
|
112
|
+
return sequenceId === undefined ? [...slides, { sceneId, fragments: [time] }] : slides;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Remove a fragment hold-point by value from a main-line slide. */
|
|
117
|
+
export function removeFragment(
|
|
118
|
+
manifest: SlideshowManifest,
|
|
119
|
+
sceneId: string,
|
|
120
|
+
time: number,
|
|
121
|
+
sequenceId?: string,
|
|
122
|
+
): SlideshowManifest {
|
|
123
|
+
return mapSlidesIn(manifest, sequenceId, (slides) =>
|
|
124
|
+
slides.map((s) =>
|
|
125
|
+
s.sceneId === sceneId
|
|
126
|
+
? { ...s, fragments: (s.fragments ?? []).filter((f) => f !== time) }
|
|
127
|
+
: s,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Create a new branch sequence. Rejects duplicate ids. */
|
|
133
|
+
export function createSequence(
|
|
134
|
+
manifest: SlideshowManifest,
|
|
135
|
+
id: string,
|
|
136
|
+
label: string,
|
|
137
|
+
): SlideshowManifest {
|
|
138
|
+
const existing = manifest.slideSequences ?? [];
|
|
139
|
+
if (existing.some((seq) => seq.id === id)) return manifest;
|
|
140
|
+
return {
|
|
141
|
+
...manifest,
|
|
142
|
+
slideSequences: [...existing, { id, label, slides: [] }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Rename an existing branch sequence label. */
|
|
147
|
+
export function renameSequence(
|
|
148
|
+
manifest: SlideshowManifest,
|
|
149
|
+
id: string,
|
|
150
|
+
label: string,
|
|
151
|
+
): SlideshowManifest {
|
|
152
|
+
return {
|
|
153
|
+
...manifest,
|
|
154
|
+
slideSequences: (manifest.slideSequences ?? []).map((seq) =>
|
|
155
|
+
seq.id === id ? { ...seq, label } : seq,
|
|
156
|
+
),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function pruneHotspots(slides: SlideRef[], targetId: string): SlideRef[] {
|
|
161
|
+
return slides.map((s) => {
|
|
162
|
+
if (!s.hotspots) return s;
|
|
163
|
+
const hotspots = s.hotspots.filter((h) => h.target !== targetId);
|
|
164
|
+
return hotspots.length === s.hotspots.length ? s : { ...s, hotspots };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Delete a branch sequence by id, removing any hotspot targeting it. */
|
|
169
|
+
export function deleteSequence(manifest: SlideshowManifest, id: string): SlideshowManifest {
|
|
170
|
+
const remainingSequences = (manifest.slideSequences ?? []).filter((seq) => seq.id !== id);
|
|
171
|
+
return {
|
|
172
|
+
...manifest,
|
|
173
|
+
slides: pruneHotspots(manifest.slides, id),
|
|
174
|
+
slideSequences: remainingSequences.map((seq) => ({
|
|
175
|
+
...seq,
|
|
176
|
+
slides: pruneHotspots(seq.slides, id),
|
|
177
|
+
})),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Add or remove a scene slide from a branch sequence. */
|
|
182
|
+
export function assignToBranch(
|
|
183
|
+
manifest: SlideshowManifest,
|
|
184
|
+
sequenceId: string,
|
|
185
|
+
sceneId: string,
|
|
186
|
+
assign: boolean,
|
|
187
|
+
): SlideshowManifest {
|
|
188
|
+
return {
|
|
189
|
+
...manifest,
|
|
190
|
+
slideSequences: (manifest.slideSequences ?? []).map((seq) => {
|
|
191
|
+
if (seq.id !== sequenceId) return seq;
|
|
192
|
+
if (assign) {
|
|
193
|
+
if (seq.slides.some((s) => s.sceneId === sceneId)) return seq;
|
|
194
|
+
return { ...seq, slides: [...seq.slides, { sceneId }] };
|
|
195
|
+
}
|
|
196
|
+
return { ...seq, slides: seq.slides.filter((s) => s.sceneId !== sceneId) };
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Add a hotspot to a main-line slide. */
|
|
202
|
+
export function addHotspot(
|
|
203
|
+
manifest: SlideshowManifest,
|
|
204
|
+
sceneId: string,
|
|
205
|
+
hotspot: SlideHotspot,
|
|
206
|
+
sequenceId?: string,
|
|
207
|
+
): SlideshowManifest {
|
|
208
|
+
return mapSlidesIn(manifest, sequenceId, (slides) =>
|
|
209
|
+
slides.map((s) => {
|
|
210
|
+
if (s.sceneId !== sceneId) return s;
|
|
211
|
+
const existing = s.hotspots ?? [];
|
|
212
|
+
if (existing.some((h) => h.id === hotspot.id)) return s;
|
|
213
|
+
return { ...s, hotspots: [...existing, hotspot] };
|
|
214
|
+
}),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Remove a hotspot by id from a main-line slide. */
|
|
219
|
+
export function removeHotspot(
|
|
220
|
+
manifest: SlideshowManifest,
|
|
221
|
+
sceneId: string,
|
|
222
|
+
hotspotId: string,
|
|
223
|
+
sequenceId?: string,
|
|
224
|
+
): SlideshowManifest {
|
|
225
|
+
return mapSlidesIn(manifest, sequenceId, (slides) =>
|
|
226
|
+
slides.map((s) =>
|
|
227
|
+
s.sceneId === sceneId
|
|
228
|
+
? { ...s, hotspots: (s.hotspots ?? []).filter((h) => h.id !== hotspotId) }
|
|
229
|
+
: s,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useCallback, type MutableRefObject } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import type { SlideshowManifest } from "@hyperframes/core/slideshow";
|
|
4
|
+
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
+
import { persistSlideshowManifest } from "../utils/setSlideshowManifest";
|
|
6
|
+
|
|
7
|
+
export interface UseSlideshowPersistParams {
|
|
8
|
+
sdkSession: Composition | null;
|
|
9
|
+
activeCompPath: string | null;
|
|
10
|
+
readProjectFile: (path: string) => Promise<string>;
|
|
11
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
12
|
+
recordEdit: (entry: {
|
|
13
|
+
label: string;
|
|
14
|
+
kind: EditHistoryKind;
|
|
15
|
+
files: Record<string, { before: string; after: string }>;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
reloadPreview: () => void;
|
|
18
|
+
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
19
|
+
/**
|
|
20
|
+
* When provided, rapid writes with the same key coalesce through the
|
|
21
|
+
* save-queue infra (via recordEdit's coalesceKey) so back-to-back persists
|
|
22
|
+
* collapse to a single undo entry rather than polluting history.
|
|
23
|
+
* Pass e.g. `"slideshow-notes:" + activeCompPath` for the notes path.
|
|
24
|
+
*/
|
|
25
|
+
coalesceKey?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function useSlideshowPersist({
|
|
29
|
+
sdkSession,
|
|
30
|
+
activeCompPath,
|
|
31
|
+
readProjectFile,
|
|
32
|
+
writeProjectFile,
|
|
33
|
+
recordEdit,
|
|
34
|
+
reloadPreview,
|
|
35
|
+
domEditSaveTimestampRef,
|
|
36
|
+
coalesceKey,
|
|
37
|
+
}: UseSlideshowPersistParams): (manifest: SlideshowManifest) => Promise<void> {
|
|
38
|
+
return useCallback(
|
|
39
|
+
async (manifest: SlideshowManifest) => {
|
|
40
|
+
if (!sdkSession) return;
|
|
41
|
+
const path = activeCompPath ?? "index.html";
|
|
42
|
+
const originalContent = await readProjectFile(path);
|
|
43
|
+
await persistSlideshowManifest({
|
|
44
|
+
manifest,
|
|
45
|
+
sdkSession,
|
|
46
|
+
originalContent,
|
|
47
|
+
targetPath: path,
|
|
48
|
+
deps: {
|
|
49
|
+
editHistory: { recordEdit },
|
|
50
|
+
writeProjectFile,
|
|
51
|
+
reloadPreview,
|
|
52
|
+
domEditSaveTimestampRef,
|
|
53
|
+
},
|
|
54
|
+
coalesceKey,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
[
|
|
58
|
+
sdkSession,
|
|
59
|
+
activeCompPath,
|
|
60
|
+
readProjectFile,
|
|
61
|
+
writeProjectFile,
|
|
62
|
+
recordEdit,
|
|
63
|
+
reloadPreview,
|
|
64
|
+
domEditSaveTimestampRef,
|
|
65
|
+
coalesceKey,
|
|
66
|
+
],
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -123,7 +123,8 @@ export function createTimelineElementFromManifestClip(params: {
|
|
|
123
123
|
if (clip.kind === "composition" && clip.compositionId) {
|
|
124
124
|
let resolvedSrc = clip.compositionSrc;
|
|
125
125
|
if (!resolvedSrc) {
|
|
126
|
-
hostEl =
|
|
126
|
+
hostEl =
|
|
127
|
+
doc?.querySelector(`[data-composition-id="${CSS.escape(clip.compositionId)}"]`) ?? hostEl;
|
|
127
128
|
resolvedSrc =
|
|
128
129
|
hostEl?.getAttribute("data-composition-src") ??
|
|
129
130
|
hostEl?.getAttribute("data-composition-file") ??
|
|
@@ -163,13 +163,13 @@ export function getImplicitTimelineLayerLabel(el: HTMLElement): string {
|
|
|
163
163
|
// ---------------------------------------------------------------------------
|
|
164
164
|
|
|
165
165
|
export function getTimelineElementSelector(el: Element): string | undefined {
|
|
166
|
-
if (isHtmlElement(el) && el.id) return `#${el.id}`;
|
|
166
|
+
if (isHtmlElement(el) && el.id) return `#${CSS.escape(el.id)}`;
|
|
167
167
|
const compId = el.getAttribute("data-composition-id");
|
|
168
|
-
if (compId) return `[data-composition-id="${compId}"]`;
|
|
168
|
+
if (compId) return `[data-composition-id="${CSS.escape(compId)}"]`;
|
|
169
169
|
if (isHtmlElement(el)) {
|
|
170
170
|
const classes = el.className.split(/\s+/).filter(Boolean);
|
|
171
171
|
const firstClass = classes.find((className) => className !== "clip") ?? classes[0];
|
|
172
|
-
if (firstClass) return `.${firstClass}`;
|
|
172
|
+
if (firstClass) return `.${CSS.escape(firstClass)}`;
|
|
173
173
|
}
|
|
174
174
|
return undefined;
|
|
175
175
|
}
|
|
@@ -283,8 +283,8 @@ function nodeMatchesManifestClip(node: Element, clip: ClipManifestClip): boolean
|
|
|
283
283
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|
284
284
|
return (
|
|
285
285
|
doc.getElementById(id) ??
|
|
286
|
-
doc.querySelector(`[data-composition-id="${id}"]`) ??
|
|
287
|
-
doc.querySelector(`.${id}`) ??
|
|
286
|
+
doc.querySelector(`[data-composition-id="${CSS.escape(id)}"]`) ??
|
|
287
|
+
doc.querySelector(`.${CSS.escape(id)}`) ??
|
|
288
288
|
null
|
|
289
289
|
);
|
|
290
290
|
}
|
|
@@ -295,7 +295,8 @@ export function buildMissingCompositionElements(
|
|
|
295
295
|
let start = parseFloat(startAttr);
|
|
296
296
|
if (isNaN(start)) {
|
|
297
297
|
const ref =
|
|
298
|
-
doc.getElementById(startAttr) ||
|
|
298
|
+
doc.getElementById(startAttr) ||
|
|
299
|
+
doc.querySelector(`[data-composition-id="${CSS.escape(startAttr)}"]`);
|
|
299
300
|
if (ref) {
|
|
300
301
|
const refStartAttr = ref.getAttribute("data-start") ?? "0";
|
|
301
302
|
let refStart = parseFloat(refStartAttr);
|
|
@@ -303,7 +304,7 @@ export function buildMissingCompositionElements(
|
|
|
303
304
|
if (isNaN(refStart)) {
|
|
304
305
|
const refRef =
|
|
305
306
|
doc.getElementById(refStartAttr) ||
|
|
306
|
-
doc.querySelector(`[data-composition-id="${refStartAttr}"]`);
|
|
307
|
+
doc.querySelector(`[data-composition-id="${CSS.escape(refStartAttr)}"]`);
|
|
307
308
|
const rrStart = parseFloat(refRef?.getAttribute("data-start") ?? "0") || 0;
|
|
308
309
|
const rrCompId = refRef?.getAttribute("data-composition-id");
|
|
309
310
|
const rrDur =
|
|
@@ -400,7 +401,7 @@ export function buildMissingCompositionElements(
|
|
|
400
401
|
// Find the matching DOM host by element id or composition id
|
|
401
402
|
const host =
|
|
402
403
|
doc.getElementById(existing.id) ??
|
|
403
|
-
doc.querySelector(`[data-composition-id="${existing.id}"]`);
|
|
404
|
+
doc.querySelector(`[data-composition-id="${CSS.escape(existing.id)}"]`);
|
|
404
405
|
if (!host) return existing;
|
|
405
406
|
const compSrc =
|
|
406
407
|
host.getAttribute("data-composition-src") || host.getAttribute("data-composition-file");
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// keep-in-sync-with: packages/core/src/utils/htmlAttrSafety.ts
|
|
2
|
+
const ALLOWED_HTML_ATTRS = new Set([
|
|
3
|
+
"id",
|
|
4
|
+
"class",
|
|
5
|
+
"style",
|
|
6
|
+
"title",
|
|
7
|
+
"name",
|
|
8
|
+
"for",
|
|
9
|
+
"type",
|
|
10
|
+
"lang",
|
|
11
|
+
"dir",
|
|
12
|
+
"translate",
|
|
13
|
+
"hidden",
|
|
14
|
+
"tabindex",
|
|
15
|
+
"draggable",
|
|
16
|
+
"contenteditable",
|
|
17
|
+
"role",
|
|
18
|
+
"slot",
|
|
19
|
+
"href",
|
|
20
|
+
"target",
|
|
21
|
+
"rel",
|
|
22
|
+
"src",
|
|
23
|
+
"srcset",
|
|
24
|
+
"sizes",
|
|
25
|
+
"alt",
|
|
26
|
+
"poster",
|
|
27
|
+
"loading",
|
|
28
|
+
"decoding",
|
|
29
|
+
"crossorigin",
|
|
30
|
+
"preload",
|
|
31
|
+
"autoplay",
|
|
32
|
+
"loop",
|
|
33
|
+
"muted",
|
|
34
|
+
"controls",
|
|
35
|
+
"playsinline",
|
|
36
|
+
"width",
|
|
37
|
+
"height",
|
|
38
|
+
"colspan",
|
|
39
|
+
"rowspan",
|
|
40
|
+
"scope",
|
|
41
|
+
"placeholder",
|
|
42
|
+
"value",
|
|
43
|
+
"min",
|
|
44
|
+
"max",
|
|
45
|
+
"step",
|
|
46
|
+
"pattern",
|
|
47
|
+
"required",
|
|
48
|
+
"disabled",
|
|
49
|
+
"readonly",
|
|
50
|
+
"checked",
|
|
51
|
+
"selected",
|
|
52
|
+
"multiple",
|
|
53
|
+
"accept",
|
|
54
|
+
"maxlength",
|
|
55
|
+
"minlength",
|
|
56
|
+
"rows",
|
|
57
|
+
"cols",
|
|
58
|
+
"wrap",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const URI_BEARING_ATTRS = new Set([
|
|
62
|
+
"src",
|
|
63
|
+
"href",
|
|
64
|
+
"action",
|
|
65
|
+
"formaction",
|
|
66
|
+
"poster",
|
|
67
|
+
"srcset",
|
|
68
|
+
"xlink:href",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
|
|
72
|
+
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
|
|
73
|
+
|
|
74
|
+
export function isAllowedHtmlAttribute(name: string): boolean {
|
|
75
|
+
const lower = name.toLowerCase();
|
|
76
|
+
if (lower.startsWith("on")) return false;
|
|
77
|
+
if (ALLOWED_HTML_ATTRS.has(lower)) return true;
|
|
78
|
+
if (lower.startsWith("data-")) return true;
|
|
79
|
+
if (lower.startsWith("aria-")) return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isSafeAttributeValue(name: string, value: string): boolean {
|
|
84
|
+
if (URI_BEARING_ATTRS.has(name.toLowerCase())) {
|
|
85
|
+
const trimmed = value.trim();
|
|
86
|
+
if (DANGEROUS_URI_SCHEMES.test(trimmed)) return false;
|
|
87
|
+
if (DANGEROUS_DATA_URI.test(trimmed)) return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
@@ -98,6 +98,36 @@ describe("shouldUseSdkCutover", () => {
|
|
|
98
98
|
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(false);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
it("declines html-attribute ops with event handler names", () => {
|
|
102
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onclick", "alert(1)")])).toBe(
|
|
103
|
+
false,
|
|
104
|
+
);
|
|
105
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("onload", "fetch()")])).toBe(
|
|
106
|
+
false,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("declines html-attribute ops with disallowed attribute names", () => {
|
|
111
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("formaction", "/x")])).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("declines html-attribute ops with dangerous URI schemes", () => {
|
|
115
|
+
expect(
|
|
116
|
+
shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("href", "javascript:alert(1)")]),
|
|
117
|
+
).toBe(false);
|
|
118
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("src", "vbscript:run")])).toBe(
|
|
119
|
+
false,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("declines html-attribute ops with dangerous data URIs", () => {
|
|
124
|
+
expect(
|
|
125
|
+
shouldUseSdkCutover(true, true, "hf-abc", [
|
|
126
|
+
htmlAttrOp("href", "data:text/html,<script>alert(1)</script>"),
|
|
127
|
+
]),
|
|
128
|
+
).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
101
131
|
it("returns true when ops mix all supported types", () => {
|
|
102
132
|
expect(
|
|
103
133
|
shouldUseSdkCutover(true, true, "hf-abc", [
|
|
@@ -205,6 +235,27 @@ describe("sdkCutoverPersist", () => {
|
|
|
205
235
|
});
|
|
206
236
|
});
|
|
207
237
|
|
|
238
|
+
it.each([
|
|
239
|
+
{ name: "multi-child targets", children: [{ id: "a" }, { id: "b" }] },
|
|
240
|
+
{ name: "single non-html children", children: [{ id: "a", tag: "svg" }] },
|
|
241
|
+
])("declines text-content cutover for $name", async ({ children }) => {
|
|
242
|
+
const deps = makeDeps();
|
|
243
|
+
const session = makeSession(true);
|
|
244
|
+
(session!.getElement as ReturnType<typeof vi.fn>).mockReturnValue({ children });
|
|
245
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
246
|
+
const result = await sdkCutoverPersist(
|
|
247
|
+
sel,
|
|
248
|
+
[textOp("Hello world")],
|
|
249
|
+
"before",
|
|
250
|
+
"/comp.html",
|
|
251
|
+
session,
|
|
252
|
+
deps,
|
|
253
|
+
);
|
|
254
|
+
expect(result).toBe(false);
|
|
255
|
+
expect(session!.dispatch).not.toHaveBeenCalled();
|
|
256
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
208
259
|
it("dispatches setAttribute for attribute op with data- prefix", async () => {
|
|
209
260
|
const deps = makeDeps();
|
|
210
261
|
const session = makeSession(true);
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { trackStudioEvent } from "./studioTelemetry";
|
|
|
8
8
|
import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry";
|
|
9
9
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
10
10
|
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
|
|
11
|
+
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety";
|
|
11
12
|
|
|
12
13
|
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
|
|
13
14
|
"inline-style",
|
|
@@ -52,6 +53,54 @@ function mapsToReservedAttr(op: PatchOperation): boolean {
|
|
|
52
53
|
return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase());
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
// ─── html-attribute safety ───────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function hasUnsafeHtmlAttributeOp(ops: PatchOperation[]): boolean {
|
|
59
|
+
return ops.some(
|
|
60
|
+
(op) =>
|
|
61
|
+
op.type === "html-attribute" &&
|
|
62
|
+
(!isAllowedHtmlAttribute(op.property) ||
|
|
63
|
+
(op.value !== null && !isSafeAttributeValue(op.property, op.value))),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasTextContentOp(ops: PatchOperation[]): boolean {
|
|
68
|
+
return ops.some((op) => op.type === "text-content");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function targetChildren(target: unknown): unknown[] | null {
|
|
72
|
+
if (!target || typeof target !== "object" || !("children" in target)) return null;
|
|
73
|
+
const children = (target as { children?: unknown }).children;
|
|
74
|
+
return Array.isArray(children) ? children : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function elementTag(element: unknown): string | null {
|
|
78
|
+
if (!element || typeof element !== "object" || !("tag" in element)) return null;
|
|
79
|
+
const tag = (element as { tag?: unknown }).tag;
|
|
80
|
+
return typeof tag === "string" ? tag.toLowerCase() : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Tags that are non-HTML namespace elements in a linkedom-parsed HTML body.
|
|
84
|
+
// Mirrors the engine's `isHTMLElementTarget` (model.ts) which uses `instanceof
|
|
85
|
+
// HTMLElement` — that runtime check catches the same set, but we can't use it
|
|
86
|
+
// here because `target` is a plain SDK object, not a DOM Element. If linkedom
|
|
87
|
+
// (or a future parser) surfaces additional foreign-content elements as
|
|
88
|
+
// non-HTMLElement, add them here.
|
|
89
|
+
const NON_HTML_CHILD_TAGS = new Set(["svg", "math"]);
|
|
90
|
+
|
|
91
|
+
function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOperation[]): boolean {
|
|
92
|
+
if (!hasTextContentOp(ops)) return false;
|
|
93
|
+
const children = targetChildren(target);
|
|
94
|
+
if (!children) return false;
|
|
95
|
+
// Legacy patch-element replaces the whole element for multi-child targets and
|
|
96
|
+
// for single non-HTML children. The SDK text patch stream stores a scalar
|
|
97
|
+
// inverse, so those shapes cannot be made both byte-identical and undo-safe
|
|
98
|
+
// here. Let the server path remain authoritative for them.
|
|
99
|
+
if (children.length > 1) return true;
|
|
100
|
+
const tag = elementTag(children[0]);
|
|
101
|
+
return tag !== null && NON_HTML_CHILD_TAGS.has(tag);
|
|
102
|
+
}
|
|
103
|
+
|
|
55
104
|
export function shouldUseSdkCutover(
|
|
56
105
|
flagEnabled: boolean,
|
|
57
106
|
hasSession: boolean,
|
|
@@ -64,7 +113,8 @@ export function shouldUseSdkCutover(
|
|
|
64
113
|
!!hfId &&
|
|
65
114
|
ops.length > 0 &&
|
|
66
115
|
ops.every((o) => CUTOVER_OP_TYPES.has(o.type)) &&
|
|
67
|
-
!ops.some(mapsToReservedAttr)
|
|
116
|
+
!ops.some(mapsToReservedAttr) &&
|
|
117
|
+
!hasUnsafeHtmlAttributeOp(ops)
|
|
68
118
|
);
|
|
69
119
|
}
|
|
70
120
|
|
|
@@ -144,10 +194,11 @@ interface CutoverOptions {
|
|
|
144
194
|
skipRefresh?: boolean;
|
|
145
195
|
}
|
|
146
196
|
|
|
147
|
-
// ponytail:
|
|
197
|
+
// ponytail: exported for setSlideshowManifest (third caller — island write bypasses
|
|
198
|
+
// the SDK dispatch path since <script> nodes are not in the element tree).
|
|
148
199
|
// `after` is serialized once by the caller (which also did the no-op check
|
|
149
200
|
// against its pre-dispatch snapshot), so this never re-serializes.
|
|
150
|
-
async function persistSdkSerialize(
|
|
201
|
+
export async function persistSdkSerialize(
|
|
151
202
|
after: string,
|
|
152
203
|
targetPath: string,
|
|
153
204
|
originalContent: string,
|
|
@@ -185,7 +236,9 @@ export async function sdkCutoverPersist(
|
|
|
185
236
|
if (!sdkSession) return false;
|
|
186
237
|
const hfId = selection.hfId;
|
|
187
238
|
if (!hfId) return false;
|
|
188
|
-
|
|
239
|
+
const target = sdkSession.getElement(hfId);
|
|
240
|
+
if (!target) return false;
|
|
241
|
+
if (shouldDeclineTextCutoverForTarget(target, ops)) return false;
|
|
189
242
|
if (wrongCompositionFile(deps, targetPath)) return false;
|
|
190
243
|
try {
|
|
191
244
|
const before = sdkSession.serialize();
|