mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
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:
1 parent
86a5512c77
commit
9bfa855fab
17 files changed
+334
-370
No files matched your search
@@ -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
|
||||
<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,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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user