From 9b0332801be1ee88e5d10a9dde208fb32ad3d9dc Mon Sep 17 00:00:00 2001 From: Chris Villa Date: Thu, 29 Feb 2024 21:45:05 +0100 Subject: [PATCH] feat: add viewport switching --- .../components/DraggableComponent/index.tsx | 7 +- packages/core/components/DropZone/context.tsx | 1 - packages/core/components/DropZone/index.tsx | 2 - .../components/DropZone/styles.module.css | 8 - .../Puck/components/Canvas/index.tsx | 144 +++++++++++++ .../Puck/components/Preview/index.tsx | 14 +- packages/core/components/Puck/context.tsx | 6 + packages/core/components/Puck/index.tsx | 44 ++-- .../core/components/Puck/styles.module.css | 50 ++++- .../components/ViewportControls/index.tsx | 196 ++++++++++++++++++ packages/core/lib/get-zoom-config.ts | 49 +++++ packages/core/package.json | 1 + packages/core/types/Config.tsx | 5 + 13 files changed, 478 insertions(+), 49 deletions(-) create mode 100644 packages/core/components/Puck/components/Canvas/index.tsx create mode 100644 packages/core/components/ViewportControls/index.tsx create mode 100644 packages/core/lib/get-zoom-config.ts diff --git a/packages/core/components/DraggableComponent/index.tsx b/packages/core/components/DraggableComponent/index.tsx index 879d5e9f92..d66d20e8ce 100644 --- a/packages/core/components/DraggableComponent/index.tsx +++ b/packages/core/components/DraggableComponent/index.tsx @@ -5,6 +5,7 @@ import getClassNameFactory from "../../lib/get-class-name-factory"; import { Copy, Trash } from "lucide-react"; import { useModifierHeld } from "../../lib/use-modifier-held"; import { ClipLoader } from "react-spinners"; +import { useAppContext } from "../Puck/context"; const getClassName = getClassNameFactory("DraggableComponent", styles); @@ -51,6 +52,7 @@ export const DraggableComponent = ({ indicativeHover?: boolean; style?: CSSProperties; }) => { + const { state } = useAppContext(); const isModifierHeld = useModifierHeld("Alt"); useEffect(onMount, []); @@ -92,7 +94,10 @@ export const DraggableComponent = ({ )} -
+
{label && (
{label}
diff --git a/packages/core/components/DropZone/context.tsx b/packages/core/components/DropZone/context.tsx index b3fcc620df..d4761b5580 100644 --- a/packages/core/components/DropZone/context.tsx +++ b/packages/core/components/DropZone/context.tsx @@ -41,7 +41,6 @@ export type DropZoneContext< pathData?: PathData; registerPath?: (selector: ItemSelector) => void; mode?: "edit" | "render"; - disableZoom?: boolean; } | null; export const dropZoneContext = createContext(null); diff --git a/packages/core/components/DropZone/index.tsx b/packages/core/components/DropZone/index.tsx index 5e054c08e8..3eff734d68 100644 --- a/packages/core/components/DropZone/index.tsx +++ b/packages/core/components/DropZone/index.tsx @@ -38,7 +38,6 @@ function DropZoneEdit({ zone, allow, disallow, style }: DropZoneProps) { registerZoneArea, areasWithZones, hoveringComponent, - disableZoom = false, } = ctx! || {}; let content = data.content || []; @@ -172,7 +171,6 @@ function DropZoneEdit({ zone, allow, disallow, style }: DropZoneProps) { isDisabled: !isEnabled, isAreaSelected, hasChildren: content.length > 0, - zoomEnabled: !disableZoom, })} > { + const { dispatch, state, overrides, setUi } = useAppContext(); + const { ui } = state; + const frameRef = useRef(null); + + const [rootHeight, setRootHeight] = useState(0); + const [autoZoom, setAutoZoom] = useState(0); + const [showTransition, setShowTransition] = useState(false); + + const defaultRender = useMemo< + React.FunctionComponent<{ children?: ReactNode }> + >(() => { + const PuckDefault = ({ children }: { children?: ReactNode }) => ( + <>{children} + ); + + return PuckDefault; + }, []); + + const CustomPreview = useMemo( + () => overrides.preview || defaultRender, + [overrides] + ); + + const getFrameDimensions = useCallback(() => { + if (frameRef.current) { + const frame = frameRef.current; + + const box = getBox(frame); + + return { width: box.contentBox.width, height: box.contentBox.height }; + } + + return { width: 0, height: 0 }; + }, [frameRef]); + + // TODO deal with window resize + const resetAutoZoom = useCallback( + (uiViewport: AppState["ui"]["viewport"]) => { + if (frameRef.current) { + const zoomConfig = getZoomConfig(uiViewport, frameRef.current); + + setRootHeight(zoomConfig.rootHeight); + setAutoZoom(zoomConfig.autoZoom); + + if (zoomConfig.zoom) { + dispatch({ + type: "setUi", + ui: { + ...ui, + viewport: { + ...uiViewport, + zoom: zoomConfig.zoom, + }, + }, + }); + } + } + }, + [frameRef, ui.leftSideBarVisible, ui, autoZoom] + ); + + // Auto zoom + useEffect(() => { + resetAutoZoom(ui.viewport); + }, [frameRef, ui.leftSideBarVisible, ui.rightSideBarVisible]); + + // Constrain height + useEffect(() => { + const { height: frameHeight } = getFrameDimensions(); + + if (ui.viewport.height === "auto") { + setRootHeight(frameHeight / ui.viewport.zoom); + } + }, [ui.viewport.zoom]); + + return ( +
+ dispatch({ + type: "setUi", + ui: { itemSelector: null }, + recordHistory: true, + }) + } + > +
+ { + setShowTransition(true); + + const uiViewport = { ...viewport, zoom: ui.viewport.zoom }; + + setUi({ viewport: uiViewport }); + + if (ZOOM_ON_CHANGE) { + resetAutoZoom(uiViewport); + } + }} + /> +
+
+
+ + + +
+
+
+ ); +}; diff --git a/packages/core/components/Puck/components/Preview/index.tsx b/packages/core/components/Puck/components/Preview/index.tsx index 50ee2cfceb..4b2f93b176 100644 --- a/packages/core/components/Puck/components/Preview/index.tsx +++ b/packages/core/components/Puck/components/Preview/index.tsx @@ -1,6 +1,6 @@ -import { DropZone, dropZoneContext } from "../../../DropZone"; +import { DropZone } from "../../../DropZone"; import { rootDroppableId } from "../../../../lib/root-droppable-id"; -import { useCallback, useContext, useRef } from "react"; +import { useCallback, useRef } from "react"; import { useAppContext } from "../../context"; import AutoFrame from "@measured/auto-frame-component"; import styles from "./styles.module.css"; @@ -22,8 +22,6 @@ export const Preview = ({ id = "puck-preview" }: { id?: string }) => { // DEPRECATED const rootProps = state.data.root.props || state.data.root; - const { disableZoom = false } = useContext(dropZoneContext) || {}; - const ref = useRef(null); return ( @@ -40,11 +38,9 @@ export const Preview = ({ id = "puck-preview" }: { id?: string }) => { data-rfd-iframe ref={ref} > -
- - - -
+ + +
); diff --git a/packages/core/components/Puck/context.tsx b/packages/core/components/Puck/context.tsx index bbe50a61f9..7a967a45de 100644 --- a/packages/core/components/Puck/context.tsx +++ b/packages/core/components/Puck/context.tsx @@ -5,6 +5,7 @@ import { getItem } from "../../lib/get-item"; import { Plugin } from "../../types/Plugin"; import { Overrides } from "../../types/Overrides"; import { PuckHistory } from "../../lib/use-puck-history"; +import { viewports } from "../ViewportControls"; export const defaultAppState: AppState = { data: { content: [], root: { props: { title: "" } } }, @@ -15,6 +16,11 @@ export const defaultAppState: AppState = { itemSelector: null, componentList: {}, isDragging: false, + viewport: { + width: viewports.large.width, + height: viewports.large.height, + zoom: 1, + }, }, }; diff --git a/packages/core/components/Puck/index.tsx b/packages/core/components/Puck/index.tsx index 781d60d45f..dd98e4af91 100644 --- a/packages/core/components/Puck/index.tsx +++ b/packages/core/components/Puck/index.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useReducer, + useRef, useState, } from "react"; import { DragDropContext, DragStart, DragUpdate } from "@measured/dnd"; @@ -42,6 +43,8 @@ import { Overrides } from "../../types/Overrides"; import { loadOverrides } from "../../lib/load-overrides"; import { usePuckHistory } from "../../lib/use-puck-history"; import { useHistoryStore } from "../../lib/use-history-store"; +import { Canvas } from "./components/Canvas"; +import { viewports } from "../ViewportControls"; const getClassName = getClassNameFactory("Puck", styles); @@ -51,7 +54,7 @@ export function Puck< children, config, data: initialData = { content: [], root: { props: { title: "" } } }, - ui: initialUi = defaultAppState.ui, + ui: initialUi, onChange, onPublish, plugins = [], @@ -88,12 +91,24 @@ export function Puck< ); const [initialAppState] = useState(() => { + const initial = { ...defaultAppState.ui, ...initialUi }; + + const viewportWidth = window.innerWidth; + + const viewportDifferences = Object.entries(viewports) + .map(([key, value]) => ({ + key, + diff: Math.abs(viewportWidth - value.width), + })) + .sort((a, b) => (a.diff > b.diff ? 1 : -1)); + + const closestViewport = viewportDifferences[0].key; + return { ...defaultAppState, data: initialData, ui: { - ...defaultAppState.ui, - ...initialUi, + ...initial, // Store categories under componentList on state to allow render functions and plugins to modify componentList: config.categories ? Object.entries(config.categories).reduce( @@ -118,6 +133,10 @@ export function Puck< leftSideBarVisible: false, rightSideBarVisible: false, }), + viewport: { + ...initial.viewport, + width: initialUi?.viewport?.width || viewports[closestViewport].width, + }, }, }; }); @@ -289,10 +308,6 @@ export function Puck< [loadedOverrides] ); - const CustomPreview = useMemo( - () => loadedOverrides.preview || defaultRender, - [loadedOverrides] - ); const CustomHeader = useMemo( () => loadedOverrides.header || defaultHeaderRender, [loadedOverrides] @@ -302,8 +317,6 @@ export function Puck< [loadedOverrides] ); - const disableZoom = children || loadedOverrides.puck ? true : false; - return (
@@ -405,7 +417,6 @@ export function Puck< leftSideBarVisible, menuOpen, rightSideBarVisible, - disableZoom, })} >
-
setItemSelector(null)} - > -
- - - -
-
+
= { + // small: { width: 390, height: 844 }, // iPhone + small: { width: 360, height: "auto" }, + medium: { width: 768, height: "auto" }, + large: { width: 1280, height: "auto" }, +}; + +const ViewportButton = ({ + Icon, + height, + title, + width, + onClick, +}: { + Icon: LucideIcon; + height: number | "auto"; + title: string; + width: number; + onClick: (viewport: Viewport) => void; +}) => { + const { state } = useAppContext(); + + const isActive = width === state.ui.viewport.width; + + return ( + { + e.stopPropagation(); + onClick({ width, height }); + }} + > + + + ); +}; + +// Based on Chrome dev tools +const defaultZoomOptions = [ + { label: "25%", value: 0.25 }, + { label: "50%", value: 0.5 }, + { label: "75%", value: 0.75 }, + { label: "100%", value: 1 }, + { label: "125%", value: 1.25 }, + { label: "150%", value: 1.5 }, + { label: "200%", value: 2 }, +]; + +export const ViewportControls = ({ + autoZoom, + onViewportChange, +}: { + autoZoom: number; + onViewportChange: (viewport: Viewport) => void; +}) => { + const { state, setUi } = useAppContext(); + const viewport = state.ui.viewport; + + const defaultsContainAutoZoom = defaultZoomOptions.find( + (option) => option.value === autoZoom + ); + + const zoomOptions = useMemo( + () => + [ + ...defaultZoomOptions, + ...(defaultsContainAutoZoom + ? [] + : [ + { + value: autoZoom, + label: `${(autoZoom * 100).toFixed(0)}% (Auto)`, + }, + ]), + ].sort((a, b) => (a.value > b.value ? 1 : -1)), + [autoZoom] + ); + + return ( + <> + + + +
+
+
+ { + e.stopPropagation(); + setUi({ + viewport: { + ...viewport, + zoom: zoomOptions[ + Math.max( + zoomOptions.findIndex( + (option) => option.value === viewport.zoom + ) - 1, + 0 + ) + ].value, + }, + }); + }} + > + + + { + e.stopPropagation(); + + setUi({ + viewport: { + ...viewport, + zoom: zoomOptions[ + Math.min( + zoomOptions.findIndex( + (option) => option.value === viewport.zoom + ) + 1, + zoomOptions.length - 1 + ) + ].value, + }, + }); + }} + > + + +
+
+
+ + + ); +}; diff --git a/packages/core/lib/get-zoom-config.ts b/packages/core/lib/get-zoom-config.ts new file mode 100644 index 0000000000..8fde08bcef --- /dev/null +++ b/packages/core/lib/get-zoom-config.ts @@ -0,0 +1,49 @@ +import { getBox } from "css-box-model"; +import { AppState } from "../types/Config"; + +const RESET_ZOOM_SMALLER_THAN_FRAME = true; + +export const getZoomConfig = ( + uiViewport: AppState["ui"]["viewport"], + frame: HTMLElement +) => { + const box = getBox(frame); + + const { width: frameWidth, height: frameHeight } = box.contentBox; + + const viewportHeight = + uiViewport.height === "auto" ? frameHeight : uiViewport.height; + + let rootHeight = 0; + let autoZoom = 1; + let zoom = uiViewport.zoom; + + if (uiViewport.width > frameWidth || viewportHeight > frameHeight) { + const widthZoom = Math.min(frameWidth / uiViewport.width, 1); + const heightZoom = Math.min(frameHeight / viewportHeight, 1); + + zoom = widthZoom; + + if (widthZoom < heightZoom) { + rootHeight = viewportHeight / zoom; + } else { + rootHeight = viewportHeight; + zoom = heightZoom; + } + + autoZoom = zoom; + } else { + if (RESET_ZOOM_SMALLER_THAN_FRAME) { + autoZoom = 1; + zoom = 1; + + if (uiViewport.zoom === autoZoom) { + rootHeight = viewportHeight; + } else { + rootHeight = viewportHeight / uiViewport.zoom; + } + } + } + + return { autoZoom, rootHeight, zoom }; +}; diff --git a/packages/core/package.json b/packages/core/package.json index 20020f252f..5d0a51722d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,7 @@ "@types/jest": "^29.5.4", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", + "css-box-model": "^1.2.1", "eslint": "^7.32.0", "eslint-config-custom": "*", "jest": "^29.6.4", diff --git a/packages/core/types/Config.tsx b/packages/core/types/Config.tsx index 4487753c14..7eee4dcbd5 100644 --- a/packages/core/types/Config.tsx +++ b/packages/core/types/Config.tsx @@ -273,6 +273,11 @@ export type UiState = { } >; isDragging: boolean; + viewport: { + width: number; + height: number | "auto"; + zoom: number; + }; }; export type AppState = { data: Data; ui: UiState };