From 083b138ebf997473f52d000979eb5f9eeac698ea Mon Sep 17 00:00:00 2001 From: Roman Pominov Date: Fri, 7 Apr 2023 16:25:15 +0400 Subject: [PATCH] Refactor "file" props control; Free up `id` attribute (#1393) Ref. #1011 ## Description 1. Fixes #1382 1. Changed `file-image` to `file` with an accept option 2. Added `file` control to the props-panel stories: https://file-control--6382151c8b47d4399fb9fc69.chromatic.com/index.html?path=/story/builder-features-props-panel--story ## Steps for reproduction 1. No visible to user changes (unless you set `accept` to something like `.png` in some prop meta) --- .../props-panel/controls/combined.tsx | 10 +- .../props-panel/controls/file-image.tsx | 44 ---- .../features/props-panel/controls/file.tsx | 55 ++++ .../props-panel/props-panel.stories.tsx | 49 +++- .../features/props-panel/use-props-logic.ts | 4 +- .../builder/shared/assets/asset-upload.tsx | 50 ++-- .../builder/shared/assets/assets-shell.tsx | 4 +- .../app/builder/shared/assets/use-filter.ts | 28 --- .../app/builder/shared/assets/use-search.tsx | 6 +- .../shared/fonts-manager/fonts-manager.tsx | 50 ++-- .../shared/image-manager/image-manager.tsx | 62 ++--- .../webstudio-component.tsx | 3 - packages/asset-uploader/package.json | 3 +- packages/asset-uploader/src/index.ts | 1 + .../asset-uploader/src/utils/mime.test.ts | 140 +++++++++++ packages/asset-uploader/src/utils/mime.ts | 235 ++++++++++++++++++ .../src/components/input-field.tsx | 2 +- packages/generate-arg-types/src/types.ts | 11 +- packages/image/src/index.ts | 2 +- .../react-sdk/src/components/image.ws.tsx | 2 +- pnpm-lock.yaml | 3 + 21 files changed, 588 insertions(+), 176 deletions(-) delete mode 100644 apps/builder/app/builder/features/props-panel/controls/file-image.tsx create mode 100644 apps/builder/app/builder/features/props-panel/controls/file.tsx delete mode 100644 apps/builder/app/builder/shared/assets/use-filter.ts create mode 100644 packages/asset-uploader/src/utils/mime.test.ts create mode 100644 packages/asset-uploader/src/utils/mime.ts diff --git a/apps/builder/app/builder/features/props-panel/controls/combined.tsx b/apps/builder/app/builder/features/props-panel/controls/combined.tsx index 3d163290e7..59ea49dbcb 100644 --- a/apps/builder/app/builder/features/props-panel/controls/combined.tsx +++ b/apps/builder/app/builder/features/props-panel/controls/combined.tsx @@ -5,7 +5,7 @@ import { CheckControl } from "./check"; import { RadioControl } from "./radio"; import { SelectControl } from "./select"; import { BooleanControl } from "./boolean"; -import { FileImageControl } from "./file-image"; +import { FileControl } from "./file"; import { UrlControl } from "./url"; import type { ControlProps } from "../shared"; @@ -66,10 +66,10 @@ export const renderControl = ({ } if ( - meta.control === "file-image" && + meta.control === "file" && (prop === undefined || prop.type === "asset") ) { - return ; + return ; } if ( @@ -129,11 +129,11 @@ export const renderControl = ({ if (prop.type === "asset") { return ( - ) => { - const assetStore = useMemo( - () => - computed(assetsStore, (assets) => - prop ? assets.get(prop.value) : undefined - ), - [prop] - ); - const asset = useStore(assetStore); - - return ( - - - onChange({ type: "asset", value: asset.id }, asset) - } - /> - } - > - - - - ); -}; diff --git a/apps/builder/app/builder/features/props-panel/controls/file.tsx b/apps/builder/app/builder/features/props-panel/controls/file.tsx new file mode 100644 index 0000000000..0207992238 --- /dev/null +++ b/apps/builder/app/builder/features/props-panel/controls/file.tsx @@ -0,0 +1,55 @@ +import { useMemo } from "react"; +import { computed } from "nanostores"; +import { useStore } from "@nanostores/react"; +import { Button, Text } from "@webstudio-is/design-system"; +import { assetsStore } from "~/shared/nano-states"; +import { FloatingPanel } from "~/builder/shared/floating-panel"; +import { ImageManager } from "~/builder/shared/image-manager"; +import { type ControlProps, getLabel, VerticalLayout } from "../shared"; +import { acceptToMimeCategories } from "@webstudio-is/asset-uploader"; + +export const FileControl = ({ + meta, + prop, + propName, + onChange, + onDelete, +}: ControlProps<"file", "asset">) => { + const assetStore = useMemo( + () => + computed(assetsStore, (assets) => + prop ? assets.get(prop.value) : undefined + ), + [prop] + ); + const asset = useStore(assetStore); + + const acceptCategories = acceptToMimeCategories(meta?.accept || ""); + + return ( + + {acceptCategories === "*" || + (acceptCategories.size === 1 && acceptCategories.has("image")) ? ( + + onChange({ type: "asset", value: asset.id }, asset) + } + accept={meta?.accept} + /> + } + > + + + ) : ( + + Unsupported accept value: {meta?.accept} + + )} + + ); +}; diff --git a/apps/builder/app/builder/features/props-panel/props-panel.stories.tsx b/apps/builder/app/builder/features/props-panel/props-panel.stories.tsx index c8a5a66acc..85e68406f7 100644 --- a/apps/builder/app/builder/features/props-panel/props-panel.stories.tsx +++ b/apps/builder/app/builder/features/props-panel/props-panel.stories.tsx @@ -2,17 +2,21 @@ import { useState } from "react"; import { ButtonElementIcon } from "@webstudio-is/icons"; import { PropsPanel } from "./props-panel"; import { usePropsLogic } from "./use-props-logic"; -import { pagesStore } from "~/shared/nano-states"; +import { assetsStore, pagesStore } from "~/shared/nano-states"; import type { Prop } from "@webstudio-is/project-build"; import type { WsComponentMeta, WsComponentPropsMeta, } from "@webstudio-is/react-sdk"; import { textVariants } from "@webstudio-is/design-system"; +import type { Asset } from "@webstudio-is/asset-uploader"; let id = 0; const unique = () => `${++id}`; +const instanceId = unique(); +const projectId = unique(); + const page = (name: string, path: string) => ({ id: unique(), name, @@ -31,6 +35,28 @@ pagesStore.set({ ], }); +const imageAsset = (name = "cat", format = "jpeg"): Asset => ({ + id: unique(), + projectId, + type: "image", + name: `${name}.${format}`, + path: `https://loremflickr.com/128/180/${name}`, + format: format, + location: "FS", + size: 100000, + createdAt: new Date().toISOString(), + description: null, + meta: { width: 128, height: 180 }, +}); + +assetsStore.set( + new Map( + [imageAsset("cat"), imageAsset("car", "png"), imageAsset("beach")].map( + (asset) => [asset.id, asset] + ) + ) +); + type PropMeta = WsComponentPropsMeta["props"][string]; const textProp = (label?: string): PropMeta => ({ @@ -76,6 +102,14 @@ const urlProp = (label?: string): PropMeta => ({ label, }); +const fileProp = (label?: string, accept?: string): PropMeta => ({ + type: "string", + control: "file", + required: false, + label, + accept, +}); + const defaultOptions = ["one", "two", "three-the-very-long-one-so-much-long"]; const radioProp = (options = defaultOptions, label?: string): PropMeta => ({ @@ -102,8 +136,6 @@ const checkProp = (options = defaultOptions, label?: string): PropMeta => ({ label, }); -const instanceId = unique(); - const componentMeta: WsComponentMeta = { category: "general", type: "rich-text", @@ -122,6 +154,7 @@ const componentPropsMeta: WsComponentPropsMeta = { initialSelect: selectProp(), initialCheck: checkProp(), initialUrl: urlProp(), + initialFile: fileProp("Initial File (PNG only)", ".png"), addedText: textProp(), addedShortText: shortTextProp(), addedNumber: numberProp(), @@ -134,6 +167,7 @@ const componentPropsMeta: WsComponentPropsMeta = { addedUrlPage: urlProp("Added URL (Page)"), addedUrlEmail: urlProp("Added URL (Email)"), addedUrlPhone: urlProp("Added URL (Phone)"), + addedFile: fileProp(), availableText: textProp(), availableShortText: shortTextProp(), availableNumber: numberProp(), @@ -143,6 +177,7 @@ const componentPropsMeta: WsComponentPropsMeta = { availableSelect: selectProp(), availableCheck: checkProp(), availableUrl: urlProp(), + availableFile: fileProp(), }, initialProps: [ "initialText", @@ -154,6 +189,7 @@ const componentPropsMeta: WsComponentPropsMeta = { "initialSelect", "initialCheck", "initialUrl", + "initialFile", ], }; @@ -242,6 +278,13 @@ const startingProps: Prop[] = [ type: "string", value: "tel:+1234567890", }, + { + id: unique(), + instanceId, + name: "addedFile", + type: "asset", + value: Array.from(assetsStore.get().keys() ?? [])[0] ?? "", + }, ]; export const Story = () => { diff --git a/apps/builder/app/builder/features/props-panel/use-props-logic.ts b/apps/builder/app/builder/features/props-panel/use-props-logic.ts index 6c4faff737..948b47d9d1 100644 --- a/apps/builder/app/builder/features/props-panel/use-props-logic.ts +++ b/apps/builder/app/builder/features/props-panel/use-props-logic.ts @@ -20,7 +20,7 @@ export type NameAndLabel = { name: string; label?: string }; // // We want to avoid this if possible, but for some types like "asset" we can't export const getStartingValue = (meta: PropMeta): PropValue | undefined => { - if (meta.type === "string" && meta.control !== "file-image") { + if (meta.type === "string" && meta.control !== "file") { return { type: "string", value: meta.defaultValue ?? "", @@ -67,7 +67,7 @@ const getDefaultMetaForType = (type: Prop["type"]): PropMeta => { case "boolean": return { type: "boolean", control: "boolean", required: false }; case "asset": - return { type: "string", control: "file-image", required: false }; + return { type: "string", control: "file", required: false }; case "string[]": throw new Error( "A prop with type string[] must have a meta, we can't provide a default one because we need a list of options" diff --git a/apps/builder/app/builder/shared/assets/asset-upload.tsx b/apps/builder/app/builder/shared/assets/asset-upload.tsx index 402a37c9ee..cab7812b69 100644 --- a/apps/builder/app/builder/shared/assets/asset-upload.tsx +++ b/apps/builder/app/builder/shared/assets/asset-upload.tsx @@ -51,40 +51,52 @@ const acceptMap = { type AssetUploadProps = { type: AssetType; + accept?: string; }; -export const AssetUpload = ({ type }: AssetUploadProps) => { +const EnabledAssetUpload = ({ accept, type }: AssetUploadProps) => { const { inputRef, onChange } = useUpload(type); - const [authPermit] = useAuthPermit(); - - const isUploadDisabled = authPermit === "view"; - const tooltipContent = isUploadDisabled - ? "View mode. You can't upload assets." - : undefined; return (
- - - +
); }; + +export const AssetUpload = ({ type }: AssetUploadProps) => { + const [authPermit] = useAuthPermit(); + + if (authPermit !== "view") { + // Split into a separate component to avoid using `useUpload` hook unnecessarily + // (It's hard to mock this hook in storybook) + return ; + } + + return ( + + + + + + ); +}; diff --git a/apps/builder/app/builder/shared/assets/assets-shell.tsx b/apps/builder/app/builder/shared/assets/assets-shell.tsx index 2176b0c694..7ada56addd 100644 --- a/apps/builder/app/builder/shared/assets/assets-shell.tsx +++ b/apps/builder/app/builder/shared/assets/assets-shell.tsx @@ -10,6 +10,7 @@ type AssetsShellProps = { searchProps: ComponentProps; children: JSX.Element; type: AssetType; + accept?: string; isEmpty: boolean; }; @@ -18,6 +19,7 @@ export const AssetsShell = ({ isEmpty, children, type, + accept, }: AssetsShellProps) => { return ( - + diff --git a/apps/builder/app/builder/shared/assets/use-filter.ts b/apps/builder/app/builder/shared/assets/use-filter.ts deleted file mode 100644 index 5f544172d7..0000000000 --- a/apps/builder/app/builder/shared/assets/use-filter.ts +++ /dev/null @@ -1,28 +0,0 @@ -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/builder/app/builder/shared/assets/use-search.tsx b/apps/builder/app/builder/shared/assets/use-search.tsx index a6e90fee90..7fee5d84bd 100644 --- a/apps/builder/app/builder/shared/assets/use-search.tsx +++ b/apps/builder/app/builder/shared/assets/use-search.tsx @@ -5,12 +5,10 @@ import { } from "react"; type UseSearch = { - onCancel: () => void; - onSearch: (search: string) => void; onSelect: (direction: "next" | "previous" | "current") => void; }; -export const useSearch = ({ onSearch, onSelect, onCancel }: UseSearch) => { +export const useSearch = ({ onSelect }: UseSearch) => { const [search, setSearch] = useState(""); const handleKeyDown: KeyboardEventHandler = ({ code }) => { const keyMap = { @@ -27,12 +25,10 @@ export const useSearch = ({ onSearch, onSelect, onCancel }: UseSearch) => { const handleChange: ChangeEventHandler = (event) => { const { value } = event.currentTarget; setSearch(value); - onSearch(value); }; const handleCancel = () => { setSearch(""); - onCancel(); }; return { diff --git a/apps/builder/app/builder/shared/fonts-manager/fonts-manager.tsx b/apps/builder/app/builder/shared/fonts-manager/fonts-manager.tsx index 2b069f0eee..542a06325c 100644 --- a/apps/builder/app/builder/shared/fonts-manager/fonts-manager.tsx +++ b/apps/builder/app/builder/shared/fonts-manager/fonts-manager.tsx @@ -21,7 +21,6 @@ import { groupItemsByType, toItems, } from "./item-utils"; -import { useFilter } from "../assets/use-filter"; const useLogic = ({ onChange, @@ -33,12 +32,30 @@ const useLogic = ({ const { assetContainers } = useAssets("font"); const [selectedIndex, setSelectedIndex] = useState(-1); const fontItems = useMemo(() => toItems(assetContainers), [assetContainers]); - const { filteredItems, resetFilteredItems, setFilteredItems } = useFilter({ - items: fontItems, - onReset() { - searchProps.onCancel(); + + const searchProps = useSearch({ + onSelect(direction) { + if (direction === "current") { + handleChangeCurrent(selectedIndex); + return; + } + const nextIndex = findNextListIndex( + selectedIndex, + groupedItems.length, + direction + ); + setSelectedIndex(nextIndex); }, }); + + const filteredItems = useMemo( + () => + searchProps.value === "" + ? fontItems + : filterItems(searchProps.value, fontItems), + [fontItems, searchProps.value] + ); + const { uploadedItems, systemItems, groupedItems } = useMemo( () => groupItemsByType(filteredItems), [filteredItems] @@ -74,29 +91,6 @@ const useLogic = ({ } }; - const searchProps = useSearch({ - onCancel: resetFilteredItems, - onSearch(search) { - if (search === "") { - return resetFilteredItems(); - } - const items = filterItems(search, groupedItems); - setFilteredItems(items); - }, - onSelect(direction) { - if (direction === "current") { - handleChangeCurrent(selectedIndex); - return; - } - const nextIndex = findNextListIndex( - selectedIndex, - groupedItems.length, - direction - ); - setSelectedIndex(nextIndex); - }, - }); - return { groupedItems, uploadedItems, 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 628ea724e9..57c8ed5a6a 100644 --- a/apps/builder/app/builder/shared/image-manager/image-manager.tsx +++ b/apps/builder/app/builder/shared/image-manager/image-manager.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { findNextListIndex, Grid } from "@webstudio-is/design-system"; import { AssetsShell, @@ -7,39 +7,26 @@ import { useSearch, deleteAssets, } from "../assets"; -import { useFilter } from "../assets/use-filter"; import { ImageThumbnail } from "./image-thumbnail"; import { matchSorter } from "match-sorter"; -import type { ImageAsset } from "@webstudio-is/asset-uploader"; +import { + acceptToMimePatterns, + doesAssetMatchMimePatterns, + type ImageAsset, +} from "@webstudio-is/asset-uploader"; -const filterItems = (search: string, items: AssetContainer[]) => { - return matchSorter(items, search, { - keys: [(item) => item.asset.name], - }); -}; - -const useLogic = ({ onChange }: { onChange?: (asset: ImageAsset) => void }) => { +const useLogic = ({ + onChange, + accept, +}: { + onChange?: (asset: ImageAsset) => void; + accept?: string; +}) => { const { assetContainers } = useAssets("image"); const [selectedIndex, setSelectedIndex] = useState(-1); - // @todo filter out deleting assets - const { filteredItems, resetFilteredItems, setFilteredItems } = useFilter({ - items: assetContainers, - onReset() { - searchProps.onCancel(); - }, - }); - const searchProps = useSearch({ - onCancel: resetFilteredItems, - onSearch(search) { - if (search === "") { - return resetFilteredItems(); - } - const items = filterItems(search, assetContainers); - setFilteredItems(items); - }, onSelect(direction) { if (direction === "current") { setSelectedIndex(selectedIndex); @@ -61,6 +48,22 @@ const useLogic = ({ onChange }: { onChange?: (asset: ImageAsset) => void }) => { }, }); + const filteredItems = useMemo(() => { + let acceptable = assetContainers; + const patterns = acceptToMimePatterns(accept ?? ""); + if (patterns !== "*") { + acceptable = assetContainers.filter((item) => + doesAssetMatchMimePatterns(item.asset, patterns) + ); + } + if (searchProps.value === "") { + return acceptable; + } + return matchSorter(acceptable, searchProps.value, { + keys: [(item) => item.asset.name], + }); + }, [assetContainers, accept, searchProps.value]); + const handleSelect = (assetContainer?: AssetContainer) => { const selectedIndex = filteredItems.findIndex( (item) => item.asset.id === assetContainer?.asset.id @@ -79,22 +82,25 @@ const useLogic = ({ onChange }: { onChange?: (asset: ImageAsset) => void }) => { type ImageManagerProps = { onChange?: (asset: ImageAsset) => void; + /** acceptable file types in the `` attribute format */ + accept?: string; }; -export const ImageManager = ({ onChange }: ImageManagerProps) => { +export const ImageManager = ({ accept, onChange }: ImageManagerProps) => { const { handleDelete, handleSelect, filteredItems, searchProps, selectedIndex, - } = useLogic({ onChange }); + } = useLogic({ onChange, accept }); return ( {filteredItems.map((assetContainer, index) => ( diff --git a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx index aa2d51ca90..c72bd21c8b 100644 --- a/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx +++ b/apps/builder/app/canvas/features/webstudio-component/webstudio-component.tsx @@ -152,9 +152,6 @@ export const WebstudioComponentDev = ({ ...userProps, ...readonlyProps, tabIndex: 0, - // we should replace id, data-component and data-id with "data-ws"=instance.id and grab the rest always over the id - // for this we need to also make search by id fast - id: instance.id, [componentAttribute]: instance.component, [idAttribute]: instance.id, onClick: (event: MouseEvent) => { diff --git a/packages/asset-uploader/package.json b/packages/asset-uploader/package.json index 57e592bc10..e74bd7f744 100644 --- a/packages/asset-uploader/package.json +++ b/packages/asset-uploader/package.json @@ -24,7 +24,8 @@ "app-root-path": "^3.0.0", "immer": "^9.0.12", "nanoid": "^3.2.0", - "sharp": "^0.30.7" + "sharp": "^0.30.7", + "warn-once": "^0.1.1" }, "devDependencies": { "@jest/globals": "^29.3.1", diff --git a/packages/asset-uploader/src/index.ts b/packages/asset-uploader/src/index.ts index feff34e0af..ed0e291cda 100644 --- a/packages/asset-uploader/src/index.ts +++ b/packages/asset-uploader/src/index.ts @@ -3,3 +3,4 @@ export * from "./schema"; export * from "./constants"; export * from "./utils/to-bytes"; export * from "./utils/sanitize-s3-key"; +export * from "./utils/mime"; diff --git a/packages/asset-uploader/src/utils/mime.test.ts b/packages/asset-uploader/src/utils/mime.test.ts new file mode 100644 index 0000000000..3b7ecdd4fa --- /dev/null +++ b/packages/asset-uploader/src/utils/mime.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect } from "@jest/globals"; +import { + acceptToMimePatterns, + acceptToMimeCategories, + getAssetMime, + doesAssetMatchMimePatterns, +} from "./mime"; + +describe("acceptToMimePatterns", () => { + test("returns * if accept is empty or * or */*", () => { + expect(acceptToMimePatterns("")).toBe("*"); + expect(acceptToMimePatterns("*")).toBe("*"); + expect(acceptToMimePatterns("*/*")).toBe("*"); + expect(acceptToMimePatterns("*,.png")).toBe("*"); + }); + + test("returns * if it doesn't recognise a pattern", () => { + expect(acceptToMimePatterns("image/wrong")).toBe("*"); + expect(acceptToMimePatterns("wrong/*")).toBe("*"); + expect(acceptToMimePatterns(".wrong")).toBe("*"); + }); + + test("leaves full mimes as is", () => { + expect(acceptToMimePatterns("image/png,font/otf")).toEqual( + new Set(["image/png", "font/otf"]) + ); + }); + + test("leaves mime patterns as is", () => { + expect(acceptToMimePatterns("image/*,font/*")).toEqual( + new Set(["image/*", "font/*"]) + ); + }); + + test("converts extensions to mimes", () => { + expect(acceptToMimePatterns(".svg,.otf")).toEqual( + new Set(["image/svg+xml", "font/otf"]) + ); + }); +}); + +describe("acceptToMimeCategories", () => { + test("returns * if accept is empty or * or */*", () => { + expect(acceptToMimeCategories("")).toBe("*"); + expect(acceptToMimeCategories("*")).toBe("*"); + expect(acceptToMimeCategories("*/*")).toBe("*"); + expect(acceptToMimeCategories("*,.png")).toBe("*"); + }); + + test("returns * if it doesn't recognise a pattern", () => { + expect(acceptToMimeCategories("image/wrong")).toBe("*"); + expect(acceptToMimeCategories("wrong/*")).toBe("*"); + expect(acceptToMimeCategories(".wrong")).toBe("*"); + }); + + test("handles full mimes", () => { + expect(acceptToMimeCategories("image/png,font/otf")).toEqual( + new Set(["image", "font"]) + ); + }); + + test("handles mime patterns", () => { + expect(acceptToMimeCategories("image/*,font/*")).toEqual( + new Set(["image", "font"]) + ); + }); + + test("handles extensions", () => { + expect(acceptToMimeCategories(".svg,.otf")).toEqual( + new Set(["image", "font"]) + ); + }); +}); + +describe("getAssetMime", () => { + test("handles woff", () => { + expect(getAssetMime({ type: "font", format: "woff" })).toBe("font/woff"); + }); + + test("handles png", () => { + expect(getAssetMime({ type: "image", format: "png" })).toBe("image/png"); + }); + + test("handles svg", () => { + expect(getAssetMime({ type: "image", format: "svg" })).toBe( + "image/svg+xml" + ); + }); + + test("returns undefined for unknown format", () => { + expect(getAssetMime({ type: "image", format: "unknown" })).toBeUndefined(); + }); +}); + +describe("doesAssetMatchMimePatterns", () => { + test("returns true if mime patterns is *", () => { + expect( + doesAssetMatchMimePatterns({ type: "image", format: "png" }, "*") + ).toBe(true); + }); + + test("handles full mimes", () => { + expect( + doesAssetMatchMimePatterns( + { type: "image", format: "svg" }, + new Set(["image/svg+xml"]) + ) + ).toBe(true); + expect( + doesAssetMatchMimePatterns( + { type: "image", format: "svg" }, + new Set(["image/png"]) + ) + ).toBe(false); + }); + + test("handles mime patterns", () => { + expect( + doesAssetMatchMimePatterns( + { type: "image", format: "svg" }, + new Set(["image/*"]) + ) + ).toBe(true); + expect( + doesAssetMatchMimePatterns( + { type: "image", format: "svg" }, + new Set(["font/*"]) + ) + ).toBe(false); + }); + + test("if asset format has unexpected value, returns false", () => { + expect( + doesAssetMatchMimePatterns( + { type: "image", format: "unknown" }, + new Set(["image/*"]) + ) + ).toBe(false); + }); +}); diff --git a/packages/asset-uploader/src/utils/mime.ts b/packages/asset-uploader/src/utils/mime.ts new file mode 100644 index 0000000000..3899c08ba3 --- /dev/null +++ b/packages/asset-uploader/src/utils/mime.ts @@ -0,0 +1,235 @@ +// Utils related to MIME types and the `accept` attribute value format +// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept + +import warnOnce from "warn-once"; +import type { Asset } from "../schema"; + +type SetType = T extends Set ? U : never; + +const mimeCategories = [ + "image", + "audio", + "video", + "font", + "text", + "application", +] as const; + +type MimeCategory = (typeof mimeCategories)[number]; + +const extensionToMime = new Map([ + [".avif" as string, "image/avif"], + [".bmp", "image/bmp"], + [".gif", "image/gif"], + [".ico", "image/vnd.microsoft.icon"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".png", "image/png"], + [".svg", "image/svg+xml"], + [".tif", "image/tiff"], + [".tiff", "image/tiff"], + [".webp", "image/webp"], + [".woff", "font/woff"], + [".woff2", "font/woff2"], + [".ttf", "font/ttf"], + [".otf", "font/otf"], +] as const); + +const mimeTypes = new Set(extensionToMime.values()); +type Mime = SetType; +const isMime = (value: string): value is Mime => mimeTypes.has(value as Mime); + +const mimePatterns = new Set([ + ...mimeTypes.values(), + ...mimeCategories.map((category): `${MimeCategory}/*` => `${category}/*`), +]); +type MimePattern = SetType; +const isMimePattern = (value: string): value is MimePattern => + mimePatterns.has(value as MimePattern); + +const getCategory = (pattern: MimePattern): MimeCategory => { + const categoryAsString = pattern.split("/")[0]; + const category = mimeCategories.find( + (category) => category === categoryAsString + ); + if (category === undefined) { + throw new Error(`Invalid mime pattern: ${pattern}`); + } + return category; +}; + +/** `".svg,font/otf,text/*"` -> `"image/svg+xml", "font/otf", "text/*"` */ +export const acceptToMimePatterns = ( + accept: string +): Set | "*" => { + const result = new Set(); + + if (accept === "") { + return "*"; + } + + for (const type of accept.split(",")) { + const trimmed = type.trim(); + if (trimmed === "*" || trimmed === "*/*") { + return "*"; + } + if (isMimePattern(trimmed)) { + result.add(trimmed); + continue; + } + const mime = extensionToMime.get(trimmed); + + if (mime === undefined) { + warnOnce( + true, + `Couldn't not parse accept attribute value: ${trimmed}. Falling back to "*".` + ); + return "*"; + } + + result.add(mime); + } + + return result; +}; + +/** `".svg,font/otf,text/*"` -> `"image", "font", "text"` */ +export const acceptToMimeCategories = ( + accept: string +): Set | "*" => { + const patterns = acceptToMimePatterns(accept); + if (patterns === "*") { + return "*"; + } + return new Set(Array.from(patterns).map(getCategory)); +}; + +export const getAssetMime = ({ + format, + type, +}: Pick): Mime | undefined => { + const mime = `${type}/${format}`; + if (isMime(mime)) { + return mime; + } + const mime2 = extensionToMime.get(`.${format}`); + if (mime2 === undefined) { + warnOnce( + true, + `Couldn't determine mime type of asset: ${type}, ${format}.` + ); + } + return mime2; +}; + +export const doesAssetMatchMimePatterns = ( + asset: Pick, + patterns: Set | "*" +): boolean => { + if (patterns === "*") { + return true; + } + + const mime = getAssetMime(asset); + + if (mime === undefined) { + return false; + } + + return patterns.has(mime) || patterns.has(`${getCategory(mime)}/*`); +}; + +// Didn't delete because we may need this later +// From https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types +// const extentionToMime: [ +// string, +// Array<`${(typeof mimeCategories)[number]}/${string}`> +// ][] = [ +// [".aac", ["audio/aac"]], // AAC audio +// [".abw", ["application/x-abiword"]], // AbiWord document +// [".arc", ["application/x-freearc"]], // Archive document (multiple files embedded) +// [".avif", ["image/avif"]], // AVIF image +// [".avi", ["video/x-msvideo"]], // AVI: Audio Video Interleave +// [".azw", ["application/vnd.amazon.ebook"]], // Amazon Kindle eBook format +// [".bin", ["application/octet-stream"]], // Any kind of binary data +// [".bmp", ["image/bmp"]], // Windows OS/2 Bitmap Graphics +// [".bz", ["application/x-bzip"]], // BZip archive +// [".bz2", ["application/x-bzip2"]], // BZip2 archive +// [".cda", ["application/x-cdf"]], // CD audio +// [".csh", ["application/x-csh"]], // C-Shell script +// [".css", ["text/css"]], // Cascading Style Sheets (CSS) +// [".csv", ["text/csv"]], // Comma-separated values (CSV) +// [".doc", ["application/msword"]], // Microsoft Word +// [ +// ".docx", +// ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"], +// ], // Microsoft Word (OpenXML) +// [".eot", ["application/vnd.ms-fontobject"]], // MS Embedded OpenType fonts +// [".epub", ["application/epub+zip"]], // Electronic publication (EPUB) +// [".gz", ["application/gzip"]], // GZip Compressed Archive +// [".gif", ["image/gif"]], // Graphics Interchange Format (GIF) +// [".htm", ["text/html"]], // HyperText Markup Language (HTML) +// [".html", ["text/html"]], +// [".ico", ["image/vnd.microsoft.icon"]], // Icon format +// [".ics", ["text/calendar"]], // iCalendar format +// [".jar", ["application/java-archive"]], // Java Archive (JAR) +// [".jpeg", ["image/jpeg"]], // JPEG images +// [".jpg", ["image/jpeg"]], +// [".js", ["text/javascript"]], // JavaScript +// [".json", ["application/json"]], // JSON format +// [".jsonld", ["application/ld+json"]], // JSON-LD format +// [".mid", ["audio/midi", "audio/x-midi"]], // Musical Instrument Digital Interface (MIDI) +// [".midi", ["audio/midi", "audio/x-midi"]], +// [".mjs", ["text/javascript"]], // JavaScript module +// [".mp3", ["audio/mpeg"]], // MP3 audio +// [".mp4", ["video/mp4"]], // MP4 video +// [".mpeg", ["video/mpeg"]], // MPEG Video +// [".mpkg", ["application/vnd.apple.installer+xml"]], // Apple Installer Package +// [".odp", ["application/vnd.oasis.opendocument.presentation"]], // OpenDocument presentation document +// [".ods", ["application/vnd.oasis.opendocument.spreadsheet"]], // OpenDocument spreadsheet document +// [".odt", ["application/vnd.oasis.opendocument.text"]], // OpenDocument text document +// [".oga", ["audio/ogg"]], // OGG audio +// [".ogv", ["video/ogg"]], // OGG video +// [".ogx", ["application/ogg"]], // OGG +// [".opus", ["audio/opus"]], // Opus audio +// [".otf", ["font/otf"]], // OpenType font +// [".png", ["image/png"]], // Portable Network Graphics +// [".pdf", ["application/pdf"]], // Adobe Portable Document Format (PDF) +// [".php", ["application/x-httpd-php"]], // Hypertext Preprocessor (Personal Home Page) +// [".ppt", ["application/vnd.ms-powerpoint"]], // Microsoft PowerPoint +// [ +// ".pptx", +// [ +// "application/vnd.openxmlformats-officedocument.presentationml.presentation", +// ], +// ], // Microsoft PowerPoint (OpenXML) +// [".rar", ["application/vnd.rar"]], // RAR archive +// [".rtf", ["application/rtf"]], // Rich Text Format (RTF) +// [".sh", ["application/x-sh"]], // Bourne shell script +// [".svg", ["image/svg+xml"]], // Scalable Vector Graphics (SVG) +// [".tar", ["application/x-tar"]], // Tape Archive (TAR) +// [".tif", ["image/tiff"]], // Tagged Image File Format (TIFF) +// [".tiff", ["image/tiff"]], +// [".ts", ["video/mp2t"]], // MPEG transport stream +// [".ttf", ["font/ttf"]], // TrueType Font +// [".txt", ["text/plain"]], // Text, (generally ASCII or ISO 8859-n) +// [".vsd", ["application/vnd.visio"]], // Microsoft Visio +// [".wav", ["audio/wav"]], // Waveform Audio Format +// [".weba", ["audio/webm"]], // WEBM audio +// [".webm", ["video/webm"]], // WEBM video +// [".webp", ["image/webp"]], // WEBP image +// [".woff", ["font/woff"]], // Web Open Font Format (WOFF) +// [".woff2", ["font/woff2"]], // Web Open Font Format (WOFF) +// [".xhtml", ["application/xhtml+xml"]], // XHTML +// [".xls", ["application/vnd.ms-excel"]], // Microsoft Excel +// [ +// ".xlsx", +// ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], +// ], // Microsoft Excel (OpenXML) +// [".xml", ["application/xml", "text/xml"]], // XML +// [".xul", ["application/vnd.mozilla.xul+xml"]], // XUL +// [".zip", ["application/zip"]], // ZIP archive +// [".3gp", ["video/3gpp", "audio/3gpp"]], // 3GPP audio/video container +// [".3g2", ["video/3gpp2", "audio/3gpp2"]], // 3GPP2 audio/video container +// [".7z", ["application/x-7z-compressed"]], // 7-zip archive +// ]; diff --git a/packages/design-system/src/components/input-field.tsx b/packages/design-system/src/components/input-field.tsx index 703a365973..f0b41cdc76 100644 --- a/packages/design-system/src/components/input-field.tsx +++ b/packages/design-system/src/components/input-field.tsx @@ -36,7 +36,7 @@ const inputStyle = css({ height: "100%", paddingRight: theme.spacing[2], paddingLeft: theme.spacing[3], - "&[data-color=placeholder]:not(:hover):not(:disabled), &::placeholder": { + "&[data-color=placeholder]:not(:hover, :disabled, :focus), &::placeholder": { color: theme.colors.foregroundSubtle, }, "&[data-color=error]": { color: theme.colors.foregroundDestructive }, diff --git a/packages/generate-arg-types/src/types.ts b/packages/generate-arg-types/src/types.ts index 5d69c73b35..423e196755 100644 --- a/packages/generate-arg-types/src/types.ts +++ b/packages/generate-arg-types/src/types.ts @@ -104,14 +104,13 @@ const MultiSelect = z.object({ options: z.array(z.string()), }); -// @todo -// remove this and add a generic "file" control instead with an "accept" option -// to be in line with Storybook -const FileImage = z.object({ +const File = z.object({ ...common, - control: z.literal("file-image"), + control: z.literal("file"), type: z.literal("string"), defaultValue: z.string().optional(), + /** https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept */ + accept: z.string().optional(), }); const Url = z.object({ @@ -156,7 +155,7 @@ export const PropMeta = z.union([ MultiSelect, Check, InlineCheck, - FileImage, + File, Url, ObjectType, Date, diff --git a/packages/image/src/index.ts b/packages/image/src/index.ts index 370ba45933..cfa0aa4235 100644 --- a/packages/image/src/index.ts +++ b/packages/image/src/index.ts @@ -11,7 +11,7 @@ export const imageProps: Record = { ...publicProps, src: { type: "string", - control: "file-image", + control: "file", label: "Source", required: false, }, diff --git a/packages/react-sdk/src/components/image.ws.tsx b/packages/react-sdk/src/components/image.ws.tsx index 3889eb6446..9e3bf99ce2 100644 --- a/packages/react-sdk/src/components/image.ws.tsx +++ b/packages/react-sdk/src/components/image.ws.tsx @@ -37,7 +37,7 @@ export const propsMeta: WsComponentPropsMeta = { ...props, src: { type: "string", - control: "file-image", + control: "file", label: "Source", required: false, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a78a1a163a..09043537f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -399,6 +399,9 @@ importers: sharp: specifier: ^0.30.7 version: 0.30.7 + warn-once: + specifier: ^0.1.1 + version: 0.1.1 devDependencies: '@jest/globals': specifier: ^29.3.1