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 & {