From 00da9c8e86eaea5a6660ecf4548dede45a209bcc Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Fri, 13 Jan 2023 19:32:23 +0300 Subject: [PATCH] Move breakpoints logic to designer (#782) We have stores synchronization and can move the logic from canvas to designer. Here refactored this way breakpoints, got rid from direct atom changes without store and removed breakpoints pub sub events. --- apps/designer/app/canvas/canvas.tsx | 8 +-- .../designer/app/canvas/shared/breakpoints.ts | 65 ------------------- apps/designer/app/canvas/shared/styles.ts | 15 +++-- .../breakpoints/breakpoints-editor.tsx | 49 ++++++++------ .../features/breakpoints/breakpoints.tsx | 49 +++++++------- .../app/designer/features/topbar/topbar.tsx | 2 +- apps/designer/app/shared/array-utils.test.ts | 43 ++++++++++-- apps/designer/app/shared/array-utils.ts | 23 +++++-- .../app/shared/nano-states/nano-states.ts | 7 +- 9 files changed, 125 insertions(+), 136 deletions(-) delete mode 100644 apps/designer/app/canvas/shared/breakpoints.ts 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);