From 9bfa855fabfcc39fd8d837eb2e2d97d47c01fb7a Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Tue, 20 Aug 2024 19:27:46 +0100 Subject: [PATCH] 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 --- apps/builder/app/builder/builder.tsx | 190 ++++-------------- .../builder/features/ai/ai-command-bar.tsx | 17 +- .../builder/features/sidebar-left/index.tsx | 1 - .../features/sidebar-left/panels/index.ts | 9 +- .../sidebar-left/panels/navigator/index.ts | 1 - .../panels/navigator/navigator-content.tsx | 53 ----- .../panels/navigator/navigator.tsx | 36 +++- .../features/sidebar-left/sidebar-left.tsx | 83 +++----- .../builder/features/sidebar-left/types.ts | 11 +- .../app/builder/features/topbar/menu/menu.tsx | 12 +- .../app/builder/features/topbar/topbar.tsx | 15 +- .../builder/features/workspace/workspace.tsx | 11 +- .../shared/client-settings/settings.ts | 51 ++--- apps/builder/app/builder/shared/commands.ts | 25 ++- apps/builder/app/builder/shared/loading.tsx | 108 ++++++++++ .../app/builder/shared/nano-states/index.ts | 50 ++++- .../src/components/tree/tree.tsx | 31 +-- 17 files changed, 334 insertions(+), 370 deletions(-) delete mode 100644 apps/builder/app/builder/features/sidebar-left/panels/navigator/navigator-content.tsx create mode 100644 apps/builder/app/builder/shared/loading.tsx diff --git a/apps/builder/app/builder/builder.tsx b/apps/builder/app/builder/builder.tsx index 6eaa2eb188..082538068e 100644 --- a/apps/builder/app/builder/builder.tsx +++ b/apps/builder/app/builder/builder.tsx @@ -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; 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 ( - - - - - - ); -}; - -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 ( - - - - - ); -}; - 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} > - + + } />
- + {dataLoadingState === "loaded" && (
- - + @@ -522,7 +406,7 @@ export const Builder = ({ project={project} />
- + diff --git a/apps/builder/app/builder/features/ai/ai-command-bar.tsx b/apps/builder/app/builder/features/ai/ai-command-bar.tsx index f42c5e1df9..5445122b14 100644 --- a/apps/builder/app/builder/features/ai/ai-command-bar.tsx +++ b/apps/builder/app/builder/features/ai/ai-command-bar.tsx @@ -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> = { @@ -64,12 +64,10 @@ const initialPrompts = [ export const AiCommandBar = ({ isPreviewMode }: { isPreviewMode: boolean }) => { const [value, setValue] = useState(""); const [prompts, setPrompts] = useState(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; } diff --git a/apps/builder/app/builder/features/sidebar-left/index.tsx b/apps/builder/app/builder/features/sidebar-left/index.tsx index 8964ebd8b2..e403e34164 100644 --- a/apps/builder/app/builder/features/sidebar-left/index.tsx +++ b/apps/builder/app/builder/features/sidebar-left/index.tsx @@ -1,2 +1 @@ export { SidebarLeft } from "./sidebar-left"; -export { NavigatorContent, useNavigatorLayout } from "./panels/navigator"; diff --git a/apps/builder/app/builder/features/sidebar-left/panels/index.ts b/apps/builder/app/builder/features/sidebar-left/panels/index.ts index ad708ecb34..75a825772f 100644 --- a/apps/builder/app/builder/features/sidebar-left/panels/index.ts +++ b/apps/builder/app/builder/features/sidebar-left/panels/index.ts @@ -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([ +export const panels = new Map([ ["components", components], ["pages", pages], ["navigator", navigator], diff --git a/apps/builder/app/builder/features/sidebar-left/panels/navigator/index.ts b/apps/builder/app/builder/features/sidebar-left/panels/navigator/index.ts index c54ad3aaf7..e17e01b012 100644 --- a/apps/builder/app/builder/features/sidebar-left/panels/navigator/index.ts +++ b/apps/builder/app/builder/features/sidebar-left/panels/navigator/index.ts @@ -1,2 +1 @@ export * from "./navigator"; -export * from "./navigator-content"; diff --git a/apps/builder/app/builder/features/sidebar-left/panels/navigator/navigator-content.tsx b/apps/builder/app/builder/features/sidebar-left/panels/navigator/navigator-content.tsx deleted file mode 100644 index 848b8cc2de..0000000000 --- a/apps/builder/app/builder/features/sidebar-left/panels/navigator/navigator-content.tsx +++ /dev/null @@ -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( - "enter", - () => emitCommand("editInstanceText"), - [] - ); - - return ( - -
onClose?.()} />} - /> - - - - - - - ); -}; diff --git a/apps/builder/app/builder/features/sidebar-left/panels/navigator/navigator.tsx b/apps/builder/app/builder/features/sidebar-left/panels/navigator/navigator.tsx index 76f6c220c9..0405ee5799 100644 --- a/apps/builder/app/builder/features/sidebar-left/panels/navigator/navigator.tsx +++ b/apps/builder/app/builder/features/sidebar-left/panels/navigator/navigator.tsx @@ -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( + "enter", + () => emitCommand("editInstanceText"), + [] + ); + return ( - { - onSetActiveTab("none"); - }} - /> + +
onSetActiveTab("none")} />} + /> + + + + + + ); }; export const Icon = NavigatorIcon; -export const label = "Navigator"; +export const label = ( + + Navigator   + + +); diff --git a/apps/builder/app/builder/features/sidebar-left/sidebar-left.tsx b/apps/builder/app/builder/features/sidebar-left/sidebar-left.tsx index 7840bcd52a..456aab9a3b 100644 --- a/apps/builder/app/builder/features/sidebar-left/sidebar-left.tsx +++ b/apps/builder/app/builder/features/sidebar-left/sidebar-left.tsx @@ -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 ( { - 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(null); - - const returnTabRef = useRef(undefined); + const returnTabRef = useRef(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 ( { @@ -159,16 +136,14 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => {
{Array.from(panels.entries()).map( - ([tabName, { Icon, label }]) => { + ([panel, { Icon, label }]) => { return ( { - setActiveTab( - activeTab === tabName ? "none" : tabName - ); + toggleActiveSidebarPanel(panel); }} > @@ -187,10 +162,10 @@ export const SidebarLeft = ({ publish }: SidebarLeftProps) => { )} { 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", }} > - + diff --git a/apps/builder/app/builder/features/sidebar-left/types.ts b/apps/builder/app/builder/features/sidebar-left/types.ts index bcc8696c18..1178a0b2fd 100644 --- a/apps/builder/app/builder/features/sidebar-left/types.ts +++ b/apps/builder/app/builder/features/sidebar-left/types.ts @@ -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; }; diff --git a/apps/builder/app/builder/features/topbar/menu/menu.tsx b/apps/builder/app/builder/features/topbar/menu/menu.tsx index 1645bd0be5..287e13ddb7 100644 --- a/apps/builder/app/builder/features/topbar/menu/menu.tsx +++ b/apps/builder/app/builder/features/topbar/menu/menu.tsx @@ -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 ( View { const setting = - clientSettings.navigatorLayout === "undocked" - ? "docked" - : "undocked"; - setClientSetting("navigatorLayout", setting); + navigatorLayout === "undocked" ? "docked" : "undocked"; + setSetting("navigatorLayout", setting); }} > Undock navigator diff --git a/apps/builder/app/builder/features/topbar/topbar.tsx b/apps/builder/app/builder/features/topbar/topbar.tsx index d7d50b63ae..293505f3b8 100644 --- a/apps/builder/app/builder/features/topbar/topbar.tsx +++ b/apps/builder/app/builder/features/topbar/topbar.tsx @@ -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 ( ); }; diff --git a/apps/builder/app/builder/features/workspace/workspace.tsx b/apps/builder/app/builder/features/workspace/workspace.tsx index 2cd63bbfd1..93d0b4f88e 100644 --- a/apps/builder/app/builder/features/workspace/workspace.tsx +++ b/apps/builder/app/builder/features/workspace/workspace.tsx @@ -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 (
diff --git a/apps/builder/app/builder/shared/client-settings/settings.ts b/apps/builder/app/builder/shared/client-settings/settings.ts index 03094480cf..73938528e6 100644 --- a/apps/builder/app/builder/shared/client-settings/settings.ts +++ b/apps/builder/app/builder/shared/client-settings/settings.ts @@ -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: Name) => { - const settings = read(); - const value = settings[name]; - return value; -}; +export const $settings = atom(defaultSettings); export const setSetting = ( 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(defaultSettings); - -export const useClientSettings = (): [Settings, typeof setSetting, boolean] => { - const settings = useStore($settings); - - useEffect(() => { - $settings.set(read()); - }, []); - - const setSettingValue = ( - 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: Name) => { + let settings = $settings.get(); + if (settings === defaultSettings) { + settings = read(); + $settings.set(settings); + } + return settings[name]; }; diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 9abbf45fd0..d465e59614 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -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 = ( 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, diff --git a/apps/builder/app/builder/shared/loading.tsx b/apps/builder/app/builder/shared/loading.tsx new file mode 100644 index 0000000000..e42c0978cf --- /dev/null +++ b/apps/builder/app/builder/shared/loading.tsx @@ -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 ( + { + 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 ( + + { + setTransitionEnded(true); + }} + /> + {state.state !== "ready" && ( + + + + + )} + + ); +}; diff --git a/apps/builder/app/builder/shared/nano-states/index.ts b/apps/builder/app/builder/shared/nano-states/index.ts index ecf600b636..3b4fd1025e 100644 --- a/apps/builder/app/builder/shared/nano-states/index.ts +++ b/apps/builder/app/builder/shared/nano-states/index.ts @@ -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(false); @@ -35,8 +35,6 @@ export const $scale = computed( } ); -export const $activeSidebarPanel = atom("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(); + +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); +}; diff --git a/packages/design-system/src/components/tree/tree.tsx b/packages/design-system/src/components/tree/tree.tsx index a6f5ba48be..90728e2a2b 100644 --- a/packages/design-system/src/components/tree/tree.tsx +++ b/packages/design-system/src/components/tree/tree.tsx @@ -508,11 +508,11 @@ const useExpandState = ({ selectedItemSelector: undefined | ItemSelector; getItemChildren: (itemSelector: ItemSelector) => Data[]; }) => { - const [record, setRecord] = useState>({}); + const [expandMap, setExpandMap] = useState>(new Map()); // whenever selected instance is changed // all its parents should be automatically expanded - const prevSelectedItemSelector = useRef(selectedItemSelector); + const prevSelectedItemSelector = useRef(); useEffect(() => { if ( areItemSelectorsEqual( @@ -526,19 +526,19 @@ const useExpandState = ({ 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 = ({ 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]