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
This commit is contained in:
Miguel Ángel
2026-10-07 09:11:58 +00:00
committed by GitHub
parent 9701f0da61
commit 17c699db4a
7 changed files with 229 additions and 27 deletions
@@ -565,6 +565,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onSetHtmlAttribute={props.onSetHtmlAttribute}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
/>
@@ -351,6 +351,7 @@ export function PropertyPanelFlat({
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onSetHtmlAttribute={forwardedProps.onSetHtmlAttribute}
onPreviewStyle={onPreviewStyle}
onImportAssets={onImportAssets}
gsapBorderRadius={gsapBorderRadius}
@@ -10,6 +10,7 @@ import {
} from "./gradientValue";
import { ReverseGradientIcon } from "../icons/ReverseGradientIcon";
import { IMAGE_EXT } from "../../utils/mediaTypes";
import type { DomEditSelection } from "./domEditing";
import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
import {
DetailField,
@@ -27,9 +28,13 @@ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
function normalizeProjectPath(value: string): string {
const trimmed = value.trim();
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
return decodeURIComponent(maybeUrl)
.replace(/\\/g, "/")
.replace(/^\.?\//, "");
let decodedPath = maybeUrl;
try {
decodedPath = decodeURIComponent(maybeUrl);
} catch (error) {
if (!(error instanceof URIError)) throw error;
}
return decodedPath.replace(/\\/g, "/").replace(/^\.?\//, "");
}
function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
@@ -75,7 +80,8 @@ function resolveSelectedAsset(
export function ImageFillField({
projectId,
sourceFile,
element,
onSetHtmlAttribute,
value,
assets,
disabled,
@@ -83,7 +89,8 @@ export function ImageFillField({
onImportAssets,
}: {
projectId: string;
sourceFile: string;
element: DomEditSelection;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
value: string;
assets: string[];
disabled?: boolean;
@@ -91,15 +98,29 @@ export function ImageFillField({
onImportAssets?: (files: FileList) => Promise<string[]>;
}) {
const track = useTrackDesignInput();
const sourceFile = element.sourceFile;
const isImage = element.tagName === "img";
const imageUrl = isImage ? (element.element.getAttribute("src") ?? "") : value;
const commitImage = (next: string, projectAsset = false) => {
if (isImage) {
if (next)
void onSetHtmlAttribute(
"src",
projectAsset ? toRelativeProjectAssetPath(sourceFile, next) : next,
);
return;
}
onCommit(next ? `url("${next}")` : "none");
};
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
const selectedAsset = useMemo(
() => resolveSelectedAsset(value, sourceFile, imageAssets),
[imageAssets, sourceFile, value],
() => resolveSelectedAsset(imageUrl, sourceFile, imageAssets),
[imageAssets, sourceFile, imageUrl],
);
const externalUrlValue = selectedAsset ? "" : value;
const externalUrlValue = selectedAsset ? "" : imageUrl;
const handleUpload = async (files: FileList | null) => {
if (!files?.length || !onImportAssets) return;
@@ -110,7 +131,7 @@ export function ImageFillField({
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
if (nextImage) {
track("button", "Upload image");
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
commitImage(toProjectRootAssetPath(nextImage), true);
}
} catch {
setUploadError("Upload failed — check the file and try again.");
@@ -174,14 +195,16 @@ export function ImageFillField({
const next = e.target.value;
track("select", "Project asset");
if (!next) {
onCommit("none");
commitImage("");
return;
}
onCommit(`url("${toProjectRootAssetPath(next)}")`);
commitImage(toProjectRootAssetPath(next), true);
}}
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"
>
<option value="">None</option>
<option value="" disabled={isImage}>
None
</option>
{imageAssets.map((asset) => (
<option key={asset} value={asset}>
{asset}
@@ -201,7 +224,7 @@ export function ImageFillField({
label="External URL"
value={externalUrlValue}
disabled={disabled}
onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
onCommit={(next) => commitImage(next.trim())}
/>
</div>
);
@@ -63,6 +63,7 @@ function renderSection(
styles={mergedStyles}
assets={[]}
onSetStyle={onSetStyle}
onSetHtmlAttribute={vi.fn()}
gsapBorderRadius={gsapBorderRadius}
/>,
);
@@ -375,6 +376,7 @@ describe("FlatStyleSection — blur sliders", () => {
styles={{ filter: "blur(4px)" }}
assets={[]}
onSetStyle={onSetStyle}
onSetHtmlAttribute={vi.fn()}
gsapBorderRadius={null}
/>,
);
@@ -483,6 +485,7 @@ describe("FlatStyleSection — Overflow and Mask", () => {
styles={{}}
assets={[]}
onSetStyle={onSetStyle}
onSetHtmlAttribute={vi.fn()}
gsapBorderRadius={null}
/>,
);
@@ -593,6 +596,7 @@ describe("FlatStyleSection — Opacity", () => {
styles={{}}
assets={[]}
onSetStyle={onSetStyle}
onSetHtmlAttribute={vi.fn()}
gsapBorderRadius={null}
/>,
);
@@ -43,6 +43,7 @@ function FlatFillFields({
styles,
assets,
onSetStyle,
onSetHtmlAttribute,
onPreviewStyle,
onImportAssets,
}: {
@@ -51,18 +52,19 @@ function FlatFillFields({
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onPreviewStyle?: (prop: string, value: string) => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
const backgroundImage = styles["background-image"] ?? "none";
const hasTextControls = isTextEditableSelection(element);
const fillMode =
backgroundImage && backgroundImage !== "none"
? backgroundImage.includes("gradient")
? "Gradient"
: "Image"
: "Solid";
let fillMode = "Solid";
if (element.tagName === "img") {
fillMode = "Image";
} else if (backgroundImage && backgroundImage !== "none") {
fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
}
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
const imageUrl = extractBackgroundImageUrl(backgroundImage);
@@ -124,7 +126,8 @@ function FlatFillFields({
) : (
<ImageFillField
projectId={projectId}
sourceFile={element.sourceFile}
element={element}
onSetHtmlAttribute={onSetHtmlAttribute}
value={imageUrl}
assets={assets}
disabled={styleEditingDisabled}
@@ -456,6 +459,7 @@ export function FlatStyleSection({
styles,
assets,
onSetStyle,
onSetHtmlAttribute,
onPreviewStyle,
onImportAssets,
gsapBorderRadius,
@@ -465,6 +469,7 @@ export function FlatStyleSection({
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onPreviewStyle?: (prop: string, value: string) => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
@@ -478,6 +483,7 @@ export function FlatStyleSection({
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onSetHtmlAttribute={onSetHtmlAttribute}
onPreviewStyle={onPreviewStyle}
onImportAssets={onImportAssets}
/>
@@ -0,0 +1,164 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StyleSections } from "./propertyPanelStyleSections";
import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
import type { DomEditSelection } from "./domEditing";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => {
document.body.innerHTML = "";
});
function mount(
Panel: typeof StyleSections | typeof FlatStyleSection,
tag = "img",
sourceFile = "index.html",
src = "assets/old.png",
) {
const selected = document.createElement(tag);
selected.id = "picture";
selected.setAttribute("src", src);
document.body.append(selected);
const element: DomEditSelection = {
element: selected,
id: "picture",
selector: "#picture",
label: "Picture",
tagName: tag,
sourceFile,
compositionPath: sourceFile,
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 320, height: 180 },
textContent: "",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: true,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
};
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const onSetStyle = vi.fn((prop: string, value: string) =>
selected.style.setProperty(prop, value),
);
const onSetHtmlAttribute = vi.fn((attr: string, value: string | null) => {
if (value !== null) selected.setAttribute(attr, value);
});
act(() =>
root.render(
<Panel
projectId="fixture"
element={element}
styles={{}}
assets={["assets/old.png", "assets/new.png"]}
onSetStyle={onSetStyle}
onSetHtmlAttribute={onSetHtmlAttribute}
/>,
),
);
const image = Array.from(host.querySelectorAll("button")).find(
(button) => button.textContent === "Image",
);
if (!image) throw new Error("Image control must exist");
act(() => image.click());
// The Image asset select is the one whose options include the fixture asset.
const assetSelect = Array.from(host.querySelectorAll("select")).find((node) =>
Array.from(node.options).some((option) => option.value === "assets/new.png"),
);
if (!assetSelect) throw new Error("Project asset control must exist");
function choose(value: string) {
const control = assetSelect;
if (!control) throw new Error("Asset control must exist");
act(() => {
control.value = value;
control.dispatchEvent(new Event("change", { bubbles: true }));
});
}
return { host, selected, root, choose, assetSelect, onSetStyle, onSetHtmlAttribute };
}
describe.each([
["classic", StyleSections],
["flat", FlatStyleSection],
] as const)("%s Image fill", (_name, Panel) => {
it("shows the img source as the current project asset", () => {
const view = mount(Panel);
expect(view.assetSelect.value).toBe("assets/old.png");
act(() => view.root.unmount());
});
it.each([
"assets/100%.png",
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100%"></svg>',
])("shows an authored source containing a literal percent: %s", (src) => {
const view = mount(Panel, "img", "index.html", src);
const input = Array.from(view.host.querySelectorAll("label"))
.find((node) => node.querySelector("span")?.textContent === "External URL")
?.querySelector("input");
expect(input?.value).toBe(src);
act(() => view.root.unmount());
});
it("replaces the img source rather than writing a hidden background", () => {
const view = mount(Panel);
view.choose("assets/new.png");
expect(view.selected.getAttribute("src")).toBe("assets/new.png");
expect(view.onSetHtmlAttribute).toHaveBeenCalledExactlyOnceWith("src", "assets/new.png");
expect(view.onSetStyle).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
it("replaces an img with an external URL without CSS wrapping", () => {
const view = mount(Panel);
const row = Array.from(view.host.querySelectorAll("label")).find(
(node) =>
node.querySelector("span")?.textContent === "External URL" && node.querySelector("input"),
);
const input = row?.querySelector("input");
if (!input) throw new Error("External URL input must exist");
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
input,
"https://example.com/new.png",
);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
expect(view.selected.getAttribute("src")).toBe("https://example.com/new.png");
expect(view.onSetStyle).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
it("keeps the img source when cleared", () => {
const view = mount(Panel);
view.choose("");
expect(view.selected.getAttribute("src")).toBe("assets/old.png");
expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
expect(view.onSetStyle).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
it("uses source-relative image paths in nested compositions", () => {
const view = mount(Panel, "img", "compositions/scene.html");
view.choose("assets/new.png");
expect(view.selected.getAttribute("src")).toBe("../assets/new.png");
act(() => view.root.unmount());
});
it("keeps background-image replacement and clearing for ordinary elements", () => {
const view = mount(Panel, "div");
view.choose("assets/new.png");
expect(view.selected.style.backgroundImage).toBe('url("assets/new.png")');
view.choose("");
expect(view.selected.style.backgroundImage).toBe("none");
expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
act(() => view.root.unmount());
});
});
@@ -45,6 +45,7 @@ export function StyleSections({
styles,
assets,
onSetStyle,
onSetHtmlAttribute,
onImportAssets,
gsapBorderRadius,
hideFlex = false,
@@ -54,6 +55,7 @@ export function StyleSections({
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onImportAssets?: (files: FileList) => Promise<string[]>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
// When true, the Flex `Section` is suppressed. The flat inspector renders
@@ -108,12 +110,12 @@ export function StyleSections({
const backgroundImage = styles["background-image"] ?? "none";
const hasTextControls = isTextEditableSelection(element);
const fillMode =
backgroundImage && backgroundImage !== "none"
? backgroundImage.includes("gradient")
? "Gradient"
: "Image"
: "Solid";
let fillMode = "Solid";
if (element.tagName === "img") {
fillMode = "Image";
} else if (backgroundImage && backgroundImage !== "none") {
fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
}
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
const imageUrl = extractBackgroundImageUrl(backgroundImage);
// Remember the last authored gradient so an exploratory Solid↔Gradient
@@ -488,7 +490,8 @@ export function StyleSections({
) : (
<ImageFillField
projectId={projectId}
sourceFile={element.sourceFile}
element={element}
onSetHtmlAttribute={onSetHtmlAttribute}
value={imageUrl}
assets={assets}
disabled={styleEditingDisabled}