Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 68 additions & 20 deletions apps/builder/app/builder/shared/asset-manager/asset-info.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import isValidFilename from "valid-filename";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useDebouncedCallback } from "use-debounce";
import prettyBytes from "pretty-bytes";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import { getMimeTypeByExtension } from "@webstudio-is/sdk";
import { getMimeTypeByExtension, IMAGE_MIME_TYPES } from "@webstudio-is/sdk";
import type { Asset, Pages, Props, Styles, Instance } from "@webstudio-is/sdk";
import type {
ImageValue,
Expand Down Expand Up @@ -46,6 +46,7 @@ import {
GearIcon,
InfoCircleIcon,
PageIcon,
RefreshCcwIcon,
TrashIcon,
} from "@webstudio-is/icons";
import { hyphenateProperty } from "@webstudio-is/css-engine";
Expand All @@ -67,7 +68,8 @@ import {
selectPage,
} from "~/shared/awareness";
import { updateWebstudioData } from "~/shared/instance-utils";
import { deleteAssets } from "~/builder/shared/assets";
import { deleteAssets, replaceAsset } from "~/builder/shared/assets";
import { validateFiles } from "~/builder/shared/assets/asset-upload";
import {
$activeInspectorPanel,
setActiveSidebarPanel,
Expand Down Expand Up @@ -435,6 +437,20 @@ const AssetInfoContent = ({
);

const authPermit = useStore($authPermit);
const replaceInputRef = useRef<HTMLInputElement>(null);

const handleReplaceFile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const files = validateFiles(Array.from(event.target.files ?? []));
const file = files[0];
if (file) {
replaceAsset(id, file);
}
// Reset input so the same file can be selected again
event.target.value = "";
},
[id]
);

let downloadError: undefined | string;
if (authPermit === "view") {
Expand All @@ -444,6 +460,12 @@ const AssetInfoContent = ({
downloadError = "Upgrade to Pro to download assets.";
}

const isImage = asset.type === "image";
let replaceError: undefined | string;
if (authPermit === "view") {
replaceError = "View mode. You can't replace assets.";
}

return (
<>
<Box css={{ padding: theme.panel.padding }}>
Expand Down Expand Up @@ -592,23 +614,49 @@ const AssetInfoContent = ({
</Dialog>
)}

{downloadError ? (
<Tooltip side="bottom" content={downloadError}>
<IconButton disabled>
<DownloadIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip side="bottom" content="Download asset">
<IconButton
as="a"
download={formatAssetName(asset)}
href={getAssetUrl(asset, window.location.origin).href}
>
<DownloadIcon />
</IconButton>
</Tooltip>
)}
<Flex gap="1">
{isImage && (
<>
<input
ref={replaceInputRef}
type="file"
accept={IMAGE_MIME_TYPES.join(", ")}
style={{ display: "none" }}
onChange={handleReplaceFile}
/>
{replaceError ? (
<Tooltip side="bottom" content={replaceError}>
<IconButton disabled>
<RefreshCcwIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip side="bottom" content="Replace asset">
<IconButton onClick={() => replaceInputRef.current?.click()}>
<RefreshCcwIcon />
</IconButton>
</Tooltip>
)}
</>
)}
{downloadError ? (
<Tooltip side="bottom" content={downloadError}>
<IconButton disabled>
<DownloadIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip side="bottom" content="Download asset">
<IconButton
as="a"
download={formatAssetName(asset)}
href={getAssetUrl(asset, window.location.origin).href}
>
<DownloadIcon />
</IconButton>
</Tooltip>
)}
</Flex>
</Flex>
</>
);
Expand Down
1 change: 1 addition & 0 deletions apps/builder/app/builder/shared/assets/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { useAssets } from "./use-assets";
export * from "./delete-assets";
export { replaceAsset } from "./replace-asset";
export { uploadAssets } from "./upload-assets";
export * from "./types";
export * from "./separator";
Expand Down
85 changes: 85 additions & 0 deletions apps/builder/app/builder/shared/assets/replace-asset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, test, expect } from "vitest";
import type { StyleValue } from "@webstudio-is/css-engine";
import { __testing__ } from "./replace-asset";

const { replaceAssetInStyleValue } = __testing__;

describe("replaceAssetInStyleValue", () => {
test("replaces asset id in a direct image value", () => {
const value: StyleValue = {
type: "image",
value: { type: "asset", value: "old-id" },
};
replaceAssetInStyleValue(value, "old-id", "new-id");
expect(value).toEqual({
type: "image",
value: { type: "asset", value: "new-id" },
});
});

test("does not mutate image value with a different asset id", () => {
const value: StyleValue = {
type: "image",
value: { type: "asset", value: "other-id" },
};
replaceAssetInStyleValue(value, "old-id", "new-id");
expect(value).toEqual({
type: "image",
value: { type: "asset", value: "other-id" },
});
});

test("does not mutate image value with url type", () => {
const value: StyleValue = {
type: "image",
value: { type: "url", url: "https://example.com/img.png" },
};
replaceAssetInStyleValue(value, "old-id", "new-id");
expect(value).toEqual({
type: "image",
value: { type: "url", url: "https://example.com/img.png" },
});
});

test("replaces asset id inside a tuple value", () => {
const value: StyleValue = {
type: "tuple",
value: [
{ type: "image", value: { type: "asset", value: "old-id" } },
{ type: "image", value: { type: "asset", value: "other-id" } },
],
};
replaceAssetInStyleValue(value, "old-id", "new-id");
expect(value).toEqual({
type: "tuple",
value: [
{ type: "image", value: { type: "asset", value: "new-id" } },
{ type: "image", value: { type: "asset", value: "other-id" } },
],
});
});

test("replaces asset id inside a layers value", () => {
const value: StyleValue = {
type: "layers",
value: [
{ type: "image", value: { type: "asset", value: "old-id" } },
{ type: "image", value: { type: "asset", value: "old-id" } },
],
};
replaceAssetInStyleValue(value, "old-id", "new-id");
expect(value).toEqual({
type: "layers",
value: [
{ type: "image", value: { type: "asset", value: "new-id" } },
{ type: "image", value: { type: "asset", value: "new-id" } },
],
});
});

test("does not mutate non-image value types", () => {
const value: StyleValue = { type: "keyword", value: "auto" };
replaceAssetInStyleValue(value, "old-id", "new-id");
expect(value).toEqual({ type: "keyword", value: "auto" });
});
});
125 changes: 125 additions & 0 deletions apps/builder/app/builder/shared/assets/replace-asset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { Asset } from "@webstudio-is/sdk";
import { toast } from "@webstudio-is/design-system";
import { $assets, $pages, $props, $styles } from "~/shared/nano-states";
import { serverSyncStore } from "~/shared/sync/sync-stores";
import { onNextTransactionComplete } from "~/shared/sync/project-queue";
import { invalidateAssets } from "~/shared/resources";
import { uploadAssets } from "./upload-assets";
import type { StyleValue } from "@webstudio-is/css-engine";

/**
* Replace an image asset with a new file.
*
* 1. Uploads the new file via the existing upload pipeline
* 2. Waits for the upload to complete (new asset appears in $assets)
* 3. Re-points all references (props, styles, page meta) from old → new asset
* 4. Copies filename & description from old asset to new asset
* 5. Deletes the old asset
*/
export const replaceAsset = async (
oldAssetId: Asset["id"],
file: File
): Promise<void> => {
const oldAsset = $assets.get().get(oldAssetId);
if (!oldAsset) {
toast.error("Original asset not found");
return;
}

const fileToAssetId = await uploadAssets("image", [file]);
const newAssetId = fileToAssetId.get(file);

if (!newAssetId) {
toast.error("Failed to upload replacement asset");
return;
}

await waitForAsset(newAssetId);

serverSyncStore.createTransaction(
Comment thread
kof marked this conversation as resolved.
[$pages, $props, $styles, $assets],
(pages, props, styles, assets) => {
const updatedNewAsset = assets.get(newAssetId);
if (updatedNewAsset) {
updatedNewAsset.description = oldAsset.description;
}

for (const prop of props.values()) {
if (prop.type === "asset" && prop.value === oldAssetId) {
prop.value = newAssetId;
}
}

for (const styleDecl of styles.values()) {
replaceAssetInStyleValue(styleDecl.value, oldAssetId, newAssetId);
}

if (pages) {
if (pages.meta?.faviconAssetId === oldAssetId) {
pages.meta.faviconAssetId = newAssetId;
}
for (const page of [pages.homePage, ...pages.pages]) {
if (page.meta.socialImageAssetId === oldAssetId) {
page.meta.socialImageAssetId = newAssetId;
}
if (page.marketplace?.thumbnailAssetId === oldAssetId) {
page.marketplace.thumbnailAssetId = newAssetId;
}
}
}

assets.delete(oldAssetId);
}
);

onNextTransactionComplete(() => {
invalidateAssets();
});

toast.success("Asset replaced successfully");
};

const replaceAssetInStyleValue = (
styleValue: StyleValue,
oldAssetId: string,
newAssetId: string
): void => {
if (
styleValue.type === "image" &&
styleValue.value.type === "asset" &&
styleValue.value.value === oldAssetId
) {
styleValue.value.value = newAssetId;
}
if (styleValue.type === "tuple") {
for (const item of styleValue.value) {
replaceAssetInStyleValue(item, oldAssetId, newAssetId);
}
}
if (styleValue.type === "layers") {
for (const item of styleValue.value) {
replaceAssetInStyleValue(item, oldAssetId, newAssetId);
}
}
};

Comment thread
kof marked this conversation as resolved.
const waitForAsset = (assetId: string): Promise<Asset> => {
// Check if asset already exists (avoids TDZ with synchronous subscribe callback)
const existingAsset = $assets.get().get(assetId);
if (existingAsset !== undefined) {
return Promise.resolve(existingAsset);
}

// Use .listen() instead of .subscribe(), so the `unsubscribe` variable is always assigned before the callback runs.
return new Promise((resolve) => {
const unsubscribe = $assets.listen((assets) => {
const asset = assets.get(assetId);
if (asset !== undefined) {
unsubscribe();
resolve(asset);
}
});
});
};

export const __testing__ = { replaceAssetInStyleValue };
7 changes: 7 additions & 0 deletions apps/builder/docs/test-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@
- Upload an image
- Check it loads and shows a progress bar
- Delete an asset by clicking on the `x` icon and then the delete button in the tooltip
- Click on an image asset to open asset details
- Click the replace (reverse) icon button next to the download button
- Select a new image file from the file picker
- Check that the asset is replaced and the new image is shown
- Check that all usages of the old asset (image `src` props, background image styles, favicon, social image) now reference the new file
- Check that the original asset's filename is preserved on the new asset
- Verify the replace button is absent for non-image assets (e.g. fonts)

1. Navigator view settings

Expand Down
16 changes: 16 additions & 0 deletions packages/icons/icons/refresh-ccw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading