Merge branch 'main' into feature-style-panel-flexChild-section
No files matched your search
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.9 KiB |
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||