@hyperframes/studio 0.8.139 → 0.8.140

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.
Files changed (72) hide show
  1. package/dist/assets/{hyperframes-player-CckOoCJ8.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DsEkE5_G.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-BmIPrb8I.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-Br1Whc0f.js → index-VAHDM_zL.js} +204 -204
  5. package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
  6. package/dist/index.d.ts +19 -3
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +6498 -5813
  9. package/dist/index.js.map +1 -1
  10. package/dist/player.js +1 -1
  11. package/package.json +8 -8
  12. package/src/components/TimelineToolbar.test.tsx +79 -1
  13. package/src/components/TimelineToolbar.tsx +14 -29
  14. package/src/components/editor/DomEditOverlay.tsx +2 -2
  15. package/src/components/editor/PropertyPanel.tsx +1 -0
  16. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  17. package/src/components/editor/manualEdits.ts +1 -1
  18. package/src/components/editor/manualEditsParsing.ts +14 -2
  19. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  20. package/src/components/editor/propertyPanelFill.tsx +36 -13
  21. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  23. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  24. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  25. package/src/components/nle/NLEPreview.test.ts +44 -1
  26. package/src/components/nle/NLEPreview.tsx +7 -2
  27. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  28. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  29. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  30. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  31. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  32. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  33. package/src/hooks/useSdkSession.ts +4 -1
  34. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  35. package/src/hooks/useThumbnailStripSize.ts +23 -1
  36. package/src/index.ts +1 -0
  37. package/src/player/components/AudioWaveform.test.tsx +153 -1
  38. package/src/player/components/AudioWaveform.tsx +93 -37
  39. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  40. package/src/player/components/Timeline.test.ts +3 -1
  41. package/src/player/components/TimelineCanvas.tsx +4 -0
  42. package/src/player/components/TimelineClip.tsx +20 -6
  43. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  44. package/src/player/components/TimelineClipFades.tsx +4 -4
  45. package/src/player/components/TimelineClipJoins.tsx +23 -18
  46. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  47. package/src/player/components/TimelineGroupRow.tsx +1 -0
  48. package/src/player/components/TimelineLaneClip.tsx +127 -0
  49. package/src/player/components/TimelineLanes.test.tsx +51 -6
  50. package/src/player/components/TimelineLanes.tsx +166 -178
  51. package/src/player/components/TimelineRuler.tsx +2 -0
  52. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  53. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  54. package/src/player/components/VideoThumbnail.tsx +37 -9
  55. package/src/player/components/thumbnailUtils.test.ts +17 -3
  56. package/src/player/components/thumbnailUtils.ts +17 -1
  57. package/src/player/components/timelineLayout.test.ts +3 -3
  58. package/src/player/components/timelineMotion.test.ts +27 -0
  59. package/src/player/components/timelineMotion.ts +62 -0
  60. package/src/player/components/timelineZoomInput.test.ts +438 -0
  61. package/src/player/components/timelineZoomInput.ts +456 -0
  62. package/src/player/components/useTimelineGeometry.ts +0 -3
  63. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  64. package/src/player/components/useTimelinePlayhead.ts +53 -59
  65. package/src/player/components/useTimelineProviderState.tsx +16 -21
  66. package/src/player/components/useTimelineZoom.ts +7 -12
  67. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  68. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  69. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  70. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  71. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  72. /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
package/dist/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-E422JDF5.js";
3
+ } from "./chunk-H2DUOXTF.js";
4
4
  import "./chunk-LOVBLSMR.js";
5
5
  import "./chunk-TFLU2MGI.js";
6
6
  import "./chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.139",
3
+ "version": "0.8.140",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.139",
64
- "@hyperframes/player": "0.8.139",
65
- "@hyperframes/shader-transitions": "0.8.139",
66
- "@hyperframes/studio-server": "0.8.139",
67
- "@hyperframes/sdk": "0.8.139",
68
- "@hyperframes/parsers": "0.8.139"
63
+ "@hyperframes/core": "0.8.140",
64
+ "@hyperframes/parsers": "0.8.140",
65
+ "@hyperframes/sdk": "0.8.140",
66
+ "@hyperframes/player": "0.8.140",
67
+ "@hyperframes/studio-server": "0.8.140",
68
+ "@hyperframes/shader-transitions": "0.8.140"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -82,7 +82,7 @@
82
82
  "vite": "^6.4.2",
83
83
  "vitest": "^4.1.11",
84
84
  "zustand": "^5.0.0",
85
- "@hyperframes/producer": "0.8.139"
85
+ "@hyperframes/producer": "0.8.140"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": "19",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { makeSelection } from "../hooks/domSelectionTestHarness";
@@ -11,6 +11,7 @@ import { readStudioUiPreferences } from "../utils/studioUiPreferences";
11
11
  import { dispatchPlainKey, type HotkeyCallbacks } from "../hooks/appHotkeysDispatch";
12
12
  import { AudioMeterStrip } from "./nle/AudioMeterStrip";
13
13
  import { TimelineToolbar } from "./TimelineToolbar";
14
+ import { registerTimelineZoomViewport } from "../player/components/timelineZoomInput";
14
15
 
15
16
  vi.mock("../contexts/StudioContext", () => ({
16
17
  useStudioShellContextOptional: () => ({
@@ -239,6 +240,81 @@ describe("TimelineToolbar snap key", () => {
239
240
  });
240
241
 
241
242
  describe("TimelineToolbar Fit", () => {
243
+ // A zoom lands on the next frame; these tests step frames instead of waiting for them.
244
+ beforeEach(() => {
245
+ vi.useFakeTimers({
246
+ toFake: [
247
+ "requestAnimationFrame",
248
+ "cancelAnimationFrame",
249
+ "setTimeout",
250
+ "clearTimeout",
251
+ "performance",
252
+ ],
253
+ });
254
+ });
255
+ afterEach(() => {
256
+ vi.useRealTimers();
257
+ });
258
+
259
+ /** A mounted timeline for the zoom to preview on: 1080px wide, 32px of headers. */
260
+ function mountTimelineViewport() {
261
+ const scroll = document.createElement("div");
262
+ Object.defineProperties(scroll, {
263
+ clientWidth: { value: 1080 },
264
+ // At Fit the content is as wide as the view.
265
+ scrollWidth: { value: 1080 },
266
+ scrollLeft: { value: 0, writable: true },
267
+ });
268
+ usePlayerStore.setState({
269
+ zoomMode: "fit",
270
+ manualZoomPercent: 100,
271
+ timelineFitPps: 10,
272
+ timelinePps: 10,
273
+ });
274
+ return registerTimelineZoomViewport({ scroll, contentOrigin: 32 });
275
+ }
276
+
277
+ it("moves the slider and its readout with a zoom while it is previewed", () => {
278
+ const unregister = mountTimelineViewport();
279
+ const { host, root } = renderToolbar();
280
+ const slider = host.querySelector<HTMLInputElement>('input[aria-label="Timeline zoom"]')!;
281
+ const before = slider.value;
282
+ act(() => {
283
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
284
+ slider,
285
+ String(Number(before) + 5),
286
+ );
287
+ slider.dispatchEvent(new Event("input", { bubbles: true }));
288
+ });
289
+ act(() => vi.advanceTimersToNextFrame());
290
+ // Not laid out yet: the store still holds Fit, but the toolbar shows the zoom on screen.
291
+ expect(usePlayerStore.getState().zoomMode).toBe("fit");
292
+ expect(slider.value).not.toBe(before);
293
+ expect(host.querySelector('[aria-label="Timeline zoom level"]')?.textContent).not.toBe("Fit");
294
+ act(() => root.unmount());
295
+ act(() => unregister());
296
+ });
297
+
298
+ it("keeps Fit when it is picked while a zoom waits to be laid out", () => {
299
+ const unregister = mountTimelineViewport();
300
+ const { host, root } = renderToolbar();
301
+ act(() =>
302
+ host
303
+ .querySelector('button[aria-label="Zoom in"]')
304
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
305
+ );
306
+
307
+ act(() =>
308
+ host
309
+ .querySelector('button[aria-label="Fit timeline to width"]')
310
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
311
+ );
312
+ act(() => vi.advanceTimersByTime(1000));
313
+ expect(usePlayerStore.getState().zoomMode).toBe("fit");
314
+ act(() => root.unmount());
315
+ act(() => unregister());
316
+ });
317
+
242
318
  it("shows Fit as a named icon and says whether fit is on", () => {
243
319
  const { host, root } = renderToolbar();
244
320
  const fit = () => host.querySelector('button[aria-label="Fit timeline to width"]');
@@ -251,6 +327,7 @@ describe("TimelineToolbar Fit", () => {
251
327
  .querySelector('button[aria-label="Zoom in"]')
252
328
  ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
253
329
  );
330
+ act(() => vi.advanceTimersToNextFrame());
254
331
  expect(fit()?.getAttribute("aria-pressed")).toBe("false");
255
332
  act(() => root.unmount());
256
333
  });
@@ -263,6 +340,7 @@ describe("TimelineToolbar Fit", () => {
263
340
  .querySelector('button[aria-label="Zoom in"]')
264
341
  ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
265
342
  );
343
+ act(() => vi.advanceTimersToNextFrame());
266
344
  expect(usePlayerStore.getState().userZoomCount).toBe(before + 1);
267
345
  act(() => root.unmount());
268
346
  });
@@ -14,12 +14,14 @@ import {
14
14
  import { computeElementPercentage, playsNear } from "../hooks/gsapShared";
15
15
  import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
16
16
  import {
17
- getNextTimelineZoomPercent,
18
- getTimelineZoomPercent,
19
17
  timelineZoomPercentToSlider,
20
18
  timelineSliderToZoomPercent,
21
19
  } from "../player/components/timelineZoom";
22
20
  import { useTimelineZoom } from "../player/components/useTimelineZoom";
21
+ import {
22
+ useShownTimelineZoomPercent,
23
+ zoomTimelineStep,
24
+ } from "../player/components/timelineZoomInput";
23
25
  import { usePlayerStore, type TimelineElement } from "../player";
24
26
  import { Tooltip } from "./ui";
25
27
  import { AudioMetersIcon } from "./icons/AudioMetersIcon";
@@ -170,17 +172,13 @@ export const TimelineToolbar = memo(function TimelineToolbar({
170
172
  // Subscribe so the add-beat button reacts to playhead movement and analysis load.
171
173
  const currentTime = usePlayerStore((s) => s.currentTime);
172
174
  const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
173
- // Subscribe (not getState) so the split button enables/disables the moment
174
- // the selection changes, not only on the next playhead tick.
175
+ // Subscribed, so the split button follows the selection, not only playhead ticks.
175
176
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
176
177
  const elements = usePlayerStore((s) => s.elements);
177
178
  const timelineFitPps = usePlayerStore((s) => s.timelineFitPps);
178
- const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
179
- const displayedTimelineZoomPercent = getTimelineZoomPercent(
180
- zoomMode,
181
- manualZoomPercent,
182
- timelineFitPps,
183
- );
179
+ const { zoomMode, setZoomMode, setManualZoomPercent } = useTimelineZoom();
180
+ const displayedTimelineZoomPercent = useShownTimelineZoomPercent();
181
+ const showsFit = zoomMode === "fit" && displayedTimelineZoomPercent === 100;
184
182
  const {
185
183
  state: keyframeState,
186
184
  isMotionPath: keyframeIsMotionPath,
@@ -215,8 +213,6 @@ export const TimelineToolbar = memo(function TimelineToolbar({
215
213
  }, []);
216
214
 
217
215
  return (
218
- // The "TIMELINE" label is dropped for CapCut-like density — the pane's
219
- // position (tracks right below) makes it self-evident.
220
216
  <div className="border-b border-neutral-800/60">
221
217
  <div className="flex items-center justify-between px-2 py-0.5">
222
218
  <div className="flex items-center gap-0.5">
@@ -510,12 +506,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
510
506
  <button
511
507
  type="button"
512
508
  aria-label="Zoom out"
513
- onClick={() => {
514
- setZoomMode("manual");
515
- setManualZoomPercent(
516
- getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent, timelineFitPps),
517
- );
518
- }}
509
+ onClick={() => zoomTimelineStep("out")}
519
510
  className={flatIdle}
520
511
  >
521
512
  <MagnifyingGlassMinus size={16} aria-hidden="true" />
@@ -528,12 +519,11 @@ export const TimelineToolbar = memo(function TimelineToolbar({
528
519
  value={timelineZoomPercentToSlider(displayedTimelineZoomPercent, timelineFitPps)}
529
520
  title={`${displayedTimelineZoomPercent}%`}
530
521
  aria-label="Timeline zoom"
531
- onChange={(e) => {
532
- setZoomMode("manual");
522
+ onChange={(e) =>
533
523
  setManualZoomPercent(
534
524
  timelineSliderToZoomPercent(Number(e.target.value), timelineFitPps),
535
- );
536
- }}
525
+ )
526
+ }
537
527
  // h-6 on the input is the 24x24 WCAG 2.2 (2.5.8) target: the visible
538
528
  // track stays 2px and the thumb 10px, only the pointer box grows.
539
529
  className="mx-1 h-6 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-text-0 [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
@@ -542,12 +532,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
542
532
  <button
543
533
  type="button"
544
534
  aria-label="Zoom in"
545
- onClick={() => {
546
- setZoomMode("manual");
547
- setManualZoomPercent(
548
- getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent, timelineFitPps),
549
- );
550
- }}
535
+ onClick={() => zoomTimelineStep("in")}
551
536
  className={flatIdle}
552
537
  >
553
538
  <MagnifyingGlassPlus size={16} aria-hidden="true" />
@@ -558,7 +543,7 @@ export const TimelineToolbar = memo(function TimelineToolbar({
558
543
  className="ml-1 w-[38px] text-right font-mono text-[11px] tabular-nums text-neutral-500 select-none"
559
544
  aria-label="Timeline zoom level"
560
545
  >
561
- {zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`}
546
+ {showsFit ? "Fit" : `${displayedTimelineZoomPercent}%`}
562
547
  </span>
563
548
  </div>
564
549
  </div>
@@ -61,7 +61,7 @@ export interface DomEditOverlayProps {
61
61
  hoverSelection: DomEditSelection | null;
62
62
  allowCanvasMovement?: boolean;
63
63
  allowBodyDrag?: boolean;
64
- /** "host": no hover, marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
64
+ /** "host": no marquee, box re-select, or body drag if allowBodyDrag is false; Enter still opens text. */
65
65
  canvasInput?: "overlay" | "host";
66
66
  onTextEditingChange?: (editing: boolean) => void;
67
67
  /** A click on a single selection's box, in either mode; the event may be the pointerup. */
@@ -500,7 +500,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
500
500
  onLostPointerCapture={gestures.onLostPointerCapture}
501
501
  onContextMenu={hostInput ? undefined : handleContextMenu}
502
502
  >
503
- {!hostInput && hoverSelection && hoverRect && compRect.width > 0 && (
503
+ {hoverSelection && hoverRect && compRect.width > 0 && (
504
504
  <div
505
505
  aria-hidden="true"
506
506
  data-dom-edit-hover-box="true"
@@ -565,6 +565,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
565
565
  styles={styles}
566
566
  assets={assets}
567
567
  onSetStyle={onSetStyle}
568
+ onSetHtmlAttribute={props.onSetHtmlAttribute}
568
569
  onImportAssets={onImportAssets}
569
570
  gsapBorderRadius={gsapBorderRadius}
570
571
  />
@@ -351,6 +351,7 @@ export function PropertyPanelFlat({
351
351
  styles={styles}
352
352
  assets={assets}
353
353
  onSetStyle={onSetStyle}
354
+ onSetHtmlAttribute={forwardedProps.onSetHtmlAttribute}
354
355
  onPreviewStyle={onPreviewStyle}
355
356
  onImportAssets={onImportAssets}
356
357
  gsapBorderRadius={gsapBorderRadius}
@@ -10,7 +10,7 @@ export {
10
10
  } from "./manualEditsTypes";
11
11
 
12
12
  export {
13
- mergeFileChangeAffectedCompositions,
13
+ mergeFileChangePayloads,
14
14
  readFileChangeAffectedCompositions,
15
15
  readFileChangeAffectsPreview,
16
16
  readFileChangeField,
@@ -44,9 +44,21 @@ export function readFileChangeAffectedCompositions(payload: unknown): readonly s
44
44
  return value.map(normalizeStudioFileChangePath);
45
45
  }
46
46
 
47
- export function mergeFileChangeAffectedCompositions(waiting: unknown, incoming: unknown): unknown {
47
+ export function mergeFileChangePayloads(waiting: unknown, incoming: unknown): unknown {
48
48
  const before = readFileChangeAffectedCompositions(waiting);
49
49
  const after = readFileChangeAffectedCompositions(incoming);
50
50
  const merged = before && after ? [...new Set([...before, ...after])] : null;
51
- return { ...asPayloadRecord(incoming), affectedCompositions: merged };
51
+ const next = asPayloadRecord(incoming);
52
+ const affectsPreview =
53
+ readFileChangeAffectsPreview(waiting) || readFileChangeAffectsPreview(incoming);
54
+ if (readStudioFileChangePath(waiting) === readStudioFileChangePath(incoming)) {
55
+ return { ...next, affectsPreview, affectedCompositions: merged };
56
+ }
57
+ // Changes to two files are owed: only a project-wide reload ("." path) covers both.
58
+ return {
59
+ path: ".",
60
+ projectId: next?.projectId,
61
+ affectsPreview,
62
+ affectedCompositions: merged,
63
+ };
52
64
  }
@@ -311,7 +311,7 @@ describe("DomEditOverlay with canvasInput host", () => {
311
311
  expect(fixture(HOST).overlay.className).toContain("pointer-events-none");
312
312
  });
313
313
 
314
- it("draws no hover box", () => {
314
+ it("draws the hover box for the selection the host hovers, and none without one", () => {
315
315
  layout.hover = RECT;
316
316
  const hovered = (props = {}) => {
317
317
  const { overlay } = fixture(props);
@@ -321,7 +321,8 @@ describe("DomEditOverlay with canvasInput host", () => {
321
321
  };
322
322
  const selection = makeSelection("Hover", textElement("hover"));
323
323
  expect(hovered({ hoverSelection: selection })).not.toBeNull();
324
- expect(hovered({ ...HOST, hoverSelection: selection })).toBeNull();
324
+ expect(hovered({ ...HOST, hoverSelection: selection })).not.toBeNull();
325
+ expect(hovered({ ...HOST, hoverSelection: null })).toBeNull();
325
326
  });
326
327
 
327
328
  it("starts no marquee and makes no selection from a press on empty canvas", () => {
@@ -10,6 +10,7 @@ import {
10
10
  } from "./gradientValue";
11
11
  import { ReverseGradientIcon } from "../icons/ReverseGradientIcon";
12
12
  import { IMAGE_EXT } from "../../utils/mediaTypes";
13
+ import type { DomEditSelection } from "./domEditing";
13
14
  import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
14
15
  import {
15
16
  DetailField,
@@ -27,9 +28,13 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
27
28
  function normalizeProjectPath(value: string): string {
28
29
  const trimmed = value.trim();
29
30
  const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
30
- return decodeURIComponent(maybeUrl)
31
- .replace(/\\/g, "/")
32
- .replace(/^\.?\//, "");
31
+ let decodedPath = maybeUrl;
32
+ try {
33
+ decodedPath = decodeURIComponent(maybeUrl);
34
+ } catch (error) {
35
+ if (!(error instanceof URIError)) throw error;
36
+ }
37
+ return decodedPath.replace(/\\/g, "/").replace(/^\.?\//, "");
33
38
  }
34
39
 
35
40
  function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
@@ -75,7 +80,8 @@ function resolveSelectedAsset(
75
80
 
76
81
  export function ImageFillField({
77
82
  projectId,
78
- sourceFile,
83
+ element,
84
+ onSetHtmlAttribute,
79
85
  value,
80
86
  assets,
81
87
  disabled,
@@ -83,7 +89,8 @@ export function ImageFillField({
83
89
  onImportAssets,
84
90
  }: {
85
91
  projectId: string;
86
- sourceFile: string;
92
+ element: DomEditSelection;
93
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
87
94
  value: string;
88
95
  assets: string[];
89
96
  disabled?: boolean;
@@ -91,15 +98,29 @@ export function ImageFillField({
91
98
  onImportAssets?: (files: FileList) => Promise<string[]>;
92
99
  }) {
93
100
  const track = useTrackDesignInput();
101
+ const sourceFile = element.sourceFile;
102
+ const isImage = element.tagName === "img";
103
+ const imageUrl = isImage ? (element.element.getAttribute("src") ?? "") : value;
104
+ const commitImage = (next: string, projectAsset = false) => {
105
+ if (isImage) {
106
+ if (next)
107
+ void onSetHtmlAttribute(
108
+ "src",
109
+ projectAsset ? toRelativeProjectAssetPath(sourceFile, next) : next,
110
+ );
111
+ return;
112
+ }
113
+ onCommit(next ? `url("${next}")` : "none");
114
+ };
94
115
  const fileInputRef = useRef<HTMLInputElement | null>(null);
95
116
  const [uploading, setUploading] = useState(false);
96
117
  const [uploadError, setUploadError] = useState<string | null>(null);
97
118
  const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
98
119
  const selectedAsset = useMemo(
99
- () => resolveSelectedAsset(value, sourceFile, imageAssets),
100
- [imageAssets, sourceFile, value],
120
+ () => resolveSelectedAsset(imageUrl, sourceFile, imageAssets),
121
+ [imageAssets, sourceFile, imageUrl],
101
122
  );
102
- const externalUrlValue = selectedAsset ? "" : value;
123
+ const externalUrlValue = selectedAsset ? "" : imageUrl;
103
124
 
104
125
  const handleUpload = async (files: FileList | null) => {
105
126
  if (!files?.length || !onImportAssets) return;
@@ -110,7 +131,7 @@ export function ImageFillField({
110
131
  const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
111
132
  if (nextImage) {
112
133
  track("button", "Upload image");
113
- onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
134
+ commitImage(toProjectRootAssetPath(nextImage), true);
114
135
  }
115
136
  } catch {
116
137
  setUploadError("Upload failed — check the file and try again.");
@@ -174,14 +195,16 @@ export function ImageFillField({
174
195
  const next = e.target.value;
175
196
  track("select", "Project asset");
176
197
  if (!next) {
177
- onCommit("none");
198
+ commitImage("");
178
199
  return;
179
200
  }
180
- onCommit(`url("${toProjectRootAssetPath(next)}")`);
201
+ commitImage(toProjectRootAssetPath(next), true);
181
202
  }}
182
203
  className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-hidden disabled:cursor-not-allowed disabled:text-neutral-600"
183
204
  >
184
- <option value="">None</option>
205
+ <option value="" disabled={isImage}>
206
+ None
207
+ </option>
185
208
  {imageAssets.map((asset) => (
186
209
  <option key={asset} value={asset}>
187
210
  {asset}
@@ -201,7 +224,7 @@ export function ImageFillField({
201
224
  label="External URL"
202
225
  value={externalUrlValue}
203
226
  disabled={disabled}
204
- onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
227
+ onCommit={(next) => commitImage(next.trim())}
205
228
  />
206
229
  </div>
207
230
  );
@@ -63,6 +63,7 @@ function renderSection(
63
63
  styles={mergedStyles}
64
64
  assets={[]}
65
65
  onSetStyle={onSetStyle}
66
+ onSetHtmlAttribute={vi.fn()}
66
67
  gsapBorderRadius={gsapBorderRadius}
67
68
  />,
68
69
  );
@@ -375,6 +376,7 @@ describe("FlatStyleSection — blur sliders", () => {
375
376
  styles={{ filter: "blur(4px)" }}
376
377
  assets={[]}
377
378
  onSetStyle={onSetStyle}
379
+ onSetHtmlAttribute={vi.fn()}
378
380
  gsapBorderRadius={null}
379
381
  />,
380
382
  );
@@ -483,6 +485,7 @@ describe("FlatStyleSection — Overflow and Mask", () => {
483
485
  styles={{}}
484
486
  assets={[]}
485
487
  onSetStyle={onSetStyle}
488
+ onSetHtmlAttribute={vi.fn()}
486
489
  gsapBorderRadius={null}
487
490
  />,
488
491
  );
@@ -593,6 +596,7 @@ describe("FlatStyleSection — Opacity", () => {
593
596
  styles={{}}
594
597
  assets={[]}
595
598
  onSetStyle={onSetStyle}
599
+ onSetHtmlAttribute={vi.fn()}
596
600
  gsapBorderRadius={null}
597
601
  />,
598
602
  );
@@ -43,6 +43,7 @@ function FlatFillFields({
43
43
  styles,
44
44
  assets,
45
45
  onSetStyle,
46
+ onSetHtmlAttribute,
46
47
  onPreviewStyle,
47
48
  onImportAssets,
48
49
  }: {
@@ -51,18 +52,19 @@ function FlatFillFields({
51
52
  styles: Record<string, string>;
52
53
  assets: string[];
53
54
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
55
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
54
56
  onPreviewStyle?: (prop: string, value: string) => void;
55
57
  onImportAssets?: (files: FileList) => Promise<string[]>;
56
58
  }) {
57
59
  const styleEditingDisabled = !element.capabilities.canEditStyles;
58
60
  const backgroundImage = styles["background-image"] ?? "none";
59
61
  const hasTextControls = isTextEditableSelection(element);
60
- const fillMode =
61
- backgroundImage && backgroundImage !== "none"
62
- ? backgroundImage.includes("gradient")
63
- ? "Gradient"
64
- : "Image"
65
- : "Solid";
62
+ let fillMode = "Solid";
63
+ if (element.tagName === "img") {
64
+ fillMode = "Image";
65
+ } else if (backgroundImage && backgroundImage !== "none") {
66
+ fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
67
+ }
66
68
  const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
67
69
  const imageUrl = extractBackgroundImageUrl(backgroundImage);
68
70
 
@@ -124,7 +126,8 @@ function FlatFillFields({
124
126
  ) : (
125
127
  <ImageFillField
126
128
  projectId={projectId}
127
- sourceFile={element.sourceFile}
129
+ element={element}
130
+ onSetHtmlAttribute={onSetHtmlAttribute}
128
131
  value={imageUrl}
129
132
  assets={assets}
130
133
  disabled={styleEditingDisabled}
@@ -456,6 +459,7 @@ export function FlatStyleSection({
456
459
  styles,
457
460
  assets,
458
461
  onSetStyle,
462
+ onSetHtmlAttribute,
459
463
  onPreviewStyle,
460
464
  onImportAssets,
461
465
  gsapBorderRadius,
@@ -465,6 +469,7 @@ export function FlatStyleSection({
465
469
  styles: Record<string, string>;
466
470
  assets: string[];
467
471
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
472
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
468
473
  onPreviewStyle?: (prop: string, value: string) => void;
469
474
  onImportAssets?: (files: FileList) => Promise<string[]>;
470
475
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
@@ -478,6 +483,7 @@ export function FlatStyleSection({
478
483
  styles={styles}
479
484
  assets={assets}
480
485
  onSetStyle={onSetStyle}
486
+ onSetHtmlAttribute={onSetHtmlAttribute}
481
487
  onPreviewStyle={onPreviewStyle}
482
488
  onImportAssets={onImportAssets}
483
489
  />