mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
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:
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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user