diff --git a/apps/designer/app/canvas/canvas.tsx b/apps/designer/app/canvas/canvas.tsx index 49fb8b9fdb..84573b6c39 100644 --- a/apps/designer/app/canvas/canvas.tsx +++ b/apps/designer/app/canvas/canvas.tsx @@ -28,14 +28,11 @@ import { useTrackSelectedElement } from "./shared/use-track-selected-element"; import { WrapperComponentDev } from "./features/wrapper-component"; import { useSync } from "./shared/sync"; import { useManageProps } from "./shared/props"; -import { - useManageBreakpoints, - useInitializeBreakpoints, -} from "./shared/breakpoints"; import { rootInstanceContainer, useBreakpoints, useRootInstance, + useSetBreakpoints, useSetPresetStyles, useSetRootInstance, useSetStyles, @@ -135,7 +132,6 @@ type DesignModeProps = { }; const DesignMode = ({ treeId, buildId }: DesignModeProps) => { - useManageBreakpoints(); useManageDesignModeStyles(); useManageProps({ treeId }); usePublishSelectedInstanceData(); @@ -166,9 +162,9 @@ export const Canvas = ({ data }: CanvasProps): JSX.Element | null => { throw new Error("Tree is null"); } const isDesignerReady = useSubscribeDesignerReady(); - useInitializeBreakpoints(data.breakpoints); useInitializeDesignTokens(data.designTokens); const assets = useAssets(data.assets); + useSetBreakpoints(data.breakpoints); useAllUserProps(data.props); useSetPresetStyles(data.tree.presetStyles); useSetStyles(data.tree.styles); diff --git a/apps/designer/app/canvas/shared/breakpoints.ts b/apps/designer/app/canvas/shared/breakpoints.ts deleted file mode 100644 index 29ef4572a1..0000000000 --- a/apps/designer/app/canvas/shared/breakpoints.ts +++ /dev/null @@ -1,65 +0,0 @@ -import store from "immerhin"; -import type { Breakpoint } from "@webstudio-is/css-data"; -import { useSubscribe } from "~/shared/pubsub"; -import { - breakpointsContainer, - rootInstanceContainer, - stylesContainer, - useBreakpoints, -} from "~/shared/nano-states"; -import { addMediaRules } from "./styles"; -import { useSyncInitializeOnce } from "~/shared/hook-utils"; -import { filterMutable } from "~/shared/array-utils"; - -export const useInitializeBreakpoints = (breakpoints: Array) => { - const [, setCurrentBreakpoints] = useBreakpoints(); - useSyncInitializeOnce(() => { - setCurrentBreakpoints(breakpoints); - addMediaRules(breakpoints); - }); -}; - -const useBreakpointChange = () => { - useSubscribe("breakpointChange", (breakpoint) => { - store.createTransaction([breakpointsContainer], (breakpoints) => { - const foundBreakpoint = breakpoints.find( - ({ id }) => id === breakpoint.id - ); - if (foundBreakpoint) { - foundBreakpoint.label = breakpoint.label; - foundBreakpoint.minWidth = breakpoint.minWidth; - } else { - // Its a new breakpoint - breakpoints.push(breakpoint); - } - - addMediaRules(breakpoints); - }); - }); -}; - -const useBreakpointDelete = () => { - useSubscribe("breakpointDelete", (breakpoint) => { - store.createTransaction( - [breakpointsContainer, rootInstanceContainer, stylesContainer], - (breakpoints, rootInstance, styles) => { - if (rootInstance === undefined) { - return; - } - - const index = breakpoints.findIndex(({ id }) => id === breakpoint.id); - if (index !== -1) { - breakpoints.splice(index, 1); - } - - // delete breakpoint styles - filterMutable(styles, (style) => style.breakpointId !== breakpoint.id); - } - ); - }); -}; - -export const useManageBreakpoints = () => { - useBreakpointChange(); - useBreakpointDelete(); -}; diff --git a/apps/designer/app/canvas/shared/styles.ts b/apps/designer/app/canvas/shared/styles.ts index 0cf6d599a7..90d66e2e36 100644 --- a/apps/designer/app/canvas/shared/styles.ts +++ b/apps/designer/app/canvas/shared/styles.ts @@ -17,7 +17,6 @@ import { } from "@webstudio-is/react-sdk"; import { validStaticValueTypes, - type Breakpoint, type ValidStaticStyleValue, type StyleValue, type CssRule, @@ -80,6 +79,14 @@ const tokensCssEngine = createCssEngine({ name: "tokens" }); const presetStylesEngine = createCssEngine({ name: "presetStyles" }); export const GlobalStyles = ({ assets }: { assets: Array }) => { + const [breakpoints] = useBreakpoints(); + + useIsomorphicLayoutEffect(() => { + for (const breakpoint of breakpoints) { + cssEngine.addMediaRule(breakpoint.id, breakpoint); + } + }, [breakpoints]); + useIsomorphicLayoutEffect(() => { for (const style of helperStyles) { helpersCssEngine.addPlaintextRule(style); @@ -159,12 +166,6 @@ const toVarStyleWithFallback = (instanceId: string, style: Style): Style => { return dynamicStyle; }; -export const addMediaRules = (breakpoints: Array) => { - for (const breakpoint of breakpoints) { - cssEngine.addMediaRule(breakpoint.id, breakpoint); - } -}; - const wrappedRulesMap = new Map(); const addRule = (id: string, cssRule: CssRule) => { diff --git a/apps/designer/app/designer/features/breakpoints/breakpoints-editor.tsx b/apps/designer/app/designer/features/breakpoints/breakpoints-editor.tsx index eb18ad6343..25d91dd91b 100644 --- a/apps/designer/app/designer/features/breakpoints/breakpoints-editor.tsx +++ b/apps/designer/app/designer/features/breakpoints/breakpoints-editor.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; import { useDebouncedCallback } from "use-debounce"; -import { type Breakpoint } from "@webstudio-is/css-data"; -import { type Publish } from "~/shared/pubsub"; +import ObjectId from "bson-objectid"; +import store from "immerhin"; +import type { Breakpoint } from "@webstudio-is/css-data"; import { Button, TextField, Flex, Text } from "@webstudio-is/design-system"; import { PlusIcon, TrashIcon } from "@webstudio-is/icons"; -import ObjectId from "bson-objectid"; -import { useBreakpoints } from "~/shared/nano-states"; +import { breakpointsContainer, useBreakpoints } from "~/shared/nano-states"; +import { replaceByOrAppendMutable } from "~/shared/array-utils"; type BreakpointEditorItemProps = { breakpoint: Breakpoint; @@ -87,16 +88,23 @@ const BreakpointEditorItem = ({ }; type BreakpointsEditorProps = { - breakpoints: Array; - publish: Publish; onDelete: (breakpoint: Breakpoint) => void; }; -export const BreakpointsEditor = ({ - publish, - onDelete, -}: BreakpointsEditorProps) => { - const [breakpoints, setBreakpoints] = useBreakpoints(); +export const BreakpointsEditor = ({ onDelete }: BreakpointsEditorProps) => { + const [breakpoints] = useBreakpoints(); + const [addedBreakpoints, setAddedBreakpoints] = useState([]); + const storedBreakpoints = new Set(); + for (const breakpoint of breakpoints) { + storedBreakpoints.add(breakpoint.id); + } + // filter out new breakpoints which are already store + // instead of deleting from state to avoid the case + // when both states do not have such breakpoint + // and focused input remounts + const newBreakpoints = addedBreakpoints.filter( + (breakpoint) => storedBreakpoints.has(breakpoint.id) === false + ); return ( { - setBreakpoints([ - ...breakpoints, + setAddedBreakpoints((prev) => [ + ...prev, { id: ObjectId().toString(), label: "", @@ -125,20 +133,19 @@ export const BreakpointsEditor = ({ prefix={} /> - {breakpoints.map((breakpoint) => { + {[...breakpoints, ...newBreakpoints].map((breakpoint) => { return ( { - publish({ type: "breakpointChange", payload: updatedBreakpoint }); - const nextBreakpoints = breakpoints.map((breakpoint) => { - if (breakpoint.id === updatedBreakpoint.id) { - return updatedBreakpoint; - } - return breakpoint; + store.createTransaction([breakpointsContainer], (breakpoints) => { + replaceByOrAppendMutable( + breakpoints, + updatedBreakpoint, + ({ id }) => id === updatedBreakpoint.id + ); }); - setBreakpoints(nextBreakpoints); }} onDelete={onDelete} /> diff --git a/apps/designer/app/designer/features/breakpoints/breakpoints.tsx b/apps/designer/app/designer/features/breakpoints/breakpoints.tsx index db5aca45db..5ee9fda26d 100644 --- a/apps/designer/app/designer/features/breakpoints/breakpoints.tsx +++ b/apps/designer/app/designer/features/breakpoints/breakpoints.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; +import store from "immerhin"; import { type Breakpoint } from "@webstudio-is/css-data"; -import { type Publish, useSubscribe } from "~/shared/pubsub"; +import { useSubscribe } from "~/shared/pubsub"; import { Text, DropdownMenu, @@ -23,16 +24,13 @@ import { useSubscribeZoomFromShortcut, } from "./use-subscribe-shortcuts"; import { ConfirmationDialog } from "./confirmation-dialog"; -import { useBreakpoints } from "~/shared/nano-states"; +import { + breakpointsContainer, + stylesContainer, + useBreakpoints, +} from "~/shared/nano-states"; import { utils } from "@webstudio-is/project"; - -declare module "~/shared/pubsub" { - export interface PubsubMap { - breakpointDelete: Breakpoint; - breakpointChange: Breakpoint; - loadBreakpoints: Array; - } -} +import { removeByMutable } from "~/shared/array-utils"; type BreakpointSelectorItemProps = { breakpoint: Breakpoint; @@ -56,18 +54,14 @@ const menuItemCss = { minWidth: 180, }; -type BreakpointsProps = { - publish: Publish; -}; - -export const Breakpoints = ({ publish }: BreakpointsProps) => { +export const Breakpoints = () => { const [view, setView] = useState< "selector" | "editor" | "confirmation" | undefined >(); const [breakpointToDelete, setBreakpointToDelete] = useState< Breakpoint | undefined >(); - const [breakpoints, setBreakpoints] = useBreakpoints(); + const [breakpoints] = useBreakpoints(); const [selectedBreakpoint, setSelectedBreakpoint] = useSelectedBreakpoint(); const [breakpointPreview, setBreakpointPreview] = useState(selectedBreakpoint); @@ -94,17 +88,20 @@ export const Breakpoints = ({ publish }: BreakpointsProps) => { if (breakpointToDelete === undefined) { return; } - const nextBreakpoints = [...breakpoints]; - const index = breakpoints.indexOf(breakpointToDelete); - nextBreakpoints.splice(index, 1); - setBreakpoints(nextBreakpoints); + const [updatedBreakpoints] = store.createTransaction( + [breakpointsContainer, stylesContainer], + (breakpoints, styles) => { + removeByMutable(breakpoints, ({ id }) => id === breakpointToDelete.id); + removeByMutable( + styles, + (style) => style.breakpointId === breakpointToDelete.id + ); + } + ); if (breakpointToDelete === selectedBreakpoint) { - setSelectedBreakpoint(utils.breakpoints.sort(nextBreakpoints)[0]); + setSelectedBreakpoint(utils.breakpoints.sort(updatedBreakpoints)[0]); } - publish({ - type: "breakpointDelete", - payload: breakpointToDelete, - }); + setBreakpointToDelete(undefined); setView("editor"); }; @@ -140,8 +137,6 @@ export const Breakpoints = ({ publish }: BreakpointsProps) => { {view === "editor" && ( <> { setBreakpointToDelete(breakpoint); setView("confirmation"); diff --git a/apps/designer/app/designer/features/topbar/topbar.tsx b/apps/designer/app/designer/features/topbar/topbar.tsx index bae4a6d0af..8b46b47e5c 100644 --- a/apps/designer/app/designer/features/topbar/topbar.tsx +++ b/apps/designer/app/designer/features/topbar/topbar.tsx @@ -41,7 +41,7 @@ export const Topbar = ({ css, project, publish, previewUrl }: TopbarProps) => { }} > - + { +test("removeByMutable", () => { const array = [ { param: 1 }, { param: 2 }, @@ -10,7 +10,7 @@ test("filterMutable", () => { { param: 5 }, { param: 6 }, ]; - filterMutable(array, (item) => item.param % 2 === 0); + removeByMutable(array, (item) => item.param % 2 === 1); expect(array).toMatchInlineSnapshot(` [ { @@ -24,7 +24,7 @@ test("filterMutable", () => { }, ] `); - filterMutable(array, (item) => item.param === 4); + removeByMutable(array, (item) => item.param !== 4); expect(array).toMatchInlineSnapshot(` [ { @@ -33,3 +33,38 @@ test("filterMutable", () => { ] `); }); + +test("replaceByOrAppendMutable", () => { + const array = [{ param: 1 }, { param: 2 }, { param: 3 }]; + replaceByOrAppendMutable(array, { param: 6 }, (item) => item.param === 2); + expect(array).toMatchInlineSnapshot(` + [ + { + "param": 1, + }, + { + "param": 6, + }, + { + "param": 3, + }, + ] + `); + replaceByOrAppendMutable(array, { param: 8 }, (item) => item.param === 8); + expect(array).toMatchInlineSnapshot(` + [ + { + "param": 1, + }, + { + "param": 6, + }, + { + "param": 3, + }, + { + "param": 8, + }, + ] + `); +}); diff --git a/apps/designer/app/shared/array-utils.ts b/apps/designer/app/shared/array-utils.ts index d224aa8bb5..f543881a89 100644 --- a/apps/designer/app/shared/array-utils.ts +++ b/apps/designer/app/shared/array-utils.ts @@ -1,11 +1,26 @@ -export const filterMutable = ( - array: T[], - predicate: (value: T) => boolean +type Predicate = (value: Item) => boolean; + +export const removeByMutable = ( + array: Item[], + predicate: Predicate ) => { // reversed order to splice without breaking index for (let index = array.length - 1; index >= 0; index -= 1) { - if (predicate(array[index]) === false) { + if (predicate(array[index])) { array.splice(index, 1); } } }; + +export const replaceByOrAppendMutable = ( + array: Item[], + item: Item, + predicate: Predicate +) => { + const matchedIndex = array.findIndex(predicate); + if (matchedIndex === -1) { + array.push(item); + } else { + array[matchedIndex] = item; + } +}; diff --git a/apps/designer/app/shared/nano-states/nano-states.ts b/apps/designer/app/shared/nano-states/nano-states.ts index d6011e0654..a8a1b8756d 100644 --- a/apps/designer/app/shared/nano-states/nano-states.ts +++ b/apps/designer/app/shared/nano-states/nano-states.ts @@ -38,8 +38,13 @@ export const useSetStyles = (styles: Styles) => { }); }; -export const breakpointsContainer = atom>([]); +export const breakpointsContainer = atom([]); export const useBreakpoints = () => useValue(breakpointsContainer); +export const useSetBreakpoints = (breakpoints: Breakpoint[]) => { + useSyncInitializeOnce(() => { + breakpointsContainer.set(breakpoints); + }); +}; const isPreviewModeContainer = atom(false); export const useIsPreviewMode = () => useValue(isPreviewModeContainer);