Merge branch 'main' into ds-textfield

This commit is contained in:
Andrey Okonetchnikov authored and GitHub committed 2022-09-20 13:34:56 +02:00
commit 211e91fba2
41 files changed
+584 -203

No files matched your search

+3 -1
View File
@@ -46,6 +46,7 @@ import { usePublishScrollState } from "./shared/use-publish-scroll-state";
import { useDragAndDrop } from "./shared/use-drag-drop";
import { setInstanceChildrenMutable } from "~/shared/tree-utils";
import { CanvasData } from "~/shared/db";
import { useSubscribeDesignerReady } from "./shared/use-designer-ready";
registerContainers();
@@ -118,6 +119,7 @@ export const Canvas = ({ data }: CanvasProps): JSX.Element | null => {
if (data.tree === null) {
throw new Error("Tree is null");
}
const isDesignerReady = useSubscribeDesignerReady();
useInitializeBreakpoints(data.breakpoints);
globalStyles();
useAllUserProps(data.props);
@@ -129,7 +131,7 @@ export const Canvas = ({ data }: CanvasProps): JSX.Element | null => {
if (elements === undefined) return null;
if (isPreviewMode) {
if (isPreviewMode || isDesignerReady === false) {
return elements;
}
@@ -0,0 +1,18 @@
import { useState } from "react";
import { publish, useSubscribe } from "~/shared/pubsub";
declare module "~/shared/pubsub" {
export interface PubsubMap {
designerReady: undefined;
designerReadyAck: undefined;
}
}
export const useSubscribeDesignerReady = () => {
const [isReady, setIsReady] = useState<boolean>(false);
useSubscribe("designerReady", () => {
setIsReady(true);
publish({ type: "designerReadyAck" });
});
return isReady;
};
+33 -2
View File
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useEffect, useState } from "react";
import { type Publish, usePublish, useSubscribe } from "~/shared/pubsub";
import type { Project } from "@webstudio-is/project";
import type { Config } from "~/config";
@@ -7,6 +7,7 @@ import interStyles from "~/shared/font-faces/inter.css";
import { SidebarLeft } from "./features/sidebar-left";
import { Inspector } from "./features/inspector";
import {
useAssets,
useHoveredInstanceData,
useSelectedInstanceData,
useSyncStatus,
@@ -33,6 +34,8 @@ import {
import { useClientSettings } from "./shared/client-settings";
import { Navigator } from "./features/sidebar-left";
import { PANEL_WIDTH } from "./shared/constants";
import { Asset } from "@webstudio-is/asset-uploader";
import { useInterval } from "react-use";
export const links = () => {
return [
@@ -61,6 +64,15 @@ const useSubscribeSyncStatus = () => {
useSubscribe("syncStatus", setValue);
};
const useSetAssets = (assets?: Array<Asset>) => {
const [, setAssets] = useAssets();
useEffect(() => {
if (assets) {
setAssets(assets);
}
}, [assets, setAssets]);
};
const useNavigatorLayout = () => {
// We need to render the detached state only once the setting was actually loaded from local storage.
// Otherwise we may show the detached state because its the default and then hide it immediately.
@@ -68,6 +80,23 @@ const useNavigatorLayout = () => {
return isLoaded ? clientSettings.navigatorLayout : "docked";
};
const usePublishDesignerReady = (publish: Publish) => {
const [isAcknowledged, setIsAcknowledged] = useState(false);
useInterval(
() => {
// We publish this even to let canvas know that we are now listening to the events, otherwise if canvas loads faster than designer, which is possible with SSR,
// we can miss the events and designer will just not connect to the canvas.
publish({ type: "designerReady" });
},
isAcknowledged ? null : 100
);
useSubscribe("designerReadyAck", () => {
setIsAcknowledged(true);
});
};
type SidePanelProps = {
children: JSX.Element | Array<JSX.Element>;
isPreviewMode: boolean;
@@ -220,12 +249,14 @@ export const Designer = ({ config, project }: DesignerProps) => {
useSubscribeSelectedInstanceData();
useSubscribeHoveredInstanceData();
useSubscribeBreakpoints();
useSetAssets(project.assets);
const [publish, publishRef] = usePublish();
const [isPreviewMode] = useIsPreviewMode();
usePublishShortcuts(publish);
const onRefReadCanvasWidth = useUpdateCanvasWidth();
const { onRef: onRefReadCanvas, onTransitionEnd } = useReadCanvasRect();
const [dragAndDropState] = useDragAndDropState();
usePublishDesignerReady(publish);
const iframeRefCallback = useCallback(
(ref) => {
@@ -263,7 +294,7 @@ export const Designer = ({ config, project }: DesignerProps) => {
</Workspace>
</Main>
<SidePanel gridArea="sidebar" isPreviewMode={isPreviewMode}>
<SidebarLeft assets={project.assets} publish={publish} />
<SidebarLeft publish={publish} />
</SidePanel>
<NavigatorPanel publish={publish} isPreviewMode={isPreviewMode} />
<SidePanel
@@ -8,10 +8,9 @@ import {
PopoverHeader,
} from "@webstudio-is/design-system";
import { GearIcon, gearIconCssVars } from "@webstudio-is/icons";
import { AssetInfo } from "./asset-info";
import { cssVars } from "@webstudio-is/css-vars";
import { BaseAsset } from "~/designer/shared/assets";
import { Asset } from "@webstudio-is/asset-uploader";
const triggerVisibilityVar = cssVars.define("trigger-visibility");
@@ -23,7 +22,7 @@ export const AssetInfoTrigger = ({
asset,
onDelete,
}: {
asset: BaseAsset;
asset: Asset;
onDelete: () => void;
}) => {
const [isInfoOpen, setInfoOpen] = useState(false);
@@ -8,7 +8,7 @@ import {
} from "@webstudio-is/icons";
import { useSubmit } from "@remix-run/react";
import prettyBytes from "pretty-bytes";
import type { BaseAsset } from "~/designer/shared/assets";
import { Asset } from "@webstudio-is/asset-uploader";
const Filename = ({ name }: { name: string }) => {
const splitName = name.split(".");
@@ -45,7 +45,7 @@ const Filename = ({ name }: { name: string }) => {
);
};
type AssetInfoProps = BaseAsset & {
type AssetInfoProps = Asset & {
onClose: () => void;
onDelete: () => void;
};
@@ -81,20 +81,22 @@ export const AssetInfo = ({
</Flex>
</Grid>
</Box>
<Box css={{ p: "$2 $3" }}>
<Grid columns={2} gap={2} align="center">
<Flex align="center" css={{ gap: "$1" }}>
<SizeIcon />
<Text variant="label">
{meta.width} x {meta.height}
</Text>
</Flex>{" "}
<Flex align="center" css={{ gap: "$1" }}>
<AspectRatioIcon />
<Text variant="label">{getFormattedAspectRatio(meta)}</Text>
</Flex>
</Grid>
</Box>
{"width" in meta && "height" in meta ? (
<Box css={{ p: "$2 $3" }}>
<Grid columns={2} gap={2} align="center">
<Flex align="center" css={{ gap: "$1" }}>
<SizeIcon />
<Text variant="label">
{meta.width} x {meta.height}
</Text>
</Flex>{" "}
<Flex align="center" css={{ gap: "$1" }}>
<AspectRatioIcon />
<Text variant="label">{getFormattedAspectRatio(meta)}</Text>
</Flex>
</Grid>
</Box>
) : null}
<Box css={{ p: "$2 $3" }}>
<Button variant="red" size="2" onClick={handleDeleteAsset}>
<Flex align="center" css={{ gap: "$1" }}>
@@ -1,22 +1,16 @@
import { ImageIcon } from "@webstudio-is/icons";
import { Box, Flex, Grid } from "@webstudio-is/design-system";
import {
AssetUpload,
useAssets,
type BaseAsset,
} from "~/designer/shared/assets";
import { AssetUpload, useAssets } from "~/designer/shared/assets";
import { TabName } from "../../types";
import { Header } from "../../lib/header";
import { AssetThumbnail } from "./asset-thumbnail";
export const TabContent = ({
assets: loadedAssets,
onSetActiveTab,
}: {
onSetActiveTab: (tabName: TabName) => void;
assets: Array<BaseAsset>;
}) => {
const { assets, onUploadAsset } = useAssets(loadedAssets);
const { assets, onSubmitAssets, onActionData } = useAssets("image");
return (
<>
<Header
@@ -31,7 +25,11 @@ export const TabContent = ({
css={{ padding: "$1", paddingTop: "$2" }}
>
<Box css={{ padding: "$2" }}>
<AssetUpload onSubmit={onUploadAsset} type="image" />
<AssetUpload
onSubmit={onSubmitAssets}
onActionData={onActionData}
type="image"
/>
</Box>
<Grid columns={2} gap={2}>
{assets.map((asset) => (
@@ -4,7 +4,8 @@ import placeholderImage from "~/shared/images/image-placeholder.svg";
import brokenImage from "~/shared/images/broken-image-placeholder.svg";
import { UploadingAnimation } from "./uploading-animation";
import { AssetInfoTrigger, assetInfoTriggerCssVars } from "./asset-info-tigger";
import type { BaseAsset } from "~/designer/shared/assets";
import type { PreviewAsset } from "~/designer/shared/assets";
import { Asset } from "@webstudio-is/asset-uploader";
const useImageWithFallback = ({
path = placeholderImage,
@@ -25,7 +26,7 @@ const useImageWithFallback = ({
type ThumbnailProps = {
path?: string;
status: BaseAsset["status"];
status: Asset["status"];
};
export const Thumbnail = ({ path, status }: ThumbnailProps) => {
@@ -51,15 +52,17 @@ export const Thumbnail = ({ path, status }: ThumbnailProps) => {
);
};
export const AssetThumbnail = (asset: BaseAsset) => {
const { path, description, status, name } = asset;
export const AssetThumbnail = (asset: Asset | PreviewAsset) => {
const { path, status, name } = asset;
const description =
"description" in asset && asset.description ? asset.description : name;
const [isDeleting, setIsDeleting] = useState(false);
const isUploading = status === "uploading";
const isUploadedAsset = isUploading === false && "size" in asset;
return (
<Box
title={description || name}
title={description}
css={{
aspectRatio: "1/1",
display: "flex",
@@ -1,5 +1,5 @@
import * as components from "./components";
import * as navigator from "./navigator";
import * as assetManager from "./asset-manager";
import * as assets from "./assets";
export const panels = { components, navigator, assetManager } as const;
export const panels = { components, navigator, assets } as const;
@@ -12,17 +12,15 @@ import type { TabName } from "./types";
import { isFeatureEnabled } from "~/shared/feature-flags";
import { useClientSettings } from "~/designer/shared/client-settings";
import { PANEL_WIDTH } from "~/designer/shared/constants";
import { Asset } from "@webstudio-is/asset-uploader";
import { Flex } from "@webstudio-is/design-system";
const none = { TabContent: () => null };
type SidebarLeftProps = {
publish: Publish;
assets?: Array<Asset>;
};
export const SidebarLeft = ({ publish, assets }: SidebarLeftProps) => {
export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
const [dragAndDropState] = useDragAndDropState();
const [activeTab, setActiveTab] = useState<TabName>("none");
const { TabContent } = activeTab === "none" ? none : panels[activeTab];
@@ -38,7 +36,7 @@ export const SidebarLeft = ({ publish, assets }: SidebarLeftProps) => {
const enabledPanels = (Object.keys(panels) as Array<TabName>).filter(
(panel) => {
switch (panel) {
case "assetManager":
case "assets":
return isFeatureEnabled("assets");
case "navigator":
return clientSettings.navigatorLayout === "docked";
@@ -78,11 +76,7 @@ export const SidebarLeft = ({ publish, assets }: SidebarLeftProps) => {
overflow: "auto",
}}
>
<TabContent
assets={assets || []}
publish={publish}
onSetActiveTab={setActiveTab}
/>
<TabContent publish={publish} onSetActiveTab={setActiveTab} />
</SidebarTabsContent>
</SidebarTabs>
</Flex>
@@ -68,6 +68,8 @@ const styles = {
border: "none",
textAlign: "center",
outline: "none",
// No idea why it's hidden otherwise
zIndex: 0,
},
inputFromCurrentBreakpoint: {
color: propertyNameColorForSelectedBreakpoint,
@@ -38,6 +38,7 @@ import {
RowGapIcon,
ColumnGapIcon,
} from "@webstudio-is/icons";
import { isFeatureEnabled } from "~/shared/feature-flags";
type BaseStyleConfig = {
label: string;
@@ -90,9 +91,9 @@ const getControl = (property: StyleProperty): Control => {
case "placeContent": {
return "Empty";
}
//case "fontFamily": {
// return "FontFamily";
//}
case "fontFamily": {
return isFeatureEnabled("assets") ? "FontFamily" : "Text";
}
}
return "Text";
@@ -1,26 +1,28 @@
import { Form, useSubmit } from "@remix-run/react";
import { Form, useActionData, useSubmit } from "@remix-run/react";
import ObjectID from "bson-objectid";
import { ChangeEvent, useRef } from "react";
import { ChangeEvent, useEffect, useRef } from "react";
import { Button, Flex, Text } from "@webstudio-is/design-system";
import { UploadIcon } from "@webstudio-is/icons";
import type { BaseAsset } from "./types";
import { type AssetType, FONT_FORMATS } from "@webstudio-is/asset-uploader";
import type { ActionData, PreviewAsset } from "./types";
const readAssets = (fileList: FileList): Promise<BaseAsset[]> => {
const assets: Array<Promise<BaseAsset>> = Array.from(fileList).map(
const readAssets = (fileList: FileList): Promise<PreviewAsset[]> => {
const assets: Array<Promise<PreviewAsset>> = Array.from(fileList).map(
(file) =>
new Promise((resolve) => {
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", (event) => {
const dataUri = event?.target?.result;
const path = dataUri ? String(dataUri) : undefined;
if (dataUri === undefined) {
return reject(new Error("Could not read file"));
}
resolve({
path,
format: file.type.split("/")[1],
path: String(dataUri),
name: file.name,
id: ObjectID().toString(),
status: "uploading",
description: file.name,
size: file.size,
meta: {},
});
});
reader.readAsDataURL(file);
@@ -32,17 +34,23 @@ const readAssets = (fileList: FileList): Promise<BaseAsset[]> => {
const acceptMap = {
image: "image/*",
font: ".woff2,.woff,.ttf",
font: FONT_FORMATS.map((format) => `.${format}`).join(", "),
};
type AssetUploadProps = {
onSubmit: (assets: Array<BaseAsset>) => void;
type: "image" | "font";
onActionData: (data: ActionData) => void;
onSubmit: (assets: Array<PreviewAsset>) => void;
type: AssetType;
};
export const AssetUpload = ({ onSubmit, type }: AssetUploadProps) => {
export const AssetUpload = ({
onSubmit,
onActionData,
type,
}: AssetUploadProps) => {
const submit = useSubmit();
const inputRef = useRef<HTMLInputElement | null>(null);
const actionData: ActionData | undefined = useActionData();
const onFormChange = (event: ChangeEvent<HTMLFormElement>) => {
const newFiles = inputRef?.current?.files;
@@ -53,6 +61,12 @@ export const AssetUpload = ({ onSubmit, type }: AssetUploadProps) => {
}
};
useEffect(() => {
if (actionData !== undefined) {
onActionData(actionData);
}
}, [actionData, onActionData]);
return (
<Flex
as={Form}
@@ -1,8 +1,8 @@
import { Asset } from "@webstudio-is/asset-uploader";
export type BaseAsset = Pick<
export type PreviewAsset = Pick<
Asset,
"id" | "status" | "name" | "path" | "description" | "size" | "meta"
"path" | "name" | "id" | "status" | "format"
>;
export type ActionData = {
@@ -1,37 +1,53 @@
import { useEffect, useState } from "react";
import { useActionData } from "@remix-run/react";
import type { BaseAsset, ActionData } from "./types";
import { useMemo, useState } from "react";
import type { PreviewAsset, ActionData } from "./types";
import {
type AssetType,
type Asset,
filterByType,
} from "@webstudio-is/asset-uploader";
import { useAssets as useAllAssets } from "../nano-states";
export const useAssets = (initialAssets: Array<BaseAsset> = []) => {
const actionData: ActionData | undefined = useActionData();
const [assets, setAssets] = useState<BaseAsset[]>(initialAssets);
export const useAssets = (type: AssetType) => {
const [allAssets] = useAllAssets();
const [actionData, setActionData] = useState<ActionData>({});
const [uploadingAssets, setUploadingAssets] = useState<Array<PreviewAsset>>(
[]
);
useEffect(() => {
const { errors, uploadedAssets, deletedAsset } = actionData ?? {};
if (errors) {
setAssets((currentAssets) =>
currentAssets.filter((asset) => asset.status !== "uploading")
const assets = useMemo(() => {
const { errors, uploadedAssets = [], deletedAsset } = actionData ?? {};
let assets: Array<Asset | PreviewAsset> = [];
// Once we have uploaded or deleted assets in action data, current upload was finished.
if (
uploadedAssets.length === 0 &&
deletedAsset === undefined &&
errors === undefined
) {
assets = [...uploadingAssets];
}
for (const uploadedAsset of uploadedAssets) {
const isInAllAssets = allAssets.some(
(asset) => asset.id === uploadedAsset.id
);
if (isInAllAssets === false) {
assets.unshift(uploadedAsset);
}
}
if (uploadedAssets?.length) {
setAssets((currentAssets) => [
...uploadedAssets.filter((uploadedAsset) =>
currentAssets.every(
(currentAsset) => currentAsset.id !== uploadedAsset.id
)
),
...currentAssets.filter((asset) => asset.status !== "uploading"),
]);
}
if (deletedAsset?.id) {
setAssets((currentAssets) => [
...currentAssets.filter((asset) => asset.id !== deletedAsset.id),
]);
}
}, [actionData]);
const onUploadAsset = (uploadedAssets: Array<BaseAsset>) =>
setAssets((assets) => [...uploadedAssets, ...assets]);
for (const asset of allAssets) {
if (asset.id !== deletedAsset?.id) {
assets.push(asset);
}
}
return { assets, onUploadAsset };
return filterByType(assets, type);
}, [actionData, uploadingAssets, type, allAssets]);
return {
assets,
onSubmitAssets: setUploadingAssets,
onActionData: setActionData,
};
};
@@ -2,11 +2,15 @@ import { Flex, Box } from "@webstudio-is/design-system";
import { AssetUpload, useAssets } from "~/designer/shared/assets";
export const FontsManager = () => {
const { assets, onUploadAsset } = useAssets([]);
const { assets, onSubmitAssets, onActionData } = useAssets("font");
return (
<Flex gap="3" direction="column" css={{ padding: "$1", paddingTop: "$2" }}>
<Box css={{ padding: "$2" }}>
<AssetUpload onSubmit={onUploadAsset} type="font" />
<AssetUpload
onSubmit={onSubmitAssets}
onActionData={onActionData}
type="font"
/>
</Box>
{assets.map((asset) => asset.name)}
</Flex>
@@ -5,6 +5,7 @@ import {
type HoveredInstanceData,
} from "~/shared/canvas-components";
import { type SyncStatus } from "~/shared/sync";
import { Asset } from "@webstudio-is/asset-uploader";
const selectedInstanceDataContainer = createValueContainer<
SelectedInstanceData | undefined
@@ -45,3 +46,6 @@ export const useSyncStatus = () => useValue(syncStatusContainer);
const selectionRectContainer = createValueContainer<DOMRect | undefined>();
export const useSelectionRect = () => useValue(selectionRectContainer);
const assetsContainer = createValueContainer<Array<Asset>>([]);
export const useAssets = () => useValue(assetsContainer);
@@ -23,9 +23,14 @@ const instanceRelatedProps = {
return components[item.component].canAcceptChildren;
},
getItemChildren(item: Instance) {
// Only content editable components can have `string` children
// and this cab be a hot path for performance.
if (components[item.component].isContentEditable === false) {
const component = components[item.component];
// We want to avoid calling .filter() unnecessarily, because this is a hot path for performance.
// We rely on the fact that only content editable or inline components may have `string` children.
if (
component.isContentEditable === false &&
component.isInlineOnly === false
) {
return item.children as Instance[];
}
-1
View File
@@ -61,7 +61,6 @@
"remix-auth-form": "^1.1.2",
"remix-auth-github": "^1.1.1",
"remix-auth-google": "^1.1.0",
"sharp": "^0.30.7",
"slugify": "^1.6.5",
"zod-form-data": "^1.2.1"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+4 -1
View File
@@ -1 +1,4 @@
export * from "./src/index";
export * from "./src/db";
export * from "./src/upload";
export * from "./src/delete";
export * from "./src/utils/format-asset";
+2
View File
@@ -17,10 +17,12 @@
"@aws-sdk/lib-storage": "^3.131.0",
"@webstudio-is/prisma-client": "*",
"app-root-path": "^3.0.0",
"fontkit": "^2.0.2",
"sharp": "^0.30.7"
},
"devDependencies": {
"@types/app-root-path": "^1.2.4",
"@types/fontkit": "^1.8.0",
"@types/node": "^18.7.16",
"@types/sharp": "^0.30.4",
"@webstudio-is/jest-config": "*",
+1
View File
@@ -1 +1,2 @@
export const DEFAULT_UPLOAD_PATH = "public/uploads";
export const FONT_FORMATS = ["ttf", "woff", "woff2", "otf"] as const;
+27 -12
View File
@@ -3,27 +3,42 @@ import {
type Location,
type Project,
} from "@webstudio-is/prisma-client";
import { type Metadata } from "sharp";
import { formatAsset } from "../utils/format-asset";
import {
formatAsset,
type FontMeta,
type ImageMeta,
} from "../utils/format-asset";
type Options = {
type BaseOptions = {
name: string;
size: number;
metadata: Metadata;
location: Location;
format: string;
};
type Options =
| ({
type: "image";
meta: ImageMeta;
} & BaseOptions)
| ({ type: "font"; meta: FontMeta } & BaseOptions);
const create = (projectId: Project["id"], options: Options) => {
const size = options.size || options.metadata.size || 0;
const { metadata, name, location } = options;
const size = options.size || 0;
const { meta, format, name, location } = options;
const baseData = {
location,
name,
size,
format,
projectId,
};
return prisma.asset.create({
data: {
location,
name,
size,
format: metadata.format,
meta: JSON.stringify({ width: metadata.width, height: metadata.height }),
projectId,
...baseData,
meta: JSON.stringify(meta),
},
});
};
+2 -4
View File
@@ -1,5 +1,3 @@
export * from "./upload";
export * from "./delete";
export * from "./db";
export * from "./utils/format-asset";
export * from "./types";
export * from "./constants";
export * from "./utils/filter-by-type";
@@ -33,14 +33,15 @@ export const uploadToFs = async ({
uploadHandler
);
const formDataImages = AssetsFromFs.parse(formData.getAll("image"));
const formDataFonts = AssetsFromFs.parse(formData.getAll("font"));
const formDataAll = [...formDataImages, ...formDataFonts];
// @todo fonts
const assets = formDataImages.map(async (asset) =>
const assets = formDataAll.map(async (asset) =>
getAssetData({
type: "image" as const,
type: formDataFonts.includes(asset) ? "font" : "image",
name: asset.name,
size: asset.size,
buffer: (await asset.arrayBuffer()) as Uint8Array,
data: new Uint8Array(await asset.arrayBuffer()),
location: Location.FS,
})
);
@@ -7,10 +7,10 @@ import { PutObjectCommandInput } from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { Location } from "@webstudio-is/prisma-client";
import { S3EnvVariables } from "../../schema";
import { toBuffer } from "../../utils/to-buffer";
import { toUint8Array } from "../../utils/to-uint8-array";
import { getAssetData } from "../../utils/get-asset-data";
import { createMany } from "../../db";
import { Asset } from "../../types";
import type { Asset } from "../../types";
import { getUniqueFilename } from "../../utils/get-unique-filename";
import { getS3Client } from "./client";
@@ -37,7 +37,8 @@ export const uploadToS3 = async ({
);
const imagesFormData = formData.getAll("image") as Array<string>;
const assetsData = imagesFormData.map((dataString) => {
const fontsFormData = formData.getAll("font") as Array<string>;
const assetsData = [...imagesFormData, ...fontsFormData].map((dataString) => {
// @todo validate with zod
return JSON.parse(dataString);
});
@@ -60,12 +61,16 @@ const uploadHandler = async ({
// this has to be a stream that goes directly to s3
// Size check has to happen as you stream and interrupted when size is too big
// Also check if S3 client has an option to check the size limit
const buffer = await toBuffer(file.data);
const data = await toUint8Array(file.data);
if (buffer.byteLength > maxSize) {
if (data.byteLength > maxSize) {
throw new Error(`Asset cannot be bigger than ${maxSize}MB`);
}
if (file.filename === undefined) {
throw new Error("Filename is required");
}
const uniqueFilename = getUniqueFilename(file.filename);
const s3Envs = S3EnvVariables.parse(process.env);
@@ -77,7 +82,7 @@ const uploadHandler = async ({
...ACL,
Bucket: s3Envs.S3_BUCKET,
Key: encodeURIComponent(uniqueFilename),
Body: buffer,
Body: data,
ContentType: file.contentType,
Metadata: {
filename: file.filename || "unnamed",
@@ -88,14 +93,33 @@ const uploadHandler = async ({
AssetsUploadedSuccess.parse(await upload.done());
// @todo fonts
const assetOptions = {
type: "image" as const,
const type = file.contentType.startsWith("image")
? ("image" as const)
: ("font" as const);
const baseAssetOptions = {
name: uniqueFilename,
size: buffer.byteLength,
buffer,
size: data.byteLength,
data,
location: Location.REMOTE,
};
let assetOptions;
if (type === "image") {
assetOptions = {
type,
...baseAssetOptions,
};
} else if (type === "font") {
assetOptions = {
type,
...baseAssetOptions,
};
}
if (assetOptions === undefined) {
throw new Error("Asset type not supported");
}
const assetData = await getAssetData(assetOptions);
+5 -7
View File
@@ -1,12 +1,10 @@
import { Asset as DbAsset } from "@webstudio-is/prisma-client";
type ImageMeta = {
width?: number;
height?: number;
};
import type { FontMeta, ImageMeta } from "./utils/format-asset";
export type Asset = Omit<DbAsset, "meta"> & {
path?: string;
path: string;
status?: "uploading" | "uploaded";
meta: ImageMeta;
meta: ImageMeta | FontMeta;
};
export type AssetType = "image" | "font";
@@ -0,0 +1,16 @@
import type { AssetType } from "../types";
import { FONT_FORMATS } from "../constants";
export const filterByType = <PartialAsset extends { format: string }>(
assets: Array<PartialAsset>,
type: AssetType
) => {
return assets.filter(({ format }: PartialAsset) => {
const isFont = FONT_FORMATS.includes(format as typeof FONT_FORMATS[number]);
if (type === "font") {
return isFont;
}
return isFont === false;
});
};
@@ -2,23 +2,29 @@ import { z } from "zod";
import { Asset as DbAsset } from "@webstudio-is/prisma-client";
import { Asset } from "../types";
import { getAssetPath } from "./get-asset-path";
// @todo to use to distinguish between images and fonts
//const formatTypeMap = {
// woff: "font",
// woff2: "font",
// ttf: "font",
// otf: "font",
// eot: "font",
//} as const;
import { FONT_FORMATS } from "../constants";
import { styles } from "./get-font-data";
const ImageMeta = z.object({
width: z.number().optional(),
height: z.number().optional(),
width: z.number(),
height: z.number(),
});
export type ImageMeta = z.infer<typeof ImageMeta>;
export const FontMeta = z.object({
family: z.string(),
style: z.enum(styles),
weight: z.number(),
});
export type FontMeta = z.infer<typeof FontMeta>;
export const formatAsset = (asset: DbAsset): Asset => {
const meta = ImageMeta.parse(JSON.parse(asset.meta));
const Schema = FONT_FORMATS.includes(
asset.format as typeof FONT_FORMATS[number]
)
? FontMeta
: ImageMeta;
const meta = Schema.parse(JSON.parse(asset.meta));
return {
...asset,
meta,
@@ -1,43 +1,72 @@
import { Location } from "@webstudio-is/prisma-client";
import sharp, { type Metadata } from "sharp";
import sharp from "sharp";
import { FontMeta, ImageMeta } from "./format-asset";
import { getFontData } from "./get-font-data";
//type FontData = any;
type ImageData = {
type BaseData = {
name: string;
size: number;
metadata: Metadata;
location: Location;
format: string;
};
type ImageData = BaseData & {
type: "image";
meta: ImageMeta;
};
type FontData = BaseData & {
type: "font";
meta: FontMeta;
};
export type AssetData = ImageData | FontData;
type BaseAssetOptions = {
name: string;
size: number;
data: Uint8Array;
location: Location;
};
export type AssetData = ImageData; // | FontData;
type AssetOptions = {
type: "image" | "font";
name: string;
size: number;
buffer: Buffer | Uint8Array;
location: Location;
};
type AssetOptions =
| ({
type: "image";
} & BaseAssetOptions)
| ({ type: "font" } & BaseAssetOptions);
export const getAssetData = async (
options: AssetOptions
): Promise<AssetData> => {
let metadata;
if (options.type === "image") {
const sharpImage = sharp(options.buffer);
metadata = await sharpImage.metadata();
}
if (metadata === undefined) {
throw new Error("Could not get metadata for asset");
}
// @todo fonts
return {
const baseData = {
name: options.name,
size: options.size,
location: options.location,
metadata,
};
if (options.type === "image") {
const sharpImage = sharp(options.data);
const { width, height, format } = await sharpImage.metadata();
if (format === undefined) {
throw new Error("Unknown image format");
}
if (width === undefined || height === undefined) {
throw new Error("Unknown image dimensions");
}
return {
...baseData,
type: options.type,
format,
meta: { width, height },
};
}
const { format, ...meta } = getFontData(options.data);
return {
type: options.type,
...baseData,
format,
meta,
};
};
@@ -1,16 +1,16 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { Decimal } from "@prisma/client/runtime";
import { Location } from "@webstudio-is/prisma-client";
import { Asset, Location } from "@webstudio-is/prisma-client";
const commonAsset = {
const commonAsset: Asset = {
id: "sa-546",
width: new Decimal(200),
height: new Decimal(200),
name: "test",
location: Location.FS,
projectId: "id",
size: 2135,
format: "png",
createdAt: new Date(),
alt: "",
description: "",
meta: JSON.stringify({ width: 200, height: 200 }),
};
describe("getAssetPath", () => {
@@ -0,0 +1,52 @@
import { parseSubfamily } from "./get-font-data";
describe("parseSubfamily()", () => {
test("Black Italic", () => {
expect(parseSubfamily("Black Italic")).toEqual({
style: "italic",
weight: 900,
});
});
test("Bold", () => {
expect(parseSubfamily("Bold")).toEqual({
style: "normal",
weight: 700,
});
});
test("Demi Bold Italic", () => {
expect(parseSubfamily("Demi Bold Italic")).toEqual({
style: "italic",
weight: 600,
});
});
test("Light", () => {
expect(parseSubfamily("Light")).toEqual({
style: "normal",
weight: 300,
});
});
test("Extra Light", () => {
expect(parseSubfamily("Extra Light")).toEqual({
style: "normal",
weight: 200,
});
});
test("Extra Light Italic", () => {
expect(parseSubfamily("Extra Light Italic")).toEqual({
style: "italic",
weight: 200,
});
});
test("Heavy Italic", () => {
expect(parseSubfamily("Heavy Italic")).toEqual({
style: "italic",
weight: 900,
});
});
test("Medium Italic", () => {
expect(parseSubfamily("Medium Italic")).toEqual({
style: "italic",
weight: 500,
});
});
});
@@ -0,0 +1,73 @@
import fontkit from "fontkit";
import { FONT_FORMATS } from "../constants";
// @todo sumbit this to definitely typed, they are not up to date
declare module "fontkit" {
export interface Font {
type: string;
getName: (name: string) => string;
}
}
export const styles = ["normal", "italic", "oblique"] as const;
type Style = typeof styles[number];
const weights = {
thin: 100,
hairline: 100,
"extra light": 200,
"ultra light": 200,
light: 300,
normal: 400,
medium: 500,
"semi bold": 600,
"demi bold": 600,
bold: 700,
"extra bold": 800,
"ultra bold": 800,
black: 900,
heavy: 900,
} as const;
type WeightKey = keyof typeof weights;
type WeightValue = typeof weights[WeightKey];
export const parseSubfamily = (subfamily: string) => {
const subfamilyLow = subfamily.toLowerCase();
let style: Style = "normal";
for (const possibleStyle of styles) {
if (subfamilyLow.includes(possibleStyle)) {
style = possibleStyle;
break;
}
}
let weight: WeightValue = weights.normal;
let possibleWeight: WeightKey;
for (possibleWeight in weights) {
if (subfamilyLow.includes(possibleWeight)) {
weight = weights[possibleWeight];
break;
}
}
return { style, weight };
};
export type FontData = {
format: typeof FONT_FORMATS[number];
family: string;
style: Style;
weight: WeightValue;
};
export const getFontData = (data: Uint8Array): FontData => {
const font = fontkit.create(data as Buffer);
const format = font.type.toLowerCase() as FontData["format"];
const family = font.getName("fontFamily");
const parsedSubfamily = parseSubfamily(font.getName("preferredSubfamily"));
return {
format,
family,
...parsedSubfamily,
};
};
@@ -1,12 +1,8 @@
import path from "path";
import ObjectID from "bson-objectid";
export const getUniqueFilename = (filename?: string): string => {
export const getUniqueFilename = (filename: string): string => {
const id = ObjectID().toString();
if (filename === undefined) {
// @todo we need to actually detect the extesion || ".jpeg"
return `${id}.jpeg`;
}
const extension = path.extname(filename);
const name = path.basename(filename, extension);
return `${name}_${id}${extension}`;
@@ -1,7 +0,0 @@
export const toBuffer = async (data: AsyncIterable<Uint8Array>) => {
const result = [];
for await (const chunk of data) {
result.push(chunk);
}
return Buffer.concat(result);
};
@@ -0,0 +1,10 @@
export const toUint8Array = async (data: AsyncIterable<Uint8Array>) => {
const result = [];
for await (const chunk of data) {
for (const byte of chunk) {
result.push(byte);
}
}
return Uint8Array.from(result);
};
+1 -1
View File
@@ -26,7 +26,7 @@ model Asset {
id String @id @default(uuid())
project Project @relation(fields: [projectId], references: [id])
projectId String
format String?
format String
size Int
name String
description String?
+75 -3
View File
@@ -4673,6 +4673,13 @@
regenerator-runtime "^0.13.7"
resolve-from "^5.0.0"
"@swc/helpers@^0.4.2":
version "0.4.11"
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.4.11.tgz#db23a376761b3d31c26502122f349a21b592c8de"
integrity sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw==
dependencies:
tslib "^2.4.0"
"@szmarczak/http-timer@^4.0.5":
version "4.0.6"
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807"
@@ -4848,6 +4855,13 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40"
integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==
"@types/fontkit@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@types/fontkit/-/fontkit-1.8.0.tgz#2c100c8eada6f7403c3b740602f771bea4fd7971"
integrity sha512-hLlxFWmyMkWyJiO/RVc8L5OVxHXzoyH0ZKZsUQkhlKwUdUtwb77u7jjxVtdTpFHaAtfifbA8CQ4/QjcQcLiwDw==
dependencies:
"@types/node" "*"
"@types/fs-extra@^9.0.13":
version "9.0.13"
resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.13.tgz#7594fbae04fe7f1918ce8b3d213f74ff44ac1f45"
@@ -6416,7 +6430,7 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-js@^1.0.2, base64-js@^1.3.1:
base64-js@^1.0.2, base64-js@^1.1.2, base64-js@^1.3.0, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@@ -6607,6 +6621,13 @@ brorand@^1.0.1, brorand@^1.1.0:
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==
brotli@^1.3.2:
version "1.3.3"
resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.3.tgz#7365d8cc00f12cf765d2b2c898716bcf4b604d48"
integrity sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==
dependencies:
base64-js "^1.1.2"
browser-assert@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/browser-assert/-/browser-assert-1.2.1.tgz#9aaa5a2a8c74685c2ae05bfe46efd606f068c200"
@@ -7224,6 +7245,11 @@ clone@^1.0.2:
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
clone@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==
clsx@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702"
@@ -8005,6 +8031,11 @@ detect-port@^1.3.0:
address "^1.0.1"
debug "^2.6.0"
dfa@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/dfa/-/dfa-1.2.0.tgz#96ac3204e2d29c49ea5b57af8d92c2ae12790657"
integrity sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==
diff-sequences@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327"
@@ -9661,6 +9692,21 @@ focus-lock@^0.8.0:
dependencies:
tslib "^1.9.3"
fontkit@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/fontkit/-/fontkit-2.0.2.tgz#ac5384f3ecab8327c6d2ea2e4d384afc544b48fd"
integrity sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA==
dependencies:
"@swc/helpers" "^0.4.2"
brotli "^1.3.2"
clone "^2.1.2"
dfa "^1.2.0"
fast-deep-equal "^3.1.3"
restructure "^3.0.0"
tiny-inflate "^1.0.3"
unicode-properties "^1.4.0"
unicode-trie "^2.0.0"
for-each@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e"
@@ -13839,7 +13885,7 @@ p-try@^2.0.0:
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
pako@~0.2.0:
pako@^0.2.5, pako@~0.2.0:
version "0.2.9"
resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
integrity sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==
@@ -15365,6 +15411,11 @@ restore-cursor@^3.1.0:
onetime "^5.1.0"
signal-exit "^3.0.2"
restructure@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/restructure/-/restructure-3.0.0.tgz#a55031d7ed3314bf585f815836fff9da3d65101d"
integrity sha512-Xj8/MEIhhfj9X2rmD9iJ4Gga9EFqVlpMj3vfLnV2r/Mh5jRMryNV+6lWh9GdJtDBcBSPIqzRdfBQ3wDtNFv/uw==
ret@~0.1.10:
version "0.1.15"
resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
@@ -16646,6 +16697,11 @@ timers-browserify@^2.0.4:
dependencies:
setimmediate "^1.0.4"
tiny-inflate@^1.0.0, tiny-inflate@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4"
integrity sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==
tinycolor2@^1.4.1:
version "1.4.2"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803"
@@ -16841,7 +16897,7 @@ tslib@^1.0.0, tslib@^1.11.1, tslib@^1.13.0, tslib@^1.8.1, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1:
tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
@@ -17133,11 +17189,27 @@ unicode-match-property-value-ecmascript@^2.0.0:
resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz#1a01aa57247c14c568b89775a54938788189a714"
integrity sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==
unicode-properties@^1.4.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/unicode-properties/-/unicode-properties-1.4.1.tgz#96a9cffb7e619a0dc7368c28da27e05fc8f9be5f"
integrity sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==
dependencies:
base64-js "^1.3.0"
unicode-trie "^2.0.0"
unicode-property-aliases-ecmascript@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8"
integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ==
unicode-trie@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/unicode-trie/-/unicode-trie-2.0.0.tgz#8fd8845696e2e14a8b67d78fa9e0dd2cad62fec8"
integrity sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==
dependencies:
pako "^0.2.5"
tiny-inflate "^1.0.0"
unified@9.2.0:
version "9.2.0"
resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8"