diff --git a/react/README.md b/react/README.md index 38185e28..8d19d1d5 100644 --- a/react/README.md +++ b/react/README.md @@ -2,119 +2,453 @@ A React wrapper component for GridStack that provides better TypeScript support and React integration experience. +Open in [CodeSandbox](https://codesandbox.io/p/sandbox/github/gridstack/gridstack.js/tree/master/react?file=/src/App.tsx) + ## TODO -- [x] Component mapping -- [x] SubGrid support -- [ ] Save and restore layout -- [ ] Publish to npm +- [x] Add Widgets +- [x] Add Sub Grid +- [x] Nested Sub Grid +- [x] Remove Widget +- [x] Copy(Duplicate) Widget +- [x] Drag between two grid stacks +- [x] Custom handle (Experimental) +- [x] Drag in item (Experimental) +- [ ] Save/Load grid stack options from storage + +## Usage + +This is not an npm package, it's just a demo project. Please copy the `src/lib` code to your project to use it. + +**Simple** -## Basic Usage +Render item with widget id selector. -This is not an npm package, it's just a demo project. Please copy the relevant code to your project to use it. +Code here: [src/examples/000-simple/index.tsx](src/examples/000-simple/index.tsx) ```tsx -import { - GridStackProvider, - GridStackRender, - GridStackRenderProvider, -} from "path/to/lib"; -import "gridstack/dist/gridstack.css"; -import "gridstack/dist/gridstack-extra.css"; -import "path/to/demo.css"; - -function Text({ content }: { content: string }) { - return
{content}
; +function Simple() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "item2", h: 2, w: 2, x: 2, y: 0 }, + ], + })); + + return ( + + +
hello
+
+ + +
grid
+
+
+ ); } +``` -const COMPONENT_MAP = { - Text, - // ... other components -}; +Or split the grid stack container to provide grid stack context and render component for access to grid stack context. -// Grid options -const gridOptions = { - acceptWidgets: true, - margin: 8, - cellHeight: 50, - children: [ - { - id: "item1", - h: 2, - w: 2, - content: JSON.stringify({ - name: "Text", - props: { content: "Item 1" }, - }), - }, - // ... other grid items - ], -}; +Code here: [src/examples/001-simple/index.tsx](src/examples/001-simple/index.tsx) + +```tsx +function Simple() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "item2", h: 2, w: 2, x: 2, y: 0 }, + ], + })); -function App() { return ( - - + + {/* Custom toolbar component. Access to grid stack context by useGridStackContext hook. */} + - - - - - + + +
hello
+
- + +
grid
+
+
); } ``` -## Advanced Features +**Drag In** -### Toolbar Operations +Drag items from outside into the grid. -Provide APIs to add new components and sub-grids: +Code here: [src/examples/004-drag-in/index.tsx](src/examples/004-drag-in/index.tsx) ```tsx -function Toolbar() { - const { addWidget, addSubGrid } = useGridStackContext(); +function DragIn() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "004-item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "004-item2", h: 2, w: 2, x: 2, y: 0 }, + ], + })); return (
- - +
+ +
+ Drag me add to the grid +
+
+
+ + + +
hello
+
+ + +
grid
+
+
); } ``` -### Layout Saving +**Advanced** + +Render item with widget map component info. + +_ComponentInfoMap is just an example, you can use any way you want to store and retrieve component information._ -Get the current layout: +Code here: [src/examples/009-advanced/index.tsx](src/examples/009-advanced/index.tsx) ```tsx -const { saveOptions } = useGridStackContext(); +function Advanced() { + // Data about layout by gridstack option + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "item2", h: 2, w: 2, x: 2, y: 0 }, + { + id: "sub-grid-1", + h: 5, + sizeToContent: true, + subGridOpts: { + children: [ + { + id: "sub-grid-1-title", + locked: true, + noMove: true, + noResize: true, + w: 12, + x: 0, + y: 0, + content: "Sub Grid 1", + }, + { id: "item3", h: 2, w: 2, x: 0, y: 1 }, + { id: "item4", h: 2, w: 2, x: 2, y: 0 }, + ], + }, + w: 4, + x: 0, + y: 2, + }, + { id: "item5", w: 4, h: 4, x: 0, y: 2 }, + ], + })); -const currentLayout = saveOptions(); + // Data about every content + const [initialComponentInfoMap] = useState>( + () => ({ + item1: { component: "Text", serializableProps: { content: "Text" } }, + item2: { component: "Text", serializableProps: { content: "Text" } }, + "sub-grid-1-title": { + component: "Text", + serializableProps: { content: "Sub Grid 1" }, + }, + item3: { component: "Text", serializableProps: { content: "Text" } }, + item4: { + component: "Counter", + serializableProps: { label: "Click me" }, + }, + item5: { + component: "ComplexCard", + serializableProps: { title: "Complex Card", color: "red" }, + }, + }) + ); + + return ( + + + + + + + + + + ); +} + +function DynamicGridStackItems() { + const { componentInfoMap } = useComponentInfoMap(); + + return ( + <> + {Array.from(componentInfoMap.entries()).map( + ([widgetId, componentInfo]) => { + const Component = COMPONENT_MAP[componentInfo.component]; + if (!Component) { + throw new Error(`Component ${componentInfo.component} not found`); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props = componentInfo.serializableProps as any; + + if (componentInfo.component === "ComplexCard") { + return ( + + + + + + ); + } + + // ... more render conditions here + + return ( + + + + ); + } + )} + + ); +} +``` + +**Experimental** + +Render item with custom handle. + +Code here: [src/examples/003-custom-handle/index.tsx](src/examples/003-custom-handle/index.tsx) + +```tsx +function CustomHandle() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [{ id: "item1", h: 2, w: 2, x: 0, y: 0 }], + })); + + return ( + + + +
Custom Handle
+ + {/* Experimental: Render item with custom handle */} + + + +
+
+
+ ); +} ``` ## API Reference -### GridStackProvider +### Components + +#### GridStackContainer + +Top-level component that provides GridStack context and GridStack root container. Equivalent to `GridStackProvider` and `GridStackRender` combined. + +```typescript +type GridStackContainerProps = { + initialOptions: GridStackOptions; // GridStack initialization options + children: React.ReactNode; +}; +``` + +#### GridStackProvider + +Top-level component that provides GridStack context. -The main context provider, accepts the following properties: +```typescript +type GridStackProviderProps = { + initialOptions: GridStackOptions; // GridStack initialization options + children: React.ReactNode; +}; +``` + +#### GridStackRender + +Render GridStack root container component. + +```typescript +type GridStackRenderProps = { + children: React.ReactNode; +}; +``` + +#### GridStackItem + +Component representing a single grid item. + +```typescript +type GridStackItemProps = { + id: string; // Grid item unique identifier + children: React.ReactNode; +}; +``` + +#### GridStackHandleReInitializer -- `initialOptions`: Initial configuration options for GridStack +Experimental component for reinitializing the drag handle of a grid item. -### GridStackRender +```typescript +type GridStackHandleReInitializerProps = { + children: React.ReactNode; +}; +``` + +#### GridStackDragInItem + +Experimental component for dragging items from outside into the grid. + +```typescript +type GridStackDragInItemProps = { + widget: Omit; // Widget configuration without content + dragOptions?: DDDragOpt; // Drag options + content?: ReactNode; // Optional content to render in the dragged clone + children: React.ReactNode; + // Plus other div props +}; +``` -The core component for rendering the grid, accepts the following properties: +### Contexts + +#### GridStackContext + +Provide GridStack core functionality context. + +```typescript +interface GridStackContextType { + initialOptions: GridStackOptions; + addWidget: (widget: GridStackWidget) => void; + removeWidget: (el: GridStackElement) => void; + saveOptions: () => ReturnType | undefined; + + _gridStack: { + value: GridStack | null; + set: React.Dispatch>; + }; +} +``` -- `componentMap`: A mapping from component names to actual React components +#### GridStackItemContext + +Provide single grid item functionality context. + +```typescript +type GridStackItemContextType = { + id: string; + // Native methods + remove: () => void; + update: (opt: GridStackWidget) => void; + + // Extended methods + getBounds: () => { + current: { x?: number; y?: number; w?: number; h?: number }; + original: { x?: number; y?: number; w?: number; h?: number }; + } | null; + setSize: (size: { w: number; h: number }) => void; + setPosition: (position: { x: number; y: number }) => void; +}; +``` + +#### GridStackRenderContext + +Provide rendering related functionality context. + +```typescript +type GridStackRenderContextType = { + getContainerByWidgetId: (widgetId: string) => HTMLElement | null; +}; +``` ### Hooks -- `useGridStackContext()`: Access GridStack context and operations - - `addWidget`: Add a new component - - `addSubGrid`: Add a new sub-grid - - `saveOptions`: Save current layout - - `initialOptions`: Initial configuration options +#### useGridStackContext + +Get GridStack context. + +```typescript +function useGridStackContext(): GridStackContextType; +``` + +#### useGridStackItemContext + +Get grid item context. + +```typescript +function useGridStackItemContext(): GridStackItemContextType; +``` + +#### useGridStackRenderContext + +Get rendering context. + +```typescript +function useGridStackRenderContext(): GridStackRenderContextType; +``` + +### Type Exports + +```typescript +export type { + GridStackContextType, + GridStackProviderProps, + GridStackRenderContextType, + GridStackRenderProps, + GridStackItemProps, + GridStackItemContextType, + GridStackHandleReInitializerProps, + GridStackDragInItemProps, +}; +``` diff --git a/react/lib/grid-stack-context.ts b/react/lib/grid-stack-context.ts deleted file mode 100644 index 51e95070..00000000 --- a/react/lib/grid-stack-context.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { GridStack, GridStackOptions, GridStackWidget } from "gridstack"; -import { createContext, useContext } from "react"; - -export const GridStackContext = createContext<{ - initialOptions: GridStackOptions; - gridStack: GridStack | null; - addWidget: (fn: (id: string) => Omit) => void; - removeWidget: (id: string) => void; - addSubGrid: ( - fn: ( - id: string, - withWidget: (w: Omit) => GridStackWidget - ) => Omit - ) => void; - saveOptions: () => GridStackOptions | GridStackWidget[] | undefined; - - _gridStack: { - value: GridStack | null; - set: React.Dispatch>; - }; - _rawWidgetMetaMap: { - value: Map; - set: React.Dispatch>>; - }; -} | null>(null); - -export function useGridStackContext() { - const context = useContext(GridStackContext); - if (!context) { - throw new Error( - "useGridStackContext must be used within a GridStackProvider" - ); - } - return context; -} diff --git a/react/lib/grid-stack-provider.tsx b/react/lib/grid-stack-provider.tsx deleted file mode 100644 index 1afe4348..00000000 --- a/react/lib/grid-stack-provider.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import type { GridStack, GridStackOptions, GridStackWidget } from "gridstack"; -import { type PropsWithChildren, useCallback, useState } from "react"; -import { GridStackContext } from "./grid-stack-context"; - -export function GridStackProvider({ - children, - initialOptions, -}: PropsWithChildren<{ initialOptions: GridStackOptions }>) { - const [gridStack, setGridStack] = useState(null); - const [rawWidgetMetaMap, setRawWidgetMetaMap] = useState(() => { - const map = new Map(); - const deepFindNodeWithContent = (obj: GridStackWidget) => { - if (obj.id && obj.content) { - map.set(obj.id, obj); - } - if (obj.subGridOpts?.children) { - obj.subGridOpts.children.forEach((child: GridStackWidget) => { - deepFindNodeWithContent(child); - }); - } - }; - initialOptions.children?.forEach((child: GridStackWidget) => { - deepFindNodeWithContent(child); - }); - return map; - }); - - const addWidget = useCallback( - (fn: (id: string) => Omit) => { - const newId = `widget-${Math.random().toString(36).substring(2, 15)}`; - const widget = fn(newId); - gridStack?.addWidget({ ...widget, id: newId }); - setRawWidgetMetaMap((prev) => { - const newMap = new Map(prev); - newMap.set(newId, widget); - return newMap; - }); - }, - [gridStack] - ); - - const addSubGrid = useCallback( - ( - fn: ( - id: string, - withWidget: (w: Omit) => GridStackWidget - ) => Omit - ) => { - const newId = `sub-grid-${Math.random().toString(36).substring(2, 15)}`; - const subWidgetIdMap = new Map(); - - const widget = fn(newId, (w) => { - const subWidgetId = `widget-${Math.random() - .toString(36) - .substring(2, 15)}`; - subWidgetIdMap.set(subWidgetId, w); - return { ...w, id: subWidgetId }; - }); - - gridStack?.addWidget({ ...widget, id: newId }); - - setRawWidgetMetaMap((prev) => { - const newMap = new Map(prev); - subWidgetIdMap.forEach((meta, id) => { - newMap.set(id, meta); - }); - return newMap; - }); - }, - [gridStack] - ); - - const removeWidget = useCallback( - (id: string) => { - gridStack?.removeWidget(id); - setRawWidgetMetaMap((prev) => { - const newMap = new Map(prev); - newMap.delete(id); - return newMap; - }); - }, - [gridStack] - ); - - const saveOptions = useCallback(() => { - return gridStack?.save(true, true, (_, widget) => widget); - }, [gridStack]); - - return ( - - {children} - - ); -} diff --git a/react/lib/grid-stack-render-context.ts b/react/lib/grid-stack-render-context.ts deleted file mode 100644 index 1135f8a4..00000000 --- a/react/lib/grid-stack-render-context.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext, useContext } from "react"; - -export const GridStackRenderContext = createContext<{ - getWidgetContainer: (widgetId: string) => HTMLElement | null; -} | null>(null); - -export function useGridStackRenderContext() { - const context = useContext(GridStackRenderContext); - if (!context) { - throw new Error( - "useGridStackRenderContext must be used within a GridStackProvider" - ); - } - return context; -} diff --git a/react/lib/grid-stack-render-provider.tsx b/react/lib/grid-stack-render-provider.tsx deleted file mode 100644 index 82c8e1f2..00000000 --- a/react/lib/grid-stack-render-provider.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { - PropsWithChildren, - useCallback, - useLayoutEffect, - useMemo, - useRef, -} from "react"; -import { useGridStackContext } from "./grid-stack-context"; -import { GridStack, GridStackOptions, GridStackWidget } from "gridstack"; -import { GridStackRenderContext } from "./grid-stack-render-context"; -import isEqual from "react-fast-compare"; - -export function GridStackRenderProvider({ children }: PropsWithChildren) { - const { - _gridStack: { value: gridStack, set: setGridStack }, - initialOptions, - } = useGridStackContext(); - - const widgetContainersRef = useRef>(new Map()); - const containerRef = useRef(null); - const optionsRef = useRef(initialOptions); - - const renderCBFn = useCallback( - (element: HTMLElement, widget: GridStackWidget) => { - if (widget.id) { - widgetContainersRef.current.set(widget.id, element); - } - }, - [] - ); - - const initGrid = useCallback(() => { - if (containerRef.current) { - GridStack.renderCB = renderCBFn; - return GridStack.init(optionsRef.current, containerRef.current); - // ! Change event not firing on nested grids (resize, move...) https://github.com/gridstack/gridstack.js/issues/2671 - // .on("change", () => { - // console.log("changed"); - // }) - // .on("resize", () => { - // console.log("resize"); - // }) - } - return null; - }, [renderCBFn]); - - useLayoutEffect(() => { - if (!isEqual(initialOptions, optionsRef.current) && gridStack) { - try { - gridStack.removeAll(false); - gridStack.destroy(false); - widgetContainersRef.current.clear(); - optionsRef.current = initialOptions; - setGridStack(initGrid()); - } catch (e) { - console.error("Error reinitializing gridstack", e); - } - } - }, [initialOptions, gridStack, initGrid, setGridStack]); - - useLayoutEffect(() => { - if (!gridStack) { - try { - setGridStack(initGrid()); - } catch (e) { - console.error("Error initializing gridstack", e); - } - } - }, [gridStack, initGrid, setGridStack]); - - return ( - ({ - getWidgetContainer: (widgetId: string) => { - return widgetContainersRef.current.get(widgetId) || null; - }, - }), - // ! gridStack is required to reinitialize the grid when the options change - // eslint-disable-next-line react-hooks/exhaustive-deps - [gridStack] - )} - > -
{gridStack ? children : null}
-
- ); -} diff --git a/react/lib/grid-stack-render.tsx b/react/lib/grid-stack-render.tsx deleted file mode 100644 index cf9a8489..00000000 --- a/react/lib/grid-stack-render.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { createPortal } from "react-dom"; -import { useGridStackContext } from "./grid-stack-context"; -import { useGridStackRenderContext } from "./grid-stack-render-context"; -import { GridStackWidgetContext } from "./grid-stack-widget-context"; -import { GridStackWidget } from "gridstack"; -import { ComponentType } from "react"; - -export interface ComponentDataType { - name: string; - props: T; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ComponentMap = Record>; - -function parseWeightMetaToComponentData( - meta: GridStackWidget -): ComponentDataType & { error: unknown } { - let error = null; - let name = ""; - let props = {}; - try { - if (meta.content) { - const result = JSON.parse(meta.content) as { - name: string; - props: object; - }; - name = result.name; - props = result.props; - } - } catch (e) { - error = e; - } - return { - name, - props, - error, - }; -} - -export function GridStackRender(props: { componentMap: ComponentMap }) { - const { _rawWidgetMetaMap } = useGridStackContext(); - const { getWidgetContainer } = useGridStackRenderContext(); - - return ( - <> - {Array.from(_rawWidgetMetaMap.value.entries()).map(([id, meta]) => { - const componentData = parseWeightMetaToComponentData(meta); - - const WidgetComponent = props.componentMap[componentData.name]; - - const widgetContainer = getWidgetContainer(id); - - if (!widgetContainer) { - throw new Error(`Widget container not found for id: ${id}`); - } - - return ( - - {createPortal( - , - widgetContainer - )} - - ); - })} - - ); -} diff --git a/react/lib/grid-stack-widget-context.ts b/react/lib/grid-stack-widget-context.ts deleted file mode 100644 index 14ee1c65..00000000 --- a/react/lib/grid-stack-widget-context.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createContext, useContext } from "react"; - -// TODO: support full widget metadata -export const GridStackWidgetContext = createContext<{ - widget: { - id: string; - }; -} | null>(null); - -export function useGridStackWidgetContext() { - const context = useContext(GridStackWidgetContext); - if (!context) { - throw new Error( - "useGridStackWidgetContext must be used within a GridStackWidgetProvider" - ); - } - return context; -} diff --git a/react/lib/index.ts b/react/lib/index.ts deleted file mode 100644 index e5984f37..00000000 --- a/react/lib/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { GridStackProvider } from "./grid-stack-provider"; -import { GridStackRenderProvider } from "./grid-stack-render-provider"; -import { - GridStackRender, - ComponentDataType, - ComponentMap, -} from "./grid-stack-render"; -import { useGridStackContext } from "./grid-stack-context"; -import { useGridStackWidgetContext } from "./grid-stack-widget-context"; - -export { - GridStackProvider, - GridStackRenderProvider, - GridStackRender, - type ComponentDataType, - type ComponentMap, - useGridStackContext, - useGridStackWidgetContext, -}; diff --git a/react/src/App.tsx b/react/src/App.tsx index 72c9508a..90d14089 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -1,14 +1,29 @@ -import { GridStackDemo } from "./demo/demo"; +import "./demo.css"; +import { Simple0 } from "./examples/000-simple"; +import { Simple } from "./examples/001-simple"; +import { Nested } from "./examples/002-nested"; +import { CustomHandle } from "./examples/003-custom-handle"; +import { Advanced } from "./examples/009-advanced"; +import { DragIn } from "./examples/004-drag-in"; -function App() { +export default function App() { return ( - <> -

Gridstack React Wrapper Demo

- -

(Uncontrolled)

- - +
+

Simple

+

Render content by GridStackItem with id selector.

+ +

Simple With Toolbar

+

With toolbar

+ +

Nested

+

Only use gridstack.js native subGridOpts.

+ +

Custom Handle

+ +

Drag In (Copy)

+ +

Advanced

+ +
); } - -export default App; diff --git a/react/src/components/debug-info.tsx b/react/src/components/debug-info.tsx new file mode 100644 index 00000000..33b30c04 --- /dev/null +++ b/react/src/components/debug-info.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { useGridStackContext } from "../lib"; +import { GridStackOptions, GridStackWidget } from "gridstack"; + +export function DebugInfo() { + const { initialOptions, saveOptions } = useGridStackContext(); + + const [realtimeOptions, setRealtimeOptions] = useState< + GridStackOptions | GridStackWidget[] | undefined + >(undefined); + + useEffect(() => { + const timer = setInterval(() => { + if (saveOptions) { + const data = saveOptions(); + setRealtimeOptions(data); + } + }, 2000); + + return () => clearInterval(timer); + }, [saveOptions]); + + return ( +
+

Debug Info

+
+
+

Initial Options

+
+            {JSON.stringify(initialOptions, null, 2)}
+          
+
+
+

Realtime Options (2s refresh)

+
+            {JSON.stringify(realtimeOptions, null, 2)}
+          
+
+
+
+ ); +} diff --git a/react/src/default-grid-options.ts b/react/src/default-grid-options.ts new file mode 100644 index 00000000..1297cb6d --- /dev/null +++ b/react/src/default-grid-options.ts @@ -0,0 +1,37 @@ +import { GridStackOptions } from "gridstack"; + +export const CELL_HEIGHT = 50; +export const BREAKPOINTS = [ + { c: 1, w: 700 }, + { c: 3, w: 850 }, + { c: 6, w: 950 }, + { c: 8, w: 1100 }, +]; + +export const CUSTOM_DRAGGABLE_HANDLE_CLASSNAME = "custom-draggable-handle"; + +export const defaultGridOptions: GridStackOptions = { + handleClass: CUSTOM_DRAGGABLE_HANDLE_CLASSNAME, + acceptWidgets: true, + columnOpts: { + breakpointForWindow: true, + breakpoints: BREAKPOINTS, + layout: "moveScale", + columnMax: 12, + }, + margin: 8, + cellHeight: CELL_HEIGHT, + subGridOpts: { + acceptWidgets: true, + columnOpts: { + breakpoints: BREAKPOINTS, + layout: "moveScale", + }, + margin: 8, + minRow: 2, + cellHeight: CELL_HEIGHT, + alwaysShowResizeHandle: false, + column: "auto", + layout: "list", + }, +}; diff --git a/react/src/demo/demo.css b/react/src/demo.css similarity index 100% rename from react/src/demo/demo.css rename to react/src/demo.css diff --git a/react/src/demo/demo.tsx b/react/src/demo/demo.tsx deleted file mode 100644 index 2e1499b5..00000000 --- a/react/src/demo/demo.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { ComponentProps, useEffect, useState } from "react"; -import { GridStackOptions, GridStackWidget } from "gridstack"; -import { - ComponentDataType, - ComponentMap, - GridStackProvider, - GridStackRender, - GridStackRenderProvider, - useGridStackContext, -} from "../../lib"; - -import "gridstack/dist/gridstack-extra.css"; -import "gridstack/dist/gridstack.css"; -import "./demo.css"; - -const CELL_HEIGHT = 50; -const BREAKPOINTS = [ - { c: 1, w: 700 }, - { c: 3, w: 850 }, - { c: 6, w: 950 }, - { c: 8, w: 1100 }, -]; - -function Text({ content }: { content: string }) { - return
{content}
; -} - -const COMPONENT_MAP: ComponentMap = { - Text, - // ... other components here -}; - -// ! Content must be json string like this: -// { name: "Text", props: { content: "Item 1" } } -const gridOptions: GridStackOptions = { - acceptWidgets: true, - columnOpts: { - breakpointForWindow: true, - breakpoints: BREAKPOINTS, - layout: "moveScale", - columnMax: 12, - }, - margin: 8, - cellHeight: CELL_HEIGHT, - subGridOpts: { - acceptWidgets: true, - columnOpts: { - breakpoints: BREAKPOINTS, - layout: "moveScale", - }, - margin: 8, - minRow: 2, - cellHeight: CELL_HEIGHT, - }, - children: [ - { - id: "item1", - h: 2, - w: 2, - x: 0, - y: 0, - content: JSON.stringify({ - name: "Text", - props: { content: "Item 1" }, - } satisfies ComponentDataType>), // if need type check - }, - { - id: "item2", - h: 2, - w: 2, - x: 2, - y: 0, - content: JSON.stringify({ - name: "Text", - props: { content: "Item 2" }, - }), - }, - { - id: "sub-grid-1", - h: 5, - sizeToContent: true, - subGridOpts: { - acceptWidgets: true, - cellHeight: CELL_HEIGHT, - alwaysShowResizeHandle: false, - column: "auto", - minRow: 2, - layout: "list", - margin: 8, - children: [ - { - id: "sub-grid-1-title", - locked: true, - noMove: true, - noResize: true, - w: 12, - x: 0, - y: 0, - content: JSON.stringify({ - name: "Text", - props: { content: "Sub Grid 1 Title" }, - }), - }, - { - id: "item3", - h: 2, - w: 2, - x: 0, - y: 1, - content: JSON.stringify({ - name: "Text", - props: { content: "Item 3" }, - }), - }, - { - id: "item4", - h: 2, - w: 2, - x: 2, - y: 0, - content: JSON.stringify({ - name: "Text", - props: { content: "Item 4" }, - }), - }, - ], - }, - w: 12, - x: 0, - y: 2, - }, - ], -}; - -export function GridStackDemo() { - // ! Uncontrolled - const [initialOptions] = useState(gridOptions); - - return ( - - - - - - - - - - ); -} - -function Toolbar() { - const { addWidget, addSubGrid } = useGridStackContext(); - - return ( -
- - - -
- ); -} - -function DebugInfo() { - const { initialOptions, saveOptions } = useGridStackContext(); - - const [realtimeOptions, setRealtimeOptions] = useState< - GridStackOptions | GridStackWidget[] | undefined - >(undefined); - - useEffect(() => { - const timer = setInterval(() => { - if (saveOptions) { - const data = saveOptions(); - setRealtimeOptions(data); - } - }, 2000); - - return () => clearInterval(timer); - }, [saveOptions]); - - return ( -
-

Debug Info

-
-
-

Initial Options

-
-            {JSON.stringify(initialOptions, null, 2)}
-          
-
-
-

Realtime Options (2s refresh)

-
-            {JSON.stringify(realtimeOptions, null, 2)}
-          
-
-
-
- ); -} diff --git a/react/src/examples/000-simple/index.tsx b/react/src/examples/000-simple/index.tsx new file mode 100644 index 00000000..e4cefa8c --- /dev/null +++ b/react/src/examples/000-simple/index.tsx @@ -0,0 +1,27 @@ +import { GridStackOptions } from "gridstack"; +import { useState } from "react"; +import { defaultGridOptions } from "../../default-grid-options"; +import { GridStackItem } from "../../lib"; +import { GridStackContainer } from "../../lib/grid-stack-container"; + +export function Simple0() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "000-item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "000-item2", h: 2, w: 2, x: 2, y: 0 }, + ], + })); + + return ( + + +
hello
+
+ + +
grid
+
+
+ ); +} diff --git a/react/src/examples/001-simple/index.tsx b/react/src/examples/001-simple/index.tsx new file mode 100644 index 00000000..b4277597 --- /dev/null +++ b/react/src/examples/001-simple/index.tsx @@ -0,0 +1,66 @@ +import { GridStackOptions } from "gridstack"; +import { useState } from "react"; +import { defaultGridOptions } from "../../default-grid-options"; +import { + GridStackItem, + GridStackProvider, + GridStackRender, + useGridStackContext, +} from "../../lib"; +import { newId } from "../../utils"; + +export function Simple() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "001-item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "001-item2", h: 2, w: 2, x: 2, y: 0 }, + ], + })); + + return ( + + + + + +
hello
+
+ + +
grid
+
+
+
+ ); +} + +export function Toolbar() { + const { addWidget } = useGridStackContext(); + + function handleAddText(w: number, h: number) { + const widgetId = newId(); + addWidget({ id: widgetId, w, h, x: 0, y: 0, content: "text-" + widgetId }); + } + + return ( +
+ +
+ ); +} diff --git a/react/src/examples/002-nested/index.tsx b/react/src/examples/002-nested/index.tsx new file mode 100644 index 00000000..daa6690a --- /dev/null +++ b/react/src/examples/002-nested/index.tsx @@ -0,0 +1,135 @@ +import { GridStackOptions } from "gridstack"; +import { useState } from "react"; +import { defaultGridOptions } from "../../default-grid-options"; +import { + GridStackItem, + GridStackProvider, + GridStackRender, + useGridStackContext, +} from "../../lib"; +import { newId } from "../../utils"; + +export function Nested() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "002-item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "002-item2", h: 2, w: 2, x: 2, y: 0 }, + { + id: "002-sub-grid-1", + h: 5, + sizeToContent: true, + subGridOpts: { + children: [ + { + id: "002-sub-grid-1-title", + locked: true, + noMove: true, + noResize: true, + w: 12, + x: 0, + y: 0, + content: "Sub Grid 1", + }, + { id: "002-item3", h: 2, w: 2, x: 0, y: 1 }, + { id: "002-item4", h: 2, w: 2, x: 2, y: 0 }, + ], + }, + w: 12, + x: 0, + y: 2, + }, + ], + })); + + return ( + + + + + +
hello
+
+ + +
grid
+
+ + +
nested one
+
+ + +
nested two
+
+
+
+ ); +} + +export function Toolbar() { + const { addWidget } = useGridStackContext(); + + function handleAddText(w: number, h: number) { + const widgetId = newId(); + addWidget({ id: widgetId, w, h, x: 0, y: 0, content: "text-" + widgetId }); + } + + function handleAddSubGrid() { + const subGridId = newId(); + const item1Id = newId(); + const item2Id = newId(); + addWidget({ + id: "sub-grid-" + subGridId, + h: 5, + sizeToContent: true, + subGridOpts: { + children: [ + { + id: "sub-grid-" + subGridId + "-title", + locked: true, + noMove: true, + noResize: true, + w: 12, + x: 0, + y: 0, + content: "Sub Grid " + subGridId, + }, + { id: item1Id, h: 2, w: 2, x: 0, y: 1, content: "item" + item1Id }, + { id: item2Id, h: 2, w: 2, x: 2, y: 0, content: "item" + item2Id }, + ], + }, + w: 4, + x: 0, + y: 0, + }); + } + + return ( +
+ + +
+ ); +} diff --git a/react/src/examples/003-custom-handle/index.tsx b/react/src/examples/003-custom-handle/index.tsx new file mode 100644 index 00000000..f112bb60 --- /dev/null +++ b/react/src/examples/003-custom-handle/index.tsx @@ -0,0 +1,36 @@ +import { GridStackOptions } from "gridstack"; +import { useState } from "react"; +import { + CUSTOM_DRAGGABLE_HANDLE_CLASSNAME, + defaultGridOptions, +} from "../../default-grid-options"; +import { + GridStackHandleReInitializer, + GridStackItem, + GridStackProvider, + GridStackRender, +} from "../../lib"; + +export function CustomHandle() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [{ id: "003-item1", h: 2, w: 2, x: 0, y: 0 }], + })); + + return ( + + + +
Custom Handle
+ + {/* Experimental: Render item with custom handle */} + + + +
+
+
+ ); +} diff --git a/react/src/examples/004-drag-in/index.tsx b/react/src/examples/004-drag-in/index.tsx new file mode 100644 index 00000000..a79149df --- /dev/null +++ b/react/src/examples/004-drag-in/index.tsx @@ -0,0 +1,52 @@ +import { GridStackOptions } from "gridstack"; +import { useState } from "react"; +import { defaultGridOptions } from "../../default-grid-options"; +import { GridStackItem, GridStackDragInItem } from "../../lib"; +import { GridStackContainer } from "../../lib/grid-stack-container"; + +export function DragIn() { + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "004-item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "004-item2", h: 2, w: 2, x: 2, y: 0 }, + ], + })); + + return ( +
+
+ +
+ Drag me add to the grid +
+
+
+ + + +
hello
+
+ + +
grid
+
+
+
+ ); +} diff --git a/react/src/examples/009-advanced/component-map.tsx b/react/src/examples/009-advanced/component-map.tsx new file mode 100644 index 00000000..598b0c64 --- /dev/null +++ b/react/src/examples/009-advanced/component-map.tsx @@ -0,0 +1,22 @@ +import { ComponentProps } from "react"; +import { ComplexCard } from "./components/complex-card"; +import { Counter } from "./components/counter"; +import { Text } from "./components/text"; + +export const COMPONENT_MAP = { + Text, + Counter, + ComplexCard, + // ... more components here +}; + +export type ComponentMapName = keyof typeof COMPONENT_MAP; +export type ComponentMapProps = { + [K in ComponentMapName]: ComponentProps<(typeof COMPONENT_MAP)[K]>; +}; +export type ComponentInfo = { + [K in ComponentMapName]: { + component: K; + serializableProps: ComponentMapProps[K]; + }; +}[ComponentMapName]; diff --git a/react/src/examples/009-advanced/components/complex-card.tsx b/react/src/examples/009-advanced/components/complex-card.tsx new file mode 100644 index 00000000..056e6794 --- /dev/null +++ b/react/src/examples/009-advanced/components/complex-card.tsx @@ -0,0 +1,170 @@ +import { PropsWithChildren, useState } from "react"; +import { + GridStackHandleReInitializer, + useGridStackContext, + useGridStackItemContext, +} from "../../../lib"; +import { newId } from "../../../utils"; +import { CUSTOM_DRAGGABLE_HANDLE_CLASSNAME } from "../../../default-grid-options"; +import { useComponentInfoMap } from "./component-info-map"; + +type ComplexCardProps = { + title: string; + color?: string; +}; + +export function ComplexCard(props: ComplexCardProps) { + return ( +
+

{props.title}

+
+ ); +} + +export function ComplexCardEditableWrapper( + props: PropsWithChildren<{ serializableProps: ComplexCardProps }> +) { + const { + id, + remove, + getBounds, + setSize: setSizeGridStack, + } = useGridStackItemContext(); + const { addWidget } = useGridStackContext(); + const { addComponentInfo, updateComponentInfo } = useComponentInfoMap(); + + const [dialogEditOpen, setDialogEditOpen] = useState(false); + + const title = props.serializableProps.title; + const color = props.serializableProps.color; + const setTitle = (title: string) => { + updateComponentInfo(id, { + component: "ComplexCard", + serializableProps: { title, color }, + }); + }; + const setColor = (color: string) => { + updateComponentInfo(id, { + component: "ComplexCard", + serializableProps: { title, color }, + }); + }; + const [size, _setSize] = useState<{ w: number; h: number }>({ + w: 0, + h: 0, + }); + const setSize = (size: { w: number; h: number }) => { + _setSize(size); + setSizeGridStack(size); + }; + + return ( + <> + {props.children} +
+ + + + + setDialogEditOpen(false)}> +
+ + + +
+ +
+
+
+ + + + + + +
+ + ); +} diff --git a/react/src/examples/009-advanced/components/component-info-map/component-info-map-context.ts b/react/src/examples/009-advanced/components/component-info-map/component-info-map-context.ts new file mode 100644 index 00000000..6781eb9f --- /dev/null +++ b/react/src/examples/009-advanced/components/component-info-map/component-info-map-context.ts @@ -0,0 +1,16 @@ +import { createContext } from "react"; +import { ComponentInfo } from "../../component-map"; + +export const ComponentInfoMapContext = createContext<{ + componentInfoMap: Map; + setComponentInfoMap: (componentInfoMap: Map) => void; + removeComponentInfo: (widgetId: string) => void; + addComponentInfo: (widgetId: string, componentInfo: ComponentInfo) => void; + updateComponentInfo: (widgetId: string, componentInfo: ComponentInfo) => void; +}>({ + componentInfoMap: new Map(), + setComponentInfoMap: () => {}, + removeComponentInfo: () => {}, + addComponentInfo: () => {}, + updateComponentInfo: () => {}, +}); diff --git a/react/src/examples/009-advanced/components/component-info-map/component-info-map-provider.tsx b/react/src/examples/009-advanced/components/component-info-map/component-info-map-provider.tsx new file mode 100644 index 00000000..87dee6e6 --- /dev/null +++ b/react/src/examples/009-advanced/components/component-info-map/component-info-map-provider.tsx @@ -0,0 +1,58 @@ +import { PropsWithChildren, useState, useCallback } from "react"; +import { ComponentInfo } from "../../component-map"; +import { ComponentInfoMapContext } from "./component-info-map-context"; + +export function ComponentInfoMapProvider({ + children, + initialComponentInfoMap, +}: PropsWithChildren<{ + initialComponentInfoMap: Record; +}>) { + const [componentInfoMap, setComponentInfoMap] = useState< + Map + >(new Map(Object.entries(initialComponentInfoMap))); + + const removeComponentInfo = useCallback((widgetId: string) => { + setComponentInfoMap((prev) => { + const newMap = new Map(prev); + newMap.delete(widgetId); + return newMap; + }); + }, []); + + const addComponentInfo = useCallback( + (widgetId: string, componentInfo: ComponentInfo) => { + setComponentInfoMap((prev) => { + const newMap = new Map(prev); + newMap.set(widgetId, componentInfo); + return newMap; + }); + }, + [] + ); + + const updateComponentInfo = useCallback( + (widgetId: string, componentInfo: ComponentInfo) => { + setComponentInfoMap((prev) => { + const newMap = new Map(prev); + newMap.set(widgetId, componentInfo); + return newMap; + }); + }, + [] + ); + + return ( + + {children} + + ); +} diff --git a/react/src/examples/009-advanced/components/component-info-map/index.ts b/react/src/examples/009-advanced/components/component-info-map/index.ts new file mode 100644 index 00000000..a4eabda6 --- /dev/null +++ b/react/src/examples/009-advanced/components/component-info-map/index.ts @@ -0,0 +1,3 @@ +export { ComponentInfoMapContext } from "./component-info-map-context"; +export { useComponentInfoMap } from "./use-component-info-map"; +export { ComponentInfoMapProvider } from "./component-info-map-provider"; diff --git a/react/src/examples/009-advanced/components/component-info-map/use-component-info-map.ts b/react/src/examples/009-advanced/components/component-info-map/use-component-info-map.ts new file mode 100644 index 00000000..7abf92ef --- /dev/null +++ b/react/src/examples/009-advanced/components/component-info-map/use-component-info-map.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { ComponentInfoMapContext } from "./component-info-map-context"; + +export function useComponentInfoMap() { + const context = useContext(ComponentInfoMapContext); + if (!context) { + throw new Error( + "useComponentInfoMap must be used within a ComponentInfoMapProvider" + ); + } + return context; +} diff --git a/react/src/examples/009-advanced/components/counter.tsx b/react/src/examples/009-advanced/components/counter.tsx new file mode 100644 index 00000000..9e49d0df --- /dev/null +++ b/react/src/examples/009-advanced/components/counter.tsx @@ -0,0 +1,15 @@ +import { useState } from "react"; + +type CounterProps = { + label: string; +}; + +export function Counter(props: CounterProps) { + const [count, setCount] = useState(0); + + return ( + + ); +} diff --git a/react/src/examples/009-advanced/components/text.tsx b/react/src/examples/009-advanced/components/text.tsx new file mode 100644 index 00000000..7200c33d --- /dev/null +++ b/react/src/examples/009-advanced/components/text.tsx @@ -0,0 +1,7 @@ +type TextProps = { + content: string; +}; + +export function Text(props: TextProps) { + return
{props.content}
; +} diff --git a/react/src/examples/009-advanced/index.tsx b/react/src/examples/009-advanced/index.tsx new file mode 100644 index 00000000..24d2cbae --- /dev/null +++ b/react/src/examples/009-advanced/index.tsx @@ -0,0 +1,283 @@ +import { useState } from "react"; + +import { + GridStackDragInItem, + GridStackItem, + GridStackProvider, + GridStackRender, + useGridStackContext, +} from "../../lib"; +import { GridStackOptions } from "gridstack"; +import { defaultGridOptions } from "../../default-grid-options"; +import { COMPONENT_MAP, ComponentInfo } from "./component-map"; +import { ComplexCardEditableWrapper } from "./components/complex-card"; +import { + ComponentInfoMapProvider, + useComponentInfoMap, +} from "./components/component-info-map"; +import { newId } from "../../utils"; +import { Counter } from "./components/counter"; + +export function Advanced() { + // Data about layout by gridstack option + const [uncontrolledInitialOptions] = useState(() => ({ + ...defaultGridOptions, + children: [ + { id: "009-item1", h: 2, w: 2, x: 0, y: 0 }, + { id: "009-item2", h: 2, w: 2, x: 2, y: 0 }, + { + id: "009-sub-grid-1", + h: 5, + sizeToContent: true, + subGridOpts: { + children: [ + { + id: "009-sub-grid-1-title", + locked: true, + noMove: true, + noResize: true, + w: 12, + x: 0, + y: 0, + content: "Sub Grid 1", + }, + { id: "009-item3", h: 2, w: 2, x: 0, y: 1 }, + { id: "009-item4", h: 2, w: 2, x: 2, y: 0 }, + ], + }, + w: 4, + x: 0, + y: 2, + }, + { id: "009-item5", w: 4, h: 4, x: 0, y: 2 }, + ], + })); + + // Data about every content + const [initialComponentInfoMap] = useState>( + () => ({ + "009-item1": { + component: "Text", + serializableProps: { content: "Text" }, + }, + "009-item2": { + component: "Text", + serializableProps: { content: "Text" }, + }, + "009-sub-grid-1-title": { + component: "Text", + serializableProps: { content: "Sub Grid 1" }, + }, + "009-item3": { + component: "Text", + serializableProps: { content: "Text" }, + }, + "009-item4": { + component: "Counter", + serializableProps: { label: "Click me" }, + }, + "009-item5": { + component: "ComplexCard", + serializableProps: { title: "Complex Card", color: "red" }, + }, + }) + ); + + return ( + + + + + + + + + + ); +} + +function DynamicGridStackItems() { + const { componentInfoMap } = useComponentInfoMap(); + + return ( + <> + {Array.from(componentInfoMap.entries()).map( + ([widgetId, componentInfo]) => { + const Component = COMPONENT_MAP[componentInfo.component]; + if (!Component) { + throw new Error(`Component ${componentInfo.component} not found`); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const props = componentInfo.serializableProps as any; + + if (componentInfo.component === "ComplexCard") { + return ( + + + + + + ); + } + + // ... more render conditions here + + return ( + + + + ); + } + )} + + ); +} + +export function Toolbar() { + const { addWidget } = useGridStackContext(); + const { addComponentInfo } = useComponentInfoMap(); + + function handleAddText(w: number, h: number) { + const widgetId = newId(); + + // Add item to layout + addWidget({ id: widgetId, w, h, x: 0, y: 0 }); + + // Add component to item + addComponentInfo(widgetId, { + component: "Text", + serializableProps: { content: "Text " + widgetId }, + }); + } + + function handleAddSubGrid() { + const subGridId = newId(); + const subGridTitleId = newId(); + const item1Id = newId(); + const item2Id = newId(); + + addWidget({ + id: subGridId, + h: 5, + sizeToContent: true, + subGridOpts: { + children: [ + { + id: subGridTitleId, + locked: true, + noMove: true, + noResize: true, + w: 12, + x: 0, + y: 0, + }, + { id: item1Id, h: 2, w: 2, x: 0, y: 1 }, + { id: item2Id, h: 2, w: 2, x: 2, y: 0 }, + ], + }, + w: 4, + x: 0, + y: 0, + }); + + addComponentInfo(subGridTitleId, { + component: "Text", + serializableProps: { content: "Sub Grid " + subGridId }, + }); + + addComponentInfo(item1Id, { + component: "Text", + serializableProps: { content: "Item " + item1Id }, + }); + + addComponentInfo(item2Id, { + component: "Text", + serializableProps: { content: "Item " + item2Id }, + }); + } + + function handleAddComplexCard() { + const widgetId = newId(); + + addWidget({ id: widgetId, w: 4, h: 4 }); // No position + + addComponentInfo(widgetId, { + component: "ComplexCard", + serializableProps: { title: "Complex Card", color: "red" }, + }); + } + + return ( +
+ + + + + + {/* TODO add to the component info map */} + +
+ Drag me add to the grid +
+
+ + {/* TODO add to the component info map */} + + Copied Counter: + +
+ } + > +
+ Drag Counter like: +
+ + + ); +} diff --git a/react/src/lib/global.ts b/react/src/lib/global.ts new file mode 100644 index 00000000..10862cf7 --- /dev/null +++ b/react/src/lib/global.ts @@ -0,0 +1,10 @@ +import { GridStack, GridStackWidget } from "gridstack"; + +export const widgetContainers = new Array<{ + element: HTMLElement; + initWidget: GridStackWidget; +}>(); + +GridStack.renderCB = (element: HTMLElement, widget: GridStackWidget) => { + widgetContainers.push({ element, initWidget: widget }); +}; diff --git a/react/src/lib/grid-stack-container.tsx b/react/src/lib/grid-stack-container.tsx new file mode 100644 index 00000000..0a5646b7 --- /dev/null +++ b/react/src/lib/grid-stack-container.tsx @@ -0,0 +1,19 @@ +import { GridStackOptions } from "gridstack"; +import { PropsWithChildren } from "react"; +import { GridStackProvider } from "./grid-stack-provider"; +import { GridStackRender } from "./grid-stack-render"; + +export type GridStackContainerProps = PropsWithChildren<{ + initialOptions: GridStackOptions; +}>; + +export function GridStackContainer({ + children, + initialOptions, +}: GridStackContainerProps) { + return ( + + {children} + + ); +} diff --git a/react/src/lib/grid-stack-context.ts b/react/src/lib/grid-stack-context.ts new file mode 100644 index 00000000..ef3a0011 --- /dev/null +++ b/react/src/lib/grid-stack-context.ts @@ -0,0 +1,33 @@ +import type { + GridStack, + GridStackElement, + GridStackOptions, + GridStackWidget, +} from "gridstack"; +import { createContext, useContext } from "react"; + +export interface GridStackContextType { + initialOptions: GridStackOptions; + addWidget: (widget: GridStackWidget) => void; + removeWidget: (el: GridStackElement) => void; + saveOptions: () => ReturnType | undefined; + + _gridStack: { + value: GridStack | null; + set: React.Dispatch>; + }; +} + +export const GridStackContext = createContext( + null +); + +export function useGridStackContext() { + const context = useContext(GridStackContext); + if (!context) { + throw new Error( + "useGridStackContext must be used within a GridStackProvider" + ); + } + return context; +} diff --git a/react/src/lib/grid-stack-drag-in-item.tsx b/react/src/lib/grid-stack-drag-in-item.tsx new file mode 100644 index 00000000..5337445c --- /dev/null +++ b/react/src/lib/grid-stack-drag-in-item.tsx @@ -0,0 +1,77 @@ +import { DDDragOpt, GridStack, GridStackWidget, Utils } from "gridstack"; +import { + ComponentProps, + Fragment, + PropsWithChildren, + ReactNode, + useEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +export type GridStackDragInItemProps = PropsWithChildren< + Omit, "content" | "children" | "widget"> & { + widget: Omit; + dragOptions?: DDDragOpt; + content?: ReactNode; + } +>; + +/** + * @experimental + * This is a temporary solution to drag in items to the grid. + * Copy the original element and render it in the portal. + */ +export function GridStackDragInItem({ + children, + widget, + className, + dragOptions, + content, + ...props +}: GridStackDragInItemProps) { + const panelRef = useRef(null); + const [clones, setClones] = useState>(new Map()); + const incrementalId = useRef(0); + + useEffect(() => { + if (panelRef.current) { + GridStack.setupDragIn( + [panelRef.current], + { + ...dragOptions, + helper: (el) => { + const clone = Utils.cloneNode(el); + const id = String(incrementalId.current++); + + setClones((prev) => { + const newMap = new Map(prev); + newMap.set(id, clone); + return newMap; + }); + // ! clear dom copied from the original element + clone.children[0].innerHTML = ""; + return clone; + }, + }, + [widget] + ); + } + }, [clones, dragOptions, widget]); + + return ( + <> +
+
{children}
+
+ + {/* Render the cloned element in the portal */} + {Array.from(clones.entries()).map(([id, clone]) => ( + + {createPortal(content ?? children, clone.children[0])} + + ))} + + ); +} diff --git a/react/src/lib/grid-stack-handle-re-initializer.tsx b/react/src/lib/grid-stack-handle-re-initializer.tsx new file mode 100644 index 00000000..0d86af13 --- /dev/null +++ b/react/src/lib/grid-stack-handle-re-initializer.tsx @@ -0,0 +1,63 @@ +import { PropsWithChildren, useLayoutEffect } from "react"; +import { useGridStackContext } from "./grid-stack-context"; +import { + GridItemHTMLElement, + GridStack, + GridStackNode, + Utils, +} from "gridstack"; +import { DDElementHost } from "gridstack/dist/dd-element"; +import { useGridStackItemContext } from "./grid-stack-item-context"; +import { useGridStackRenderContext } from "./grid-stack-render-context"; + +export type GridStackHandleReInitializerProps = PropsWithChildren; + +/** + * @experimental + * This is a temporary solution to reinitialize the handle for the grid stack item. + */ +export function GridStackHandleReInitializer( + props: GridStackHandleReInitializerProps +) { + const { + _gridStack: { value: gridStack }, + } = useGridStackContext(); + const { id: widgetId } = useGridStackItemContext(); + const { getContainerByWidgetId } = useGridStackRenderContext(); + + useLayoutEffect(() => { + if (gridStack) { + const widgetContainer = getContainerByWidgetId(widgetId); + if (widgetContainer) { + const element = Utils.getElement( + widgetContainer.parentElement! + ) as GridItemHTMLElement & DDElementHost; + const rawNode = element.gridstackNode; + const ddElement = element.ddElement; + if (rawNode && ddElement) { + // https://github.com/gridstack/gridstack.js/blob/a917afcada4bd2892963678c8b1bde7630bb9528/src/gridstack.ts#L2417 + const node = rawNode as GridStackNode & { _initDD: boolean }; + node._initDD = false; + + ddElement.cleanDraggable(); + ddElement.cleanDroppable(); + + // https://github.com/gridstack/gridstack.js/blob/a917afcada4bd2892963678c8b1bde7630bb9528/src/gridstack.ts#L2402 + const g = gridStack as GridStack & { + _prepareDragDropByNode?: (node: GridStackNode) => void; + }; + if (g._prepareDragDropByNode) { + g._prepareDragDropByNode(node); + } else { + // https://github.com/gridstack/gridstack.js/blob/90a014d5f396ac335962c4192d6aa434f04bf223/src/gridstack.ts#L2413 + if (g.prepareDragDrop) { + g.prepareDragDrop(element); + } + } + } + } + } + }, [getContainerByWidgetId, gridStack, widgetId]); + + return <>{props.children}; +} diff --git a/react/src/lib/grid-stack-item-context.ts b/react/src/lib/grid-stack-item-context.ts new file mode 100644 index 00000000..72886922 --- /dev/null +++ b/react/src/lib/grid-stack-item-context.ts @@ -0,0 +1,60 @@ +import { GridStackWidget } from "gridstack"; +import { createContext, useContext } from "react"; + +export type GridStackItemContextType = { + id: string; + + // Native methods + remove: () => void; + update: (opt: GridStackWidget) => void; + + // Extended methods + getBounds: () => { + current: { + x: number | undefined; + y: number | undefined; + w: number | undefined; + h: number | undefined; + }; + original: { + x: number | undefined; + y: number | undefined; + w: number | undefined; + h: number | undefined; + }; + } | null; + setSize: (size: { w: number; h: number }) => void; + setPosition: (position: { x: number; y: number }) => void; +}; + +export const GridStackItemContext = createContext({ + id: "", + remove: () => { + console.error("remove not implemented"); + }, + update: () => { + console.error("update not implemented"); + }, + getBounds: () => { + console.error("getBounds not implemented"); + return null; + }, + setSize: () => { + console.error("setSize not implemented"); + }, + setPosition: () => { + console.error("setPosition not implemented"); + }, +}); + +export function useGridStackItemContext() { + const context = useContext(GridStackItemContext); + + if (!context) { + throw new Error( + "useGridStackItemContext must be used within a GridStackItemContext" + ); + } + + return context; +} diff --git a/react/src/lib/grid-stack-item.tsx b/react/src/lib/grid-stack-item.tsx new file mode 100644 index 00000000..fca47c50 --- /dev/null +++ b/react/src/lib/grid-stack-item.tsx @@ -0,0 +1,100 @@ +import { PropsWithChildren, useCallback } from "react"; +import { useGridStackRenderContext } from "./grid-stack-render-context"; +import { createPortal } from "react-dom"; +import { GridStackItemContext } from "./grid-stack-item-context"; +import { useGridStackContext } from "./grid-stack-context"; +import { GridItemHTMLElement, GridStackWidget } from "gridstack"; + +export type GridStackItemProps = PropsWithChildren<{ + id: string; +}>; + +export function GridStackItem(props: GridStackItemProps) { + const renderContext = useGridStackRenderContext(); + const widgetContainer = renderContext.getContainerByWidgetId(props.id); + + const { removeWidget, _gridStack } = useGridStackContext(); + + const remove = useCallback(() => { + if (widgetContainer?.parentElement) { + removeWidget(widgetContainer.parentElement as GridItemHTMLElement); + } + }, [removeWidget, widgetContainer?.parentElement]); + + const update = useCallback( + (opt: GridStackWidget) => { + if (widgetContainer?.parentElement) { + _gridStack.value?.update(widgetContainer.parentElement, opt); + } + }, + [_gridStack.value, widgetContainer?.parentElement] + ); + + const getBounds = useCallback(() => { + const parentNode = widgetContainer?.parentElement; + if (parentNode) { + const widgetNode = parentNode as GridItemHTMLElement; + if (widgetNode.gridstackNode) { + const gridstackNode = widgetNode.gridstackNode; + return { + current: { + x: gridstackNode.x, + y: gridstackNode.y, + w: gridstackNode.w, + h: gridstackNode.h, + }, + original: { + x: gridstackNode.x, + y: gridstackNode.y, + w: gridstackNode.w, + h: gridstackNode.h, + }, + }; + } + } + return null; + }, [widgetContainer?.parentElement]); + + const setSize = useCallback( + (size: { w: number; h: number }) => { + if (widgetContainer?.parentElement) { + _gridStack.value?.update(widgetContainer.parentElement, { + w: size.w, + h: size.h, + }); + } + }, + [_gridStack.value, widgetContainer?.parentElement] + ); + const setPosition = useCallback( + (position: { x: number; y: number }) => { + if (widgetContainer?.parentElement) { + _gridStack.value?.update(widgetContainer.parentElement, { + x: position.x, + y: position.y, + }); + } + }, + [_gridStack.value, widgetContainer?.parentElement] + ); + + if (!widgetContainer) { + return null; + } + + return createPortal( + + {props.children} + , + widgetContainer + ); +} diff --git a/react/src/lib/grid-stack-provider.tsx b/react/src/lib/grid-stack-provider.tsx new file mode 100644 index 00000000..3fcd3777 --- /dev/null +++ b/react/src/lib/grid-stack-provider.tsx @@ -0,0 +1,56 @@ +import type { + GridStack, + GridStackElement, + GridStackOptions, + GridStackWidget, +} from "gridstack"; +import { type PropsWithChildren, useCallback, useState } from "react"; +import { GridStackContext } from "./grid-stack-context"; + +export type GridStackProviderProps = PropsWithChildren<{ + initialOptions: GridStackOptions; +}>; + +export function GridStackProvider({ + children, + initialOptions, +}: GridStackProviderProps) { + const [gridStack, setGridStack] = useState(null); + + const addWidget = useCallback( + (widget: GridStackWidget) => { + gridStack?.addWidget(widget); + }, + [gridStack] + ); + + const removeWidget = useCallback( + (el: GridStackElement) => { + gridStack?.removeWidget(el); + }, + [gridStack] + ); + + const saveOptions = useCallback(() => { + return gridStack?.save(true, true, (_, widget) => widget); + }, [gridStack]); + + return ( + + {children} + + ); +} diff --git a/react/src/lib/grid-stack-render-context.ts b/react/src/lib/grid-stack-render-context.ts new file mode 100644 index 00000000..0bfeb5ac --- /dev/null +++ b/react/src/lib/grid-stack-render-context.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; + +export type GridStackRenderContextType = { + getContainerByWidgetId: (widgetId: string) => HTMLElement | null; +}; + +export const GridStackRenderContext = createContext( + { + getContainerByWidgetId: () => { + console.error("getContainerByWidgetId not implemented"); + return null; + }, + } +); + +export function useGridStackRenderContext() { + const context = useContext(GridStackRenderContext); + if (!context) { + throw new Error( + "useGridStackRenderContext must be used within a GridStackProvider" + ); + } + return context; +} diff --git a/react/src/lib/grid-stack-render.tsx b/react/src/lib/grid-stack-render.tsx new file mode 100644 index 00000000..83be4cfe --- /dev/null +++ b/react/src/lib/grid-stack-render.tsx @@ -0,0 +1,55 @@ +import { + ComponentProps, + PropsWithChildren, + useCallback, + useLayoutEffect, + useRef, +} from "react"; +import { useGridStackContext } from "./grid-stack-context"; +import { GridStack, GridStackOptions } from "gridstack"; +import { GridStackRenderContext } from "./grid-stack-render-context"; +import { widgetContainers } from "./global"; + +export type GridStackRenderProps = PropsWithChildren>; + +export function GridStackRender({ children, ...props }: GridStackRenderProps) { + const { + _gridStack: { value: gridStack, set: setGridStack }, + initialOptions, + } = useGridStackContext(); + + const containerRef = useRef(null); + const optionsRef = useRef(initialOptions); + + const initGrid = useCallback(() => { + if (containerRef.current) { + return GridStack.init(optionsRef.current, containerRef.current); + } + return null; + }, []); + + useLayoutEffect(() => { + if (!gridStack) { + try { + setGridStack(initGrid()); + } catch (e) { + console.error("Error initializing gridstack", e); + } + } + }, [gridStack, initGrid, setGridStack]); + + const getContainerByWidgetId = useCallback((widgetId: string) => { + return ( + widgetContainers.find((container) => container.initWidget.id === widgetId) + ?.element || null + ); + }, []); + + return ( + +
+ {gridStack ? children : null} +
+
+ ); +} diff --git a/react/src/lib/index.ts b/react/src/lib/index.ts new file mode 100644 index 00000000..c5b26975 --- /dev/null +++ b/react/src/lib/index.ts @@ -0,0 +1,32 @@ +export type { GridStackContainerProps } from "./grid-stack-container"; +export { GridStackContainer } from "./grid-stack-container"; + +export type { GridStackContextType } from "./grid-stack-context"; +export { GridStackContext, useGridStackContext } from "./grid-stack-context"; + +export type { GridStackProviderProps } from "./grid-stack-provider"; +export { GridStackProvider } from "./grid-stack-provider"; + +export type { GridStackRenderContextType } from "./grid-stack-render-context"; +export { + GridStackRenderContext, + useGridStackRenderContext, +} from "./grid-stack-render-context"; + +export type { GridStackRenderProps } from "./grid-stack-render"; +export { GridStackRender } from "./grid-stack-render"; + +export type { GridStackItemProps } from "./grid-stack-item"; +export { GridStackItem } from "./grid-stack-item"; + +export type { GridStackItemContextType } from "./grid-stack-item-context"; +export { + GridStackItemContext, + useGridStackItemContext, +} from "./grid-stack-item-context"; + +export type { GridStackHandleReInitializerProps } from "./grid-stack-handle-re-initializer"; +export { GridStackHandleReInitializer } from "./grid-stack-handle-re-initializer"; + +export type { GridStackDragInItemProps } from "./grid-stack-drag-in-item"; +export { GridStackDragInItem } from "./grid-stack-drag-in-item"; diff --git a/react/src/main.tsx b/react/src/main.tsx index 0bf19b8c..a874241f 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -1,15 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' +import "gridstack/dist/gridstack-extra.css"; +import "gridstack/dist/gridstack.css"; -import 'gridstack/dist/gridstack-extra.css'; -import 'gridstack/dist/gridstack.css'; +import App from "./App.tsx"; -import App from './App.tsx' - - -createRoot(document.getElementById('root')!).render( +createRoot(document.getElementById("root")!).render( - , -) + +); diff --git a/react/src/utils.ts b/react/src/utils.ts new file mode 100644 index 00000000..0342b9c5 --- /dev/null +++ b/react/src/utils.ts @@ -0,0 +1,3 @@ +export function newId() { + return `${Math.random().toString(36).substring(2, 15)}`; +} diff --git a/react/vite.config.ts b/react/vite.config.ts index 861b04b3..71158220 100644 --- a/react/vite.config.ts +++ b/react/vite.config.ts @@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react-swc' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + server: { + host: true + }, +});