Merge branch 'main' into feature-style-panel-flexChild-section

This commit is contained in:
Sultan committed 2022-11-12 11:36:44 +03:00
commit e20e10fd3e
312 files changed
+12623 -3637

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
oleg@webstudio.is.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
@@ -1,47 +0,0 @@
import React from "react";
import { Button, Tooltip } from "@webstudio-is/design-system";
import env from "~/shared/env";
const isPreviewEnvironment = env.VERCEL_ENV === "preview";
export const LoginButton = ({
children,
isDevLogin = false,
enabled,
...props
}: {
children: React.ReactChild;
isDevLogin?: boolean;
onClick?: () => void;
enabled: boolean;
}) => {
const isSocialLoginInPreviewEnvironment =
isPreviewEnvironment && isDevLogin === false;
const showTooltip = isSocialLoginInPreviewEnvironment || enabled === false;
if (showTooltip) {
const content = isSocialLoginInPreviewEnvironment
? "Social login does not work in preview deployments"
: "This login is not configured";
return (
<Tooltip content={content} delayDuration={0}>
<span tabIndex={0} style={{ width: "100%", display: "block" }}>
<Button
{...props}
css={{ width: "100%" }}
size={3}
type="submit"
disabled
>
{children}
</Button>
</span>
</Tooltip>
);
}
return (
<Button {...props} css={{ width: "100%" }} size={3} type="submit">
{children}
</Button>
);
};
+51
View File
@@ -0,0 +1,51 @@
import React from "react";
import { Button, Flex, Tooltip } from "@webstudio-is/design-system";
import env from "~/shared/env";
const isPreviewEnvironment = env.VERCEL_ENV === "preview";
export const LoginButton = ({
children,
isDevLogin = false,
disabled,
icon,
...props
}: {
children: React.ReactChild;
isDevLogin?: boolean;
onClick?: () => void;
disabled: boolean;
icon: JSX.Element;
}) => {
const isSocialLoginInPreviewEnvironment =
isPreviewEnvironment && isDevLogin === false;
const button = (
<Button
{...props}
size={3}
type="submit"
disabled={disabled}
css={{ width: "100%" }}
>
<Flex gap="2" align="center">
<Flex css={{ size: "$spacing$10" }}>{icon}</Flex>
{children}
</Flex>
</Button>
);
if (isSocialLoginInPreviewEnvironment) {
const content = disabled
? "Social login does not work in preview deployments"
: "This login is not configured";
return (
<Tooltip content={content} delayDuration={0}>
{button}
</Tooltip>
);
}
return button;
};
+20 -27
View File
@@ -11,8 +11,9 @@ import {
} 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 { LoginButton } from "./login-button";
import loginStyles from "./login.css";
import { authPath } from "~/shared/router-utils";
export const links: LinksFunction = () => {
return [
@@ -45,42 +46,38 @@ export const Login = ({ errorMessage }: { errorMessage: string }) => {
<Card size={2}>
<Flex direction="column" align="center" gap="3">
<Heading size="2">Login</Heading>
<Flex direction="column" gap="4" css={{ width: 300 }}>
<Flex direction="column" gap="4">
{errorMessage.length ? (
<Text align="center" color="error">
{errorMessage}
</Text>
) : null}
<Flex gap="2" direction="column">
<Form action="/auth/github" method="post">
<LoginButton enabled={data.isGithubEnabled}>
<Flex gap="2" align="center">
<Flex css={{ width: 20, height: 20 }}>
<GithubIcon />
</Flex>
Login with GitHub
</Flex>
<Form action={authPath({ provider: "github" })} method="post">
<LoginButton
disabled={data.isGithubEnabled === false}
icon={<GithubIcon />}
>
Login with GitHub
</LoginButton>
</Form>
<Form action="/auth/google" method="post">
<LoginButton enabled={data.isGoogleEnabled}>
<Flex gap="2" align="center">
<Flex css={{ width: 20, height: 20 }}>
<GoogleIcon />
</Flex>
Login with Google
</Flex>
<Form action={authPath({ provider: "google" })} method="post">
<LoginButton
disabled={data.isGoogleEnabled === false}
icon={<GoogleIcon />}
>
Login with Google
</LoginButton>
</Form>
{data.isDevLogin &&
(isDevLoginOpen ? (
<Flex
as="form"
action="/auth/dev"
action={authPath({ provider: "dev" })}
method="post"
css={{
flexDirection: "row",
gap: "$2",
gap: "$spacing$5",
}}
>
<TextField
@@ -96,16 +93,12 @@ export const Login = ({ errorMessage }: { errorMessage: string }) => {
</Flex>
) : (
<LoginButton
enabled={data.isDevLogin}
disabled={data.isDevLogin === false}
isDevLogin
onClick={() => openDevLogin(true)}
icon={<CommitIcon />}
>
<Flex gap="2" align="center">
<Flex css={{ width: 20, height: 20 }}>
<CommitIcon />
</Flex>
Dev Login
</Flex>
Dev Login
</LoginButton>
))}
</Flex>
@@ -0,0 +1,3 @@
export { TextEditor } from "./text-editor";
// React.lazy requires default export
export { TextEditor as default } from "./text-editor";
@@ -0,0 +1,159 @@
import {
type TextNode,
type ElementNode,
$getRoot,
$createTextNode,
$createParagraphNode,
$createLineBreakNode,
$isTextNode,
$isElementNode,
$isParagraphNode,
$isLineBreakNode,
} from "lexical";
import { $createLinkNode, $isLinkNode } from "@lexical/link";
import type { ChildrenUpdates, Instance } from "@webstudio-is/react-sdk";
import { $isSpanNode, $setNodeSpan } from "./toolbar-connector";
// Map<nodeKey, Instance>
export type Refs = Map<string, Instance>;
const lexicalFormats = [
["bold", "Bold"],
["italic", "Italic"],
["superscript", "Superscript"],
["subscript", "Subscript"],
] as const;
const $writeUpdates = (
node: ElementNode,
updates: ChildrenUpdates,
refs: Refs
) => {
const children = node.getChildren();
for (const child of children) {
if ($isParagraphNode(child)) {
if (updates.length !== 0) {
updates.push("\n");
}
$writeUpdates(child, updates, refs);
}
if ($isLineBreakNode(child)) {
if (updates.length !== 0) {
// @todo should we visually distinct line breaks and paragraphs?
updates.push("\n");
}
}
if ($isLinkNode(child)) {
const id = refs.get(child.getKey())?.id;
const childrenUpdates: ChildrenUpdates = [];
$writeUpdates(child, childrenUpdates, refs);
updates.push({ id, component: "Link", children: childrenUpdates });
}
if ($isTextNode(child)) {
// support nesting bold into italic and vice versa
// considering lexical represents both as single node
// and add ref suffix to distinct styling on one node key
const text = child.getTextContent();
let parentUpdates = updates;
if ($isSpanNode(child)) {
const id = refs.get(`${child.getKey()}:span`)?.id;
const update: ChildrenUpdates[number] = {
id,
component: "Span",
children: [],
};
parentUpdates.push(update);
parentUpdates = update.children;
}
// convert all lexical formats
for (const [format, component] of lexicalFormats) {
if (child.hasFormat(format)) {
const id = refs.get(`${child.getKey()}:${format}`)?.id;
const update: ChildrenUpdates[number] = {
id,
component,
children: [],
};
parentUpdates.push(update);
parentUpdates = update.children;
}
}
parentUpdates.push(text);
}
}
};
export const $convertToUpdates = (refs: Refs) => {
const updates: ChildrenUpdates = [];
const root = $getRoot();
$writeUpdates(root, updates, refs);
return updates;
};
type InstanceChild = string | Instance;
const $writeLexical = (
parent: ElementNode | TextNode,
children: InstanceChild[],
refs: Refs
) => {
for (const child of children) {
// convert text
if (child === "\n" && $isElementNode(parent)) {
const lineBreakNode = $createLineBreakNode();
parent.append(lineBreakNode);
continue;
}
if (typeof child === "string") {
if ($isTextNode(parent)) {
parent.setTextContent(child);
} else {
const textNode = $createTextNode(child);
parent.append(textNode);
}
continue;
}
// convert instances
if (child.component === "Link" && $isElementNode(parent)) {
const linkNode = $createLinkNode("");
refs.set(linkNode.getKey(), child);
parent.append(linkNode);
$writeLexical(linkNode, child.children, refs);
}
if (child.component === "Span") {
let textNode;
if ($isTextNode(parent)) {
textNode = parent;
} else {
textNode = $createTextNode("");
parent.append(textNode);
}
$setNodeSpan(textNode);
refs.set(`${textNode.getKey()}:span`, child);
$writeLexical(textNode, child.children, refs);
}
// convert all lexical formats
for (const [format, component] of lexicalFormats) {
if (child.component === component) {
let textNode;
if ($isTextNode(parent)) {
textNode = parent;
} else {
textNode = $createTextNode("");
parent.append(textNode);
}
textNode.toggleFormat(format);
refs.set(`${textNode.getKey()}:${format}`, child);
$writeLexical(textNode, child.children, refs);
}
}
}
};
export const $convertToLexical = (instance: Instance, refs: Refs) => {
const root = $getRoot();
const p = $createParagraphNode();
root.append(p);
$writeLexical(p, instance.children, refs);
};
@@ -0,0 +1,132 @@
import { useState } from "react";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import type { ComponentStory, ComponentMeta } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { Box } from "@webstudio-is/design-system";
import { useSubscribe, publish } from "~/shared/pubsub";
import { createInstance } from "~/shared/tree-utils";
import type { TextToolbarState } from "~/designer/shared/nano-states";
import { TextEditor } from "./text-editor";
export default {
component: TextEditor,
title: "Text Editor 2",
} as ComponentMeta<typeof TextEditor>;
type Format =
| "bold"
| "italic"
| "superscript"
| "subscript"
| "link"
| "span"
| "clear";
export const Basic: ComponentStory<typeof TextEditor> = ({ onChange }) => {
const [state, setState] = useState<null | TextToolbarState>(null);
useSubscribe("showTextToolbar", (event) => {
setState(event);
});
useSubscribe("hideTextToolbar", () => {
setState(null);
});
const setFormat = (type: Format) => {
publish({ type: "formatTextToolbar", payload: type });
};
return (
<div>
<button
disabled={state == null}
style={{ fontWeight: state?.isBold ? "bold" : "normal" }}
onClick={() => setFormat("bold")}
>
Bold
</button>
<button
disabled={state == null}
style={{ fontWeight: state?.isItalic ? "bold" : "normal" }}
onClick={() => setFormat("italic")}
>
Italic
</button>
<button
disabled={state == null}
style={{ fontWeight: state?.isSuperscript ? "bold" : "normal" }}
onClick={() => setFormat("superscript")}
>
Superscript
</button>
<button
disabled={state == null}
style={{ fontWeight: state?.isSubscript ? "bold" : "normal" }}
onClick={() => setFormat("subscript")}
>
Subscript
</button>
<button
disabled={state == null}
style={{ fontWeight: state?.isLink ? "bold" : "normal" }}
onClick={() => setFormat("link")}
>
Link
</button>
<button
disabled={state == null}
style={{ fontWeight: state?.isSpan ? "bold" : "normal" }}
onClick={() => setFormat("span")}
>
Span
</button>
<button
disabled={state == null}
style={{ fontWeight: "normal" }}
onClick={() => setFormat("clear")}
>
Clear
</button>
<Box
css={{
"& > div": {
padding: "0 $spacing$5",
border: "1px solid #999",
color: "black",
},
}}
>
<TextEditor
instance={createInstance({
component: "TextBlock",
children: [
"Pragraph you can edit ",
createInstance({
component: "Bold",
children: ["very bold text "],
}),
createInstance({
component: "Bold",
children: [
createInstance({
component: "Italic",
children: ["with small italic"],
}),
],
}),
createInstance({
component: "Bold",
children: [" subtext"],
}),
],
})}
contentEditable={<ContentEditable />}
onChange={onChange}
/>
</Box>
</div>
);
};
Basic.args = {
onChange: action("onChange"),
};
@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import type { LexicalEditor } from "lexical";
import { LinkNode } from "@lexical/link";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
import type { ChildrenUpdates, Instance } from "@webstudio-is/react-sdk";
import { toCss, css } from "@webstudio-is/react-sdk";
import { useBreakpoints } from "~/shared/nano-states";
import { ToolbarConnectorPlugin } from "./toolbar-connector";
import { type Refs, $convertToLexical, $convertToUpdates } from "./interop";
const NodeConnector = ({
editor,
nodeKey,
instance,
}: {
editor: LexicalEditor;
nodeKey: string;
instance: Instance;
}) => {
const [breakpoints] = useBreakpoints();
useEffect(() => {
// extract key from stored key:style format
const [key] = nodeKey.split(":");
const element = editor.getElementByKey(key);
if (element) {
const className = css(toCss(instance.cssRules, breakpoints))().toString();
// assume only styles are important while editing text
element.className = className;
}
}, [editor, nodeKey, instance, breakpoints]);
return null;
};
const NodesConnectorPlugin = ({ refs }: { refs: Refs }) => {
const [editor] = useLexicalComposerContext();
return (
<>
{Array.from(refs).map(([nodeKey, instance]) => (
<NodeConnector
key={nodeKey}
editor={editor}
nodeKey={nodeKey}
instance={instance}
/>
))}
</>
);
};
const onError = (error: Error) => {
throw error;
};
type TextEditorProps = {
instance: Instance;
contentEditable: JSX.Element;
onChange: (updates: ChildrenUpdates) => void;
};
export const TextEditor = ({
instance,
contentEditable,
onChange,
}: TextEditorProps) => {
// store references separately because lexical nodes
// cannot store custom data
// Map<nodeKey, Instance>
const [refs] = useState<Refs>(() => new Map());
const initialConfig = {
namespace: "WsTextEditor",
editorState: () => {
// text editor is unmounted when change properties in side panel
// so assume new nodes don't need to preserve instance id
// and store only initial references
$convertToLexical(instance, refs);
},
nodes: [LinkNode],
onError,
};
return (
<LexicalComposer initialConfig={initialConfig}>
<ToolbarConnectorPlugin />
<NodesConnectorPlugin refs={refs} />
<RichTextPlugin
ErrorBoundary={LexicalErrorBoundary}
contentEditable={contentEditable}
placeholder=""
/>
<LinkPlugin />
<HistoryPlugin />
<OnChangePlugin
ignoreSelectionChange={true}
onChange={(editorState) => {
editorState.read(() => {
onChange($convertToUpdates(refs));
});
}}
/>
</LexicalComposer>
);
};
@@ -0,0 +1,186 @@
import { useCallback, useEffect } from "react";
import {
type RangeSelection,
type TextNode,
$getSelection,
$isRangeSelection,
$isTextNode,
FORMAT_TEXT_COMMAND,
SELECTION_CHANGE_COMMAND,
COMMAND_PRIORITY_LOW,
} from "lexical";
import { $getNearestNodeOfType } from "@lexical/utils";
import { $patchStyleText } from "@lexical/selection";
import { LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useSubscribe, publish } from "~/shared/pubsub";
const spanTriggerName = "--style-node-trigger";
export const $isSpanNode = (node: TextNode) => {
return node.getStyle().includes(spanTriggerName);
};
export const $setNodeSpan = (node: TextNode) => {
return node.setStyle(`${spanTriggerName}:;`);
};
const $getSpanNodes = (selection: RangeSelection) => {
const nodes = selection.getNodes();
const spans: TextNode[] = [];
// check each TextNode within selection for existing span nodes
for (const node of nodes) {
if ($isTextNode(node) && $isSpanNode(node)) {
spans.push(node);
}
}
return spans;
};
const $toggleSpan = () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const spans = $getSpanNodes(selection);
if (spans.length === 0) {
// lexical creates separate text node when style property do not match
$patchStyleText(selection, {
[spanTriggerName]: "",
});
} else {
// clear span nodes style
for (const node of spans) {
node.setStyle("");
}
}
}
};
const $clearText = () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// split nodes by selection and mark with style
$patchStyleText(selection, {
"--clear-selection-trigger": "",
});
// recompute selection to get new splitted nodes
const newSelection = $getSelection();
if ($isRangeSelection(newSelection)) {
for (const node of selection.getNodes()) {
if ($isTextNode(node)) {
node.setFormat(0);
node.setStyle("");
}
}
}
}
};
const $isSelectedLink = (selection: RangeSelection) => {
const [selectedNode] = selection.getNodes();
return $getNearestNodeOfType(selectedNode, LinkNode) != null;
};
export const ToolbarConnectorPlugin = () => {
const [editor] = useLexicalComposerContext();
// control toolbar state on data or selection updates
const updateToolbar = useCallback(() => {
const selection = $getSelection();
const nativeSelection = window.getSelection();
if (
$isRangeSelection(selection) &&
selection.getTextContent().length !== 0 &&
nativeSelection != null
) {
const domRange = nativeSelection.getRangeAt(0);
const selectionRect = domRange.getBoundingClientRect();
const isBold = selection.hasFormat("bold");
const isItalic = selection.hasFormat("italic");
const isSuperscript = selection.hasFormat("superscript");
const isSubscript = selection.hasFormat("subscript");
const isLink = $isSelectedLink(selection);
const isSpan = $getSpanNodes(selection).length !== 0;
publish({
type: "showTextToolbar",
payload: {
selectionRect,
isBold,
isItalic,
isSuperscript,
isSubscript,
isLink,
isSpan,
},
});
} else {
publish({ type: "hideTextToolbar" });
}
}, []);
useEffect(() => {
// hide toolbar when editor is unmounted
return () => {
publish({ type: "hideTextToolbar" });
};
}, []);
useEffect(() => {
return editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
updateToolbar();
return false;
},
COMMAND_PRIORITY_LOW
);
}, [editor, updateToolbar]);
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar();
});
});
}, [editor, updateToolbar]);
// dispatch commands sent from toolbar
useSubscribe("formatTextToolbar", (type) => {
if (type === "bold") {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
}
if (type === "italic") {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
}
if (type === "superscript") {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "superscript");
}
if (type === "subscript") {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "subscript");
}
if (type === "link") {
const editorState = editor.getEditorState();
let isLink = false;
editorState.read(() => {
const selection = $getSelection();
isLink = $isRangeSelection(selection) && $isSelectedLink(selection);
});
if (isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
}
}
if (type === "span") {
editor.update(() => {
$toggleSpan();
});
}
if (type === "clear") {
editor.update(() => {
$clearText();
});
}
});
return null;
};
@@ -1,9 +0,0 @@
import { InstanceNode } from "./nodes/node-instance";
export const config = {
namespace: "WsTextEditor",
onError(error: Error) {
throw error;
},
nodes: [InstanceNode],
};
@@ -1,22 +0,0 @@
import { memo, lazy, Suspense } from "react";
import type { EditorProps } from "./editor";
const EditorLazy = lazy(() => import("./editor"));
type EditorWithSuspenseProps = EditorProps & {
fallback: JSX.Element;
};
const EditorWithSuspense = ({
fallback,
...props
}: EditorWithSuspenseProps) => {
return (
<Suspense fallback={fallback}>
<EditorLazy {...props} />
</Suspense>
);
};
// Prevent rerender because in editing mode Editor controls the node.
export const Editor = memo(EditorWithSuspense, () => true);
@@ -1,57 +0,0 @@
import type { ChildrenUpdates, Instance } from "@webstudio-is/react-sdk";
import {
RichTextPlugin,
HistoryPlugin,
LexicalComposer,
useLexicalComposerContext,
} from "./lexical";
import { InstancePlugin } from "./plugins/plugin-instance";
import { ToolbarConnectorPlugin } from "./plugins/plugin-toolbar-connector";
import { OnChangePlugin } from "./plugins/plugin-on-change";
import { config } from "./config";
import { useCallback } from "react";
type InstanceRenderProps = {
ref: (element: HTMLElement | null) => void;
contentEditable: boolean;
};
export type EditorProps = {
renderInstance: (renderProps: InstanceRenderProps) => JSX.Element;
instance: Instance;
onChange: (updates: ChildrenUpdates) => void;
};
const useSetElement = () => {
const [editor] = useLexicalComposerContext();
return useCallback(
(element) => {
editor.setRootElement(element);
},
[editor]
);
};
const Editor = ({ instance, renderInstance, onChange }: EditorProps) => {
const ref = useSetElement();
const editable = renderInstance({ ref, contentEditable: true });
return (
<>
<RichTextPlugin contentEditable={editable} placeholder="" />
<OnChangePlugin onChange={onChange} />
<HistoryPlugin />
<InstancePlugin>{instance.children}</InstancePlugin>
<ToolbarConnectorPlugin />
</>
);
};
const EditorConfigured = (props: EditorProps) => {
return (
<LexicalComposer initialConfig={config}>
<Editor {...props} />
</LexicalComposer>
);
};
export default EditorConfigured;
@@ -1,16 +0,0 @@
import type { ComponentStory, ComponentMeta } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { ExampleTextEditor } from "./example-text-editor";
export default {
title: "Text Editor",
component: ExampleTextEditor,
} as ComponentMeta<typeof ExampleTextEditor>;
export const TextEditor: ComponentStory<typeof ExampleTextEditor> = (args) => {
return <ExampleTextEditor {...args} />;
};
TextEditor.args = {
onChange: action("onChange"),
};
@@ -1,147 +0,0 @@
import {
type ChildrenUpdates,
useAllUserProps,
type Instance,
} from "@webstudio-is/react-sdk";
import { createInstance } from "~/shared/tree-utils";
import { Box, Button } from "@webstudio-is/design-system";
import { LexicalComposer, ContentEditable } from "./lexical";
import { TreeViewPlugin } from "./plugins/tree-view-plugin";
import { config } from "./config";
import Editor from "./editor";
import { publish } from "~/shared/pubsub";
declare module "~/shared/pubsub" {
export interface PubsubMap {
insertInlineInstance: Instance;
}
}
type ExampleTextEditorProps = {
onChange: (state: ChildrenUpdates) => void;
};
const Menu = () => {
const [, setAllUserProps] = useAllUserProps();
return (
<Box
css={{
margin: "$2",
display: "flex",
gap: "$2",
}}
>
<Button
onClick={() => {
publish({
type: "insertInlineInstance",
payload: createInstance({ component: "Bold" }),
});
}}
>
Bold
</Button>
<Button
onClick={() => {
publish({
type: "insertInlineInstance",
payload: createInstance({ component: "Italic" }),
});
}}
>
Italic
</Button>
<Button
onClick={() => {
const instance = createInstance({ component: "Link" });
publish({
type: "insertInlineInstance",
payload: instance,
});
const url = prompt("Enter url");
if (url === null) return;
setAllUserProps({
[instance.id]: {
id: "1",
instanceId: instance.id,
treeId: "3",
props: [
{
id: "4",
prop: "href",
value: url,
},
],
},
});
}}
>
Link
</Button>
</Box>
);
};
export const ExampleTextEditor = ({ onChange }: ExampleTextEditorProps) => {
return (
<>
<Menu />
<LexicalComposer initialConfig={config}>
<Box
css={{
background: "#fff",
borderRadius: 2,
maxWidth: 600,
color: "#000",
position: "relative",
lineHeight: 1.5,
fontWeight: 400,
textAlign: "left",
borderTopLeftRadius: "$3",
borderTopRightRadius: "$3",
overflow: "hidden",
"& p": {
margin: 0,
},
}}
>
<Editor
renderInstance={() => (
<ContentEditable
style={{
minHeight: 150,
resize: "none",
fontSize: "1em",
position: "relative",
tabSize: 1,
outline: 0,
padding: "15px 10px",
caretColor: "#444",
background: "#eee",
}}
/>
)}
instance={
{
component: "Paragraph",
id: "62bd56ad8f5562223cb85cc2",
cssRules: [],
children: [
"Pragraph you can edit ",
{
component: "Bold",
id: "62bd56ad8f5562223cb85cc2",
cssRules: [],
children: ["bold"],
},
],
} as Instance
}
onChange={onChange}
/>
<TreeViewPlugin />
</Box>
</LexicalComposer>
</>
);
};
@@ -1 +0,0 @@
export { Editor } from "./editor-lazy";
@@ -1,36 +0,0 @@
export type {
EditorConfig,
EditorState,
LexicalCommand,
LexicalEditor,
LexicalNode,
RangeSelection,
SerializedEditorState,
SerializedLexicalNode,
SerializedElementNode,
SerializedTextNode,
SerializedRootNode,
ParagraphNode,
} from "lexical";
export {
ElementNode,
TextNode,
RootNode,
createCommand,
$isRangeSelection,
$getSelection,
$getRoot,
$getNodeByKey,
$createTextNode,
$createParagraphNode,
COMMAND_PRIORITY_EDITOR,
SELECTION_CHANGE_COMMAND,
COMMAND_PRIORITY_LOW,
} from "lexical";
export { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
export { LexicalComposer } from "@lexical/react/LexicalComposer";
export { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
export { ContentEditable } from "@lexical/react/LexicalContentEditable";
export { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
export { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
export { TreeView } from "@lexical/react/LexicalTreeView";
@@ -1,80 +0,0 @@
import { render } from "react-dom";
import { type Instance } from "@webstudio-is/react-sdk";
import {
type EditorConfig,
type SerializedTextNode,
TextNode,
} from "../lexical";
import { InlineWrapperComponentDev } from "../../wrapper-component";
type Options = {
instance: Instance;
text: string;
isNew: boolean;
};
export type SerializedInstanceNode = SerializedTextNode & Options;
export class InstanceNode extends TextNode {
options: Options;
static getType(): "instance" {
return "instance";
}
static clone(node: InstanceNode) {
return new InstanceNode(node.options);
}
constructor(options: Options) {
// This makes sure caret is at the end of the selection after inserting this node instance
super(options.text);
this.options = options;
}
exportJSON(): SerializedInstanceNode {
const json = super.exportJSON();
return {
...json,
...this.options,
type: InstanceNode.getType(),
};
}
createDOM(config: EditorConfig) {
const container = super.createDOM(config);
const element = (
<InlineWrapperComponentDev instance={this.options.instance}>
{this.options.text}
</InlineWrapperComponentDev>
);
render(element, container);
return container;
}
updateDOM(prevNode: TextNode, dom: HTMLElement, config: EditorConfig) {
const inner = dom.firstChild as HTMLElement;
if (inner === null) {
return true;
}
super.updateDOM(prevNode, inner, config);
return false;
}
isInline(): true {
return true;
}
canInsertTextBefore(): false {
return false;
}
canInsertTextAfter(): false {
return false;
}
}
export const $createInstanceNode = (options: Options) => {
return new InstanceNode(options);
};
@@ -1,76 +0,0 @@
import { useEffect } from "react";
import { type Instance } from "@webstudio-is/react-sdk";
import { useSubscribe } from "~/shared/pubsub";
import { $createInstanceNode, InstanceNode } from "../nodes/node-instance";
import {
createCommand,
$isRangeSelection,
$getSelection,
$getRoot,
COMMAND_PRIORITY_EDITOR,
useLexicalComposerContext,
type LexicalCommand,
} from "../lexical";
import { toLexicalNodes } from "../utils/to-lexical-nodes";
const populateRoot = (children: Instance["children"]) => {
const nodes = toLexicalNodes(children);
const root = $getRoot();
root.clear();
for (const node of nodes) {
root.append(node);
}
root.selectStart();
};
const INSERT_INSTANCE_COMMAND: LexicalCommand<Instance> = createCommand();
type InstancePluginProps = {
children: Instance["children"];
};
export const InstancePlugin = ({ children }: InstancePluginProps) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (editor.hasNodes([InstanceNode]) === false) {
throw new Error("InstancePlugin: InstanceNode not registered on editor");
}
return editor.registerCommand<Instance>(
INSERT_INSTANCE_COMMAND,
(instance) => {
const selection = $getSelection();
const text = selection?.getTextContent();
if ($isRangeSelection(selection) && text) {
const instanceNode = $createInstanceNode({
instance,
text,
isNew: true,
});
selection.insertNodes([instanceNode]);
// Dirty hack. When clicking on toolbar outside of the iframe, we are loosing focus.
// For some reason we can only refocus after a delay
requestAnimationFrame(() => {
editor.update(() => {
instanceNode.select();
});
});
}
return true;
},
COMMAND_PRIORITY_EDITOR
);
}, [editor]);
useEffect(() => {
editor.update(() => {
populateRoot(children);
});
}, [editor, children]);
useSubscribe("insertInlineInstance", (payload) => {
editor.dispatchCommand<Instance>(INSERT_INSTANCE_COMMAND, payload);
});
return null;
};
@@ -1,31 +0,0 @@
import { type ChildrenUpdates } from "@webstudio-is/react-sdk";
import { useCallback, useEffect, useState } from "react";
import { useDebounce } from "react-use";
import {
OnChangePlugin as LexicalOnChangePlugin,
type EditorState,
} from "../lexical";
import { toUpdates } from "../utils/to-updates";
export const OnChangePlugin = ({
onChange,
}: {
onChange: (updates: ChildrenUpdates) => void;
}) => {
const [editorState, setEditorState] = useState<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,64 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import {
$isRangeSelection,
$getSelection,
SELECTION_CHANGE_COMMAND,
COMMAND_PRIORITY_LOW,
useLexicalComposerContext,
} from "../lexical";
import { publish } from "~/shared/pubsub";
declare module "~/shared/pubsub" {
export interface PubsubMap {
selectionRect?: DOMRect;
}
}
export const ToolbarConnectorPlugin = () => {
const [editor] = useLexicalComposerContext();
const lastSelectionRef = useRef<unknown>();
const clearSelectionRect = () => {
if (lastSelectionRef.current) {
// Undefined Rect will hide toolbar
publish({ type: "selectionRect", payload: undefined });
lastSelectionRef.current = undefined;
}
};
const publishSelectionRect = useCallback(() => {
const selection = $getSelection();
const text = selection?.getTextContent();
const nativeSelection = window.getSelection();
const isTextSelected =
$isRangeSelection(selection) && Boolean(text) && nativeSelection !== null;
if (isTextSelected) {
const domRange = nativeSelection.getRangeAt(0);
const rect = domRange.getBoundingClientRect();
publish({
type: "selectionRect",
payload: rect,
});
lastSelectionRef.current = selection;
return true;
}
clearSelectionRect();
return true;
}, []);
useEffect(() => {
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
publishSelectionRect,
COMMAND_PRIORITY_LOW
);
}, [editor, publishSelectionRect]);
useEffect(() => clearSelectionRect, []);
return null;
};
@@ -1,55 +0,0 @@
.tree-view-output {
display: block;
background: #222;
color: #fff;
padding: 5px;
font-size: 12px;
white-space: pre-wrap;
margin: 1px auto 10px auto;
max-height: 250px;
position: relative;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
overflow: hidden;
line-height: 14px;
}
.debug-timetravel-panel {
overflow: hidden;
padding: 0 0 10px 0;
margin: auto;
display: flex;
}
.debug-timetravel-panel-slider {
padding: 0;
flex: 8;
}
.debug-timetravel-panel-button {
padding: 0;
border: 0;
background: none;
flex: 1;
color: #fff;
font-size: 12px;
}
.debug-timetravel-panel-button:hover {
text-decoration: underline;
}
.debug-timetravel-button {
border: 0;
padding: 0;
font-size: 12px;
top: 10px;
right: 15px;
position: absolute;
background: none;
color: #fff;
}
.debug-timetravel-button:hover {
text-decoration: underline;
}
@@ -1,17 +0,0 @@
import { useLexicalComposerContext, TreeView } from "../lexical";
import "./tree-view-plugin.css";
// Only used in the storybook
export const TreeViewPlugin = () => {
const [editor] = useLexicalComposerContext();
return (
<TreeView
viewClassName="tree-view-output"
timeTravelPanelClassName="debug-timetravel-panel"
timeTravelButtonClassName="debug-timetravel-button"
timeTravelPanelSliderClassName="debug-timetravel-panel-slider"
timeTravelPanelButtonClassName="debug-timetravel-panel-button"
editor={editor}
/>
);
};
@@ -1,40 +0,0 @@
import { type Instance } from "@webstudio-is/react-sdk";
import { $createInstanceNode } from "../nodes/node-instance";
import {
$createTextNode,
$createParagraphNode,
type ParagraphNode,
} from "../lexical";
export const toLexicalNodes = (children: Instance["children"]) => {
const nodes: Array<ParagraphNode> = [];
let p = $createParagraphNode();
nodes.push(p);
for (const child of children) {
if (child === "\n") {
p = $createParagraphNode();
nodes.push(p);
continue;
}
if (typeof child === "string") {
const textNode = $createTextNode(child);
p.append(textNode);
continue;
}
// Inline components should always have a single child string
const text = typeof child.children[0] === "string" ? child.children[0] : "";
const paragraph = nodes[nodes.length - 1];
const instanceNode = $createInstanceNode({
instance: child,
text,
isNew: false,
});
paragraph.append(instanceNode);
}
return nodes;
};
@@ -1,121 +0,0 @@
import { toUpdates } from "./to-updates";
describe("toUpdates", () => {
test("two lines of text", () => {
const state = {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: "normal",
style: "",
text: "a",
type: "text",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
type: "paragraph",
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: "normal",
style: "",
text: "b",
type: "text",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
type: "paragraph",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
type: "root",
version: 1,
},
} as const;
const updates = toUpdates(state.root);
expect(updates).toStrictEqual(["a", "\n", "b"]);
});
test("text and instance", () => {
const state = {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: "normal",
style: "",
text: "a ",
type: "text",
version: 1,
},
{
detail: 0,
format: 0,
mode: "normal",
style: "",
text: "b",
type: "instance",
version: 1,
instance: {
component: "Bold",
id: "62bcc02160a439686c7eabde",
cssRules: [],
children: [],
},
},
{
detail: 0,
format: 0,
mode: "normal",
style: "",
text: "c",
type: "text",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
type: "paragraph",
version: 1,
},
],
direction: "ltr",
format: "",
indent: 0,
type: "root",
version: 1,
},
} as const;
const updates = toUpdates(state.root);
expect(updates).toStrictEqual([
"a ",
{
id: "62bcc02160a439686c7eabde",
text: "b",
},
"c",
]);
});
});
@@ -1,52 +0,0 @@
import { type ChildrenUpdates } from "@webstudio-is/react-sdk";
import {
type SerializedTextNode,
type SerializedElementNode,
type SerializedRootNode,
type SerializedLexicalNode,
} from "../lexical";
import { type SerializedInstanceNode } from "../nodes/node-instance";
type Node =
| SerializedRootNode
| SerializedElementNode
| SerializedTextNode
| SerializedLexicalNode
| SerializedInstanceNode;
export const toUpdates = (
node: Node,
updates: ChildrenUpdates = []
): ChildrenUpdates => {
if (node.type === "text" && "text" in node) {
updates.push(node.text);
}
if (node.type === "instance" && "instance" in node) {
if ("isNew" in node && node.isNew === true) {
updates.push({
id: node.instance.id,
text: node.text,
component: node.instance.component,
createInstance: true,
});
} else {
updates.push({
id: node.instance.id,
text: node.text,
});
}
}
if ("children" in node) {
for (const child of node.children) {
if (child.type === "paragraph" && updates.length !== 0) {
updates.push("\n");
}
toUpdates(child, updates);
}
}
return updates;
};
@@ -1,19 +1,43 @@
import { useCallback, MouseEvent, FormEvent, useMemo } from "react";
import { MouseEvent, FormEvent } from "react";
import { Suspense, lazy, useCallback } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
type Instance,
type CSS,
type OnChangeChildren,
toCss,
useUserProps,
renderWrapperComponentChildren,
components,
} from "@webstudio-is/react-sdk";
import { useBreakpoints, useTextEditingInstanceId } from "~/shared/nano-states";
import { useTextEditingInstanceId } from "~/shared/nano-states";
import { useCss } from "./use-css";
import { Editor } from "./text-editor";
import noop from "lodash.noop";
import { useSelectedElement } from "~/canvas/shared/nano-states";
const TextEditor = lazy(() => import("../text-editor"));
const ContentEditable = ({
Component,
elementRef,
...props
}: {
// eslint-disable-next-line
Component: any;
elementRef: (element: null | HTMLElement) => void;
}) => {
const [editor] = useLexicalComposerContext();
const ref = useCallback(
(rootElement: null | HTMLElement) => {
editor.setRootElement(rootElement);
elementRef(rootElement);
},
[editor, elementRef]
);
return <Component ref={ref} {...props} contentEditable={true} />;
};
type WrapperComponentDevProps = {
instance: Instance;
css: CSS;
@@ -59,7 +83,6 @@ export const WrapperComponentDev = ({
id: instance.id,
"data-component": instance.component,
"data-id": instance.id,
ref: refCallback,
onClick: (event: MouseEvent) => {
if (instance.component === "Link") {
event.preventDefault();
@@ -80,53 +103,20 @@ export const WrapperComponentDev = ({
}
return (
<Editor
instance={instance}
fallback={instanceElement}
renderInstance={({ ref, ...renderProps }) => (
<Component
{...props}
{...renderProps}
ref={(element: HTMLElement | null) => {
props.ref(element);
ref(element);
}}
/>
)}
onChange={(updates) => {
onChangeChildren({ instanceId: instance.id, updates });
}}
/>
);
};
// Only used for instances inside text editor.
export const InlineWrapperComponentDev = ({
instance,
...rest
}: {
instance: Instance;
children: string;
}) => {
const [breakpoints] = useBreakpoints();
const css = useMemo(
() => toCss(instance.cssRules, breakpoints),
[instance, breakpoints]
);
const className = useCss({ instance, css });
const userProps = useUserProps(instance.id);
const { Component } = components[instance.component];
return (
<Component
{...rest}
{...userProps}
data-outline-disabled
key={instance.id}
// @todo stop using id to free it up to the user
id={instance.id}
// @todo merge className with props
className={className}
/>
<Suspense fallback={instanceElement}>
<TextEditor
instance={instance}
contentEditable={
<ContentEditable
{...props}
elementRef={refCallback}
Component={Component}
/>
}
onChange={(updates) => {
onChangeChildren({ instanceId: instance.id, updates });
}}
/>
</Suspense>
);
};
+3 -8
View File
@@ -36,7 +36,7 @@ import {
useRootInstance,
useTextEditingInstanceId,
} from "~/shared/nano-states";
import { useMeasure } from "~/shared/dom-hooks";
import { useMeasureInstance } from "~/canvas/shared/use-measure-instance";
import {
findInstanceByElement,
getInstanceElementById,
@@ -254,15 +254,10 @@ const publishRect = (rect: DOMRect) => {
export const usePublishSelectedInstanceDataRect = () => {
const [element] = useSelectedElement();
const [refCallback, rect] = useMeasure();
const rect = useMeasureInstance(element);
useEffect(() => {
// Disconnect observer when there is no element.
refCallback(element ?? null);
}, [element, refCallback]);
useEffect(() => {
if (rect !== undefined) publishRect(rect);
if (rect) publishRect(rect);
}, [rect]);
};
@@ -3,6 +3,7 @@ import { sync } from "immerhin";
import { enqueue } from "./queue";
import type { Build } from "@webstudio-is/project";
import { Tree } from "@webstudio-is/react-sdk";
import { restPatchPath } from "~/shared/router-utils";
export const useSync = ({
treeId,
@@ -20,7 +21,7 @@ export const useSync = ({
// and backend fetches and updates big objects, so if we send quickly,
// we end up overwriting things
enqueue(() =>
fetch(`/rest/patch`, {
fetch(restPatchPath(), {
method: "post",
body: JSON.stringify({
transactions: entries,
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useMemo } from "react";
import { useMeasure, useRectState } from "~/shared/dom-hooks";
import { type PubsubMap, useSubscribeAll } from "~/shared/pubsub";
const useSubscribeTreeChange = (onChange: () => void, isEnabled: boolean) => {
const callback = useMemo(() => {
if (isEnabled === false) {
return () => null;
}
return (type: keyof PubsubMap) => {
if (
type === "updateProps" ||
type === "deleteProp" ||
type === "insertInstance" ||
type === "deleteInstance" ||
type === "reparentInstance" ||
type === "updateStyle" ||
type.startsWith("previewStyle:")
) {
onChange();
}
};
}, [onChange, isEnabled]);
useSubscribeAll(callback);
};
// A version of useMeasure capable of measuring inlined elements, but works only within canvas.
export const useMeasureInstance = (element: HTMLElement | undefined) => {
const { canObserve, rect: baseRect } = useMeasure(element);
const [rect, setRect] = useRectState();
const handleChange = useCallback(() => {
setRect(element && element.getBoundingClientRect());
}, [element, setRect]);
useSubscribeTreeChange(handleChange, canObserve === false);
useEffect(() => {
if (canObserve === false) {
handleChange();
}
}, [handleChange, canObserve]);
return canObserve ? baseRect : rect;
};
-11
View File
@@ -1,11 +0,0 @@
const config = {
designerPath: "/designer",
dashboardPath: "/dashboard",
loginPath: "/login",
logoutPath: "/logout",
googleCallbackPath: "/google/callback",
githubCallbackPath: "/github/callback",
authPath: "/auth",
};
export default config;
export type Config = typeof config;
@@ -10,16 +10,15 @@ import {
Heading,
Text,
} from "@webstudio-is/design-system";
import { designerPath } from "~/shared/router-utils";
type SelectProjectProjectCardProps = {
projects: Array<{ id: string; title: string }>;
config: { designerPath: string };
errors: string;
};
export const SelectProjectCard = ({
projects,
config,
errors,
}: SelectProjectProjectCardProps) => {
const [selectedProject, setSelectedProject] = useState("");
@@ -28,7 +27,7 @@ export const SelectProjectCard = ({
const transition = useTransition();
const handleOpen = () => {
navigate(`${config.designerPath}/${selectedProject}`);
navigate(designerPath({ projectId: selectedProject }));
};
const options = ["", ...projects.map((project) => project.id)];
@@ -75,7 +74,7 @@ export const SelectProjectCard = ({
</Button>
</Flex>
{errors ? (
<Text color="error" css={{ marginTop: "$1" }}>
<Text color="error" css={{ marginTop: "$spacing$3" }}>
{errors}
</Text>
) : null}
@@ -11,6 +11,7 @@ import {
} from "@webstudio-is/design-system";
import { User } from "@webstudio-is/prisma-client";
import { useNavigate } from "react-router-dom";
import { logoutPath } from "~/shared/router-utils";
export const DashboardHeader = ({ user }: { user: User }) => {
const navigate = useNavigate();
@@ -24,16 +25,16 @@ export const DashboardHeader = ({ user }: { user: User }) => {
align="center"
justify="end"
css={{
p: "$1",
p: "$spacing$3",
bc: "$loContrast",
borderBottom: "1px solid $slate8",
borderBottom: "$spacing$1 solid $slate8",
}}
>
<Flex gap="1" align="center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="raw" aria-label="Menu Button">
<Flex gap="1" align="center" css={{ height: "$5" }}>
<Flex gap="1" align="center" css={{ height: "$spacing$11" }}>
<Avatar
src={user?.image || undefined}
fallback={userNameFallback}
@@ -44,7 +45,7 @@ export const DashboardHeader = ({ user }: { user: User }) => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => navigate("/logout")}>
<DropdownMenuItem onSelect={() => navigate(logoutPath())}>
<Text>Logout</Text>
</DropdownMenuItem>
</DropdownMenuContent>
+2 -7
View File
@@ -22,10 +22,9 @@ export const links = () => {
type DashboardProps = {
projects?: Array<{ id: string; title: string }>;
user: User;
config: { designerPath: string };
};
export const Dashboard = ({ projects = [], config, user }: DashboardProps) => {
export const Dashboard = ({ projects = [], user }: DashboardProps) => {
const actionData = useActionData();
return (
<>
@@ -36,11 +35,7 @@ export const Dashboard = ({ projects = [], config, user }: DashboardProps) => {
align="center"
justify="center"
>
<SelectProjectCard
projects={projects}
config={config}
errors={actionData?.errors}
/>
<SelectProjectCard projects={projects} errors={actionData?.errors} />
</Flex>
</>
);
+9 -18
View File
@@ -5,7 +5,6 @@ import {
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";
import { SidebarLeft } from "./features/sidebar-left";
@@ -40,8 +39,8 @@ import {
import { useClientSettings } from "./shared/client-settings";
import { Navigator } from "./features/sidebar-left";
import { PANEL_WIDTH } from "./shared/constants";
import env from "~/shared/env";
import { useSetAssets } from "./shared/assets";
import { getBuildUrl } from "~/shared/router-utils";
export const links = () => {
return [
@@ -246,7 +245,6 @@ const NavigatorPanel = ({ publish, isPreviewMode }: NavigatorPanelProps) => {
};
export type DesignerProps = {
config: Config;
project: Project;
pages: Pages;
pageId: string;
@@ -254,7 +252,6 @@ export type DesignerProps = {
};
export const Designer = ({
config,
project,
pages,
pageId,
@@ -294,25 +291,19 @@ export const Designer = ({
return page;
}, [pages, pageId]);
const buildUrl = new URL(buildOrigin);
buildUrl.pathname = page.path;
if (env.BUILD_REQUIRE_SUBDOMAIN) {
buildUrl.host = `${project.domain}.${buildUrl.host}`;
} else {
buildUrl.searchParams.set("projectId", project.id);
}
const canvasUrl = getBuildUrl({ buildOrigin, project, page, mode: "edit" });
buildUrl.searchParams.set("mode", "edit");
const canvasUrl = buildUrl.toString();
buildUrl.searchParams.set("mode", "preview");
const previewUrl = buildUrl.toString();
const previewUrl = getBuildUrl({
buildOrigin,
project,
page,
mode: "preview",
});
return (
<ChromeWrapper isPreviewMode={isPreviewMode}>
<Topbar
css={{ gridArea: "header" }}
config={config}
project={project}
publish={publish}
previewUrl={previewUrl}
@@ -336,7 +327,7 @@ export const Designer = ({
</Workspace>
</Main>
<SidePanel gridArea="sidebar" isPreviewMode={isPreviewMode}>
<SidebarLeft publish={publish} config={config} />
<SidebarLeft publish={publish} />
</SidePanel>
<NavigatorPanel publish={publish} isPreviewMode={isPreviewMode} />
<SidePanel
@@ -50,7 +50,10 @@ const BreakpointEditorItem = ({
setBreakpoint(nextBreakpoint);
}}
>
<Flex gap="1" css={{ paddingLeft: "$4", paddingRight: "$3" }}>
<Flex
gap="1"
css={{ paddingLeft: "$spacing$10", paddingRight: "$spacing$9" }}
>
<TextField
css={{ width: 100, flexGrow: 1 }}
type="text"
@@ -101,7 +104,11 @@ export const BreakpointsEditor = ({
align="center"
gap="1"
justify="between"
css={{ paddingLeft: "$5", paddingRight: "$3", py: "$1" }}
css={{
paddingLeft: "$spacing$11",
paddingRight: "$spacing$9",
py: "$spacing$3",
}}
>
<Text>Breakpoints</Text>
<Button
@@ -50,7 +50,7 @@ const BreakpointSelectorItem = ({
};
const menuItemCss = {
display: "flex",
gap: "$3",
gap: "$spacing$9",
justifyContent: "start",
flexGrow: 1,
minWidth: 180,
@@ -13,7 +13,11 @@ export const ConfirmationDialog = ({
onAbort,
}: ConfirmationDialogProps) => {
return (
<Flex gap="2" direction="column" css={{ px: "$5", py: "$2", width: 300 }}>
<Flex
gap="2"
direction="column"
css={{ px: "$spacing$11", py: "$spacing$5", width: 300 }}
>
<Paragraph>{`Are you sure you want to delete "${breakpoint.label}"?`}</Paragraph>
<Paragraph>
{`Deleting a breakpoint will also delete all styles associated with this
@@ -7,7 +7,11 @@ type PreviewProps = {
export const Preview = ({ breakpoint }: PreviewProps) => {
return (
<Flex css={{ px: "$5", py: "$1" }} gap="1" direction="column">
<Flex
css={{ px: "$spacing$11", py: "$spacing$3" }}
gap="1"
direction="column"
>
<Text>CSS Preview</Text>
<Paragraph color="hint">
{breakpoint === undefined
@@ -44,7 +44,7 @@ export const TriggerButton = forwardRef<
<Button
{...props}
ref={ref}
css={{ gap: "$1" }}
css={{ gap: "$spacing$3" }}
ghost
aria-label="Show breakpoints"
>
@@ -53,7 +53,11 @@ export const WidthSetting = () => {
);
return (
<Flex css={{ px: "$5", py: "$1" }} gap="1" direction="column">
<Flex
css={{ px: "$spacing$11", py: "$spacing$3" }}
gap="1"
direction="column"
>
<Text>Canvas width</Text>
<Flex gap="3" align="center">
<Slider
@@ -6,7 +6,11 @@ export const minZoom = 10;
export const ZoomSetting = () => {
const [value, setValue] = useZoom();
return (
<Flex css={{ px: "$5", py: "$1" }} gap="1" direction="column">
<Flex
css={{ px: "$spacing$11", py: "$spacing$3" }}
gap="1"
direction="column"
>
<Text>Zoom</Text>
<Flex gap="3" align="center">
<Slider
@@ -17,7 +17,7 @@ const Breadcrumb = ({ children, onClick }: BreadcrumbProps) => {
onClick={onClick}
css={{
color: "$hiContrast",
px: "$2",
px: "$spacing$5",
borderRadius: "100vh",
height: "100%",
}}
@@ -10,12 +10,12 @@ export const Footer = ({ publish }: { publish: Publish }) => {
align="center"
css={{
gridArea: "footer",
height: "$sizes$5",
height: "$spacing$11",
background: "$loContrast",
boxShadow: "inset 0 1px 0 0 $colors$panelOutline",
}}
>
<Box css={{ height: "100%", p: "$sizes$1" }}>
<Box css={{ height: "100%", p: "$spacing$3" }}>
<Breadcrumbs publish={publish} />
</Box>
</Flex>
@@ -28,9 +28,9 @@ export const Inspector = ({ publish }: InspectorProps) => {
if (selectedInstanceData === undefined) {
return (
<Box css={{ p: "$2", flexBasis: "100%" }}>
<Box css={{ p: "$spacing$5", flexBasis: "100%" }}>
{/* @todo: use this space for something more usefull: a-la figma's no instance selected sate, maybe create an issue with a more specific proposal? */}
<Card css={{ p: "$3", mt: "$3", width: "100%" }}>
<Card css={{ p: "$spacing$9", mt: "$spacing$9", width: "100%" }}>
<Paragraph>Select an instance on the canvas</Paragraph>
</Card>
</Box>
@@ -202,7 +202,7 @@ export const PropsPanel = ({
);
return (
<>
<Box css={{ p: "$3" }}>
<Box css={{ p: "$spacing$9" }}>
<ComponentInfo selectedInstanceData={selectedInstanceData} />
</Box>
<CollapsibleSection
@@ -53,7 +53,7 @@ const getPropsWithDefaultValue = (
.filter(([_, value]) => value.defaultValue != null)
.map(([prop, propObj]) => {
const { defaultValue } = propObj;
const value = "value" in defaultValue ? defaultValue.value : defaultValue;
const value = defaultValue;
return {
id: ObjectId().toString(),
prop,
@@ -1,4 +1,4 @@
import { Flex, IconButton, Text } from "@webstudio-is/design-system";
import { Flex, IconButton, Text, Tooltip } from "@webstudio-is/design-system";
import { Separator } from "@webstudio-is/design-system";
import { CrossIcon } from "@webstudio-is/icons";
@@ -11,12 +11,12 @@ export const Header = ({ title, suffix }: HeaderProps) => {
return (
<>
<Flex
css={{ height: 40, paddingLeft: "$3", flexShrink: 0 }}
css={{ height: 40, paddingLeft: "$spacing$9", flexShrink: 0 }}
align="center"
justify="between"
>
<Text variant="title">{title}</Text>
{suffix && <Flex css={{ marginRight: "$1" }}>{suffix}</Flex>}
{suffix && <Flex css={{ marginRight: "$spacing$3" }}>{suffix}</Flex>}
</Flex>
<Separator css={{ height: 2 }} />
</>
@@ -24,7 +24,9 @@ export const Header = ({ title, suffix }: HeaderProps) => {
};
export const CloseButton = ({ onClick }: { onClick: () => void }) => (
<IconButton onClick={onClick} size="2" aria-label="Close">
<CrossIcon />
</IconButton>
<Tooltip content="Close panel" side="bottom" align="end">
<IconButton onClick={onClick} size="2" aria-label="Close panel">
<CrossIcon />
</IconButton>
</Tooltip>
);
@@ -3,7 +3,7 @@ import { forwardRef, type ElementRef, type ComponentProps } from "react";
import { Flex, Text, styled } from "@webstudio-is/design-system";
const Thumb = styled(Flex, {
px: "$1",
px: "$spacing$3",
width: 72,
height: 72,
border: "1px solid $slate6",
@@ -140,7 +140,7 @@ export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => {
<Flex
gap="1"
wrap="wrap"
css={{ padding: "$1", overflow: "auto" }}
css={{ padding: "$spacing$3", overflow: "auto" }}
ref={useDragHandlers.rootRef}
>
{componentNames.map((component: Instance["component"]) => (
@@ -1,28 +1,37 @@
import {
TreeNodeLabel,
TreeNode,
IconButton,
TreeItemLabel,
TreeItemBody,
TreeNode,
type TreeItemRenderProps,
styled,
Flex,
Tooltip,
} from "@webstudio-is/design-system";
import { type Publish } from "~/shared/pubsub";
import { NewPageIcon, PageIcon } from "@webstudio-is/icons";
import {
ChevronRightIcon,
MenuIcon,
NewPageIcon,
PageIcon,
} from "@webstudio-is/icons";
import type { TabName } from "../../types";
import { CloseButton, Header } from "../../lib/header";
import { type Page, type Pages } from "@webstudio-is/project";
import { useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { type Config } from "~/config";
import {
useCurrentPageId,
usePages,
useProject,
} from "~/designer/shared/nano-states";
import { SettingsPanel } from "./settings-panel";
import { NewPageSettings } from "./settings";
import { NewPageSettings, PageSettings } from "./settings";
import { designerPath } from "~/shared/router-utils";
type TabContentProps = {
onSetActiveTab: (tabName: TabName) => void;
publish: Publish;
config: Config;
};
type PagesTreeNode =
@@ -60,36 +69,86 @@ const staticTreeProps = {
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 MenuButton = styled(IconButton, {
color: "$hint",
"&:hover, &:focus": { color: "$hiContrast" },
variants: {
isParentSelected: {
true: {
color: "$loContrast",
"&:hover, &:focus": { color: "$slate7" },
},
},
},
});
const PagesPanel = ({
onClose,
onNewPage,
onCreateNewPage,
onSelect,
selectedPageId,
onEdit,
editingPageId,
}: {
onClose?: () => void;
onNewPage?: () => void;
onCreateNewPage?: () => void;
onSelect: (pageId: string) => void;
selectedPageId: string;
onEdit?: (pageId: string) => void;
editingPageId?: string;
}) => {
const [pages] = usePages();
const pagesTree = useMemo(() => pages && toTreeData(pages), [pages]);
const renderItem = useCallback(
(props: TreeItemRenderProps<PagesTreeNode>) => {
if (props.itemData.type === "folder") {
return null;
}
const isSelected = props.selectedItemId === props.itemData.id;
const isEditing = editingPageId === props.itemData.id;
return (
<TreeItemBody
{...props}
suffix={
onEdit && (
<Flex css={{ mr: "$spacing$5" }} align="center">
{isEditing ? (
<ChevronRightIcon />
) : (
<Tooltip
content="Open page settings"
side="left"
disableHoverableContent
>
<MenuButton
aria-label="Open page settings"
isParentSelected={isSelected}
onClick={() => onEdit(props.itemData.id)}
>
<MenuIcon />
</MenuButton>
</Tooltip>
)}
</Flex>
)
}
alwaysShowSuffix={isEditing}
forceFocus={isEditing}
>
<TreeItemLabel prefix={<PageIcon />}>
{props.itemData.data.name}
</TreeItemLabel>
</TreeItemBody>
);
},
[editingPageId, onEdit]
);
if (pagesTree === undefined) {
return null;
}
@@ -100,14 +159,16 @@ const PagesPanel = ({
title="Pages"
suffix={
<>
{onNewPage && (
<IconButton
size="2"
onClick={() => onNewPage()}
aria-label="New Page"
>
<NewPageIcon />
</IconButton>
{onCreateNewPage && (
<Tooltip content="New page" side="bottom">
<IconButton
size="2"
onClick={() => onCreateNewPage()}
aria-label="New page"
>
<NewPageIcon />
</IconButton>
</Tooltip>
)}
{onClose && <CloseButton onClick={onClose} />}
</>
@@ -118,6 +179,7 @@ const PagesPanel = ({
selectedItemId={selectedPageId}
onSelect={onSelect}
itemData={pagesTree}
renderItem={renderItem}
{...staticTreeProps}
/>
</>
@@ -129,14 +191,20 @@ export const TabContent = (props: TabContentProps) => {
const [project] = useProject();
const navigate = useNavigate();
const handleSelect = (pageId: string) => {
const handleSelect = (pageId: "home" | Page["id"]) => {
if (project === undefined) {
return;
}
navigate(`${props.config.designerPath}/${project.id}?pageId=${pageId}`);
navigate(
designerPath({
projectId: project.id,
pageId: pageId === "home" ? undefined : pageId,
})
);
};
const [newPageOpen, setNewPageOpen] = useState(false);
const newPageId = "new-page";
const [editingPageId, setEditingPageId] = useState<string>();
if (currentPageId === undefined || project === undefined) {
return null;
@@ -146,19 +214,41 @@ export const TabContent = (props: TabContentProps) => {
<>
<PagesPanel
onClose={() => props.onSetActiveTab("none")}
onNewPage={() => setNewPageOpen((current) => !current)}
onCreateNewPage={() =>
setEditingPageId((current) =>
current === newPageId ? undefined : newPageId
)
}
onSelect={handleSelect}
selectedPageId={currentPageId}
onEdit={setEditingPageId}
editingPageId={editingPageId}
/>
<SettingsPanel isOpen={newPageOpen}>
<NewPageSettings
projectId={project.id}
onClose={() => setNewPageOpen(false)}
onSuccess={(page) => {
setNewPageOpen(false);
handleSelect(page.id);
}}
/>
<SettingsPanel isOpen={editingPageId !== undefined}>
{editingPageId === newPageId && (
<NewPageSettings
projectId={project.id}
onClose={() => setEditingPageId(undefined)}
onSuccess={(page) => {
setEditingPageId(undefined);
handleSelect(page.id);
}}
/>
)}
{editingPageId !== newPageId && editingPageId !== undefined && (
<PageSettings
onClose={() => setEditingPageId(undefined)}
onDelete={() => {
setEditingPageId(undefined);
if (editingPageId === currentPageId) {
handleSelect("home");
}
}}
pageId={editingPageId}
projectId={project.id}
key={editingPageId}
/>
)}
</SettingsPanel>
</>
);
@@ -3,29 +3,138 @@ import {
Button,
Box,
Label,
TextArea,
TextField,
useId,
styled,
Flex,
toast,
InputErrorsTooltip,
Tooltip,
} from "@webstudio-is/design-system";
import { useFetcher } from "@remix-run/react";
import { ChevronDoubleLeftIcon } from "@webstudio-is/icons";
import { ChevronDoubleLeftIcon, TrashIcon } from "@webstudio-is/icons";
import { utils as projectUtils } from "@webstudio-is/project";
import type { ZodError } from "zod";
import { Header } from "../../lib/header";
import { useEffect, useRef, useState } from "react";
import { useState, useCallback, ComponentProps } from "react";
import { type Page } from "@webstudio-is/project";
import { usePages } from "~/designer/shared/nano-states";
import { useDebounce, useUnmount } from "react-use";
import { useOnFetchEnd, usePersistentFetcher } from "~/shared/fetcher";
import {
normalizeErrors,
toastUnknownFieldErrors,
useIds,
useFetcherErrors,
} from "~/shared/form-utils";
import type {
DeletePageData,
EditPageData,
CreatePageData,
} from "~/shared/pages";
import { restPagesPath } from "~/shared/router-utils";
const Group = styled(Flex, {
marginBottom: "$3",
gap: "$2",
marginBottom: "$spacing$9",
gap: "$spacing$5",
defaultVariants: { direction: "column" },
});
export type CreatePageData =
| { ok: true; page: Page }
| { errors: string | ZodError["formErrors"] };
const fieldNames = ["name", "path", "title", "description"] as const;
type FieldName = typeof fieldNames[number];
type FormPage = Pick<Page, "name" | "path" | "title"> & {
description: string;
};
const toFormPage = (page: Page): FormPage => {
return {
name: page.name,
path: page.path,
title: page.title,
description: page.meta.description ?? "",
};
};
const FormFields = ({
disabled,
values,
onChange,
fieldErrors,
}: {
disabled?: boolean;
values: FormPage;
onChange: <Name extends FieldName>(event: {
field: Name;
value: FormPage[Name];
}) => void;
fieldErrors: ZodError["formErrors"]["fieldErrors"];
}) => {
const fieldIds = useIds(fieldNames);
return (
<>
<Group>
<Label htmlFor={fieldIds.name}>Page Name</Label>
<InputErrorsTooltip errors={fieldErrors.name}>
<TextField
state={fieldErrors.name && "invalid"}
id={fieldIds.name}
name="name"
disabled={disabled}
value={values?.name}
onChange={(event) => {
onChange({ field: "name", value: event.target.value });
}}
/>
</InputErrorsTooltip>
</Group>
<Group>
<Label htmlFor={fieldIds.path}>Path</Label>
<InputErrorsTooltip errors={fieldErrors.path}>
<TextField
state={fieldErrors.path && "invalid"}
id={fieldIds.path}
name="path"
disabled={disabled}
value={values?.path}
onChange={(event) => {
onChange({ field: "path", value: event.target.value });
}}
/>
</InputErrorsTooltip>
</Group>
<Group>
<Label htmlFor={fieldIds.title}>Title</Label>
<InputErrorsTooltip errors={fieldErrors.title}>
<TextField
state={fieldErrors.title && "invalid"}
id={fieldIds.title}
name="title"
disabled={disabled}
value={values?.title}
onChange={(event) => {
onChange({ field: "title", value: event.target.value });
}}
/>
</InputErrorsTooltip>
</Group>
<Group>
<Label htmlFor={fieldIds.description}>Description</Label>
<InputErrorsTooltip errors={fieldErrors.description}>
<TextArea
state={fieldErrors.description && "invalid"}
id={fieldIds.description}
name="description"
disabled={disabled}
value={values?.description}
onChange={(event) => {
onChange({ field: "description", value: event.target.value });
}}
/>
</InputErrorsTooltip>
</Group>
</>
);
};
export const NewPageSettings = ({
onClose,
@@ -36,93 +145,259 @@ export const NewPageSettings = ({
onSuccess?: (page: Page) => void;
projectId: string;
}) => {
const nameFieldId = useId();
const pathFieldId = useId();
const fetcher = useFetcher<CreatePageData>();
const [fieldErrors, setFieldErrors] = useState<
ZodError["formErrors"]["fieldErrors"]
>({});
useOnFetchEnd(fetcher, (data) => {
if (data.status === "ok") {
onSuccess?.(data.page);
}
});
const isSubmitting = fetcher.state !== "idle";
const prevFetcher = useRef(fetcher);
useEffect(() => {
if (prevFetcher.current.state !== fetcher.state) {
if (fetcher.state === "idle" && fetcher.data !== undefined) {
if ("ok" in fetcher.data) {
onSuccess?.(fetcher.data.page);
} else {
const errors =
typeof fetcher.data.errors === "string"
? { formErrors: [fetcher.data.errors], fieldErrors: {} }
: fetcher.data.errors;
const { fieldErrors, resetFieldError } = useFetcherErrors({
fetcher,
fieldNames,
});
if (errors.formErrors.length > 0) {
for (const message of errors.formErrors) {
toast.error(message);
}
}
setFieldErrors(errors.fieldErrors);
}
}
}
prevFetcher.current = fetcher;
}, [fetcher, onSuccess]);
const [values, setValues] = useState<FormPage>({
name: "",
path: "",
title: "",
description: "",
});
const handleSubmit = () => {
fetcher.submit(values, {
method: "put",
action: restPagesPath({ projectId }),
});
};
return (
<NewPageSettingsView
onSubmit={handleSubmit}
onClose={onClose}
isSubmitting={isSubmitting}
fieldErrors={fieldErrors}
disabled={isSubmitting}
values={values}
onChange={({ field, value }) => {
resetFieldError(field);
setValues((values) => ({ ...values, [field]: value }));
}}
/>
);
};
const NewPageSettingsView = ({
onSubmit,
isSubmitting,
onClose,
...formFieldsProps
}: {
onSubmit: () => void;
isSubmitting: boolean;
onClose?: () => void;
} & ComponentProps<typeof FormFields>) => {
return (
<>
<Header
title="New Page Settings"
suffix={
onClose && (
<IconButton size="2" onClick={onClose} aria-label="Cancel">
<ChevronDoubleLeftIcon />
</IconButton>
<Tooltip content="Cancel" side="bottom" align="end">
<IconButton size="2" onClick={onClose} aria-label="Cancel">
<ChevronDoubleLeftIcon />
</IconButton>
</Tooltip>
)
}
/>
<Box css={{ overflow: "auto", padding: "$2 $3" }}>
<fetcher.Form method="put" action={`/rest/pages/${projectId}`}>
<Group>
<Label htmlFor={nameFieldId} size={2}>
Page Name
</Label>
<InputErrorsTooltip errors={fieldErrors.name}>
<TextField
state={fieldErrors.name && "invalid"}
id={nameFieldId}
name="name"
disabled={isSubmitting}
onChange={() => {
setFieldErrors(({ name, ...rest }) => rest);
}}
/>
</InputErrorsTooltip>
</Group>
<Group>
<Label htmlFor={pathFieldId} size={2}>
Path
</Label>
<InputErrorsTooltip errors={fieldErrors.path}>
<TextField
state={fieldErrors.path && "invalid"}
id={pathFieldId}
name="path"
disabled={isSubmitting}
onChange={() => {
setFieldErrors(({ path, ...rest }) => rest);
}}
/>
</InputErrorsTooltip>
</Group>
<Box css={{ overflow: "auto", padding: "$spacing$5 $spacing$9" }}>
<form
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<FormFields {...formFieldsProps} />
<Group css={{ alignItems: "end" }}>
<Button type="submit" variant="green" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create"}
</Button>
</Group>
</fetcher.Form>
</form>
</Box>
</>
);
};
const toFormData = (page: Partial<FormPage> & { id: string }): FormData => {
const formData = new FormData();
for (const [key, value] of Object.entries(page)) {
// @todo: handle "meta"
if (typeof value === "string") {
formData.append(key, value);
}
}
return formData;
};
export const PageSettings = ({
onClose,
onDelete,
pageId,
projectId,
}: {
onClose?: () => void;
onDelete?: () => void;
pageId: string;
projectId: string;
}) => {
const submitPersistently = usePersistentFetcher();
const fetcher = useFetcher<EditPageData>();
const [pages] = usePages();
const page = pages && projectUtils.pages.findById(pages, pageId);
const [unsavedValues, setUnsavedValues] = useState<Partial<FormPage>>({});
const [submittedValues, setSubmittedValues] = useState<Partial<FormPage>>({});
const { fieldErrors, resetFieldError } = useFetcherErrors({
fetcher,
fieldNames,
});
const handleChange = useCallback(
<Name extends FieldName>(event: { field: Name; value: FormPage[Name] }) => {
resetFieldError(event.field);
setUnsavedValues((values) => ({ ...values, [event.field]: event.value }));
},
[resetFieldError]
);
useDebounce(
() => {
if (Object.keys(unsavedValues).length === 0) {
return;
}
// We're re-submitting the submittedValues because previous submit is going to be cancelled
// (normally, submittedValues are empty at this point)
const valuesToSubmit = { ...submittedValues, ...unsavedValues };
fetcher.submit(toFormData({ id: pageId, ...valuesToSubmit }), {
method: "post",
action: restPagesPath({ projectId }),
});
setSubmittedValues(valuesToSubmit);
setUnsavedValues({});
},
1000,
[unsavedValues]
);
useUnmount(() => {
if (Object.keys(unsavedValues).length === 0) {
return;
}
// We use submitPersistently instead of fetcher.submit
// because we don't want the request to be canceled when the component unmounts
submitPersistently<EditPageData>(
toFormData({ id: pageId, ...submittedValues, ...unsavedValues }),
{ method: "post", action: restPagesPath({ projectId }) },
(data) => {
if (data.status === "error") {
toastUnknownFieldErrors(normalizeErrors(data.errors), []);
}
}
);
});
useOnFetchEnd(fetcher, (data) => {
if (data.status === "error") {
setUnsavedValues({ ...submittedValues, ...unsavedValues });
}
setSubmittedValues({});
});
const hanldeDelete = () => {
// We use submitPersistently instead of fetcher.submit
// because we don't want the request to be canceled when the component unmounts
submitPersistently<DeletePageData>(
{ id: pageId },
{ method: "delete", action: restPagesPath({ projectId }) },
(data) => {
if (data.status === "error") {
toastUnknownFieldErrors(normalizeErrors(data.errors), []);
}
}
);
onDelete?.();
};
if (page === undefined) {
return null;
}
return (
<PageSettingsView
isHomePage={pageId === pages?.homePage.id}
onClose={onClose}
onDelete={hanldeDelete}
fieldErrors={fieldErrors}
values={{ ...toFormPage(page), ...submittedValues, ...unsavedValues }}
onChange={handleChange}
/>
);
};
const PageSettingsView = ({
isHomePage,
onDelete,
onClose,
...formFieldsProps
}: {
isHomePage: boolean;
onDelete: () => void;
onClose?: () => void;
} & ComponentProps<typeof FormFields>) => {
return (
<>
<Header
title="Page Settings"
suffix={
<>
{isHomePage === false && (
<Tooltip content="Delete page" side="bottom">
<IconButton
size="2"
onClick={onDelete}
aria-label="Delete page"
>
<TrashIcon />
</IconButton>
</Tooltip>
)}
{onClose && (
<Tooltip content="Close page settings" side="bottom" align="end">
<IconButton
size="2"
onClick={onClose}
aria-label="Close page settings"
>
<ChevronDoubleLeftIcon />
</IconButton>
</Tooltip>
)}
</>
}
/>
<Box css={{ overflow: "auto", padding: "$spacing$5 $spacing$9" }}>
<FormFields {...formFieldsProps} />
</Box>
</>
);
@@ -13,16 +13,14 @@ 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, config }: SidebarLeftProps) => {
export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
const [dragAndDropState] = useDragAndDropState();
const [activeTab, setActiveTab] = useState<TabName>("none");
const { TabContent } = activeTab === "none" ? none : panels[activeTab];
@@ -40,8 +38,6 @@ export const SidebarLeft = ({ publish, config }: SidebarLeftProps) => {
switch (panel) {
case "assets":
return isFeatureEnabled("assets");
case "pages":
return isFeatureEnabled("pages");
case "navigator":
return clientSettings.navigatorLayout === "docked";
}
@@ -79,11 +75,7 @@ export const SidebarLeft = ({ publish, config }: SidebarLeftProps) => {
: "visible",
}}
>
<TabContent
publish={publish}
onSetActiveTab={setActiveTab}
config={config}
/>
<TabContent publish={publish} onSetActiveTab={setActiveTab} />
</SidebarTabsContent>
</SidebarTabs>
</Flex>
@@ -29,7 +29,6 @@ export const FontFamilyControl = ({
content={<FontsManager value={String(value.value)} onChange={setValue} />}
onOpenChange={setIsOpen}
>
{/* @todo this should be part of the design system, probably a varian="button" */}
<TextField
defaultValue={value.value}
state={isOpen ? "active" : undefined}
@@ -0,0 +1,120 @@
import { Select } from "@webstudio-is/design-system";
import { FontWeight, fontWeights } from "@webstudio-is/fonts";
import { toValue } from "@webstudio-is/react-sdk";
import { useMemo } from "react";
import { useAssets } from "~/designer/shared/assets";
import { getFinalValue } from "../../shared/get-final-value";
import type { ControlProps } from "../../style-sections";
type FontWeightItem = {
label: string;
weight: FontWeight;
};
const allFontWeights: Array<FontWeightItem> = (
Object.keys(fontWeights) as Array<FontWeight>
).map((weight) => ({
label: `${fontWeights[weight].label} (${weight})`,
weight,
}));
// All font weights for the current family
const useAvailableFontWeights = (
currentFamily: string
): Array<FontWeightItem> => {
const { assets } = useAssets("font");
// Find all font weights that are available for the current font family.
return useMemo(() => {
const found = allFontWeights.filter((option) => {
return assets.find((asset) => {
return (
"meta" in asset &&
"family" in asset.meta &&
asset.meta.family === currentFamily &&
String(asset.meta.weight) === option.weight
);
});
});
return found.length === 0 ? allFontWeights : found;
}, [currentFamily, assets]);
};
const useLabels = (
availableFontWeights: Array<FontWeightItem>,
currentWeight: string
) => {
const labels = useMemo(
() => availableFontWeights.map((option) => option.label),
[availableFontWeights]
);
const selectedLabel = useMemo(() => {
const selectedOption = availableFontWeights.find(
(option) => option.weight == currentWeight
);
return selectedOption?.label;
}, [currentWeight, availableFontWeights]);
return { labels, selectedLabel };
};
export const FontWeightControl = ({
currentStyle,
inheritedStyle,
setProperty,
styleConfig,
}: ControlProps) => {
// @todo show which instance we inherited the value from
const fontWeight = getFinalValue({
currentStyle,
inheritedStyle,
property: styleConfig.property,
});
// We need the font family to determine which font weights are available
const fontFamily = getFinalValue({
currentStyle,
inheritedStyle,
property: "fontFamily",
});
const availableFontWeights = useAvailableFontWeights(
toValue(fontFamily, { withFallback: false })
);
const { labels, selectedLabel } = useLabels(
availableFontWeights,
toValue(fontWeight)
);
if (fontWeight === undefined) return null;
const setValue = setProperty(styleConfig.property);
return (
<Select
options={labels}
// We use a weight as a value, because there are only 9 weights and they are unique.
value={selectedLabel}
onChange={(label) => {
const selected = availableFontWeights.find(
(option) => option.label == label
);
if (selected) {
setValue(selected.weight);
}
}}
ghost
css={{
// @todo this shouldn't be in design system by default
gap: "calc($spacing$3 / 2)",
paddingLeft: "calc($spacing$10 / 2)",
height: "calc($spacing$11 + $spacing$3)",
boxShadow: "inset 0 0 0 1px $colors$slate7",
textTransform: "capitalize",
fontWeight: "inherit",
"&:hover": { background: "none" },
}}
/>
);
};
@@ -30,13 +30,12 @@ export const ImageControl = ({
title="Images"
content={
<ImageManager
onSelect={(asset) => {
onChange={(asset) => {
setValue(asset.id);
}}
/>
}
>
{/* @todo this should be part of the design system, probably a varian="button" */}
<TextField defaultValue={selectedAsset?.name} />
</ValuePickerPopover>
);
@@ -3,5 +3,6 @@ export * from "./color/color-control";
export * from "./select/select-control";
export * from "./menu/menu-control";
export * from "./font-family/font-family-control";
export * from "./font-weight/font-weight-control";
export * from "./image/image-control";
export * from "./toggle/toggle-control";
@@ -24,16 +24,6 @@ export const SelectControl = ({
options={styleConfig.items.map(({ label }) => label)}
value={String(value.value)}
onChange={setValue}
ghost
css={{
gap: "calc($sizes$1 / 2)",
paddingLeft: "calc($sizes$4 / 2)",
height: "calc($sizes$5 + $sizes$1)",
boxShadow: "inset 0 0 0 1px $colors$slate7",
textTransform: "capitalize",
fontWeight: "inherit",
"&:hover": { background: "none" },
}}
/>
);
};
@@ -3,13 +3,15 @@ import { getFinalValue } from "../../shared/get-final-value";
import { ControlProps } from "../../style-sections";
import { type StyleValue, toValue } from "@webstudio-is/react-sdk";
import { useState } from "react";
import { Box, Tooltip } from "@webstudio-is/design-system";
export const TextControl = ({
currentStyle,
inheritedStyle,
setProperty,
styleConfig,
}: ControlProps) => {
icon,
}: ControlProps & { icon?: JSX.Element }) => {
const value = getFinalValue({
currentStyle,
inheritedStyle,
@@ -19,34 +21,42 @@ export const TextControl = ({
const setValue = setProperty(styleConfig.property);
const [currentValue, setCurrentValue] = useState<StyleValue>();
return (
<CssValueInput
property={styleConfig.property}
value={currentValue ?? value}
keywords={styleConfig.items.map((item) => ({
type: "keyword",
value: item.name,
}))}
onChange={(styleValue) => {
setCurrentValue(styleValue);
setValue(toValue(styleValue), { isEphemeral: true });
}}
onItemHighlight={(styleValue) => {
const nextValue = styleValue ?? currentValue ?? value;
if (nextValue) {
setValue(toValue(nextValue), {
isEphemeral: true,
});
}
}}
onChangeComplete={(styleValue) => {
const prevValue = toValue(value);
const nextValue = toValue(styleValue);
if (prevValue === nextValue) return;
setCurrentValue(undefined);
setValue(toValue(styleValue));
}}
/>
<Tooltip
content={styleConfig.label}
delayDuration={600}
disableHoverableContent={true}
>
<Box>
<CssValueInput
icon={icon}
property={styleConfig.property}
value={currentValue ?? value}
keywords={styleConfig.items.map((item) => ({
type: "keyword",
value: item.name,
}))}
onChange={(styleValue) => {
setCurrentValue(styleValue);
setValue(toValue(styleValue), { isEphemeral: true });
}}
onItemHighlight={(styleValue) => {
const nextValue = styleValue ?? currentValue ?? value;
if (nextValue) {
setValue(toValue(nextValue), {
isEphemeral: true,
});
}
}}
onChangeComplete={(styleValue) => {
const prevValue = toValue(value);
const nextValue = toValue(styleValue);
if (prevValue === nextValue) return;
setCurrentValue(undefined);
setValue(toValue(styleValue));
}}
/>
</Box>
</Tooltip>
);
};
@@ -6,6 +6,7 @@ import { ShowMore } from "../../shared/show-more";
import { renderProperty } from "../../style-sections";
import { MenuControl, SelectControl, TextControl } from "../../controls";
import { PropertyName } from "../../shared/property-name";
import { ColumnGapIcon, RowGapIcon } from "@webstudio-is/icons";
const LayoutSectionFlex = ({
currentStyle,
@@ -32,14 +33,14 @@ const LayoutSectionFlex = ({
].some((config) => config !== undefined);
return (
<Flex css={{ flexDirection: "column", gap: "$2" }}>
<Flex css={{ flexDirection: "column", gap: "$spacing$5" }}>
{display?.styleConfig && (
<Grid
css={{
gridArea: "display",
gridTemplateColumns: "auto 1fr",
gap: "$space$2",
width: "fit-content",
gap: "$spacing$13",
width: "100%",
fontWeight: "500",
}}
>
@@ -53,9 +54,10 @@ const LayoutSectionFlex = ({
{hasMenuItems && (
<Grid
css={{
gap: "$2",
gridTemplateColumns: "repeat(2, $6) repeat(3, $6)",
gridTemplateRows: "repeat(2, $6)",
gap: "$spacing$5",
gridTemplateColumns:
"repeat(2, $spacing$13) repeat(3, $spacing$13)",
gridTemplateRows: "repeat(2, $spacing$13)",
gridTemplateAreas: `
"grid grid flexDirection flexWrap ."
"grid grid alignItems justifyContent alignContent"
@@ -106,11 +108,11 @@ const LayoutSectionFlex = ({
>
{columnGap?.styleConfig && (
<Box css={{ gridArea: "columnGap" }}>
<TextControl {...columnGap} />
<TextControl icon={<ColumnGapIcon />} {...columnGap} />
</Box>
)}
{rowGap?.styleConfig && columnGap?.styleConfig && (
<Box css={{ gridArea: "lock", px: "$1" }}>
<Box css={{ gridArea: "lock", px: "$spacing$3" }}>
<Lock
pairedKeys={["columnGap", "rowGap"]}
currentStyle={currentStyle}
@@ -120,7 +122,7 @@ const LayoutSectionFlex = ({
)}
{rowGap?.styleConfig && (
<Box css={{ gridArea: "rowGap" }}>
<TextControl {...rowGap} />
<TextControl icon={<RowGapIcon />} {...rowGap} />
</Box>
)}
</Grid>
@@ -57,12 +57,12 @@ export const FlexGrid = ({
css={{
width: "100%",
aspectRatio: "1 / 1",
padding: "6px",
padding: "$spacing$4",
borderRadius: "4px",
background: "$loContrast",
border: "2px solid currentColor",
alignItems: "center",
gap: "1px",
gap: "$spacing$1",
gridTemplateColumns: "repeat(3, 1fr)",
gridTemplateRows: "repeat(3, 1fr)",
gridTemplateAreas: `
@@ -133,7 +133,7 @@ export const FlexGrid = ({
flexDirection: "column",
width: "100%",
height: "100%",
gap: "calc($sizes$1 / 2)",
gap: "calc($spacing$3 / 2)",
color: "currentColor",
}}
>
@@ -143,9 +143,9 @@ export const FlexGrid = ({
css={{
inlineSize: value,
blockSize: "calc(100% / 3)",
maxBlockSize: "6px",
maxBlockSize: "$spacing$4",
background: "currentColor",
borderRadius: "calc($radii$1 / 2)",
borderRadius: "calc($borderRadius$4 / 2)",
}}
></Flex>
))}
@@ -24,7 +24,7 @@ export const Lock = ({
const [isPaired, setIsPaired] = useState(aVal === bVal);
useEffect(() => {
if (!isPaired) return;
if (isPaired === false) return;
if (aVal === bVal) return;
batchUpdate.setProperty(aKey)(aVal);
batchUpdate.setProperty(bKey)(aVal);
@@ -42,7 +42,7 @@ const grid = {
const styles = {
spacingEdit: {
fontSize: "$1",
fontSize: "$fontSize$3",
// @todo use a color from design system
color: "rgb(217, 217, 217)",
display: "flex",
@@ -52,11 +52,13 @@ const styles = {
wrapper: {
display: "grid",
height: 130,
gridTemplateColumns: "$5 $1 $5 1fr $5 $1 $5",
gridTemplateRows: "$5 $1 $5 1fr $5 $1 $5",
gridTemplateColumns:
"$spacing$11 $spacing$3 $spacing$11 1fr $spacing$11 $spacing$3 $spacing$11",
gridTemplateRows:
"$spacing$11 $spacing$3 $spacing$11 1fr $spacing$11 $spacing$3 $spacing$11",
},
input: {
fontSize: 10,
fontSize: "$fontSize$2",
fontWeight: 400,
fontFamily: "inherit",
display: "block",
@@ -77,35 +79,35 @@ const styles = {
emptySpace: {
gridArea: "2 / 2 / 3 / 2",
background: "$loContrast",
width: "$6",
width: "$spacing$13",
margin: "auto",
height: "100%",
borderRadius: "$1",
borderRadius: "$borderRadius$4",
},
marginGrid: {
gridArea: "1 / 1 / -1 / -1",
display: "grid",
gridTemplateColumns: "$5 1fr $5",
gridTemplateRows: "$5 minmax($3, 1fr) $5",
gridTemplateColumns: "$spacing$11 1fr $spacing$11",
gridTemplateRows: "$spacing$11 minmax($spacing$9, 1fr) $spacing$11",
height: 130,
backgroundColor: "$gray6",
borderRadius: "$1",
borderRadius: "$borderRadius$4",
px: 2,
},
text: {
fontWeight: "bold",
color: "$gray12",
fontSize: 8,
margin: "$1",
fontSize: "$fontSize$1",
margin: "$spacing$3",
},
paddingGrid: {
gridArea: "3 / 3 / span 3 / span 3",
display: "grid",
gridTemplateColumns: "$5 1fr $5",
gridTemplateRows: "$5 minmax($3, 1fr) $5",
gridTemplateColumns: "$spacing$11 1fr $spacing$11",
gridTemplateRows: "$spacing$11 minmax($spacing$9, 1fr) $spacing$11",
border: "2px solid",
borderColor: "$loContrast",
borderRadius: "$1",
borderRadius: "$borderRadius$4",
},
};
@@ -17,7 +17,7 @@ const stringifyRGBA = (color: RGBColor) => {
};
const pickerStyle = css({
padding: "$2",
padding: "$spacing$5",
background: "$panel",
// @todo this lib doesn't have another way to define styles for inputs
// we should either submit a PR or replace it
@@ -64,9 +64,9 @@ export const ColorPicker = ({
>
<Box
css={{
margin: "$sizes$1",
width: "$sizes$4",
height: "$sizes$4",
margin: "$spacing$3",
width: "$spacing$10",
height: "$spacing$10",
borderRadius: 2,
background: value,
}}
@@ -53,7 +53,8 @@ export type Control =
| "MenuControl"
| "SelectControl"
| "FontFamilyControl"
| "ImageControl";
| "ImageControl"
| "FontWeightControl";
export type StyleConfig = BaseStyleConfig & {
control: Control;
@@ -74,6 +75,9 @@ const getControl = (property: StyleProperty): Control => {
case "backgroundImage": {
return isFeatureEnabled("assets") ? "ImageControl" : "TextControl";
}
case "fontWeight": {
return "FontWeightControl";
}
}
return "TextControl";
@@ -99,7 +103,7 @@ const createStyleConfigs = () => {
property,
appliesTo: properties[property].appliesTo,
control: getControl(property),
items: keywords.map((keyword: string) => ({
items: keywords.map((keyword) => ({
label: keyword,
name: keyword,
})),
@@ -81,7 +81,7 @@ export const WithUnits = () => {
});
return (
<Flex css={{ gap: "$3" }}>
<Flex css={{ gap: "$spacing$9" }}>
<CssValueInput
property="rowGap"
value={value}
@@ -9,6 +9,7 @@ import {
ComboboxListboxItem,
numericScrubControl,
TextFieldIconButton,
styled,
} from "@webstudio-is/design-system";
import { ChevronDownIcon } from "@webstudio-is/icons";
import {
@@ -20,10 +21,12 @@ import {
import {
type KeyboardEventHandler,
type KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useIsFromCurrentBreakpoint } from "../use-is-from-current-breakpoint";
import { useUnitSelect } from "./unit-select";
const unsetValue: UnsetValue = { type: "unset", value: "" };
@@ -56,6 +59,7 @@ const useHandleOnChange = (
// We want to switch to unit mode if entire input is a number.
if (isNumericString(input)) {
if (value.type === "unit" && String(Number(input)) !== input) return;
onChange?.({
type: "unit",
// Use previously known unit or fallback to px.
@@ -69,7 +73,7 @@ const useHandleOnChange = (
type: "keyword",
value: input,
});
}, [input, onChange]);
}, [input, value.type, onChange]);
useEffect(() => {
valueRef.current = value;
@@ -80,11 +84,18 @@ const useScrub = ({
value,
onChange,
onChangeComplete,
shouldHandleEvent,
}: {
value: StyleValue;
onChange: (value: StyleValue) => void;
onChangeComplete: (value: StyleValue) => void;
}): [React.MutableRefObject<HTMLInputElement | null>, boolean] => {
shouldHandleEvent?: (node: EventTarget) => boolean;
}): [
React.MutableRefObject<HTMLDivElement | null>,
React.MutableRefObject<HTMLInputElement | null>,
boolean
] => {
const scrubRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [isInputActive, setIsInputActive] = useState(false);
const onChangeRef = useRef(onChange);
@@ -101,10 +112,15 @@ const useScrub = ({
// Since scrub is going to call onChange and onChangeComplete callbacks, it will result in a new value and potentially new callback refs.
// We need this effect to ONLY run when type or unit changes, but not when callbacks or value.value changes.
useEffect(() => {
if (type !== "unit" || unit === undefined || inputRef.current === null) {
if (
type !== "unit" ||
unit === undefined ||
inputRef.current === null ||
scrubRef.current === null
) {
return;
}
const scrub = numericScrubControl(inputRef.current, {
const scrub = numericScrubControl(scrubRef.current, {
initialValue: valueRef.current.value,
onValueInput(event) {
onChangeRef.current({
@@ -125,12 +141,13 @@ const useScrub = ({
inputRef.current?.focus();
inputRef.current?.select();
},
shouldHandleEvent: shouldHandleEvent,
});
return scrub.disconnectedCallback;
}, [type, unit]);
}, [type, unit, shouldHandleEvent]);
return [inputRef, isInputActive];
return [scrubRef, inputRef, isInputActive];
};
const useHandleKeyDown =
@@ -207,13 +224,14 @@ type CssValueInputProps = {
*/
export const CssValueInput = ({
icon,
property,
value = unsetValue,
keywords = [],
onChange,
onChangeComplete,
onItemHighlight,
}: CssValueInputProps) => {
}: CssValueInputProps & { icon?: JSX.Element }) => {
const {
items,
getInputProps,
@@ -249,10 +267,14 @@ export const CssValueInput = ({
},
});
const [inputRef, isInputActive] = useScrub({
const shouldHandleEvent = useCallback((node) => {
return suffixRef.current?.contains?.(node) === false;
}, []);
const [scrubRef, inputRef, isInputActive] = useScrub({
value,
onChange,
onChangeComplete,
shouldHandleEvent,
});
const handleOnBlur: KeyboardEventHandler = (event) => {
@@ -270,17 +292,31 @@ export const CssValueInput = ({
closeMenu,
});
const suffix =
value.type === "keyword" ? (
<TextFieldIconButton
{...getToggleButtonProps()}
state={isOpen ? "active" : undefined}
>
<ChevronDownIcon />
</TextFieldIconButton>
) : value.type === "unit" ? (
unitSelectElement
) : null;
const isCurrentBreakpoint = useIsFromCurrentBreakpoint(property);
const prefix = icon && (
<CssValueInputIconButton
state={isCurrentBreakpoint ? "set" : undefined}
css={value.type == "unit" ? { cursor: "ew-resize" } : undefined}
>
{icon}
</CssValueInputIconButton>
);
const suffixRef = useRef<HTMLDivElement | null>(null);
const suffix = (
<Box ref={suffixRef}>
{value.type === "keyword" ? (
<TextFieldIconButton
{...getToggleButtonProps()}
state={isOpen ? "active" : undefined}
>
<ChevronDownIcon />
</TextFieldIconButton>
) : value.type === "unit" ? (
unitSelectElement
) : null}
</Box>
);
return (
<ComboboxPopper>
@@ -294,6 +330,7 @@ export const CssValueInput = ({
}}
onBlur={handleOnBlur}
onKeyDown={handleKeyDown}
baseRef={scrubRef}
inputRef={inputRef}
name={property}
state={
@@ -303,6 +340,7 @@ export const CssValueInput = ({
? "active"
: undefined
}
prefix={prefix}
suffix={suffix}
css={{ cursor: "default" }}
/>
@@ -328,3 +366,26 @@ export const CssValueInput = ({
</ComboboxPopper>
);
};
const CssValueInputIconButton = styled(TextFieldIconButton, {
variants: {
state: {
set: {
backgroundColor: "$blue4",
color: "$blue11",
"&:hover": {
backgroundColor: "$blue4",
color: "$blue11",
},
},
inherited: {
backgroundColor: "$orange4",
color: "$orange11",
"&:hover": {
backgroundColor: "$orange4",
color: "$orange11",
},
},
},
},
});
@@ -1,12 +1,10 @@
import { Flex, Label, Tooltip, IconButton } from "@webstudio-is/design-system";
import { Flex, Label, Tooltip } from "@webstudio-is/design-system";
import { useIsFromCurrentBreakpoint } from "./use-is-from-current-breakpoint";
import { propertyNameColorForSelectedBreakpoint } from "./constants";
import type { PropertyProps } from "../style-sections";
import { forwardRef, PointerEventHandler } from "react";
export const PropertyName = ({ property, label, css }: PropertyProps) => {
const isCurrentBreakpoint = useIsFromCurrentBreakpoint(property);
return (
<Tooltip content={label} delayDuration={600} disableHoverableContent={true}>
<Flex
@@ -23,15 +21,14 @@ export const PropertyName = ({ property, label, css }: PropertyProps) => {
? {
color: propertyNameColorForSelectedBreakpoint,
backgroundColor: "$colors$blue4",
padding: "calc($radii$1 / 2) $radii$1",
borderRadius: "$radii$1",
padding: "calc($spacing$3 / 2) $spacing$3",
borderRadius: "$borderRadius$4",
}
: {
color: "$hiContrast",
}),
}}
variant="contrast"
size="1"
htmlFor={property}
>
{label}
</Label>
@@ -40,37 +37,3 @@ export const PropertyName = ({ property, label, css }: PropertyProps) => {
);
};
PropertyName.displayName = "PropertyName";
export const PropertyIcon = forwardRef<
HTMLButtonElement,
PropertyProps & {
children?: JSX.Element;
[key: `on${string}`]: PointerEventHandler;
}
>(({ property, label, children, ...props }, forwardRef) => {
const isCurrentBreakpoint = useIsFromCurrentBreakpoint(property);
return (
<Tooltip content={label} delayDuration={600} disableHoverableContent={true}>
<IconButton
{...props}
ref={forwardRef}
size="1"
css={{
borderRadius: "$1",
border: "2px solid $colors$loContrast",
...(isCurrentBreakpoint && {
bc: "$colors$blue4",
color: "$colors$blue11",
"&:hover,&:active,&:focus": {
bc: "$colors$blue4",
color: "$colors$blue11",
},
}),
}}
>
{children}
</IconButton>
</Tooltip>
);
});
PropertyIcon.displayName = "PropertyIcon";
@@ -13,7 +13,7 @@ export const ShowMore = ({
<Collapsible.Root asChild onOpenChange={setIsOpen}>
<Flex direction="column" gap="3">
<Collapsible.Trigger asChild>
<Button css={{ width: "100%", gap: "$1" }}>
<Button css={{ width: "100%", gap: "$spacing$3" }}>
{isOpen ? <TriangleDownIcon /> : <TriangleRightIcon />}Show more
</Button>
</Collapsible.Trigger>
@@ -39,9 +39,9 @@ export const StylePanel = ({
if (willRender(breakpoint, canvasWidth) === false) {
return (
<Box css={{ p: "$2" }}>
<Card css={{ p: "$3", mt: "$3" }}>
<Paragraph css={{ marginBottom: "$2" }}>
<Box css={{ p: "$spacing$5" }}>
<Card css={{ p: "$spacing$9", mt: "$spacing$9" }}>
<Paragraph css={{ marginBottom: "$spacing$5" }}>
{`Please increase the canvas width.`}
</Paragraph>
<Paragraph>
@@ -54,7 +54,7 @@ export const StylePanel = ({
return (
<>
<Box css={{ px: "$3", py: "$1" }}>
<Box css={{ px: "$spacing$9", py: "$spacing$3" }}>
<SearchField
placeholder="Search"
onChange={(event) => {
@@ -15,7 +15,6 @@ import {
Box,
} from "@webstudio-is/design-system";
import { HamburgerMenuIcon, ChevronRightIcon } from "@webstudio-is/icons";
import type { Config } from "~/config";
import { ShortcutHint } from "./shortcut-hint";
import {
useIsShareDialogOpen,
@@ -27,10 +26,11 @@ import {
type ThemeSetting,
} from "~/shared/theme";
import { useClientSettings } from "~/designer/shared/client-settings";
import { dashboardPath } from "~/shared/router-utils";
const menuItemCss = {
display: "flex",
gap: "$3",
gap: "$spacing$9",
justifyContent: "space-between",
flexGrow: 1,
minWidth: 140,
@@ -99,11 +99,10 @@ const ViewMenuItem = () => {
};
type MenuProps = {
config: Config;
publish: Publish;
};
export const Menu = ({ config, publish }: MenuProps) => {
export const Menu = ({ publish }: MenuProps) => {
const navigate = useNavigate();
const [, setIsShareOpen] = useIsShareDialogOpen();
const [, setIsPublishOpen] = useIsPublishDialogOpen();
@@ -113,7 +112,7 @@ export const Menu = ({ config, publish }: MenuProps) => {
<DropdownMenuTrigger asChild>
<Box
css={{
width: "$sizes$7",
width: "$spacing$17",
height: "100%",
borderRadius: "0",
outline: "none",
@@ -138,7 +137,7 @@ export const Menu = ({ config, publish }: MenuProps) => {
<DropdownMenuItem
css={menuItemCss}
onSelect={() => {
navigate(config.dashboardPath);
navigate(dashboardPath());
}}
>
Dashboard
@@ -17,6 +17,7 @@ import {
import { useIsPublishDialogOpen } from "../../shared/nano-states";
import env from "~/shared/env";
import type { Project } from "@webstudio-is/project";
import { restPublishPath } from "~/shared/router-utils";
type PublishButtonProps = { project: Project };
const getHost = () => {
@@ -42,14 +43,14 @@ const Content = ({ project }: PublishButtonProps) => {
return (
<PopoverContent
css={{ padding: "$3" }}
css={{ padding: "$spacing$9" }}
hideArrow={true}
onFocusOutside={(event) => {
// Used to prevent closing when opened from the main dropdown menu
event.preventDefault();
}}
>
<fetcher.Form method="post" action="/rest/publish">
<fetcher.Form method="post" action={restPublishPath()}>
<Flex direction="column" gap="2">
{url !== undefined && (
<Link
@@ -90,7 +91,7 @@ export const PublishButton = ({ project }: PublishButtonProps) => {
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild aria-label="Publish">
<Button ghost css={{ display: "flex", gap: "$1" }}>
<Button ghost css={{ display: "flex", gap: "$spacing$3" }}>
<RocketIcon />
<Text>Publish</Text>
</Button>
@@ -21,7 +21,7 @@ const Content = ({ url }: ShareButtonProps) => {
}
return (
<PopoverContent
css={{ padding: "$3" }}
css={{ padding: "$spacing$9" }}
hideArrow={true}
onFocusOutside={(event) => {
// Used to prevent closing when opened from the main dropdown menu
@@ -14,7 +14,7 @@ const StyledCheckIcon = styled(CheckIcon, {
width: iconSize,
height: iconSize,
background: "$green9",
borderRadius: "$radii$round",
borderRadius: "$borderRadius$round",
});
const ellipsisKeyframes = keyframes({
@@ -1,6 +1,5 @@
import type { Publish } from "~/shared/pubsub";
import { darkTheme, Flex, type CSS } from "@webstudio-is/design-system";
import type { Config } from "~/config";
import { PreviewButton } from "./preview";
import { ShareButton } from "./share";
import { PublishButton } from "./publish";
@@ -10,19 +9,12 @@ import { Breakpoints } from "../breakpoints";
import type { Project } from "@webstudio-is/project";
type TopbarProps = {
config: Config;
css: CSS;
project: Project;
publish: Publish;
previewUrl: string;
};
export const Topbar = ({
config,
css,
project,
publish,
previewUrl,
}: TopbarProps) => {
export const Topbar = ({ css, project, publish, previewUrl }: TopbarProps) => {
return (
<Flex
className={darkTheme}
@@ -31,7 +23,7 @@ export const Topbar = ({
justify="between"
css={{
bc: "$loContrast",
height: "$sizes$7",
height: "$spacing$17",
"[data-theme=dark] &": {
boxShadow: "inset 0 -1px 0 0 $colors$panelOutline",
},
@@ -48,7 +40,7 @@ export const Topbar = ({
...css,
}}
>
<Menu config={config} publish={publish} />
<Menu publish={publish} />
<Breakpoints publish={publish} />
<Flex
align="center"
@@ -56,7 +48,7 @@ export const Topbar = ({
"& > *": {
height: "inherit",
width: "auto",
padding: "0 $2",
padding: "0 $spacing$5",
},
}}
>
@@ -74,7 +74,10 @@ export const TreePrevew = () => {
return (
treeProps && (
<Flex direction="column" css={{ pt: "$1", pb: "$1", width: "100%" }}>
<Flex
direction="column"
css={{ pt: "$spacing$3", pb: "$spacing$3", width: "100%" }}
>
<InstanceTreeNode {...treeProps} />
</Flex>
)
@@ -13,9 +13,8 @@ import {
SelectedInstanceOutline,
DropTargetOutline,
} from "./outline";
import { TextToolbar } from "./text-toolbar";
import { useSubscribeTextToolbar, TextToolbar } from "./text-toolbar";
import { useSubscribeInstanceRect } from "./hooks/use-subscribe-instance-rect";
import { useSubscribeSelectionRect } from "./hooks/use-subscribe-selection-rect";
import { useSubscribeTextEditingInstanceId } from "./hooks/use-subscribe-editing-instance-id";
const toolsStyle = {
@@ -33,8 +32,9 @@ type CanvasToolsProps = {
};
export const CanvasTools = ({ publish }: CanvasToolsProps) => {
// @todo try to setup cross-frame atoms to vaoid this
useSubscribeInstanceRect();
useSubscribeSelectionRect();
useSubscribeTextToolbar();
useSubscribeScrollState();
useSubscribeDragAndDropState();
useSubscribeTextEditingInstanceId();
@@ -1,7 +0,0 @@
import { useSubscribe } from "~/shared/pubsub";
import { useSelectionRect } from "~/designer/shared/nano-states";
export const useSubscribeSelectionRect = () => {
const [, setSelectionRect] = useSelectionRect();
useSubscribe("selectionRect", setSelectionRect);
};
@@ -37,16 +37,16 @@ const LabelContainer = styled(
{
position: "absolute",
display: "flex",
padding: "0 $1",
height: "$4",
padding: "0 $spacing$3",
height: "$spacing$10",
color: "white",
alignItems: "center",
justifyContent: "center",
gap: "$1",
fontSize: "$1",
gap: "$spacing$3",
fontSize: "$fontSize$3",
fontFamily: "$sans",
lineHeight: 1,
minWidth: "$6",
minWidth: "$spacing$13",
whiteSpace: "nowrap",
backgroundColor: "$blue9",
},
@@ -54,13 +54,13 @@ const LabelContainer = styled(
variants: {
position: {
top: {
top: "-$4",
top: "-$spacing$10",
},
inside: {
top: 0,
},
bottom: {
bottom: "-$4",
bottom: "-$spacing$10",
},
},
},
@@ -2,11 +2,43 @@ import { type Publish } from "~/shared/pubsub";
import { useMemo, useState, type MouseEventHandler } from "react";
import {
useSelectedInstanceData,
useSelectionRect,
type TextToolbarState,
useTextToolbarState,
} from "~/designer/shared/nano-states";
import { ToggleGroup, type CSS } from "@webstudio-is/design-system";
import { FontBoldIcon, FontItalicIcon, Link2Icon } from "@webstudio-is/icons";
import { createInstance } from "~/shared/tree-utils";
import {
FontBoldIcon,
FontItalicIcon,
SuperscriptIcon,
SubscriptIcon,
Link2Icon,
BrushIcon,
FormatClearIcon,
} from "@webstudio-is/icons";
import { useSubscribe } from "~/shared/pubsub";
type Format =
| "bold"
| "italic"
| "superscript"
| "subscript"
| "link"
| "span"
| "clear";
declare module "~/shared/pubsub" {
export interface PubsubMap {
showTextToolbar: TextToolbarState;
hideTextToolbar: void;
formatTextToolbar: Format;
}
}
export const useSubscribeTextToolbar = () => {
const [, setTextToolbar] = useTextToolbarState();
useSubscribe("showTextToolbar", setTextToolbar);
useSubscribe("hideTextToolbar", () => setTextToolbar(undefined));
};
const getPlacement = ({
toolbarRect,
@@ -40,8 +72,6 @@ const getPlacement = ({
return { top, left, marginBottom, marginTop, transform, visibility };
};
type Value = "Bold" | "Italic" | "Link";
const onClickPreventDefault: MouseEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
event.stopPropagation();
@@ -49,34 +79,90 @@ const onClickPreventDefault: MouseEventHandler<HTMLDivElement> = (event) => {
type ToolbarProps = {
css?: CSS;
onValueChange: (value: Value) => void;
rootRef: React.Ref<HTMLDivElement>;
state: TextToolbarState;
onToggle: (value: Format) => void;
};
const Toolbar = ({ css, onValueChange, rootRef }: ToolbarProps) => {
const Toolbar = ({ css, rootRef, state, onToggle }: ToolbarProps) => {
const value: Format[] = [];
if (state.isBold) {
value.push("bold");
}
if (state.isItalic) {
value.push("italic");
}
if (state.isSuperscript) {
value.push("superscript");
}
if (state.isSubscript) {
value.push("subscript");
}
if (state.isLink) {
value.push("link");
}
if (state.isSpan) {
value.push("span");
}
return (
<ToggleGroup.Root
ref={rootRef}
type="single"
onValueChange={onValueChange}
type="multiple"
value={value}
onValueChange={(newValues: Format[]) => {
// @todo refactor with per button callback
if (state.isBold !== newValues.includes("bold")) {
onToggle("bold");
}
if (state.isItalic !== newValues.includes("italic")) {
onToggle("italic");
}
if (state.isSuperscript !== newValues.includes("superscript")) {
onToggle("superscript");
}
if (state.isSubscript !== newValues.includes("subscript")) {
onToggle("subscript");
}
if (state.isLink !== newValues.includes("link")) {
onToggle("link");
}
if (state.isSpan !== newValues.includes("span")) {
onToggle("span");
}
if (newValues.includes("clear")) {
onToggle("clear");
}
}}
onClick={onClickPreventDefault}
css={{
position: "absolute",
borderRadius: "$1",
padding: "$1 $2",
borderRadius: "$borderRadius$4",
padding: "$spacing$3 $spacing$5",
pointerEvents: "auto",
...css,
}}
>
<ToggleGroup.Item value="Bold">
<ToggleGroup.Item value="bold">
<FontBoldIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="Italic">
<ToggleGroup.Item value="italic">
<FontItalicIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="Link">
<ToggleGroup.Item value="superscript">
<SuperscriptIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="subscript">
<SubscriptIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="link">
<Link2Icon />
</ToggleGroup.Item>
<ToggleGroup.Item value="span">
<BrushIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="clear">
<FormatClearIcon />
</ToggleGroup.Item>
</ToggleGroup.Root>
);
};
@@ -86,16 +172,19 @@ type TextToolbarProps = {
};
export const TextToolbar = ({ publish }: TextToolbarProps) => {
const [selectionRect] = useSelectionRect();
const [textToolbar] = useTextToolbarState();
const [selectedIntsanceData] = useSelectedInstanceData();
const [element, setElement] = useState<HTMLElement | null>(null);
const placement = useMemo(() => {
if (selectionRect === undefined || element === null) return;
if (textToolbar == null || element === null) return;
const toolbarRect = element.getBoundingClientRect();
return getPlacement({ toolbarRect, selectionRect });
}, [selectionRect, element]);
return getPlacement({
toolbarRect,
selectionRect: textToolbar.selectionRect,
});
}, [textToolbar, element]);
if (selectionRect === undefined || selectedIntsanceData === undefined) {
if (textToolbar == null || selectedIntsanceData === undefined) {
return null;
}
@@ -103,13 +192,13 @@ export const TextToolbar = ({ publish }: TextToolbarProps) => {
<Toolbar
rootRef={setElement}
css={placement}
onValueChange={(component) => {
const instance = createInstance({ component });
state={textToolbar}
onToggle={(value) =>
publish({
type: "insertInlineInstance",
payload: instance,
});
}}
type: "formatTextToolbar",
payload: value,
})
}
/>
);
};
@@ -0,0 +1,50 @@
import { type ComponentProps } from "react";
import type { AssetType } from "@webstudio-is/asset-uploader";
import { Flex, SearchField } from "@webstudio-is/design-system";
import { AssetUpload } from "./asset-upload";
import { NotFound } from "./not-found";
import { Separator } from "./separator";
type AssetsShellProps = {
searchProps: ComponentProps<typeof SearchField>;
children: JSX.Element;
type: AssetType;
isEmpty: boolean;
};
export const AssetsShell = ({
searchProps,
isEmpty,
children,
type,
}: AssetsShellProps) => {
return (
<Flex
direction="column"
css={{
overflow: "hidden",
paddingTop: "$spacing$3",
paddingBottom: "$spacing$9",
}}
>
<Flex
css={{ py: "$spacing$5", px: "$spacing$9" }}
gap="2"
direction="column"
>
<AssetUpload type={type} />
<SearchField {...searchProps} autoFocus placeholder="Search" />
</Flex>
<Separator />
{isEmpty && <NotFound />}
<Flex
css={{
flexDirection: "column",
px: "$spacing$9",
}}
>
{children}
</Flex>
</Flex>
);
};
@@ -1,4 +1,6 @@
export * from "./asset-upload";
export * from "./use-assets";
export * from "./types";
export * from "./use-set-assets";
export * from "./separator";
export * from "./use-search";
export * from "./assets-shell";
@@ -0,0 +1,9 @@
import { Flex } from "@webstudio-is/design-system";
export const NotFound = () => {
return (
<Flex align="center" justify="center" css={{ height: 100 }}>
No matching assets
</Flex>
);
};
@@ -0,0 +1,9 @@
import {
Separator as SeparatorPrimitive,
styled,
} from "@webstudio-is/design-system";
export const Separator = styled(SeparatorPrimitive, {
marginTop: "$spacing$3",
marginBottom: "$spacing$5",
});
@@ -1,9 +1,8 @@
import { Asset } from "@webstudio-is/asset-uploader";
export type PreviewAsset = Pick<
Asset,
"path" | "name" | "id" | "status" | "format"
>;
export type PreviewAsset = Pick<Asset, "path" | "name" | "id" | "format"> & {
status: "uploading";
};
export type ActionData = {
uploadedAssets?: Array<Asset>;
@@ -8,6 +8,7 @@ import {
import { toast } from "@webstudio-is/design-system";
import ObjectID from "bson-objectid";
import { useMemo } from "react";
import { restAssetsPath } from "~/shared/router-utils";
import { useAssets as useAssetsState, useProject } from "../nano-states";
import { PreviewAsset } from "./types";
@@ -62,7 +63,7 @@ export const useAssets = (type: AssetType) => {
const { submit, Form } = useFetcher();
const [assets, setAssets] = useAssetsState();
const [project] = useProject();
const action = `/rest/assets/${project?.id}`;
const action = project && restAssetsPath({ projectId: project.id });
const handleDelete = (ids: Array<string>) => {
const formData = new FormData();
@@ -0,0 +1,28 @@
import { useCallback, useEffect, useRef, useState } from "react";
export const useFilter = <Item>({
items,
onReset,
}: {
onReset: () => void;
items: Array<Item>;
}) => {
const [filteredItems, setFilteredItems] = useState(items);
const onResetRef = useRef(onReset);
onResetRef.current = onReset;
const resetFilteredItems = useCallback(() => {
setFilteredItems(items);
}, [items]);
useEffect(() => {
setFilteredItems(items);
onResetRef.current();
}, [items]);
return {
filteredItems,
resetFilteredItems,
setFilteredItems,
};
};
@@ -1,15 +1,16 @@
import {
Flex,
Separator as SeparatorPrimitive,
SearchField,
List,
ListItem,
useList,
findNextListIndex,
styled,
} from "@webstudio-is/design-system";
import { AssetUpload, useAssets } from "~/designer/shared/assets";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AssetsShell,
Separator,
useAssets,
useSearch,
} from "~/designer/shared/assets";
import { useMemo, useState } from "react";
import { useMenu } from "./item-menu";
import { CheckIcon } from "@webstudio-is/icons";
import {
@@ -19,38 +20,7 @@ import {
groupItemsByType,
toItems,
} from "./item-utils";
import { useSearch } from "./use-search";
const NotFound = () => {
return (
<Flex align="center" justify="center" css={{ height: 100 }}>
Font not found
</Flex>
);
};
const useFilteredItems = ({ onReset }: { onReset: () => void }) => {
const { assets } = useAssets("font");
const fontItems = useMemo(() => toItems(assets), [assets]);
const [filteredItems, setFilteredItems] = useState(fontItems);
const onResetRef = useRef(onReset);
onResetRef.current = onReset;
const resetFilteredItems = useCallback(() => {
setFilteredItems(fontItems);
}, [fontItems]);
useEffect(() => {
setFilteredItems(fontItems);
onResetRef.current();
}, [fontItems]);
return {
filteredItems,
resetFilteredItems,
setFilteredItems,
};
};
import { useFilter } from "../assets/use-filter";
const useLogic = ({
onChange,
@@ -61,12 +31,13 @@ const useLogic = ({
}) => {
const { assets, handleDelete: handleDeleteAssets } = useAssets("font");
const [selectedIndex, setSelectedIndex] = useState(-1);
const { filteredItems, resetFilteredItems, setFilteredItems } =
useFilteredItems({
onReset() {
searchProps.onCancel();
},
});
const fontItems = useMemo(() => toItems(assets), [assets]);
const { filteredItems, resetFilteredItems, setFilteredItems } = useFilter({
items: fontItems,
onReset() {
searchProps.onCancel();
},
});
const { uploadedItems, systemItems, groupedItems } = useMemo(
() => groupItemsByType(filteredItems),
[filteredItems]
@@ -136,11 +107,6 @@ const useLogic = ({
};
};
const Separator = styled(SeparatorPrimitive, {
marginTop: "$1",
marginBottom: "$2",
});
type FontsManagerProps = {
value: string;
onChange: (value: string) => void;
@@ -180,46 +146,33 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
};
return (
<Flex
direction="column"
css={{ overflow: "hidden", paddingTop: "$1", paddingBottom: "$3" }}
<AssetsShell
searchProps={searchProps}
type="font"
isEmpty={groupedItems.length === 0}
>
<Flex css={{ py: "$2", px: "$3" }} gap="2" direction="column">
<AssetUpload type="font" />
<SearchField {...searchProps} autoFocus placeholder="Search" />
</Flex>
<Separator />
{groupedItems.length === 0 && <NotFound />}
<Flex
css={{
flexDirection: "column",
gap: "$3",
px: "$3",
<List
{...listProps}
onBlur={(event) => {
if (isMenuOpen === false) {
listProps.onBlur(event);
}
}}
>
<List
{...listProps}
onBlur={(event) => {
if (isMenuOpen === false) {
listProps.onBlur(event);
}
}}
>
{uploadedItems.length !== 0 && (
<ListItem state="disabled">{"Uploaded"}</ListItem>
)}
{uploadedItems.map(renderItem)}
{systemItems.length !== 0 && (
<>
{uploadedItems.length !== 0 && <Separator />}
<ListItem state="disabled">{"System"}</ListItem>
</>
)}
{systemItems.map((item, index) =>
renderItem(item, index + uploadedItems.length)
)}
</List>
</Flex>
</Flex>
{uploadedItems.length !== 0 && (
<ListItem state="disabled">{"Uploaded"}</ListItem>
)}
{uploadedItems.map(renderItem)}
{systemItems.length !== 0 && (
<>
{uploadedItems.length !== 0 && <Separator />}
<ListItem state="disabled">{"System"}</ListItem>
</>
)}
{systemItems.map((item, index) =>
renderItem(item, index + uploadedItems.length)
)}
</List>
</AssetsShell>
);
};
@@ -8,7 +8,7 @@ import {
DropdownMenuPortal,
styled,
} from "@webstudio-is/design-system";
import { DotsHorizontalIcon } from "@webstudio-is/icons";
import { MenuIcon } from "@webstudio-is/icons";
import { type FocusEventHandler, useState, useRef, useEffect } from "react";
const MenuButton = styled(IconButton, {
@@ -64,7 +64,7 @@ const ItemMenu = ({
event.stopPropagation();
}}
>
<DotsHorizontalIcon />
<MenuIcon />
</MenuButton>
</DropdownMenuTrigger>
<DropdownMenuPortal>
@@ -0,0 +1,20 @@
import { Flex, Text } from "@webstudio-is/design-system";
import type { ComponentProps } from "react";
type FilenameProps = Omit<ComponentProps<typeof Text>, "children"> & {
children: string;
};
export const Filename = ({ children, ...props }: FilenameProps) => {
const splitName = children.split(".");
const extension = splitName[splitName.length - 1];
const baseName = children.substr(0, children.length - extension.length - 1);
return (
<Flex>
<Text truncate {...props}>
{baseName}
</Text>
<Text {...props}>{extension}</Text>
</Flex>
);
};
@@ -37,8 +37,8 @@ export const ImageInfoTrigger = ({
visibility: cssVars.use(triggerVisibilityVar, "hidden"),
position: "absolute",
color: "$slate11",
top: "$1",
right: "$1",
top: "$spacing$3",
right: "$spacing$3",
cursor: "pointer",
transition: "opacity 100ms ease",
"&:hover": {
@@ -8,41 +8,7 @@ import {
} from "@webstudio-is/icons";
import prettyBytes from "pretty-bytes";
import { Asset } from "@webstudio-is/asset-uploader";
const Filename = ({ name }: { name: string }) => {
const splitName = name.split(".");
const extension = splitName[splitName.length - 1];
const lastLetterBeforeExtension = splitName[splitName.length - 2]
.split("")
.pop();
return (
<Box
css={{
position: "relative",
maxWidth: "70%",
}}
>
<Text
variant="label"
data-extension={`${lastLetterBeforeExtension}.${extension}`}
css={{
whiteSpace: "nowrap",
textOverflow: "ellipsis",
overflow: "hidden",
"&::after": {
content: "attr(data-extension)",
position: "absolute",
left: "100%",
top: 0,
whiteSpace: "nowrap",
},
}}
>
{name}
</Text>
</Box>
);
};
import { Filename } from "./filename";
type ImageInfoProps = {
asset: Asset;
@@ -53,36 +19,36 @@ export const ImageInfo = ({ asset, onDelete }: ImageInfoProps) => {
const { size, meta, id, name } = asset;
return (
<>
<Box css={{ p: "$2 $3" }}>
<Box css={{ p: "$spacing$5 $spacing$9" }}>
<Grid columns={2} align="center" gap={2}>
<Box css={{ width: 100 }}>
<Filename name={name} />
<Filename variant="label">{name}</Filename>
</Box>
<Flex align="center" css={{ gap: "$1" }}>
<Flex align="center" css={{ gap: "$spacing$3" }}>
<CloudIcon />
<Text variant="label">{prettyBytes(size)}</Text>
</Flex>
</Grid>
</Box>
{"width" in meta && "height" in meta ? (
<Box css={{ p: "$2 $3" }}>
<Box css={{ p: "$spacing$5 $spacing$9" }}>
<Grid columns={2} gap={2} align="center">
<Flex align="center" css={{ gap: "$1" }}>
<Flex align="center" css={{ gap: "$spacing$3" }}>
<SizeIcon />
<Text variant="label">
{meta.width} x {meta.height}
</Text>
</Flex>{" "}
<Flex align="center" css={{ gap: "$1" }}>
<Flex align="center" css={{ gap: "$spacing$3" }}>
<AspectRatioIcon />
<Text variant="label">{getFormattedAspectRatio(meta)}</Text>
</Flex>
</Grid>
</Box>
) : null}
<Box css={{ p: "$2 $3" }}>
<Box css={{ p: "$spacing$5 $spacing$9" }}>
<Button variant="red" size="2" onClick={() => onDelete([id])}>
<Flex align="center" css={{ gap: "$1" }}>
<Flex align="center" css={{ gap: "$spacing$3" }}>
<TrashIcon />
Delete
</Flex>
@@ -1,29 +1,101 @@
import { Box, Flex, Grid } from "@webstudio-is/design-system";
import { useState } from "react";
import { findNextListIndex, Grid } from "@webstudio-is/design-system";
import type { Asset } from "@webstudio-is/asset-uploader";
import { AssetsShell, PreviewAsset, useAssets, useSearch } from "../assets";
import { useFilter } from "../assets/use-filter";
import { ImageThumbnail } from "./image-thumbnail";
import { AssetUpload, useAssets } from "../assets";
import { matchSorter } from "match-sorter";
type ImageManagerProps = {
onSelect?: (asset: Asset) => void;
const filterItems = (search: string, items: Array<Asset | PreviewAsset>) => {
return matchSorter(items, search, {
keys: [(item) => item.name],
});
};
export const ImageManager = ({ onSelect }: ImageManagerProps) => {
const useLogic = ({ onChange }: { onChange?: (asset: Asset) => void }) => {
const { assets, handleDelete } = useAssets("image");
const [selectedIndex, setSelectedIndex] = useState(-1);
const { filteredItems, resetFilteredItems, setFilteredItems } = useFilter({
items: assets,
onReset() {
searchProps.onCancel();
},
});
const searchProps = useSearch({
onCancel: resetFilteredItems,
onSearch(search) {
if (search === "") {
return resetFilteredItems();
}
const items = filterItems(search, assets);
setFilteredItems(items);
},
onSelect(direction) {
if (direction === "current") {
setSelectedIndex(selectedIndex);
const asset = filteredItems[selectedIndex];
if (asset?.status === "uploaded") {
onChange?.(asset);
}
return;
}
const nextIndex = findNextListIndex(
selectedIndex,
filteredItems.length,
direction
);
setSelectedIndex(nextIndex);
},
});
const handleSelect = (asset?: Asset | PreviewAsset) => {
const selectedIndex = filteredItems.findIndex(
(item) => item.id === asset?.id
);
setSelectedIndex(selectedIndex);
};
return {
searchProps,
handleDelete,
filteredItems,
handleSelect,
selectedIndex,
};
};
type ImageManagerProps = {
onChange?: (asset: Asset) => void;
};
export const ImageManager = ({ onChange }: ImageManagerProps) => {
const {
handleDelete,
handleSelect,
filteredItems,
searchProps,
selectedIndex,
} = useLogic({ onChange });
return (
<Flex gap="3" direction="column" css={{ padding: "$1", paddingTop: "$2" }}>
<Box css={{ padding: "$2" }}>
<AssetUpload type="image" />
</Box>
<Grid columns={2} gap={2}>
{assets.map((asset) => (
<AssetsShell
searchProps={searchProps}
isEmpty={filteredItems.length === 0}
type="image"
>
<Grid columns={3} gap={2}>
{filteredItems.map((asset, index) => (
<ImageThumbnail
key={asset.id}
asset={asset}
onDelete={handleDelete}
onSelect={onSelect}
onSelect={handleSelect}
onChange={onChange}
state={index === selectedIndex ? "selected" : undefined}
/>
))}
</Grid>
</Flex>
</AssetsShell>
);
};
@@ -1,11 +1,17 @@
import { useEffect, useState } from "react";
import { Box } from "@webstudio-is/design-system";
import {
useEffect,
useState,
type KeyboardEvent,
type FocusEvent,
} from "react";
import { Box, styled } from "@webstudio-is/design-system";
import placeholderImage from "~/shared/images/image-placeholder.svg";
import brokenImage from "~/shared/images/broken-image-placeholder.svg";
import { UploadingAnimation } from "./uploading-animation";
import { ImageInfoTrigger, imageInfoTriggerCssVars } from "./image-info-tigger";
import type { PreviewAsset } from "~/designer/shared/assets";
import { Asset } from "@webstudio-is/asset-uploader";
import type { Asset } from "@webstudio-is/asset-uploader";
import { Filename } from "./filename";
const useImageWithFallback = ({
path = placeholderImage,
@@ -24,72 +30,102 @@ const useImageWithFallback = ({
return src;
};
type ThumbnailProps = {
path?: string;
status: Asset["status"];
onClick: () => void;
};
const ThumbnailContainer = styled(Box, {
position: "relative",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
border: "2px solid transparent",
borderRadius: "$borderRadius$4",
outline: 0,
gap: "$spacing$3",
overflow: "hidden",
backgroundColor: "$slate4",
"&:hover": imageInfoTriggerCssVars({ show: true }),
variants: {
status: {
uploading: {
filter: "blur(1px)",
opacity: 0.7,
},
uploaded: {},
},
state: {
selected: {
boxShadow:
"0px 0px 0px 2px $colors$blue10, 0px 0px 0px 2px $colors$blue10",
},
},
},
});
const Thumbnail = ({ path, status, onClick }: ThumbnailProps) => {
const src = useImageWithFallback({ path });
const Thumbnail = styled(Box, {
width: "$spacing$19",
height: "$spacing$19",
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
flexShrink: 0,
});
return (
<Box
css={{
backgroundImage: `url("${src}")`,
width: "100%",
height: "100%",
backgroundSize: "contain",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
position: "absolute",
left: 0,
top: 0,
...(status === "uploading"
? { filter: "blur(1px)", opacity: 0.7 }
: {}),
}}
onClick={onClick}
></Box>
);
type ImageThumbnailProps = {
asset: Asset | PreviewAsset;
onDelete: (ids: Array<string>) => void;
onSelect: (asset?: Asset | PreviewAsset) => void;
onChange?: (asset: Asset) => void;
state?: "selected";
};
export const ImageThumbnail = ({
asset,
onDelete,
onSelect,
}: {
asset: Asset | PreviewAsset;
onDelete: (ids: Array<string>) => void;
onSelect?: (asset: Asset) => void;
}) => {
onChange,
state,
}: ImageThumbnailProps) => {
const { path, status, name } = asset;
const description =
"description" in asset && asset.description ? asset.description : name;
const [isDeleting, setIsDeleting] = useState(false);
const isUploading = status === "uploading";
const isUploadedAsset = isUploading === false && "size" in asset;
const [isDeleting, setIsDeleting] = useState(false);
const src = useImageWithFallback({ path });
return (
<Box
<ThumbnailContainer
title={description}
css={{
aspectRatio: "1/1",
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: "0 $2",
position: "relative",
"&:hover": imageInfoTriggerCssVars({ show: true }),
tabIndex={0}
status={status}
state={state}
onFocus={() => onSelect?.(asset)}
onBlur={(event: FocusEvent) => {
const isFocusWithin = event.currentTarget.contains(event.relatedTarget);
if (isFocusWithin === false) {
onSelect();
}
}}
onKeyDown={(event: KeyboardEvent) => {
if (event.code === "Enter" && asset.status === "uploaded") {
onChange?.(asset);
}
}}
>
<Thumbnail
path={path}
status={status}
css={{ backgroundImage: `url("${src}")` }}
onClick={() => {
if (isUploadedAsset) onSelect?.(asset);
if (isUploadedAsset) onChange?.(asset);
}}
/>
<Box
css={{
width: "100%",
// @todo should be a token from design system
height: 12,
}}
>
<Filename variant={"tiny"}>{name}</Filename>
</Box>
{isUploadedAsset && (
<ImageInfoTrigger
asset={asset}
@@ -100,6 +136,6 @@ export const ImageThumbnail = ({
/>
)}
{(isUploading || isDeleting) && <UploadingAnimation />}
</Box>
</ThumbnailContainer>
);
};
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff. Show more