Merge branch 'main' into feature-style-panel-text-control
No files matched your search
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["14.x", "16.x"]
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16.x
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build Storybook for Design System
|
||||
run: yarn storybook:ds:build
|
||||
|
||||
- name: Lost Pixel
|
||||
uses: lost-pixel/lost-pixel@v2.20.0
|
||||
env:
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
LOST_PIXEL_CONFIG_DIR: packages/design-system
|
||||
@@ -48,3 +48,4 @@ yarn-error.log*
|
||||
# builds
|
||||
packages/**/lib
|
||||
generated
|
||||
storybook-static
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -1,7 +1,6 @@
|
||||
import { LinksFunction, MetaFunction } from "@remix-run/node";
|
||||
import { Form, useLoaderData } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
Flex,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
TextField,
|
||||
} from "@webstudio-is/design-system";
|
||||
import interStyles from "~/shared/font-faces/inter.css";
|
||||
|
||||
import { GithubIcon, CommitIcon, GoogleIcon } from "@webstudio-is/icons";
|
||||
import { LoginButton } from "./components/login-button";
|
||||
import loginStyles from "./login.css";
|
||||
@@ -86,7 +84,6 @@ export const Login = ({ errorMessage }: { errorMessage: string }) => {
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
size={2}
|
||||
name="secret"
|
||||
type="text"
|
||||
minLength={2}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ChildrenUpdates } from "@webstudio-is/react-sdk";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useDebounce } from "react-use";
|
||||
import {
|
||||
OnChangePlugin as LexicalOnChangePlugin,
|
||||
@@ -14,17 +14,18 @@ export const OnChangePlugin = ({
|
||||
}) => {
|
||||
const [editorState, setEditorState] = useState<EditorState>();
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (editorState === undefined) return;
|
||||
editorState.read(() => {
|
||||
const updates = toUpdates(editorState.toJSON().root);
|
||||
onChange(updates);
|
||||
});
|
||||
},
|
||||
500,
|
||||
[editorState]
|
||||
);
|
||||
const handleChange = useCallback(() => {
|
||||
if (editorState === undefined) return;
|
||||
editorState.read(() => {
|
||||
const updates = toUpdates(editorState.toJSON().root);
|
||||
onChange(updates);
|
||||
});
|
||||
}, [editorState, onChange]);
|
||||
|
||||
// When we quit editing mode, we need to safe again otherwise it can happen before debounce calls.
|
||||
useEffect(() => handleChange);
|
||||
|
||||
useDebounce(handleChange, 1000, [handleChange]);
|
||||
|
||||
return <LexicalOnChangePlugin onChange={setEditorState} />;
|
||||
};
|
||||
@@ -1,18 +1,30 @@
|
||||
import { useState } from "react";
|
||||
import { useInterval } from "react-use";
|
||||
import { publish, useSubscribe } from "~/shared/pubsub";
|
||||
|
||||
declare module "~/shared/pubsub" {
|
||||
export interface PubsubMap {
|
||||
designerReady: undefined;
|
||||
designerReadyAck: undefined;
|
||||
canvasReady: undefined;
|
||||
canvasReadyAck: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const useSubscribeDesignerReady = () => {
|
||||
const [isReady, setIsReady] = useState<boolean>(false);
|
||||
useSubscribe("designerReady", () => {
|
||||
setIsReady(true);
|
||||
publish({ type: "designerReadyAck" });
|
||||
const [isAcknowledged, setIsAcknowledged] = useState(false);
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
// We publish this event to make sure designer is 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: "canvasReady" });
|
||||
},
|
||||
isAcknowledged ? null : 100
|
||||
);
|
||||
|
||||
useSubscribe("canvasReadyAck", () => {
|
||||
setIsAcknowledged(true);
|
||||
});
|
||||
return isReady;
|
||||
|
||||
return isAcknowledged;
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
const config = {
|
||||
previewPath: "/preview",
|
||||
canvasPath: "/canvas",
|
||||
designerPath: "/designer",
|
||||
dashboardPath: "/dashboard",
|
||||
loginPath: "/login",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
insertCriticalCss as insert,
|
||||
getCssText as getCanvasCssText,
|
||||
} from "@webstudio-is/react-sdk";
|
||||
import { getCssText as getDesignerCssText } from "@webstudio-is/design-system";
|
||||
import config from "./config";
|
||||
|
||||
const getCssTextFunctions = {
|
||||
[config.previewPath]: getCanvasCssText,
|
||||
[config.canvasPath]: getCanvasCssText,
|
||||
[config.designerPath]: getDesignerCssText,
|
||||
[config.dashboardPath]: getDesignerCssText,
|
||||
[config.loginPath]: getDesignerCssText,
|
||||
};
|
||||
|
||||
const getCssTextFunction = (url: string) => {
|
||||
const { pathname } = new URL(url);
|
||||
let path: keyof typeof getCssTextFunctions;
|
||||
for (path in getCssTextFunctions) {
|
||||
if (pathname.indexOf(path) === 0) {
|
||||
return getCssTextFunctions[path];
|
||||
}
|
||||
}
|
||||
return getCanvasCssText;
|
||||
};
|
||||
|
||||
/**
|
||||
* This works around the problem with getCssTextfunction being a different one
|
||||
* depending on createStitches() used in each outlet.
|
||||
* @todo find a better way
|
||||
*/
|
||||
export const insertCriticalCss = (markup: string, url: string): string => {
|
||||
return insert(markup, getCssTextFunction(url));
|
||||
};
|
||||
@@ -55,7 +55,6 @@ export const SelectProjectCard = ({
|
||||
<Form method="post">
|
||||
<Flex gap="1">
|
||||
<TextField
|
||||
size={2}
|
||||
state={errors ? "invalid" : undefined}
|
||||
name="project"
|
||||
defaultValue={newProject}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { type Publish, usePublish, useSubscribe } from "~/shared/pubsub";
|
||||
import type { Page, Project } from "@webstudio-is/project";
|
||||
import {
|
||||
type Pages,
|
||||
type Project,
|
||||
utils as projectUtils,
|
||||
} from "@webstudio-is/project";
|
||||
import type { Config } from "~/config";
|
||||
import { Box, type CSS, Flex, Grid } from "@webstudio-is/design-system";
|
||||
import interStyles from "~/shared/font-faces/inter.css";
|
||||
@@ -9,6 +13,9 @@ import { Inspector } from "./features/inspector";
|
||||
import {
|
||||
useAssets,
|
||||
useHoveredInstanceData,
|
||||
usePages,
|
||||
useProject,
|
||||
useCurrentPageId,
|
||||
useSelectedInstanceData,
|
||||
useSyncStatus,
|
||||
} from "./shared/nano-states";
|
||||
@@ -35,7 +42,6 @@ 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 [
|
||||
@@ -73,6 +79,27 @@ const useSetAssets = (assets?: Array<Asset>) => {
|
||||
}, [assets, setAssets]);
|
||||
};
|
||||
|
||||
const useSetProject = (project: Project) => {
|
||||
const [, setProject] = useProject();
|
||||
useEffect(() => {
|
||||
setProject(project);
|
||||
}, [project, setProject]);
|
||||
};
|
||||
|
||||
const useSetPages = (pages: Pages) => {
|
||||
const [, setPages] = usePages();
|
||||
useEffect(() => {
|
||||
setPages(pages);
|
||||
}, [pages, setPages]);
|
||||
};
|
||||
|
||||
const useSetCurrentPageId = (pageId: string) => {
|
||||
const [, setCurrentPageId] = useCurrentPageId();
|
||||
useEffect(() => {
|
||||
setCurrentPageId(pageId);
|
||||
}, [pageId, setCurrentPageId]);
|
||||
};
|
||||
|
||||
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.
|
||||
@@ -80,20 +107,9 @@ 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);
|
||||
export const useSubscribeCanvasReady = (publish: Publish) => {
|
||||
useSubscribe("canvasReady", () => {
|
||||
publish({ type: "canvasReadyAck" });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -241,23 +257,27 @@ const NavigatorPanel = ({ publish, isPreviewMode }: NavigatorPanelProps) => {
|
||||
type DesignerProps = {
|
||||
config: Config;
|
||||
project: Project;
|
||||
page: Page;
|
||||
pages: Pages;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
export const Designer = ({ config, project, page }: DesignerProps) => {
|
||||
export const Designer = ({ config, project, pages, pageId }: DesignerProps) => {
|
||||
useSubscribeSyncStatus();
|
||||
useSubscribeRootInstance();
|
||||
useSubscribeSelectedInstanceData();
|
||||
useSubscribeHoveredInstanceData();
|
||||
useSubscribeBreakpoints();
|
||||
useSetAssets(project.assets);
|
||||
useSetProject(project);
|
||||
useSetPages(pages);
|
||||
useSetCurrentPageId(pageId);
|
||||
const [publish, publishRef] = usePublish();
|
||||
const [isPreviewMode] = useIsPreviewMode();
|
||||
usePublishShortcuts(publish);
|
||||
const onRefReadCanvasWidth = useUpdateCanvasWidth();
|
||||
const { onRef: onRefReadCanvas, onTransitionEnd } = useReadCanvasRect();
|
||||
const [dragAndDropState] = useDragAndDropState();
|
||||
usePublishDesignerReady(publish);
|
||||
useSubscribeCanvasReady(publish);
|
||||
|
||||
const iframeRefCallback = useCallback(
|
||||
(ref) => {
|
||||
@@ -268,6 +288,14 @@ export const Designer = ({ config, project, page }: DesignerProps) => {
|
||||
[publishRef, onRefReadCanvasWidth, onRefReadCanvas]
|
||||
);
|
||||
|
||||
const page = useMemo(() => {
|
||||
const page = projectUtils.pages.findById(pages, pageId);
|
||||
if (page === undefined) {
|
||||
throw new Error(`Page with id ${pageId} not found`);
|
||||
}
|
||||
return page;
|
||||
}, [pages, pageId]);
|
||||
|
||||
return (
|
||||
<ChromeWrapper isPreviewMode={isPreviewMode}>
|
||||
<Topbar
|
||||
@@ -281,7 +309,7 @@ export const Designer = ({ config, project, page }: DesignerProps) => {
|
||||
<Workspace onTransitionEnd={onTransitionEnd} publish={publish}>
|
||||
<CanvasIframe
|
||||
ref={iframeRefCallback}
|
||||
src={`${config.canvasPath}/${project.id}/${page.id}`}
|
||||
src={`${page.path || "/"}?projectId=${project.id}&mode=edit`}
|
||||
pointerEvents={
|
||||
dragAndDropState.isDragging && dragAndDropState.origin === "panel"
|
||||
? "none"
|
||||
@@ -296,7 +324,7 @@ export const Designer = ({ config, project, page }: DesignerProps) => {
|
||||
</Workspace>
|
||||
</Main>
|
||||
<SidePanel gridArea="sidebar" isPreviewMode={isPreviewMode}>
|
||||
<SidebarLeft publish={publish} />
|
||||
<SidebarLeft publish={publish} config={config} />
|
||||
</SidePanel>
|
||||
<NavigatorPanel publish={publish} isPreviewMode={isPreviewMode} />
|
||||
<SidePanel
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSubscribe } from "~/shared/pubsub";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
useSelectedBreakpoint,
|
||||
@@ -32,7 +31,19 @@ export const useUpdateCanvasWidth = () => {
|
||||
setCanvasWidth(Math.max(selectedBreakpoint.minWidth, minWidth));
|
||||
}, [selectedBreakpoint, setCanvasWidth]);
|
||||
|
||||
useSubscribe("canvasWidth", setCanvasWidth);
|
||||
// This fallback is needed for cases when something unexpected loads in the iframe.
|
||||
// In that case the width remains 0, and user is unable to see what has loaded,
|
||||
// in particular any error messages.
|
||||
// The delay is used to make sure we don't set the fallback width too early,
|
||||
// because when canvas loads normally this will cause a jump in the width.
|
||||
useEffect(() => {
|
||||
if (canvasWidth === 0) {
|
||||
const timeout = setTimeout(() => {
|
||||
setCanvasWidth(600);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [canvasWidth, setCanvasWidth]);
|
||||
|
||||
// Set the initial canvas width based on the selected breakpoint upper bound, which starts where the next breakpoint begins.
|
||||
return useCallback(
|
||||
|
||||
@@ -10,19 +10,100 @@ import type { SelectedInstanceData } from "~/shared/canvas-components";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Combobox,
|
||||
ComboboxTextField,
|
||||
ComboboxPopperContent,
|
||||
Grid,
|
||||
TextField,
|
||||
Tooltip,
|
||||
useCombobox,
|
||||
ComboboxPopper,
|
||||
ComboboxPopperContent,
|
||||
ComboboxPopperAnchor,
|
||||
ComboboxListbox,
|
||||
ComboboxListboxItem,
|
||||
IconButton,
|
||||
} from "@webstudio-is/design-system";
|
||||
import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@webstudio-is/icons";
|
||||
import { handleChangePropType, usePropsLogic } from "./use-props-logic";
|
||||
|
||||
type ComboboxProps = {
|
||||
isReadonly: boolean;
|
||||
isInvalid: boolean;
|
||||
items: Array<string>;
|
||||
value: string;
|
||||
onItemSelect: (value: string | null) => void;
|
||||
onSubmit: (value: string) => void;
|
||||
};
|
||||
|
||||
const Combobox = ({
|
||||
isReadonly,
|
||||
isInvalid,
|
||||
items: itemsProp,
|
||||
value,
|
||||
onItemSelect,
|
||||
onSubmit,
|
||||
}: ComboboxProps) => {
|
||||
const {
|
||||
items,
|
||||
getInputProps,
|
||||
getComboboxProps,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
isOpen,
|
||||
} = useCombobox({
|
||||
items: itemsProp,
|
||||
value,
|
||||
itemToString: (item) => item ?? "",
|
||||
onItemSelect,
|
||||
});
|
||||
|
||||
return (
|
||||
<ComboboxPopper>
|
||||
<Box {...getComboboxProps()}>
|
||||
<ComboboxPopperAnchor>
|
||||
<TextField
|
||||
{...getInputProps({
|
||||
onKeyPress: (event) => {
|
||||
if (event.key === "Enter") {
|
||||
onSubmit(event.currentTarget.value);
|
||||
}
|
||||
},
|
||||
})}
|
||||
name="prop"
|
||||
placeholder="Property"
|
||||
readOnly={isReadonly}
|
||||
state={isInvalid ? "invalid" : undefined}
|
||||
suffix={
|
||||
<IconButton {...getToggleButtonProps()}>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</ComboboxPopperAnchor>
|
||||
<ComboboxPopperContent align="start" sideOffset={5}>
|
||||
<ComboboxListbox {...getMenuProps()}>
|
||||
{isOpen &&
|
||||
items.map((item, index) => {
|
||||
return (
|
||||
<ComboboxListboxItem
|
||||
{...getItemProps({ item, index })}
|
||||
key={index}
|
||||
>
|
||||
{item}
|
||||
</ComboboxListboxItem>
|
||||
);
|
||||
})}
|
||||
</ComboboxListbox>
|
||||
</ComboboxPopperContent>
|
||||
</Box>
|
||||
</ComboboxPopper>
|
||||
);
|
||||
};
|
||||
|
||||
type PropertyProps = UserProp & {
|
||||
component: Instance["component"];
|
||||
onChange: handleChangePropType;
|
||||
@@ -33,14 +114,14 @@ const Property = ({
|
||||
id,
|
||||
prop,
|
||||
value,
|
||||
required,
|
||||
required = false,
|
||||
component,
|
||||
onChange,
|
||||
onDelete,
|
||||
}: PropertyProps) => {
|
||||
const meta = componentsMeta[component];
|
||||
const argType = meta.argTypes?.[prop as keyof typeof meta.argTypes];
|
||||
const isInvalidProp =
|
||||
const isInvalid =
|
||||
prop != null &&
|
||||
prop.length > 0 &&
|
||||
typeof argType === "undefined" &&
|
||||
@@ -56,30 +137,21 @@ const Property = ({
|
||||
css={{ gridTemplateColumns: "1fr 1fr auto", alignItems: "center" }}
|
||||
>
|
||||
<Combobox
|
||||
name="prop"
|
||||
placeholder="Property"
|
||||
items={allProps}
|
||||
value={prop}
|
||||
itemToString={(item) => item ?? ""}
|
||||
onItemSelect={(value) => {
|
||||
onChange(id, "prop", value as string);
|
||||
onItemSelect={(value: PropertyProps["value"] | null) => {
|
||||
if (value !== null) {
|
||||
onChange(id, "prop", value);
|
||||
}
|
||||
}}
|
||||
renderTextField={({ inputProps, toggleProps }) => (
|
||||
<ComboboxTextField
|
||||
toggleProps={toggleProps}
|
||||
inputProps={{
|
||||
...inputProps,
|
||||
readOnly: required,
|
||||
state: isInvalidProp ? "invalid" : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderPopperContent={(props) => (
|
||||
<ComboboxPopperContent {...props} align="start" sideOffset={5} />
|
||||
)}
|
||||
onSubmit={(value) => {
|
||||
onChange(id, "prop", value);
|
||||
}}
|
||||
isInvalid={isInvalid}
|
||||
isReadonly={required}
|
||||
/>
|
||||
|
||||
{isInvalidProp ? (
|
||||
{isInvalid ? (
|
||||
<Tooltip content={`Invalid property name: ${prop}`}>
|
||||
<ExclamationTriangleIcon width={12} height={12} />
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as components from "./components";
|
||||
import * as navigator from "./navigator";
|
||||
import * as assets from "./assets";
|
||||
import * as pages from "./pages";
|
||||
|
||||
export const panels = { components, navigator, assets } as const;
|
||||
export const panels = { components, navigator, assets, pages } as const;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pages";
|
||||
@@ -0,0 +1,126 @@
|
||||
import { TreeNodeLabel, TreeNode } from "@webstudio-is/design-system";
|
||||
import { type Publish } from "~/shared/pubsub";
|
||||
import { PageIcon } from "@webstudio-is/icons";
|
||||
import type { TabName } from "../../types";
|
||||
import { Header } from "../../lib/header";
|
||||
import { type Page, type Pages } from "@webstudio-is/project";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { type Config } from "~/config";
|
||||
import {
|
||||
useCurrentPageId,
|
||||
usePages,
|
||||
useProject,
|
||||
} from "~/designer/shared/nano-states";
|
||||
|
||||
type TabContentProps = {
|
||||
onSetActiveTab: (tabName: TabName) => void;
|
||||
publish: Publish;
|
||||
config: Config;
|
||||
};
|
||||
|
||||
type PagesTreeNode =
|
||||
| {
|
||||
// currently used only for root node
|
||||
type: "folder";
|
||||
id: string;
|
||||
children: PagesTreeNode[];
|
||||
}
|
||||
| {
|
||||
type: "page";
|
||||
id: string;
|
||||
data: Page;
|
||||
};
|
||||
|
||||
const toTreeData = (pages: Pages): PagesTreeNode => {
|
||||
return {
|
||||
type: "folder",
|
||||
id: "root",
|
||||
children: [pages.homePage, ...pages.pages].map((data) => ({
|
||||
type: "page",
|
||||
id: data.id,
|
||||
data,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const staticTreeProps = {
|
||||
getItemChildren(node: PagesTreeNode) {
|
||||
if (node.type === "folder") {
|
||||
return node.children;
|
||||
}
|
||||
return [];
|
||||
},
|
||||
getIsExpanded(_node: PagesTreeNode) {
|
||||
return true;
|
||||
},
|
||||
renderItem(props: { data: PagesTreeNode; isSelected: boolean }) {
|
||||
if (props.data.type === "folder") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeNodeLabel
|
||||
isSelected={props.isSelected}
|
||||
text={props.data.data.name}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const Pages = ({
|
||||
onClose,
|
||||
onSelect,
|
||||
selectedPageId,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSelect: (pageId: string) => void;
|
||||
selectedPageId: string;
|
||||
}) => {
|
||||
const [pages] = usePages();
|
||||
const pagesTree = useMemo(() => pages && toTreeData(pages), [pages]);
|
||||
|
||||
if (pagesTree === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Pages" onClose={onClose} />
|
||||
<TreeNode
|
||||
hideRoot
|
||||
selectedItemId={selectedPageId}
|
||||
onSelect={onSelect}
|
||||
itemData={pagesTree}
|
||||
{...staticTreeProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabContent = (props: TabContentProps) => {
|
||||
const [currentPageId] = useCurrentPageId();
|
||||
const [project] = useProject();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const handleSelect = (pageId: string) => {
|
||||
if (project === undefined) {
|
||||
return;
|
||||
}
|
||||
navigate(`${props.config.designerPath}/${project.id}?pageId=${pageId}`);
|
||||
};
|
||||
|
||||
if (currentPageId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pages
|
||||
onClose={() => props.onSetActiveTab("none")}
|
||||
onSelect={handleSelect}
|
||||
selectedPageId={currentPageId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const icon = <PageIcon />;
|
||||
@@ -13,14 +13,16 @@ import { isFeatureEnabled } from "~/shared/feature-flags";
|
||||
import { useClientSettings } from "~/designer/shared/client-settings";
|
||||
import { PANEL_WIDTH } from "~/designer/shared/constants";
|
||||
import { Flex } from "@webstudio-is/design-system";
|
||||
import { Config } from "~/config";
|
||||
|
||||
const none = { TabContent: () => null };
|
||||
|
||||
type SidebarLeftProps = {
|
||||
publish: Publish;
|
||||
config: Config;
|
||||
};
|
||||
|
||||
export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
|
||||
export const SidebarLeft = ({ publish, config }: SidebarLeftProps) => {
|
||||
const [dragAndDropState] = useDragAndDropState();
|
||||
const [activeTab, setActiveTab] = useState<TabName>("none");
|
||||
const { TabContent } = activeTab === "none" ? none : panels[activeTab];
|
||||
@@ -38,6 +40,8 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
|
||||
switch (panel) {
|
||||
case "assets":
|
||||
return isFeatureEnabled("assets");
|
||||
case "pages":
|
||||
return isFeatureEnabled("pages");
|
||||
case "navigator":
|
||||
return clientSettings.navigatorLayout === "docked";
|
||||
}
|
||||
@@ -76,7 +80,11 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<TabContent publish={publish} onSetActiveTab={setActiveTab} />
|
||||
<TabContent
|
||||
publish={publish}
|
||||
onSetActiveTab={setActiveTab}
|
||||
config={config}
|
||||
/>
|
||||
</SidebarTabsContent>
|
||||
</SidebarTabs>
|
||||
</Flex>
|
||||
|
||||
@@ -30,7 +30,11 @@ export const Search = ({ onSearch, selectedInstanceData }: SearchProps) => {
|
||||
type="search"
|
||||
value={search}
|
||||
inputRef={inputRef}
|
||||
prefix={<MagnifyingGlassIcon />}
|
||||
prefix={
|
||||
<IconButton aria-label="Search">
|
||||
<MagnifyingGlassIcon />
|
||||
</IconButton>
|
||||
}
|
||||
suffix={
|
||||
search.length > 0 && (
|
||||
<IconButton
|
||||
|
||||
@@ -9,21 +9,22 @@ import {
|
||||
TextField,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { Share1Icon } from "@webstudio-is/icons";
|
||||
import type { Project, Page } from "@webstudio-is/project";
|
||||
import type { Project } from "@webstudio-is/project";
|
||||
import { useIsShareDialogOpen } from "../../shared/nano-states";
|
||||
|
||||
type ShareButtonProps = {
|
||||
path: string;
|
||||
projectId: Project["id"];
|
||||
pageId: Page["id"];
|
||||
pagePath: string;
|
||||
};
|
||||
|
||||
const Content = ({ path, projectId, pageId }: ShareButtonProps) => {
|
||||
const Content = ({ projectId, pagePath }: ShareButtonProps) => {
|
||||
if (typeof location === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(
|
||||
`${location.protocol}//${location.host}${path}/${projectId}/${pageId}`
|
||||
`${location.protocol}//${location.host}${
|
||||
pagePath || "/"
|
||||
}?projectId=${projectId}&mode=preview`
|
||||
);
|
||||
return (
|
||||
<PopoverContent
|
||||
|
||||
@@ -62,11 +62,7 @@ export const Topbar = ({
|
||||
>
|
||||
<SyncStatus />
|
||||
<PreviewButton publish={publish} />
|
||||
<ShareButton
|
||||
path={config.previewPath}
|
||||
projectId={project.id}
|
||||
pageId={page.id}
|
||||
/>
|
||||
<ShareButton projectId={project.id} pagePath={page.path} />
|
||||
<PublishButton project={project} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "~/shared/canvas-components";
|
||||
import { type SyncStatus } from "~/shared/sync";
|
||||
import { Asset } from "@webstudio-is/asset-uploader";
|
||||
import { type Pages, type Project } from "@webstudio-is/project";
|
||||
|
||||
const selectedInstanceDataContainer = createValueContainer<
|
||||
SelectedInstanceData | undefined
|
||||
@@ -49,3 +50,12 @@ export const useSelectionRect = () => useValue(selectionRectContainer);
|
||||
|
||||
const assetsContainer = createValueContainer<Array<Asset>>([]);
|
||||
export const useAssets = () => useValue(assetsContainer);
|
||||
|
||||
const pagesContainer = createValueContainer<Pages | undefined>();
|
||||
export const usePages = () => useValue(pagesContainer);
|
||||
|
||||
const currentPageIdContainer = createValueContainer<string | undefined>();
|
||||
export const useCurrentPageId = () => useValue(currentPageIdContainer);
|
||||
|
||||
const projectContainer = createValueContainer<Project | undefined>();
|
||||
export const useProject = () => useValue(projectContainer);
|
||||
@@ -1,10 +1,10 @@
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { insertCriticalCss } from "./critical-css";
|
||||
import type { EntryContext } from "@remix-run/node";
|
||||
import * as Sentry from "@sentry/remix";
|
||||
import { initSentry } from "./shared/sentry";
|
||||
import { prisma } from "@webstudio-is/prisma-client";
|
||||
import { handleRequest as handleRequestDesigner } from "./shared/remix";
|
||||
import { handleRequest as handleRequestCanvas } from "@webstudio-is/react-sdk";
|
||||
import { getCanvasRequestParams } from "./shared/router-utils";
|
||||
|
||||
initSentry({
|
||||
integrations: [new Sentry.Integrations.Prisma({ client: prisma })],
|
||||
@@ -16,17 +16,17 @@ export default function handleRequest(
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
let markup = renderToString(
|
||||
<RemixServer context={remixContext} url={request.url} />
|
||||
);
|
||||
let handle = handleRequestDesigner;
|
||||
|
||||
markup = insertCriticalCss(markup, request.url);
|
||||
if (
|
||||
remixContext.matches.some(
|
||||
(match) =>
|
||||
match.route.id === "routes/$" || match.route.id === "routes/index"
|
||||
) &&
|
||||
getCanvasRequestParams(request) !== undefined
|
||||
) {
|
||||
handle = handleRequestCanvas;
|
||||
}
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
responseHeaders.set("Accept-CH", "Sec-CH-Prefers-Color-Scheme");
|
||||
|
||||
return new Response("<!DOCTYPE html>" + markup, {
|
||||
status: responseStatusCode,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
return handle(request, responseStatusCode, responseHeaders, remixContext);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { type LoaderFunction, redirect, MetaFunction } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { InstanceRoot, Root } from "@webstudio-is/react-sdk";
|
||||
import { loadCanvasData, type CanvasData } from "~/shared/db";
|
||||
import config from "~/config";
|
||||
import { db } from "@webstudio-is/project/index.server";
|
||||
import env, { Env } from "~/env.server";
|
||||
import { sentryException } from "~/shared/sentry";
|
||||
import { Canvas } from "~/canvas";
|
||||
import { ErrorMessage } from "~/shared/error";
|
||||
import {
|
||||
type CanvasRouteMode,
|
||||
getCanvasRequestParams,
|
||||
} from "~/shared/router-utils";
|
||||
|
||||
type Data =
|
||||
| (CanvasData & { env: Env; mode: CanvasRouteMode })
|
||||
| { errors: string; env: Env };
|
||||
|
||||
export const meta: MetaFunction = ({ data }: { data: Data }) => {
|
||||
if ("errors" in data) {
|
||||
return { title: "Error" };
|
||||
}
|
||||
const { page } = data;
|
||||
return { title: page.title, ...page.meta };
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({
|
||||
request,
|
||||
}): Promise<Data | Response> => {
|
||||
try {
|
||||
const canvasRequest = getCanvasRequestParams(request);
|
||||
|
||||
if (canvasRequest === undefined) {
|
||||
return redirect(config.dashboardPath);
|
||||
}
|
||||
|
||||
const { mode, pathname } = canvasRequest;
|
||||
|
||||
const project =
|
||||
"projectId" in canvasRequest
|
||||
? await db.project.loadById(canvasRequest.projectId)
|
||||
: await db.project.loadByDomain(canvasRequest.projectDomain);
|
||||
|
||||
if (project === null) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const canvasData = await loadCanvasData(
|
||||
project.id,
|
||||
mode === "published" ? "prod" : "dev",
|
||||
pathname
|
||||
);
|
||||
|
||||
return { ...canvasData, env, mode };
|
||||
} catch (error) {
|
||||
sentryException({ error });
|
||||
return {
|
||||
errors: error instanceof Error ? error.message : String(error),
|
||||
env,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Content = () => {
|
||||
const data = useLoaderData<Data>();
|
||||
|
||||
if ("errors" in data) {
|
||||
return <ErrorMessage message={data.errors} />;
|
||||
}
|
||||
|
||||
const Outlet =
|
||||
data.mode === "edit"
|
||||
? () => <Canvas data={data} />
|
||||
: () => <InstanceRoot data={data} />;
|
||||
|
||||
// @todo This is non-standard for Remix, is there a better way?
|
||||
// Maybe there is a way to tell remix to use the right outlet somehow and avoid passing it?
|
||||
return <Root Outlet={Outlet} />;
|
||||
};
|
||||
|
||||
export default Content;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./$projectId";
|
||||
export { default } from "./$projectId";
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import { Canvas } from "~/canvas";
|
||||
import { loadCanvasData, type CanvasData } from "~/shared/db";
|
||||
import { ErrorMessage } from "~/shared/error";
|
||||
import { sentryException } from "~/shared/sentry";
|
||||
import { Root } from "@webstudio-is/react-sdk";
|
||||
|
||||
type Data = CanvasData | { errors: string };
|
||||
|
||||
export const meta: MetaFunction = ({ data }: { data: Data }) => {
|
||||
if ("errors" in data) {
|
||||
return { title: "Error" };
|
||||
}
|
||||
const { page } = data;
|
||||
return { title: page.title, ...page.meta };
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ params }): Promise<Data> => {
|
||||
try {
|
||||
if (params.projectId === undefined) {
|
||||
throw new Error("Missing projectId");
|
||||
}
|
||||
|
||||
return loadCanvasData(params.projectId, params.pageId);
|
||||
} catch (error) {
|
||||
sentryException({ error });
|
||||
return { errors: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const Outlet = () => {
|
||||
const data = useLoaderData<CanvasData>();
|
||||
return <Canvas data={data} />;
|
||||
};
|
||||
|
||||
const CanvasRoute = () => {
|
||||
const data = useLoaderData<Data>();
|
||||
if ("errors" in data) {
|
||||
return <ErrorMessage message={data.errors} />;
|
||||
}
|
||||
return <Root Outlet={Outlet} />;
|
||||
};
|
||||
|
||||
export default CanvasRoute;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import { Designer } from "~/shared/documents/designer";
|
||||
import { Root } from "~/shared/remix";
|
||||
import env from "~/env.server";
|
||||
import { getThemeData } from "~/shared/theme";
|
||||
|
||||
@@ -14,4 +14,4 @@ export const meta: MetaFunction = () => {
|
||||
return { title: "Webstudio Dashboard" };
|
||||
};
|
||||
|
||||
export default Designer;
|
||||
export default Root;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import { Designer } from "~/shared/documents/designer";
|
||||
import { Root } from "~/shared/remix";
|
||||
import env from "~/env.server";
|
||||
import { getThemeData } from "~/shared/theme";
|
||||
|
||||
@@ -14,4 +14,4 @@ export const meta: MetaFunction = () => {
|
||||
return { title: "Webstudio" };
|
||||
};
|
||||
|
||||
export default Designer;
|
||||
export default Root;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./$projectId";
|
||||
export { default } from "./$projectId";
|
||||
@@ -2,7 +2,7 @@ import { useLoaderData } from "@remix-run/react";
|
||||
import { LoaderFunction } from "@remix-run/node";
|
||||
import { Designer, links } from "~/designer";
|
||||
import { db } from "@webstudio-is/project/index.server";
|
||||
import { type Project, type Page, utils } from "@webstudio-is/project";
|
||||
import { type Project, type Pages } from "@webstudio-is/project";
|
||||
import config from "~/config";
|
||||
import { ErrorMessage } from "~/shared/error";
|
||||
import { action, useAction } from "./_assets";
|
||||
@@ -12,12 +12,16 @@ export { action, links };
|
||||
|
||||
export const loader: LoaderFunction = async ({
|
||||
params,
|
||||
request,
|
||||
}): Promise<Data | Error> => {
|
||||
try {
|
||||
if (params.projectId === undefined) {
|
||||
throw new Error("Project id undefined");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const pageIdParam = url.searchParams.get("pageId");
|
||||
|
||||
const project = await db.project.loadById(params.projectId);
|
||||
|
||||
if (project === null) {
|
||||
@@ -26,16 +30,12 @@ export const loader: LoaderFunction = async ({
|
||||
|
||||
const devBuild = await db.build.loadByProjectId(project.id, "dev");
|
||||
|
||||
const page =
|
||||
params.pageId === undefined
|
||||
? devBuild.pages.homePage
|
||||
: utils.pages.findById(devBuild.pages, params.pageId);
|
||||
|
||||
if (page === undefined) {
|
||||
throw new Error(`Page "${params.pageId}" not found`);
|
||||
}
|
||||
|
||||
return { config, project, page };
|
||||
return {
|
||||
config,
|
||||
project,
|
||||
pages: devBuild.pages,
|
||||
pageId: pageIdParam || devBuild.pages.homePage.id,
|
||||
};
|
||||
} catch (error) {
|
||||
sentryException({ error });
|
||||
return { errors: error instanceof Error ? error.message : String(error) };
|
||||
@@ -45,7 +45,8 @@ export const loader: LoaderFunction = async ({
|
||||
type Data = {
|
||||
config: typeof config;
|
||||
project: Project;
|
||||
page: Page;
|
||||
pages: Pages;
|
||||
pageId: string;
|
||||
};
|
||||
|
||||
type Error = {
|
||||
@@ -55,6 +56,7 @@ type Error = {
|
||||
export const DesignerRoute = () => {
|
||||
const data = useLoaderData<Data | Error>();
|
||||
useAction();
|
||||
|
||||
if ("errors" in data) {
|
||||
return <ErrorMessage message={data.errors} />;
|
||||
}
|
||||
|
||||
@@ -1,87 +1,18 @@
|
||||
import { type LoaderFunction, redirect } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { type Data, InstanceRoot, Root } from "@webstudio-is/react-sdk";
|
||||
import config from "~/config";
|
||||
import { db } from "@webstudio-is/project/index.server";
|
||||
import env, { Env } from "~/env.server";
|
||||
// We want our catch-all route to also handle `/`.
|
||||
// Unfortunately, Remix doesn't do this by default,
|
||||
// see https://github.com/remix-run/remix/issues/2098#issuecomment-1049262218 .
|
||||
// To solve this, we're re-exporting the $.tsx route API in index.tsx
|
||||
|
||||
// @todo all this subdomain logic is very hacky
|
||||
import { LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import CatchAllContnet, {
|
||||
loader as catchAllloader,
|
||||
meta as catchAllmeta,
|
||||
} from "./$";
|
||||
|
||||
type Error = { errors: string };
|
||||
type LoaderReturnProps = Promise<
|
||||
(Data & { env: Env }) | (Error & { env: Env }) | Response
|
||||
>;
|
||||
// We're wrapping functions in order for them to be distinct from the ones in $.tsx.
|
||||
// If they are the same, Remix may get confused, and don't load data on page transitions.
|
||||
|
||||
export const loader: LoaderFunction = async ({
|
||||
request,
|
||||
}): LoaderReturnProps => {
|
||||
const host =
|
||||
request.headers.get("x-forwarded-host") ||
|
||||
request.headers.get("host") ||
|
||||
"";
|
||||
|
||||
const [userDomain, wstdDomain] = host.split(".");
|
||||
// We render the site from a subdomain
|
||||
if (
|
||||
typeof userDomain === "string" &&
|
||||
(wstdDomain === "wstd" || wstdDomain?.includes("localhost"))
|
||||
) {
|
||||
try {
|
||||
const project = await db.project.loadByDomain(userDomain);
|
||||
if (project === null) {
|
||||
throw new Error(`Unknown domain "${userDomain}"`);
|
||||
}
|
||||
|
||||
const prodBuild = await db.build.loadByProjectId(project.id, "prod");
|
||||
|
||||
if (prodBuild === undefined) {
|
||||
throw new Error(`Site is not published`);
|
||||
}
|
||||
|
||||
// @todo: use a correct page rather than homePage
|
||||
|
||||
const [tree, props, breakpoints] = await Promise.all([
|
||||
db.tree.loadById(prodBuild.pages.homePage.treeId),
|
||||
db.props.loadByTreeId(prodBuild.pages.homePage.treeId),
|
||||
db.breakpoints.load(prodBuild.id),
|
||||
]);
|
||||
if (tree === null) {
|
||||
throw new Error(`Tree ${prodBuild.pages.homePage.treeId} not found`);
|
||||
}
|
||||
if (breakpoints === null) {
|
||||
throw new Error(
|
||||
`Breakpoints for tree ${prodBuild.pages.homePage.treeId} not found`
|
||||
);
|
||||
}
|
||||
return { tree, props, breakpoints: breakpoints.values, env };
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return { errors: error.message, env };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(config.dashboardPath);
|
||||
};
|
||||
|
||||
const Index = () => {
|
||||
const data = useLoaderData<Data | Error>();
|
||||
if ("errors" in data) {
|
||||
return <p>{data.errors}</p>;
|
||||
}
|
||||
|
||||
// We render the site from a subdomain
|
||||
if (data.tree && data.props) {
|
||||
const Outlet = () => <InstanceRoot data={data} />;
|
||||
|
||||
// @todo This is non-standard for Remix, is there a better way?
|
||||
// We need to render essentially the preview route but from the index,
|
||||
// so we have to know the layout and the outlet from here.
|
||||
// Maybe there is a way to tell remix to use the right outlet somehow and avoid passing it?
|
||||
return <Root Outlet={Outlet} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default Index;
|
||||
export const meta: MetaFunction = (args) => catchAllmeta(args);
|
||||
export const loader: LoaderFunction = (args) => catchAllloader(args);
|
||||
const Content = () => <CatchAllContnet />;
|
||||
export default Content;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import { Designer } from "~/shared/documents/designer";
|
||||
import { Root } from "~/shared/remix";
|
||||
import env from "~/env.server";
|
||||
import { getThemeData } from "~/shared/theme";
|
||||
|
||||
@@ -14,4 +14,4 @@ export const meta: MetaFunction = () => {
|
||||
return { title: "Webstudio Login" };
|
||||
};
|
||||
|
||||
export default Designer;
|
||||
export default Root;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./$projectId";
|
||||
export { default } from "./$projectId";
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
|
||||
import { loadCanvasData, type CanvasData } from "~/shared/db";
|
||||
import { InstanceRoot, Root } from "@webstudio-is/react-sdk";
|
||||
import { sentryException } from "~/shared/sentry";
|
||||
|
||||
type Data = CanvasData | { errors: string };
|
||||
|
||||
export const meta: MetaFunction = ({ data }: { data: Data }) => {
|
||||
if ("errors" in data) {
|
||||
return { title: "Error" };
|
||||
}
|
||||
const { page } = data;
|
||||
return { title: page.title, ...page.meta };
|
||||
};
|
||||
|
||||
export const loader: LoaderFunction = async ({ params }): Promise<Data> => {
|
||||
try {
|
||||
if (params.projectId === undefined) {
|
||||
throw new Error("Missing projectId");
|
||||
}
|
||||
|
||||
return loadCanvasData(params.projectId, params.pageId);
|
||||
} catch (error) {
|
||||
sentryException({ error });
|
||||
return { errors: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const Outlet = () => {
|
||||
const data = useLoaderData<CanvasData>();
|
||||
return <InstanceRoot data={data} />;
|
||||
};
|
||||
|
||||
const PreviewRoute = () => {
|
||||
const data = useLoaderData<Data>();
|
||||
if ("errors" in data) {
|
||||
return <p>{data.errors}</p>;
|
||||
}
|
||||
return <Root Outlet={Outlet} />;
|
||||
};
|
||||
|
||||
export default PreviewRoute;
|
||||
@@ -1,8 +1,8 @@
|
||||
import { User } from "@webstudio-is/prisma-client";
|
||||
import { Authenticator } from "remix-auth";
|
||||
import { FormStrategy } from "remix-auth-form";
|
||||
import { GitHubStrategy } from "remix-auth-github";
|
||||
import { GoogleStrategy } from "remix-auth-google";
|
||||
import { GitHubStrategy, type GitHubProfile } from "remix-auth-github";
|
||||
import { GoogleStrategy, type GoogleProfile } from "remix-auth-google";
|
||||
import config from "~/config";
|
||||
import * as db from "~/shared/db";
|
||||
import { sessionStorage } from "~/services/session.server";
|
||||
@@ -15,41 +15,57 @@ const url = `${
|
||||
: `http://localhost:${process.env.PORT || 3000}`
|
||||
}${config.authPath}`;
|
||||
|
||||
const github = new GitHubStrategy(
|
||||
{
|
||||
clientID: process.env.GH_CLIENT_ID as string,
|
||||
clientSecret: process.env.GH_CLIENT_SECRET as string,
|
||||
callbackURL: `${url}${config.githubCallbackPath}`,
|
||||
},
|
||||
async ({ profile }) => {
|
||||
const user = await db.user.createOrLoginWithGithub(profile);
|
||||
|
||||
const strategyCallback = async ({
|
||||
profile,
|
||||
}: {
|
||||
profile: GitHubProfile | GoogleProfile;
|
||||
}) => {
|
||||
try {
|
||||
const user = await db.user.createOrLoginWithOAuth(profile);
|
||||
return user;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
sentryException({
|
||||
error,
|
||||
extras: {
|
||||
loginMethod: AUTH_PROVIDERS.LOGIN_DEV,
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
const google = new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
|
||||
callbackURL: `${url}${config.googleCallbackPath}`,
|
||||
},
|
||||
async ({ profile }) => {
|
||||
const user = await db.user.createOrLoginWithGoogle(profile);
|
||||
|
||||
return user;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Create an instance of the authenticator, pass a generic with what
|
||||
// strategies will return and will store in the session
|
||||
export const authenticator = new Authenticator<User>(sessionStorage);
|
||||
export const authenticator = new Authenticator<User>(sessionStorage, {
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
if (process.env.GH_CLIENT_ID && process.env.GH_CLIENT_SECRET) {
|
||||
const github = new GitHubStrategy(
|
||||
{
|
||||
clientID: process.env.GH_CLIENT_ID,
|
||||
clientSecret: process.env.GH_CLIENT_SECRET,
|
||||
callbackURL: `${url}${config.githubCallbackPath}`,
|
||||
},
|
||||
strategyCallback
|
||||
);
|
||||
authenticator.use(github, "github");
|
||||
}
|
||||
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
const google = new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${url}${config.googleCallbackPath}`,
|
||||
},
|
||||
strategyCallback
|
||||
);
|
||||
authenticator.use(google, "google");
|
||||
}
|
||||
|
||||
if (process.env.DEV_LOGIN === "true") {
|
||||
authenticator.use(
|
||||
new FormStrategy(async ({ form }) => {
|
||||
@@ -68,6 +84,7 @@ if (process.env.DEV_LOGIN === "true") {
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,27 +7,28 @@ export type CanvasData = Data & { buildId: Build["id"]; page: Page };
|
||||
|
||||
export const loadCanvasData = async (
|
||||
projectId: Project["id"],
|
||||
pageId?: Page["id"]
|
||||
env: "dev" | "prod",
|
||||
pagePath = ""
|
||||
): Promise<CanvasData> => {
|
||||
const project = await db.project.loadById(projectId);
|
||||
const build =
|
||||
env === "dev"
|
||||
? await db.build.loadByProjectId(projectId, "dev")
|
||||
: await db.build.loadByProjectId(projectId, "prod");
|
||||
|
||||
if (project === null) throw new Error(`Project "${projectId}" not found`);
|
||||
if (build === undefined) {
|
||||
throw new Error("The project is not published");
|
||||
}
|
||||
|
||||
const devBuild = await db.build.loadByProjectId(projectId, "dev");
|
||||
|
||||
const page =
|
||||
pageId === undefined
|
||||
? devBuild.pages.homePage
|
||||
: utils.pages.findById(devBuild.pages, pageId);
|
||||
const page = utils.pages.findByPath(build.pages, pagePath);
|
||||
|
||||
if (page === undefined) {
|
||||
throw new Error(`Page "${pageId}" not found`);
|
||||
throw new Error(`Page "${pagePath}" not found`);
|
||||
}
|
||||
|
||||
const [tree, props, breakpoints] = await Promise.all([
|
||||
db.tree.loadById(page.treeId),
|
||||
db.props.loadByTreeId(page.treeId),
|
||||
db.breakpoints.load(devBuild.id),
|
||||
db.breakpoints.load(build.id),
|
||||
]);
|
||||
|
||||
if (tree === null) {
|
||||
@@ -42,7 +43,7 @@ export const loadCanvasData = async (
|
||||
tree,
|
||||
props,
|
||||
breakpoints: breakpoints.values,
|
||||
buildId: devBuild.id,
|
||||
buildId: build.id,
|
||||
page,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GitHubProfile } from "remix-auth-github";
|
||||
import { GoogleProfile } from "remix-auth-google";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import type { GoogleProfile } from "remix-auth-google";
|
||||
import { prisma, User } from "@webstudio-is/prisma-client";
|
||||
|
||||
export const createDemoUser = async (userId: string) => {
|
||||
@@ -73,26 +73,13 @@ const genericCreateAccount = async (userData: {
|
||||
return newTeam.users[0];
|
||||
};
|
||||
|
||||
export const createOrLoginWithGithub = async (
|
||||
profile: GitHubProfile
|
||||
export const createOrLoginWithOAuth = async (
|
||||
profile: GoogleProfile | GitHubProfile
|
||||
): Promise<User> => {
|
||||
const userData = {
|
||||
email: profile._json.email,
|
||||
email: (profile.emails ?? [])[0]?.value,
|
||||
username: profile.displayName,
|
||||
image: profile._json.avatar_url,
|
||||
provider: profile.provider,
|
||||
};
|
||||
const newUser = await genericCreateAccount(userData);
|
||||
return newUser;
|
||||
};
|
||||
|
||||
export const createOrLoginWithGoogle = async (
|
||||
profile: GoogleProfile
|
||||
): Promise<User> => {
|
||||
const userData = {
|
||||
email: profile._json.email,
|
||||
username: profile.displayName,
|
||||
image: profile._json.picture,
|
||||
image: (profile.photos ?? [])[0]?.value,
|
||||
provider: profile.provider,
|
||||
};
|
||||
const newUser = await genericCreateAccount(userData);
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { publish } from "~/shared/pubsub";
|
||||
|
||||
declare module "~/shared/pubsub" {
|
||||
export interface PubsubMap {
|
||||
canvasWidth: number;
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorMessage = ({ message }: { message: string }) => {
|
||||
useEffect(() => {
|
||||
publish({ type: "canvasWidth", payload: 600 });
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
<h1>Error</h1>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const assets = false;
|
||||
export const example = false;
|
||||
export const pages = false;
|
||||
@@ -0,0 +1 @@
|
||||
export const CRITICAL_CSS_MARKER = "__CRITICAL_CSS__";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import type { EntryContext } from "@remix-run/node";
|
||||
import { insertCriticalCss } from "./insert-critical-css";
|
||||
|
||||
export const handleRequest = (
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) => {
|
||||
const markup = renderToString(
|
||||
<RemixServer context={remixContext} url={request.url} />
|
||||
);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
responseHeaders.set("Accept-CH", "Sec-CH-Prefers-Color-Scheme");
|
||||
|
||||
return new Response("<!DOCTYPE html>" + insertCriticalCss(markup), {
|
||||
status: responseStatusCode,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./handle-request.server";
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,6 @@
|
||||
import { flushCss } from "@webstudio-is/design-system";
|
||||
import { CRITICAL_CSS_MARKER } from "./constants";
|
||||
|
||||
export const insertCriticalCss = (markup: string): string => {
|
||||
return markup.replace(CRITICAL_CSS_MARKER, `<style>${flushCss()}</style>`);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react";
|
||||
import { CriticalCss } from "@webstudio-is/react-sdk";
|
||||
import { Env } from "~/shared/env";
|
||||
import { useThemeProps } from "~/shared/theme";
|
||||
import { CRITICAL_CSS_MARKER } from "./constants";
|
||||
|
||||
export const Designer = () => {
|
||||
export const Root = () => {
|
||||
const themeProps = useThemeProps();
|
||||
return (
|
||||
<html lang="en" {...themeProps}>
|
||||
@@ -12,7 +12,7 @@ export const Designer = () => {
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<CriticalCss />
|
||||
{typeof document === "undefined" ? CRITICAL_CSS_MARKER : null}
|
||||
</head>
|
||||
<body>
|
||||
<Outlet />
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getCanvasRequestParams } from "./canvas-request-params";
|
||||
|
||||
const makeRequest = (url: string, headers: { [key: string]: string }) => ({
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string) => (headers[name] === undefined ? null : headers[name]),
|
||||
},
|
||||
});
|
||||
|
||||
describe("getCanvasRequestParams", () => {
|
||||
test("returns undefined if the request is not an canvas request", () => {
|
||||
expect(
|
||||
getCanvasRequestParams(makeRequest("https://example.com", {}))
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("detects project domain on localhost via `x-forwarded-host` header", () => {
|
||||
expect(
|
||||
getCanvasRequestParams(
|
||||
makeRequest("https://foo.localhost", {
|
||||
"x-forwarded-host": "foo.localhost",
|
||||
})
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"mode": "published",
|
||||
"pathname": "/",
|
||||
"projectDomain": "foo",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test("detects project domain on localhost via `host` header", () => {
|
||||
expect(
|
||||
getCanvasRequestParams(
|
||||
makeRequest("https://foo.localhost", { host: "foo.localhost" })
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"mode": "published",
|
||||
"pathname": "/",
|
||||
"projectDomain": "foo",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test("detects projectId parameter", () => {
|
||||
expect(
|
||||
getCanvasRequestParams(
|
||||
makeRequest("https://foo.localhost?projectId=bar", {})
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"mode": "published",
|
||||
"pathname": "/",
|
||||
"projectId": "bar",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test("detects path", () => {
|
||||
expect(
|
||||
getCanvasRequestParams(
|
||||
makeRequest("https://foo.localhost/some/path?projectId=bar", {})
|
||||
)?.pathname
|
||||
).toMatchInlineSnapshot(`"/some/path"`);
|
||||
});
|
||||
|
||||
test("detects mode", () => {
|
||||
expect(
|
||||
getCanvasRequestParams(
|
||||
makeRequest(
|
||||
"https://foo.localhost/some/path?projectId=bar&mode=edit",
|
||||
{}
|
||||
)
|
||||
)?.mode
|
||||
).toMatchInlineSnapshot(`"edit"`);
|
||||
|
||||
expect(
|
||||
getCanvasRequestParams(
|
||||
makeRequest(
|
||||
"https://foo.localhost/some/path?projectId=bar&mode=preview",
|
||||
{}
|
||||
)
|
||||
)?.mode
|
||||
).toMatchInlineSnapshot(`"preview"`);
|
||||
|
||||
expect(
|
||||
getCanvasRequestParams(
|
||||
makeRequest("https://foo.localhost/some/path?projectId=bar", {})
|
||||
)?.mode
|
||||
).toMatchInlineSnapshot(`"published"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
export type CanvasRouteMode = "edit" | "preview" | "published";
|
||||
|
||||
const modes = ["edit", "preview", "published"] as CanvasRouteMode[];
|
||||
|
||||
export const getCanvasRequestParams = (request: {
|
||||
url: string;
|
||||
headers: { get: (name: string) => string | null };
|
||||
}):
|
||||
| {
|
||||
projectId: string;
|
||||
mode: CanvasRouteMode;
|
||||
pathname: string;
|
||||
}
|
||||
| {
|
||||
projectDomain: string;
|
||||
mode: CanvasRouteMode;
|
||||
pathname: string;
|
||||
}
|
||||
| undefined => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const pathname = url.pathname;
|
||||
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
|
||||
let projectDomain = undefined;
|
||||
if (projectId === null) {
|
||||
// @todo all this subdomain logic is very hacky
|
||||
const host =
|
||||
request.headers.get("x-forwarded-host") ||
|
||||
request.headers.get("host") ||
|
||||
"";
|
||||
const [userDomain, wstdDomain] = host.split(".");
|
||||
if (wstdDomain === "wstd" || wstdDomain?.includes("localhost")) {
|
||||
projectDomain = userDomain;
|
||||
}
|
||||
}
|
||||
|
||||
const modeParam = url.searchParams.get("mode");
|
||||
const mode =
|
||||
modeParam === null ? "published" : modes.find((mode) => mode === modeParam);
|
||||
if (mode === undefined) {
|
||||
throw new Error(`Invalid mode "${modeParam}"`);
|
||||
}
|
||||
|
||||
if (projectId !== null) {
|
||||
return {
|
||||
projectId,
|
||||
mode,
|
||||
pathname,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectDomain !== undefined) {
|
||||
return {
|
||||
projectDomain,
|
||||
mode,
|
||||
pathname,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./canvas-request-params";
|
||||
@@ -15,6 +15,8 @@
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"prepare": "husky install",
|
||||
"storybook": "turbo run storybook:run",
|
||||
"storybook:build": "npm run build && turbo run storybook:build",
|
||||
"storybook:ds:build": "turbo run storybook:build --filter=@webstudio-is/design-system",
|
||||
"migrations": "cd apps/designer && migrations --dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,7 +29,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=7.0.0",
|
||||
"node": ">=14 <=16"
|
||||
"node": "16"
|
||||
},
|
||||
"dependencies": {},
|
||||
"packageManager": "yarn@1.22.19",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { CustomProjectConfig } from "lost-pixel";
|
||||
import * as process from "process";
|
||||
|
||||
export const config: CustomProjectConfig = {
|
||||
storybookShots: {
|
||||
storybookUrl: "packages/design-system/storybook-static",
|
||||
},
|
||||
lostPixelProjectId: "cl8a8xmx714904901m9oce4vhqc",
|
||||
s3: {
|
||||
endPoint: "ams3.digitaloceanspaces.com",
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
bucketName: "beta-seven-turtle-time-blue-narrow",
|
||||
},
|
||||
};
|
||||
@@ -17,13 +17,15 @@
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "^6.5.6",
|
||||
"@storybook/addon-essentials": "^6.5.6",
|
||||
"@storybook/addon-interactions": "^6.5.6",
|
||||
"@storybook/addon-interactions": "^6.5.12",
|
||||
"@storybook/addon-links": "^6.5.6",
|
||||
"@storybook/builder-webpack4": "^6.5.6",
|
||||
"@storybook/jest": "^0.0.10",
|
||||
"@storybook/manager-webpack4": "^6.5.6",
|
||||
"@storybook/react": "^6.5.6",
|
||||
"@storybook/testing-library": "^0.0.11",
|
||||
"@storybook/testing-library": "^0.0.13",
|
||||
"@webstudio-is/jest-config": "*",
|
||||
"lost-pixel": "^2.20.0",
|
||||
"tsup": "^6.1.3",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
@@ -60,6 +62,8 @@
|
||||
"@radix-ui/react-toggle-group": "^1.0.0",
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"@radix-ui/react-use-layout-effect": "^1.0.0",
|
||||
"@react-aria/interactions": "^3.11.0",
|
||||
"@react-aria/utils": "^3.13.3",
|
||||
"@stitches/react": "^1.2.8",
|
||||
"@webstudio-is/icons": "*",
|
||||
"react-hot-toast": "^2.3.0"
|
||||
|
||||
@@ -25,7 +25,7 @@ export const Button = styled("button", {
|
||||
// Custom
|
||||
height: "$5",
|
||||
px: "$2",
|
||||
fontFamily: "$untitled",
|
||||
fontFamily: "$sans",
|
||||
fontSize: "$2",
|
||||
fontWeight: 500,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
|
||||
@@ -1,71 +1,101 @@
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { ComponentStory } from "@storybook/react";
|
||||
import React from "react";
|
||||
import { Combobox, ComboboxTextField } from "./combobox";
|
||||
import { MagnifyingGlassIcon } from "@webstudio-is/icons";
|
||||
import { useCombobox as useDownshiftCombobox } from "downshift";
|
||||
import React, { useCallback } from "react";
|
||||
import { TextField } from "./text-field";
|
||||
import { Combobox, ComboboxListboxItem, useCombobox } from "./combobox";
|
||||
import { Flex } from "./flex";
|
||||
|
||||
export default {
|
||||
component: Combobox,
|
||||
};
|
||||
|
||||
export const Simple: ComponentStory<typeof Combobox> = () => {
|
||||
const items = [
|
||||
{ label: "Apple" },
|
||||
{ label: "Banana" },
|
||||
{ label: "Orange" },
|
||||
] as const;
|
||||
const [value, setValue] = React.useState<typeof items[number] | undefined>();
|
||||
const items = [{ label: "Apple" }, { label: "Banana" }, { label: "Orange" }];
|
||||
const [value, setValue] = React.useState<typeof items[number] | null>(null);
|
||||
return (
|
||||
<Combobox
|
||||
name="fruit"
|
||||
placeholder="Select a fruit"
|
||||
items={items}
|
||||
value={value}
|
||||
onItemSelect={setValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomInput: ComponentStory<typeof Combobox> = () => {
|
||||
const items = [
|
||||
{ label: "Apple", value: "apple", disabled: true },
|
||||
{ label: "Banana", value: "banana" },
|
||||
{ label: "Orange", value: "orange" },
|
||||
];
|
||||
const [value, setValue] = React.useState<typeof items[0] | undefined>(
|
||||
items[0]
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
name="fruit"
|
||||
placeholder="Select a fruit"
|
||||
items={items}
|
||||
value={value}
|
||||
onItemSelect={setValue}
|
||||
onItemHighlight={(item) => {
|
||||
action("Highlight")(item);
|
||||
onItemSelect={(value) => {
|
||||
// If the value is cleared, we revert to previous state
|
||||
if (value !== null) {
|
||||
setValue(value);
|
||||
}
|
||||
}}
|
||||
renderTextField={({ inputProps, toggleProps }) => (
|
||||
<ComboboxTextField
|
||||
toggleProps={toggleProps}
|
||||
inputProps={{
|
||||
...inputProps,
|
||||
onKeyDown: (event) => {
|
||||
inputProps.onKeyDown?.(event);
|
||||
switch (event.key) {
|
||||
case "Enter": {
|
||||
action("Enter")(inputProps.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onBlur: (event) => {
|
||||
inputProps.onBlur?.(event);
|
||||
action("Blur")(inputProps.value);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
itemToString={(item) => item?.label ?? ""}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Complex: ComponentStory<typeof Combobox> = () => {
|
||||
const [value, setValue] = React.useState<string | null>(null);
|
||||
|
||||
const stateReducer = useCallback((state, actionAndChanges) => {
|
||||
const { type, changes } = actionAndChanges;
|
||||
switch (type) {
|
||||
// on item selection.
|
||||
case useDownshiftCombobox.stateChangeTypes.ItemClick:
|
||||
case useDownshiftCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useDownshiftCombobox.stateChangeTypes.InputBlur:
|
||||
case useDownshiftCombobox.stateChangeTypes
|
||||
.ControlledPropUpdatedSelectedItem:
|
||||
return {
|
||||
...changes,
|
||||
// if we have a selected item.
|
||||
...(changes.selectedItem && {
|
||||
// we will set the input value to "" (empty string).
|
||||
inputValue: "",
|
||||
}),
|
||||
};
|
||||
|
||||
// Remove "reset" action
|
||||
case useDownshiftCombobox.stateChangeTypes.InputKeyDownEscape: {
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return changes; // otherwise business as usual.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { items, getComboboxProps, getMenuProps, getItemProps, getInputProps } =
|
||||
useCombobox({
|
||||
items: ["Apple", "Banana", "Orange"],
|
||||
value,
|
||||
itemToString: (item) => item ?? "",
|
||||
stateReducer,
|
||||
onItemSelect: (value) => {
|
||||
setValue(value);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex {...getComboboxProps()} css={{ flexDirection: "column", gap: "$3" }}>
|
||||
<TextField
|
||||
type="search"
|
||||
prefix={<MagnifyingGlassIcon />}
|
||||
{...getInputProps({})}
|
||||
/>
|
||||
<fieldset>
|
||||
<legend>Choose an item</legend>
|
||||
<Flex {...getMenuProps()} css={{ flexDirection: "column" }}>
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<ComboboxListboxItem
|
||||
key={index}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
{item}
|
||||
</ComboboxListboxItem>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</fieldset>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,230 +1,146 @@
|
||||
import {
|
||||
useState,
|
||||
forwardRef,
|
||||
/*
|
||||
useCallback,
|
||||
*/
|
||||
type ComponentProps,
|
||||
type ForwardRefRenderFunction,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { CheckIcon, ChevronDownIcon } from "@webstudio-is/icons";
|
||||
import { Popper, PopperContent, PopperAnchor } from "@radix-ui/react-popper";
|
||||
import { useCombobox, type UseComboboxGetItemPropsOptions } from "downshift";
|
||||
import {
|
||||
DownshiftState,
|
||||
UseComboboxStateChangeOptions,
|
||||
useCombobox as useDownshiftCombobox,
|
||||
} from "downshift";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { styled } from "../stitches.config";
|
||||
import { IconButton } from "./icon-button";
|
||||
import { itemCss } from "./menu";
|
||||
import { panelStyles } from "./panel";
|
||||
import { TextField } from "./text-field";
|
||||
import { Box } from "./box";
|
||||
import { Grid } from "./grid";
|
||||
|
||||
type Label = string;
|
||||
|
||||
type BaseItem = { label: Label; disabled?: boolean } | Label;
|
||||
|
||||
type ComboboxTextFieldProps<Item> = {
|
||||
inputProps: ComponentProps<typeof TextField>;
|
||||
toggleProps: ComponentProps<typeof IconButton>;
|
||||
highlightedItem?: Item;
|
||||
};
|
||||
|
||||
const ComboboxTextFieldBase: ForwardRefRenderFunction<
|
||||
HTMLDivElement,
|
||||
ComboboxTextFieldProps<BaseItem>
|
||||
> = ({ inputProps, toggleProps }, ref) => {
|
||||
return (
|
||||
<TextField
|
||||
ref={ref}
|
||||
suffix={
|
||||
<IconButton {...toggleProps}>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComboboxTextField = forwardRef(ComboboxTextFieldBase);
|
||||
|
||||
ComboboxTextField.displayName = "ComboboxTextField";
|
||||
|
||||
const Listbox = styled("ul", {
|
||||
margin: 0,
|
||||
const Listbox = styled("ul", panelStyles, {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
overflow: "auto",
|
||||
minWidth: 124,
|
||||
maxHeight: 190,
|
||||
py: "$1",
|
||||
backgroundColor: "$colors$slate4",
|
||||
borderRadius: "$1",
|
||||
boxShadow:
|
||||
"0px 2px 7px rgba(0, 0, 0, 0.1), 0px 5px 17px rgba(0, 0, 0, 0.15), inset 0 0 1px 1px $colors$slate1, 0 0 0 1px $colors$slate8",
|
||||
"&:empty": {
|
||||
display: "none",
|
||||
},
|
||||
// @todo need some non-hardcoded value
|
||||
maxHeight: 400,
|
||||
minWidth: 230,
|
||||
});
|
||||
|
||||
const ListboxItem = styled("li", {
|
||||
all: "unset",
|
||||
fontSize: "$2",
|
||||
lineHeight: 1,
|
||||
color: "$hiContrast",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "$5",
|
||||
padding: "0 $2",
|
||||
position: "relative",
|
||||
"&[aria-selected=true]": {
|
||||
backgroundColor: "$blue10",
|
||||
color: "white",
|
||||
},
|
||||
const ListboxItem = styled("li", itemCss, {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
});
|
||||
|
||||
type ListProps<Item> = {
|
||||
containerProps: ComponentProps<typeof Listbox>;
|
||||
items: ReadonlyArray<Item>;
|
||||
getItemProps: (
|
||||
options: UseComboboxGetItemPropsOptions<Item>
|
||||
) => ComponentProps<typeof ListboxItem>;
|
||||
highlightedIndex: number;
|
||||
selectedItem: Item | null;
|
||||
itemToString: (item: Item | null) => string;
|
||||
};
|
||||
|
||||
export const List = <Item extends BaseItem>({
|
||||
containerProps,
|
||||
items,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
itemToString,
|
||||
}: ListProps<Item>) => {
|
||||
export const ListboxItemBase: ForwardRefRenderFunction<
|
||||
HTMLLIElement,
|
||||
ComponentProps<typeof ListboxItem> & {
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
highlighted?: boolean;
|
||||
}
|
||||
> = (props, ref) => {
|
||||
const { disabled, selected, highlighted, children, ...rest } = props;
|
||||
return (
|
||||
<Listbox {...containerProps}>
|
||||
{items.map((item, index) => {
|
||||
const itemProps: Record<string, unknown> = getItemProps({
|
||||
item,
|
||||
index,
|
||||
key: index,
|
||||
...(typeof item === "object" && item.disabled
|
||||
? { "data-disabled": true, disabled: true }
|
||||
: {}),
|
||||
...(highlightedIndex === index ? { "data-found": true } : {}),
|
||||
});
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<ListboxItem {...itemProps}>
|
||||
<Grid align="center" css={{ gridTemplateColumns: "$4 1fr" }}>
|
||||
{selectedItem === item && <CheckIcon />}
|
||||
<Box css={{ gridColumn: 2 }}>{itemToString(item)}</Box>
|
||||
</Grid>
|
||||
</ListboxItem>
|
||||
);
|
||||
})}
|
||||
</Listbox>
|
||||
<ListboxItem
|
||||
ref={ref}
|
||||
{...(disabled ? { "aria-disabled": true, disabled: true } : {})}
|
||||
{...(selected ? { "aria-current": true } : {})}
|
||||
{...rest}
|
||||
>
|
||||
<Grid align="center" css={{ gridTemplateColumns: "$4 1fr" }}>
|
||||
{selected && <CheckIcon />}
|
||||
<Box css={{ gridColumn: 2 }}>{children}</Box>
|
||||
</Grid>
|
||||
</ListboxItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComboboxListbox = Listbox;
|
||||
|
||||
export const ComboboxListboxItem = forwardRef(ListboxItemBase);
|
||||
|
||||
export const ComboboxPopper = Popper;
|
||||
|
||||
export const ComboboxPopperContent = PopperContent;
|
||||
|
||||
type ComboboxProps<Item> = {
|
||||
name: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
items: ReadonlyArray<Item>;
|
||||
value?: Item;
|
||||
onItemSelect?: (value?: Item) => void;
|
||||
onItemHighlight?: (value?: Item) => void;
|
||||
itemToString?: (item?: Item | null) => string;
|
||||
renderTextField?: (
|
||||
props: ComponentProps<typeof ComboboxTextField>
|
||||
) => JSX.Element;
|
||||
renderList?: (props: ListProps<Item>) => JSX.Element;
|
||||
renderPopperContent?: (
|
||||
props: ComponentProps<typeof ComboboxPopperContent>
|
||||
) => JSX.Element;
|
||||
export const ComboboxPopperAnchor = PopperAnchor;
|
||||
|
||||
const useFilter = <Item,>({
|
||||
items,
|
||||
itemToString,
|
||||
}: {
|
||||
items: Array<Item>;
|
||||
itemToString: (item: Item | null) => string;
|
||||
}) => {
|
||||
const [filteredItems, setFilteredItems] = useState<Array<Item>>(items);
|
||||
const cachedItems = useRef(items);
|
||||
|
||||
useEffect(() => {
|
||||
cachedItems.current = items;
|
||||
}, [items]);
|
||||
|
||||
const filter = useCallback(
|
||||
(search?: string) => {
|
||||
const foundItems: Array<Item> = matchSorter(items, search ?? "", {
|
||||
keys: [(item) => itemToString(item)],
|
||||
});
|
||||
setFilteredItems(foundItems);
|
||||
},
|
||||
[itemToString, items]
|
||||
);
|
||||
|
||||
const resetFilter = useCallback(() => {
|
||||
setFilteredItems(cachedItems.current);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
filteredItems,
|
||||
filter,
|
||||
resetFilter,
|
||||
};
|
||||
};
|
||||
|
||||
export const Combobox = <Item extends BaseItem>({
|
||||
type useComboboxProps<Item> = {
|
||||
items: Array<Item>;
|
||||
itemToString: (item: Item | null) => string;
|
||||
value: Item | null; // This is to prevent: "downshift: A component has changed the uncontrolled prop "selectedItem" to be controlled."
|
||||
onItemSelect?: (value: Item | null) => void;
|
||||
onItemHighlight?: (value: Item | null) => void;
|
||||
stateReducer?: (
|
||||
state: DownshiftState<Item>,
|
||||
changes: UseComboboxStateChangeOptions<Item>
|
||||
) => Partial<UseComboboxStateChangeOptions<Item>>;
|
||||
};
|
||||
|
||||
export const useCombobox = <Item,>({
|
||||
items,
|
||||
value,
|
||||
name,
|
||||
placeholder,
|
||||
itemToString = (item) =>
|
||||
item != null && typeof item == "object" && "label" in item
|
||||
? item.label
|
||||
: item ?? "",
|
||||
itemToString,
|
||||
onItemSelect,
|
||||
onItemHighlight,
|
||||
renderTextField = (props) => <ComboboxTextField {...props} />,
|
||||
// IMPORTANT! Without Item passed to list <List<Item> typescript is 10x slower!
|
||||
renderList = (props) => <List<Item> {...props} />,
|
||||
renderPopperContent = (props) => <ComboboxPopperContent {...props} />,
|
||||
}: ComboboxProps<Item>) => {
|
||||
const [foundItems, setFoundItems] = useState(items);
|
||||
/*
|
||||
const stateReducer = useCallback((state, actionAndChanges) => {
|
||||
const { type, changes } = actionAndChanges;
|
||||
switch (type) {
|
||||
// on item selection.
|
||||
case useCombobox.stateChangeTypes.ItemClick:
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEnter:
|
||||
case useCombobox.stateChangeTypes.InputBlur:
|
||||
case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem:
|
||||
return {
|
||||
...changes,
|
||||
// if we had an item selected.
|
||||
...(changes.selectedItem && {
|
||||
// we will set the input value to be empty since we display it using prefix
|
||||
inputValue: "",
|
||||
}),
|
||||
};
|
||||
// Clear input value on escape
|
||||
case useCombobox.stateChangeTypes.InputKeyDownEscape:
|
||||
return {
|
||||
...changes,
|
||||
inputValue: "",
|
||||
selectedItem: null,
|
||||
};
|
||||
// Reset selectedItem when there is selection and we type something
|
||||
case useCombobox.stateChangeTypes.InputChange:
|
||||
return {
|
||||
...changes,
|
||||
selectedItem: null,
|
||||
};
|
||||
default:
|
||||
return changes; // otherwise business as usual.
|
||||
}
|
||||
}, []);
|
||||
*/
|
||||
const {
|
||||
isOpen,
|
||||
getToggleButtonProps,
|
||||
// getLabelProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
getComboboxProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
selectedItem,
|
||||
} = useCombobox({
|
||||
items: foundItems as Item[],
|
||||
selectedItem: value ?? null, // Avoid downshift warning about switching controlled mode
|
||||
/* @todo breaks input
|
||||
stateReducer = (state, { changes }) => changes,
|
||||
}: useComboboxProps<Item>) => {
|
||||
const { filteredItems, filter, resetFilter } = useFilter<Item>({
|
||||
items,
|
||||
itemToString,
|
||||
});
|
||||
|
||||
const downshiftProps = useDownshiftCombobox({
|
||||
items: filteredItems,
|
||||
selectedItem: value, // Prevent downshift warning about switching controlled mode
|
||||
stateReducer,
|
||||
*/
|
||||
itemToString,
|
||||
onInputValueChange({ inputValue }) {
|
||||
const options =
|
||||
typeof items[0] === "object" && "label" in items[0]
|
||||
? { keys: ["label"] }
|
||||
: undefined;
|
||||
const foundItems = matchSorter(items, inputValue ?? "", options);
|
||||
setFoundItems(foundItems);
|
||||
filter(inputValue);
|
||||
},
|
||||
onSelectedItemChange({ selectedItem }) {
|
||||
onItemSelect?.(selectedItem ?? undefined);
|
||||
onItemSelect?.(selectedItem ?? null);
|
||||
},
|
||||
onHighlightedIndexChange({ highlightedIndex }) {
|
||||
if (highlightedIndex !== undefined) {
|
||||
@@ -233,49 +149,97 @@ export const Combobox = <Item extends BaseItem>({
|
||||
},
|
||||
});
|
||||
|
||||
const inputProps: Record<string, unknown> = getInputProps({
|
||||
name,
|
||||
placeholder: selectedItem ? "" : placeholder, // Placeholder should not be visible when we have selected item
|
||||
prefix: itemToString?.(selectedItem ?? undefined),
|
||||
/* @todo breaks input
|
||||
onKeyDown: (event) => {
|
||||
// When we press Backspace and the input is empty,
|
||||
// we should to clear the selection
|
||||
// @todo: it would be much better to handle this in reducer but downshift doesn't handle this event
|
||||
if (event.key === "Backspace" && selectedItem !== null) {
|
||||
onItemSelect?.(undefined);
|
||||
}
|
||||
},
|
||||
*/
|
||||
});
|
||||
const toggleProps: Record<string, unknown> = getToggleButtonProps();
|
||||
const comboboxProps: Record<string, unknown> = getComboboxProps();
|
||||
const menuProps: Record<string, unknown> = getMenuProps();
|
||||
const highlightedItem = foundItems[highlightedIndex];
|
||||
const { isOpen, getItemProps, highlightedIndex, selectedItem } =
|
||||
downshiftProps;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen === false) {
|
||||
resetFilter();
|
||||
}
|
||||
}, [isOpen, resetFilter]);
|
||||
|
||||
const enhancedGetItemProps = useCallback(
|
||||
(options) => {
|
||||
return getItemProps({
|
||||
highlighted: highlightedIndex === options.index,
|
||||
// We need to either deep compare objects here or use itemToString to get primitive types
|
||||
selected: itemToString(selectedItem) === itemToString(options.item),
|
||||
key: options.id,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[getItemProps, highlightedIndex, itemToString, selectedItem]
|
||||
);
|
||||
|
||||
return {
|
||||
...downshiftProps,
|
||||
items: filteredItems,
|
||||
getItemProps: enhancedGetItemProps,
|
||||
};
|
||||
};
|
||||
|
||||
type ComboboxProps<Item> = useComboboxProps<Item> & {
|
||||
name: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export const Combobox = <Item,>({
|
||||
items,
|
||||
value = null,
|
||||
name,
|
||||
placeholder,
|
||||
itemToString,
|
||||
onItemSelect,
|
||||
onItemHighlight,
|
||||
}: ComboboxProps<Item>) => {
|
||||
const {
|
||||
items: foundItems,
|
||||
getInputProps,
|
||||
getComboboxProps,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
isOpen,
|
||||
} = useCombobox({
|
||||
items,
|
||||
value,
|
||||
itemToString,
|
||||
onItemSelect,
|
||||
onItemHighlight,
|
||||
});
|
||||
return (
|
||||
<Popper>
|
||||
<Box {...comboboxProps}>
|
||||
<Box {...getComboboxProps()}>
|
||||
<PopperAnchor>
|
||||
{renderTextField({
|
||||
inputProps,
|
||||
toggleProps,
|
||||
highlightedItem,
|
||||
})}
|
||||
<TextField
|
||||
{...getInputProps({
|
||||
name,
|
||||
placeholder,
|
||||
})}
|
||||
suffix={
|
||||
<IconButton {...getToggleButtonProps()}>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
</PopperAnchor>
|
||||
{renderPopperContent({
|
||||
style: { zIndex: 1 },
|
||||
align: "end",
|
||||
sideOffset: 8,
|
||||
children: renderList({
|
||||
containerProps: menuProps,
|
||||
items: isOpen ? foundItems : [],
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
itemToString,
|
||||
}),
|
||||
})}
|
||||
<PopperContent>
|
||||
<Listbox {...getMenuProps()}>
|
||||
{isOpen &&
|
||||
foundItems.map((item, index) => {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<ComboboxListboxItem
|
||||
key={index}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
{itemToString(item)}
|
||||
</ComboboxListboxItem>
|
||||
);
|
||||
})}
|
||||
</Listbox>
|
||||
</PopperContent>
|
||||
</Box>
|
||||
</Popper>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ export const baseItemCss = css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
fontFamily: "$untitled",
|
||||
fontFamily: "$sans",
|
||||
fontSize: "$1",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
lineHeight: "1",
|
||||
@@ -23,12 +23,12 @@ export const baseItemCss = css({
|
||||
export const itemCss = css(baseItemCss, {
|
||||
position: "relative",
|
||||
color: "$hiContrast",
|
||||
"&:focus, &[data-found]": {
|
||||
"&:focus, &[data-found], &[aria-selected=true]": {
|
||||
outline: "none",
|
||||
backgroundColor: "$blue10",
|
||||
color: "white",
|
||||
},
|
||||
"&[data-disabled]": {
|
||||
"&[data-disabled], &[aria-disabled]": {
|
||||
color: "$slate9",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,6 +66,11 @@ export const useDrag = <DragItemData>(
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (event: Event) => {
|
||||
// Prevent default drag behavior. For example, dragging a link.
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0 || state.current.status !== "idle") {
|
||||
return;
|
||||
@@ -89,6 +94,7 @@ export const useDrag = <DragItemData>(
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerCancel);
|
||||
window.addEventListener("dragstart", handleDragStart);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
@@ -154,6 +160,7 @@ export const useDrag = <DragItemData>(
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerCancel);
|
||||
window.removeEventListener("dragstart", handleDragStart);
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { expect } from "@storybook/jest";
|
||||
import { userEvent, waitFor, within } from "@storybook/testing-library";
|
||||
import React from "react";
|
||||
import { ComponentStory } from "@storybook/react";
|
||||
import { BrushIcon, ChevronDownIcon } from "@webstudio-is/icons";
|
||||
import { RowGapIcon, ChevronDownIcon } from "@webstudio-is/icons";
|
||||
import { Button } from "./button";
|
||||
import { Flex } from "./flex";
|
||||
import { IconButton } from "./icon-button";
|
||||
@@ -11,6 +13,10 @@ import { Text } from "./text";
|
||||
|
||||
export default {
|
||||
component: TextField,
|
||||
argTypes: {
|
||||
onFocus: { action: true },
|
||||
onBlur: { action: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const Default: ComponentStory<typeof TextField> = () => {
|
||||
@@ -30,15 +36,6 @@ export const NativeProps: ComponentStory<typeof TextField> = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Sizes: ComponentStory<typeof TextField> = () => {
|
||||
return (
|
||||
<Flex direction="column" gap={3}>
|
||||
<TextField size={1} />
|
||||
<TextField size={2} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export const Variants: ComponentStory<typeof TextField> = () => {
|
||||
return (
|
||||
<Flex direction="column" gap={3}>
|
||||
@@ -62,7 +59,7 @@ export const PrefixSuffix: ComponentStory<typeof TextField> = () => {
|
||||
return (
|
||||
<Flex direction="column" gap={3}>
|
||||
<TextField
|
||||
prefix={<BrushIcon />}
|
||||
prefix={<RowGapIcon />}
|
||||
suffix={
|
||||
<IconButton>
|
||||
<ChevronDownIcon />
|
||||
@@ -71,25 +68,16 @@ export const PrefixSuffix: ComponentStory<typeof TextField> = () => {
|
||||
/>
|
||||
<TextField
|
||||
state="invalid"
|
||||
prefix={<BrushIcon />}
|
||||
prefix={<RowGapIcon />}
|
||||
suffix={
|
||||
<IconButton>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
size={2}
|
||||
prefix={<BrushIcon />}
|
||||
suffix={
|
||||
<IconButton size={2}>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled
|
||||
prefix={<BrushIcon />}
|
||||
prefix={<RowGapIcon />}
|
||||
suffix={
|
||||
<IconButton>
|
||||
<ChevronDownIcon />
|
||||
@@ -106,7 +94,7 @@ export const Layout: ComponentStory<typeof TextField> = () => {
|
||||
<Flex direction="row" gap={2} css={{ justifyContent: "space-between" }}>
|
||||
<TextField
|
||||
value="Long content comes here and it doesn't wrap"
|
||||
prefix={<BrushIcon />}
|
||||
prefix={<RowGapIcon />}
|
||||
suffix={
|
||||
<IconButton>
|
||||
<ChevronDownIcon />
|
||||
@@ -141,7 +129,7 @@ export const Layout: ComponentStory<typeof TextField> = () => {
|
||||
<TextField
|
||||
id="field"
|
||||
value="Long content comes here and it doesn't wrap"
|
||||
prefix={<BrushIcon />}
|
||||
prefix={<RowGapIcon />}
|
||||
/>
|
||||
</Box>
|
||||
<Box css={{ background: "$muted" }}>
|
||||
@@ -184,3 +172,55 @@ export const Interactive: ComponentStory<typeof TextField> = () => {
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export const FocusEvents: ComponentStory<typeof TextField> = (args) => {
|
||||
return (
|
||||
<label>
|
||||
Focus and blur:
|
||||
<TextField name="focus" suffix={<Button>test</Button>} {...args} />
|
||||
</label>
|
||||
);
|
||||
};
|
||||
FocusEvents.play = async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const el = canvas.getByLabelText("Focus and blur:");
|
||||
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(el).toHaveFocus());
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(args.onBlur).not.toHaveBeenCalled());
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(args.onBlur).toHaveBeenCalled());
|
||||
|
||||
await waitFor(() => expect(args.onFocus).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(args.onBlur).toHaveBeenCalledTimes(1));
|
||||
};
|
||||
|
||||
export const ClickCapture: ComponentStory<typeof TextField> = (args) => {
|
||||
return (
|
||||
<TextField
|
||||
name="click"
|
||||
placeholder="Click on the icon to focus input"
|
||||
prefix={
|
||||
<Flex title="icon">
|
||||
<RowGapIcon />
|
||||
</Flex>
|
||||
}
|
||||
suffix={
|
||||
<IconButton>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
}
|
||||
{...args}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ClickCapture.play = async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByPlaceholderText("Click on the icon to focus input");
|
||||
await userEvent.click(canvas.getByTitle("icon"));
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
await userEvent.click(canvas.getByRole("button"));
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mergeRefs } from "@react-aria/utils";
|
||||
import React from "react";
|
||||
import { useFocusWithin } from "@react-aria/interactions";
|
||||
import { styled } from "../stitches.config";
|
||||
import { Flex } from "./flex";
|
||||
|
||||
@@ -10,6 +12,7 @@ const InputBase = styled("input", {
|
||||
boxSizing: "border-box",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
color: "inherit",
|
||||
margin: "0",
|
||||
padding: "0",
|
||||
flexGrow: 1,
|
||||
@@ -41,7 +44,7 @@ const InputBase = styled("input", {
|
||||
},
|
||||
|
||||
"&:-webkit-autofill::first-line": {
|
||||
fontFamily: "$untitled",
|
||||
fontFamily: "$sans",
|
||||
color: "$hiContrast",
|
||||
},
|
||||
|
||||
@@ -67,9 +70,23 @@ const TextFieldBase = styled("div", {
|
||||
color: "$hiContrast",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
|
||||
px: "$2",
|
||||
borderRadius: "$1",
|
||||
fontFamily: "$sans",
|
||||
fontSize: "$1",
|
||||
height: 28, // @todo waiting for the sizing scale
|
||||
lineHeight: 1,
|
||||
"&[data-has-prefix]": {
|
||||
paddingLeft: 2,
|
||||
},
|
||||
|
||||
"&[data-has-suffix]": {
|
||||
paddingRight: 2,
|
||||
},
|
||||
|
||||
"&:focus-within": {
|
||||
boxShadow:
|
||||
"inset 0px 0px 0px 1px $colors$blue8, 0px 0px 0px 1px $colors$blue8",
|
||||
"inset 0px 0px 0px 1px $colors$blue10, 0px 0px 0px 1px $colors$blue10",
|
||||
},
|
||||
|
||||
"&[aria-disabled=true]": {
|
||||
@@ -84,22 +101,6 @@ const TextFieldBase = styled("div", {
|
||||
},
|
||||
|
||||
variants: {
|
||||
size: {
|
||||
"1": {
|
||||
px: "$1",
|
||||
borderRadius: "$1",
|
||||
fontSize: "$1",
|
||||
height: "$5",
|
||||
lineHeight: "$sizes$5",
|
||||
},
|
||||
"2": {
|
||||
px: "$2",
|
||||
borderRadius: "$2",
|
||||
fontSize: "$3",
|
||||
height: "$6",
|
||||
lineHeight: "$sizes$6",
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
ghost: {
|
||||
boxShadow: "none",
|
||||
@@ -112,7 +113,7 @@ const TextFieldBase = styled("div", {
|
||||
"&:focus": {
|
||||
backgroundColor: "$loContrast",
|
||||
boxShadow:
|
||||
"inset 0px 0px 0px 1px $colors$blue8, 0px 0px 0px 1px $colors$blue8",
|
||||
"inset 0px 0px 0px 1px $colors$blue8, 0px 0px 0px 1px $colors$blue10",
|
||||
},
|
||||
"&:disabled": {
|
||||
backgroundColor: "transparent",
|
||||
@@ -139,14 +140,11 @@ const TextFieldBase = styled("div", {
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "1",
|
||||
},
|
||||
});
|
||||
|
||||
type TextFieldProps = Pick<
|
||||
React.ComponentProps<typeof TextFieldBase>,
|
||||
"size" | "variant" | "state" | "css"
|
||||
"variant" | "state" | "css"
|
||||
> &
|
||||
Omit<React.ComponentProps<"input">, "prefix" | "children"> & {
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
@@ -162,23 +160,49 @@ export const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
|
||||
css,
|
||||
disabled,
|
||||
inputRef,
|
||||
size,
|
||||
state,
|
||||
variant,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...textFieldProps
|
||||
} = props;
|
||||
|
||||
const internalInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const focusInnerInput = React.useCallback(() => {
|
||||
internalInputRef.current?.focus();
|
||||
}, [internalInputRef]);
|
||||
|
||||
const { focusWithinProps } = useFocusWithin({
|
||||
isDisabled: disabled,
|
||||
onFocusWithin: (event) => {
|
||||
// @ts-expect-error Type mismatch from react-aria
|
||||
onFocus?.(event);
|
||||
},
|
||||
onBlurWithin: (event) => {
|
||||
// @ts-expect-error Type mismatch from react-aria
|
||||
onBlur?.(event);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TextFieldBase
|
||||
aria-disabled={disabled}
|
||||
ref={forwardedRef}
|
||||
size={size}
|
||||
state={state}
|
||||
variant={variant}
|
||||
css={css}
|
||||
{...focusWithinProps}
|
||||
{...(prefix && { "data-has-prefix": true })}
|
||||
{...(suffix && { "data-has-suffix": true })}
|
||||
onClickCapture={focusInnerInput}
|
||||
>
|
||||
{/* We want input to be the first element in DOM so it receives the focus first */}
|
||||
<InputBase disabled={disabled} {...textFieldProps} ref={inputRef} />
|
||||
<InputBase
|
||||
disabled={disabled}
|
||||
{...textFieldProps}
|
||||
ref={mergeRefs(internalInputRef, inputRef ?? null)}
|
||||
/>
|
||||
|
||||
{prefix && (
|
||||
<Flex
|
||||
|
||||
@@ -149,12 +149,15 @@ export type TreeNodeProps<Data extends { id: string }> = {
|
||||
level?: number;
|
||||
animate?: boolean;
|
||||
forceHoverStateAtItem?: string;
|
||||
|
||||
hideRoot?: boolean;
|
||||
};
|
||||
|
||||
export const TreeNode = <Data extends { id: string }>({
|
||||
itemData,
|
||||
level = 0,
|
||||
parentIsSelected,
|
||||
hideRoot,
|
||||
...commonProps
|
||||
}: TreeNodeProps<Data>) => {
|
||||
const {
|
||||
@@ -216,42 +219,44 @@ export const TreeNode = <Data extends { id: string }>({
|
||||
onOpenChange={(isOpen) => setIsExpanded?.(itemData, isOpen)}
|
||||
data-drop-target-id={itemData.id}
|
||||
>
|
||||
<ItemWrapper
|
||||
onMouseEnter={() => onMouseEnter?.(itemData)}
|
||||
onMouseLeave={() => onMouseLeave?.(itemData)}
|
||||
isSelected={isSelected}
|
||||
parentIsSelected={parentIsSelected}
|
||||
enableHoverState={forceHoverStateAtItem === undefined}
|
||||
forceHoverState={forceHoverStateAtItem === itemData.id}
|
||||
>
|
||||
{/* We want the main ItemButton to take the entire space,
|
||||
* and then position the collapsible trigger on top of it using absolute positionning.
|
||||
* When user clicks anywhere on a tree item, they should either hit the main button or the trigger.
|
||||
*/}
|
||||
|
||||
<ItemButton
|
||||
type="button"
|
||||
data-drag-item-id={itemData.id}
|
||||
data-item-button-id={itemData.id}
|
||||
onFocus={makeSelected}
|
||||
{hideRoot !== true && (
|
||||
<ItemWrapper
|
||||
onMouseEnter={() => onMouseEnter?.(itemData)}
|
||||
onMouseLeave={() => onMouseLeave?.(itemData)}
|
||||
isSelected={isSelected}
|
||||
parentIsSelected={parentIsSelected}
|
||||
enableHoverState={forceHoverStateAtItem === undefined}
|
||||
forceHoverState={forceHoverStateAtItem === itemData.id}
|
||||
>
|
||||
<NestingLines isSelected={isSelected} level={level} />
|
||||
{isAlwaysExpanded === false && <TriggerPlaceholder />}
|
||||
{renderItem({ data: itemData, isSelected })}
|
||||
</ItemButton>
|
||||
{/* We want the main ItemButton to take the entire space,
|
||||
* and then position the collapsible trigger on top of it using absolute positionning.
|
||||
* When user clicks anywhere on a tree item, they should either hit the main button or the trigger.
|
||||
*/}
|
||||
|
||||
{shouldRenderExpandButton && (
|
||||
<CollapsibleTrigger
|
||||
style={{ left: (level - 1) * INDENT + ITEM_PADDING }}
|
||||
// We don't want a separate focusable control inside a tree item.
|
||||
// tabIndex makes it skipped over when tabbing.
|
||||
// It still can be focused using mouse, but we handle this elsewhere.
|
||||
tabIndex={-1}
|
||||
<ItemButton
|
||||
type="button"
|
||||
data-drag-item-id={itemData.id}
|
||||
data-item-button-id={itemData.id}
|
||||
onFocus={makeSelected}
|
||||
>
|
||||
{isExpanded ? <TriangleDownIcon /> : <TriangleRightIcon />}
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</ItemWrapper>
|
||||
<NestingLines isSelected={isSelected} level={level} />
|
||||
{isAlwaysExpanded === false && <TriggerPlaceholder />}
|
||||
{renderItem({ data: itemData, isSelected })}
|
||||
</ItemButton>
|
||||
|
||||
{shouldRenderExpandButton && (
|
||||
<CollapsibleTrigger
|
||||
style={{ left: (level - 1) * INDENT + ITEM_PADDING }}
|
||||
// We don't want a separate focusable control inside a tree item.
|
||||
// tabIndex makes it skipped over when tabbing.
|
||||
// It still can be focused using mouse, but we handle this elsewhere.
|
||||
tabIndex={-1}
|
||||
>
|
||||
{isExpanded ? <TriangleDownIcon /> : <TriangleRightIcon />}
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</ItemWrapper>
|
||||
)}
|
||||
{isExpandable && (
|
||||
<CollapsibleContent
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
@@ -264,7 +269,7 @@ export const TreeNode = <Data extends { id: string }>({
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
itemData={child}
|
||||
level={level + 1}
|
||||
level={hideRoot ? level : level + 1}
|
||||
parentIsSelected={isSelected || parentIsSelected}
|
||||
{...commonProps}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
export {
|
||||
config,
|
||||
createTheme,
|
||||
css,
|
||||
darkTheme,
|
||||
getCssText,
|
||||
globalCss,
|
||||
keyframes,
|
||||
reset,
|
||||
styled,
|
||||
theme,
|
||||
type CSS,
|
||||
} from "./stitches.config";
|
||||
export * from "./stitches.config";
|
||||
export * from "./components/toast";
|
||||
|
||||
export * as Collapsible from "@radix-ui/react-collapsible";
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
import type * as Stitches from "@stitches/react";
|
||||
export type { VariantProps } from "@stitches/react";
|
||||
|
||||
export const {
|
||||
const {
|
||||
styled,
|
||||
css,
|
||||
theme,
|
||||
@@ -414,6 +414,14 @@ export const {
|
||||
|
||||
export type CSS = Stitches.CSS<typeof config>;
|
||||
|
||||
export { styled, css, theme, globalCss, keyframes, config };
|
||||
|
||||
export const flushCss = () => {
|
||||
const css = getCssText();
|
||||
reset();
|
||||
return css;
|
||||
};
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
colors: {
|
||||
...grayDark,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@webstudio-is/http-client",
|
||||
"version": "0.0.1",
|
||||
"version": "0.1.1",
|
||||
"description": "Webstudio HTTP Client",
|
||||
"author": "Webstudio <github@webstudio.is>",
|
||||
"homepage": "https://webstudio.is",
|
||||
@@ -13,15 +13,16 @@
|
||||
"checks": "yarn typecheck && yarn lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/isomorphic-fetch": "^0.0.36",
|
||||
"@webstudio-is/jest-config": "*",
|
||||
"@webstudio-is/react-sdk": "*",
|
||||
"@webstudio-is/prisma-client": "*",
|
||||
"@webstudio-is/react-sdk": "*",
|
||||
"tsup": "^6.1.3",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"peerDependencies": {},
|
||||
"dependencies": {
|
||||
"node-fetch": "^3.2.10"
|
||||
"isomorphic-fetch": "^3.0.0"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import fetch from "node-fetch";
|
||||
|
||||
if (globalThis.fetch === undefined) {
|
||||
// @ts-expect-error node-fetch doesn't implement a complete spec
|
||||
globalThis.fetch = fetch;
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
import { loadProject } from "./index";
|
||||
|
||||
const existingProject = "ab3619d3-831f-46f8-abb6-609558cffd99";
|
||||
const notPublishedProject = "7ec397c6-b3d0-4967-9073-9d83623fcf8e";
|
||||
const nonExistingProject = "1";
|
||||
const existingProjectId = "ab3619d3-831f-46f8-abb6-609558cffd99";
|
||||
const notPublishedProjectId = "7ec397c6-b3d0-4967-9073-9d83623fcf8e";
|
||||
const apiUrl = "http://localhost:3000";
|
||||
|
||||
describe("getProjectDetails", () => {
|
||||
test("loads existing project", async () => {
|
||||
const response = await loadProject({ projectId: existingProject });
|
||||
expect(response.tree.id).toBeTruthy();
|
||||
});
|
||||
test("loads non-existing project", async () => {
|
||||
const response = await loadProject({
|
||||
projectId: nonExistingProject,
|
||||
apiUrl,
|
||||
projectId: existingProjectId,
|
||||
});
|
||||
expect(response.tree.errors).toBe("Project required");
|
||||
expect(response.tree.id).toBeTruthy();
|
||||
});
|
||||
test("loads not published project", async () => {
|
||||
const response = await loadProject({
|
||||
projectId: notPublishedProject,
|
||||
apiUrl,
|
||||
projectId: notPublishedProjectId,
|
||||
});
|
||||
expect(response.tree.errors).toBe(
|
||||
"Site needs to be published, production tree ID is null."
|
||||
"Project 7ec397c6-b3d0-4967-9073-9d83623fcf8e needs to be published first"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,19 @@
|
||||
import "./fetch-polyfill";
|
||||
import fetch from "isomorphic-fetch";
|
||||
import type { Includes, Project } from "./index.d";
|
||||
|
||||
const protocol = process.env.NODE_ENV === "production" ? "https" : "http";
|
||||
|
||||
export const loadProject = async ({
|
||||
apiUrl,
|
||||
projectId,
|
||||
host = process.env.DESIGNER_HOST || "localhost:3000",
|
||||
include = { tree: true, props: true, breakpoints: true },
|
||||
}: {
|
||||
apiUrl: string;
|
||||
projectId: string;
|
||||
host?: string;
|
||||
include?: Includes<boolean>;
|
||||
}): Promise<Project> => {
|
||||
const baseUrl = new URL("/", `${protocol}://${host}`);
|
||||
if (apiUrl === undefined) {
|
||||
throw new Error("Webstudio API URL is required.");
|
||||
}
|
||||
const baseUrl = new URL(`${apiUrl}`);
|
||||
const treeUrl = new URL(`/rest/tree/${projectId}`, baseUrl);
|
||||
const propsUrl = new URL(`/rest/props/${projectId}`, baseUrl);
|
||||
const breakpointsUrl = new URL(`/rest/breakpoints/${projectId}`, baseUrl);
|
||||
|
||||
@@ -48,3 +48,4 @@ export { TrashIcon } from "./trash";
|
||||
export { SizeIcon } from "./size";
|
||||
export { UploadIcon } from "./upload";
|
||||
export { GearIcon, gearIconCssVars } from "./gear";
|
||||
export * from "./page";
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const PageIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M9.19995 2.16168V4.64C9.19995 4.97603 9.19995 5.14405 9.26535 5.27239C9.32287 5.38529 9.41466 5.47708 9.52756 5.5346C9.6559 5.6 9.82392 5.6 10.16 5.6H11.3991H12.6383M12.8 6.79293V11.12C12.8 12.1281 12.8 12.6321 12.6038 13.0172C12.4312 13.3559 12.1558 13.6312 11.8171 13.8038C11.4321 14 10.928 14 9.91995 14H6.07995C5.07186 14 4.56781 14 4.18277 13.8038C3.84408 13.6312 3.56871 13.3559 3.39614 13.0172C3.19995 12.6321 3.19995 12.1281 3.19995 11.12V4.88C3.19995 3.87191 3.19995 3.36786 3.39614 2.98282C3.56871 2.64413 3.84408 2.36876 4.18277 2.19619C4.56781 2 5.07186 2 6.07995 2H8.00702C8.44728 2 8.66741 2 8.87457 2.04973C9.05823 2.09383 9.23381 2.16656 9.39486 2.26525C9.57651 2.37656 9.73217 2.53222 10.0435 2.84353L11.9564 4.75647C12.2677 5.06778 12.4234 5.22344 12.5347 5.40509C12.6334 5.56614 12.7061 5.74172 12.7502 5.92538C12.8 6.13254 12.8 6.35267 12.8 6.79293Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PageIcon.displayName = "PageIcon";
|
||||
@@ -2,7 +2,7 @@ import { prisma, Build as DbBuild } from "@webstudio-is/prisma-client";
|
||||
import { type Breakpoint } from "@webstudio-is/react-sdk";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import * as db from ".";
|
||||
import { Build, Pages, PagesSchema } from "./types";
|
||||
import { Build, Page, Pages, PagesSchema } from "./types";
|
||||
|
||||
export const parseBuild = (build: DbBuild): Build => {
|
||||
const pages = PagesSchema.parse(JSON.parse(build.pages));
|
||||
@@ -78,21 +78,23 @@ const createPages = async (breakpoints: Array<Breakpoint>) => {
|
||||
});
|
||||
};
|
||||
|
||||
const clonePages = async (source: Pages) => {
|
||||
// @todo: clone pages other than home page
|
||||
|
||||
const treeId = source.homePage.treeId;
|
||||
const clonePage = async (source: Page) => {
|
||||
const treeId = source.treeId;
|
||||
|
||||
const tree = await db.tree.clone(treeId);
|
||||
await db.props.clone({ previousTreeId: treeId, nextTreeId: tree.id });
|
||||
|
||||
return { ...source, id: uuid(), treeId: tree.id };
|
||||
};
|
||||
|
||||
const clonePages = async (source: Pages) => {
|
||||
const clones = [];
|
||||
for (const page of source.pages) {
|
||||
clones.push(await clonePage(page));
|
||||
}
|
||||
return PagesSchema.parse({
|
||||
homePage: {
|
||||
...source.homePage,
|
||||
id: uuid(),
|
||||
treeId: tree.id,
|
||||
},
|
||||
pages: [],
|
||||
homePage: await clonePage(source.homePage),
|
||||
pages: clones,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type Pages, type Page } from "../../db/types";
|
||||
|
||||
export const findByPath = (
|
||||
pages: Pages,
|
||||
pagePath: string
|
||||
): Page | undefined => {
|
||||
if (pagePath === "" || pagePath === "/") {
|
||||
return pages.homePage;
|
||||
}
|
||||
return pages.pages.find((page) => page.path === pagePath);
|
||||
};
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./find-by-id";
|
||||
export * from "./find-by-path";
|
||||
@@ -6,7 +6,7 @@
|
||||
"homepage": "https://webstudio.is",
|
||||
"scripts": {
|
||||
"dev": "tsup src/index.ts --format esm,cjs --watch --out-dir=lib",
|
||||
"build": "yarn build:mdn-data && yarn build:args && yarn build:lib",
|
||||
"build": "yarn build:lib",
|
||||
"build:mdn-data": "tsx ./bin/mdn-data.ts ./src/css",
|
||||
"build:args": "tsx ./bin/generate-arg-types.ts './src/components/*.tsx !./src/**/*.stories.tsx !./src/**/*.ws.tsx'",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./canvas";
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import type { ComponentMeta, ComponentStory } from "@storybook/react";
|
||||
import { Image as ImagePrimitive } from "./image";
|
||||
import argTypes from "./image.props.json";
|
||||
|
||||
export default {
|
||||
title: "Components/Image",
|
||||
component: ImagePrimitive,
|
||||
argTypes,
|
||||
} as ComponentMeta<typeof ImagePrimitive>;
|
||||
|
||||
const Template: ComponentStory<typeof ImagePrimitive> = (args) => (
|
||||
<ImagePrimitive {...args} />
|
||||
);
|
||||
|
||||
export const Image = Template.bind({});
|
||||
|
||||
Image.args = {};
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { forwardRef, type ElementRef, type ComponentProps } from "react";
|
||||
|
||||
const defaultTag = "img";
|
||||
|
||||
type ImageProps = ComponentProps<typeof defaultTag>;
|
||||
|
||||
export const Image = forwardRef<ElementRef<typeof defaultTag>, ImageProps>(
|
||||
(props, ref) => <img {...props} ref={ref} />
|
||||
);
|
||||
|
||||
Image.defaultProps = {
|
||||
src: "",
|
||||
loading: "lazy",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
alt: "",
|
||||
};
|
||||
|
||||
Image.displayName = "Image";
|
||||
|
||||
const imagePlaceholderSvg = `<svg
|
||||
width="140"
|
||||
height="140"
|
||||
viewBox="0 0 600 600"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="600" height="600" fill="#CCCCCC" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M450 170H150C141.716 170 135 176.716 135 185V415C135 423.284 141.716 430 150 430H450C458.284 430 465 423.284 465 415V185C465 176.716 458.284 170 450 170ZM150 145C127.909 145 110 162.909 110 185V415C110 437.091 127.909 455 150 455H450C472.091 455 490 437.091 490 415V185C490 162.909 472.091 145 450 145H150Z"
|
||||
fill="#A2A2A2"
|
||||
/>
|
||||
<path
|
||||
d="M237.135 235.012C237.135 255.723 220.345 272.512 199.635 272.512C178.924 272.512 162.135 255.723 162.135 235.012C162.135 214.301 178.924 197.512 199.635 197.512C220.345 197.512 237.135 214.301 237.135 235.012Z"
|
||||
fill="#A2A2A2"
|
||||
/>
|
||||
<path
|
||||
d="M160 405V367.205L221.609 306.364L256.552 338.628L358.161 234L440 316.043V405H160Z"
|
||||
fill="#A2A2A2"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
// Defining src here prevents analyzer from putting the image into the UI.
|
||||
// @todo it would be better to have the image hosted like a normal image,
|
||||
// the problem is inside sdk bundler doesn't know how to do that.
|
||||
Image.defaultProps.src = `data:image/svg+xml;base64,${btoa(
|
||||
imagePlaceholderSvg
|
||||
)}`;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ImageIcon } from "@webstudio-is/icons";
|
||||
import type { WsComponentMeta } from "./component-type";
|
||||
import { Image } from "./image";
|
||||
|
||||
const meta: WsComponentMeta<typeof Image> = {
|
||||
Icon: ImageIcon,
|
||||
Component: Image,
|
||||
canAcceptChildren: false,
|
||||
isContentEditable: false,
|
||||
isInlineOnly: false,
|
||||
isListed: true,
|
||||
label: "Image",
|
||||
};
|
||||
|
||||
export default meta;
|
||||