From 31819eaa3b422600db9bbd10c636923153589e4b Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sat, 9 Aug 2025 00:37:36 +0200 Subject: [PATCH 1/3] feat: show when asset is used Ref https://github.com/webstudio-is/webstudio/issues/572 https://github.com/webstudio-is/webstudio/issues/3261 - made settings gear visible when asset is used anywhere - when asset is used delete button shows dialog with all usages --- .../app/builder/shared/assets/use-assets.tsx | 110 ++++++- .../image-manager/image-info-tigger.tsx | 65 ---- .../shared/image-manager/image-info.tsx | 284 +++++++++++++++++- .../shared/image-manager/image-manager.tsx | 20 +- .../shared/image-manager/image-thumbnail.tsx | 15 +- apps/builder/app/shared/shim.ts | 14 + 6 files changed, 391 insertions(+), 117 deletions(-) delete mode 100644 apps/builder/app/builder/shared/image-manager/image-info-tigger.tsx diff --git a/apps/builder/app/builder/shared/assets/use-assets.tsx b/apps/builder/app/builder/shared/assets/use-assets.tsx index 16fab0b4b3aa..21aeb092085b 100644 --- a/apps/builder/app/builder/shared/assets/use-assets.tsx +++ b/apps/builder/app/builder/shared/assets/use-assets.tsx @@ -1,26 +1,33 @@ import { useMemo } from "react"; -import { computed } from "nanostores"; +import { computed, type ReadableAtom } from "nanostores"; import { useStore } from "@nanostores/react"; import warnOnce from "warn-once"; -import type { Asset } from "@webstudio-is/sdk"; +import invariant from "tiny-invariant"; +import type { Asset, Page } from "@webstudio-is/sdk"; import type { AssetType } from "@webstudio-is/asset-uploader"; import { Box, toast, css, theme } from "@webstudio-is/design-system"; import { sanitizeS3Key } from "@webstudio-is/asset-uploader"; +import { Image, wsImageLoader } from "@webstudio-is/image"; +import type { ImageValue, StyleValue } from "@webstudio-is/css-engine"; import { restAssetsUploadPath, restAssetsPath } from "~/shared/router-utils"; -import type { - AssetContainer, - UploadedAssetContainer, - UploadingAssetContainer, -} from "./types"; +import { fetch } from "~/shared/fetch.client"; import type { ActionData } from "~/builder/shared/assets"; import { $assets, $authToken, + $pages, $project, + $props, + $styles, $uploadingFilesDataStore, type UploadingFileData, } from "~/shared/nano-states"; import { serverSyncStore } from "~/shared/sync"; +import type { + AssetContainer, + UploadedAssetContainer, + UploadingAssetContainer, +} from "./types"; import { getFileName, getMimeType, @@ -28,9 +35,92 @@ import { getSha256HashOfFile, uploadingFileDataToAsset, } from "./asset-utils"; -import { Image, wsImageLoader } from "@webstudio-is/image"; -import invariant from "tiny-invariant"; -import { fetch } from "~/shared/fetch.client"; +import { mapGetOrInsert } from "~/shared/shim"; + +export type AssetUsage = + | { type: "favicon" } + | { type: "socialImage"; pageId: Page["id"] } + | { type: "marketplaceThumbnail"; pageId: Page["id"] } + | { type: "prop"; propId: string } + | { type: "style"; styleDeclKey: string }; + +const traverseStyleValue = ( + styleValue: StyleValue, + callback: (value: ImageValue) => void +) => { + if (styleValue.type === "image") { + callback(styleValue); + } + if (styleValue.type === "tuple") { + for (const item of styleValue.value) { + traverseStyleValue(item, callback); + } + } + if (styleValue.type === "layers") { + for (const item of styleValue.value) { + traverseStyleValue(item, callback); + } + } +}; + +export const $usagesByAssetId = computed( + [$pages, $props, $styles], + (pages, props, styles) => { + const usagesByAsset = new Map(); + if (pages?.meta?.faviconAssetId) { + const usages = mapGetOrInsert( + usagesByAsset, + pages.meta.faviconAssetId, + [] + ); + usages.push({ type: "favicon" }); + } + if (pages) { + for (const page of [pages.homePage, ...pages.pages]) { + if (page.meta.socialImageAssetId) { + const usages = mapGetOrInsert( + usagesByAsset, + page.meta.socialImageAssetId, + [] + ); + usages.push({ type: "socialImage", pageId: page.id }); + } + if (page.marketplace?.thumbnailAssetId) { + const usages = mapGetOrInsert( + usagesByAsset, + page.marketplace.thumbnailAssetId, + [] + ); + usages.push({ type: "marketplaceThumbnail", pageId: page.id }); + } + } + } + for (const prop of props.values()) { + if ( + prop.type === "asset" && + // ignore width and height properties which are specific to size + prop.name !== "width" && + prop.name !== "height" + ) { + const usages = mapGetOrInsert(usagesByAsset, prop.value, []); + usages.push({ type: "prop", propId: prop.id }); + } + } + for (const [styleDeclKey, styleDecl] of styles) { + traverseStyleValue(styleDecl.value, (imageValue) => { + if (imageValue.value.type === "asset") { + const usages = mapGetOrInsert( + usagesByAsset, + imageValue.value.value, + [] + ); + usages.push({ type: "style", styleDeclKey }); + } + }); + } + return usagesByAsset; + } +); export const deleteAssets = (assetIds: Asset["id"][]) => { serverSyncStore.createTransaction([$assets], (assets) => { diff --git a/apps/builder/app/builder/shared/image-manager/image-info-tigger.tsx b/apps/builder/app/builder/shared/image-manager/image-info-tigger.tsx deleted file mode 100644 index 10ec8df20c86..000000000000 --- a/apps/builder/app/builder/shared/image-manager/image-info-tigger.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from "react"; -import { - SmallIconButton, - Popover, - PopoverTrigger, - PopoverContent, - PopoverTitle, -} from "@webstudio-is/design-system"; -import { GearIcon } from "@webstudio-is/icons"; -import type { Asset } from "@webstudio-is/sdk"; -import { theme } from "@webstudio-is/design-system"; -import { ImageInfo } from "./image-info"; - -const triggerVisibilityVar = `--ws-image-info-trigger-visibility`; - -export const imageInfoTriggerCssVars = ({ show }: { show: boolean }) => ({ - [triggerVisibilityVar]: show ? "visible" : "hidden", -}); - -export const ImageInfoTrigger = ({ - asset, - onDelete, -}: { - asset: Asset; - onDelete: (ids: Array) => void; -}) => { - const [isInfoOpen, setInfoOpen] = useState(false); - return ( - - - setInfoOpen(true)} - tabIndex={-1} - css={{ - visibility: `var(${triggerVisibilityVar}, hidden)`, - position: "absolute", - color: theme.colors.backgroundIconSubtle, - top: theme.spacing[3], - right: theme.spacing[3], - cursor: "pointer", - transition: "opacity 100ms ease", - "& svg": { - fill: `oklch(from ${theme.colors.white} l c h / 0.9)`, - }, - "&:hover": { - color: theme.colors.foregroundIconMain, - }, - }} - icon={} - /> - - - Asset Details - { - setInfoOpen(false); - onDelete(ids); - }} - asset={asset} - /> - - - ); -}; diff --git a/apps/builder/app/builder/shared/image-manager/image-info.tsx b/apps/builder/app/builder/shared/image-manager/image-info.tsx index 7fd9852d7328..3a846a3f442a 100644 --- a/apps/builder/app/builder/shared/image-manager/image-info.tsx +++ b/apps/builder/app/builder/shared/image-manager/image-info.tsx @@ -1,11 +1,23 @@ +import prettyBytes from "pretty-bytes"; import { useStore } from "@nanostores/react"; import { getMimeByExtension } from "@webstudio-is/asset-uploader"; import { Box, Button, + css, + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, Flex, Grid, + Popover, + PopoverContent, + PopoverTitle, + PopoverTrigger, + SmallIconButton, Text, + textVariants, theme, Tooltip, } from "@webstudio-is/design-system"; @@ -13,20 +25,175 @@ import { AspectRatioIcon, CloudIcon, DimensionsIcon, + GearIcon, PageIcon, TrashIcon, } from "@webstudio-is/icons"; -import type { Asset } from "@webstudio-is/sdk"; -import prettyBytes from "pretty-bytes"; -import { $authPermit } from "~/shared/nano-states"; +import type { Asset, Instance } from "@webstudio-is/sdk"; +import { + $authPermit, + $editingPageId, + $instances, + $pages, + $props, + $styles, + $styleSourceSelections, +} from "~/shared/nano-states"; +import { deleteAssets, $usagesByAssetId, type AssetUsage } from "../assets"; import { getFormattedAspectRatio } from "./utils"; +import { hyphenateProperty } from "@webstudio-is/css-engine"; +import { $openProjectSettings } from "~/shared/nano-states/project-settings"; +import { + $awareness, + findAwarenessByInstanceId, + selectPage, +} from "~/shared/awareness"; +import { $activeInspectorPanel, setActiveSidebarPanel } from "../nano-states"; -type ImageInfoProps = { - asset: Asset; - onDelete: (ids: Array) => void; +const buttonLinkClass = css({ + all: "unset", + cursor: "pointer", + ...textVariants.link, +}).toString(); + +const AssetUsagesList = ({ usages }: { usages: AssetUsage[] }) => { + const props = useStore($props); + const styles = useStore($styles); + return ( + + {usages.map((usage, index) => { + if (usage.type === "favicon") { + return ( +
  • + +
  • + ); + } + if (usage.type === "socialImage") { + return ( +
  • + +
  • + ); + } + if (usage.type === "marketplaceThumbnail") { + return ( +
  • + +
  • + ); + } + if (usage.type === "prop") { + return ( +
  • + +
  • + ); + } + if (usage.type === "style") { + const styleDecl = styles.get(usage.styleDeclKey); + const property = styleDecl + ? hyphenateProperty(styleDecl.property) + : undefined; + return ( +
  • + +
  • + ); + } + usage satisfies never; + })} +
    + ); }; -export const ImageInfo = ({ asset, onDelete }: ImageInfoProps) => { +const ImageInfoContent = ({ + asset, + usages, +}: { + asset: Asset; + usages: AssetUsage[]; +}) => { const { size, meta, id, name } = asset; const parts = name.split("."); @@ -34,11 +201,6 @@ export const ImageInfo = ({ asset, onDelete }: ImageInfoProps) => { const authPermit = useStore($authPermit); - const isDeleteDisabled = authPermit === "view"; - const tooltipContent = isDeleteDisabled - ? "View mode. You can't delete assets." - : undefined; - return ( <> @@ -80,17 +242,107 @@ export const ImageInfo = ({ asset, onDelete }: ImageInfoProps) => { - + {authPermit === "view" ? ( + + + + ) : usages.length === 0 ? ( - + ) : ( + + + + + + Delete asset? + + + This asset is used in following places: + + + + + + + + + )} ); }; + +const triggerVisibilityVar = `--ws-image-info-trigger-visibility`; + +export const imageInfoCssVars = ({ show }: { show: boolean }) => ({ + [triggerVisibilityVar]: show ? "visible" : "hidden", +}); + +export const ImageInfo = ({ asset }: { asset: Asset }) => { + const usagesByAssetId = useStore($usagesByAssetId); + const usages = usagesByAssetId.get(asset.id) ?? []; + return ( + + + 0 ? "reused" : "unused"} + css={{ + visibility: `var(${triggerVisibilityVar}, hidden)`, + position: "absolute", + color: theme.colors.backgroundIconSubtle, + top: theme.spacing[3], + right: theme.spacing[3], + cursor: "pointer", + transition: "opacity 100ms ease", + "& svg": { + fill: `oklch(from ${theme.colors.white} l c h / 0.9)`, + }, + "&:hover": { + color: theme.colors.foregroundIconMain, + }, + "&[data-usage=reused]": { + visibility: "visible", + "& svg": { + fill: `color-mix( + in lch, + ${theme.colors.backgroundStyleSourceToken} 30%, + white 70% + )`, + }, + "&:hover": { + color: theme.colors.foregroundIconMain, + }, + }, + }} + icon={} + /> + + + Asset Details + + + + ); +}; diff --git a/apps/builder/app/builder/shared/image-manager/image-manager.tsx b/apps/builder/app/builder/shared/image-manager/image-manager.tsx index 5549e94fd562..ddbcb94b3cf0 100644 --- a/apps/builder/app/builder/shared/image-manager/image-manager.tsx +++ b/apps/builder/app/builder/shared/image-manager/image-manager.tsx @@ -11,12 +11,7 @@ import { acceptToMimePatterns, doesAssetMatchMimePatterns, } from "@webstudio-is/asset-uploader"; -import { - AssetsShell, - type AssetContainer, - useAssets, - deleteAssets, -} from "../assets"; +import { AssetsShell, type AssetContainer, useAssets } from "../assets"; import { ImageThumbnail } from "./image-thumbnail"; const useLogic = ({ @@ -85,7 +80,6 @@ const useLogic = ({ return { searchProps, - handleDelete: deleteAssets, filteredItems, handleSelect, selectedIndex, @@ -99,13 +93,10 @@ type ImageManagerProps = { }; export const ImageManager = ({ accept, onChange }: ImageManagerProps) => { - const { - handleDelete, - handleSelect, - filteredItems, - searchProps, - selectedIndex, - } = useLogic({ onChange, accept }); + const { handleSelect, filteredItems, searchProps, selectedIndex } = useLogic({ + onChange, + accept, + }); // https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/accept // https://github.com/webstudio-is/webstudio/blob/83503e39b0e1561ea93cfcff92aa35b54c15fefa/packages/sdk-components-react/src/video.ws.ts#L34 @@ -138,7 +129,6 @@ export const ImageManager = ({ accept, onChange }: ImageManagerProps) => { { if (assetContainer.asset.type === "image") { diff --git a/apps/builder/app/builder/shared/image-manager/image-thumbnail.tsx b/apps/builder/app/builder/shared/image-manager/image-thumbnail.tsx index e2a4ff2dd6fd..a12b02972a82 100644 --- a/apps/builder/app/builder/shared/image-manager/image-thumbnail.tsx +++ b/apps/builder/app/builder/shared/image-manager/image-thumbnail.tsx @@ -1,7 +1,7 @@ import type { KeyboardEvent, FocusEvent } from "react"; import { Box, styled } from "@webstudio-is/design-system"; import { UploadingAnimation } from "./uploading-animation"; -import { ImageInfoTrigger, imageInfoTriggerCssVars } from "./image-info-tigger"; +import { ImageInfo, imageInfoCssVars } from "./image-info"; import type { AssetContainer } from "~/builder/shared/assets"; import { Filename } from "./filename"; import { Image } from "./image"; @@ -73,7 +73,7 @@ const ThumbnailContainer = styled(Box, { overflow: "hidden", padding: 2, "&:hover": { - ...imageInfoTriggerCssVars({ show: true }), + ...imageInfoCssVars({ show: true }), backgroundColor: theme.colors.backgroundAssetcardHover, }, variants: { @@ -87,7 +87,7 @@ const ThumbnailContainer = styled(Box, { outline: `1px solid ${theme.colors.borderFocus}`, outlineOffset: -1, backgroundColor: theme.colors.backgroundAssetcardHover, - ...imageInfoTriggerCssVars({ show: true }), + ...imageInfoCssVars({ show: true }), }, }, }, @@ -102,7 +102,6 @@ const Thumbnail = styled(Box, { type ImageThumbnailProps = { assetContainer: AssetContainer; - onDelete: (ids: Array) => void; onSelect: (assetContainer?: AssetContainer) => void; onChange?: (assetContainer: AssetContainer) => void; state?: "selected"; @@ -110,7 +109,6 @@ type ImageThumbnailProps = { export const ImageThumbnail = ({ assetContainer, - onDelete, onSelect, onChange, state, @@ -175,12 +173,7 @@ export const ImageThumbnail = ({ {name} {assetContainer.status === "uploaded" && ( - { - onDelete(ids); - }} - /> + )} {isUploading && } diff --git a/apps/builder/app/shared/shim.ts b/apps/builder/app/shared/shim.ts index 4ddf0d66a562..8490300fcfef 100644 --- a/apps/builder/app/shared/shim.ts +++ b/apps/builder/app/shared/shim.ts @@ -59,3 +59,17 @@ export const objectGroupBy = ( ) => { return Object.fromEntries(mapGroupBy(array, getKey)); }; + +// https://github.com/tc39/proposal-upsert +export const mapGetOrInsert = ( + map: Map, + key: Key, + defaultValue: Value +): Value => { + let value = map.get(key); + if (value === undefined) { + value = defaultValue; + map.set(key, value); + } + return value; +}; From 45e3ea00c3b1c38c90ef77a25d529e8452b4994c Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sat, 9 Aug 2025 14:52:03 +0200 Subject: [PATCH 2/3] Add amount of uses to asset info --- .../shared/image-manager/image-info.tsx | 93 +++++++++++-------- packages/icons/src/index.stories.tsx | 2 +- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/apps/builder/app/builder/shared/image-manager/image-info.tsx b/apps/builder/app/builder/shared/image-manager/image-info.tsx index 3a846a3f442a..575627ed9213 100644 --- a/apps/builder/app/builder/shared/image-manager/image-info.tsx +++ b/apps/builder/app/builder/shared/image-manager/image-info.tsx @@ -16,6 +16,7 @@ import { PopoverTitle, PopoverTrigger, SmallIconButton, + styled, Text, textVariants, theme, @@ -187,6 +188,16 @@ const AssetUsagesList = ({ usages }: { usages: AssetUsage[] }) => { ); }; +const UsageDot = styled(Box, { + width: 6, + height: 6, + backgroundColor: theme.colors.foregroundDestructive, + border: "1px solid white", + boxShadow: "0 0 3px rgb(0, 0, 0)", + borderRadius: "50%", + pointerEvents: "none", +}); + const ImageInfoContent = ({ asset, usages, @@ -223,7 +234,7 @@ const ImageInfoContent = ({ {getMimeByExtension(extension)} - {"width" in meta && "height" in meta ? ( + {"width" in meta && "height" in meta && ( <> @@ -238,7 +249,20 @@ const ImageInfoContent = ({ - ) : null} + )} + + + + + {usages.length} uses + @@ -302,47 +326,38 @@ export const ImageInfo = ({ asset }: { asset: Asset }) => { const usagesByAssetId = useStore($usagesByAssetId); const usages = usagesByAssetId.get(asset.id) ?? []; return ( - - - 0 ? "reused" : "unused"} - css={{ - visibility: `var(${triggerVisibilityVar}, hidden)`, - position: "absolute", - color: theme.colors.backgroundIconSubtle, - top: theme.spacing[3], - right: theme.spacing[3], - cursor: "pointer", - transition: "opacity 100ms ease", - "& svg": { - fill: `oklch(from ${theme.colors.white} l c h / 0.9)`, - }, - "&:hover": { - color: theme.colors.foregroundIconMain, - }, - "&[data-usage=reused]": { - visibility: "visible", + <> + + + } - /> - - - Asset Details - - - + }} + icon={} + /> + + + Asset Details + + + + {usages.length === 0 && ( + + )} + ); }; diff --git a/packages/icons/src/index.stories.tsx b/packages/icons/src/index.stories.tsx index 693c1dc9a86f..b21029e3043c 100644 --- a/packages/icons/src/index.stories.tsx +++ b/packages/icons/src/index.stories.tsx @@ -33,7 +33,7 @@ export const Icons = ({ testColor }: { testColor: boolean }): ReactNode => { justifyContent: "center", }} > - +
    Date: Sat, 9 Aug 2025 14:55:53 +0200 Subject: [PATCH 3/3] Remove unused import --- apps/builder/app/builder/shared/assets/use-assets.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/builder/app/builder/shared/assets/use-assets.tsx b/apps/builder/app/builder/shared/assets/use-assets.tsx index 21aeb092085b..786623d4a651 100644 --- a/apps/builder/app/builder/shared/assets/use-assets.tsx +++ b/apps/builder/app/builder/shared/assets/use-assets.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { computed, type ReadableAtom } from "nanostores"; +import { computed } from "nanostores"; import { useStore } from "@nanostores/react"; import warnOnce from "warn-once"; import invariant from "tiny-invariant";