feat: added navigator panel keyboard shortcut and refactored navigator docking behavior (#3961)

## Description

With shortcuts, it became very clear that we need to make navigator
closable even when it is undocked, because a shortcut should't just stop
working.

I had to refactor loading animation because to cover with overlay the
panel it now need a different way of rendering the overlay. Instead of
passing some CSS, I am now rendering an overlay above everything else.


## Steps for reproduction

1. Navigator should close/open using
  a) tab trigger
  b) close button in the panel
  c) keyboard shortcut (z)
2. It should behave the same way in docked and undocked state, with only
one difference: when undocked - it takes space in the layout, makes
canvas smaller. Docked - it goes above.




## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
Oleg Isonen authored and GitHub committed 2024-08-20 20:27:46 +02:00
1 parent 86a5512c77
commit 9bfa855fab
17 files changed
+334 -370

No files matched your search

+37 -153
View File
@@ -4,23 +4,12 @@ import { TooltipProvider } from "@radix-ui/react-tooltip";
import { usePublish, $publisher } from "~/shared/pubsub";
import type { Build } from "@webstudio-is/project-build";
import type { Project } from "@webstudio-is/project";
import {
theme,
Box,
type CSS,
Flex,
Grid,
Progress,
} from "@webstudio-is/design-system";
import { theme, Box, type CSS, Flex, Grid } from "@webstudio-is/design-system";
import type { AuthPermit } from "@webstudio-is/trpc-interface/index.server";
import { createImageLoader } from "@webstudio-is/image";
import { registerContainers, useBuilderStore } from "~/shared/sync";
import { startProjectSync, useSyncServer } from "./shared/sync/sync-server";
import {
SidebarLeft,
NavigatorContent,
useNavigatorLayout,
} from "./features/sidebar-left";
import { SidebarLeft } from "./features/sidebar-left";
import { Inspector } from "./features/inspector";
import { Topbar } from "./features/topbar";
import { Footer } from "./features/footer";
@@ -41,7 +30,7 @@ import {
$imageLoader,
$textEditingInstanceSelector,
} from "~/shared/nano-states";
import { type Settings } from "./shared/client-settings";
import { $settings, type Settings } from "./shared/client-settings";
import { getBuildUrl } from "~/shared/router-utils";
import { useCopyPaste } from "~/shared/copy-paste";
import { BlockingAlerts } from "./features/blocking-alerts";
@@ -52,21 +41,22 @@ import { AiCommandBar } from "./features/ai/ai-command-bar";
import { ProjectSettings } from "./features/project-settings";
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
import {
$activeSidebarPanel,
$dataLoadingState,
$isCloneDialogOpen,
$loadingState,
$userPlanFeatures,
type SidebarPanelName,
} from "./shared/nano-states";
import { CloneProjectDialog } from "~/shared/clone-project";
import type { TokenPermissions } from "@webstudio-is/authorization-token";
import { useToastErrors } from "~/shared/error/toast-error";
import { canvasApi } from "~/shared/canvas-api";
import { loadBuilderData, setBuilderData } from "~/shared/builder-data";
import { WebstudioIcon } from "@webstudio-is/icons";
import { initBuilderApi } from "~/shared/builder-api";
import { updateWebstudioData } from "~/shared/instance-utils";
import { migrateWebstudioDataMutable } from "~/shared/webstudio-data-migrator";
import { useInterval } from "~/shared/hook-utils/use-interval";
import { Loading, LoadingBackground } from "./shared/loading";
registerContainers();
@@ -134,14 +124,17 @@ const Main = ({ children }: { children: ReactNode }) => (
type ChromeWrapperProps = {
children: Array<JSX.Element | null | false>;
isPreviewMode: boolean;
navigatorLayout: Settings["navigatorLayout"];
};
const getChromeLayout = ({
isPreviewMode,
navigatorLayout,
activeSidebarPanel,
}: {
isPreviewMode: boolean;
navigatorLayout: Settings["navigatorLayout"];
activeSidebarPanel?: SidebarPanelName;
}) => {
if (isPreviewMode) {
return {
@@ -154,7 +147,7 @@ const getChromeLayout = ({
};
}
if (navigatorLayout === "undocked") {
if (navigatorLayout === "undocked" && activeSidebarPanel !== "none") {
return {
gridTemplateColumns: `auto ${theme.spacing[30]} 1fr ${theme.spacing[30]}`,
gridTemplateAreas: `
@@ -175,11 +168,16 @@ const getChromeLayout = ({
};
};
const ChromeWrapper = ({ children, isPreviewMode }: ChromeWrapperProps) => {
const navigatorLayout = useNavigatorLayout();
const ChromeWrapper = ({
children,
isPreviewMode,
navigatorLayout,
}: ChromeWrapperProps) => {
const activeSidebarPanel = useStore($activeSidebarPanel);
const gridLayout = getChromeLayout({
isPreviewMode,
navigatorLayout,
activeSidebarPanel,
});
return (
@@ -197,101 +195,6 @@ const ChromeWrapper = ({ children, isPreviewMode }: ChromeWrapperProps) => {
);
};
type NavigatorPanelProps = {
isPreviewMode: boolean;
navigatorLayout: "docked" | "undocked";
css: CSS;
};
const NavigatorPanel = ({
isPreviewMode,
navigatorLayout,
css,
}: NavigatorPanelProps) => {
if (navigatorLayout === "docked") {
return;
}
return (
<SidePanel gridArea="navigator" isPreviewMode={isPreviewMode}>
<Box
css={{
borderRight: `1px solid ${theme.colors.borderMain}`,
width: theme.spacing[30],
height: "100%",
...css,
}}
>
<NavigatorContent isClosable={false} />
</Box>
</SidePanel>
);
};
const revealAnimation = ({
show,
backgroundColor,
}: {
show: boolean;
backgroundColor: string;
}): CSS => ({
position: "relative",
"> ::after": {
content: "",
position: "absolute",
inset: 0,
zIndex: 1,
transitionDuration: "300ms",
pointerEvents: "none",
transitionProperty: "opacity",
backgroundColor,
opacity: show ? 0 : 1,
},
});
const ProgressIndicator = ({ value }: { value: number }) => {
const [fakeValue, setFakeValue] = useState(value);
useInterval((intervalId) => {
setFakeValue((previousFakeValue) => {
// Makeing sure fake value is not higher than real value to prevent jumping back
let nextFakeValue = Math.max(previousFakeValue + 1, value);
// Make sure fake value is not lower than 10 to avoid showing empty progress.
nextFakeValue = Math.max(nextFakeValue, 10);
// Making sure fake value can't get bigger than 100, now that it reached 100 we can stop faking it.
if (nextFakeValue >= 100) {
clearInterval(intervalId);
return previousFakeValue;
}
return nextFakeValue;
});
}, 100);
if (value >= 100) {
return;
}
return (
<Flex
direction="column"
gap="3"
css={{
position: "absolute",
inset: 0,
justifyContent: "center",
alignItems: "center",
zIndex: 1,
}}
>
<WebstudioIcon
size={60}
style={{ filter: "drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.7))" }}
/>
<Progress value={fakeValue} />
</Flex>
);
};
export type BuilderProps = {
project: Project;
publisherHost: string;
@@ -393,7 +296,7 @@ export const Builder = ({
[publishRef, onRefReadCanvas]
);
const navigatorLayout = useNavigatorLayout();
const { navigatorLayout } = useStore($settings);
const dataLoadingState = useStore($dataLoadingState);
const [loadingState, setLoadingState] = useState(() => $loadingState.get());
@@ -453,28 +356,29 @@ export const Builder = ({
onPointerDown={handlePointerDown}
onInput={handleInput}
>
<ChromeWrapper isPreviewMode={isPreviewMode}>
<ChromeWrapper
isPreviewMode={isPreviewMode}
navigatorLayout={navigatorLayout}
>
<ProjectSettings />
<Topbar
project={project}
hasProPlan={userPlanFeatures.hasProPlan}
css={{
gridArea: "header",
...revealAnimation({
css={{ gridArea: "header" }}
loading={
<LoadingBackground
// Looks nicer when topbar is already visible earlier, so user has more sense of progress.
show: loadingState.readyStates.get("dataLoadingState") ?? false,
backgroundColor: theme.colors.backgroundTopbar,
}),
}}
show={
loadingState.readyStates.get("dataLoadingState")
? false
: true
}
backgroundColor={theme.colors.backgroundTopbar}
/>
}
/>
<Main>
<Workspace
onTransitionEnd={onTransitionEnd}
css={revealAnimation({
show: loadingState.state === "ready",
backgroundColor: theme.colors.backgroundCanvas,
})}
>
<Workspace onTransitionEnd={onTransitionEnd}>
{dataLoadingState === "loaded" && (
<CanvasIframe
ref={iframeRefCallback}
@@ -485,33 +389,13 @@ export const Builder = ({
</Workspace>
<AiCommandBar isPreviewMode={isPreviewMode} />
</Main>
<NavigatorPanel
isPreviewMode={isPreviewMode}
navigatorLayout={navigatorLayout}
css={revealAnimation({
show: loadingState.state === "ready",
backgroundColor: theme.colors.backgroundCanvas,
})}
/>
<SidePanel
gridArea="sidebar"
css={revealAnimation({
show: loadingState.state === "ready",
backgroundColor: theme.colors.backgroundCanvas,
})}
>
<SidePanel gridArea="sidebar">
<SidebarLeft publish={publish} />
</SidePanel>
<SidePanel
gridArea="inspector"
isPreviewMode={isPreviewMode}
css={{
overflow: "hidden",
...revealAnimation({
show: loadingState.state === "ready",
backgroundColor: theme.colors.backgroundCanvas,
}),
}}
css={{ overflow: "hidden" }}
>
<Inspector navigatorLayout={navigatorLayout} />
</SidePanel>
@@ -522,7 +406,7 @@ export const Builder = ({
project={project}
/>
</ChromeWrapper>
<ProgressIndicator value={loadingState.progress} />
<Loading state={loadingState} />
<BlockingAlerts />
</div>
</TooltipProvider>
@@ -39,7 +39,7 @@ import { fetchTranscription } from "./ai-fetch-transcription";
import { fetchResult } from "./ai-fetch-result";
import { useEffectEvent } from "~/shared/hook-utils/effect-event";
import { AiApiException, RateLimitException } from "./api-exceptions";
import { useClientSettings } from "~/builder/shared/client-settings";
import { getSetting, setSetting } from "~/builder/shared/client-settings";
import { flushSync } from "react-dom";
type PartialButtonProps<T = ComponentPropsWithoutRef<typeof Button>> = {
@@ -64,12 +64,10 @@ const initialPrompts = [
export const AiCommandBar = ({ isPreviewMode }: { isPreviewMode: boolean }) => {
const [value, setValue] = useState("");
const [prompts, setPrompts] = useState<string[]>(initialPrompts);
const [clientSettings, setClientSetting, isClientSettingsLoaded] =
useClientSettings();
const isMenuOpen = isClientSettingsLoaded && clientSettings.isAiMenuOpen;
const setIsMenuOpen = useEffectEvent((value: boolean) =>
setClientSetting("isAiMenuOpen", value)
);
const isMenuOpen = getSetting("isAiMenuOpen");
const setIsMenuOpen = (value: boolean) => {
setSetting("isAiMenuOpen", value);
};
const [isAudioTranscribing, setIsAudioTranscribing] = useState(false);
const [isAiRequesting, setIsAiRequesting] = useState(false);
@@ -273,10 +271,7 @@ export const AiCommandBar = ({ isPreviewMode }: { isPreviewMode: boolean }) => {
selectPrompt();
};
if (
isClientSettingsLoaded === false ||
clientSettings.isAiCommandBarVisible === false
) {
if (getSetting("isAiCommandBarVisible") === false) {
return;
}
@@ -1,2 +1 @@
export { SidebarLeft } from "./sidebar-left";
export { NavigatorContent, useNavigatorLayout } from "./panels/navigator";
@@ -1,11 +1,12 @@
import type { ReactNode } from "react";
import type { IconComponent } from "@webstudio-is/icons";
import type { SidebarPanelName } from "~/builder/shared/nano-states";
import * as components from "./components";
import * as navigator from "./navigator";
import * as assets from "./assets";
import * as pages from "./pages";
import * as marketplace from "./marketplace";
import type { TabContentProps, TabName } from "../types";
import type { ReactNode } from "react";
import type { IconComponent } from "@webstudio-is/icons";
import type { TabContentProps } from "../types";
type PanelExports = {
TabContent: (props: TabContentProps) => ReactNode;
@@ -13,7 +14,7 @@ type PanelExports = {
label: ReactNode;
};
export const panels = new Map<TabName, PanelExports>([
export const panels = new Map<SidebarPanelName, PanelExports>([
["components", components],
["pages", pages],
["navigator", navigator],
@@ -1,2 +1 @@
export * from "./navigator";
export * from "./navigator-content";
@@ -1,53 +0,0 @@
import { Flex, Separator } from "@webstudio-is/design-system";
import { NavigatorTree } from "~/builder/shared/navigator-tree";
import { Header, CloseButton, Root } from "../../shared/panel";
import { CssPreview } from "./css-preview";
import { useHotkeys } from "react-hotkeys-hook";
import { emitCommand } from "~/builder/shared/commands";
import {
useClientSettings,
type Settings,
} from "~/builder/shared/client-settings";
export const useNavigatorLayout = (
onChange?: (navigatorLayout: Settings["navigatorLayout"]) => void
) => {
// We need to render the detached state only once the setting was actually loaded from local storage.
// Otherwise we may show the detached state because its the default and then hide it immediately.
const [clientSettings, _, isLoaded] = useClientSettings();
const navigatorLayout = isLoaded
? clientSettings.navigatorLayout
: "undocked";
onChange?.(navigatorLayout);
return navigatorLayout;
};
type NavigatorProps = {
isClosable?: boolean;
onClose?: () => void;
};
export const NavigatorContent = ({
isClosable = true,
onClose,
}: NavigatorProps) => {
const shortcutRef = useHotkeys<HTMLDivElement>(
"enter",
() => emitCommand("editInstanceText"),
[]
);
return (
<Root ref={shortcutRef}>
<Header
title="Navigator"
suffix={isClosable && <CloseButton onClick={() => onClose?.()} />}
/>
<Flex grow direction="column" justify="end">
<NavigatorTree />
<Separator />
<CssPreview />
</Flex>
</Root>
);
};
@@ -1,17 +1,39 @@
import { NavigatorIcon } from "@webstudio-is/icons";
import { NavigatorContent } from "./navigator-content";
import { Kbd, Text, Flex, Separator } from "@webstudio-is/design-system";
import { useHotkeys } from "react-hotkeys-hook";
import { NavigatorTree } from "~/builder/shared/navigator-tree";
import { emitCommand } from "~/builder/shared/commands";
import type { TabContentProps } from "../../types";
import { Header, CloseButton, Root } from "../../shared/panel";
import { CssPreview } from "./css-preview";
export const TabContent = ({ onSetActiveTab }: TabContentProps) => {
const shortcutRef = useHotkeys<HTMLDivElement>(
"enter",
() => emitCommand("editInstanceText"),
[]
);
return (
<NavigatorContent
onClose={() => {
onSetActiveTab("none");
}}
/>
<Root ref={shortcutRef}>
<Header
title="Navigator"
suffix={<CloseButton onClick={() => onSetActiveTab("none")} />}
/>
<Flex grow direction="column" justify="end">
<NavigatorTree />
<Separator />
<CssPreview />
</Flex>
</Root>
);
};
export const Icon = NavigatorIcon;
export const label = "Navigator";
export const label = (
<Text>
Navigator&nbsp;&nbsp;
<Kbd value={["z"]} color="moreSubtle" />
</Text>
);
@@ -1,15 +1,19 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { Box, rawTheme } from "@webstudio-is/design-system";
import { useSubscribe, type Publish } from "~/shared/pubsub";
import { $dragAndDropState, $isPreviewMode } from "~/shared/nano-states";
import { panels } from "./panels";
import { useClientSettings } from "~/builder/shared/client-settings";
import { Flex } from "@webstudio-is/design-system";
import { theme } from "@webstudio-is/design-system";
import { AiIcon, HelpIcon } from "@webstudio-is/icons";
import { HelpPopover } from "./help-popover";
import { useStore } from "@nanostores/react";
import { $activeSidebarPanel } from "~/builder/shared/nano-states";
import {
$activeSidebarPanel,
setActiveSidebarPanel,
toggleActiveSidebarPanel,
type SidebarPanelName,
} from "~/builder/shared/nano-states";
import {
SidebarButton,
SidebarTabs,
@@ -24,44 +28,19 @@ import {
useOnDropEffect,
useExternalDragStateEffect,
} from "~/builder/shared/assets/drag-monitor";
import type { TabName } from "./types";
import { getSetting, setSetting } from "~/builder/shared/client-settings";
const none = { TabContent: () => null };
const useActiveTab = () => {
const activeTab = useStore($activeSidebarPanel);
const [clientSettings] = useClientSettings();
let nextTab = activeTab;
if (
nextTab === "navigator" &&
clientSettings.navigatorLayout === "undocked"
) {
nextTab = "none";
}
return [nextTab, $activeSidebarPanel.set] as const;
};
const useHideActiveTabOnPreview = () => {
useEffect(() => {
return $isPreviewMode.subscribe((isPreviewMode) => {
// When user switches to preview mode we want to hide any active sidebar panel.
if (isPreviewMode) {
$activeSidebarPanel.set("none");
}
});
}, []);
};
const AiTabTrigger = () => {
const [clientSettings, setClientSetting] = useClientSettings();
return (
<SidebarButton
label="AI"
data-state={clientSettings.isAiCommandBarVisible ? "active" : undefined}
data-state={getSetting("isAiCommandBarVisible") ? "active" : undefined}
onClick={() => {
setClientSetting(
setSetting(
"isAiCommandBarVisible",
clientSettings.isAiCommandBarVisible ? false : true
getSetting("isAiCommandBarVisible") ? false : true
);
}}
>
@@ -91,17 +70,15 @@ type SidebarLeftProps = {
};
export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
const [activeTab, setActiveTab] = useActiveTab();
useHideActiveTabOnPreview();
const activePanel = useStore($activeSidebarPanel);
const dragAndDropState = useStore($dragAndDropState);
const { TabContent } = panels.get(activeTab) ?? none;
const { TabContent } = panels.get(activePanel) ?? none;
const isPreviewMode = useStore($isPreviewMode);
const tabsWrapperRef = useRef<HTMLDivElement>(null);
const returnTabRef = useRef<TabName | undefined>(undefined);
const returnTabRef = useRef<SidebarPanelName | undefined>(undefined);
useSubscribe("dragEnd", () => {
setActiveTab("none");
setActiveSidebarPanel("none");
});
useOnDropEffect(() => {
@@ -121,7 +98,7 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
useExternalDragStateEffect((state) => {
if (state !== POTENTIAL) {
if (returnTabRef.current !== undefined) {
setActiveTab(returnTabRef.current);
setActiveSidebarPanel(returnTabRef.current);
}
returnTabRef.current = undefined;
return;
@@ -137,16 +114,16 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
return;
}
returnTabRef.current = activeTab;
returnTabRef.current = activePanel;
// Save prevous state
setActiveTab("assets");
setActiveSidebarPanel("assets");
});
return (
<Flex grow>
<SidebarTabs
activationMode="manual"
value={activeTab}
value={activePanel}
orientation="vertical"
>
{
@@ -159,16 +136,14 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
<div ref={tabsWrapperRef} style={{ display: "contents" }}>
<SidebarTabsList>
{Array.from(panels.entries()).map(
([tabName, { Icon, label }]) => {
([panel, { Icon, label }]) => {
return (
<SidebarTabsTrigger
key={tabName}
key={panel}
label={label}
value={tabName}
value={panel}
onClick={() => {
setActiveTab(
activeTab === tabName ? "none" : tabName
);
toggleActiveSidebarPanel(panel);
}}
>
<Icon size={rawTheme.spacing[10]} />
@@ -187,10 +162,10 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
)}
<SidebarTabsContent
value={activeTab === "none" ? "" : activeTab}
value={activePanel === "none" ? "" : activePanel}
onKeyDown={(event) => {
if (event.key === "Escape") {
setActiveTab("none");
setActiveSidebarPanel("none");
}
}}
css={{
@@ -199,12 +174,16 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
// to keep receiving the drag events.
visibility:
dragAndDropState.isDragging &&
dragAndDropState.dragPayload?.origin === "panel"
dragAndDropState.dragPayload?.origin === "panel" &&
getSetting("navigatorLayout") !== "undocked"
? "hidden"
: "visible",
}}
>
<TabContent publish={publish} onSetActiveTab={setActiveTab} />
<TabContent
publish={publish}
onSetActiveTab={setActiveSidebarPanel}
/>
</SidebarTabsContent>
</SidebarTabs>
</Flex>
@@ -1,14 +1,7 @@
import type { SidebarPanelName } from "~/builder/shared/nano-states";
import type { Publish } from "~/shared/pubsub";
export type TabName =
| "assets"
| "components"
| "navigator"
| "pages"
| "marketplace"
| "none";
export type TabContentProps = {
onSetActiveTab: (tabName: TabName) => void;
onSetActiveTab: (tabName: SidebarPanelName) => void;
publish: Publish;
};
@@ -21,29 +21,27 @@ import {
$isShareDialogOpen,
$isPublishDialogOpen,
} from "~/builder/shared/nano-states";
import { useClientSettings } from "~/builder/shared/client-settings";
import { dashboardPath } from "~/shared/router-utils";
import { $authPermit, $authTokenPermissions } from "~/shared/nano-states";
import { emitCommand } from "~/builder/shared/commands";
import { MenuButton } from "./menu-button";
import { $isProjectSettingsOpen } from "~/shared/nano-states/seo";
import { UpgradeIcon } from "@webstudio-is/icons";
import { getSetting, setSetting } from "~/builder/shared/client-settings";
const ViewMenuItem = () => {
const [clientSettings, setClientSetting] = useClientSettings();
const navigatorLayout = getSetting("navigatorLayout");
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>View</DropdownMenuSubTrigger>
<DropdownMenuSubContent width="regular">
<DropdownMenuCheckboxItem
checked={clientSettings.navigatorLayout === "undocked"}
checked={navigatorLayout === "undocked"}
onSelect={() => {
const setting =
clientSettings.navigatorLayout === "undocked"
? "docked"
: "undocked";
setClientSetting("navigatorLayout", setting);
navigatorLayout === "undocked" ? "docked" : "undocked";
setSetting("navigatorLayout", setting);
}}
>
Undock navigator
@@ -21,8 +21,9 @@ import {
BreakpointsPopover,
} from "../breakpoints";
import { ViewMode } from "./view-mode";
import { $activeSidebarPanel } from "~/builder/shared/nano-states";
import { AddressBarPopover } from "../address-bar";
import { toggleActiveSidebarPanel } from "~/builder/shared/nano-states";
import type { ReactNode } from "react";
const PagesButton = () => {
const page = useStore($selectedPage);
@@ -38,9 +39,7 @@ const PagesButton = () => {
}}
aria-label="Toggle Pages"
onClick={() => {
$activeSidebarPanel.set(
$activeSidebarPanel.get() === "pages" ? "none" : "pages"
);
toggleActiveSidebarPanel("pages");
}}
tabIndex={0}
>
@@ -50,6 +49,7 @@ const PagesButton = () => {
};
const topbarContainerStyle = css({
position: "relative",
display: "flex",
background: theme.colors.backgroundTopbar,
height: theme.spacing[15],
@@ -67,12 +67,13 @@ const hideOnMobile: CSS = {
};
type TopbarProps = {
css: CSS;
project: Project;
hasProPlan: boolean;
loading: ReactNode;
css: CSS;
};
export const Topbar = ({ css, project, hasProPlan }: TopbarProps) => {
export const Topbar = ({ project, hasProPlan, css, loading }: TopbarProps) => {
const pages = useStore($pages);
return (
<nav className={topbarContainerStyle({ css })}>
@@ -101,6 +102,7 @@ export const Topbar = ({ css, project, hasProPlan }: TopbarProps) => {
<ToolbarToggleGroup
type="single"
css={{
isolation: "isolate",
justifyContent: "flex-end",
gap: theme.spacing[5],
width: theme.spacing[30],
@@ -113,6 +115,7 @@ export const Topbar = ({ css, project, hasProPlan }: TopbarProps) => {
<PublishButton projectId={project.id} />
</ToolbarToggleGroup>
</Toolbar>
{loading}
</nav>
);
};
@@ -1,6 +1,6 @@
import { useEffect, useRef, type ReactNode } from "react";
import { useStore } from "@nanostores/react";
import { theme, Toaster, css, type CSS } from "@webstudio-is/design-system";
import { theme, Toaster, css } from "@webstudio-is/design-system";
import {
$canvasWidth,
$scale,
@@ -91,14 +91,9 @@ const useOutlineStyle = () => {
type WorkspaceProps = {
children: ReactNode;
onTransitionEnd: () => void;
css: CSS;
};
export const Workspace = ({
children,
onTransitionEnd,
css,
}: WorkspaceProps) => {
export const Workspace = ({ children, onTransitionEnd }: WorkspaceProps) => {
const canvasStyle = useCanvasStyle();
const outlineStyle = useOutlineStyle();
const workspaceRef = useMeasureWorkspace();
@@ -110,7 +105,7 @@ export const Workspace = ({
return (
<div
className={workspaceStyle({ css })}
className={workspaceStyle()}
onClick={handleWorkspaceClick}
ref={workspaceRef}
>
@@ -1,6 +1,4 @@
import { useEffect } from "react";
import { atom } from "nanostores";
import { useStore } from "@nanostores/react";
import { z } from "zod";
const Settings = z.object({
@@ -46,45 +44,26 @@ const write = (settings: Settings) => {
localStorage.setItem(namespace, JSON.stringify(settings));
};
/**
* Get a value from local storage or a default.
*/
export const getSetting = <Name extends keyof Settings>(name: Name) => {
const settings = read();
const value = settings[name];
return value;
};
export const $settings = atom<Settings>(defaultSettings);
export const setSetting = <Name extends keyof Settings>(
name: Name,
value: Settings[Name]
) => {
const settings = read();
write({ ...settings, [name]: value });
const settings = $settings.get();
if (settings[name] === value) {
return;
}
const nextSettings = { ...settings, [name]: value };
$settings.set(nextSettings);
write(nextSettings);
};
const $settings = atom<Settings>(defaultSettings);
export const useClientSettings = (): [Settings, typeof setSetting, boolean] => {
const settings = useStore($settings);
useEffect(() => {
$settings.set(read());
}, []);
const setSettingValue = <Name extends keyof Settings>(
name: Name,
value: Settings[Name]
) => {
if (settings[name] === value) {
return;
}
$settings.set({ ...settings, [name]: value });
setSetting(name, value);
};
const isLoaded = settings !== defaultSettings;
return [settings, setSettingValue, isLoaded];
export const getSetting = <Name extends keyof Settings>(name: Name) => {
let settings = $settings.get();
if (settings === defaultSettings) {
settings = read();
$settings.set(settings);
}
return settings[name];
};
+21 -4
View File
@@ -21,8 +21,13 @@ import {
import type { InstanceSelector } from "~/shared/tree-utils";
import { serverSyncStore } from "~/shared/sync";
import { $publisher } from "~/shared/pubsub";
import { $activeInspectorPanel, $activeSidebarPanel } from "./nano-states";
import {
$activeInspectorPanel,
setActiveSidebarPanel,
toggleActiveSidebarPanel,
} from "./nano-states";
import { toast } from "@webstudio-is/design-system";
import { getSetting } from "./client-settings";
const makeBreakpointCommand = <CommandName extends string>(
name: CommandName,
@@ -107,7 +112,10 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
name: "clickCanvas",
handler: () => {
$breakpointsMenuView.set(undefined);
$activeSidebarPanel.set("none");
// Only close the panel if its covering the canvas.
if (getSetting("navigatorLayout") === "docked") {
setActiveSidebarPanel("none");
}
},
},
@@ -127,10 +135,19 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
},
},
{
name: "openComponentsPanel",
name: "toggleComponentsPanel",
defaultHotkeys: ["a"],
handler: () => {
$activeSidebarPanel.set("components");
toggleActiveSidebarPanel("components");
},
disableHotkeyOnFormTags: true,
disableHotkeyOnContentEditable: true,
},
{
name: "toggleNavigatorPanel",
defaultHotkeys: ["z"],
handler: () => {
toggleActiveSidebarPanel("navigator");
},
disableHotkeyOnFormTags: true,
disableHotkeyOnContentEditable: true,
+108
View File
@@ -0,0 +1,108 @@
import { useState } from "react";
import { Box, Flex, Progress, theme } from "@webstudio-is/design-system";
import { WebstudioIcon } from "@webstudio-is/icons";
import { useInterval } from "~/shared/hook-utils/use-interval";
export const LoadingBackground = ({
show,
backgroundColor,
onTransitionEnd,
}: {
show: boolean;
backgroundColor: string;
onTransitionEnd?: () => void;
}) => {
const [transitionEnded, setTransitionEnded] = useState(false);
if (transitionEnded) {
return;
}
return (
<Box
css={{
position: "absolute",
inset: 0,
transitionDuration: "300ms",
pointerEvents: "none",
transitionProperty: "opacity",
backgroundColor,
opacity: show ? 1 : 0,
isolation: "isolate",
}}
onTransitionEnd={() => {
setTransitionEnded(true);
onTransitionEnd?.();
}}
/>
);
};
type LoadingState = {
state: "ready" | "loading";
progress: number;
readyStates: Map<
"dataLoadingState" | "selectedInstanceRenderState" | "canvasIframeState",
boolean
>;
};
export const Loading = ({ state }: { state: LoadingState }) => {
const [fakeProgress, setFakeProgress] = useState(state.progress);
const [transitionEnded, setTransitionEnded] = useState(false);
useInterval((intervalId) => {
setFakeProgress((previousFakeValue) => {
// Makeing sure fake value is not higher than real value to prevent jumping back
let nextFakeValue = Math.max(previousFakeValue + 1, state.progress);
// Make sure fake value is not lower than 10 to avoid showing empty progress.
nextFakeValue = Math.max(nextFakeValue, 10);
// Making sure fake value can't get bigger than 100, now that it reached 100 we can stop faking it.
if (nextFakeValue >= 100) {
clearInterval(intervalId);
return previousFakeValue;
}
return nextFakeValue;
});
}, 100);
if (state.state === "ready" && transitionEnded) {
return;
}
return (
<Flex
justify="center"
css={{
position: "absolute",
inset: 0,
top: theme.spacing[15],
pointerEvents: "none",
zIndex: 0,
}}
>
<LoadingBackground
show={state.state !== "ready"}
backgroundColor={theme.colors.backgroundCanvas}
onTransitionEnd={() => {
setTransitionEnded(true);
}}
/>
{state.state !== "ready" && (
<Flex
direction="column"
align="center"
justify="center"
gap="3"
css={{ isolation: "isolate" }}
>
<WebstudioIcon
size={60}
style={{ filter: "drop-shadow(3px 3px 6px rgba(0, 0, 0, 0.7))" }}
/>
<Progress value={fakeProgress} />
</Flex>
)}
</Flex>
);
};
@@ -1,11 +1,11 @@
import { atom, computed } from "nanostores";
import type { TabName } from "~/builder/features/sidebar-left/types";
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
import {
$isPreviewMode,
$selectedInstanceRenderState,
} from "~/shared/nano-states/misc";
import { $canvasIframeState } from "~/shared/nano-states/canvas";
import { $settings, getSetting } from "../client-settings";
export const $isShareDialogOpen = atom<boolean>(false);
@@ -35,8 +35,6 @@ export const $scale = computed(
}
);
export const $activeSidebarPanel = atom<TabName>("none");
export const $activeInspectorPanel = atom<"style" | "settings">("style");
// keep in sync with user-plan-features.server
@@ -84,3 +82,49 @@ export const $loadingState = computed(
return { state, progress, readyStates };
}
);
export type SidebarPanelName =
| "assets"
| "components"
| "navigator"
| "pages"
| "marketplace"
| "none";
// Only used internally to avoid directly setting the value without using setActiveSidebarPanel.
const $activeSidebarPanel_ = atom<SidebarPanelName | undefined>();
export const $activeSidebarPanel = computed(
[$activeSidebarPanel_, $isPreviewMode, $loadingState, $settings],
(currentPanel, isPreviewMode, loadingState, { navigatorLayout }) => {
if (isPreviewMode || loadingState.state !== "ready") {
return "none";
}
if (currentPanel === undefined) {
return navigatorLayout === "undocked" ? "navigator" : "none";
}
return currentPanel;
}
);
export const setActiveSidebarPanel = (nextPanel?: SidebarPanelName) => {
const currentPanel = $activeSidebarPanel.get();
// - When navigator is open, user is trying to close the navigator.
// - Navigator is closed, user is trying to close some other panel, and if navigator is undocked, it needs to be opened.
if (nextPanel === "none") {
if (currentPanel === "navigator") {
$activeSidebarPanel_.set("none");
return;
}
if (getSetting("navigatorLayout") === "undocked") {
$activeSidebarPanel_.set("navigator");
return;
}
}
$activeSidebarPanel_.set(nextPanel);
};
export const toggleActiveSidebarPanel = (panel: SidebarPanelName) => {
const currentPanel = $activeSidebarPanel.get();
setActiveSidebarPanel(panel === currentPanel ? "none" : panel);
};
@@ -508,11 +508,11 @@ const useExpandState = <Data extends { id: string }>({
selectedItemSelector: undefined | ItemSelector;
getItemChildren: (itemSelector: ItemSelector) => Data[];
}) => {
const [record, setRecord] = useState<Record<string, boolean>>({});
const [expandMap, setExpandMap] = useState<Map<string, boolean>>(new Map());
// whenever selected instance is changed
// all its parents should be automatically expanded
const prevSelectedItemSelector = useRef(selectedItemSelector);
const prevSelectedItemSelector = useRef<ItemSelector>();
useEffect(() => {
if (
areItemSelectorsEqual(
@@ -526,19 +526,19 @@ const useExpandState = <Data extends { id: string }>({
if (selectedItemSelector === undefined) {
return;
}
setRecord((record) => {
const newRecord = { ...record };
setExpandMap((expandMap) => {
const newExpandMap = new Map(expandMap);
let expanded = 0;
// do not expand the selected instance itself, start with parent
for (let index = 1; index < selectedItemSelector.length; index += 1) {
const key = selectedItemSelector.slice(index).join();
if (newRecord[key] !== true) {
newRecord[key] = true;
if (newExpandMap.get(key) !== true) {
newExpandMap.set(key, true);
expanded += 1;
}
}
// prevent rerender if nothing new is expanded
return expanded === 0 ? record : newRecord;
return expanded === 0 ? expandMap : newExpandMap;
});
}, [selectedItemSelector]);
@@ -548,28 +548,29 @@ const useExpandState = <Data extends { id: string }>({
if (itemSelector.length === 1) {
return true;
}
return record[itemSelector.join()] === true;
return expandMap.get(itemSelector.join()) === true;
},
[record]
[expandMap]
);
const setIsExpanded = useCallback(
(itemSelector: ItemSelector, value: boolean, all?: boolean) => {
setRecord((record) => {
setExpandMap((expandMap) => {
const newExpandMap = new Map(expandMap);
if (all) {
const newRecord = { ...record };
const addChildren = (parentSelector: string[]) => {
for (const child of getItemChildren(parentSelector)) {
const itemSelector = [child.id, ...parentSelector];
newRecord[itemSelector.join()] = value;
newExpandMap.set(itemSelector.join(), value);
addChildren(itemSelector);
}
};
newRecord[itemSelector.join()] = value;
newExpandMap.set(itemSelector.join(), value);
addChildren(itemSelector);
return newRecord;
return newExpandMap;
}
return { ...record, [itemSelector.join()]: value };
newExpandMap.set(itemSelector.join(), value);
return newExpandMap;
});
},
[getItemChildren]