@hyperframes/studio 0.6.25 → 0.6.27

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.
@@ -1,54 +1,18 @@
1
1
  import {
2
- WarningCircle,
3
- Warning,
4
- ArrowLeft as PhArrowLeft,
5
2
  Check as PhCheck,
6
- CheckCircle as PhCheckCircle,
7
- Circle as PhCircle,
8
3
  Clock as PhClock,
9
- Code as PhCode,
10
- DownloadSimple,
11
- Pencil as PhPencil,
12
- ArrowSquareOut,
13
4
  Eye as PhEye,
14
- EyeClosed,
15
- File as PhFile,
16
- FileCode as PhFileCode,
17
- FileText as PhFileText,
18
5
  FilmStrip,
19
- Heart as PhHeart,
20
- Image as PhImage,
21
- Info as PhInfo,
22
6
  Stack,
23
- SpinnerGap,
24
- ArrowsOut,
25
- CornersOut,
26
- ChatCircle,
27
7
  ChatCenteredText,
28
- Cursor,
29
8
  ArrowsOutCardinal,
30
9
  MusicNote,
31
10
  Palette as PhPalette,
32
- Paperclip as PhPaperclip,
33
- Pause as PhPause,
34
- Play as PhPlay,
35
11
  Plus as PhPlus,
36
- MagnifyingGlass,
37
- PaperPlaneRight,
38
- SkipBack as PhSkipBack,
39
- SkipForward as PhSkipForward,
40
12
  Square as PhSquare,
41
- Trash,
42
13
  TextT,
43
- UploadSimple,
44
- User as PhUser,
45
- UsersThree,
46
- VideoCamera,
47
14
  X as PhX,
48
15
  Lightning,
49
- MagnifyingGlassPlus,
50
- MagnifyingGlassMinus,
51
- Terminal as PhTerminal,
52
16
  CaretDown,
53
17
  CaretRight,
54
18
  ClipboardText,
@@ -69,60 +33,21 @@ const makeIcon = (Icon: PhosphorIcon) => {
69
33
  };
70
34
 
71
35
  // Lucide name → Phosphor equivalent
72
- export const AlertCircle = makeIcon(WarningCircle);
73
- export const AlertTriangle = makeIcon(Warning);
74
- export const ArrowLeft = makeIcon(PhArrowLeft);
75
36
  export const Check = makeIcon(PhCheck);
76
- export const CheckCircle = makeIcon(PhCheckCircle);
77
- /** CheckCircle2 in lucide is visually identical to CheckCircle */
78
- export const CheckCircle2 = makeIcon(PhCheckCircle);
79
- export const Circle = makeIcon(PhCircle);
80
37
  export const Clock = makeIcon(PhClock);
81
- export const Code = makeIcon(PhCode);
82
- export const Download = makeIcon(DownloadSimple);
83
- export const Edit2 = makeIcon(PhPencil);
84
- export const ExternalLink = makeIcon(ArrowSquareOut);
85
38
  export const Eye = makeIcon(PhEye);
86
- export const EyeOff = makeIcon(EyeClosed);
87
- export const File = makeIcon(PhFile);
88
- export const FileCode = makeIcon(PhFileCode);
89
- export const FileText = makeIcon(PhFileText);
90
39
  export const Film = makeIcon(FilmStrip);
91
- export const Heart = makeIcon(PhHeart);
92
- export const Image = makeIcon(PhImage);
93
- export const Info = makeIcon(PhInfo);
94
40
  export const Layers = makeIcon(Stack);
95
- export const Loader2 = makeIcon(SpinnerGap);
96
- export const Maximize = makeIcon(ArrowsOut);
97
- export const Maximize2 = makeIcon(CornersOut);
98
- export const MessageCircle = makeIcon(ChatCircle);
99
41
  export const MessageSquare = makeIcon(ChatCenteredText);
100
- export const MousePointer = makeIcon(Cursor);
101
42
  export const Move = makeIcon(ArrowsOutCardinal);
102
43
  export const Music = makeIcon(MusicNote);
103
44
  export const Palette = makeIcon(PhPalette);
104
- export const Paperclip = makeIcon(PhPaperclip);
105
- export const Pause = makeIcon(PhPause);
106
- export const Pencil = makeIcon(PhPencil);
107
- export const Play = makeIcon(PhPlay);
108
45
  export const Plus = makeIcon(PhPlus);
109
- export const Search = makeIcon(MagnifyingGlass);
110
- export const Send = makeIcon(PaperPlaneRight);
111
- export const SkipBack = makeIcon(PhSkipBack);
112
- export const SkipForward = makeIcon(PhSkipForward);
113
46
  export const Square = makeIcon(PhSquare);
114
- export const Trash2 = makeIcon(Trash);
115
47
  export const Type = makeIcon(TextT);
116
- export const Upload = makeIcon(UploadSimple);
117
- export const User = makeIcon(PhUser);
118
- export const Users = makeIcon(UsersThree);
119
- export const Video = makeIcon(VideoCamera);
120
48
  export const X = makeIcon(PhX);
121
49
  export const Zap = makeIcon(Lightning);
122
- export const ZoomIn = makeIcon(MagnifyingGlassPlus);
123
- export const ZoomOut = makeIcon(MagnifyingGlassMinus);
124
50
  // Extra icons used in this project (not in lucide's default mapping above)
125
- export const Terminal = makeIcon(PhTerminal);
126
51
  export const ChevronDown = makeIcon(CaretDown);
127
52
  export const ChevronRight = makeIcon(CaretRight);
128
53
  export const ClipboardList = makeIcon(ClipboardText);
@@ -4,7 +4,6 @@ import {
4
4
  buildPromptCopyText,
5
5
  buildTimelineElementAgentPrompt,
6
6
  buildTimelineAgentPrompt,
7
- buildTrackZIndexMap,
8
7
  canOffsetTrimClipStart,
9
8
  getTimelineEditCapabilities,
10
9
  hasPatchableTimelineTarget,
@@ -159,29 +158,6 @@ describe("resolveTimelineMove", () => {
159
158
  });
160
159
  });
161
160
 
162
- describe("buildTrackZIndexMap", () => {
163
- it("maps visually higher tracks onto higher z-index values", () => {
164
- expect(buildTrackZIndexMap([-2, -1, 0, 3])).toEqual(
165
- new Map([
166
- [-2, 4],
167
- [-1, 3],
168
- [0, 2],
169
- [3, 1],
170
- ]),
171
- );
172
- });
173
-
174
- it("deduplicates tracks before assigning z-index values", () => {
175
- expect(buildTrackZIndexMap([-1, 0, -1, 3, 3])).toEqual(
176
- new Map([
177
- [-1, 3],
178
- [0, 2],
179
- [3, 1],
180
- ]),
181
- );
182
- });
183
- });
184
-
185
161
  describe("canOffsetTrimClipStart", () => {
186
162
  it("allows front trim for clips that carry playback offset metadata", () => {
187
163
  expect(
@@ -114,12 +114,6 @@ export function resolveTimelineMove(
114
114
  };
115
115
  }
116
116
 
117
- export function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
118
- const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
119
- const maxZIndex = uniqueTracks.length;
120
- return new Map(uniqueTracks.map((track, index) => [track, maxZIndex - index]));
121
- }
122
-
123
117
  export function resolveTimelineResize(
124
118
  input: TimelineResizeInput,
125
119
  edge: "start" | "end",
@@ -144,19 +144,3 @@ export function useTimelineRangeSelection({
144
144
  handlePointerUp,
145
145
  };
146
146
  }
147
-
148
- /* ── Seek + scroll utilities (used in Timeline only) ──────────────── */
149
- export function seekTimeFromScrollX(
150
- scrollEl: HTMLDivElement,
151
- clientX: number,
152
- effectiveDuration: number,
153
- pps: number,
154
- onSeek?: (time: number) => void,
155
- ): void {
156
- const rect = scrollEl.getBoundingClientRect();
157
- const x = clientX - rect.left + scrollEl.scrollLeft - GUTTER;
158
- if (x < 0) return;
159
- const time = Math.max(0, Math.min(effectiveDuration, x / pps));
160
- liveTime.notify(time);
161
- onSeek?.(time);
162
- }
@@ -18,7 +18,6 @@ import {
18
18
  findTimelineDomNodeForClip,
19
19
  createImplicitTimelineLayersFromDOM,
20
20
  buildStandaloneRootTimelineElement,
21
- mergeTimelineElementsPreservingDowngrades,
22
21
  getTimelineElementSelector,
23
22
  } from "../lib/timelineDOM";
24
23
  import {
@@ -26,7 +25,6 @@ import {
26
25
  autoHealMissingCompositionIds,
27
26
  buildMissingCompositionElements,
28
27
  } from "../lib/timelineIframeHelpers";
29
- import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
30
28
 
31
29
  interface UseTimelineSyncCallbacksParams {
32
30
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -288,7 +286,3 @@ export function useTimelineSyncCallbacks({
288
286
  onIframeLoad,
289
287
  };
290
288
  }
291
-
292
- // Re-export the merge helper so the hook can use it via this module (avoids
293
- // adding another import line to the already-large useTimelinePlayer.ts).
294
- export { mergeTimelineElementsPreservingDowngrades, getTimelineElementIdentity };
@@ -5,10 +5,7 @@ import {
5
5
  resolveTimelineAssetInitialGeometry,
6
6
  } from "./timelineAssetDrop";
7
7
  import { collectHtmlIds } from "./studioHelpers";
8
- import {
9
- buildTrackZIndexMap,
10
- formatTimelineAttributeNumber,
11
- } from "../player/components/timelineEditing";
8
+ import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
12
9
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
13
10
  import type { EditHistoryKind } from "./editHistory";
14
11
 
@@ -127,8 +124,7 @@ export async function addBlockToProject(
127
124
  ? Math.max(...relevantElements.map((te) => te.track)) + 1
128
125
  : 1);
129
126
 
130
- const trackZIndices = buildTrackZIndexMap([...relevantElements.map((te) => te.track), track]);
131
- const zIndex = trackZIndices.get(track) ?? 1;
127
+ const zIndex = Math.max(1, relevantElements.length + 1);
132
128
 
133
129
  const width = isBlock
134
130
  ? (block as { dimensions: { width: number } }).dimensions.width
@@ -35,6 +35,28 @@ describe("applyPatchByTarget", () => {
35
35
  );
36
36
  });
37
37
 
38
+ it("adds inline style to a self-closing void element without malforming it", () => {
39
+ const html = `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" />`;
40
+ const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
41
+
42
+ const result = applyPatch(html, "gif-img", op);
43
+ expect(result).toBe(
44
+ `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" style="z-index: 3" />`,
45
+ );
46
+ expect(result).not.toContain("/ style");
47
+ });
48
+
49
+ it("adds inline style to a self-closing void element matched by selector", () => {
50
+ const html = `<img class="clip hero" data-start="0" src="bg.png" alt="" />`;
51
+ const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
52
+
53
+ const result = applyPatchByTarget(html, { selector: ".hero" }, op);
54
+ expect(result).toBe(
55
+ `<img class="clip hero" data-start="0" src="bg.png" alt="" style="opacity: 0.5" />`,
56
+ );
57
+ expect(result).not.toContain("/ style");
58
+ });
59
+
38
60
  it("patches inline move styles by target", () => {
39
61
  const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
40
62
 
@@ -203,9 +203,9 @@ function patchInlineStyleInTag(
203
203
  } else {
204
204
  // No existing style attribute
205
205
  if (value === null) return html; // nothing to remove
206
- // Add one
207
- const newTag =
208
- tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`;
206
+ const selfClosing = /\s*\/$/.test(tag);
207
+ const base = selfClosing ? tag.replace(/\s*\/$/, "") : tag;
208
+ const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
209
209
  return html.replace(tag, newTag);
210
210
  }
211
211
  }
@@ -23,13 +23,7 @@ export function getTimelineElementLabel(element: TimelineElement): string {
23
23
  return element.label || element.id || element.tag;
24
24
  }
25
25
 
26
- export function confirmElementDelete(label: string, kind: "timeline clip" | "element"): boolean {
27
- return window.confirm(
28
- `Delete ${kind} "${label}"?\n\nThis removes it from the project source. You can use Undo to restore it.`,
29
- );
30
- }
31
-
32
- export function normalizeProjectAssetPath(value: string): string {
26
+ function normalizeProjectAssetPath(value: string): string {
33
27
  const trimmed = value.trim();
34
28
  const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
35
29
  return decodeURIComponent(maybeUrl)
@@ -51,7 +45,7 @@ export function toRelativeProjectAssetPath(sourceFile: string, assetPath: string
51
45
  return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
52
46
  }
53
47
 
54
- export function isAbsoluteFilePath(value: string): boolean {
48
+ function isAbsoluteFilePath(value: string): boolean {
55
49
  return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
56
50
  }
57
51
 
@@ -181,7 +175,7 @@ export function collectHtmlIds(source: string): string[] {
181
175
  return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
182
176
  }
183
177
 
184
- export const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
178
+ const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
185
179
  image: 3,
186
180
  video: 5,
187
181
  audio: 5,
@@ -1,24 +1,18 @@
1
- import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing";
1
+ import type { DomEditViewport } from "../components/editor/domEditing";
2
2
  import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
3
3
  import {
4
4
  getDomLayerPatchTarget,
5
5
  isElementComputedVisible,
6
6
  } from "../components/editor/domEditingElement";
7
- import { usePlayerStore, liveTime } from "../player";
8
7
  import { getEventTargetElement } from "./studioHelpers";
9
8
 
10
- export interface PreviewLocalPointer {
9
+ interface PreviewLocalPointer {
11
10
  x: number;
12
11
  y: number;
13
12
  viewport: DomEditViewport;
14
13
  }
15
14
 
16
- export interface PreviewPlayerCompat {
17
- getTime: () => number;
18
- renderSeek: (timeSeconds: number) => void;
19
- }
20
-
21
- export function resolvePreviewLocalPointer(
15
+ function resolvePreviewLocalPointer(
22
16
  iframe: HTMLIFrameElement,
23
17
  doc: Document,
24
18
  win: Window,
@@ -42,24 +36,6 @@ export function resolvePreviewLocalPointer(
42
36
  };
43
37
  }
44
38
 
45
- export function getPreviewLocalPointer(
46
- iframe: HTMLIFrameElement,
47
- clientX: number,
48
- clientY: number,
49
- ): PreviewLocalPointer | null {
50
- let doc: Document | null = null;
51
- let win: Window | null = null;
52
- try {
53
- doc = iframe.contentDocument;
54
- win = iframe.contentWindow;
55
- } catch {
56
- return null;
57
- }
58
- if (!doc || !win) return null;
59
-
60
- return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
61
- }
62
-
63
39
  const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
64
40
 
65
41
  function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
@@ -122,21 +98,6 @@ export function getPreviewTargetFromPointer(
122
98
  }
123
99
  }
124
100
 
125
- export function buildRasterClickSelectionContext(
126
- selection: DomEditSelection,
127
- localPointer: PreviewLocalPointer,
128
- ): string {
129
- return [
130
- "The user clicked a large raster/background element in the Studio preview.",
131
- `Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
132
- localPointer.viewport.width,
133
- )}x${Math.round(localPointer.viewport.height)} composition.`,
134
- `Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
135
- "Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
136
- "If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
137
- ].join("\n");
138
- }
139
-
140
101
  function objectLike(value: unknown): object | null {
141
102
  return value && (typeof value === "object" || typeof value === "function") ? value : null;
142
103
  }
@@ -162,33 +123,6 @@ function readPlaybackTime(target: object | null, key: string): number | null {
162
123
  }
163
124
  }
164
125
 
165
- export function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
166
- const player = objectLike(win ? Reflect.get(win, "__player") : null);
167
- if (!player) return null;
168
- const getTime = Reflect.get(player, "getTime");
169
- const renderSeek = Reflect.get(player, "renderSeek");
170
- if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
171
- return {
172
- getTime: () => {
173
- const value = getTime.call(player);
174
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
175
- },
176
- renderSeek: (timeSeconds: number) => {
177
- renderSeek.call(player, timeSeconds);
178
- },
179
- };
180
- }
181
-
182
- export function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
183
- const player = getPreviewPlayer(iframe?.contentWindow);
184
- if (!player) return false;
185
- const nextTime = Math.max(0, timeSeconds);
186
- player.renderSeek(nextTime);
187
- usePlayerStore.getState().setCurrentTime(nextTime);
188
- liveTime.notify(nextTime);
189
- return true;
190
- }
191
-
192
126
  export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
193
127
  const win = iframe?.contentWindow;
194
128
  if (!win) return null;
@@ -1,6 +1,4 @@
1
1
  export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T";
2
- const TIMELINE_EDITOR_HINT_STORAGE_KEY = "hf-studio-timeline-editor-hint-dismissed";
3
-
4
2
  type TimelineToggleHotkeyEvent = Pick<
5
3
  KeyboardEvent,
6
4
  "key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target"
@@ -41,17 +39,3 @@ export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEven
41
39
  export function getTimelineToggleTitle(timelineVisible: boolean): string {
42
40
  return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`;
43
41
  }
44
-
45
- export function getTimelineEditorHintDismissed(): boolean {
46
- if (typeof window === "undefined") return false;
47
- return window.localStorage.getItem(TIMELINE_EDITOR_HINT_STORAGE_KEY) === "1";
48
- }
49
-
50
- export function setTimelineEditorHintDismissed(dismissed: boolean): void {
51
- if (typeof window === "undefined") return;
52
- if (dismissed) {
53
- window.localStorage.setItem(TIMELINE_EDITOR_HINT_STORAGE_KEY, "1");
54
- return;
55
- }
56
- window.localStorage.removeItem(TIMELINE_EDITOR_HINT_STORAGE_KEY);
57
- }
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyPatchByTarget, applyPatch } from "./sourcePatcher";
3
+
4
+ /**
5
+ * Reproduction tests for https://github.com/heygen-com/hyperframes/issues/958
6
+ *
7
+ * The bug: dragging a clip in the Studio timeline rewrites index.html with
8
+ * inline style="z-index: N" on EVERY clip, overriding the author's CSS z-index.
9
+ * Additionally, void elements (img, audio) get malformed self-closing tags.
10
+ */
11
+
12
+ const ISSUE_HTML = `<style>
13
+ * { margin: 0; padding: 0; box-sizing: border-box; }
14
+ html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
15
+ #bg-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
16
+ #title { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
17
+ font: 700 120px sans-serif; color: #fff; z-index: 20; }
18
+ </style>
19
+
20
+ <div id="root" data-composition-id="main" data-start="0" data-duration="10"
21
+ data-width="1920" data-height="1080">
22
+ <video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>
23
+ <div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>
24
+ </div>`;
25
+
26
+ const VOID_ELEMENT_HTML = `<div id="root" data-composition-id="main" data-start="0" data-duration="14"
27
+ data-width="1920" data-height="1080">
28
+ <video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>
29
+ <img id="gif-img" class="clip" data-start="1" data-duration="13" data-track-index="2" src="assets/earth.gif" alt="rotating earth gif" />
30
+ <div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>
31
+ </div>`;
32
+
33
+ describe("issue #958 — timeline drag must not inject inline z-index", () => {
34
+ it("reproduces the old bug: z-index injection on all clips overrides CSS layering", () => {
35
+ // Simulate the OLD behavior: buildTrackZIndexMap + loop over all clips
36
+ function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
37
+ const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
38
+ const maxZIndex = uniqueTracks.length;
39
+ return new Map(uniqueTracks.map((track, index) => [track, maxZIndex - index]));
40
+ }
41
+
42
+ const elements = [
43
+ { id: "bg-video", track: 0 },
44
+ { id: "title", track: 1 },
45
+ ];
46
+ const trackZIndices = buildTrackZIndexMap(elements.map((e) => e.track));
47
+
48
+ // Apply the old z-index injection loop
49
+ let broken = ISSUE_HTML;
50
+ for (const el of elements) {
51
+ const nextZIndex = trackZIndices.get(el.track);
52
+ if (nextZIndex == null) continue;
53
+ broken = applyPatch(broken, el.id, {
54
+ type: "inline-style",
55
+ property: "z-index",
56
+ value: String(nextZIndex),
57
+ });
58
+ }
59
+
60
+ // Verify the bug: bg-video gets z-index: 2, title gets z-index: 1
61
+ // This INVERTS the intended layering (CSS had bg-video: 0, title: 20)
62
+ expect(broken).toContain('id="bg-video"');
63
+ expect(broken).toContain('style="z-index: 2"');
64
+ expect(broken).toContain('id="title"');
65
+ expect(broken).toContain('style="z-index: 1"');
66
+
67
+ // The title (z-index: 1) is now BEHIND the video (z-index: 2)
68
+ // — the opposite of the author's intent (CSS z-index: 20 vs 0)
69
+ });
70
+
71
+ it("verifies the fix: moving a clip only patches data-start and data-track-index", () => {
72
+ // Simulate the NEW behavior: only patch the moved clip's timing attributes
73
+ const movedElement = { id: "bg-video" };
74
+ const updates = { start: "2.5", track: "0" };
75
+
76
+ let fixed = applyPatchByTarget(
77
+ ISSUE_HTML,
78
+ { id: movedElement.id },
79
+ { type: "attribute", property: "start", value: updates.start },
80
+ );
81
+ fixed = applyPatchByTarget(
82
+ fixed,
83
+ { id: movedElement.id },
84
+ { type: "attribute", property: "track-index", value: updates.track },
85
+ );
86
+
87
+ // The moved clip's timing changed
88
+ expect(fixed).toContain('id="bg-video" data-start="2.5" data-track-index="0"');
89
+
90
+ // No inline z-index was injected on ANY element
91
+ expect(fixed).not.toContain('style="z-index');
92
+
93
+ // The title clip is completely untouched
94
+ expect(fixed).toContain(
95
+ '<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
96
+ );
97
+
98
+ // CSS z-index declarations in <style> are preserved
99
+ expect(fixed).toContain("z-index: 0;");
100
+ expect(fixed).toContain("z-index: 20;");
101
+ });
102
+
103
+ it("verifies the fix: deleting a clip does not inject z-index on remaining clips", () => {
104
+ // After the element removal API call returns the content without bg-video,
105
+ // the old code would loop over remaining clips and inject z-index.
106
+ // The new code just uses the removal result as-is.
107
+ const afterRemoval = ISSUE_HTML.replace(/<video id="bg-video"[^>]*><\/video>\n /, "");
108
+
109
+ // No z-index injection step — the result is used directly
110
+ expect(afterRemoval).not.toContain('style="z-index');
111
+ expect(afterRemoval).toContain(
112
+ '<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
113
+ );
114
+ });
115
+ });
116
+
117
+ describe("issue #958 — void element inline style injection", () => {
118
+ it("reproduces the old bug: self-closing tags get malformed when style is injected", () => {
119
+ // The old code did: tag.replace(/>$/, "") + ` style="z-index: 7"`
120
+ // But `tag` from the regex capture never includes `>`, and for self-closing
121
+ // elements it ends with `/`. The replace was a no-op, producing:
122
+ // <img ... / style="z-index: 7">
123
+ const img = `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" />`;
124
+ const result = applyPatch(img, "gif-img", {
125
+ type: "inline-style",
126
+ property: "z-index",
127
+ value: "7",
128
+ });
129
+
130
+ // With the fix, the style is inserted before the self-closing slash
131
+ expect(result).not.toContain("/ style");
132
+ expect(result).toContain('style="z-index: 7" />');
133
+ });
134
+
135
+ it("verifies inline style on void elements in a full composition", () => {
136
+ // Even though we no longer inject z-index during move/delete,
137
+ // the patchInlineStyleInTag fix is still important for other inline style
138
+ // patches (e.g., position, opacity) that the Studio applies to void elements.
139
+ const result = applyPatch(VOID_ELEMENT_HTML, "gif-img", {
140
+ type: "inline-style",
141
+ property: "opacity",
142
+ value: "0.8",
143
+ });
144
+
145
+ expect(result).toContain('style="opacity: 0.8" />');
146
+ expect(result).not.toContain("/ style");
147
+ // Other elements untouched
148
+ expect(result).toContain(
149
+ '<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>',
150
+ );
151
+ expect(result).toContain(
152
+ '<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
153
+ );
154
+ });
155
+ });