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)
This commit is contained in:
Roman Pominov authored and GitHub committed 2023-04-07 15:25:15 +03:00
1 parent b123f5a498
commit 083b138ebf
21 files changed
+588 -176

No files matched your search

@@ -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 <FileImageControl meta={meta} prop={prop} {...rest} />;
return <FileControl meta={meta} prop={prop} {...rest} />;
}
if (
@@ -129,11 +129,11 @@ export const renderControl = ({
if (prop.type === "asset") {
return (
<FileImageControl
<FileControl
meta={{
...meta,
defaultValue: undefined,
control: "file-image",
control: "file",
type: "string",
}}
prop={prop}
@@ -1,44 +0,0 @@
import { useMemo } from "react";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import { Button } 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";
export const FileImageControl = ({
meta,
prop,
propName,
onChange,
onDelete,
}: ControlProps<"file-image", "asset">) => {
const assetStore = useMemo(
() =>
computed(assetsStore, (assets) =>
prop ? assets.get(prop.value) : undefined
),
[prop]
);
const asset = useStore(assetStore);
return (
<VerticalLayout label={getLabel(meta, propName)} onDelete={onDelete}>
<FloatingPanel
title="Images"
content={
<ImageManager
onChange={(asset) =>
onChange({ type: "asset", value: asset.id }, asset)
}
/>
}
>
<Button color="neutral" css={{ width: "100%" }}>
{asset?.name ?? "Choose image"}
</Button>
</FloatingPanel>
</VerticalLayout>
);
};
@@ -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 (
<VerticalLayout label={getLabel(meta, propName)} onDelete={onDelete}>
{acceptCategories === "*" ||
(acceptCategories.size === 1 && acceptCategories.has("image")) ? (
<FloatingPanel
title="Images"
content={
<ImageManager
onChange={(asset) =>
onChange({ type: "asset", value: asset.id }, asset)
}
accept={meta?.accept}
/>
}
>
<Button color="neutral" css={{ width: "100%" }}>
{asset?.name ?? "Choose source"}
</Button>
</FloatingPanel>
) : (
<Text color="destructive">
Unsupported accept value: {meta?.accept}
</Text>
)}
</VerticalLayout>
);
};
@@ -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 = () => {
@@ -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"
@@ -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 (
<form onChange={onChange}>
<Flex>
<input
accept={acceptMap[type]}
accept={accept ?? acceptMap[type]}
type="file"
name={type}
multiple
ref={inputRef}
style={{ display: "none" }}
/>
<Tooltip side="bottom" content={tooltipContent}>
<Button
type="button"
onClick={() => inputRef?.current?.click()}
css={{ flexGrow: 1 }}
prefix={<UploadIcon />}
disabled={isUploadDisabled}
>
Upload
</Button>
</Tooltip>
<Button
type="button"
onClick={() => inputRef?.current?.click()}
css={{ flexGrow: 1 }}
prefix={<UploadIcon />}
>
Upload
</Button>
</Flex>
</form>
);
};
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 <EnabledAssetUpload type={type} />;
}
return (
<Flex>
<Tooltip side="bottom" content="View mode. You can't upload assets.">
<Button css={{ flexGrow: 1 }} prefix={<UploadIcon />} disabled={true}>
Upload
</Button>
</Tooltip>
</Flex>
);
};
@@ -10,6 +10,7 @@ type AssetsShellProps = {
searchProps: ComponentProps<typeof SearchField>;
children: JSX.Element;
type: AssetType;
accept?: string;
isEmpty: boolean;
};
@@ -18,6 +19,7 @@ export const AssetsShell = ({
isEmpty,
children,
type,
accept,
}: AssetsShellProps) => {
return (
<Flex
@@ -33,7 +35,7 @@ export const AssetsShell = ({
direction="column"
shrink={false}
>
<AssetUpload type={type} />
<AssetUpload type={type} accept={accept} />
<SearchField {...searchProps} autoFocus placeholder="Search" />
</Flex>
<Separator />
@@ -1,28 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
export const useFilter = <Item>({
items,
onReset,
}: {
onReset: () => void;
items: Array<Item>;
}) => {
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,
};
};
@@ -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<HTMLInputElement> = (event) => {
const { value } = event.currentTarget;
setSearch(value);
onSearch(value);
};
const handleCancel = () => {
setSearch("");
onCancel();
};
return {
@@ -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,
@@ -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 `<imput accept>` 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 (
<AssetsShell
searchProps={searchProps}
isEmpty={filteredItems.length === 0}
type="image"
accept={accept}
>
<Grid columns={3} gap={2}>
{filteredItems.map((assetContainer, index) => (
@@ -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) => {
+2 -1
View File
@@ -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",
+1
View File
@@ -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";
@@ -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);
});
});
+235
View File
@@ -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> = T extends Set<infer U> ? 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<typeof mimeTypes>;
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<typeof mimePatterns>;
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<MimePattern> | "*" => {
const result = new Set<MimePattern>();
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<MimeCategory> | "*" => {
const patterns = acceptToMimePatterns(accept);
if (patterns === "*") {
return "*";
}
return new Set(Array.from(patterns).map(getCategory));
};
export const getAssetMime = ({
format,
type,
}: Pick<Asset, "format" | "type">): 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<Asset, "format" | "type">,
patterns: Set<MimePattern> | "*"
): 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
// ];
@@ -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 },
+5 -6
View File
@@ -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,
+1 -1
View File
@@ -11,7 +11,7 @@ export const imageProps: Record<string, PropMeta> = {
...publicProps,
src: {
type: "string",
control: "file-image",
control: "file",
label: "Source",
required: false,
},
@@ -37,7 +37,7 @@ export const propsMeta: WsComponentPropsMeta = {
...props,
src: {
type: "string",
control: "file-image",
control: "file",
label: "Source",
required: false,
},
+3
View File
@@ -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