Skip to content

Commit 17c699d

Browse files
fix(studio): replacing an image's fill on an img changes its picture (#5170)
* fix(studio): replace image fill through its source attribute * fix(studio): preserve literal percent image sources
1 parent 9701f0d commit 17c699d

7 files changed

Lines changed: 229 additions & 27 deletions

‎packages/studio/src/components/editor/PropertyPanel.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
565565
styles={styles}
566566
assets={assets}
567567
onSetStyle={onSetStyle}
568+
onSetHtmlAttribute={props.onSetHtmlAttribute}
568569
onImportAssets={onImportAssets}
569570
gsapBorderRadius={gsapBorderRadius}
570571
/>

‎packages/studio/src/components/editor/PropertyPanelFlat.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ export function PropertyPanelFlat({
351351
styles={styles}
352352
assets={assets}
353353
onSetStyle={onSetStyle}
354+
onSetHtmlAttribute={forwardedProps.onSetHtmlAttribute}
354355
onPreviewStyle={onPreviewStyle}
355356
onImportAssets={onImportAssets}
356357
gsapBorderRadius={gsapBorderRadius}

‎packages/studio/src/components/editor/propertyPanelFill.tsx‎

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "./gradientValue";
1111
import { ReverseGradientIcon } from "../icons/ReverseGradientIcon";
1212
import { IMAGE_EXT } from "../../utils/mediaTypes";
13+
import type { DomEditSelection } from "./domEditing";
1314
import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
1415
import {
1516
DetailField,
@@ -27,9 +28,13 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
2728
function normalizeProjectPath(value: string): string {
2829
const trimmed = value.trim();
2930
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(/^\.?\//, "");
3338
}
3439

3540
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
@@ -75,31 +80,47 @@ function resolveSelectedAsset(
7580

7681
export function ImageFillField({
7782
projectId,
78-
sourceFile,
83+
element,
84+
onSetHtmlAttribute,
7985
value,
8086
assets,
8187
disabled,
8288
onCommit,
8389
onImportAssets,
8490
}: {
8591
projectId: string;
86-
sourceFile: string;
92+
element: DomEditSelection;
93+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
8794
value: string;
8895
assets: string[];
8996
disabled?: boolean;
9097
onCommit: (nextValue: string) => void;
9198
onImportAssets?: (files: FileList) => Promise<string[]>;
9299
}) {
93100
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+
};
94115
const fileInputRef = useRef<HTMLInputElement | null>(null);
95116
const [uploading, setUploading] = useState(false);
96117
const [uploadError, setUploadError] = useState<string | null>(null);
97118
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
98119
const selectedAsset = useMemo(
99-
() => resolveSelectedAsset(value, sourceFile, imageAssets),
100-
[imageAssets, sourceFile, value],
120+
() => resolveSelectedAsset(imageUrl, sourceFile, imageAssets),
121+
[imageAssets, sourceFile, imageUrl],
101122
);
102-
const externalUrlValue = selectedAsset ? "" : value;
123+
const externalUrlValue = selectedAsset ? "" : imageUrl;
103124

104125
const handleUpload = async (files: FileList | null) => {
105126
if (!files?.length || !onImportAssets) return;
@@ -110,7 +131,7 @@ export function ImageFillField({
110131
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
111132
if (nextImage) {
112133
track("button", "Upload image");
113-
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
134+
commitImage(toProjectRootAssetPath(nextImage), true);
114135
}
115136
} catch {
116137
setUploadError("Upload failed — check the file and try again.");
@@ -174,14 +195,16 @@ export function ImageFillField({
174195
const next = e.target.value;
175196
track("select", "Project asset");
176197
if (!next) {
177-
onCommit("none");
198+
commitImage("");
178199
return;
179200
}
180-
onCommit(`url("${toProjectRootAssetPath(next)}")`);
201+
commitImage(toProjectRootAssetPath(next), true);
181202
}}
182203
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"
183204
>
184-
<option value="">None</option>
205+
<option value="" disabled={isImage}>
206+
None
207+
</option>
185208
{imageAssets.map((asset) => (
186209
<option key={asset} value={asset}>
187210
{asset}
@@ -201,7 +224,7 @@ export function ImageFillField({
201224
label="External URL"
202225
value={externalUrlValue}
203226
disabled={disabled}
204-
onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
227+
onCommit={(next) => commitImage(next.trim())}
205228
/>
206229
</div>
207230
);

‎packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ function renderSection(
6363
styles={mergedStyles}
6464
assets={[]}
6565
onSetStyle={onSetStyle}
66+
onSetHtmlAttribute={vi.fn()}
6667
gsapBorderRadius={gsapBorderRadius}
6768
/>,
6869
);
@@ -375,6 +376,7 @@ describe("FlatStyleSection — blur sliders", () => {
375376
styles={{ filter: "blur(4px)" }}
376377
assets={[]}
377378
onSetStyle={onSetStyle}
379+
onSetHtmlAttribute={vi.fn()}
378380
gsapBorderRadius={null}
379381
/>,
380382
);
@@ -483,6 +485,7 @@ describe("FlatStyleSection — Overflow and Mask", () => {
483485
styles={{}}
484486
assets={[]}
485487
onSetStyle={onSetStyle}
488+
onSetHtmlAttribute={vi.fn()}
486489
gsapBorderRadius={null}
487490
/>,
488491
);
@@ -593,6 +596,7 @@ describe("FlatStyleSection — Opacity", () => {
593596
styles={{}}
594597
assets={[]}
595598
onSetStyle={onSetStyle}
599+
onSetHtmlAttribute={vi.fn()}
596600
gsapBorderRadius={null}
597601
/>,
598602
);

‎packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ function FlatFillFields({
4343
styles,
4444
assets,
4545
onSetStyle,
46+
onSetHtmlAttribute,
4647
onPreviewStyle,
4748
onImportAssets,
4849
}: {
@@ -51,18 +52,19 @@ function FlatFillFields({
5152
styles: Record<string, string>;
5253
assets: string[];
5354
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
55+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
5456
onPreviewStyle?: (prop: string, value: string) => void;
5557
onImportAssets?: (files: FileList) => Promise<string[]>;
5658
}) {
5759
const styleEditingDisabled = !element.capabilities.canEditStyles;
5860
const backgroundImage = styles["background-image"] ?? "none";
5961
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+
}
6668
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
6769
const imageUrl = extractBackgroundImageUrl(backgroundImage);
6870

@@ -124,7 +126,8 @@ function FlatFillFields({
124126
) : (
125127
<ImageFillField
126128
projectId={projectId}
127-
sourceFile={element.sourceFile}
129+
element={element}
130+
onSetHtmlAttribute={onSetHtmlAttribute}
128131
value={imageUrl}
129132
assets={assets}
130133
disabled={styleEditingDisabled}
@@ -456,6 +459,7 @@ export function FlatStyleSection({
456459
styles,
457460
assets,
458461
onSetStyle,
462+
onSetHtmlAttribute,
459463
onPreviewStyle,
460464
onImportAssets,
461465
gsapBorderRadius,
@@ -465,6 +469,7 @@ export function FlatStyleSection({
465469
styles: Record<string, string>;
466470
assets: string[];
467471
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
472+
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
468473
onPreviewStyle?: (prop: string, value: string) => void;
469474
onImportAssets?: (files: FileList) => Promise<string[]>;
470475
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
@@ -478,6 +483,7 @@ export function FlatStyleSection({
478483
styles={styles}
479484
assets={assets}
480485
onSetStyle={onSetStyle}
486+
onSetHtmlAttribute={onSetHtmlAttribute}
481487
onPreviewStyle={onPreviewStyle}
482488
onImportAssets={onImportAssets}
483489
/>
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// @vitest-environment happy-dom
2+
import React, { act } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { StyleSections } from "./propertyPanelStyleSections";
6+
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
7+
import type { DomEditSelection } from "./domEditing";
8+
9+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10+
afterEach(() => {
11+
document.body.innerHTML = "";
12+
});
13+
14+
function mount(
15+
Panel: typeof StyleSections | typeof FlatStyleSection,
16+
tag = "img",
17+
sourceFile = "index.html",
18+
src = "assets/old.png",
19+
) {
20+
const selected = document.createElement(tag);
21+
selected.id = "picture";
22+
selected.setAttribute("src", src);
23+
document.body.append(selected);
24+
const element: DomEditSelection = {
25+
element: selected,
26+
id: "picture",
27+
selector: "#picture",
28+
label: "Picture",
29+
tagName: tag,
30+
sourceFile,
31+
compositionPath: sourceFile,
32+
isCompositionHost: false,
33+
isInsideLockedComposition: false,
34+
boundingBox: { x: 0, y: 0, width: 320, height: 180 },
35+
textContent: "",
36+
dataAttributes: {},
37+
inlineStyles: {},
38+
computedStyles: {},
39+
textFields: [],
40+
capabilities: {
41+
canSelect: true,
42+
canEditStyles: true,
43+
canCrop: true,
44+
canMove: true,
45+
canResize: true,
46+
canApplyManualOffset: true,
47+
canApplyManualSize: true,
48+
canApplyManualRotation: true,
49+
},
50+
};
51+
const host = document.createElement("div");
52+
document.body.append(host);
53+
const root = createRoot(host);
54+
const onSetStyle = vi.fn((prop: string, value: string) =>
55+
selected.style.setProperty(prop, value),
56+
);
57+
const onSetHtmlAttribute = vi.fn((attr: string, value: string | null) => {
58+
if (value !== null) selected.setAttribute(attr, value);
59+
});
60+
act(() =>
61+
root.render(
62+
<Panel
63+
projectId="fixture"
64+
element={element}
65+
styles={{}}
66+
assets={["assets/old.png", "assets/new.png"]}
67+
onSetStyle={onSetStyle}
68+
onSetHtmlAttribute={onSetHtmlAttribute}
69+
/>,
70+
),
71+
);
72+
const image = Array.from(host.querySelectorAll("button")).find(
73+
(button) => button.textContent === "Image",
74+
);
75+
if (!image) throw new Error("Image control must exist");
76+
act(() => image.click());
77+
// The Image asset select is the one whose options include the fixture asset.
78+
const assetSelect = Array.from(host.querySelectorAll("select")).find((node) =>
79+
Array.from(node.options).some((option) => option.value === "assets/new.png"),
80+
);
81+
if (!assetSelect) throw new Error("Project asset control must exist");
82+
function choose(value: string) {
83+
const control = assetSelect;
84+
if (!control) throw new Error("Asset control must exist");
85+
act(() => {
86+
control.value = value;
87+
control.dispatchEvent(new Event("change", { bubbles: true }));
88+
});
89+
}
90+
return { host, selected, root, choose, assetSelect, onSetStyle, onSetHtmlAttribute };
91+
}
92+
93+
describe.each([
94+
["classic", StyleSections],
95+
["flat", FlatStyleSection],
96+
] as const)("%s Image fill", (_name, Panel) => {
97+
it("shows the img source as the current project asset", () => {
98+
const view = mount(Panel);
99+
expect(view.assetSelect.value).toBe("assets/old.png");
100+
act(() => view.root.unmount());
101+
});
102+
it.each([
103+
"assets/100%.png",
104+
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100%"></svg>',
105+
])("shows an authored source containing a literal percent: %s", (src) => {
106+
const view = mount(Panel, "img", "index.html", src);
107+
const input = Array.from(view.host.querySelectorAll("label"))
108+
.find((node) => node.querySelector("span")?.textContent === "External URL")
109+
?.querySelector("input");
110+
expect(input?.value).toBe(src);
111+
act(() => view.root.unmount());
112+
});
113+
it("replaces the img source rather than writing a hidden background", () => {
114+
const view = mount(Panel);
115+
view.choose("assets/new.png");
116+
expect(view.selected.getAttribute("src")).toBe("assets/new.png");
117+
expect(view.onSetHtmlAttribute).toHaveBeenCalledExactlyOnceWith("src", "assets/new.png");
118+
expect(view.onSetStyle).not.toHaveBeenCalled();
119+
act(() => view.root.unmount());
120+
});
121+
it("replaces an img with an external URL without CSS wrapping", () => {
122+
const view = mount(Panel);
123+
const row = Array.from(view.host.querySelectorAll("label")).find(
124+
(node) =>
125+
node.querySelector("span")?.textContent === "External URL" && node.querySelector("input"),
126+
);
127+
const input = row?.querySelector("input");
128+
if (!input) throw new Error("External URL input must exist");
129+
act(() => {
130+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
131+
input,
132+
"https://example.com/new.png",
133+
);
134+
input.dispatchEvent(new Event("input", { bubbles: true }));
135+
});
136+
act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
137+
expect(view.selected.getAttribute("src")).toBe("https://example.com/new.png");
138+
expect(view.onSetStyle).not.toHaveBeenCalled();
139+
act(() => view.root.unmount());
140+
});
141+
it("keeps the img source when cleared", () => {
142+
const view = mount(Panel);
143+
view.choose("");
144+
expect(view.selected.getAttribute("src")).toBe("assets/old.png");
145+
expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
146+
expect(view.onSetStyle).not.toHaveBeenCalled();
147+
act(() => view.root.unmount());
148+
});
149+
it("uses source-relative image paths in nested compositions", () => {
150+
const view = mount(Panel, "img", "compositions/scene.html");
151+
view.choose("assets/new.png");
152+
expect(view.selected.getAttribute("src")).toBe("../assets/new.png");
153+
act(() => view.root.unmount());
154+
});
155+
it("keeps background-image replacement and clearing for ordinary elements", () => {
156+
const view = mount(Panel, "div");
157+
view.choose("assets/new.png");
158+
expect(view.selected.style.backgroundImage).toBe('url("assets/new.png")');
159+
view.choose("");
160+
expect(view.selected.style.backgroundImage).toBe("none");
161+
expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
162+
act(() => view.root.unmount());
163+
});
164+
});

0 commit comments

Comments
 (0)