@hyperframes/studio 0.6.31 → 0.6.32

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.
@@ -0,0 +1,109 @@
1
+ import { useCallback, useState, type RefObject } from "react";
2
+ import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
3
+
4
+ interface UsePreviewBlockDropOptions {
5
+ portrait?: boolean;
6
+ stageRef: RefObject<HTMLDivElement | null>;
7
+ onBlockDrop?: (blockName: string, position: { left: number; top: number }) => void;
8
+ }
9
+
10
+ interface BlockDropPayload {
11
+ name: string;
12
+ dimensions?: { width: number; height: number };
13
+ }
14
+
15
+ function parseBlockPayload(raw: string): BlockDropPayload | null {
16
+ try {
17
+ const parsed = JSON.parse(raw) as {
18
+ name?: string;
19
+ dimensions?: { width: number; height: number };
20
+ };
21
+ return parsed.name ? (parsed as BlockDropPayload) : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function resolveCompositionPosition(
28
+ clientX: number,
29
+ clientY: number,
30
+ stageRect: DOMRect,
31
+ portrait: boolean | undefined,
32
+ ): { left: number; top: number } | null {
33
+ if (stageRect.width === 0 || stageRect.height === 0) return null;
34
+
35
+ const normalizedX = (clientX - stageRect.left) / stageRect.width;
36
+ const normalizedY = (clientY - stageRect.top) / stageRect.height;
37
+
38
+ const compWidth = portrait ? 1080 : 1920;
39
+ const compHeight = portrait ? 1920 : 1080;
40
+
41
+ return {
42
+ left: Math.max(0, Math.min(normalizedX * compWidth, compWidth)),
43
+ top: Math.max(0, Math.min(normalizedY * compHeight, compHeight)),
44
+ };
45
+ }
46
+
47
+ function centerBlockAtPosition(
48
+ pos: { left: number; top: number },
49
+ block: BlockDropPayload,
50
+ ): { left: number; top: number } {
51
+ const blockW = block.dimensions?.width ?? 0;
52
+ const blockH = block.dimensions?.height ?? 0;
53
+ return {
54
+ left: Math.max(0, pos.left - blockW / 2),
55
+ top: Math.max(0, pos.top - blockH / 2),
56
+ };
57
+ }
58
+
59
+ export function usePreviewBlockDrop({
60
+ portrait,
61
+ stageRef,
62
+ onBlockDrop,
63
+ }: UsePreviewBlockDropOptions) {
64
+ const [isDragOver, setIsDragOver] = useState(false);
65
+
66
+ const handleDragOver = useCallback(
67
+ (e: React.DragEvent) => {
68
+ if (!onBlockDrop) return;
69
+ if (!e.dataTransfer.types.includes(TIMELINE_BLOCK_MIME)) return;
70
+ e.preventDefault();
71
+ e.dataTransfer.dropEffect = "copy";
72
+ setIsDragOver(true);
73
+ },
74
+ [onBlockDrop],
75
+ );
76
+
77
+ const handleDragLeave = useCallback(() => {
78
+ setIsDragOver(false);
79
+ }, []);
80
+
81
+ // fallow-ignore-next-line complexity
82
+ const handleDrop = useCallback(
83
+ (e: React.DragEvent) => {
84
+ setIsDragOver(false);
85
+ if (!onBlockDrop) return;
86
+
87
+ const payload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
88
+ if (!payload) return;
89
+ e.preventDefault();
90
+
91
+ const block = parseBlockPayload(payload);
92
+ const stage = stageRef.current;
93
+ if (!block || !stage) return;
94
+
95
+ const pos = resolveCompositionPosition(
96
+ e.clientX,
97
+ e.clientY,
98
+ stage.getBoundingClientRect(),
99
+ portrait,
100
+ );
101
+ if (!pos) return;
102
+
103
+ onBlockDrop(block.name, centerBlockAtPosition(pos, block));
104
+ },
105
+ [onBlockDrop, stageRef, portrait],
106
+ );
107
+
108
+ return { isDragOver, handleDragOver, handleDragLeave, handleDrop };
109
+ }
@@ -1,5 +1,4 @@
1
1
  import { memo, useState, useCallback, useRef, useEffect } from "react";
2
- import { createPortal } from "react-dom";
3
2
  import { useBlockCatalog } from "../../hooks/useBlockCatalog";
4
3
  import {
5
4
  BLOCK_CATEGORIES,
@@ -8,12 +7,19 @@ import {
8
7
  } from "../../utils/blockCategories";
9
8
  import { TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
10
9
 
10
+ export interface BlockPreviewInfo {
11
+ videoUrl?: string;
12
+ posterUrl?: string;
13
+ title: string;
14
+ }
15
+
11
16
  interface BlocksTabProps {
12
17
  onAddBlock: (blockName: string) => void;
18
+ onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
13
19
  }
14
20
 
15
21
  // fallow-ignore-next-line complexity
16
- export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps) {
22
+ export const BlocksTab = memo(function BlocksTab({ onAddBlock, onPreviewBlock }: BlocksTabProps) {
17
23
  const { loading, error, search, setSearch, category, setCategory, filteredBlocks } =
18
24
  useBlockCatalog();
19
25
 
@@ -114,6 +120,7 @@ export const BlocksTab = memo(function BlocksTab({ onAddBlock }: BlocksTabProps)
114
120
  videoUrl={block.preview?.video}
115
121
  dimensions={dims}
116
122
  onAdd={() => onAddBlock(block.name)}
123
+ onPreview={onPreviewBlock}
117
124
  />
118
125
  );
119
126
  })}
@@ -163,6 +170,7 @@ function BlockCard({
163
170
  videoUrl,
164
171
  dimensions,
165
172
  onAdd,
173
+ onPreview,
166
174
  }: {
167
175
  name: string;
168
176
  title: string;
@@ -173,52 +181,35 @@ function BlockCard({
173
181
  videoUrl?: string;
174
182
  dimensions?: { width: number; height: number };
175
183
  onAdd: () => void;
184
+ onPreview?: (preview: BlockPreviewInfo | null) => void;
176
185
  }) {
177
186
  const [hovered, setHovered] = useState(false);
178
187
  const [adding, setAdding] = useState(false);
179
188
  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
180
- const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
181
- const videoRef = useRef<HTMLVideoElement | null>(null);
182
189
  const colors = getCategoryColors(category);
183
190
  const needsWebGL = tags?.includes("html-in-canvas") || tags?.includes("webgl");
184
191
 
185
- const cancelLeave = useCallback(() => {
186
- if (leaveTimer.current) {
187
- clearTimeout(leaveTimer.current);
188
- leaveTimer.current = null;
189
- }
190
- }, []);
191
-
192
192
  const handleEnter = useCallback(() => {
193
- cancelLeave();
194
- hoverTimer.current = setTimeout(() => setHovered(true), 500);
195
- }, [cancelLeave]);
196
-
197
- const dismiss = useCallback(() => {
198
- if (hoverTimer.current) {
199
- clearTimeout(hoverTimer.current);
200
- hoverTimer.current = null;
201
- }
202
- cancelLeave();
203
- setHovered(false);
204
- }, [cancelLeave]);
193
+ hoverTimer.current = setTimeout(() => {
194
+ setHovered(true);
195
+ onPreview?.({ videoUrl, posterUrl, title });
196
+ }, 300);
197
+ }, [onPreview, videoUrl, posterUrl, title]);
205
198
 
206
199
  const handleLeave = useCallback(() => {
207
200
  if (hoverTimer.current) {
208
201
  clearTimeout(hoverTimer.current);
209
202
  hoverTimer.current = null;
210
203
  }
211
- leaveTimer.current = setTimeout(() => setHovered(false), 150);
212
- }, []);
204
+ setHovered(false);
205
+ onPreview?.(null);
206
+ }, [onPreview]);
213
207
 
214
208
  useEffect(() => {
215
- if (!hovered) return;
216
- const onKey = (e: KeyboardEvent) => {
217
- if (e.key === "Escape") dismiss();
209
+ return () => {
210
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
218
211
  };
219
- window.addEventListener("keydown", onKey);
220
- return () => window.removeEventListener("keydown", onKey);
221
- }, [hovered, dismiss]);
212
+ }, []);
222
213
 
223
214
  const handleAdd = useCallback(
224
215
  (e: React.MouseEvent) => {
@@ -251,7 +242,6 @@ function BlockCard({
251
242
  <div className="aspect-video w-full overflow-hidden relative">
252
243
  {hovered && videoUrl ? (
253
244
  <video
254
- ref={videoRef}
255
245
  src={videoUrl}
256
246
  autoPlay
257
247
  muted
@@ -313,72 +303,6 @@ function BlockCard({
313
303
  </span>
314
304
  </div>
315
305
  </div>
316
-
317
- {/* Fullscreen hover preview */}
318
- {hovered &&
319
- (videoUrl || posterUrl) &&
320
- createPortal(
321
- <div
322
- className="fixed inset-0 z-50 flex items-center justify-center cursor-pointer"
323
- onClick={dismiss}
324
- onPointerEnter={cancelLeave}
325
- onPointerLeave={handleLeave}
326
- >
327
- <div className="bg-black/80 absolute inset-0" />
328
- <button
329
- type="button"
330
- onClick={dismiss}
331
- className="absolute top-4 right-4 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800/80 text-neutral-400 hover:text-white hover:bg-neutral-700 transition-colors"
332
- >
333
- <svg
334
- width="16"
335
- height="16"
336
- viewBox="0 0 24 24"
337
- fill="none"
338
- stroke="currentColor"
339
- strokeWidth="2"
340
- strokeLinecap="round"
341
- strokeLinejoin="round"
342
- >
343
- <path d="M18 6 6 18" />
344
- <path d="m6 6 12 12" />
345
- </svg>
346
- </button>
347
- <div
348
- className="relative rounded-xl overflow-hidden shadow-2xl border border-neutral-600/30 cursor-default"
349
- style={{ width: "80vw", maxWidth: 1200, maxHeight: "80vh" }}
350
- onClick={(e) => e.stopPropagation()}
351
- >
352
- <div className="aspect-video bg-neutral-950">
353
- {videoUrl ? (
354
- <video
355
- src={videoUrl}
356
- autoPlay
357
- muted
358
- loop
359
- playsInline
360
- className="w-full h-full object-contain"
361
- />
362
- ) : (
363
- <img src={posterUrl} alt={title} className="w-full h-full object-contain" />
364
- )}
365
- </div>
366
- <div className="bg-neutral-900/95 px-4 py-3">
367
- <div className="text-[14px] font-semibold text-neutral-100">{title}</div>
368
- <div className="flex items-center gap-2 mt-1">
369
- <span className={`w-2 h-2 rounded-full ${colors.dot}`} />
370
- <span className={`text-[11px] ${colors.text}`}>
371
- {BLOCK_CATEGORIES.find((c) => c.id === category)?.label}
372
- </span>
373
- {duration != null && (
374
- <span className="text-[11px] text-neutral-500">{duration}s</span>
375
- )}
376
- </div>
377
- </div>
378
- </div>
379
- </div>,
380
- document.body,
381
- )}
382
306
  </div>
383
307
  );
384
308
  }
@@ -3,13 +3,14 @@ import {
3
3
  useState,
4
4
  useCallback,
5
5
  useImperativeHandle,
6
+ useRef,
6
7
  forwardRef,
7
8
  type ReactNode,
8
9
  } from "react";
9
10
  import { CompositionsTab } from "./CompositionsTab";
10
11
  import { AssetsTab } from "./AssetsTab";
11
12
  import { trackStudioEvent } from "../../utils/studioTelemetry";
12
- import { BlocksTab } from "./BlocksTab";
13
+ import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
13
14
  import { FileTree } from "../editor/FileTree";
14
15
  import { STUDIO_BLOCKS_PANEL_ENABLED } from "../editor/manualEditingAvailability";
15
16
 
@@ -17,6 +18,7 @@ export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
17
18
 
18
19
  export interface LeftSidebarHandle {
19
20
  selectTab: (tab: SidebarTab) => void;
21
+ getTab: () => SidebarTab;
20
22
  }
21
23
 
22
24
  const STORAGE_KEY = "hf-studio-sidebar-tab";
@@ -53,6 +55,7 @@ interface LeftSidebarProps {
53
55
  linting?: boolean;
54
56
  onToggleCollapse?: () => void;
55
57
  onAddBlock?: (blockName: string) => void;
58
+ onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
56
59
  takeoverContent?: ReactNode;
57
60
  }
58
61
 
@@ -82,11 +85,14 @@ export const LeftSidebar = memo(
82
85
  linting,
83
86
  onToggleCollapse,
84
87
  onAddBlock,
88
+ onPreviewBlock,
85
89
  takeoverContent,
86
90
  },
87
91
  ref,
88
92
  ) {
89
93
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
94
+ const tabRef = useRef(tab);
95
+ tabRef.current = tab;
90
96
 
91
97
  const selectTab = useCallback((t: SidebarTab) => {
92
98
  setTab(t);
@@ -94,7 +100,9 @@ export const LeftSidebar = memo(
94
100
  trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
95
101
  }, []);
96
102
 
97
- useImperativeHandle(ref, () => ({ selectTab }), [selectTab]);
103
+ const getTab = useCallback(() => tabRef.current, []);
104
+
105
+ useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
98
106
 
99
107
  return (
100
108
  <div
@@ -159,7 +167,7 @@ export const LeftSidebar = memo(
159
167
  : "text-neutral-500 hover:text-neutral-200"
160
168
  }`}
161
169
  >
162
- Blocks
170
+ Catalog
163
171
  </button>
164
172
  )}
165
173
  </div>
@@ -239,7 +247,7 @@ export const LeftSidebar = memo(
239
247
  )}
240
248
 
241
249
  {STUDIO_BLOCKS_PANEL_ENABLED && tab === "blocks" && onAddBlock && (
242
- <BlocksTab onAddBlock={onAddBlock} />
250
+ <BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
243
251
  )}
244
252
 
245
253
  {/* Lint button pinned at the bottom */}
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect } from "react";
1
+ import { useCallback, useEffect, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
4
4
  import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing";
@@ -56,6 +56,7 @@ export interface UseDomEditSessionParams {
56
56
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
57
57
  openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
58
58
  selectSidebarTab?: (tab: SidebarTab) => void;
59
+ getSidebarTab?: () => SidebarTab;
59
60
  }
60
61
 
61
62
  // ── Hook ──
@@ -93,6 +94,7 @@ export function useDomEditSession({
93
94
  setRefreshKey: _setRefreshKey,
94
95
  openSourceForSelection,
95
96
  selectSidebarTab,
97
+ getSidebarTab,
96
98
  }: UseDomEditSessionParams) {
97
99
  void _setRefreshKey;
98
100
 
@@ -281,6 +283,22 @@ export function useDomEditSession({
281
283
  applyStudioManualEditsToPreviewRef,
282
284
  ]);
283
285
 
286
+ // Auto-reveal source when an element is selected while the Code tab is active.
287
+ // Use a ref for the callback so the effect only fires on selection changes,
288
+ // not when openSourceForSelection is recreated due to editingFile content updates.
289
+ const openSourceRef = useRef(openSourceForSelection);
290
+ openSourceRef.current = openSourceForSelection;
291
+ useEffect(() => {
292
+ if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
293
+ if (!domEditSelection.sourceFile) return;
294
+ if (getSidebarTab() !== "code") return;
295
+ openSourceRef.current(domEditSelection.sourceFile, {
296
+ id: domEditSelection.id,
297
+ selector: domEditSelection.selector,
298
+ selectorIndex: domEditSelection.selectorIndex,
299
+ });
300
+ }, [domEditSelection, getSidebarTab]);
301
+
284
302
  return {
285
303
  // State
286
304
  domEditSelection,
@@ -122,6 +122,9 @@ export function useFileManager({
122
122
  const handleFileSelect = useCallback((path: string) => {
123
123
  const pid = projectIdRef.current;
124
124
  if (!pid) return;
125
+ revealAbortRef.current?.abort();
126
+ revealAbortRef.current = null;
127
+ revealRequestIdRef.current++;
125
128
  // Skip fetching binary content for media files — just set the path for preview
126
129
  if (isMediaFile(path)) {
127
130
  setEditingFile({ path, content: null });
package/src/main.tsx CHANGED
@@ -23,6 +23,12 @@ function errorProps(value: unknown): {
23
23
  }
24
24
 
25
25
  window.addEventListener("error", (event) => {
26
+ if (event.message?.includes("ResizeObserver loop")) {
27
+ event.stopImmediatePropagation();
28
+ event.preventDefault();
29
+ return;
30
+ }
31
+
26
32
  trackStudioEvent("unhandled_error", {
27
33
  ...errorProps(event.error),
28
34
  error_message: event.message,
@@ -9,11 +9,29 @@ import { formatTimelineAttributeNumber } from "../player/components/timelineEdit
9
9
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
10
10
  import type { EditHistoryKind } from "./editHistory";
11
11
 
12
+ function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
13
+ try {
14
+ const doc = iframe?.contentDocument;
15
+ if (!doc) return 0;
16
+ let max = 0;
17
+ for (const el of doc.body.querySelectorAll("*")) {
18
+ const z = parseInt(getComputedStyle(el).zIndex, 10);
19
+ if (Number.isFinite(z) && z > max) max = z;
20
+ }
21
+ return max;
22
+ } catch {
23
+ return 0;
24
+ }
25
+ }
26
+
12
27
  interface AddBlockOptions {
13
28
  projectId: string;
14
29
  blockName: string;
15
30
  activeCompPath: string | null;
16
31
  placement?: { start: number; track: number };
32
+ visualPosition?: { left: number; top: number };
33
+ previewIframe?: HTMLIFrameElement | null;
34
+ currentTime?: number;
17
35
  timelineElements: TimelineElement[];
18
36
  readProjectFile: (path: string) => Promise<string>;
19
37
  writeProjectFile: (path: string, content: string) => Promise<void>;
@@ -44,6 +62,7 @@ export async function addBlockToProject(
44
62
  blockName,
45
63
  activeCompPath,
46
64
  placement,
65
+ visualPosition,
47
66
  timelineElements,
48
67
  readProjectFile,
49
68
  writeProjectFile,
@@ -102,20 +121,18 @@ export async function addBlockToProject(
102
121
  const isBlock = block.type === "hyperframes:block";
103
122
  const hostDims = resolveTimelineAssetInitialGeometry(originalContent);
104
123
 
124
+ const currentTime = opts.currentTime ?? 0;
105
125
  const start = placement
106
126
  ? Number(formatTimelineAttributeNumber(placement.start))
107
- : isBlock
108
- ? relevantElements.reduce(
109
- (max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)),
110
- 0,
111
- )
112
- : 0;
113
- const duration = isBlock
114
- ? (block as { duration: number }).duration
115
- : relevantElements.reduce(
116
- (max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)),
117
- 10,
118
- );
127
+ : Number(formatTimelineAttributeNumber(currentTime));
128
+ const blockDuration =
129
+ "duration" in block ? (block as { duration: number }).duration : undefined;
130
+ const duration =
131
+ blockDuration ??
132
+ relevantElements.reduce(
133
+ (max, te) => Math.max(max, (te.start ?? 0) + (te.duration ?? 0)),
134
+ 10,
135
+ );
119
136
  const track =
120
137
  placement?.track ??
121
138
  (isBlock
@@ -124,26 +141,42 @@ export async function addBlockToProject(
124
141
  ? Math.max(...relevantElements.map((te) => te.track)) + 1
125
142
  : 1);
126
143
 
127
- const zIndex = Math.max(1, relevantElements.length + 1);
128
-
129
- const width = isBlock
130
- ? (block as { dimensions: { width: number } }).dimensions.width
131
- : hostDims.width;
132
- const height = isBlock
133
- ? (block as { dimensions: { height: number } }).dimensions.height
134
- : hostDims.height;
135
-
136
- const subCompHtml =
137
- `<div data-composition-id="${compId}" ` +
138
- `data-composition-src="${compositionFile}" ` +
139
- `data-start="${formatTimelineAttributeNumber(start)}" ` +
140
- `data-duration="${formatTimelineAttributeNumber(duration)}" ` +
141
- `data-track-index="${track}" ` +
142
- `data-width="${width}" data-height="${height}" ` +
143
- `style="position: absolute; left: 0px; top: 0px; width: ${width}px; height: ${height}px; z-index: ${zIndex}">` +
144
- `</div>`;
145
-
146
- const patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml);
144
+ const zIndex = getMaxZIndexFromIframe(opts.previewIframe ?? null) + 1;
145
+
146
+ const width = hostDims.width;
147
+ const height = hostDims.height;
148
+
149
+ const left = visualPosition ? Math.round(visualPosition.left) : 0;
150
+ const top = visualPosition ? Math.round(visualPosition.top) : 0;
151
+
152
+ const subCompHtml = [
153
+ `<div`,
154
+ ` data-composition-id="${compId}"`,
155
+ ` data-composition-src="${compositionFile}"`,
156
+ ` data-start="${formatTimelineAttributeNumber(start)}"`,
157
+ ` data-duration="${formatTimelineAttributeNumber(duration)}"`,
158
+ ` data-track-index="${track}"`,
159
+ ` data-width="${width}"`,
160
+ ` data-height="${height}"`,
161
+ ` style="position: absolute; left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px; z-index: ${zIndex}"`,
162
+ `></div>`,
163
+ ].join("\n");
164
+
165
+ let patchedContent = insertTimelineAssetIntoSource(originalContent, subCompHtml);
166
+
167
+ const newEnd = start + duration;
168
+ const rootDurMatch = patchedContent.match(
169
+ /(<[^>]*data-composition-id="[^"]*"[^>]*data-duration=")([^"]*)(")/,
170
+ );
171
+ if (rootDurMatch) {
172
+ const rootDur = parseFloat(rootDurMatch[2]!);
173
+ if (newEnd > rootDur) {
174
+ patchedContent = patchedContent.replace(
175
+ rootDurMatch[0],
176
+ `${rootDurMatch[1]}${formatTimelineAttributeNumber(newEnd)}${rootDurMatch[3]}`,
177
+ );
178
+ }
179
+ }
147
180
 
148
181
  await saveProjectFilesWithHistory({
149
182
  projectId,
@@ -126,5 +126,12 @@ export function insertTimelineAssetIntoSource(source: string, assetHtml: string)
126
126
  throw new Error("No composition root found in target source");
127
127
  }
128
128
  const insertAt = match.index + match[0].length;
129
- return `${source.slice(0, insertAt)}${assetHtml}${source.slice(insertAt)}`;
129
+ const lineStart = source.lastIndexOf("\n", match.index);
130
+ const leadingWhitespace = source.slice(lineStart + 1, match.index).match(/^(\s*)/)?.[1] ?? "";
131
+ const childIndent = leadingWhitespace + " ";
132
+ const indented = assetHtml
133
+ .split("\n")
134
+ .map((line, i) => (i === 0 ? line : childIndent + line))
135
+ .join("\n");
136
+ return `${source.slice(0, insertAt)}\n${childIndent}${indented}${source.slice(insertAt)}`;
130
137
  }