mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user