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 (
+
+
+ } disabled={true}>
+ Upload
+
+
+
+ );
+};
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