diff --git a/apps/designer/app/designer/features/style-panel/controls/font-family/font-family-control.tsx b/apps/designer/app/designer/features/style-panel/controls/font-family/font-family-control.tsx
index a4026cb7c2..80ea48ade2 100644
--- a/apps/designer/app/designer/features/style-panel/controls/font-family/font-family-control.tsx
+++ b/apps/designer/app/designer/features/style-panel/controls/font-family/font-family-control.tsx
@@ -29,7 +29,6 @@ export const FontFamilyControl = ({
content={}
onOpenChange={setIsOpen}
>
- {/* @todo this should be part of the design system, probably a varian="button" */}
{
+ onChange={(asset) => {
setValue(asset.id);
}}
/>
}
>
- {/* @todo this should be part of the design system, probably a varian="button" */}
);
diff --git a/apps/designer/app/designer/shared/assets/assets-shell.tsx b/apps/designer/app/designer/shared/assets/assets-shell.tsx
new file mode 100644
index 0000000000..57e38288c9
--- /dev/null
+++ b/apps/designer/app/designer/shared/assets/assets-shell.tsx
@@ -0,0 +1,42 @@
+import { type ComponentProps } from "react";
+import type { AssetType } from "@webstudio-is/asset-uploader";
+import { Flex, SearchField } from "@webstudio-is/design-system";
+import { AssetUpload } from "./asset-upload";
+import { NotFound } from "./not-found";
+import { Separator } from "./separator";
+
+type AssetsShellProps = {
+ searchProps: ComponentProps;
+ children: JSX.Element;
+ type: AssetType;
+ isEmpty: boolean;
+};
+
+export const AssetsShell = ({
+ searchProps,
+ isEmpty,
+ children,
+ type,
+}: AssetsShellProps) => {
+ return (
+
+
+
+
+
+
+ {isEmpty && }
+
+ {children}
+
+
+ );
+};
diff --git a/apps/designer/app/designer/shared/assets/index.ts b/apps/designer/app/designer/shared/assets/index.ts
index 79e7479e04..f55e12259a 100644
--- a/apps/designer/app/designer/shared/assets/index.ts
+++ b/apps/designer/app/designer/shared/assets/index.ts
@@ -1,4 +1,6 @@
-export * from "./asset-upload";
export * from "./use-assets";
export * from "./types";
export * from "./use-set-assets";
+export * from "./separator";
+export * from "./use-search";
+export * from "./assets-shell";
diff --git a/apps/designer/app/designer/shared/assets/not-found.tsx b/apps/designer/app/designer/shared/assets/not-found.tsx
new file mode 100644
index 0000000000..30351ae0b9
--- /dev/null
+++ b/apps/designer/app/designer/shared/assets/not-found.tsx
@@ -0,0 +1,9 @@
+import { Flex } from "@webstudio-is/design-system";
+
+export const NotFound = () => {
+ return (
+
+ No matching assets
+
+ );
+};
diff --git a/apps/designer/app/designer/shared/assets/separator.tsx b/apps/designer/app/designer/shared/assets/separator.tsx
new file mode 100644
index 0000000000..34ba22c805
--- /dev/null
+++ b/apps/designer/app/designer/shared/assets/separator.tsx
@@ -0,0 +1,9 @@
+import {
+ Separator as SeparatorPrimitive,
+ styled,
+} from "@webstudio-is/design-system";
+
+export const Separator = styled(SeparatorPrimitive, {
+ marginTop: "$1",
+ marginBottom: "$2",
+});
diff --git a/apps/designer/app/designer/shared/assets/types.ts b/apps/designer/app/designer/shared/assets/types.ts
index b3851cc3d7..621a2fdaf6 100644
--- a/apps/designer/app/designer/shared/assets/types.ts
+++ b/apps/designer/app/designer/shared/assets/types.ts
@@ -1,9 +1,8 @@
import { Asset } from "@webstudio-is/asset-uploader";
-export type PreviewAsset = Pick<
- Asset,
- "path" | "name" | "id" | "status" | "format"
->;
+export type PreviewAsset = Pick & {
+ status: "uploading";
+};
export type ActionData = {
uploadedAssets?: Array;
diff --git a/apps/designer/app/designer/shared/assets/use-filter.ts b/apps/designer/app/designer/shared/assets/use-filter.ts
new file mode 100644
index 0000000000..5f544172d7
--- /dev/null
+++ b/apps/designer/app/designer/shared/assets/use-filter.ts
@@ -0,0 +1,28 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export const useFilter = - ({
+ items,
+ onReset,
+}: {
+ onReset: () => void;
+ items: Array
- ;
+}) => {
+ const [filteredItems, setFilteredItems] = useState(items);
+ const onResetRef = useRef(onReset);
+ onResetRef.current = onReset;
+
+ const resetFilteredItems = useCallback(() => {
+ setFilteredItems(items);
+ }, [items]);
+
+ useEffect(() => {
+ setFilteredItems(items);
+ onResetRef.current();
+ }, [items]);
+
+ return {
+ filteredItems,
+ resetFilteredItems,
+ setFilteredItems,
+ };
+};
diff --git a/apps/designer/app/designer/shared/fonts-manager/use-search.tsx b/apps/designer/app/designer/shared/assets/use-search.tsx
similarity index 100%
rename from apps/designer/app/designer/shared/fonts-manager/use-search.tsx
rename to apps/designer/app/designer/shared/assets/use-search.tsx
diff --git a/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx b/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx
index 83e1281cf2..a73444d4d4 100644
--- a/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx
+++ b/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx
@@ -1,15 +1,16 @@
import {
- Flex,
- Separator as SeparatorPrimitive,
- SearchField,
List,
ListItem,
useList,
findNextListIndex,
- styled,
} from "@webstudio-is/design-system";
-import { AssetUpload, useAssets } from "~/designer/shared/assets";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ AssetsShell,
+ Separator,
+ useAssets,
+ useSearch,
+} from "~/designer/shared/assets";
+import { useMemo, useState } from "react";
import { useMenu } from "./item-menu";
import { CheckIcon } from "@webstudio-is/icons";
import {
@@ -19,38 +20,7 @@ import {
groupItemsByType,
toItems,
} from "./item-utils";
-import { useSearch } from "./use-search";
-
-const NotFound = () => {
- return (
-
- Font not found
-
- );
-};
-
-const useFilteredItems = ({ onReset }: { onReset: () => void }) => {
- const { assets } = useAssets("font");
- const fontItems = useMemo(() => toItems(assets), [assets]);
- const [filteredItems, setFilteredItems] = useState(fontItems);
- const onResetRef = useRef(onReset);
- onResetRef.current = onReset;
-
- const resetFilteredItems = useCallback(() => {
- setFilteredItems(fontItems);
- }, [fontItems]);
-
- useEffect(() => {
- setFilteredItems(fontItems);
- onResetRef.current();
- }, [fontItems]);
-
- return {
- filteredItems,
- resetFilteredItems,
- setFilteredItems,
- };
-};
+import { useFilter } from "../assets/use-filter";
const useLogic = ({
onChange,
@@ -61,12 +31,13 @@ const useLogic = ({
}) => {
const { assets, handleDelete: handleDeleteAssets } = useAssets("font");
const [selectedIndex, setSelectedIndex] = useState(-1);
- const { filteredItems, resetFilteredItems, setFilteredItems } =
- useFilteredItems({
- onReset() {
- searchProps.onCancel();
- },
- });
+ const fontItems = useMemo(() => toItems(assets), [assets]);
+ const { filteredItems, resetFilteredItems, setFilteredItems } = useFilter({
+ items: fontItems,
+ onReset() {
+ searchProps.onCancel();
+ },
+ });
const { uploadedItems, systemItems, groupedItems } = useMemo(
() => groupItemsByType(filteredItems),
[filteredItems]
@@ -136,11 +107,6 @@ const useLogic = ({
};
};
-const Separator = styled(SeparatorPrimitive, {
- marginTop: "$1",
- marginBottom: "$2",
-});
-
type FontsManagerProps = {
value: string;
onChange: (value: string) => void;
@@ -180,46 +146,33 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
};
return (
-
-
-
-
-
-
- {groupedItems.length === 0 && }
- {
+ if (isMenuOpen === false) {
+ listProps.onBlur(event);
+ }
}}
>
-
{
- if (isMenuOpen === false) {
- listProps.onBlur(event);
- }
- }}
- >
- {uploadedItems.length !== 0 && (
- {"Uploaded"}
- )}
- {uploadedItems.map(renderItem)}
- {systemItems.length !== 0 && (
- <>
- {uploadedItems.length !== 0 && }
- {"System"}
- >
- )}
- {systemItems.map((item, index) =>
- renderItem(item, index + uploadedItems.length)
- )}
-
-
-
+ {uploadedItems.length !== 0 && (
+ {"Uploaded"}
+ )}
+ {uploadedItems.map(renderItem)}
+ {systemItems.length !== 0 && (
+ <>
+ {uploadedItems.length !== 0 && }
+ {"System"}
+ >
+ )}
+ {systemItems.map((item, index) =>
+ renderItem(item, index + uploadedItems.length)
+ )}
+
+
);
};
diff --git a/apps/designer/app/designer/shared/image-manager/filename.tsx b/apps/designer/app/designer/shared/image-manager/filename.tsx
new file mode 100644
index 0000000000..2b15434eee
--- /dev/null
+++ b/apps/designer/app/designer/shared/image-manager/filename.tsx
@@ -0,0 +1,20 @@
+import { Flex, Text } from "@webstudio-is/design-system";
+import type { ComponentProps } from "react";
+
+type FilenameProps = Omit, "children"> & {
+ children: string;
+};
+
+export const Filename = ({ children, ...props }: FilenameProps) => {
+ const splitName = children.split(".");
+ const extension = splitName[splitName.length - 1];
+ const baseName = children.substr(0, children.length - extension.length - 1);
+ return (
+
+
+ {baseName}
+
+ {extension}
+
+ );
+};
diff --git a/apps/designer/app/designer/shared/image-manager/image-info.tsx b/apps/designer/app/designer/shared/image-manager/image-info.tsx
index cfb9514b71..d117d535a4 100644
--- a/apps/designer/app/designer/shared/image-manager/image-info.tsx
+++ b/apps/designer/app/designer/shared/image-manager/image-info.tsx
@@ -8,41 +8,7 @@ import {
} from "@webstudio-is/icons";
import prettyBytes from "pretty-bytes";
import { Asset } from "@webstudio-is/asset-uploader";
-
-const Filename = ({ name }: { name: string }) => {
- const splitName = name.split(".");
- const extension = splitName[splitName.length - 1];
- const lastLetterBeforeExtension = splitName[splitName.length - 2]
- .split("")
- .pop();
- return (
-
-
- {name}
-
-
- );
-};
+import { Filename } from "./filename";
type ImageInfoProps = {
asset: Asset;
@@ -56,7 +22,7 @@ export const ImageInfo = ({ asset, onDelete }: ImageInfoProps) => {
-
+ {name}
diff --git a/apps/designer/app/designer/shared/image-manager/image-manager.tsx b/apps/designer/app/designer/shared/image-manager/image-manager.tsx
index 860b56ccdd..637d43eb55 100644
--- a/apps/designer/app/designer/shared/image-manager/image-manager.tsx
+++ b/apps/designer/app/designer/shared/image-manager/image-manager.tsx
@@ -1,29 +1,101 @@
-import { Box, Flex, Grid } from "@webstudio-is/design-system";
+import { useState } from "react";
+import { findNextListIndex, Grid } from "@webstudio-is/design-system";
import type { Asset } from "@webstudio-is/asset-uploader";
+import { AssetsShell, PreviewAsset, useAssets, useSearch } from "../assets";
+import { useFilter } from "../assets/use-filter";
import { ImageThumbnail } from "./image-thumbnail";
-import { AssetUpload, useAssets } from "../assets";
+import { matchSorter } from "match-sorter";
-type ImageManagerProps = {
- onSelect?: (asset: Asset) => void;
+const filterItems = (search: string, items: Array) => {
+ return matchSorter(items, search, {
+ keys: [(item) => item.name],
+ });
};
-export const ImageManager = ({ onSelect }: ImageManagerProps) => {
+const useLogic = ({ onChange }: { onChange?: (asset: Asset) => void }) => {
const { assets, handleDelete } = useAssets("image");
+ const [selectedIndex, setSelectedIndex] = useState(-1);
+ const { filteredItems, resetFilteredItems, setFilteredItems } = useFilter({
+ items: assets,
+ onReset() {
+ searchProps.onCancel();
+ },
+ });
+
+ const searchProps = useSearch({
+ onCancel: resetFilteredItems,
+ onSearch(search) {
+ if (search === "") {
+ return resetFilteredItems();
+ }
+ const items = filterItems(search, assets);
+ setFilteredItems(items);
+ },
+ onSelect(direction) {
+ if (direction === "current") {
+ setSelectedIndex(selectedIndex);
+ const asset = filteredItems[selectedIndex];
+ if (asset?.status === "uploaded") {
+ onChange?.(asset);
+ }
+ return;
+ }
+ const nextIndex = findNextListIndex(
+ selectedIndex,
+ filteredItems.length,
+ direction
+ );
+ setSelectedIndex(nextIndex);
+ },
+ });
+
+ const handleSelect = (asset?: Asset | PreviewAsset) => {
+ const selectedIndex = filteredItems.findIndex(
+ (item) => item.id === asset?.id
+ );
+ setSelectedIndex(selectedIndex);
+ };
+
+ return {
+ searchProps,
+ handleDelete,
+ filteredItems,
+ handleSelect,
+ selectedIndex,
+ };
+};
+
+type ImageManagerProps = {
+ onChange?: (asset: Asset) => void;
+};
+
+export const ImageManager = ({ onChange }: ImageManagerProps) => {
+ const {
+ handleDelete,
+ handleSelect,
+ filteredItems,
+ searchProps,
+ selectedIndex,
+ } = useLogic({ onChange });
+
return (
-
-
-
-
-
- {assets.map((asset) => (
+
+
+ {filteredItems.map((asset, index) => (
))}
-
+
);
};
diff --git a/apps/designer/app/designer/shared/image-manager/image-thumbnail.tsx b/apps/designer/app/designer/shared/image-manager/image-thumbnail.tsx
index 1cc5751627..ba578f377e 100644
--- a/apps/designer/app/designer/shared/image-manager/image-thumbnail.tsx
+++ b/apps/designer/app/designer/shared/image-manager/image-thumbnail.tsx
@@ -1,11 +1,17 @@
-import { useEffect, useState } from "react";
-import { Box } from "@webstudio-is/design-system";
+import {
+ useEffect,
+ useState,
+ type KeyboardEvent,
+ type FocusEvent,
+} from "react";
+import { Box, styled } from "@webstudio-is/design-system";
import placeholderImage from "~/shared/images/image-placeholder.svg";
import brokenImage from "~/shared/images/broken-image-placeholder.svg";
import { UploadingAnimation } from "./uploading-animation";
import { ImageInfoTrigger, imageInfoTriggerCssVars } from "./image-info-tigger";
import type { PreviewAsset } from "~/designer/shared/assets";
-import { Asset } from "@webstudio-is/asset-uploader";
+import type { Asset } from "@webstudio-is/asset-uploader";
+import { Filename } from "./filename";
const useImageWithFallback = ({
path = placeholderImage,
@@ -24,72 +30,102 @@ const useImageWithFallback = ({
return src;
};
-type ThumbnailProps = {
- path?: string;
- status: Asset["status"];
- onClick: () => void;
-};
+const ThumbnailContainer = styled(Box, {
+ position: "relative",
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ flexDirection: "column",
+ border: "2px solid transparent",
+ borderRadius: "$1",
+ outline: 0,
+ gap: "$1",
+ overflow: "hidden",
+ backgroundColor: "$slate4",
+ "&:hover": imageInfoTriggerCssVars({ show: true }),
+ variants: {
+ status: {
+ uploading: {
+ filter: "blur(1px)",
+ opacity: 0.7,
+ },
+ uploaded: {},
+ },
+ state: {
+ selected: {
+ boxShadow:
+ "0px 0px 0px 2px $colors$blue10, 0px 0px 0px 2px $colors$blue10",
+ },
+ },
+ },
+});
-const Thumbnail = ({ path, status, onClick }: ThumbnailProps) => {
- const src = useImageWithFallback({ path });
+const Thumbnail = styled(Box, {
+ width: "$8",
+ height: "$8",
+ backgroundSize: "contain",
+ backgroundRepeat: "no-repeat",
+ backgroundPosition: "center",
+ flexShrink: 0,
+});
- return (
-
- );
+type ImageThumbnailProps = {
+ asset: Asset | PreviewAsset;
+ onDelete: (ids: Array) => void;
+ onSelect: (asset?: Asset | PreviewAsset) => void;
+ onChange?: (asset: Asset) => void;
+ state?: "selected";
};
export const ImageThumbnail = ({
asset,
onDelete,
onSelect,
-}: {
- asset: Asset | PreviewAsset;
- onDelete: (ids: Array) => void;
- onSelect?: (asset: Asset) => void;
-}) => {
+ onChange,
+ state,
+}: ImageThumbnailProps) => {
const { path, status, name } = asset;
const description =
"description" in asset && asset.description ? asset.description : name;
- const [isDeleting, setIsDeleting] = useState(false);
const isUploading = status === "uploading";
const isUploadedAsset = isUploading === false && "size" in asset;
+ const [isDeleting, setIsDeleting] = useState(false);
+ const src = useImageWithFallback({ path });
return (
- onSelect?.(asset)}
+ onBlur={(event: FocusEvent) => {
+ const isFocusWithin = event.currentTarget.contains(event.relatedTarget);
+ if (isFocusWithin === false) {
+ onSelect();
+ }
+ }}
+ onKeyDown={(event: KeyboardEvent) => {
+ if (event.code === "Enter" && asset.status === "uploaded") {
+ onChange?.(asset);
+ }
}}
>
{
- if (isUploadedAsset) onSelect?.(asset);
+ if (isUploadedAsset) onChange?.(asset);
}}
/>
+
+ {name}
+
{isUploadedAsset && (
)}
{(isUploading || isDeleting) && }
-
+
);
};
diff --git a/packages/asset-uploader/src/types.ts b/packages/asset-uploader/src/types.ts
index 88626e64c5..f511a227dc 100644
--- a/packages/asset-uploader/src/types.ts
+++ b/packages/asset-uploader/src/types.ts
@@ -5,7 +5,7 @@ import type { ImageMeta } from "./utils/format-asset";
type BaseAsset = Omit & {
path: string;
- status?: "uploading" | "uploaded";
+ status?: "uploaded";
};
export type FontAsset = Omit & {