): JSX.Element => {
- const {
- classes,
- demo,
- showControls,
- setShowControls,
- slots,
- splitEnabled,
- mergeEnabled,
- rowDeleteEnabled,
- columnDeleteEnabled,
- undoEnabled,
- redoEnabled,
- selectionOnlyModeEnabled,
- show,
- enableSelectionOnlyMode,
- horizontalSplitHandler,
- verticalSplitHandler,
- mergeHandler,
- rowInsertHandler,
- columnInsertHandler,
- rowDeleteHandler,
- columnDeleteHandler,
- undoHandler,
- redoHandler,
- } = props
- const { theme } = useTheme()
- const controls = [
- [
- showControls ? "md.MdEditOff" : "md.MdEdit",
- () => setShowControls(!showControls),
- "Edit Layout",
- "#ffa500",
- ],
- [
- "ri.RiSplitCellsVertical",
- horizontalSplitHandler,
- splitEnabled ? "Split Horizontal" : "Select a cell to split",
- theme === ArThemes.DARK1 ? "green" : "#17dc81",
- !splitEnabled,
- ],
- [
- "ri.RiSplitCellsHorizontal",
- verticalSplitHandler,
- splitEnabled ? "Split Vertical" : "Select a cell to split",
- theme === ArThemes.DARK1 ? "green" : "#17dc81",
- !splitEnabled,
- ],
- [
- "fa.FaCompressArrowsAlt",
- mergeHandler,
- mergeEnabled
- ? "Merge Selection"
- : "Selected at least two adjacent cells to merge",
- theme === ArThemes.DARK1 ? "green" : "#17dc81",
- !mergeEnabled,
- ],
- [
- "ri.RiInsertRowTop",
- () => rowInsertHandler("before"),
- "Insert Row Before",
- theme === ArThemes.DARK1 ? "#0299e7" : "#0299ff",
- ],
- [
- "ri.RiInsertRowBottom",
- () => rowInsertHandler("after"),
- "Insert Row After",
- theme === ArThemes.DARK1 ? "#0299e7" : "#0299ff",
- ],
- [
- "ri.RiInsertColumnLeft",
- () => columnInsertHandler("before"),
- "Insert Column Before",
- theme === ArThemes.DARK1 ? "#0299e7" : "#0299ff",
- ],
- [
- "ri.RiInsertColumnRight",
- () => columnInsertHandler("after"),
- "Insert Column After",
- theme === ArThemes.DARK1 ? "#0299e7" : "#0299ff",
- ],
- [
- "ri.RiDeleteRow",
- rowDeleteHandler,
- rowDeleteEnabled ? "Delete Row" : "Select a row to delete",
- "#ff6666",
- !rowDeleteEnabled,
- ],
- [
- "ri.RiDeleteColumn",
- columnDeleteHandler,
- columnDeleteEnabled ? "Delete Column" : "Select a column to delete",
- "#ff6666",
- !columnDeleteEnabled,
- ],
- [
- "fc.FcUndo",
- undoHandler,
- undoEnabled ? "Undo Last" : "Nothing to undo",
- "",
- !undoEnabled,
- ],
- [
- "fc.FcRedo",
- redoHandler,
- redoEnabled ? "Redo Last Undone" : "Nothing to redo",
- "",
- !redoEnabled,
- ],
- [
- "gr.GrSelect",
- () => enableSelectionOnlyMode(!selectionOnlyModeEnabled),
- !selectionOnlyModeEnabled
- ? "Enable Selection Only Mode"
- : "Disable Selection Only Mode",
- selectionOnlyModeEnabled ? "" : "#17dc81",
- false,
- selectionOnlyModeEnabled ? "#17dc81" : "",
- ],
- ]
- return (
-
- {getControl(
- theme,
- controls[0][0] as string,
- controls[0][1] as FunctionType,
- controls[0][2] as string,
- demo,
- controls[0][3] as string,
- )}
- {showControls && (
- <>
- {slots && (
- <>
-
- {controls
- .slice(1, 4)
- .map((control) =>
- getControl(
- theme,
- control[0] as string,
- control[1] as FunctionType,
- control[2] as string,
- demo,
- control[3] as string,
- control[4] as boolean,
- control[5] as string,
- showControls,
- ),
- )}
- >
- )}
-
- {controls
- .slice(4, 10)
- .map((control) =>
- getControl(
- theme,
- control[0] as string,
- control[1] as FunctionType,
- control[2] as string,
- demo,
- control[3] as string,
- control[4] as boolean,
- control[5] as string,
- showControls,
- ),
- )}
-
- {controls
- .slice(10, 12)
- .map((control) =>
- getControl(
- theme,
- control[0] as string,
- control[1] as FunctionType,
- control[2] as string,
- demo,
- control[3] as string,
- control[4] as boolean,
- control[5] as string,
- showControls,
- ),
- )}
-
- {controls
- .slice(12)
- .map((control) =>
- getControl(
- theme,
- control[0] as string,
- control[1] as FunctionType,
- control[2] as string,
- demo,
- control[3] as string,
- control[4] as boolean,
- control[5] as string,
- showControls,
- ),
- )}
- >
- )}
-
- )
- },
-)
+const LayoutControlPanel = forwardRef((props, ref) => {
+ const { classes, rowDeleteEnabled, columnDeleteEnabled, show } = props
+ const { theme } = useTheme()
+ const { eventsService, historyService, layoutService } = useLayoutContext()
+ const { selectionOnlyModeEnabled, enableSelectionOnlyMode } = useSelectionOnlyMode()
+ const { controlsEnabled, enableControls } = useShowControls()
+
+ const splitEnabled = layoutService.splitEnabled
+ const mergeEnabled = layoutService.mergeEnabled
+
+ const actions: any[] = [
+ [
+ controlsEnabled ? "md.MdEditOff" : "md.MdEdit",
+ () => enableControls(!controlsEnabled),
+ "Edit Layout",
+ "#ffa500",
+ ],
+ [
+ "ri.RiSplitCellsVertical",
+ eventsService.splitHorizontalAfter,
+ splitEnabled ? "Split Horizontal" : "Select a cell to split",
+ theme === ArThemes.DARK1 ? "green" : "#17dc81",
+ !splitEnabled,
+ ],
+ [
+ "ri.RiSplitCellsHorizontal",
+ eventsService.splitVerticalAfter,
+ splitEnabled ? "Split Vertical" : "Select a cell to split",
+ theme === ArThemes.DARK1 ? "green" : "#17dc81",
+ !splitEnabled,
+ ],
+ [
+ "fa.FaCompressArrowsAlt",
+ () => {
+ eventsService.mergeCells()
+ layoutService.mergeEnabled = false
+ },
+ mergeEnabled ? "Merge Selection" : "Selected at least two adjacent cells to merge",
+ theme === ArThemes.DARK1 ? "green" : "#17dc81",
+ !mergeEnabled,
+ ],
+ ["ri.RiInsertRowTop", eventsService.addRowBefore, "Insert Row Before", theme === ArThemes.DARK1 ? "#0299e7" : "#0299ff"],
+ ["ri.RiInsertRowBottom", eventsService.addRowAfter, "Insert Row After", theme === ArThemes.DARK1 ? "#0299e7" : "#0299ff"],
+ ["ri.RiInsertColumnLeft", eventsService.addColumnBefore, "Insert Column Before", theme === ArThemes.DARK1 ? "#0299e7" : "#0299ff"],
+ ["ri.RiInsertColumnRight", eventsService.addColumnAfter, "Insert Column After", theme === ArThemes.DARK1 ? "#0299e7" : "#0299ff"],
+ ["ri.RiDeleteRow", eventsService.deleteRow, rowDeleteEnabled ? "Delete Row" : "Select a row to delete", "#ff6666", !rowDeleteEnabled],
+ [
+ "ri.RiDeleteColumn",
+ eventsService.deleteColumn,
+ columnDeleteEnabled ? "Delete Column" : "Select a column to delete",
+ "#ff6666",
+ !columnDeleteEnabled,
+ ],
+ ["fc.FcUndo", eventsService.undo, historyService.canUndo ? "Undo Last" : "Nothing to undo", "", !historyService.canUndo],
+ ["fc.FcRedo", eventsService.redo, historyService.canRedo ? "Redo Last Undone" : "Nothing to redo", "", !historyService.canRedo],
+ [
+ "gr.GrSelect",
+ () => enableSelectionOnlyMode(!selectionOnlyModeEnabled),
+ selectionOnlyModeEnabled ? "Disable Selection Only Mode" : "Enable Selection Only Mode",
+ selectionOnlyModeEnabled ? "" : "#17dc81",
+ false,
+ selectionOnlyModeEnabled ? "#17dc81" : "",
+ true,
+ ],
+ ]
+
+ return (
+
+ {actionButton(theme, actions[0][0], actions[0][1], actions[0][2], actions[0][3])}
+ {layoutService.slots && (
+ <>
+
+ {actions.slice(1, 4).map((e, idx) =>
+ actionButton(theme, e[0], e[1], e[2], e[3], e[4], e[5])
+ )}
+ >
+ )}
+
+ {actions.slice(4, 10).map((e, idx) => actionButton(theme, e[0], e[1], e[2], e[3], e[4], e[5]))}
+
+ {actions.slice(10, 12).map((e, idx) => actionButton(theme, e[0], e[1], e[2], e[3], e[4], e[5]))}
+
+ {actions.slice(12).map((e, idx) => actionButton(theme, e[0], e[1], e[2], e[3], e[4], e[5], e[6]))}
+
+ )
+})
export default LayoutControlPanel
diff --git a/src/LayoutError.tsx b/src/LayoutError.tsx
new file mode 100644
index 0000000..0ce2d48
--- /dev/null
+++ b/src/LayoutError.tsx
@@ -0,0 +1,21 @@
+// LayoutError aligned with built artifacts (non-Error class, simple payload fields)
+export enum LayoutErrors {
+ MISSING_SLOTS = "MISSING_SLOTS",
+ MALFORMED_GRID_SPECS = "MALFORMED_GRID_SPECS",
+ MISSING_LAYOUT_CONTEXT = "MISSING_LAYOUT_CONTEXT",
+ MISSING_SLOT_DESCRIPTOR = "MISSING_SLOT_DESCRIPTOR",
+ MISSING_SLOT_DESCRIPTOR_OBJ = "MISSING_SLOT_DESCRIPTOR_OBJ",
+ MISSING_SLOT_ID = "MISSING_SLOT_ID",
+}
+
+export default class LayoutError {
+ error: string
+ code?: LayoutErrors
+ description?: string
+
+ constructor(error: string, code?: LayoutErrors, description?: string) {
+ this.error = error
+ this.code = code
+ this.description = description
+ }
+}
diff --git a/src/LayoutHelp.tsx b/src/LayoutHelp.tsx
new file mode 100644
index 0000000..57cf97e
--- /dev/null
+++ b/src/LayoutHelp.tsx
@@ -0,0 +1,98 @@
+import { forwardRef, useMemo } from "react"
+import { ArThemes } from "@armco/utils"
+import { useTheme } from "@armco/utils/hooks"
+import { ArPopoverSlots } from "@armco/shared-components/enums"
+import { Tooltip } from "@armco/shared-components"
+import Icon from "@armco/icon"
+
+type LayoutHelpProps = {
+ panelsDisplayed?: boolean
+}
+
+const keyBindings = [
+ { key: "h", action: "Split Horizontally" },
+ { key: "v", action: "Split Vertically" },
+ { key: "m", action: "Merge Rectangle" },
+ { key: "r", action: "Insert Row" },
+ { key: "c", action: "Insert Column" },
+ { key: "d", action: "Delete" },
+ { key: "z", action: "Undo" },
+ { key: "y", action: "Redo" },
+]
+
+const helpPoints = [
+ "Hover on grid to reveal control panel.",
+ "Click a cell to select it.",
+ "You may select multiple cells by clicking on them.",
+ "Click a row header to select all cells in that row, and column header to select all cells in that column",
+ "You may hover on any selected cells on specific sections within cell to split vertical or horizontal.",
+ "For Horizontal: Hover on top center or bottom center region and click",
+ "For Vertical: Hover on left center or right center region and click",
+ "You may merge cells too, at least 2 cells should be selected to merge and the selected cells should form a rectangle.",
+ "To delete a row or column, hover on its corresponding row or column header to reveal delete button and click on it.",
+ "Undo/Redo layout changes is supported. Use Undo/Redo buttons in the control panel on top of grid, or use Ctrl/Cmd + z to undo, Ctrl/Cmd + y to redo",
+ "Edit Mode should be enabled to use any shortcuts",
+]
+
+const LayoutHelp = forwardRef(
+ ({ panelsDisplayed }, ref) => {
+ const { theme } = useTheme()
+ const cmdIcon = useMemo(
+ () => (
+ <>
+ {" "}
+
+ >
+ ),
+ [],
+ )
+ return (
+
+
+ Undo: Ctrl/Cmd + z, Redo: Ctrl/Cmd + y
+
+
+
+
+
+ {keyBindings.map((kb) => (
+
+
+ {cmdIcon} + {kb.key}
+
+
{kb.action}
+
+ ))}
+
+
+
+
+
+ {helpPoints.map((h, idx) => (
+ {h}
+ ))}
+
+
+
+
+ )
+ },
+)
+
+export default LayoutHelp
diff --git a/src/README.md b/src/README.md
new file mode 100644
index 0000000..6d42076
--- /dev/null
+++ b/src/README.md
@@ -0,0 +1,8 @@
+Recovered skeletons for Layout package
+
+This directory mirrors the structure of build/es to guide reconstruction of lost sources.
+
+Conventions:
+- .ts for services and utilities
+- .tsx for React components
+- Each file contains a TODO header describing intended module purpose inferred from build outputs.
diff --git a/src/ReleaseLayout.tsx b/src/ReleaseLayout.tsx
old mode 100755
new mode 100644
index 5961e71..2b8d3d5
--- a/src/ReleaseLayout.tsx
+++ b/src/ReleaseLayout.tsx
@@ -1,29 +1,38 @@
-import { ArSlotViewMode, ReleaseLayoutProps, SlotProps } from "@armco/types"
+import React from "react"
+import { ArSlotViewMode } from "@armco/shared-components/enums"
+import type { LayoutProps } from "./types"
import Slot from "./Slot"
+import { SlotDescriptor } from "@models"
-const ReleaseLayout = (props: ReleaseLayoutProps): JSX.Element => {
- const { classes, displayMode, gridTemplate, slots, slotRenderer } = props
- return (
-
- {slots?.map((sConfig) => {
- const props: SlotProps = {
- config: sConfig,
- containerDisplayMode: displayMode,
- mode: ArSlotViewMode.RELEASE,
- }
- return slotRenderer ? (
- slotRenderer(props)
- ) : (
-
- )
- })}
-
- )
+export interface ReleaseLayoutProps extends Pick { }
+
+// Release layout simply renders provided slots with a grid template (or displayMode variant) without builder controls.
+const ReleaseLayout: React.FC = ({
+ classes,
+ displayMode,
+ gridTemplate,
+ slots,
+ slotRenderer,
+}) => {
+ return (
+
+ {slots?.map((slotDesc: SlotDescriptor) => {
+ const slotProps = {
+ slotDescriptor: slotDesc,
+ containerDisplayMode: displayMode,
+ mode: ArSlotViewMode.RELEASE as const,
+ }
+ return slotRenderer ? (
+ slotRenderer(slotProps)
+ ) : (
+
+ )
+ })}
+
+ )
}
export default ReleaseLayout
diff --git a/src/ReleaseSlot.tsx b/src/ReleaseSlot.tsx
old mode 100755
new mode 100644
index e3995f2..87beeef
--- a/src/ReleaseSlot.tsx
+++ b/src/ReleaseSlot.tsx
@@ -1,24 +1,23 @@
-import { ReleaseSlotProps } from "@armco/types"
+import type { ReleaseSlotProps } from "./types"
+import React from "react"
-const ReleaseSlot = (props: ReleaseSlotProps): JSX.Element => {
- const { config, content } = props
- const { slot, props: slotProps } = config
- const {
- classes: slotClasses,
- style: slotStyles,
- ...slotRest
- } = slotProps || {}
-
- return (
-
- {content}
-
- )
+const ReleaseSlot: React.FC = (props) => {
+ const { slotDescriptor, content } = props
+ const { props: slotProps } = slotDescriptor as any
+ const { classes, style, ...rest } = (slotProps || {}) as {
+ classes?: string
+ style?: React.CSSProperties
+ [key: string]: any
+ }
+ return (
+
+ {content}
+
+ )
}
export default ReleaseSlot
diff --git a/src/Resizable.component.scss b/src/Resizable.component.scss
old mode 100755
new mode 100644
index 2287e6c..28cb2d4
--- a/src/Resizable.component.scss
+++ b/src/Resizable.component.scss
@@ -1,54 +1,60 @@
-.ar-Resizable {
- .handle {
- &.n, &.s {
- left: 0;
- width: 100%;
- height: 1rem;
- cursor: ns-resize;
- }
- &.e, &.w {
- top: 0;
- height: 100%;
- width: 1rem;
- cursor: ew-resize;
- }
- &.ne, &.nw, &.se, &.sw {
- width: 1rem;
- height: 1rem;
- cursor: nwse-resize;
- }
- &.n {
- bottom: calc(100% - 0.5rem);
- }
- &.s {
- top: calc(100% - 0.5rem);
- }
- &.e {
- left: calc(100% - 0.5rem);
- }
- &.w {
- right: calc(100% - 0.5rem);
- }
- &.ne {
- left: calc(100% - 0.5rem);
- bottom: calc(100% - 0.5rem);
- }
- &.nw {
- right: calc(100% - 0.5rem);
- bottom: calc(100% - 0.5rem);
- }
- &.se {
- left: calc(100% - 0.5rem);
- top: calc(100% - 0.5rem);
- }
- &.sw {
- right: calc(100% - 0.5rem);
- top: calc(100% - 0.5rem);
- }
- &.n, &.s, &.e, &.w, &.ne, &.nw, &.se, &.sw {
- &:hover {
- background-color: var(--ar-bg-hover-4);
- }
- }
- }
+.ar-Resizable .handle.n,
+.ar-Resizable .handle.s {
+ left: 0;
+ width: 100%;
+ height: 1rem;
+ cursor: ns-resize;
+}
+.ar-Resizable .handle.e,
+.ar-Resizable .handle.w {
+ top: 0;
+ height: 100%;
+ width: 1rem;
+ cursor: ew-resize;
+}
+.ar-Resizable .handle.ne,
+.ar-Resizable .handle.nw,
+.ar-Resizable .handle.se,
+.ar-Resizable .handle.sw {
+ width: 1rem;
+ height: 1rem;
+ cursor: nwse-resize;
+}
+.ar-Resizable .handle.n {
+ bottom: calc(100% - 0.5rem);
+}
+.ar-Resizable .handle.s {
+ top: calc(100% - 0.5rem);
+}
+.ar-Resizable .handle.e {
+ left: calc(100% - 0.5rem);
+}
+.ar-Resizable .handle.w {
+ right: calc(100% - 0.5rem);
+}
+.ar-Resizable .handle.ne {
+ left: calc(100% - 0.5rem);
+ bottom: calc(100% - 0.5rem);
+}
+.ar-Resizable .handle.nw {
+ right: calc(100% - 0.5rem);
+ bottom: calc(100% - 0.5rem);
+}
+.ar-Resizable .handle.se {
+ left: calc(100% - 0.5rem);
+ top: calc(100% - 0.5rem);
+}
+.ar-Resizable .handle.sw {
+ right: calc(100% - 0.5rem);
+ top: calc(100% - 0.5rem);
+}
+.ar-Resizable .handle.n:hover,
+.ar-Resizable .handle.s:hover,
+.ar-Resizable .handle.e:hover,
+.ar-Resizable .handle.w:hover,
+.ar-Resizable .handle.ne:hover,
+.ar-Resizable .handle.nw:hover,
+.ar-Resizable .handle.se:hover,
+.ar-Resizable .handle.sw:hover {
+ background-color: var(--ar-bg-hover-4);
}
diff --git a/src/Resizable.tsx b/src/Resizable.tsx
old mode 100755
new mode 100644
index 54f9f3d..2a7e47b
--- a/src/Resizable.tsx
+++ b/src/Resizable.tsx
@@ -1,112 +1,66 @@
-import {
- ReactElement,
- Suspense,
- cloneElement,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from "react"
-import { ArDirections, ResizableProps } from "@armco/types"
+import React, { Suspense, cloneElement, useCallback, useEffect, useMemo, useRef, useState } from "react"
import Slot from "./Slot"
+import type { ResizableProps } from "./types"
import "./Resizable.component.scss"
-const components: {
- [key: string]: (props: any) => JSX.Element
-} = {
- Slot,
- // Add more components as needed
-}
+const Resizable: React.FC = (props) => {
+ const { reClasses, directions, display, componentName, children, style, ...rest } = props
+ const [dragging, setDragging] = useState(false)
+ const lastYRef = useRef(0)
+ useRef(0) // mimic extra ref from build (unused)
+ const childRef = useRef(null)
-const Resizable = (props: ResizableProps): JSX.Element => {
- const {
- reClasses,
- directions,
- display,
- componentName,
- children,
- style,
- ...rest
- } = props
- const [resizing, setResizing] = useState(false)
- const mouseDownY = useRef(0)
- const mouseDownX = useRef(0)
- const childRef = useRef(null)
+ const registry = useMemo(() => ({
+ Slot,
+ // Add more components as needed
+ }), [])
- const mouseMoveHandler = useCallback(
- (e: MouseEvent) => {
- if (resizing && childRef.current) {
- const childRect = childRef.current.getBoundingClientRect()
- // const initialPosition = childRect.left + childRect.width
- // const dragOffset = e.clientX - initialPosition
+ const onMouseMove = useCallback((e: MouseEvent) => {
+ if (dragging && childRef.current) {
+ const rect = childRef.current.getBoundingClientRect()
+ const _ratio = (e.clientX - rect.left) / rect.width
+ // Placeholder for resize effect, original build does not apply it yet
+ }
+ }, [dragging])
- // const newWidth = childRect.width + dragOffset
- const newWidth = e.clientX - childRect.left
- const newFrValue = newWidth / childRect.width
+ const onMouseUp = useCallback(() => setDragging(false), [])
- // setFrValue(newFrValue)
- }
- },
- [resizing, childRef],
- )
- const mouseUpHandler = useCallback(() => setResizing(false), [])
- const mouseDownHandler = useCallback(
- (
- e: React.MouseEvent,
- direction: ArDirections,
- ) => {
- setResizing(true)
- mouseDownY.current = e.clientY
- // TODO: handle direction
- },
- [],
- )
+ const onMouseDown = useCallback((e: React.MouseEvent, _dir: string) => {
+ setDragging(true)
+ lastYRef.current = e.clientY
+ }, [])
- useEffect(() => {
- document.addEventListener("mousemove", mouseMoveHandler)
- document.addEventListener("mouseup", mouseUpHandler)
- return () => {
- document.removeEventListener("mousemove", mouseMoveHandler)
- document.removeEventListener("mouseup", mouseUpHandler)
- }
- }, [mouseMoveHandler, mouseUpHandler])
+ useEffect(() => {
+ document.addEventListener("mousemove", onMouseMove)
+ document.addEventListener("mouseup", onMouseUp)
+ return () => {
+ document.removeEventListener("mousemove", onMouseMove)
+ document.removeEventListener("mouseup", onMouseUp)
+ }
+ }, [onMouseMove, onMouseUp])
- useEffect(() => {
- if (childRef.current) {
- const rect = childRef.current.getBoundingClientRect()
- }
- }, [])
+ useEffect(() => {
+ if (childRef.current) {
+ childRef.current.getBoundingClientRect()
+ }
+ }, [])
- const Component = (componentName && components[componentName]) as
- | React.ComponentType
- | undefined
+ const Dynamic = componentName && (registry as any)[componentName]
- return (
-
- {directions?.map((direction) => (
-
mouseDownHandler(e, direction)}
- />
- ))}
- {Component ? (
- Loading...
}>
-
-
- ) : (
- children &&
- cloneElement(children as ReactElement, {
- childRef: childRef,
- })
- )}
-
- )
+ return (
+
+ {directions?.map((dir) => (
+
onMouseDown(e, dir as any)} />
+ ))}
+ {Dynamic ? (
+ Loading...
}>
+
+
+ ) : (
+ children && cloneElement(children as any, { childRef })
+ )}
+
+ )
}
export default Resizable
diff --git a/src/Slot.component.scss b/src/Slot.component.scss
new file mode 100644
index 0000000..6581b0b
--- /dev/null
+++ b/src/Slot.component.scss
@@ -0,0 +1,59 @@
+.ar-Droppable:not(.hide-hover-effect) {
+ &:hover {
+ border: 1px dashed #1677ff !important;
+ }
+ &.drag-over {
+ border: 2px dashed #1677ff !important;
+ }
+}
+.ar-Slot {
+ .build {
+ &:hover {
+ border: 1px dashed #7d31f0 !important;
+ .ar-SlotTools {
+ display: flex;
+ }
+ }
+ &.selected {
+ border: 1px solid #86cff1 !important;
+ }
+ &.active {
+ outline: 4px solid #2e9cfd !important;
+ }
+ &.selected-for-delete {
+ border: 1px solid red !important;
+ }
+ .ar-Slot__overlay-btn {
+ width: 33.3333333333%;
+ &.slot:hover {
+ background-color: #55bdeda9;
+ }
+ }
+ }
+ .ar-SlotTools {
+ display: none;
+ }
+ .row-controller .ar-Slot__overlay-btn {
+ height: calc((100% - 16px) / 3);
+ &.delete {
+ height: 1rem;
+ }
+ }
+ .col-controller .ar-Slot__overlay-btn {
+ width: calc((100% - 16px) / 3);
+ &.delete {
+ width: 1rem;
+ }
+ }
+ .row-controller,
+ .col-controller {
+ .ar-Slot__overlay-btn {
+ &.slot:hover {
+ background-color: #55bdeda9;
+ }
+ &.delete {
+ background-color: red;
+ }
+ }
+ }
+}
diff --git a/src/Slot.tsx b/src/Slot.tsx
old mode 100755
new mode 100644
index c4475a7..3ef393b
--- a/src/Slot.tsx
+++ b/src/Slot.tsx
@@ -1,98 +1,100 @@
-import { ReactNode, useEffect, useState } from "react"
-import {
- ArComponentResources,
- ArSlotViewMode,
- SlotDescriptor,
- SlotProps,
-} from "@armco/types"
-import {
- generateGridAreaAndSizes,
- generateGridTemplate,
-} from "@armco/utils/gridHelper"
-import { lazyImport } from "@armco/utils/helper"
-import Component_404 from "@armco/shared-components/Component_404"
-import BuilderSlot from "./BuilderSlot"
+import React, { useEffect, useState } from "react"
+import { ArSlotViewMode } from "@armco/shared-components/enums"
+import * as Components from "@armco/shared-components"
import Layout from "./Layout"
+import BuilderSlot from "./BuilderSlot"
import ReleaseSlot from "./ReleaseSlot"
+import SlotDescriptor from "./models/SlotDescriptor"
+import { useLayoutService } from "./Layout.context"
+import type { ISlotDescriptor, SlotProps } from "./types"
+import "./Slot.component.scss"
-const Slot = (props: SlotProps): JSX.Element => {
- const { mode = ArSlotViewMode.PREVIEW, layoutMode = "preview" } = props
- const [content, setContent] = useState()
- const config = props.config
+const Slot: React.FC = (props) => {
+ const { layoutMode = "preview", slotRenderer } = props
+ const [content, setContent] = useState()
+ const slotDescriptor = props.slotDescriptor
+ const layoutService = useLayoutService()
+ const mode = layoutService.mode as any
- useEffect(() => {
- const content = config.content as {
- componentName?: string
- source?: ArComponentResources
- descriptor?: { content: Array }
- props?: { [key: string]: any }
- }
- if (content) {
- if (content.componentName) {
- // const source = content.source || ArComponentResources.STUFFLE
- // TODO: In Stuffle use component's source property to specify sources like @armco/icon, @armco/Calendar etc. and use them here
- const Component = lazyImport(content.componentName, () => (
-
- ))
- // repo[content.componentName]
- const contentProps = content.props || {}
- if (content.componentName === "Layout") {
- contentProps.displayMode = config.props?.containerDisplayMode
- }
- setContent(
- ,
- )
- } else if (
- content.descriptor?.content &&
- Array.isArray(content.descriptor.content)
- ) {
- const gaResponse =
- content?.descriptor?.content &&
- generateGridAreaAndSizes(content?.descriptor?.content, "auto")
+ // Local registry for known local components usable by descriptor.componentName
+ const localRegistry: Record> = {
+ Layout,
+ }
- const layout =
- gaResponse && generateGridTemplate(gaResponse.gridArea, "auto")
+ const renderInlineSlot = (desc?: ISlotDescriptor) => {
+ if (!(desc && (desc as any).slot)) {
+ console.warn(
+ "Missing slot ID on trying to create inline slot, Slot descriptor will not be processed:",
+ desc,
+ )
+ return undefined
+ }
+ const nextProps =
+ mode === ArSlotViewMode.BUILD
+ ? { ...props, slotDescriptor: desc }
+ : { slotDescriptor: desc, containerDisplayMode: props.containerDisplayMode }
+ return slotRenderer ? slotRenderer(nextProps) : (
+
+ )
+ }
- setContent(
- ,
- )
- }
- } else {
- setContent(null)
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [
- config.content,
- layoutMode,
- mode,
- props.lastSelected,
- props.hideBuildModePaddings,
- ])
+ const resolveComponent = (data: any): React.ComponentType | null => {
+ if (data && data.componentName) {
+ let C: any = (Components as any)[data.componentName]
+ if (!C) C = localRegistry[data.componentName]
+ return C || null
+ }
+ return null
+ }
- return mode !== ArSlotViewMode.PREVIEW && mode !== ArSlotViewMode.RELEASE ? (
-
- ) : (
-
- )
+ const renderDescriptor = (data: any) => {
+ const C = resolveComponent(data)
+ if (C) {
+ const passedProps: any = data.props || {}
+ if (data.componentName === "Layout") {
+ passedProps.displayMode = (slotDescriptor as any).containerDisplayMode
+ }
+ return (
+
+ )
+ } else if (data?.descriptor?.content && Array.isArray(data.descriptor.content)) {
+ return (
+
+ )
+ }
+ return undefined
+ }
+
+ useEffect(() => {
+ const c = (slotDescriptor as any).content
+ if (!c) return
+ if (Array.isArray(c)) {
+ const nodes = c.map((i: any) => {
+ i = SlotDescriptor.create(i)
+ return renderInlineSlot(i)
+ })
+ setContent(c.length > 0 ? nodes : [])
+ } else {
+ const node = renderDescriptor(c)
+ setContent(node)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [slotDescriptor.content, layoutMode, layoutService.mode, layoutService.activeSlot, props.hideBuildModePaddings])
+
+ // Choose builder or release slot based on service mode
+ return mode !== ArSlotViewMode.PREVIEW && mode !== ArSlotViewMode.RELEASE ? (
+
+ ) : (
+
+ )
}
export default Slot
diff --git a/src/SlotTools.tsx b/src/SlotTools.tsx
old mode 100755
new mode 100644
index 7831e9e..9d94cc6
--- a/src/SlotTools.tsx
+++ b/src/SlotTools.tsx
@@ -1,171 +1,103 @@
-import { useEffect, useState } from "react"
-import {
- ArPopoverSlots,
- ArSlotViewMode,
- SlotControlProps,
- SlotToolsProps,
-} from "@armco/types"
-import Tooltip from "@armco/shared-components/Tooltip"
+import { FC, useEffect, useState } from "react"
+import { ArSlotViewMode, ArPopoverSlots } from "@armco/shared-components/enums"
+import { Tooltip } from "@armco/shared-components"
+import { useLayoutContext } from "./Layout.context"
+import type { SlotToolsProps } from "./types"
-const staticSlotTools: Array = [
- { class: "ar-Slot__overlay-btn" },
- {
- class: "ar-Slot__overlay-btn slot split-up d-flex cursor-pointer",
- tooltip: "Insert Cell Above",
- },
- { class: "ar-Slot__overlay-btn" },
- { class: "ar-Slot__overlay-btn cursor-pointer delete", isToolSlotOnly: true },
- {
- class: "ar-Slot__overlay-btn slot split-left d-flex cursor-pointer",
- tooltip: "Insert Cell Before",
- isGridOnly: true,
- },
- {
- class: "ar-Slot__overlay-btn slot merge d-flex cursor-pointer",
- tooltip: "Merge at this cell",
- isGridOnly: true,
- },
- {
- class: "ar-Slot__overlay-btn slot split-right d-flex cursor-pointer",
- tooltip: "Insert Cell After",
- isGridOnly: true,
- },
- { class: "ar-Slot__overlay-btn", isGridOnly: true },
- {
- class: "ar-Slot__overlay-btn slot split-down d-flex cursor-pointer",
- tooltip: "Insert Cell Below",
- isGridOnly: true,
- },
- { class: "ar-Slot__overlay-btn", isGridOnly: true },
+const DEFAULT_CONTROLS = [
+ { class: "ar-Slot__overlay-btn" },
+ { class: "ar-Slot__overlay-btn slot split-up d-flex cursor-pointer", tooltip: "Insert Cell Above" },
+ { class: "ar-Slot__overlay-btn" },
+ { class: "ar-Slot__overlay-btn cursor-pointer delete", isToolSlotOnly: true },
+ { class: "ar-Slot__overlay-btn slot split-left d-flex cursor-pointer", tooltip: "Insert Cell Before", isGridOnly: true },
+ { class: "ar-Slot__overlay-btn slot merge d-flex cursor-pointer", tooltip: "Merge at this cell", isGridOnly: true },
+ { class: "ar-Slot__overlay-btn slot split-right d-flex cursor-pointer", tooltip: "Insert Cell After", isGridOnly: true },
+ { class: "ar-Slot__overlay-btn", isGridOnly: true },
+ { class: "ar-Slot__overlay-btn slot split-down d-flex cursor-pointer", tooltip: "Insert Cell Below", isGridOnly: true },
+ { class: "ar-Slot__overlay-btn", isGridOnly: true },
]
-const SlotTools = (props: SlotToolsProps): JSX.Element => {
- const {
- config,
- controlsEnabled,
- demo,
- mode = ArSlotViewMode.PREVIEW,
- horizontalSplitHandler,
- mergeHandler,
- rowInsertHandler,
- columnInsertHandler,
- rowDeleteHandler,
- columnDeleteHandler,
- verticalSplitHandler,
- rowSelectHandler,
- columnSelectHandler,
- } = props
- const [slotTools, setSlotTools] =
- useState>(staticSlotTools)
+const SlotTools: FC = (props) => {
+ const { slotDescriptor, controlsEnabled, demo, mode = ArSlotViewMode.PREVIEW } = props
+ const { eventsService } = useLayoutContext()
+ const [controls, setControls] = useState(DEFAULT_CONTROLS)
- useEffect(() => {
- const slotToolsClone: Array = JSON.parse(
- JSON.stringify(slotTools),
- )
- if (
- mode === ArSlotViewMode.ROWCONTROLLER ||
- mode === ArSlotViewMode.COLCONTROLLER
- ) {
- const isRowController = mode === ArSlotViewMode.ROWCONTROLLER
- const handler = isRowController ? rowInsertHandler : columnInsertHandler
- slotToolsClone[0].onClick = () =>
- controlsEnabled && handler && handler("before")
- slotToolsClone[0].tooltip = isRowController
- ? "Insert Row Above"
- : "Insert Column Before"
- slotToolsClone[0].class.indexOf("slot") === -1 &&
- (slotToolsClone[0].class += " slot cursor-pointer")
- slotToolsClone[1].tooltip = ""
- slotToolsClone[2].onClick = () =>
- controlsEnabled && handler && handler("after")
- slotToolsClone[2].tooltip = isRowController
- ? "Insert Row Below"
- : "Insert Column After"
- slotToolsClone[2].class.indexOf("slot") === -1 &&
- (slotToolsClone[2].class += " slot cursor-pointer")
- slotToolsClone[3].tooltip = isRowController
- ? "Delete Row"
- : "Delete Column"
- slotToolsClone[3].onClick = isRowController
- ? rowDeleteHandler
- : columnDeleteHandler
- slotToolsClone[3].onMouseEnter = isRowController
- ? () => rowSelectHandler && rowSelectHandler(true)
- : () => columnSelectHandler && columnSelectHandler(true)
- slotToolsClone[3].onMouseLeave = isRowController
- ? () => rowSelectHandler && rowSelectHandler(false)
- : () => columnSelectHandler && columnSelectHandler(false)
- } else {
- slotToolsClone[1].onClick = () =>
- controlsEnabled &&
- horizontalSplitHandler &&
- horizontalSplitHandler("before")
- slotToolsClone[4].onClick = () =>
- controlsEnabled &&
- verticalSplitHandler &&
- verticalSplitHandler("before")
- slotToolsClone[5].onClick = () =>
- controlsEnabled && mergeHandler && mergeHandler()
- slotToolsClone[6].onClick = () =>
- controlsEnabled && verticalSplitHandler && verticalSplitHandler("after")
- slotToolsClone[8].onClick = () =>
- controlsEnabled &&
- horizontalSplitHandler &&
- horizontalSplitHandler("after")
- }
- setSlotTools([...slotToolsClone])
- }, [
- controlsEnabled,
- config,
- horizontalSplitHandler,
- verticalSplitHandler,
- mergeHandler,
- ])
+ const isRowController = mode === ArSlotViewMode.ROWCONTROLLER
+ const deleteHandler = eventsService[isRowController ? "deleteRow" : "deleteColumn"].bind(eventsService)
+ const addHandler = eventsService[isRowController ? "addRow" : "addColumn"].bind(eventsService)
+ const selectHandler = eventsService[isRowController ? "selectRow" : "selectColumn"].bind(eventsService)
- return (
-
- {slotTools
- .map(
- (slotTool, index) =>
- (!slotTool.isGridOnly || mode === ArSlotViewMode.BUILD) &&
- (!slotTool.isToolSlotOnly ||
- [
- ArSlotViewMode.ROWCONTROLLER,
- ArSlotViewMode.COLCONTROLLER,
- ].indexOf(mode) > -1) && (
- {
- slotTool.tooltip && e.stopPropagation()
- slotTool.onClick && slotTool.onClick()
- }}
- onMouseEnter={slotTool.onMouseEnter}
- onMouseLeave={slotTool.onMouseLeave}
- >
- {slotTool.tooltip ? (
-
-
-
- {slotTool.tooltip}
-
-
- ) : (
-
- )}
-
- ),
- )
- .filter((s) => s)}
-
- )
+ useEffect(() => {
+ // deep clone
+ const o = JSON.parse(JSON.stringify(controls))
+ if (mode === ArSlotViewMode.ROWCONTROLLER || mode === ArSlotViewMode.COLCONTROLLER) {
+ o[0].onClick = () => controlsEnabled && addHandler("before", undefined as any)
+ o[0].tooltip = isRowController ? "Insert Row Above" : "Insert Column Before"
+ if (o[0].class.indexOf("slot") === -1) o[0].class += " slot cursor-pointer"
+ o[1].tooltip = ""
+ o[2].onClick = () => controlsEnabled && addHandler("after", undefined as any)
+ o[2].tooltip = isRowController ? "Insert Row Below" : "Insert Column After"
+ if (o[2].class.indexOf("slot") === -1) o[2].class += " slot cursor-pointer"
+ o[3].tooltip = isRowController ? "Delete Row" : "Delete Column"
+ o[3].onClick = () => deleteHandler(true)
+ o[3].onMouseEnter = () => selectHandler(slotDescriptor, true)
+ o[3].onMouseLeave = () => selectHandler(slotDescriptor, false)
+ } else {
+ o[1].onClick = () => {
+ eventsService.clickedSlot = slotDescriptor
+ controlsEnabled && eventsService.splitHorizontalBefore()
+ }
+ o[4].onClick = () => {
+ eventsService.clickedSlot = slotDescriptor
+ controlsEnabled && eventsService.splitVerticalBefore()
+ }
+ o[5].onClick = () => {
+ eventsService.clickedSlot = slotDescriptor
+ controlsEnabled && eventsService.mergeHandler()
+ }
+ o[6].onClick = () => {
+ eventsService.clickedSlot = slotDescriptor
+ controlsEnabled && eventsService.splitVerticalAfter()
+ }
+ o[8].onClick = () => {
+ eventsService.clickedSlot = slotDescriptor
+ controlsEnabled && eventsService.splitHorizontalAfter()
+ }
+ }
+ setControls([...o])
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [controlsEnabled, slotDescriptor])
+
+ return (
+
+ {controls
+ .map((o, idx) => (
+ (!o.isGridOnly || mode === ArSlotViewMode.BUILD) &&
+ (!o.isToolSlotOnly || [ArSlotViewMode.ROWCONTROLLER, ArSlotViewMode.COLCONTROLLER].indexOf(mode) > -1) && (
+ {
+ if (o.tooltip) ev.stopPropagation()
+ o.onClick && o.onClick()
+ }}
+ onMouseEnter={o.onMouseEnter}
+ onMouseLeave={o.onMouseLeave}
+ >
+ {o.tooltip ? (
+
+
+ {o.tooltip}
+
+ ) : (
+
+ )}
+
+ )
+ ))
+ .filter(Boolean)}
+
+ )
}
export default SlotTools
diff --git a/src/Test.tsx b/src/Test.tsx
new file mode 100644
index 0000000..c8e4574
--- /dev/null
+++ b/src/Test.tsx
@@ -0,0 +1,14 @@
+import ReactDOM from "react-dom/client"
+import { DndProvider } from "react-dnd"
+import { HTML5Backend } from "react-dnd-html5-backend"
+import { TouchBackend } from "react-dnd-touch-backend"
+import { isMobile as checkMobile } from "@armco/utils/helper"
+import Layout from "./Layout"
+import "./test.scss"
+
+const isMobile = checkMobile()
+const backend = isMobile ? TouchBackend : HTML5Backend
+
+const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
+
+root.render(
)
diff --git a/src/hooks/useLayoutInit.ts b/src/hooks/useLayoutInit.ts
new file mode 100644
index 0000000..4a54104
--- /dev/null
+++ b/src/hooks/useLayoutInit.ts
@@ -0,0 +1,60 @@
+import { useRef, useEffect } from "react"
+import LayoutService from "@services/Layout.service"
+import EventsService from "@services/Events.service"
+import HistoryService from "@services/History.service"
+import { UseLayoutInitProps, LayoutMode } from "../types"
+import type SlotDescriptor from "@models/SlotDescriptor"
+
+export default function useLayoutInit(
+ initProps: UseLayoutInitProps,
+ mode: LayoutMode = "build",
+) {
+ const historyRef = useRef>()
+ const layoutRef = useRef()
+ const eventsRef = useRef()
+
+ if (mode === "build" && !historyRef.current)
+ historyRef.current = new HistoryService()
+ if (!layoutRef.current) {
+ layoutRef.current = new LayoutService(
+ {
+ colWidths: initProps.colWidths,
+ gridSpecs: initProps.gridSpecs,
+ gridTemplate: initProps.gridTemplate,
+ rowHeights: initProps.rowHeights,
+ slots: initProps.slots,
+ acceptTextOnClick: initProps.acceptTextOnClick,
+ activeSlot: initProps.activeSlot,
+ mode,
+ },
+ initProps.rerender,
+ historyRef.current,
+ initProps.onLayoutChange,
+ )
+ }
+ if (mode === "build" && !eventsRef.current && historyRef.current) {
+ eventsRef.current = new (EventsService as any)(
+ layoutRef.current,
+ historyRef.current,
+ initProps.onSlotSelect,
+ )
+ }
+
+ useEffect(() => {
+ if (
+ mode === "build" &&
+ eventsRef.current &&
+ eventsRef.current.handleEvent
+ ) {
+ const handler = (e: KeyboardEvent) => eventsRef.current?.handleEvent(e)
+ document.addEventListener("keydown", handler)
+ return () => document.removeEventListener("keydown", handler)
+ }
+ }, [mode])
+
+ return {
+ eventsService: eventsRef.current,
+ layoutService: layoutRef.current,
+ historyService: historyRef.current,
+ }
+}
diff --git a/src/index.ts b/src/index.ts
old mode 100755
new mode 100644
index 161b1c8..a221551
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,7 +1,17 @@
-export { default } from "./Layout"
+export { default as Layout } from "./Layout"
export { default as BuilderLayout } from "./BuilderLayout"
export { default as ReleaseLayout } from "./ReleaseLayout"
+
export { default as Slot } from "./Slot"
export { default as BuilderSlot } from "./BuilderSlot"
export { default as ReleaseSlot } from "./ReleaseSlot"
-export { default as Resizable } from "./Resizable"
\ No newline at end of file
+export { default as SlotTools } from "./SlotTools"
+
+export { default as Resizable } from "./Resizable"
+
+export { default as ComponentInfo } from "./models/ComponentInfo"
+
+export { default as useLayoutInit } from "./hooks/useLayoutInit"
+export { default as LayoutProvider } from "./Layout.context"
+
+export * from "./types"
diff --git a/src/models/ComponentInfo.tsx b/src/models/ComponentInfo.tsx
new file mode 100644
index 0000000..397a9a9
--- /dev/null
+++ b/src/models/ComponentInfo.tsx
@@ -0,0 +1,29 @@
+import { ArComponentResources } from "@armco/shared-components/enums"
+import { IComponentInfo, ISlotDescriptor } from "../types"
+
+interface Descriptor {
+ content: Array
+ layout: string
+}
+
+class ComponentInfo implements IComponentInfo {
+ [key: string]: any
+ componentName: string
+ source?: ArComponentResources
+ descriptor?: Descriptor
+ props?: { [key: string]: any }
+
+ constructor(
+ componentName: string,
+ source?: ArComponentResources,
+ descriptor?: Descriptor,
+ props?: { [key: string]: any }
+ ) {
+ this.componentName = componentName
+ this.source = source
+ this.descriptor = descriptor
+ this.props = props
+ }
+}
+
+export default ComponentInfo
diff --git a/src/models/LayoutDescriptor.ts b/src/models/LayoutDescriptor.ts
new file mode 100644
index 0000000..75a8300
--- /dev/null
+++ b/src/models/LayoutDescriptor.ts
@@ -0,0 +1,130 @@
+import { FunctionType } from "@armco/types"
+import { IGridSpecs, ISlotDescriptor, LayoutProps } from "../types"
+import SlotDescriptor from "./SlotDescriptor"
+
+class LayoutDescriptor implements LayoutProps {
+ acceptTextOnClick?: boolean
+ colWidths?: string | Array
+ displayMode?:
+ | "grid"
+ | "inline"
+ | "inline-flex"
+ | "inline-grid"
+ | "flex"
+ | "inline-block"
+ | "block"
+ | "table"
+ hideBuildModePaddings?: boolean
+ gridSpecs?: string | IGridSpecs
+ gridTemplate: string = ""
+ isChild?: boolean
+ activeSlot?: SlotDescriptor
+ mode?: "build" | "preview" | "release"
+ rowHeights?: string | Array
+ onLayoutChange?: FunctionType
+ slotDropHandler?: FunctionType
+ onSlotSelect?: FunctionType
+ showControls?: boolean
+ showPanels?: boolean
+ slotRenderer?: FunctionType
+ slots: Array = []
+ url?: string
+
+ setAcceptTextOnClick(value: boolean): this {
+ this.acceptTextOnClick = value
+ return this
+ }
+ setColWidths(value: string | Array): this {
+ this.colWidths = value
+ return this
+ }
+ setDisplayMode(
+ value:
+ | "grid"
+ | "inline"
+ | "inline-flex"
+ | "inline-grid"
+ | "flex"
+ | "inline-block"
+ | "block"
+ | "table",
+ ): this {
+ this.displayMode = value
+ return this
+ }
+ setHideBuildModePaddings(value: boolean): this {
+ this.hideBuildModePaddings = value
+ return this
+ }
+ setGridSpecs(value: string | IGridSpecs): this {
+ this.gridSpecs = value
+ return this
+ }
+ setGridTemplate(value: string): this {
+ this.gridTemplate = value
+ return this
+ }
+ setIsChild(value: boolean): this {
+ this.isChild = value
+ return this
+ }
+ setActiveSlot(value: ISlotDescriptor): this {
+ this.activeSlot = value ? SlotDescriptor.create(value) : undefined
+ return this
+ }
+ setMode(value: "build" | "preview" | "release"): this {
+ this.mode = value
+ return this
+ }
+ setRowHeights(value: string | Array): this {
+ this.rowHeights = value
+ return this
+ }
+ setOnLayoutChange(value: FunctionType): this {
+ this.onLayoutChange = value
+ return this
+ }
+ setSlotDropHandler(value: FunctionType): this {
+ this.slotDropHandler = value
+ return this
+ }
+ setOnSlotSelect(value: FunctionType): this {
+ this.onSlotSelect = value
+ return this
+ }
+ setShowControls(value: boolean): this {
+ this.showControls = value
+ return this
+ }
+ setShowPanels(value: boolean): this {
+ this.showPanels = value
+ return this
+ }
+ setSlotRenderer(value: FunctionType): this {
+ this.slotRenderer = value
+ return this
+ }
+ setSlots(value: Array): this {
+ this.slots = value
+ ? value.map((s) => s && SlotDescriptor.create(s)).filter((s) => s)
+ : []
+ return this
+ }
+ setUrl(value: string): this {
+ this.url = value
+ return this
+ }
+ static create(props: LayoutProps): LayoutDescriptor {
+ const instance = new LayoutDescriptor()
+ Object.keys(props).forEach((key) => {
+ const value = (props as any)[key]
+ const setter = `set${key.charAt(0).toUpperCase()}${key.slice(1)}`
+ if (typeof (instance as any)[setter] === "function") {
+ ;(instance as any)[setter](value)
+ }
+ })
+ return instance
+ }
+}
+
+export default LayoutDescriptor
diff --git a/src/models/SlotDescriptor.ts b/src/models/SlotDescriptor.ts
new file mode 100644
index 0000000..6c57e58
--- /dev/null
+++ b/src/models/SlotDescriptor.ts
@@ -0,0 +1,134 @@
+import { ISlotDescriptor, IComponentInfo, LayoutProps } from "../types"
+import LayoutError, { LayoutErrors } from "../LayoutError"
+
+export interface SlotDescriptorProps extends ISlotDescriptor {}
+
+export default class SlotDescriptor implements ISlotDescriptor {
+ colSpan: number = 1
+ column: number = -1
+ gridArea: string = ""
+ row: number = -1
+ rowSpan: number = 1
+ slot: string
+ content?: Array | IComponentInfo
+ props?: {
+ [key: string]: any
+ containerDisplayMode?: LayoutProps["displayMode"]
+ style?: React.CSSProperties
+ }
+ splitFrom?: string
+ isSelected?: boolean
+ isSelectedForInlineDelete?: boolean
+
+ constructor(
+ slot: string,
+ content?: Array | IComponentInfo,
+ props?: {
+ [key: string]: any
+ containerDisplayMode?: LayoutProps["displayMode"]
+ style?: React.CSSProperties
+ },
+ ) {
+ this.slot = slot
+ this.content = content
+ this.props = props
+ }
+
+ setColSpan(colSpan: number): this {
+ this.colSpan = colSpan
+ return this
+ }
+ setColumn(column: number): this {
+ this.column = column
+ return this
+ }
+ setRow(row: number): this {
+ this.row = row
+ return this
+ }
+ setRowSpan(rowSpan: number): this {
+ this.rowSpan = rowSpan
+ return this
+ }
+ setGridArea(gridArea: string): this {
+ this.gridArea = gridArea
+ return this
+ }
+ setSplitFrom(splitFrom?: string): this {
+ this.splitFrom = splitFrom ?? this.splitFrom
+ return this
+ }
+ setIsSelected(isSelected?: boolean): this {
+ this.isSelected = isSelected ?? this.isSelected
+ return this
+ }
+ setIsSelectedForInlineDelete(isSelectedForInlineDelete?: boolean): this {
+ this.isSelectedForInlineDelete =
+ isSelectedForInlineDelete ?? this.isSelectedForInlineDelete
+ return this
+ }
+ setProps(props?: {
+ [key: string]: any
+ containerDisplayMode?: LayoutProps["displayMode"]
+ style?: React.CSSProperties
+ }): this {
+ this.props = props
+ return this
+ }
+ setContent(content?: Array | IComponentInfo): this {
+ this.content = content
+ return this
+ }
+ setStyle(style: React.CSSProperties): this {
+ if (this.props && this.props.style) {
+ this.props.style = { ...this.props.style, ...style }
+ } else {
+ this.props = { ...(this.props || {}), style }
+ }
+ return this
+ }
+ setClasses(classes: string): this {
+ if (this.props) {
+ this.props.classes = classes
+ } else {
+ this.props = { classes }
+ }
+ return this
+ }
+ get containerDisplayMode(): LayoutProps["displayMode"] | undefined {
+ return this.props?.containerDisplayMode
+ }
+ static cloneOne(slotDescriptor: SlotDescriptor): SlotDescriptor {
+ return SlotDescriptor.create({ ...slotDescriptor })
+ }
+ static clone(slotDescriptors: Array): SlotDescriptor[] {
+ return slotDescriptors.map((e) => SlotDescriptor.cloneOne(e))
+ }
+ static create(args: ISlotDescriptor): SlotDescriptor {
+ if (!args)
+ throw new LayoutError(
+ "Missing SlotDescriptor object",
+ LayoutErrors.MISSING_SLOT_DESCRIPTOR_OBJ,
+ "SlotDescriptor.create: Failed to create SlotDescriptor as args is missing",
+ )
+ if (!args.slot)
+ throw new LayoutError(
+ "Missing Slot ID",
+ LayoutErrors.MISSING_SLOT_ID,
+ "SlotDescriptor.create: Failed to create SlotDescriptor as args.slot is missing",
+ )
+ if (args instanceof SlotDescriptor) return args
+ const e = new SlotDescriptor(args.slot, args.content, args.props)
+ return e
+ .setColSpan(args.colSpan ?? e.colSpan)
+ .setColumn(args.column ?? e.column)
+ .setRow(args.row ?? e.row)
+ .setRowSpan(args.rowSpan ?? e.rowSpan)
+ .setGridArea(args.gridArea ?? e.gridArea)
+ .setSplitFrom(args.splitFrom ?? e.splitFrom)
+ .setIsSelected(args.isSelected ?? e.isSelected)
+ .setIsSelectedForInlineDelete(
+ args.isSelectedForInlineDelete ?? e.isSelectedForInlineDelete,
+ )
+ }
+}
diff --git a/src/models/index.ts b/src/models/index.ts
new file mode 100644
index 0000000..e5758e6
--- /dev/null
+++ b/src/models/index.ts
@@ -0,0 +1,3 @@
+export { default as ComponentInfo } from "./ComponentInfo"
+export { default as LayoutDescriptor } from "./LayoutDescriptor"
+export { default as SlotDescriptor } from "./SlotDescriptor"
diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts
deleted file mode 100644
index 461cd0b..0000000
--- a/src/react-app-env.d.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-///
-///
-///
-
-declare namespace NodeJS {
- interface ProcessEnv {
- readonly NODE_ENV: "development" | "production" | "test"
- readonly PUBLIC_URL: string
- }
-}
-
-declare module "*.avif" {
- const src: string
- export default src
-}
-
-declare module "*.bmp" {
- const src: string
- export default src
-}
-
-declare module "*.gif" {
- const src: string
- export default src
-}
-
-declare module "*.jpg" {
- const src: string
- export default src
-}
-
-declare module "*.jpeg" {
- const src: string
- export default src
-}
-
-declare module "*.png" {
- const src: string
- export default src
-}
-
-declare module "*.webp" {
- const src: string
- export default src
-}
-
-declare module "*.svg" {
- import * as React from "react"
-
- export const ReactComponent: React.FunctionComponent & { title?: string }>
-
- const src: string
- export default src
-}
-
-declare module "*.module.css" {
- const classes: { readonly [key: string]: string }
- export default classes
-}
-
-declare module "*.module.scss" {
- const classes: { readonly [key: string]: string }
- export default classes
-}
-
-declare module "*.module.sass" {
- const classes: { readonly [key: string]: string }
- export default classes
-}
diff --git a/src/services/Events.service.ts b/src/services/Events.service.ts
new file mode 100644
index 0000000..7cce7aa
--- /dev/null
+++ b/src/services/Events.service.ts
@@ -0,0 +1,796 @@
+import { v4 as uuid } from "uuid"
+import { FunctionType } from "@armco/types"
+import { ArComponentResources } from "@armco/shared-components/enums"
+import { SlotDescriptor } from "../models"
+import LayoutService from "./Layout.service"
+import HistoryService from "./History.service"
+
+/**
+ * EventsService wires user interactions (keyboard and toolbar actions)
+ * to Layout state mutations (split/merge/insert/remove/select and undo/redo).
+ *
+ * Stage 1: This is a strongly-typed scaffold based on the generated .d.ts.
+ * Stage 2 will fill in implementations aligned with build/es/Events.service.js.
+ */
+class EventsService {
+ layoutService: LayoutService
+ historyService: HistoryService>
+ onSlotSelectCB?: FunctionType
+ clickedSlot: SlotDescriptor | null = null
+
+ constructor(
+ layoutService: LayoutService,
+ historyService: HistoryService>,
+ onSlotSelectCB?: FunctionType,
+ ) {
+ this.layoutService = layoutService
+ this.historyService = historyService
+ this.onSlotSelectCB = onSlotSelectCB
+
+ // Bind methods to preserve `this` when used as callbacks
+ this.handleEvent = this.handleEvent.bind(this)
+ this.call = this.call.bind(this)
+ this.undo = this.undo.bind(this)
+ this.redo = this.redo.bind(this)
+ this.splitCell = this.splitCell.bind(this)
+ this.mergeCells = this.mergeCells.bind(this)
+ this.onSlotSelect = this.onSlotSelect.bind(this)
+ this.onDimSelect = this.onDimSelect.bind(this)
+ this.addRowBefore = this.addRowBefore.bind(this)
+ this.addRowAfter = this.addRowAfter.bind(this)
+ this.deleteRowInline = this.deleteRowInline.bind(this)
+ this.deleteRowNormal = this.deleteRowNormal.bind(this)
+ this.addColumnBefore = this.addColumnBefore.bind(this)
+ this.addColumnAfter = this.addColumnAfter.bind(this)
+ this.deleteColumnInline = this.deleteColumnInline.bind(this)
+ this.deleteColumnNormal = this.deleteColumnNormal.bind(this)
+ this.splitHorizontalBefore = this.splitHorizontalBefore.bind(this)
+ this.splitHorizontalAfter = this.splitHorizontalAfter.bind(this)
+ this.splitVerticalBefore = this.splitVerticalBefore.bind(this)
+ this.splitVerticalAfter = this.splitVerticalAfter.bind(this)
+ this.splitHorizontal = this.splitHorizontal.bind(this)
+ this.splitVertical = this.splitVertical.bind(this)
+ this.splitHandler = this.splitHandler.bind(this)
+ this.mergeHandler = this.mergeHandler.bind(this)
+ this.addRow = this.addRow.bind(this)
+ this.deleteRow = this.deleteRow.bind(this)
+ this.addColumn = this.addColumn.bind(this)
+ this.deleteColumn = this.deleteColumn.bind(this)
+ this.selectSlot = this.selectSlot.bind(this)
+ this.selectRow = this.selectRow.bind(this)
+ this.selectColumn = this.selectColumn.bind(this)
+ this.insertDimension = this.insertDimension.bind(this)
+ this.removeHandler = this.removeHandler.bind(this)
+ this.findIntersectingSlots = this.findIntersectingSlots.bind(this)
+ this.generateInsertionIndexes = this.generateInsertionIndexes.bind(this)
+ this.selectCellsInSelectedRowCol =
+ this.selectCellsInSelectedRowCol.bind(this)
+ }
+
+ // ======================= Event Handler Wrappers =======================
+ /**
+ * Registers keyboard events for undo, redo, merge, split, and add row/column actions.
+ * This function listens for specific key combinations (Ctrl/Cmd + Z/Y/M/H/V/R/C)
+ * and triggers the corresponding actions in the layout.
+ * @param event
+ */
+ handleEvent(event: KeyboardEvent): void {
+ if (event.ctrlKey || event.metaKey) {
+ if (event.key === "z") {
+ event.preventDefault()
+ this.undo()
+ } else if (event.key === "y") {
+ event.preventDefault()
+ this.redo()
+ } else if (event.key === "m") {
+ this.mergeHandler()
+ } else if (this.layoutService.splitEnabled) {
+ if (event.key === "h") this.splitHorizontal()
+ else if (event.key === "v") this.splitVertical()
+ else if (event.key === "r") this.addRow()
+ else if (event.key === "c") this.addColumn()
+ }
+ }
+ }
+
+ /**
+ * Calls a generic event handler with optional pre and post hooks.
+ * This generic event handler that calls one of the other event handlers in this class
+ *
+ * @param eventHandler
+ * @param preHook
+ * @param postHook
+ * @param args
+ * @returns
+ */
+ call(
+ func: keyof EventsService,
+ preHook?: FunctionType,
+ postHook?: FunctionType,
+ ...args: any[]
+ ): any {
+ if (preHook) preHook()
+ // @ts-expect-error - index access
+ const result = this[func](...args)
+ if (postHook) postHook()
+ return result
+ }
+
+ undo(): void {
+ const slots = this.historyService.undo()
+ this.layoutService.updateSlots(slots || [], true)
+ }
+
+ redo(): void {
+ const slots = this.historyService.redo()
+ this.layoutService.updateSlots(slots || [], true)
+ }
+
+ /**
+ * Adds a new row before the specified selection.
+ * @param selection - An optional selection index.
+ */
+ addRowBefore(selection?: number): SlotDescriptor[] | undefined {
+ return this.addRow("before", selection)
+ }
+ /**
+ * Adds a new row after the specified selection.
+ * @param selection - An optional selection index.
+ */
+ addRowAfter(selection?: number): SlotDescriptor[] | undefined {
+ return this.addRow("after", selection)
+ }
+ /**
+ * Deletes a row inline.
+ * @returns The updated slots array or null.
+ */
+ deleteRowInline(): Array | null {
+ return this.deleteRow(true)
+ }
+ /**
+ * Deletes a row normally.
+ * @returns The updated slots array or null.
+ */
+ deleteRowNormal(): Array | null {
+ return this.deleteRow(false)
+ }
+ /**
+ * Adds a new column before the specified selection.
+ * @param selection - An optional selection index.
+ */
+ addColumnBefore(selection?: number): SlotDescriptor[] | undefined {
+ return this.addColumn("before", selection)
+ }
+ /**
+ * Adds a new column after the specified selection.
+ * @param selection - An optional selection index.
+ */
+ addColumnAfter(selection?: number): SlotDescriptor[] | undefined {
+ return this.addColumn("after", selection)
+ }
+ /**
+ * Deletes a column inline.
+ * @returns The updated slots array or null.
+ */
+ deleteColumnInline(): Array | null {
+ return this.deleteColumn(true)
+ }
+ /**
+ * Deletes a column normally.
+ * @returns The updated slots array or null.
+ */
+ deleteColumnNormal(): Array | null {
+ return this.deleteColumn(false)
+ }
+
+ /**
+ * Adds a new row to the layout.
+ * @param placement - The placement of the new row ("before" or "after").
+ * @param selection - An optional selection index.
+ */
+ addRow(
+ placement: "before" | "after" = "after",
+ selection?: number,
+ ): SlotDescriptor[] | undefined {
+ return (
+ this.layoutService.gridToolbarSpecs &&
+ this.insertDimension("row", placement, selection)
+ )
+ }
+ /**
+ * Deletes a row from the layout.
+ * @param isInlineDelete - A boolean to indicate whether it is an inline delete.
+ * @returns The updated slots array or null.
+ */
+ deleteRow(isInlineDelete?: boolean): Array | null {
+ return (
+ (this.layoutService.gridToolbarSpecs &&
+ this.removeHandler("row", isInlineDelete)) ||
+ null
+ )
+ }
+ /**
+ * Adds a new column to the layout.
+ * @param placement - The placement of the new column ("before" or "after").
+ * @param selection - An optional selection index.
+ */
+ addColumn(
+ placement: "before" | "after" = "after",
+ selection?: number,
+ ): SlotDescriptor[] | undefined {
+ return (
+ this.layoutService.gridToolbarSpecs &&
+ this.insertDimension("column", placement, selection)
+ )
+ }
+ /**
+ * Deletes a column from the layout.
+ * @param isInlineDelete - A boolean to indicate whether it is an inline delete.
+ * @returns The updated slots array or null.
+ */
+ deleteColumn(isInlineDelete?: boolean): Array | null {
+ return (
+ (this.layoutService.gridToolbarSpecs &&
+ this.removeHandler("column", isInlineDelete)) ||
+ null
+ )
+ }
+
+ /**
+ * Splits a cell based on orientation and placement.
+ * @param orientation - The orientation of the split ("horizontal" or "vertical").
+ * @param placement - The placement of the split ("before" or "after").
+ * @returns The updated slots array.
+ */
+ splitCell(
+ orientation: "horizontal" | "vertical",
+ placement: "before" | "after",
+ ): SlotDescriptor[] {
+ return this.splitHandler(orientation, placement)
+ }
+ /**
+ * Merges selected cells into a primary cell configuration.
+ * @returns The updated slots array.
+ */
+ mergeCells(): SlotDescriptor[] | undefined {
+ return this.mergeHandler()
+ }
+
+ /**
+ * Handles slot selection.
+ * @param slotDescriptor - The slot descriptor to select.
+ * @returns The updated slots array.
+ */
+ selectSlot(slotDescriptor: SlotDescriptor): SlotDescriptor[] {
+ return this.onSlotSelect(slotDescriptor)
+ }
+ /**
+ * Handles row selection.
+ * @param slotDescriptor - The slot descriptor to select.
+ * @param selectForDelete - A boolean to indicate whether to select for delete.
+ * @returns The updated slots array.
+ */
+ selectRow(
+ slotDescriptor: SlotDescriptor,
+ selectForDelete?: boolean,
+ ): SlotDescriptor[] | undefined {
+ return this.onDimSelect(slotDescriptor, "row", selectForDelete)
+ }
+ /**
+ * Handles column selection.
+ * @param slotDescriptor - The slot descriptor to select.
+ * @param selectForDelete - A boolean to indicate whether to select for delete.
+ * @returns The updated slots array.
+ */
+ selectColumn(
+ slotDescriptor: SlotDescriptor,
+ selectForDelete?: boolean,
+ ): SlotDescriptor[] | undefined {
+ return this.onDimSelect(slotDescriptor, "column", selectForDelete)
+ }
+
+ /** Splits a slot horizontally and places new slot before the one being split */
+ splitHorizontalBefore(): SlotDescriptor[] {
+ return this.splitHandler("horizontal", "before")
+ }
+ /** Splits a slot horizontally and places new slot after the one being split */
+ splitHorizontalAfter(): SlotDescriptor[] {
+ return this.splitHandler("horizontal", "after")
+ }
+ /** Splits a slot vertically and places new slot before the one being split */
+ splitVerticalBefore(): SlotDescriptor[] {
+ return this.splitHandler("vertical", "before")
+ }
+ /** Splits a slot vertically and places new slot after the one being split */
+ splitVerticalAfter(): SlotDescriptor[] {
+ return this.splitHandler("vertical", "after")
+ }
+ /** Split slot horizontally */
+ splitHorizontal(placement: "before" | "after" = "after"): SlotDescriptor[] {
+ return this.splitHandler("horizontal", placement)
+ }
+ /** Split slot vertically */
+ splitVertical(placement: "before" | "after" = "after"): SlotDescriptor[] {
+ return this.splitHandler("vertical", placement)
+ }
+
+ // ================= Event Primaries/Juices =================
+ /**
+ * Inserts a new row or column dimension into the grid.
+ * @param type - The type of dimension ("row" or "column").
+ * @param placement - The placement of the new dimension ("before" or "after").
+ * @param selection - An optional selection index.
+ * @returns An updated slots array.
+ */
+ insertDimension(
+ type: "row" | "column",
+ placement: "before" | "after",
+ selection?: number,
+ ): SlotDescriptor[] {
+ let slots: SlotDescriptor[] = [...this.layoutService.slots]
+ const isRow = type === "row"
+ const gts = this.layoutService.gridToolbarSpecs!
+ const toolSlots = gts[isRow ? "colTools" : "rowTools"].slots
+ let created: SlotDescriptor[] = []
+ if (toolSlots.length > 0) {
+ const insertionIndexes = this.generateInsertionIndexes(
+ isRow,
+ placement,
+ selection,
+ )
+ insertionIndexes.sort()
+ const spanExtensions: Record<
+ string,
+ { d: SlotDescriptor; count: number }
+ > = {}
+ insertionIndexes.forEach((idx, loopIdx) => {
+ const selectedToolSlot =
+ gts[isRow ? "rowTools" : "colTools"].slots[
+ idx - loopIdx - (placement === "after" ? 1 : 0)
+ ]
+ const intersectingSlots = this.findIntersectingSlots(
+ selectedToolSlot,
+ isRow,
+ )
+ const newOnes = toolSlots
+ .map((toolSlot) => {
+ const intersecting = intersectingSlots.find((i) =>
+ this.isIntersecting(toolSlot, i, !isRow),
+ )
+ if (intersecting) {
+ if (
+ this.getCreateOrExtend(
+ intersecting,
+ selectedToolSlot,
+ placement,
+ isRow,
+ )
+ ) {
+ const newId = `gsa-${uuid()}`
+ return SlotDescriptor.create({
+ slot: newId,
+ row: isRow ? idx : toolSlot.row,
+ column: isRow ? toolSlot.column : idx,
+ rowSpan: isRow ? 1 : toolSlot.rowSpan,
+ colSpan: isRow ? toolSlot.colSpan : 1,
+ gridArea: newId,
+ })
+ } else {
+ if (spanExtensions[intersecting.slot])
+ spanExtensions[intersecting.slot].count += 1
+ else
+ spanExtensions[intersecting.slot] = {
+ d: intersecting,
+ count: 1,
+ }
+ }
+ }
+ return null
+ })
+ .filter((s): s is SlotDescriptor => !!s)
+ created = created.concat(newOnes)
+ })
+ insertionIndexes.forEach((idx) => {
+ const dim = isRow ? "row" : "column"
+ slots.filter((s) => s[dim] >= idx).forEach((s) => s[dim]++)
+ })
+ Object.values(spanExtensions).forEach((ext) => {
+ if (isRow) ext.d.rowSpan += ext.count
+ else ext.d.colSpan += ext.count
+ })
+ } else {
+ const id = `gsa-${crypto.randomUUID()}`
+ created.push(
+ SlotDescriptor.create({
+ slot: id,
+ row: 0,
+ column: 0,
+ rowSpan: 1,
+ colSpan: 1,
+ gridArea: id,
+ }),
+ )
+ }
+ slots = slots.concat(created)
+ this.layoutService.updateSlots(slots)
+ return slots
+ }
+ /**
+ * Removes slots based on type and grid toolbar specifications.
+ * @param type - The type of removal ("row" or "column").
+ * @param isInlineDelete - A boolean to indicate whether it is an inline delete.
+ * @returns The updated slots array or null.
+ */
+ removeHandler(
+ type: "row" | "column",
+ isInlineDelete?: boolean,
+ ): Array | null {
+ const slots: SlotDescriptor[] = [...this.layoutService.slots]
+ const isRow = type === "row"
+ const gts = this.layoutService.gridToolbarSpecs!
+ const toolSlots = isRow ? gts.rowTools.slots : gts.colTools.slots
+ toolSlots
+ .filter((s) =>
+ isInlineDelete ? s.isSelectedForInlineDelete : s.isSelected,
+ )
+ .sort((a, b) => (isRow ? a.row - b.row : a.column - b.column))
+ .forEach((toolSlot) => {
+ slots
+ .filter((slot) =>
+ isRow
+ ? toolSlot.row >= slot.row &&
+ toolSlot.row < slot.row + slot.rowSpan
+ : toolSlot.column >= slot.column &&
+ toolSlot.column < slot.column + slot.colSpan,
+ )
+ .forEach((slot) => {
+ if (
+ isRow
+ ? slot.rowSpan <= toolSlot.rowSpan
+ : slot.colSpan <= toolSlot.colSpan
+ ) {
+ const idx = slots.indexOf(slot)
+ if (idx !== -1) slots.splice(idx, 1)
+ } else {
+ if (isRow) slot.rowSpan -= 1
+ else slot.colSpan -= 1
+ }
+ })
+ let affected = slots.filter((slot) =>
+ isRow ? slot.row > toolSlot.row : slot.column > toolSlot.column,
+ )
+ affected.forEach((slot) =>
+ isRow ? (slot.row -= 1) : (slot.column -= 1),
+ )
+ affected = toolSlots.filter((slot) =>
+ isRow ? slot.row > toolSlot.row : slot.column > toolSlot.column,
+ )
+ affected.forEach((slot) =>
+ isRow ? (slot.row -= 1) : (slot.column -= 1),
+ )
+ toolSlots.splice(toolSlots.indexOf(toolSlot), 1)
+ })
+ this.layoutService.updateSlots(slots)
+ return slots
+ }
+ /**
+ * Merges selected slots into a primary slot configuration.
+ * @returns The updated slots array.
+ */
+ mergeHandler(): SlotDescriptor[] | undefined {
+ if (this.layoutService.mergeEnabled) {
+ let clicked = this.clickedSlot
+ this.clickedSlot = null
+ const slots = [...this.layoutService.slots]
+ const selected = slots.filter((s) => s.isSelected)
+ const minMax = this.layoutService.minMaxSelections
+ selected.sort((a, b) =>
+ a.row !== b.row ? a.row - b.row : a.column - b.column,
+ )
+ const first = selected[0]
+ let primary = clicked
+ ? slots.find((s) => s.slot === clicked?.slot)
+ : first
+ if (!primary) return
+ primary.row = first.row
+ primary.column = first.column
+ primary.rowSpan = minMax.maxRow - minMax.minRow + 1
+ primary.colSpan = minMax.maxColumn - minMax.minColumn + 1
+ selected.forEach((sel) => {
+ const idx = slots.findIndex((s) => s.slot === sel.slot)
+ const found = slots[idx]
+ if (found && found.slot !== primary?.slot) slots.splice(idx, 1)
+ })
+ this.layoutService.mergeEnabled = false
+ this.layoutService.updateSlots(slots)
+ return slots
+ }
+ }
+ /**
+ * Splits a slot based on orientation and placement.
+ * @param orientation - The orientation of the split ("horizontal" or "vertical").
+ * @param placement - The placement of the split ("before" or "after").
+ * @returns The updated slots array.
+ */
+ splitHandler(
+ orientation: "horizontal" | "vertical",
+ placement: "before" | "after",
+ ): SlotDescriptor[] {
+ const slots = [...this.layoutService.slots]
+ let targets: SlotDescriptor[] = []
+ if (this.clickedSlot) {
+ const found = slots.find((s) => s.slot === this.clickedSlot?.slot)
+ if (found) targets.push(found)
+ this.clickedSlot = null
+ } else {
+ targets = slots.filter((s) => s.isSelected)
+ }
+ targets.forEach((slot) => {
+ const { row, slot: slotId, column, colSpan, rowSpan } = slot
+ const isHorizontal = orientation === "horizontal"
+ const isAfter = placement === "after"
+ const newId = `gsa-${crypto.randomUUID()}`
+ const spanProp = isHorizontal ? "rowSpan" : "colSpan"
+ const dimProp = isHorizontal ? "row" : "column"
+ let spanVal: number = slot[spanProp]
+ if (spanVal > 1) {
+ spanVal = slot[spanProp] - 1
+ slot[spanProp] = spanVal
+ }
+ const newSlot = SlotDescriptor.create({
+ slot: newId,
+ row: isHorizontal && isAfter ? spanVal + row : row,
+ column: isHorizontal ? column : isAfter ? spanVal + column : column,
+ rowSpan: isHorizontal ? 1 : rowSpan,
+ colSpan: isHorizontal ? colSpan : 1,
+ gridArea: newId,
+ splitFrom: slotId,
+ })
+ if (isHorizontal ? rowSpan === 1 : colSpan === 1) {
+ slots.forEach((s) => {
+ const {
+ row: r,
+ column: c,
+ slot: sId,
+ colSpan: sColSpan,
+ rowSpan: sRowSpan,
+ } = s
+ if (isHorizontal) {
+ if (row >= r && row < r + sRowSpan && sId !== slotId) s.rowSpan += 1
+ else if (r > row) s.row += 1
+ } else {
+ if (column >= c && column < c + sColSpan && sId !== slotId)
+ s.colSpan += 1
+ else if (c > column) s.column += 1
+ }
+ })
+ }
+ if (!isAfter) slot[dimProp] += 1
+ slots.push(newSlot)
+ })
+ this.layoutService.updateSlots(slots)
+ return slots
+ }
+ /**
+ * Handles slot selection.
+ * @param slotDescriptor - The slot descriptor to select.
+ * @returns The updated slots array.
+ */
+ onSlotSelect(slotDescriptor: SlotDescriptor): SlotDescriptor[] {
+ const slots = [...this.layoutService.slots]
+ if (slotDescriptor) slotDescriptor.isSelected = !slotDescriptor.isSelected
+ if (
+ this.layoutService.acceptTextOnClick &&
+ slotDescriptor &&
+ !slotDescriptor?.content
+ ) {
+ slotDescriptor.props ||= {}
+ slotDescriptor.content = {
+ name: "SamEditor",
+ description: "Rich text editor",
+ source: ArComponentResources.STUFFLE ?? "stuffle",
+ componentName: "SamEditor",
+ props: {
+ pmOptions: { menuBar: false },
+ classes: "w-100",
+ id: slotDescriptor.slot,
+ style: { minHeight: "1.5rem", lineHeight: "1.5rem" },
+ isEditable: true,
+ },
+ }
+ }
+ this.layoutService.activeSlot = slotDescriptor
+ if (this.onSlotSelectCB) this.onSlotSelectCB(slotDescriptor)
+ this.layoutService.updateSlots(slots, true)
+ return slots
+ }
+ /**
+ * Handles dimension selection.
+ * @param slotDescriptor - The selected dimension descriptor.
+ * @param type - "row" | "column".
+ * @param selectForDelete - Whether to select for inline delete.
+ * @returns The updated slots array.
+ */
+ onDimSelect(
+ slotDescriptor: SlotDescriptor,
+ type: "row" | "column",
+ selectForDelete?: boolean,
+ ): SlotDescriptor[] | undefined {
+ const gts = this.layoutService.gridToolbarSpecs
+ if (!gts) return
+ const toolSlot = gts[type === "row" ? "rowTools" : "colTools"].slots.find(
+ (s) => s.slot === slotDescriptor.slot,
+ )
+ if (toolSlot) {
+ if (selectForDelete !== undefined)
+ toolSlot.isSelectedForInlineDelete = selectForDelete
+ else toolSlot.isSelected = !toolSlot.isSelected
+ }
+ const updated = this.selectCellsInSelectedRowCol(selectForDelete)
+ this.layoutService.updateSlots(updated, true)
+ return updated
+ }
+
+ // ======================= Event Helpers =======================
+ /**
+ * Finds intersecting slots based on a tool slot and dimension.
+ * @param toolSlot - The tool slot descriptor.
+ * @param isRow - True if row, false if column.
+ * @returns Intersecting slot descriptors.
+ */
+ findIntersectingSlots(
+ toolSlot: SlotDescriptor,
+ isRow: boolean,
+ ): SlotDescriptor[] {
+ return this.layoutService.slots.filter((slot) =>
+ this.isIntersecting(toolSlot, slot, isRow),
+ )
+ }
+ /**
+ * Generates insertion indexes for a new dimension.
+ * @param isRow - Whether dimension is row.
+ * @param placement - "before" | "after".
+ * @param selection - Optional selected index.
+ * @returns An array of insertion indexes.
+ */
+ generateInsertionIndexes(
+ isRow: boolean,
+ placement: "before" | "after",
+ selection?: number,
+ ): number[] {
+ let indexes: number[] = []
+ if (selection !== undefined)
+ indexes.push(placement === "after" ? selection + 1 : selection)
+ else
+ indexes = this.generateInsertionIndexesForDimension(
+ isRow ? "rowTools" : "colTools",
+ isRow ? "row" : "column",
+ placement,
+ isRow,
+ )
+ indexes.sort()
+ indexes = indexes.map((v, idx) => v + idx)
+ return indexes
+ }
+ /**
+ * Generates insertion indexes for a specific dimension.
+ * @param tools - "rowTools" | "colTools".
+ * @param dimension - "row" | "column".
+ * @param placement - "before" | "after".
+ * @param isRow - Whether dimension is row.
+ */
+ generateInsertionIndexesForDimension(
+ tools: "rowTools" | "colTools",
+ dimension: "row" | "column",
+ placement: "before" | "after",
+ isRow: boolean,
+ ): number[] {
+ let indexes: number[] = []
+ const gts = this.layoutService.gridToolbarSpecs!
+ const against = gts[isRow ? "rowTools" : "colTools"].slots
+ const selected = gts[tools].slots
+ .filter((s) => s.isSelected)
+ .map((s) => s[dimension] + (placement === "before" ? 0 : 1))
+ if (selected.length > 0) indexes = indexes.concat(selected)
+ else if (placement === "before") indexes.push(0)
+ else indexes.push(Math.max(...against.map((s) => s[dimension])) + 1)
+ return indexes
+ }
+ /**
+ * Checks if a tool slot intersects with a given slot.
+ * @param toolSlot - The tool slot descriptor.
+ * @param slot - The slot descriptor to check.
+ * @param isRow - Whether the check is for rows.
+ * @returns True if intersects, otherwise false.
+ */
+ isIntersecting(
+ toolSlot: SlotDescriptor,
+ slot: SlotDescriptor,
+ isRow: boolean,
+ ): boolean {
+ const dim = isRow ? "row" : "column"
+ const span = isRow ? "rowSpan" : "colSpan"
+ const l = slot[dim]
+ const c = slot[span]
+ const r = toolSlot[dim]
+ const a = toolSlot[span]
+ return l <= r && l + c >= r + a
+ }
+ /**
+ * Determines if a slot should be created or extended based on placement.
+ * @param slot - The slot descriptor.
+ * @param selectedToolSlot - The selected tool slot descriptor.
+ * @param placement - "before" | "after".
+ * @param isRow - Whether rows are targeted.
+ */
+ getCreateOrExtend(
+ slot: SlotDescriptor,
+ selectedToolSlot: SlotDescriptor,
+ placement: "before" | "after",
+ isRow: boolean,
+ ): boolean {
+ const dim = isRow ? "row" : "column"
+ const span = isRow ? "rowSpan" : "colSpan"
+ if (placement === "before") return slot[dim] === selectedToolSlot[dim]
+ return (
+ slot[dim] + slot[span] === selectedToolSlot[dim] + selectedToolSlot[span]
+ )
+ }
+ /**
+ * Selects cells in the selected row or column.
+ * @param selectForDelete - Whether to select for delete.
+ * @returns Updated slots with selection flags.
+ */
+ selectCellsInSelectedRowCol(selectForDelete?: boolean): SlotDescriptor[] {
+ const rowSlots = this.layoutService.gridToolbarSpecs?.rowTools.slots
+ const colSlots = this.layoutService.gridToolbarSpecs?.colTools.slots
+ const slots = [...this.layoutService.slots]
+ slots.forEach((slot) => {
+ slot[
+ selectForDelete !== undefined
+ ? "isSelectedForInlineDelete"
+ : "isSelected"
+ ] = this.isSlotSelected(
+ slot,
+ rowSlots || [],
+ colSlots || [],
+ selectForDelete,
+ )
+ })
+ return slots
+ }
+ /**
+ * Checks if a slot is selected based on row and column slots.
+ * @param slot - The slot descriptor to check.
+ * @param rowSlots - Row tool slots.
+ * @param colSlots - Column tool slots.
+ * @param selectForDelete - Inline delete mode flag.
+ */
+ isSlotSelected(
+ slot: SlotDescriptor,
+ rowSlots: Array,
+ colSlots: Array,
+ selectForDelete?: boolean,
+ ): boolean {
+ const { row, rowSpan, column, colSpan } = slot
+ const rowMatch = rowSlots
+ .filter((r) => r.row < row + rowSpan && r.row + r.rowSpan > row)
+ .every((r) =>
+ selectForDelete !== undefined
+ ? r.isSelectedForInlineDelete
+ : r.isSelected,
+ )
+ const colMatch = colSlots
+ .filter(
+ (c) => c.column < column + colSpan && c.column + c.colSpan > column,
+ )
+ .every((c) =>
+ selectForDelete !== undefined
+ ? c.isSelectedForInlineDelete
+ : c.isSelected,
+ )
+ return rowMatch || colMatch
+ }
+}
+
+export default EventsService
diff --git a/src/services/History.service.ts b/src/services/History.service.ts
new file mode 100644
index 0000000..8b01f21
--- /dev/null
+++ b/src/services/History.service.ts
@@ -0,0 +1,72 @@
+/**
+ * HistoryService manages undo/redo for layout state.
+ */
+class HistoryService {
+ past: T[] = []
+ present: T | null = null
+ future: T[] = []
+
+ constructor(initialState?: T) {
+ if (initialState !== undefined) {
+ this.present = initialState
+ }
+ }
+
+ get get(): T | null {
+ return this.present
+ }
+
+ get canUndo(): boolean {
+ return this.past.length > 0
+ }
+
+ get canRedo(): boolean {
+ return this.future.length > 0
+ }
+ /**
+ * Reverts to the previous state in the history.
+ * @returns The previous state or null if there are no past states.
+ */
+ undo(): T | null {
+ if (!this.canUndo) return this.present
+ const pastCopy = [...this.past]
+ const previous = pastCopy.pop()
+ this.future = [this.present as T, ...this.future]
+ this.past = pastCopy
+ this.present = previous ?? null
+ return this.present
+ }
+
+ /**
+ * Reverts to the next state in the future.
+ * @returns The next state or null if there are no future states.
+ */
+ redo(): T | null {
+ if (!this.canRedo) return this.present
+ const futureCopy = [...this.future]
+ const next = futureCopy.shift()
+ this.past = [...this.past, this.present as T]
+ this.future = futureCopy
+ this.present = next ?? null
+ return this.present
+ }
+
+ /**
+ * Sets the new state and optionally skips history recording.
+ * @param newState The new state to set.
+ * @param skipHistory If true, skips recording the current state in history.
+ * @returns The new state.
+ */
+ set(newState: T, skipHistory = false): T {
+ if (!skipHistory) {
+ this.past = this.present ? [...this.past, this.present] : this.past
+ }
+ this.present = newState
+ if (!skipHistory) {
+ this.future = []
+ }
+ return this.present
+ }
+}
+
+export default HistoryService
diff --git a/src/services/Layout.service.ts b/src/services/Layout.service.ts
new file mode 100644
index 0000000..cab4db5
--- /dev/null
+++ b/src/services/Layout.service.ts
@@ -0,0 +1,593 @@
+import { v4 as uuidv4 } from "uuid"
+import { FunctionType } from "@armco/types"
+import { LayoutDescriptor, SlotDescriptor } from "@models"
+import LayoutError, { LayoutErrors } from "../LayoutError"
+import HistoryService from "./History.service"
+import {
+ IGridSpecs,
+ IGridToolbarSpecs,
+ IMinMaxSelections,
+ LayoutMode,
+} from "../types"
+
+const DEFAULT_GRID_SPECS = { rows: 1, columns: 1 }
+
+class LayoutService {
+ private readonly layoutDescriptor: LayoutDescriptor
+ gridTemplate: string = ""
+ slots: SlotDescriptor[] = []
+ gridSpecs?: string | IGridSpecs
+ gridArea?: Array>
+ rowHeights?: Array | string
+ colWidths?: Array | string
+ gridToolbarSpecs?: IGridToolbarSpecs
+ acceptTextOnClick?: boolean
+ minMaxSelections: IMinMaxSelections = {
+ minRow: -Infinity,
+ maxRow: Infinity,
+ minColumn: -Infinity,
+ maxColumn: Infinity,
+ }
+ mergeEnabled: boolean = false
+ splitEnabled: boolean = false
+ mode: LayoutMode = "build"
+ activeSlot?: SlotDescriptor
+ historyService?: HistoryService>
+ onLayoutChange?: FunctionType
+ rerender: FunctionType
+
+ constructor(
+ descriptor: any,
+ rerender: FunctionType,
+ historyService?: HistoryService>,
+ onLayoutChange?: FunctionType,
+ ) {
+ this.layoutDescriptor = LayoutDescriptor.create(descriptor)
+ this.extract = this.extract.bind(this)
+ this.rerender = rerender
+ this.historyService = historyService
+ this.onLayoutChange = onLayoutChange
+ this.initLayout = this.initLayout.bind(this)
+ this.updateSlots = this.updateSlots.bind(this)
+ this.updateGTS = this.updateGTS.bind(this)
+ this.SGAtoGTS = this.SGAtoGTS.bind(this)
+ this.StoGA = this.StoGA.bind(this)
+ this.GStoS = this.GStoS.bind(this)
+ this.GAtoGT = this.GAtoGT.bind(this)
+ this.GTtoS = this.GTtoS.bind(this)
+ this.checkIfAdjacent = this.checkIfAdjacent.bind(this)
+ this.calculateRowHeights = this.calculateRowHeights.bind(this)
+ this.extract()
+ this.initLayout()
+ }
+
+ /**
+ * Extracts properties from the layout descriptor and initializes the layout.
+ * This method is called in the constructor and should not be called again.
+ * It initializes grid template, slots, grid area, row heights, column widths,
+ * and calls initLayout to set up the layout.
+ */
+ extract(): void {
+ this.gridTemplate = this.layoutDescriptor.gridTemplate
+ this.slots = this.layoutDescriptor.slots || []
+ this.gridSpecs = this.layoutDescriptor.gridSpecs || DEFAULT_GRID_SPECS
+ this.rowHeights = this.layoutDescriptor.rowHeights
+ this.colWidths = this.layoutDescriptor.colWidths
+ this.acceptTextOnClick = this.layoutDescriptor.acceptTextOnClick
+ this.activeSlot = this.layoutDescriptor.activeSlot
+ this.mode = this.layoutDescriptor.mode || "build"
+ }
+
+ /**
+ * Initializes layout by extracting grid template, slots, grid area, row heights, and column widths.
+ * If slots are provided, gridArea and gridTemplate is generated using optional rowHeights and colWidths
+ * If gridTemplate is provided, slots are generated using it
+ * If gridSpecs is provided, slots are generated using it
+ * Final outcome: Given optional slots, optional gridTemplate, optional gridSpecs, optional rowHeights, optional colWidths;
+ * generated gridTemplate, generated gridArea, generated slots, generated rowHeights, generated colWidths
+ *
+ * This function is mainly for a clean layout that doesn't have any content in it except basic row and column specifications,
+ * though it still needs to be called for generating the internal gridArea property which in turn is used for generating gridTemplate
+ * and gridToolbarSpecs.
+ *
+ * If all of slots, gridTemplate and gridSpecs are provided, slots take precedence followed by gridTemplate and then gridSpecs
+ * If rowHeights and colWidths are provided alongwith gridTemplate, rowHeights and colWidths take precedence
+ */
+ private initLayout(): void {
+ if (!this.slots || this.slots.length === 0) {
+ if (this.gridTemplate) {
+ const parsedTemplate = this.GTtoS()
+ this.slots = parsedTemplate.slots
+ this.rowHeights = this.rowHeights || parsedTemplate.rowHeights
+ this.colWidths = this.colWidths || parsedTemplate.colWidths
+ } else if (this.gridSpecs) {
+ this.slots = this.GStoS()
+ } else {
+ this.slots = []
+ }
+ }
+ const gridAreaRowHeights = this.StoGA()
+ this.gridArea = gridAreaRowHeights.gridArea
+ this.rowHeights = gridAreaRowHeights.rowHeights
+ if (!this.gridTemplate) {
+ this.gridTemplate = this.GAtoGT(
+ this.gridArea,
+ this.rowHeights,
+ this.colWidths,
+ )
+ }
+ this.historyService?.set(this.slots)
+ this.rerender()
+ if (this.mode === "build") this.updateGTS()
+ }
+
+ /**
+ * Updates the slots and optionally skips history.
+ * This function and its callers are all supposed to be used only in build mode.
+ * Code internal to Layout ensures the same.
+ *
+ * @param slots - An array of slot descriptors.
+ * @param skipHistory - A boolean to indicate whether to skip history.
+ * @param enforceGTSUpdate - A boolean to enforce grid toolbar specs update.
+ */
+ updateSlots(
+ slots: SlotDescriptor[],
+ skipHistory?: boolean,
+ enforceGTSUpdate?: boolean,
+ ): void {
+ const lengthChanged = this.slots.length !== slots.length
+ if (this.slots !== slots) {
+ this.slots = slots as any[]
+ if (lengthChanged) {
+ this.StoGA()
+ this.resetRowToolbarHeights()
+ // Lifecycle guarantee: StoGA() sets gridArea & rowHeights before GAtoGT.
+ // colWidths may be undefined for layouts initialized via gridSpecs/slots; GAtoGT internally defaults missing columns to 1fr.
+ this.gridTemplate = this.GAtoGT(
+ this.gridArea!,
+ this.rowHeights!,
+ this.colWidths,
+ )
+ }
+ this.mergeEnabled = this.checkIfAdjacent()
+ this.splitEnabled = slots.filter((slot) => slot.isSelected).length >= 1
+ this.historyService?.set(SlotDescriptor.clone(slots), !!skipHistory)
+ this.rerender()
+ if (lengthChanged || enforceGTSUpdate) this.updateGTS()
+ }
+ }
+
+ /**
+ * Wrapping Grid toolbar specs generator around timeout and animation frame requester
+ * since GTAtoGTS performs DOM operations to calculate row heights, and
+ * should be called after the slots have been updated and component has re-rendered.
+ */
+ updateGTS(): void {
+ setTimeout(
+ () =>
+ requestAnimationFrame(() => {
+ this.SGAtoGTS()
+ this.rerender()
+ }),
+ 10,
+ )
+ }
+
+ /**
+ * Convert grid area, slots and row heights to grid toolbar specs
+ * Grid toolbars (row and column slots) get messed up when size of cells in their corresponding rows/columns change
+ * due to content, this function is to calculate the row heights and column widths, based on common height of row and column widths
+ * after content has been added
+ * This function relies on grid area generated and saved in Layout service, and doesn't accept
+ * explicit passed grid area for the sake of simplicity. It generates row tools grid area and column tools grid area
+ * and uses those to generate their corresponding grid templates.
+ *
+ * !IMPORTANT: This function performs a DOM operation to calculate row heights, so it should be called after the slots have been updated
+ * and component has re-rendered.
+ *
+ * ***Calling this function directly may result in incorrect row heights.***
+ *
+ * @param gridArea Grid Area of row tools
+ * @returns grid toolbar specs
+ * @sets grid toolbar specs
+ */
+ private SGAtoGTS(): IGridToolbarSpecs {
+ const gridArea = this.gridArea!
+ const rowHeights = this.calculateRowHeights()
+ const rowToolGridAreas = gridArea.map((row: any[]) => [
+ row.every((cell) => cell === row[0])
+ ? row[0].replace("gsa-", "gtra-")
+ : "gtra-" + uuidv4(),
+ ])
+ const colToolGridAreas = gridArea[0].map((cell: any, colIdx: number) =>
+ gridArea.every((row: any[]) => row[colIdx] === gridArea[0][colIdx])
+ ? gridArea[0][colIdx].replace("gsa-", "gtca-")
+ : "gtca-" + uuidv4(),
+ )
+ const rowToolSlots = LayoutService.countOccurrences(
+ rowToolGridAreas.map((row: any[]) => row[0]),
+ ).map((occurrence: { slotId: string; span: number; location: number }) =>
+ SlotDescriptor.create({
+ slot: occurrence.slotId,
+ gridArea: occurrence.slotId,
+ row: occurrence.location,
+ column: 0,
+ rowSpan: occurrence.span,
+ colSpan: 1,
+ props: { style: { gridArea: occurrence.slotId } },
+ }),
+ )
+ const colToolSlots = LayoutService.countOccurrences(colToolGridAreas).map(
+ (occurrence) =>
+ SlotDescriptor.create({
+ slot: occurrence.slotId,
+ gridArea: occurrence.slotId,
+ row: 0,
+ column: occurrence.location,
+ rowSpan: 1,
+ colSpan: occurrence.span,
+ props: { style: { gridArea: occurrence.slotId } },
+ }),
+ )
+ this.gridToolbarSpecs = {
+ rowTools: {
+ slots: rowToolSlots,
+ gridTemplate: this.GAtoGT(
+ rowToolGridAreas,
+ rowHeights || "minmax(auto, 1fr)",
+ this.colWidths || "1fr",
+ ),
+ },
+ colTools: {
+ slots: colToolSlots,
+ gridTemplate: this.GAtoGT([colToolGridAreas], "1fr", "1fr"),
+ },
+ }
+ return this.gridToolbarSpecs
+ }
+
+ /**
+ * Converts slots and row heights to grid area and processed row heights
+ * @param currentRowHeights
+ * @returns
+ */
+ StoGA(): { gridArea: string[][]; rowHeights: string[] } {
+ const initialRowHeights = this.layoutDescriptor.rowHeights
+ let gridArea: any[] = []
+ const processedRowHeights: any[] = []
+ this.slots.forEach((slot: any) => {
+ let rowEnd = slot.row + slot.rowSpan,
+ colEnd = slot.column + slot.colSpan
+ for (let rowIdx = slot.row; rowIdx < rowEnd; rowIdx++) {
+ const rowHeight = Array.isArray(initialRowHeights)
+ ? initialRowHeights[rowIdx]
+ : initialRowHeights
+ slot.content
+ ? (processedRowHeights[rowIdx] = rowHeight || "auto")
+ : (processedRowHeights[rowIdx] = "minmax(2rem, auto)")
+ gridArea[rowIdx] || (gridArea[rowIdx] = [])
+ for (let colIdx = slot.column; colIdx < colEnd; colIdx++)
+ gridArea[rowIdx][colIdx] = slot.gridArea
+ }
+ })
+ this.gridArea = gridArea
+ this.rowHeights = processedRowHeights
+ return { gridArea, rowHeights: processedRowHeights }
+ }
+
+ /**
+ * Converts grid specs to slot descriptors.
+ * @returns Array of SlotDescriptor
+ */
+ GStoS(): SlotDescriptor[] {
+ if (typeof this.gridSpecs === "string") {
+ try {
+ this.gridSpecs = JSON.parse(this.gridSpecs)
+ } catch {
+ console.error(
+ "[COMPONENT:LAYOUT] Grid Specs passed as JSON string but incorrect non-parseable format",
+ )
+ throw new LayoutError(
+ "Invalid grid specs string",
+ LayoutErrors.MALFORMED_GRID_SPECS,
+ )
+ }
+ }
+ if (
+ typeof this.gridSpecs === "object" &&
+ "rows" in this.gridSpecs &&
+ "columns" in this.gridSpecs
+ ) {
+ const slotDescriptors: any[] = []
+ for (let rowIdx = 0; rowIdx < this.gridSpecs.rows; rowIdx++) {
+ for (let colIdx = 0; colIdx < this.gridSpecs.columns; colIdx++) {
+ const slotId = `gsa-${uuidv4()}`
+ slotDescriptors.push(
+ SlotDescriptor.create({
+ slot: slotId,
+ row: rowIdx,
+ column: colIdx,
+ rowSpan: 1,
+ colSpan: 1,
+ gridArea: slotId,
+ }),
+ )
+ }
+ }
+ return slotDescriptors
+ }
+ console.error(
+ "[COMPONENT:LAYOUT] Grid Specs are malformed, should be an object with rows and columns",
+ )
+ throw new LayoutError(
+ "Invalid grid specs",
+ LayoutErrors.MALFORMED_GRID_SPECS,
+ )
+ }
+
+ /**
+ * Converts grid area, row heights and column widths to grid template
+ * Row Heights at this point are the final processed row heights, and calculated row heights in case of
+ * grid templates for toolbars
+ * Calling function should pass gridArea always, not rely on this.gridArea since gridArea is different for main grid
+ * and for toolbar grids, passing explicitly distinguishes between the two
+ *
+ * @param gridArea
+ * @param rowHeight
+ * @param colWidth
+ * @returns grid template string
+ */
+ private GAtoGT(
+ gridArea: string[][],
+ rowHeights?: string[] | string,
+ colWidths?: string[] | string,
+ ): string {
+ let gridTemplate = gridArea
+ .map(
+ (row, rowIdx) =>
+ `"${row.join(" ")}" ${
+ Array.isArray(rowHeights)
+ ? rowHeights[rowIdx] || "auto"
+ : rowHeights || "minmax(3rem, 1fr)"
+ }`,
+ )
+ .join(" ")
+ const colTemplate =
+ " / " +
+ (Array.isArray(colWidths)
+ ? colWidths.join(" ")
+ : Array.from(
+ { length: gridArea[0].length },
+ () => colWidths || "1fr",
+ ).join(" "))
+ gridTemplate += colTemplate
+ const trimmedTemplate = gridTemplate.trim()
+ if (this.onLayoutChange) this.onLayoutChange(this.gridTemplate, this.slots)
+ else
+ console.warn(
+ "Missing callback onLayoutChange, you may want to pass it as a prop to Layout component in order to capture the updated layout and use it.",
+ )
+ return trimmedTemplate
+ }
+
+ /**
+ * Parse slots, row heights and column widths from grid template.
+ *
+ * This grid template parser is currently best effort basis and may not support some edge cases, it does
+ * support calc(), auto, fr, px, rem, %, minmax(), repeat() and other CSS grid properties, but has not been
+ * tested extensively against all possible grid templates.
+ *
+ * Example grid template:
+ * "ga-1 ga-1 ga-2" 1fr
+ * "ga-3 ga-4 ga-5" auto
+ * "ga-6 ga-7 ga-7" 1fr
+ * / 1fr 1fr 1fr
+ *
+ * @param gridTemplate
+ * @returns
+ */
+ GTtoS(): {
+ slots: SlotDescriptor[]
+ rowHeights: string[]
+ colWidths: string[]
+ } {
+ const gridTemplateParts = this.gridTemplate
+ .split('"')
+ .map((part: string) => part.trim())
+ .filter((part: string) => part)
+ let colWidths: any[] = []
+ const rowHeightsMap: any = {}
+ gridTemplateParts.forEach((part: string, idx: number) => {
+ if (part.startsWith("ga-")) rowHeightsMap[part] = "auto"
+ else if (part.includes("calc(")) {
+ const closeIdx = part.lastIndexOf(")"),
+ heightValue = part.substring(0, closeIdx + 1)
+ rowHeightsMap[gridTemplateParts[idx - 1]] = heightValue
+ const afterCalc = part.substring(closeIdx + 1).trim()
+ if (afterCalc.includes("/"))
+ colWidths = afterCalc
+ .split("/")[1]
+ .trim()
+ .split(" ")
+ .map((width: string) => width.trim())
+ } else {
+ rowHeightsMap[gridTemplateParts[idx - 1]] = part
+ }
+ })
+ const rowHeights = Object.values(rowHeightsMap) as string[]
+ return {
+ slots: LayoutService.generateSlots(Object.keys(rowHeightsMap)),
+ rowHeights,
+ colWidths: colWidths || [],
+ }
+ }
+
+ /**
+ * Generates slot descriptors from grid template rows.
+ * Rows is an Array of strings extracted from grid template after left after parsing out
+ * row heights and column widths
+ * Example:
+ * "ga-1 ga-1 ga-2" 1fr
+ * "ga-3 ga-4 ga-5" auto
+ * "ga-6 ga-7 ga-7" 1fr
+ * @param rows
+ * @returns Array of SlotDescriptor
+ */
+ static generateSlots(rows: string[]): SlotDescriptor[] {
+ const slotDescriptors: any[] = []
+ const seenSlots = new Set()
+ rows.forEach((rowString, rowIdx) => {
+ const slotNames = rowString.split(" ")
+ slotNames.forEach((slotName, colIdx) => {
+ const trimmedSlot = slotName.trim()
+ if (!seenSlots.has(trimmedSlot)) {
+ seenSlots.add(trimmedSlot)
+ const rowSpan = rows.filter((row) => row.includes(trimmedSlot)).length
+ const colSpan = slotNames.filter(
+ (name) => name === trimmedSlot,
+ ).length
+ slotDescriptors.push(
+ SlotDescriptor.create({
+ slot: trimmedSlot,
+ gridArea: trimmedSlot,
+ row: rowIdx,
+ column: colIdx,
+ rowSpan,
+ colSpan,
+ }),
+ )
+ }
+ })
+ })
+ return slotDescriptors
+ }
+
+ /**
+ * Checks if selected slots are adjacent.
+ * @returns true if adjacent, false otherwise
+ */
+ checkIfAdjacent(): boolean {
+ const selectedSlots = this.slots.filter((slot: any) => slot.isSelected)
+ let minRow = Infinity,
+ maxRow = -Infinity,
+ minCol = Infinity,
+ maxCol = -Infinity,
+ totalSpan = 0
+ selectedSlots?.forEach((slot: any) => {
+ if (slot.row !== undefined && slot.column !== undefined) {
+ minRow = Math.min(minRow, slot.row)
+ maxRow = Math.max(maxRow, slot.row + (slot.rowSpan - 1))
+ minCol = Math.min(minCol, slot.column)
+ maxCol = Math.max(maxCol, slot.column + (slot.colSpan - 1))
+ totalSpan += slot.rowSpan * slot.colSpan
+ }
+ })
+ const area = (maxRow - minRow + 1) * (maxCol - minCol + 1)
+ this.minMaxSelections = {
+ minRow,
+ maxRow,
+ minColumn: minCol,
+ maxColumn: maxCol,
+ }
+ return selectedSlots && selectedSlots.length > 1 && area === totalSpan
+ }
+
+ /**
+ * Counts the occurrences of each unique string in the given array and returns an array of objects
+ * representing each unique string, its count (span), and its first occurrence index (location).
+ *
+ * @param array - The array of strings to count occurrences in.
+ * @returns An array of objects, each containing:
+ * - `slotId`: The unique string from the input array.
+ * - `span`: The number of times the string appears in the input array.
+ * - `location`: The index of the first occurrence of the string in the input array.
+ */
+ static countOccurrences(
+ array: string[],
+ ): { slotId: string; span: number; location: number }[] {
+ const occurrences: { slotId: string; span: number; location: number }[] = []
+ for (let idx = 0; idx < array.length; idx++) {
+ const slotId = array[idx]
+ const found = occurrences.find((entry) => entry.slotId === slotId)
+ if (found === undefined)
+ occurrences.push({ slotId, span: 1, location: idx })
+ else found.span++
+ }
+ return occurrences
+ }
+
+ /**
+ * Resets the heights of the grid template rows of the row tools container to their default values: "minmax(2rem, auto)".
+ * This function is used to ensure that the row toolbar heights are consistent and do not interfere with the layout of the grid.
+ * This function is called when the layout is updated (layoutService.updateSlots) and ensures that the row toolbar heights
+ * do not influence heights of the main layout grid cells.
+ */
+ resetRowToolbarHeights(): void {
+ if (!this.gridArea) {
+ console.warn(
+ "Grid area is not defined. Cannot reset row toolbar heights.",
+ )
+ return
+ }
+ const defaultRowHeights = Array.from(
+ { length: this.gridArea.length },
+ () => "minmax(2rem, auto)",
+ )
+ if (this.gridToolbarSpecs && this.gridToolbarSpecs.rowTools) {
+ this.gridToolbarSpecs.rowTools.gridTemplate = this.GAtoGT(
+ this.gridArea.map((row: any[]) => [
+ row.every((cell: any) => cell === row[0])
+ ? row[0].replace("gsa-", "gtra-")
+ : "gtra-" + uuidv4(),
+ ]),
+ defaultRowHeights,
+ "1fr",
+ )
+ }
+ }
+
+ /**
+ * Calculates row heights based on slots and optional row heights.
+ * @param slots - An array of slot descriptors.
+ * @param rowHeights - An optional array or string of row heights.
+ * @param demo - A boolean to indicate whether it is a demo.
+ * @returns An array of calculated row heights.
+ */
+ calculateRowHeights(): string[] {
+ const slots = this.slots
+ const existing = this.rowHeights
+ const maxRowIdx = Math.max(
+ ...slots.map((slot: any) => slot.row + slot.rowSpan),
+ )
+ const baseArray: string[] = Array.isArray(existing)
+ ? [...existing]
+ : Array.from(
+ { length: maxRowIdx },
+ () => existing || "minmax(2rem, auto)",
+ )
+ const calculatedRowHeights: string[] = []
+ for (let rowIdx = 0; rowIdx < maxRowIdx; rowIdx++) {
+ const current = baseArray[rowIdx]
+ if (current && current !== "" && current.indexOf("auto") === -1) {
+ calculatedRowHeights[rowIdx] = current
+ continue
+ }
+ const slotHeights = slots
+ .filter(
+ (slot: any) => slot.row <= rowIdx && rowIdx < slot.row + slot.rowSpan,
+ )
+ .map((slot: any) => {
+ const element = document.getElementById(slot.slot)
+ return element
+ ? Math.floor(element.getBoundingClientRect().height / slot.rowSpan)
+ : 0
+ })
+ const maxHeight = Math.max(...slotHeights)
+ calculatedRowHeights[rowIdx] =
+ maxHeight === 0 ? "minmax(2rem, auto)" : `${maxHeight + 2}px`
+ }
+ return calculatedRowHeights
+ }
+}
+
+export default LayoutService
diff --git a/src/services/index.ts b/src/services/index.ts
new file mode 100644
index 0000000..ae075f4
--- /dev/null
+++ b/src/services/index.ts
@@ -0,0 +1,3 @@
+export { default as LayoutService } from "./Layout.service"
+export { default as EventsService } from "./Events.service"
+export { default as HistoryService } from "./History.service"
diff --git a/src/test.scss b/src/test.scss
new file mode 100644
index 0000000..dea6ba7
--- /dev/null
+++ b/src/test.scss
@@ -0,0 +1,2 @@
+@use "bootstrap/scss/bootstrap.scss";
+@use "@armco/shared-components/es/assets/Avatar.css";
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..e3cf5d7
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,222 @@
+// Recovered subset of Layout types (trimmed for initial compilation). Expand as needed.
+
+import type { ReactNode, RefObject } from "react"
+import { BaseProps, FunctionType } from "@armco/types"
+import type {
+ ArComponentResources,
+ ArDirections,
+ ArDisplayTypes,
+ ArSlotViewMode,
+} from "@armco/shared-components/enums"
+import { EventsService, HistoryService, LayoutService } from "@services"
+import { SlotDescriptor } from "@models"
+
+export interface ISlotDescriptor {
+ colSpan: number
+ column: number
+ content?: Array | IComponentInfo
+ isSelected?: boolean
+ isSelectedForInlineDelete?: boolean
+ gridArea: string
+ props?: {
+ containerDisplayMode?: LayoutProps["displayMode"]
+ style?: React.CSSProperties
+ [key: string]: any
+ }
+ row: number
+ rowSpan: number
+ slot: string
+ splitFrom?: string
+}
+
+export interface IGridSpecs {
+ rows: number
+ columns: number
+}
+
+export interface IGridToolbarSpecs {
+ rowTools: {
+ slots: Array
+ gridTemplate: string
+ }
+ colTools: {
+ slots: Array
+ gridTemplate: string
+ }
+}
+
+export interface IComponentInfo {
+ componentName: string
+ source?: ArComponentResources
+ descriptor?: {
+ content: Array
+ layout: string
+ }
+ props?: {
+ [key: string]: any
+ }
+ [key: string]: any
+}
+
+export interface IMinMaxSelections {
+ minRow: number
+ maxRow: number
+ minColumn: number
+ maxColumn: number
+}
+
+export type LayoutMode = "build" | "preview" | "release"
+
+export interface ReleaseSlotProps extends BaseProps {
+ slotDescriptor: SlotDescriptor
+ containerDisplayMode?: SlotProps["containerDisplayMode"]
+ content?: ReactNode
+}
+
+export interface BuilderLayoutCanvasProps {
+ children?: ReactNode
+ isChild?: boolean
+ isGrid?: boolean
+}
+
+export interface BuilderLayoutContainerProps {
+ children?: ReactNode
+ classes?: string
+ panelsDisplayable?: boolean
+}
+export interface BuilderSlotProps extends SlotProps {}
+
+export interface SlotToolsProps extends BaseProps {
+ slotDescriptor: SlotDescriptor
+ mode?: ArSlotViewMode
+ controlsEnabled?: boolean
+ horizontalSplitHandler?: FunctionType
+ verticalSplitHandler?: FunctionType
+ mergeHandler?: FunctionType
+ rowInsertHandler?: FunctionType
+ columnInsertHandler?: FunctionType
+ rowDeleteHandler?: FunctionType
+ columnDeleteHandler?: FunctionType
+ rowSelectHandler?: FunctionType
+ columnSelectHandler?: FunctionType
+}
+
+export interface ReleaseLayoutProps extends BaseProps {
+ displayMode?: LayoutProps["displayMode"]
+ gridTemplate?: string
+ slots?: Array
+ slotRenderer?: FunctionType
+ mode?: LayoutProps["mode"]
+}
+
+export type BuilderLayoutProps = LayoutProps
+
+export interface SlotControlProps {
+ class: string
+ tooltip?: string
+ isGridOnly?: boolean
+ isToolSlotOnly?: boolean
+ onClick?: FunctionType
+ onMouseEnter?: FunctionType
+ onMouseLeave?: FunctionType
+}
+
+export interface LayoutControlPanelProps extends BaseProps {
+ columnDeleteEnabled?: boolean
+ redoEnabled?: boolean
+ rowDeleteEnabled?: boolean
+ show?: boolean
+ slots?: Array
+ undoEnabled?: boolean
+}
+
+export interface ResizableProps extends BaseProps {
+ componentName?: string
+ children?: ReactNode
+ display?: ArDisplayTypes
+ directions?: Array
+ reClasses?: string
+ [key: string]: any
+}
+
+export interface LayoutProps extends BaseProps {
+ acceptTextOnClick?: boolean
+ colWidths?: string | Array
+ displayMode?:
+ | "grid"
+ | "inline"
+ | "inline-flex"
+ | "inline-grid"
+ | "flex"
+ | "inline-block"
+ | "block"
+ | "table"
+ hideBuildModePaddings?: boolean
+ gridSpecs?: string | IGridSpecs
+ gridTemplate?: string
+ isChild?: boolean
+ activeSlot?: SlotDescriptor
+ readonly mode?: LayoutMode
+ rowHeights?: string | Array
+ onLayoutChange?: FunctionType
+ slotDropHandler?: FunctionType
+ onSlotSelect?: FunctionType
+ showControls?: boolean
+ showPanels?: boolean
+ slotRenderer?: FunctionType
+ slots?: Array
+ url?: string
+}
+
+export interface SlotProps extends Omit {
+ childRef?: RefObject
+ slotDescriptor: SlotDescriptor
+ containerDisplayMode?: LayoutProps["displayMode"]
+ hideBuildModePaddings?: boolean
+ isFirst?: boolean
+ isLast?: boolean
+ activeSlot?: SlotDescriptor
+ mode?: ArSlotViewMode
+ controlsEnabled?: boolean
+ acceptDropTypes?: Array
+ layoutMode?: LayoutProps["mode"]
+ content?: ReactNode
+ columnDeleteHandler?: FunctionType
+ columnInsertHandler?: FunctionType
+ columnSelectHandler?: FunctionType
+ dropHandler?: FunctionType
+ horizontalSplitHandler?: FunctionType
+ mergeHandler?: FunctionType
+ onClick?: FunctionType
+ onSlotSelect?: FunctionType
+ rowDeleteHandler?: FunctionType
+ rowInsertHandler?: FunctionType
+ rowSelectHandler?: FunctionType
+ verticalSplitHandler?: FunctionType
+ slotRenderer?: FunctionType
+}
+
+export type UseLayoutInitProps = LayoutProps & {
+ rerender: FunctionType
+}
+
+export interface LayoutContextType {
+ layoutService: LayoutService
+ eventsService: EventsService
+ historyService: HistoryService>
+ controlsEnabled: boolean
+ enableControls: FunctionType
+ selectionOnlyModeEnabled: boolean
+ enableSelectionOnlyMode: FunctionType
+ panelsDisplayed: boolean
+ displayPanels: FunctionType
+}
+
+export interface LayoutProviderProps {
+ children: ReactNode
+ layoutService: LayoutService
+ eventsService: EventsService
+ historyService: HistoryService>
+ showPanels?: boolean
+ showControls?: boolean
+}
diff --git a/src/utils.ts b/src/utils.ts
new file mode 100644
index 0000000..e69de29
diff --git a/tsconfig.json b/tsconfig.json
index a951d6b..a8503e0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -6,6 +6,7 @@
"dom.iterable",
"esnext"
],
+ "baseUrl": ".",
"outDir": "build",
"allowJs": true,
"skipLibCheck": true,
@@ -19,7 +20,14 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
- "jsx": "react-jsx"
+ "jsx": "react-jsx",
+ "paths": {
+ "@models": ["src/models/index.ts"],
+ "@models/*": ["src/models/*"],
+ "@hooks/*": ["src/hooks/*"],
+ "@services": ["src/services/index.ts"],
+ "@services/*": ["src/services/*"],
+ }
},
"include": [
"src"
diff --git a/vite-dev.config.ts b/vite-dev.config.ts
new file mode 100644
index 0000000..0da994b
--- /dev/null
+++ b/vite-dev.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from "vitest/config"
+import react from "@vitejs/plugin-react"
+import tsconfigPaths from "vite-tsconfig-paths"
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react(), tsconfigPaths()],
+ server: {
+ open: true,
+ },
+ build: {
+ outDir: "build",
+ sourcemap: true,
+ },
+ test: {
+ globals: true,
+ environment: "jsdom",
+ setupFiles: "src/setupTests",
+ mockReset: true,
+ },
+})
diff --git a/vite.config.ts b/vite.config.ts
index 06192d7..7ed5185 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -5,12 +5,14 @@ import react from "@vitejs/plugin-react"
import dts from "vite-plugin-dts"
import { libInjectCss } from "vite-plugin-lib-inject-css"
import { externalizeDeps } from "vite-plugin-externalize-deps"
+import tsconfigPaths from "vite-tsconfig-paths"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
libInjectCss(),
+ tsconfigPaths(),
dts({ outDir: "build/types" }),
externalizeDeps(),
],