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]