Recovered lost changes that restructured and better organized Layout component
Some checks failed
armco-org/Layout/pipeline/head There was a failure building this commit

This commit is contained in:
2025-11-13 14:59:41 +05:30
parent e84a0498fd
commit 0caeac2dc2
41 changed files with 4689 additions and 3028 deletions

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en" ar-theme="th-light-1">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Redux App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/Test.tsx"></script>
</body>
</html>

2566
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"module": "build/es/index.js",
"types": "build/types/index.d.ts",
"scripts": {
"dev": "vite --config vite-dev.config.ts",
"build": "./build-tools/build.sh",
"format": "prettier --write .",
"lint": "eslint .",
@@ -13,15 +14,16 @@
"publish:local": "./publish-local.sh"
},
"dependencies": {
"@armco/icon": "^0.0.6",
"@armco/shared-components": "^0.0.53",
"@armco/types": "^0.0.11",
"@armco/utils": "^0.0.18",
"@armco/icon": "^0.0.13",
"@armco/shared-components": "^0.0.60",
"@armco/utils": "^0.0.31",
"bootstrap": "^5.3.8",
"react": ">16.8.0",
"react-dom": ">16.8.0",
"uuid": "^10.0.0"
"uuid": "^9.0.1"
},
"devDependencies": {
"@armco/types": "^0.0.22",
"@testing-library/dom": "^9.2.0",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^14.0.0",
@@ -30,7 +32,7 @@
"@types/react-dom": "^18.0.6",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.0.0",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^8.0.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^4.2.1",
@@ -40,12 +42,13 @@
"prettier-config-nick": "^1.0.2",
"sass": "^1.63.4",
"typescript": "^5.0.2",
"vite": "^4.0.0",
"vite": "^7.2.2",
"vite-plugin-dts": "^4.2.1",
"vite-plugin-externalize-deps": "^0.8.0",
"vite-plugin-externalize-deps": "^0.10.0",
"vite-plugin-lib-inject-css": "^2.1.1",
"vite-plugin-svgr": "^4.2.0",
"vitest": "^0.30.1"
"vite-plugin-svgr": "^4.5.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.8"
},
"eslintConfig": {
"extends": [

119
src/BuilderLayout.component.scss Executable file → Normal file
View File

@@ -1,55 +1,68 @@
.ar-BuilderLayout {
&.hide-controls > .ar-Layout__canvas > .ar-Layout__grid-tools {
grid-template: "grid" auto / auto;
}
.ar-Layout__help-panel {
transition: all 0.5s;
max-height: 0;
opacity: 0;
&.show {
max-height: 2rem;
opacity: 1;
background-color: var(--ar-color-selected);
}
}
.ar-BuilderLayout.hide-controls {
> .ar-Layout__canvas > .ar-Layout__grid-tools {
grid-template: "grid" auto/auto;
}
.ar-Layout__help-panel {
transition: all 0.5s;
max-height: 0;
opacity: 0;
&.show {
max-height: 2rem;
opacity: 1;
background-color: var(--ar-color-selected);
}
}
.ar-Layout__grid-tools {
grid-template: ". colMod" 1.5rem "rowMod grid" 1fr / 1.5rem auto;
.ar-Layout__row-tools {
grid-area: rowMod;
}
.ar-Layout__col-tools {
grid-area: colMod;
}
.ar-Layout__grid-tools__main, .ar-Layout__col-tools, .ar-Layout__row-tools {
background-color: var(--ar-bg);
}
.ar-Layout__grid {
grid-area: grid;
}
.row-controller-cell, .column-controller-cell {
.delete-row, .delete-column {
display: none;
}
.delete-row {
bottom: 0.25rem;
left: 0.2rem;
}
.delete-column {
right: 0.25rem;
top: 0.2rem;
}
&:hover {
background-color: var(--ar-bg-hover-4);
.delete-row, .delete-column {
display: inline-block;
}
}
&.selected {
background-color: #03a9f4;
}
}
}
grid-template: ". colMod" 1.5rem "rowMod grid" 1fr/1.5rem auto;
.ar-Layout__row-tools {
grid-area: rowMod;
}
.ar-Layout__col-tools {
grid-area: colMod;
}
.ar-Layout__grid-tools__main,
.ar-Layout__col-tools,
.ar-Layout__row-tools {
background-color: var(--ar-bg);
}
.ar-Layout__grid {
grid-area: grid;
}
.row-controller-cell {
.delete-row {
display: none;
}
&:hover {
background-color: var(--ar-bg-hover-4);
.delete-row {
display: inline-block;
}
}
&.selected {
background-color: #03a9f4;
}
}
.column-controller-cell {
.delete-column {
display: none;
}
&:hover {
background-color: var(--ar-bg-hover-4);
.delete-column {
display: inline-block;
}
}
&.selected {
background-color: #03a9f4;
}
}
.delete-row {
bottom: 0.25rem;
left: 0.2rem;
}
.delete-column {
right: 0.25rem;
top: 0.2rem;
}
}
}

826
src/BuilderLayout.tsx Executable file → Normal file
View File

@@ -1,778 +1,68 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { v4 as uuid } from "uuid"
import {
ArPopoverSlots,
ArSlotViewMode,
ArThemes,
BuilderLayoutProps,
GridToolbarSpecs,
SlotDescriptor,
SlotProps,
} from "@armco/types"
import Icon from "@armco/icon"
import Tooltip from "@armco/shared-components/Tooltip"
import { useTheme, useStateWithHistory } from "@armco/utils/hooks"
import { calculateRowHeights, checkIfAdjacent, generateGridAreaAndSizes, generateGridTemplate, generateGridToolbarSpecs, generateSlotConfigs, insertDimension, mergeHandler, removeHandler, selectCellsInSelectedRowCol, splitHandler } from "@armco/utils/gridHelper"
import { getDocumentElement, getWindowElement } from "@armco/utils/domHelper"
import { FC } from "react"
import { ArSlotViewMode } from "@armco/shared-components/enums"
import { SlotDescriptor } from "@models"
import { useLayoutContext } from "./Layout.context"
import type { LayoutProps } from "./types"
import BuilderLayoutContainer from "./BuilderLayoutContainer"
import BuilderLayoutCanvas from "./BuilderLayoutCanvas"
import Slot from "./Slot"
import LayoutControlPanel from "./LayoutControlPanel"
import "./BuilderLayout.component.scss"
const BuilderLayout = ({
acceptTextOnClick,
classes,
colWidths: externalColWidths,
demo,
displayMode,
hideBuildModePaddings,
isChild,
lastSelected: externalLastSelected,
mode,
slotDropHandler,
onLayoutChange,
onSlotSelect: onExternalSlotSelect,
gridSpecs,
gridTemplate: externalGridTemplate,
rowHeights: externalRowHeights,
showControls: externalShowControls,
showPanels,
slots: externalSlots,
slotRenderer,
}: BuilderLayoutProps) => {
const layoutRef = useRef<HTMLDivElement>(null)
const ctrlPanelRef = useRef<HTMLDivElement>(null)
const helpPanelRef = useRef<HTMLDivElement>(null)
const specUpdateAction = useRef<boolean>(true)
const [panelsDisplayed, displayPanels] = useState<boolean>()
const [rowHeights, setRowHeights] = useState<string | Array<string>>()
const [colWidths, setColWidths] = useState<string | Array<string>>()
const [showControls, setShowControls] = useState<boolean>()
const [gridArea, setGridArea] = useState<Array<Array<string>>>()
const [gridTemplate, setGridTemplate] = useState<string>()
const [gridToolbarSpecs, setGridToolbarSpecs] = useState<GridToolbarSpecs>()
const [slots, setSlots, undoSlots, redoSlots, canUndo, canRedo] =
useStateWithHistory<Array<SlotDescriptor>>()
const [prevSlotCount, setPrevSlotsCount] = useState<number>()
const [mergeEnabled, enableMerge] = useState<boolean>()
const [splitEnabled, enableSplit] = useState<boolean>()
const [lastSelected, setLastSelected] = useState<SlotDescriptor>()
const [selectionOnlyModeEnabled, enableSelectionOnlyMode] =
useState<boolean>()
const [minMaxSelections, setMinMaxSelections] = useState<{
minRow: number
maxRow: number
minColumn: number
maxColumn: number
}>()
const {theme} = useTheme()
const cmdCtrl = useMemo(
() => (
<>
<Icon icon="ai.AiFillMacCommand" attributes={{size: "0.8rem"}} /> /
<Icon icon="md.MdOutlineKeyboardControlKey" attributes={{size: "0.8rem"}} />
</>
),
[],
)
const isGrid = !displayMode || displayMode === "grid"
// Props specific to the BuilderLayout view extracted from runtime function 'k'
export interface BuilderLayoutProps extends Pick<LayoutProps, "classes" | "displayMode" | "hideBuildModePaddings" | "isChild" | "mode" | "slotDropHandler" | "onSlotSelect" | "slotRenderer"> { }
useEffect(() => {
const doc = getDocumentElement(demo)
const handleKeyDown = (event: KeyboardEvent) => {
if (showControls) {
if ((event.ctrlKey || event.metaKey) && event.key === "z") {
event.preventDefault()
undoSlots()
} else if ((event.ctrlKey || event.metaKey) && event.key === "y") {
event.preventDefault()
redoSlots()
} else if ((event.ctrlKey || event.metaKey) && event.key === "m") {
if (mergeEnabled && slots && minMaxSelections) {
mergeHandler(setSlots, slots, minMaxSelections)
}
} else if ((event.ctrlKey || event.metaKey) && event.key === "h") {
if (splitEnabled && slots) {
splitHandler(setSlots, slots, "horizontal", "after")
}
} else if ((event.ctrlKey || event.metaKey) && event.key === "v") {
if (splitEnabled && slots) {
splitHandler(setSlots, slots, "vertical", "after")
}
} else if ((event.ctrlKey || event.metaKey) && event.key === "r") {
if (splitEnabled && slots && gridToolbarSpecs) {
insertDimension(
"row",
gridToolbarSpecs,
slots,
setSlots,
"after",
)
}
} else if ((event.ctrlKey || event.metaKey) && event.key === "c") {
if (splitEnabled && slots && gridToolbarSpecs) {
insertDimension(
"column",
gridToolbarSpecs,
slots,
setSlots,
"after",
)
}
}
}
}
const BuilderLayout: FC<BuilderLayoutProps> = (props) => {
const {
classes,
displayMode,
hideBuildModePaddings,
isChild,
mode,
slotDropHandler,
onSlotSelect,
slotRenderer,
} = props
const { layoutService, controlsEnabled } = useLayoutContext()
doc?.addEventListener("keydown", handleKeyDown)
// From runtime: displayMode undefined or 'grid' => treat as grid layout
const isGridDisplay = !displayMode || displayMode === "grid"
const slots: SlotDescriptor[] | undefined = layoutService?.slots
const gridToolbarSpecs = layoutService?.gridToolbarSpecs
return () => {
doc?.removeEventListener("keydown", handleKeyDown)
}
}, [
demo,
gridToolbarSpecs,
mergeEnabled,
minMaxSelections,
redoSlots,
showControls,
slots,
splitEnabled,
undoSlots,
])
if (!slots || !gridToolbarSpecs) return null
useEffect(() => {
setRowHeights(externalRowHeights)
setColWidths(externalColWidths || "1fr")
}, [externalRowHeights, externalColWidths])
useEffect(() => {
displayPanels(showPanels)
}, [showPanels])
useEffect(() => {
setShowControls(externalShowControls)
}, [externalShowControls])
useEffect(() => setLastSelected(externalLastSelected), [externalLastSelected])
useEffect(() => {
if (externalSlots) {
JSON.stringify(externalSlots) !== JSON.stringify(slots) &&
setSlots(externalSlots)
} else if (externalGridTemplate) {
const { slotConfigs } =
generateSlotConfigs(externalGridTemplate)
setSlots(slotConfigs)
} else if (gridSpecs) {
let parsedGridSpecs = gridSpecs
if (typeof gridSpecs === "string") {
try {
parsedGridSpecs = JSON.parse(gridSpecs)
} catch {
console.warn("Grid Specs passed as string but incorrect JSON format")
return
}
}
if (
typeof parsedGridSpecs === "object" &&
"rows" in parsedGridSpecs &&
"columns" in parsedGridSpecs
) {
const slots: Array<SlotDescriptor> = []
for (let i = 0; i < parsedGridSpecs.rows; i++) {
for (let j = 0; j < parsedGridSpecs.columns; j++) {
const slotId = uuid()
const gridArea = `ga-${slotId}`
slots.push({
slot: slotId,
row: i,
column: j,
rowSpan: 1,
colSpan: 1,
gridArea,
})
}
}
setSlots(slots)
}
}
}, [gridSpecs, externalGridTemplate, externalSlots])
useEffect(() => {
if (slots) {
if (slots.length !== prevSlotCount) {
const { gridArea, rowHeights } = generateGridAreaAndSizes(
slots,
externalRowHeights,
)
setGridArea(gridArea)
setRowHeights(rowHeights)
setPrevSlotsCount(slots.length)
}
const { areAdjacent, ...rest } = checkIfAdjacent(slots)
setMinMaxSelections(rest)
enableSplit(slots.filter((sc) => sc.isSelected).length >= 1)
enableMerge(areAdjacent)
}
}, [slots, prevSlotCount, externalRowHeights])
useEffect(() => {
if (gridArea) {
const gridTemplate = generateGridTemplate(
gridArea,
rowHeights,
colWidths,
)
setGridTemplate(gridTemplate)
onLayoutChange && onLayoutChange(gridTemplate, slots)
}
}, [gridArea, rowHeights, colWidths])
useEffect(() => {
if (gridArea && showControls && slots) {
if (specUpdateAction.current) {
setTimeout(() => {
requestAnimationFrame(() => {
const calculatedRowHeights = calculateRowHeights(
slots,
rowHeights,
demo,
)
setGridToolbarSpecs(
generateGridToolbarSpecs(
gridArea,
calculatedRowHeights,
colWidths,
),
)
})
}, 10)
}
}
}, [slots, gridArea, showControls, rowHeights, colWidths])
useEffect(() => {
if (panelsDisplayed) {
const thisRect = layoutRef.current?.getBoundingClientRect()
if (thisRect) {
if (helpPanelRef.current) {
helpPanelRef.current.style.top = thisRect.bottom + "px"
helpPanelRef.current.style.left = thisRect.left + "px"
helpPanelRef.current.style.width = thisRect.width + "px"
}
if (ctrlPanelRef.current) {
const winObj = getWindowElement(demo)
ctrlPanelRef.current.style.bottom =
(winObj ? winObj.innerHeight - thisRect.top : 0) + "px"
ctrlPanelRef.current.style.left = thisRect.left + "px"
}
}
setTimeout(() => {
const ctrlPanelRect = ctrlPanelRef.current?.getBoundingClientRect()
if (thisRect && ctrlPanelRef.current && helpPanelRef.current) {
if (ctrlPanelRect && ctrlPanelRect.width > thisRect.width) {
ctrlPanelRef.current.style.width = thisRect.width + "px"
ctrlPanelRef.current.style.overflow = "auto"
}
}
}, 500)
}
}, [panelsDisplayed, showControls, demo])
const onSlotSelect = (slotConfig: SlotDescriptor) => {
const slotsClone = [...(slots || [])]
specUpdateAction.current = true
const matchedSlotConfig = slots?.find((sc) => sc.slot === slotConfig.slot)
matchedSlotConfig &&
(matchedSlotConfig.isSelected = !matchedSlotConfig.isSelected)
setLastSelected(matchedSlotConfig)
// Add Text in empty slot
if (acceptTextOnClick && matchedSlotConfig && !matchedSlotConfig?.content) {
if (!matchedSlotConfig.props) {
matchedSlotConfig.props = {}
}
// matchedSlotConfig.props.classes = "p-2"
matchedSlotConfig.content = {
name: "Text",
description:
"Foundational text component, that accepts classes and style attributes to create varying types of other text components",
source: "stuffle",
componentName: "Text",
props: {
classes: "w-100",
id: matchedSlotConfig.slot,
style: { minHeight: "1.5rem", lineHeight: "1.5rem" },
isEditable: true,
},
}
}
setSlots(slotsClone, true)
onExternalSlotSelect && onExternalSlotSelect(slotConfig)
}
const onRowSelect = (
slotConfig: SlotDescriptor,
// When cross on a row tool cell is clicked, this flag will be true
selectForDelete?: boolean,
) => {
specUpdateAction.current = false
if (gridToolbarSpecs) {
const rowSlots = gridToolbarSpecs.rowTools.slots
const selectedRow = rowSlots.find((slot) => slot.slot === slotConfig.slot)
if (selectedRow) {
if (selectForDelete !== undefined)
selectedRow.isSelectedForInlineDelete = selectForDelete
else selectedRow.isSelected = !selectedRow.isSelected
}
setGridToolbarSpecs({ ...gridToolbarSpecs })
slots &&
setTimeout(
() =>
setSlots(
selectCellsInSelectedRowCol(
gridToolbarSpecs,
slots,
selectForDelete,
),
true,
),
10,
)
}
}
const onColumnSelect = (
slotConfig: SlotDescriptor,
selectForDelete?: boolean,
) => {
specUpdateAction.current = false
if (gridToolbarSpecs) {
const colSlots = gridToolbarSpecs.colTools.slots
const selectedColumn = colSlots.find(
(slot) => slot.slot === slotConfig.slot,
)
if (selectedColumn) {
if (selectForDelete !== undefined)
selectedColumn.isSelectedForInlineDelete = selectForDelete
else selectedColumn.isSelected = !selectedColumn.isSelected
}
setGridToolbarSpecs({ ...gridToolbarSpecs })
slots &&
setSlots(
selectCellsInSelectedRowCol(
gridToolbarSpecs,
slots,
selectForDelete,
),
true,
)
}
}
const onRowDelete = (isInlineDelete?: boolean) => {
specUpdateAction.current = false
slots &&
gridToolbarSpecs &&
setSlots(
removeHandler(
slots,
gridToolbarSpecs,
"row",
isInlineDelete,
),
)
}
const onColumnDelete = (isInlineDelete?: boolean) => {
specUpdateAction.current = false
slots &&
gridToolbarSpecs &&
setSlots(
removeHandler(
slots,
gridToolbarSpecs,
"column",
isInlineDelete,
),
)
}
const rowToolSlots = gridToolbarSpecs?.rowTools.slots
const colToolSlots = gridToolbarSpecs?.colTools.slots
return (
<div
className={`ar-BuilderLayout mh-100 w-100 d-flex flex-column overflow-auto${
classes ? " " + classes : ""
}${showControls ? "" : " hide-controls"}`}
ref={layoutRef}
onMouseOver={(e) => {
e.stopPropagation()
!isChild && isGrid && displayPanels(true)
}}
onMouseLeave={(e) => {
e.stopPropagation()
!isChild && isGrid && displayPanels(false)
}}
>
{!isChild && isGrid && (
<LayoutControlPanel
classes="position-fixed"
demo={demo}
ref={ctrlPanelRef}
setShowControls={setShowControls}
showControls={showControls}
slots={slots}
show={panelsDisplayed}
splitEnabled={splitEnabled}
mergeEnabled={mergeEnabled}
undoEnabled={canUndo}
redoEnabled={canRedo}
selectionOnlyModeEnabled={selectionOnlyModeEnabled}
rowDeleteEnabled={
rowToolSlots && rowToolSlots.findIndex((s) => s.isSelected) > -1
}
columnDeleteEnabled={
colToolSlots && colToolSlots.findIndex((s) => s.isSelected) > -1
}
horizontalSplitHandler={() =>
slots &&
splitHandler(setSlots, slots, "horizontal", "after")
}
verticalSplitHandler={() =>
slots &&
splitHandler(setSlots, slots, "vertical", "after")
}
mergeHandler={() => {
slots &&
minMaxSelections &&
mergeHandler(setSlots, slots, minMaxSelections)
enableMerge(false)
}}
rowInsertHandler={(placement: "before" | "after") =>
slots &&
gridToolbarSpecs &&
insertDimension(
"row",
gridToolbarSpecs,
slots,
setSlots,
placement,
)
}
columnInsertHandler={(placement: "before" | "after") =>
slots &&
gridToolbarSpecs &&
insertDimension(
"column",
gridToolbarSpecs,
slots,
setSlots,
placement,
)
}
rowDeleteHandler={() => onRowDelete()}
columnDeleteHandler={() => onColumnDelete()}
undoHandler={undoSlots}
redoHandler={redoSlots}
enableSelectionOnlyMode={enableSelectionOnlyMode}
/>
)}
<div
className={`ar-Layout__canvas flex-1${
isChild && !hideBuildModePaddings ? " p-1" : ""
}`}
>
{slots && (
<div className="ar-Layout__grid-tools d-grid h-100">
{showControls && !isChild && isGrid && (
<>
<div className="ar-Layout__grid-tools__main border" />
<div
className="ar-Layout__row-tools d-grid"
style={{
gridTemplate: gridToolbarSpecs?.rowTools.gridTemplate,
}}
>
{gridToolbarSpecs?.rowTools.slots.map(
(slotConfig, index, arr) => (
<span
className={`row-controller-cell position-relative border${
slotConfig.isSelected ? " selected" : ""
}`}
style={{ gridArea: slotConfig.gridArea }}
>
<Slot
demo={demo}
mode={ArSlotViewMode.ROWCONTROLLER}
config={{
...slotConfig,
props: {
...(slotConfig.props || {}),
classes: "h-100",
},
}}
key={"controller-" + slotConfig.slot}
onClick={(slotConfig: SlotDescriptor) => {
onRowSelect(slotConfig)
onExternalSlotSelect &&
onExternalSlotSelect(slotConfig, "row")
}}
controlsEnabled={showControls}
selectionOnlyModeEnabled={selectionOnlyModeEnabled}
isLast={index === arr.length - 1}
rowDeleteHandler={() => onRowDelete(true)}
rowInsertHandler={(placement: "before" | "after") =>
slots &&
gridToolbarSpecs &&
insertDimension(
"row",
gridToolbarSpecs,
slots,
setSlots,
placement,
)
}
rowSelectHandler={(isEntering: boolean) =>
onRowSelect(slotConfig, isEntering)
}
/>
</span>
),
)}
</div>
<div
className="ar-Layout__col-tools d-grid"
style={{
gridTemplate: gridToolbarSpecs?.colTools.gridTemplate,
}}
>
{gridToolbarSpecs?.colTools.slots.map(
(slotConfig, index, arr) => (
<span
className={`column-controller-cell position-relative border${
slotConfig.isSelected ? " selected" : ""
}`}
style={{ gridArea: slotConfig.gridArea }}
>
<Slot
demo={demo}
mode={ArSlotViewMode.COLCONTROLLER}
config={{
...slotConfig,
props: {
...(slotConfig.props || {}),
classes: "h-100",
},
}}
key={"controller-" + slotConfig.slot}
onClick={(slotConfig: SlotDescriptor) => {
onColumnSelect(slotConfig)
onExternalSlotSelect &&
onExternalSlotSelect(slotConfig, "column")
}}
controlsEnabled={showControls}
selectionOnlyModeEnabled={selectionOnlyModeEnabled}
isLast={index === arr.length - 1}
columnDeleteHandler={() => onColumnDelete(true)}
columnInsertHandler={(
placement: "before" | "after",
) =>
slots &&
gridToolbarSpecs &&
insertDimension(
"column",
gridToolbarSpecs,
slots,
setSlots,
placement,
)
}
columnSelectHandler={(isEntering: boolean) =>
onColumnSelect(slotConfig, isEntering)
}
/>
</span>
),
)}
</div>
</>
)}
<div
className={`ar-Layout__grid border${isGrid ? " d-grid" : ""}${
displayMode ? " d-" + displayMode : ""
}`}
style={gridTemplate ? { gridTemplate } : {}}
>
{slots.map((sConfig) => {
const props: SlotProps = {
demo,
config: sConfig,
containerDisplayMode: displayMode,
controlsEnabled: showControls,
hideBuildModePaddings,
lastSelected,
layoutMode: mode,
mode: ArSlotViewMode.BUILD,
onClick: onSlotSelect,
selectionOnlyModeEnabled,
horizontalSplitHandler: (placement: "before" | "after") =>
slots &&
splitHandler(
setSlots,
slots,
"horizontal",
placement,
sConfig,
),
verticalSplitHandler: (placement: "before" | "after") =>
slots &&
splitHandler(
setSlots,
slots,
"vertical",
placement,
sConfig,
),
mergeHandler: () => {
slots &&
minMaxSelections &&
mergeEnabled &&
mergeHandler(
setSlots,
slots,
minMaxSelections,
sConfig,
)
enableMerge(false)
},
dropHandler: slotDropHandler,
onSlotSelect: onExternalSlotSelect,
}
return slotRenderer ? (
slotRenderer(props)
) : (
<Slot key={sConfig.slot} {...props} />
)
})}
</div>
</div>
)}
</div>
{!isChild && isGrid && (
<div
className={`ar-Layout__help-panel position-fixed overflow-hidden d-flex px-2 justify-content-between py-1${
panelsDisplayed ? " show" : ""
}`}
ref={helpPanelRef}
>
<span
className="small fw-bold"
style={{ color: theme === ArThemes.DARK1 ? "black" : "white" }}
>
Undo: Ctrl/Cmd + z, Redo: Ctrl/Cmd + y
</span>
<span>
<Tooltip demo={demo}>
<Icon
icon="fa.FaKeyboard"
slot={ArPopoverSlots.ANCHOR}
attributes={{
colors: {
fillColor: "white"
},
classes: "me-3"
}}
/>
<span slot={ArPopoverSlots.POPOVER} className="z-2">
<div className="row">
<div className="col-5 fw-bold">{cmdCtrl} + h</div>
<div className="col-7 fw-bold">Split Horizontally</div>
</div>
<div className="row">
<div className="col-5 fw-bold">{cmdCtrl} + v</div>
<div className="col-7 fw-bold">Split Vertically</div>
</div>
<div className="row">
<div className="col-5 fw-bold">{cmdCtrl} + m</div>
<div className="col-7 fw-bold">Merge Rectangle</div>
</div>
<div className="row">
<div className="col-5 fw-bold">{cmdCtrl} + r</div>
<div className="col-7 fw-bold">Insert Row</div>
</div>
<div className="row">
<div className="col-5 fw-bold">{cmdCtrl} + c</div>
<div className="col-7 fw-bold">Insert Column</div>
</div>
<div className="row">
<div className="col-5 fw-bold">{cmdCtrl} + d</div>
<div className="col-7 fw-bold">Delete</div>
</div>
<div className="row">
<div className="col-5 fw-bold">{cmdCtrl} + z</div>
<div className="col-7 fw-bold">Undo</div>
</div>
<div className="row">
<div className="col-5 fw-bold">{cmdCtrl} + y</div>
<div className="col-7 fw-bold">Redo</div>
</div>
</span>
</Tooltip>
<Tooltip demo={demo}>
<Icon
icon="md.MdInfoOutline"
slot={ArPopoverSlots.ANCHOR}
attributes={{
colors: {
fillColor: theme === ArThemes.DARK1 ? "black" : "white"
}
}}
/>
<span
className="ar-Layout__help-panel__help-content small z-2"
slot={ArPopoverSlots.POPOVER}
style={{ whiteSpace: "normal" }}
>
<div>Hover on grid to reveal control panel.</div>
<div>Click a cell to select it.</div>
<div>You may select multiple cells by clicking on them.</div>
<div>
Click a row header to select all cells in that row, and column
header to select all cells in that column
</div>
<div>
You may hover on any selected cells on specific sections
within cell to split vertical or horizontal.
</div>
<div>
For Horizontal: Hover on top center or bottom center region
and click
</div>
<div>
For Vertical: Hover on left center or right center region and
click
</div>
<div>
You may merge cells too, at least 2 cells should be selected
to merge and the selected cells shouls form a rectangle.
</div>
<div>
To delete a row or column, hover on it's corresponding row or
column header to reveal delete button and click on it.
</div>
<div>
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
</div>
<div>Edit Mode should be enabled to use any shortcuts</div>
</span>
</Tooltip>
</span>
</div>
)}
</div>
)
return (
<BuilderLayoutContainer
classes={classes || ""}
panelsDisplayable={!isChild && isGridDisplay}
>
<BuilderLayoutCanvas isChild={!!isChild} isGrid={isGridDisplay}>
<div
className={`ar-Layout__grid border${isGridDisplay ? " d-grid" : ""}${displayMode ? " d-" + displayMode : ""}`}
style={layoutService.gridTemplate ? { gridTemplate: layoutService.gridTemplate } : {}}
>
{slots?.map((slotDesc) => {
const slotProps = {
slotDescriptor: slotDesc,
containerDisplayMode: displayMode,
controlsEnabled,
hideBuildModePaddings,
layoutMode: mode,
mode: ArSlotViewMode.BUILD as const,
dropHandler: slotDropHandler,
onSlotSelect,
slotRenderer,
}
return slotRenderer ? (
slotRenderer(slotProps)
) : (
<Slot {...slotProps} key={slotDesc.slot} />
)
})}
</div>
</BuilderLayoutCanvas>
</BuilderLayoutContainer>
)
}
export default BuilderLayout

View File

@@ -0,0 +1,82 @@
import { FC } from "react"
import { Fragment } from "react"
import { ArSlotViewMode } from "@armco/shared-components/enums"
import type { BuilderLayoutCanvasProps, ISlotDescriptor } from "./types"
import { useLayoutContext } from "./Layout.context"
import Slot from "./Slot"
const BuilderLayoutCanvas: FC<BuilderLayoutCanvasProps> = ({ children, isChild, isGrid }) => {
const { eventsService, layoutService, controlsEnabled } = useLayoutContext()
const slots: ISlotDescriptor[] | undefined = layoutService?.slots
const gridToolbarSpecs = layoutService?.gridToolbarSpecs
const onSlotSelectCB = eventsService?.onSlotSelectCB
return (
<div className="ar-Layout__canvas flex-1">
{slots && (
<div className="ar-Layout__grid-tools d-grid h-100">
{controlsEnabled && !isChild && isGrid && (
<Fragment>
<div className="ar-Layout__grid-tools__main border" />
<div
className="ar-Layout__row-tools d-grid"
style={{ gridTemplate: gridToolbarSpecs?.rowTools?.gridTemplate }}
>
{gridToolbarSpecs?.rowTools?.slots?.map((r: any, h: number, d: any[]) => (
<span
key={r.slot}
className={`row-controller-cell position-relative border${r.isSelected ? " selected" : ""}`}
style={{ gridArea: r.gridArea }}
>
<Slot
mode={ArSlotViewMode.ROWCONTROLLER}
slotDescriptor={r.setClasses("h-100")}
onClick={(e: any) => {
eventsService.selectRow(e)
onSlotSelectCB && onSlotSelectCB(e, "row")
}}
controlsEnabled={controlsEnabled}
isLast={h === d.length - 1}
rowDeleteHandler={() => eventsService.deleteRow(true)}
rowInsertHandler={(e: any) => eventsService.addRow(e)}
rowSelectHandler={(e: any) => eventsService.selectRow(r, e)}
>{`controller-${r.slot}`}</Slot>
</span>
))}
</div>
<div
className="ar-Layout__col-tools d-grid"
style={{ gridTemplate: gridToolbarSpecs?.colTools?.gridTemplate }}
>
{gridToolbarSpecs?.colTools?.slots?.map((r: any, h: number, d: any[]) => (
<span
key={r.slot}
className={`column-controller-cell position-relative border${r.isSelected ? " selected" : ""}`}
style={{ gridArea: r.gridArea }}
>
<Slot
mode={ArSlotViewMode.COLCONTROLLER}
slotDescriptor={r.setClasses("h-100")}
onClick={(e: any) => {
eventsService.selectColumn(e)
onSlotSelectCB && onSlotSelectCB(e, "column")
}}
controlsEnabled={controlsEnabled}
isLast={h === d.length - 1}
columnDeleteHandler={() => eventsService.deleteColumn(true)}
columnInsertHandler={(e: any) => eventsService.addColumn(e)}
columnSelectHandler={(e: any) => eventsService.selectColumn(r, e)}
>{`controller-${r.slot}`}</Slot>
</span>
))}
</div>
</Fragment>
)}
{children}
</div>
)}
</div>
)
}
export default BuilderLayoutCanvas

View File

@@ -0,0 +1,68 @@
import { FC, useRef, useEffect } from "react"
import { BuilderLayoutContainerProps } from "./types"
import LayoutHelp from "./LayoutHelp"
import LayoutControlPanel from "./LayoutControlPanel"
import { useLayoutContext } from "./Layout.context"
const BuilderLayoutContainer: FC<BuilderLayoutContainerProps> = ({ children, classes, panelsDisplayable }) => {
const helpRef = useRef<HTMLDivElement>(null)
const controlPanelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const { layoutService, panelsDisplayed, displayPanels, controlsEnabled } = useLayoutContext()
const rowSlots = layoutService?.gridToolbarSpecs?.rowTools?.slots || []
const colSlots = layoutService?.gridToolbarSpecs?.colTools?.slots || []
useEffect(() => {
const updatePanelPositions = () => {
const containerRect = containerRef.current?.getBoundingClientRect()
if (containerRect) {
if (controlPanelRef.current) {
controlPanelRef.current.style.bottom = containerRect.bottom + "px"
controlPanelRef.current.style.left = containerRect.left + "px"
controlPanelRef.current.style.width = containerRect.width + "px"
}
if (helpRef.current) {
helpRef.current.style.top = window.innerHeight - containerRect.top + "px"
helpRef.current.style.left = containerRect.left + "px"
}
setTimeout(() => {
const helpRect = helpRef.current?.getBoundingClientRect()
if (containerRect && helpRef.current && controlPanelRef.current && helpRect && helpRect.width > containerRect.width) {
helpRef.current.style.width = containerRect.width + "px"
helpRef.current.style.overflow = "auto"
}
}, 500)
}
}
const resizeObserver = new window.ResizeObserver(() => {
if (panelsDisplayed) updatePanelPositions()
})
if (containerRef.current) resizeObserver.observe(containerRef.current)
return () => resizeObserver.disconnect()
}, [panelsDisplayed, controlsEnabled])
return (
<div
className={`ar-BuilderLayout mh-100 w-100 d-flex flex-column overflow-auto${classes ? " " + classes : ""}${controlsEnabled ? "" : " hide-controls"}`}
ref={containerRef}
onMouseOver={e => { e.stopPropagation(); panelsDisplayable && displayPanels(true) }}
onMouseLeave={e => { e.stopPropagation(); panelsDisplayable && displayPanels(false) }}
>
{panelsDisplayable && (
<LayoutControlPanel
classes="position-fixed"
ref={controlPanelRef}
show={panelsDisplayed}
rowDeleteEnabled={rowSlots.findIndex((e: any) => e.isSelected) > -1}
columnDeleteEnabled={colSlots.findIndex((e: any) => e.isSelected) > -1}
/>
)}
{children}
{panelsDisplayable && (
<LayoutHelp ref={helpRef} panelsDisplayed={panelsDisplayed} />
)}
</div>
)
}
export default BuilderLayoutContainer

View File

@@ -1,54 +0,0 @@
.ar-Slot {
&.build {
&:hover {
border: 1px dashed #86cff1 !important;
}
&.selected {
border: 1px solid #86cff1 !important;
}
&.last-selected {
outline: 4px solid #2e9cfd !important;
}
&.selected-for-delete {
border: 1px solid red !important;
}
.ar-Slot__overlay-btn {
width: calc(100% / 3);
&.slot:hover {
background-color: rgba(85, 189, 237, 0.661);
}
}
}
.ar-SlotTools {
display: none;
}
&:hover .ar-SlotTools {
display: flex;
}
&.row-controller {
.ar-Slot__overlay-btn {
height: calc((100% - 16px) / 3);
&.slot:hover {
background-color: rgba(85, 189, 237, 0.661);
}
&.delete {
height: 1rem;
background-color: red;
}
}
}
&.col-controller {
.ar-Slot__overlay-btn {
width: calc((100% - 16px) / 3);
&.slot:hover {
background-color: rgb(3, 169, 244, 0.5);
}
&.delete {
width: 1rem;
background-color: red;
}
}
}
}

156
src/BuilderSlot.tsx Executable file → Normal file
View File

@@ -1,98 +1,82 @@
import { useEffect, useRef, useState } from "react"
import { ArDndItemTypes, ArSlotViewMode, BuilderSlotProps } from "@armco/types"
import { CSSProperties, useEffect, useRef, useState } from "react"
import { ArSlotViewMode, ArDndItemTypes } from "@armco/shared-components/enums"
import Draggable from "@armco/shared-components/Draggable"
import Droppable from "@armco/shared-components/Droppable"
import SlotTools from "./SlotTools"
import "./BuilderSlot.component.scss"
import { BuilderSlotProps } from "./types"
import { useLayoutContext } from "./Layout.context"
const BuilderSlot = (props: BuilderSlotProps): JSX.Element => {
const {
acceptDropTypes,
containerDisplayMode,
content,
dropHandler,
lastSelected,
onClick,
selectionOnlyModeEnabled,
style,
...rest
} = props
const [selected, setSelected] = useState<boolean>()
const slotRef = useRef<HTMLDivElement>(null)
const { mode, config } = props
const { slot, props: slotProps } = config
const { classes, style: slotStyles, ...slotRest } = slotProps || {}
const isToolSlot =
mode === ArSlotViewMode.ROWCONTROLLER ||
mode === ArSlotViewMode.COLCONTROLLER
const {
acceptDropTypes,
containerDisplayMode,
content,
dropHandler,
activeSlot,
onClick,
...rest
} = props
const { controlsEnabled, selectionOnlyModeEnabled, eventsService } = useLayoutContext()
const [selected, setSelected] = useState<boolean>()
const { mode = ArSlotViewMode.BUILD, slotDescriptor } = props
const slotRef = useRef<HTMLDivElement>(null)
const { slot, props: slotProps } = slotDescriptor
const { classes, style: slotStyles, ...slotRest } = slotProps || {}
const isToolSlot =
mode === ArSlotViewMode.ROWCONTROLLER ||
mode === ArSlotViewMode.COLCONTROLLER
useEffect(() => {
setSelected(config.isSelected)
}, [config.isSelected])
useEffect(() => {
setSelected(!!slotDescriptor.isSelected)
}, [slotDescriptor.isSelected])
const finalSlotStyles = {
gridArea: config.gridArea,
...slotStyles,
...(style || {}),
}
const finalSlotStyles: CSSProperties = {
gridArea: slotDescriptor.gridArea,
...slotStyles,
}
if (containerDisplayMode && containerDisplayMode !== "grid" && !content) {
finalSlotStyles.minWidth = "5rem"
finalSlotStyles.minHeight = "5rem"
}
if (containerDisplayMode && containerDisplayMode !== "grid" && !content) {
finalSlotStyles.minWidth = "5rem"
finalSlotStyles.minHeight = "5rem"
}
const slotRender = (
<div
className={`ar-Slot position-relative${classes ? " " + classes : ""}${mode === "build" ? " obscure-border" : ""}${selected ? " selected" : ""}${activeSlot && activeSlot.slot === slot ? " last-selected" : ""} ${mode}${slotDescriptor.isSelectedForInlineDelete ? " selected-for-delete" : ""}`}
id={slot}
key={slot}
style={finalSlotStyles}
onClick={(e) => {
e.stopPropagation()
setSelected(!selected)
onClick && onClick(slotDescriptor)
}}
ref={slotRef}
{...slotRest}
>
{content}
{controlsEnabled && !selectionOnlyModeEnabled && (
<SlotTools {...rest} />
)}
</div>
)
const slotRender = (
<div
className={`ar-Slot position-relative${classes ? " " + classes : ""}${
mode === "build" ? " obscure-border" : ""
}${selected ? " selected" : ""} ${
lastSelected && lastSelected.slot === slot ? " last-selected" : ""
} ${mode}${
config.isSelectedForInlineDelete ? " selected-for-delete" : ""
}`}
id={slot}
key={slot}
style={finalSlotStyles}
onClick={(e) => {
e.stopPropagation()
setSelected(!selected)
onClick && onClick(config)
}}
ref={slotRef}
{...slotRest}
>
{content}
{props.controlsEnabled && !selectionOnlyModeEnabled && (
<SlotTools {...rest} />
)}
</div>
)
return isToolSlot ? (
slotRender
) : (
<Draggable
canDrag={!!config.content && props.mode === ArSlotViewMode.BUILD}
demo={props.demo}
itemData={config}
itemType={ArDndItemTypes.COMPONENTSLOT}
>
<Droppable
acceptTypes={[
ArDndItemTypes.COMPONENTSLOT,
ArDndItemTypes.TREELISTITEM,
ArDndItemTypes.ICON,
...(acceptDropTypes || []),
]}
dropHandler={(sourceData) =>
dropHandler && dropHandler(sourceData, config)
}
hideHoverEffect
>
{slotRender}
</Droppable>
</Draggable>
)
return isToolSlot ? (
slotRender
) : (
<Draggable
canDrag={!!slotDescriptor.content && mode === ArSlotViewMode.BUILD}
itemData={slotDescriptor}
itemType={ArDndItemTypes.COMPONENTSLOT}
>
<Droppable
acceptTypes={[ArDndItemTypes.COMPONENTSLOT, ArDndItemTypes.TREELISTITEM, ArDndItemTypes.ICON, ...(acceptDropTypes || [])]}
dropHandler={(sourceData) => dropHandler && dropHandler(sourceData, slotDescriptor)}
hideHoverEffect
>
{slotRender}
</Droppable>
</Draggable>
)
}
export default BuilderSlot

128
src/Layout.context.tsx Normal file
View File

@@ -0,0 +1,128 @@
import { createContext, useContext, useState, useEffect, FC, ReactElement } from "react"
import type { LayoutContextType, LayoutProviderProps } from "./types"
import type LayoutService from "./services/Layout.service"
import type EventsService from "./services/Events.service"
import type HistoryService from "./services/History.service"
import type { SlotDescriptor } from "./models"
import type { FunctionType } from "@armco/types"
import LayoutError, { LayoutErrors } from "./LayoutError"
const LayoutContext = createContext<LayoutContextType | null>(null)
const exampleUsage = `
Example usage:
<LayoutProvider
layoutService={layoutService}
eventsService={eventsService!}
historyService={HistoryService!}
showPanels={showPanels}
showControls={showControls}
>
<App />
</LayoutProvider>
`
export const useLayoutContext = (): LayoutContextType => {
const ctx = useContext(LayoutContext)
if (!ctx)
throw new LayoutError(
"useLayoutContext must be used within a LayoutProvider.",
LayoutErrors.MISSING_LAYOUT_CONTEXT,
exampleUsage,
)
return ctx
}
export const useLayoutService = (): LayoutService => {
const ctx = useLayoutContext()
if (!ctx)
throw new LayoutError(
"useLayoutService must be used within a LayoutProvider.",
LayoutErrors.MISSING_LAYOUT_CONTEXT,
exampleUsage,
)
return ctx.layoutService
}
export const useEventsService = (): EventsService => {
const ctx = useLayoutContext()
if (!ctx)
throw new LayoutError(
"useEventsService must be used within a LayoutProvider.",
LayoutErrors.MISSING_LAYOUT_CONTEXT,
exampleUsage,
)
return ctx.eventsService
}
export const useHistoryService = (): HistoryService<Array<SlotDescriptor>> => {
const ctx = useLayoutContext()
if (!ctx)
throw new LayoutError(
"useHistoryService must be used within a LayoutProvider.",
LayoutErrors.MISSING_LAYOUT_CONTEXT,
exampleUsage,
)
return ctx.historyService
}
export const useShowControls = (): { controlsEnabled: boolean; enableControls: FunctionType } => {
const ctx = useLayoutContext()
if (!ctx)
throw new LayoutError(
"useShowControls must be used within a LayoutProvider.",
LayoutErrors.MISSING_LAYOUT_CONTEXT,
exampleUsage,
)
return { controlsEnabled: ctx.controlsEnabled, enableControls: ctx.enableControls as unknown as FunctionType }
}
export const useSelectionOnlyMode = (): { selectionOnlyModeEnabled: boolean; enableSelectionOnlyMode: FunctionType } => {
const ctx = useLayoutContext()
if (!ctx)
throw new LayoutError(
"useSelectionOnlyMode must be used within a LayoutProvider.",
LayoutErrors.MISSING_LAYOUT_CONTEXT,
exampleUsage,
)
return {
selectionOnlyModeEnabled: ctx.selectionOnlyModeEnabled,
enableSelectionOnlyMode: ctx.enableSelectionOnlyMode as unknown as FunctionType,
}
}
const LayoutProvider: FC<LayoutProviderProps> = ({
children,
layoutService,
eventsService,
historyService,
showPanels,
showControls,
}): ReactElement => {
const [controlsEnabled, setControlsEnabled] = useState(!!showControls)
const [selectionOnlyModeEnabled, setSelectionOnlyModeEnabled] = useState(false)
const [panelsDisplayed, setPanelsDisplayed] = useState(!!showPanels)
useEffect(() => setPanelsDisplayed(!!showPanels), [showPanels])
useEffect(() => setControlsEnabled(!!showControls), [showControls])
return (
<LayoutContext.Provider
value={{
layoutService,
eventsService,
historyService,
controlsEnabled,
enableControls: setControlsEnabled as unknown as FunctionType,
selectionOnlyModeEnabled,
enableSelectionOnlyMode: setSelectionOnlyModeEnabled as unknown as FunctionType,
panelsDisplayed,
displayPanels: setPanelsDisplayed as unknown as FunctionType,
} as unknown as LayoutContextType}
>
{children}
</LayoutContext.Provider>
)
}
export default LayoutProvider

111
src/Layout.tsx Executable file → Normal file
View File

@@ -1,67 +1,64 @@
import { v4 as uuid } from "uuid"
import { useEffect, useState } from "react"
import { ArAlertType, LayoutProps, SlotDescriptor } from "@armco/types"
import { FC, useEffect, useState } from "react"
import { v4 as uuidv4 } from "uuid"
import { ArAlertType } from "@armco/utils"
import { get as httpGet } from "@armco/utils/network"
import { useNotification } from "@armco/utils/hooks"
import LayoutProvider from "./Layout.context"
import useLayoutInit from "./hooks/useLayoutInit"
import SlotDescriptor from "./models/SlotDescriptor"
import BuilderLayout from "./BuilderLayout"
import ReleaseLayout from "./ReleaseLayout"
import { get } from "@armco/utils/network"
import { useNotification } from "@armco/utils/hooks"
import type { LayoutProps } from "./types"
const dummyGridSpecs = { rows: 5, columns: 5 }
const Layout: FC<LayoutProps> = (props) => {
const { classes, displayMode, mode, showPanels, showControls, slotRenderer, url } = props
const { notify } = useNotification()
const [, setTick] = useState(false)
const rerender = () => setTick((v) => !v)
const Layout = (props: LayoutProps) => {
const { mode, demo, gridSpecs, url } = props
const propsClone = { ...props }
propsClone.gridSpecs = demo ? gridSpecs || dummyGridSpecs : gridSpecs
const { notify } = useNotification()
const [localSlots, setLocalSlots] = useState<
Array<SlotDescriptor> | undefined
>()
const [localGridTemplate, setLocalGridTemplate] = useState<
string | undefined
>()
propsClone.slots = localSlots
propsClone.gridTemplate = localGridTemplate
const { layoutService, eventsService, historyService } = useLayoutInit({ ...props, rerender }, mode)
useEffect(() => {
JSON.stringify(props.slots) !== JSON.stringify(localSlots) &&
setLocalSlots(props.slots)
}, [props.slots])
useEffect(() => {
if (layoutService && !(layoutService.slots || layoutService.gridTemplate) && url) {
httpGet(url)
.then((res: any) => {
const body = Array.isArray(res.body) ? res.body[0] : res.body
if (body?.descriptor) {
if (body.descriptor.content)
layoutService.slots = body.descriptor.content.map((v: any) => SlotDescriptor.create(v))
if (body.descriptor.layout) layoutService.gridTemplate = body.descriptor.layout
}
rerender()
})
.catch(() =>
notify({ message: "Failed to fetch descriptor for provided URL", uid: uuidv4(), type: ArAlertType.ERROR }),
)
}
}, [url, notify, layoutService])
useEffect(() => {
setLocalGridTemplate(props.gridTemplate)
}, [props.gridTemplate])
if ((mode ?? "build") === "build") {
return (
<LayoutProvider
layoutService={layoutService}
eventsService={eventsService!}
historyService={historyService!}
showPanels={showPanels}
showControls={showControls}
>
<BuilderLayout {...props} />
</LayoutProvider>
)
}
useEffect(() => {
!(localSlots || localGridTemplate) &&
url &&
get(url)
.then((res) => {
const component = Array.isArray(res.body) ? res.body[0] : res.body
component?.descriptor?.content &&
setLocalSlots(component.descriptor.content as Array<SlotDescriptor>)
component?.descriptor?.gridTemplate &&
setLocalGridTemplate(component.descriptor.layout as string)
})
.catch((e) =>
notify({
message: "Failed to fetch descriptor for provided URL",
uid: uuid(),
type: ArAlertType.ERROR,
}),
)
}, [url])
return mode === "build" || (!mode && demo) ? (
<BuilderLayout {...propsClone} />
) : (
<ReleaseLayout
classes={propsClone.classes}
displayMode={propsClone.displayMode}
slots={localSlots}
gridTemplate={localGridTemplate}
slotRenderer={propsClone.slotRenderer}
/>
)
return (
<ReleaseLayout
classes={classes}
displayMode={displayMode}
slots={layoutService.slots}
gridTemplate={layoutService.gridTemplate}
slotRenderer={slotRenderer}
/>
)
}
export default Layout

26
src/LayoutControlPanel.component.scss Executable file → Normal file
View File

@@ -1,18 +1,14 @@
.ar-LayoutControlPanel {
transition: all 0.5s;
max-height: 0;
opacity: 0;
transition: all 0.5s;
max-height: 0;
opacity: 0;
&.show {
max-height: 2.25rem;
max-width: calc(2.25rem - 2px);
opacity: 1;
border-radius: 6px 6px 0 0;
background-color: var(--ar-color-selected);
white-space: nowrap;
&.show-all-controls {
max-width: 30rem;
}
}
&.show {
max-height: 2.25rem;
max-width: 30rem;
opacity: 1;
border-radius: 6px 6px 0 0;
background-color: var(--ar-color-selected);
white-space: nowrap;
}
}

388
src/LayoutControlPanel.tsx Executable file → Normal file
View File

@@ -1,269 +1,135 @@
import { Ref, forwardRef } from "react"
import {
ArPopoverSlots,
ArThemes,
FunctionType,
LayoutControlPanelProps,
} from "@armco/types"
import Icon from "@armco/icon"
import Tooltip from "@armco/shared-components/Tooltip"
import { forwardRef } from "react"
import { useTheme } from "@armco/utils/hooks"
import { ArThemes } from "@armco/utils"
import { ArPopoverSlots } from "@armco/shared-components/enums"
import { Tooltip } from "@armco/shared-components"
import Icon from "@armco/icon"
import { useLayoutContext, useSelectionOnlyMode, useShowControls } from "./Layout.context"
import type { LayoutControlPanelProps } from "./types"
import "./LayoutControlPanel.component.scss"
const getControl = (
theme: string,
icon: string,
handler: FunctionType,
tooltip: string,
demo?: boolean,
color?: string,
isDisabled?: boolean,
toggleColor?: string,
showControls?: boolean,
const actionButton = (
theme: ArThemes,
icon: string,
onClick: () => void,
tooltip: string,
fillColor?: string,
disabled?: boolean,
backgroundColor?: string,
fillPath?: boolean,
) => {
const iconColor =
theme === ArThemes.DARK1
? isDisabled
? "#7a7aa7"
: color || "black"
: isDisabled
? "#cfcfcf"
: color || "white"
return (
<Tooltip
classes={showControls === undefined || showControls ? "" : " w-0"}
demo={demo}
>
<span
className={`p-1 border-radius-l2 d-inline-block${
!isDisabled ? " cursor-pointer hover-border hover-bg dark2" : ""
}`}
slot={ArPopoverSlots.ANCHOR}
onClick={isDisabled ? () => {} : handler}
style={{ backgroundColor: toggleColor }}
>
<Icon icon={icon} attributes={{colors: {fillColor: iconColor}}} />
</span>
<span slot={ArPopoverSlots.POPOVER} style={{ whiteSpace: "normal" }}>
<div>{tooltip}</div>
</span>
</Tooltip>
)
const color = theme === ArThemes.DARK1 ? (disabled ? "#7a7aa7" : fillColor || "black") : disabled ? "#cfcfcf" : fillColor || "white"
return (
<Tooltip>
<span
className={`p-1 border-radius-l2 d-inline-block${disabled ? "" : " cursor-pointer hover-border hover-bg dark2"}`}
slot={ArPopoverSlots.ANCHOR}
onClick={() => !disabled && onClick()}
style={{ backgroundColor }}
>
<Icon
icon={icon}
attributes={{ colors: { fillColor: color, strokeColor: color } }}
{...(fillPath ? ({ fillPath: true } as any) : {})}
/>
</span>
<span slot={ArPopoverSlots.POPOVER} style={{ whiteSpace: "normal" }}>
<div>{tooltip}</div>
</span>
</Tooltip>
)
}
const LayoutControlPanel = forwardRef(
(props: LayoutControlPanelProps, ref: Ref<HTMLDivElement>): 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 (
<div
className={`ar-LayoutControlPanel p-1${show ? " show" : ""}${
classes ? " " + classes : ""
}${showControls ? " show-all-controls" : ""}`}
style={{ overflow: "hidden" }}
ref={ref}
key={"layout-control-panel"}
>
{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 && (
<>
<span className="border-right mx-2" />
{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,
),
)}
</>
)}
<span className="border-right mx-2" />
{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,
),
)}
<span className="border-right mx-2" />
{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,
),
)}
<span className="border-right mx-2" />
{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,
),
)}
</>
)}
</div>
)
},
)
const LayoutControlPanel = forwardRef<HTMLDivElement, LayoutControlPanelProps>((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 (
<div
className={`ar-LayoutControlPanel p-1${show ? " show" : ""}${classes ? " " + classes : ""}`}
style={{ overflow: "hidden" }}
ref={ref}
key={"layout-control-panel"}
>
{actionButton(theme, actions[0][0], actions[0][1], actions[0][2], actions[0][3])}
{layoutService.slots && (
<>
<span className="border-right mx-2" />
{actions.slice(1, 4).map((e, idx) =>
actionButton(theme, e[0], e[1], e[2], e[3], e[4], e[5])
)}
</>
)}
<span className="border-right mx-2" />
{actions.slice(4, 10).map((e, idx) => actionButton(theme, e[0], e[1], e[2], e[3], e[4], e[5]))}
<span className="border-right mx-2" />
{actions.slice(10, 12).map((e, idx) => actionButton(theme, e[0], e[1], e[2], e[3], e[4], e[5]))}
<span className="border-right mx-2" />
{actions.slice(12).map((e, idx) => actionButton(theme, e[0], e[1], e[2], e[3], e[4], e[5], e[6]))}
</div>
)
})
export default LayoutControlPanel

21
src/LayoutError.tsx Normal file
View File

@@ -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
}
}

98
src/LayoutHelp.tsx Normal file
View File

@@ -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<HTMLDivElement, LayoutHelpProps>(
({ panelsDisplayed }, ref) => {
const { theme } = useTheme()
const cmdIcon = useMemo(
() => (
<>
<Icon icon="ai.AiFillMacCommand" attributes={{ size: "0.8rem" }} />{" "}
<Icon icon="md.MdOutlineKeyboardControlKey" attributes={{ size: "0.8rem" }} />
</>
),
[],
)
return (
<div
className={`ar-Layout__help-panel position-fixed overflow-hidden d-flex px-2 justify-content-between py-1${panelsDisplayed ? " show" : ""
}`}
ref={ref}
>
<span className="small fw-bold" style={{ color: theme === ArThemes.DARK1 ? "black" : "white" }}>
Undo: Ctrl/Cmd + z, Redo: Ctrl/Cmd + y
</span>
<div>
<Tooltip>
<Icon
icon="fa.FaKeyboard"
slot={ArPopoverSlots.ANCHOR}
attributes={{ colors: { fillColor: "white" }, classes: "me-3" }}
/>
<span slot={ArPopoverSlots.POPOVER} className="z-2">
{keyBindings.map((kb) => (
<div className="row" key={kb.key}>
<div className="col-5 fw-bold">
{cmdIcon} + {kb.key}
</div>
<div className="col-7 fw-bold">{kb.action}</div>
</div>
))}
</span>
</Tooltip>
<Tooltip>
<Icon
icon="md.MdInfoOutline"
slot={ArPopoverSlots.ANCHOR}
attributes={{ colors: { fillColor: theme === ArThemes.DARK1 ? "black" : "white" } }}
/>
<span
className="ar-Layout__help-panel__help-content small z-2"
slot={ArPopoverSlots.POPOVER}
style={{ whiteSpace: "normal" }}
>
{helpPoints.map((h, idx) => (
<div key={idx}>{h}</div>
))}
</span>
</Tooltip>
</div>
</div>
)
},
)
export default LayoutHelp

8
src/README.md Normal file
View File

@@ -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.

57
src/ReleaseLayout.tsx Executable file → Normal file
View File

@@ -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 (
<div
className={`ar-ReleaseLayout__grid h-100 ${
displayMode ? "d-" + displayMode : "d-grid"
}${classes ? " " + classes : ""}`}
style={gridTemplate ? { gridTemplate } : {}}
>
{slots?.map((sConfig) => {
const props: SlotProps = {
config: sConfig,
containerDisplayMode: displayMode,
mode: ArSlotViewMode.RELEASE,
}
return slotRenderer ? (
slotRenderer(props)
) : (
<Slot key={sConfig.slot} {...props} />
)
})}
</div>
)
export interface ReleaseLayoutProps extends Pick<LayoutProps, "classes" | "displayMode" | "gridTemplate" | "slots" | "slotRenderer"> { }
// Release layout simply renders provided slots with a grid template (or displayMode variant) without builder controls.
const ReleaseLayout: React.FC<ReleaseLayoutProps> = ({
classes,
displayMode,
gridTemplate,
slots,
slotRenderer,
}) => {
return (
<div
className={`ar-ReleaseLayout__grid h-100 ${displayMode ? "d-" + displayMode : "d-grid"}${classes ? " " + classes : ""}`}
style={gridTemplate ? { gridTemplate } : {}}
>
{slots?.map((slotDesc: SlotDescriptor) => {
const slotProps = {
slotDescriptor: slotDesc,
containerDisplayMode: displayMode,
mode: ArSlotViewMode.RELEASE as const,
}
return slotRenderer ? (
slotRenderer(slotProps)
) : (
<Slot {...slotProps} key={slotDesc.slot} />
)
})}
</div>
)
}
export default ReleaseLayout

39
src/ReleaseSlot.tsx Executable file → Normal file
View File

@@ -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 (
<div
className={`ar-Slot${slotClasses ? " " + slotClasses : ""}`}
key={slot}
style={{ gridArea: config.gridArea, ...slotStyles }}
{...slotRest}
>
{content}
</div>
)
const ReleaseSlot: React.FC<ReleaseSlotProps> = (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 (
<div
className={`ar-Slot${classes ? " " + classes : ""}`}
style={{ gridArea: slotDescriptor.gridArea, ...(style || {}) }}
{...rest}
>
{content}
</div>
)
}
export default ReleaseSlot

112
src/Resizable.component.scss Executable file → Normal file
View File

@@ -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);
}

150
src/Resizable.tsx Executable file → Normal file
View File

@@ -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<ResizableProps> = (props) => {
const { reClasses, directions, display, componentName, children, style, ...rest } = props
const [dragging, setDragging] = useState(false)
const lastYRef = useRef<number>(0)
useRef<number>(0) // mimic extra ref from build (unused)
const childRef = useRef<HTMLDivElement | null>(null)
const Resizable = (props: ResizableProps): JSX.Element => {
const {
reClasses,
directions,
display,
componentName,
children,
style,
...rest
} = props
const [resizing, setResizing] = useState(false)
const mouseDownY = useRef<number>(0)
const mouseDownX = useRef<number>(0)
const childRef = useRef<HTMLElement>(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<HTMLDivElement, 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<any>
| undefined
const Dynamic = componentName && (registry as any)[componentName]
return (
<div
className={`ar-Resizable position-relative${
reClasses ? " " + reClasses : ""
}${display ? " " + display : ""}`}
style={style}
>
{directions?.map((direction) => (
<div
key={direction}
className={`handle position-absolute ${direction}`}
onMouseDown={(e) => mouseDownHandler(e, direction)}
/>
))}
{Component ? (
<Suspense fallback={<div>Loading...</div>}>
<Component childRef={childRef} {...rest} />
</Suspense>
) : (
children &&
cloneElement(children as ReactElement, {
childRef: childRef,
})
)}
</div>
)
return (
<div className={`ar-Resizable position-relative${reClasses ? " " + reClasses : ""}${display ? " " + display : ""}`} style={style}>
{directions?.map((dir) => (
<div className={`handle position-absolute ${dir}`} key={dir} onMouseDown={(e) => onMouseDown(e, dir as any)} />
))}
{Dynamic ? (
<Suspense fallback={<div>Loading...</div>}>
<Dynamic childRef={childRef} {...rest} />
</Suspense>
) : (
children && cloneElement(children as any, { childRef })
)}
</div>
)
}
export default Resizable

59
src/Slot.component.scss Normal file
View File

@@ -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;
}
}
}
}

178
src/Slot.tsx Executable file → Normal file
View File

@@ -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<ReactNode>()
const config = props.config
const Slot: React.FC<SlotProps> = (props) => {
const { layoutMode = "preview", slotRenderer } = props
const [content, setContent] = useState<any>()
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<SlotDescriptor> }
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, () => (
<Component_404 />
))
// repo[content.componentName]
const contentProps = content.props || {}
if (content.componentName === "Layout") {
contentProps.displayMode = config.props?.containerDisplayMode
}
setContent(
<Component
demo={mode === ArSlotViewMode.BUILD}
activeId={props.lastSelected?.slot}
{...{ ...(contentProps || {}) }}
/>,
)
} 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<string, React.ComponentType<any>> = {
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) : (
<Slot {...(nextProps as any)} key={(desc as any).slot} />
)
}
setContent(
<Layout
displayMode={config.props?.containerDisplayMode}
slots={content?.descriptor?.content}
gridTemplate={layout}
hideBuildModePaddings={props.hideBuildModePaddings}
mode={layoutMode}
isChild={true}
onSlotSelect={props.onSlotSelect}
lastSelected={props.lastSelected}
/>,
)
}
} 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<any> | 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 ? (
<BuilderSlot
key={"builder-slot-" + props.config.slot}
{...{ ...props, content }}
/>
) : (
<ReleaseSlot
key={"builder-slot-" + props.config.slot}
{...{ ...props, content }}
/>
)
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 (
<C demo={mode === ArSlotViewMode.BUILD} activeId={layoutService.activeSlot?.slot} {...(passedProps || {})} />
)
} else if (data?.descriptor?.content && Array.isArray(data.descriptor.content)) {
return (
<Layout
displayMode={(slotDescriptor as any).containerDisplayMode}
slots={data?.descriptor?.content}
hideBuildModePaddings={props.hideBuildModePaddings}
mode={layoutMode}
isChild={true}
onSlotSelect={props.onSlotSelect}
activeSlot={layoutService.activeSlot as any}
/>
)
}
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 ? (
<BuilderSlot {...props} content={content} key={`builder-slot-${slotDescriptor.slot}`} />
) : (
<ReleaseSlot {...props} content={content} key={`release-slot-${slotDescriptor.slot}`} />
)
}
export default Slot

258
src/SlotTools.tsx Executable file → Normal file
View File

@@ -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<SlotControlProps> = [
{ 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<Array<SlotControlProps>>(staticSlotTools)
const SlotTools: FC<SlotToolsProps> = (props) => {
const { slotDescriptor, controlsEnabled, demo, mode = ArSlotViewMode.PREVIEW } = props
const { eventsService } = useLayoutContext()
const [controls, setControls] = useState<any[]>(DEFAULT_CONTROLS)
useEffect(() => {
const slotToolsClone: Array<SlotControlProps> = 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 (
<div
className={`ar-SlotTools position-absolute h-100 w-100 flex-wrap top-0 start-0 ${
mode === ArSlotViewMode.ROWCONTROLLER ? "flex-column" : ""
}`}
>
{slotTools
.map(
(slotTool, index) =>
(!slotTool.isGridOnly || mode === ArSlotViewMode.BUILD) &&
(!slotTool.isToolSlotOnly ||
[
ArSlotViewMode.ROWCONTROLLER,
ArSlotViewMode.COLCONTROLLER,
].indexOf(mode) > -1) && (
<span
className={slotTool.class}
key={"slotTool-" + index}
onClick={(e) => {
slotTool.tooltip && e.stopPropagation()
slotTool.onClick && slotTool.onClick()
}}
onMouseEnter={slotTool.onMouseEnter}
onMouseLeave={slotTool.onMouseLeave}
>
{slotTool.tooltip ? (
<Tooltip classes="w-100" demo={demo}>
<span
slot={ArPopoverSlots.ANCHOR}
className="d-flex h-100 w-100"
/>
<span slot={ArPopoverSlots.POPOVER}>
{slotTool.tooltip}
</span>
</Tooltip>
) : (
<span className={`${slotTool.class}`} />
)}
</span>
),
)
.filter((s) => s)}
</div>
)
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 (
<div className={`ar-SlotTools position-absolute h-100 w-100 flex-wrap top-0 start-0 ${isRowController ? "flex-column" : ""}`}>
{controls
.map((o, idx) => (
(!o.isGridOnly || mode === ArSlotViewMode.BUILD) &&
(!o.isToolSlotOnly || [ArSlotViewMode.ROWCONTROLLER, ArSlotViewMode.COLCONTROLLER].indexOf(mode) > -1) && (
<span
className={o.class}
key={`slotTool-${idx}`}
onClick={(ev) => {
if (o.tooltip) ev.stopPropagation()
o.onClick && o.onClick()
}}
onMouseEnter={o.onMouseEnter}
onMouseLeave={o.onMouseLeave}
>
{o.tooltip ? (
<Tooltip classes="w-100" demo={demo as any}>
<span slot={ArPopoverSlots.ANCHOR} className="d-flex h-100 w-100" />
<span slot={ArPopoverSlots.POPOVER}>{o.tooltip}</span>
</Tooltip>
) : (
<span />
)}
</span>
)
))
.filter(Boolean)}
</div>
)
}
export default SlotTools

14
src/Test.tsx Normal file
View File

@@ -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(<div className="flex-center h-100 w-100 container"><DndProvider backend={backend}><Layout /></DndProvider></div>)

View File

@@ -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<HistoryService<SlotDescriptor[]>>()
const layoutRef = useRef<LayoutService>()
const eventsRef = useRef<EventsService>()
if (mode === "build" && !historyRef.current)
historyRef.current = new HistoryService<SlotDescriptor[]>()
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,
}
}

14
src/index.ts Executable file → Normal file
View File

@@ -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"
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"

View File

@@ -0,0 +1,29 @@
import { ArComponentResources } from "@armco/shared-components/enums"
import { IComponentInfo, ISlotDescriptor } from "../types"
interface Descriptor {
content: Array<ISlotDescriptor>
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

View File

@@ -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<string>
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<string>
onLayoutChange?: FunctionType
slotDropHandler?: FunctionType
onSlotSelect?: FunctionType
showControls?: boolean
showPanels?: boolean
slotRenderer?: FunctionType
slots: Array<SlotDescriptor> = []
url?: string
setAcceptTextOnClick(value: boolean): this {
this.acceptTextOnClick = value
return this
}
setColWidths(value: string | Array<string>): 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<string>): 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<ISlotDescriptor>): 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

View File

@@ -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<ISlotDescriptor> | IComponentInfo
props?: {
[key: string]: any
containerDisplayMode?: LayoutProps["displayMode"]
style?: React.CSSProperties
}
splitFrom?: string
isSelected?: boolean
isSelectedForInlineDelete?: boolean
constructor(
slot: string,
content?: Array<ISlotDescriptor> | 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<ISlotDescriptor> | 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>): 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,
)
}
}

3
src/models/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { default as ComponentInfo } from "./ComponentInfo"
export { default as LayoutDescriptor } from "./LayoutDescriptor"
export { default as SlotDescriptor } from "./SlotDescriptor"

View File

@@ -1,71 +0,0 @@
/// <reference types="node" />
/// <reference types="react" />
/// <reference types="react-dom" />
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<React.SVGProps<
SVGSVGElement
> & { 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
}

View File

@@ -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<Array<SlotDescriptor>>
onSlotSelectCB?: FunctionType
clickedSlot: SlotDescriptor | null = null
constructor(
layoutService: LayoutService,
historyService: HistoryService<Array<SlotDescriptor>>,
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<SlotDescriptor> | null {
return this.deleteRow(true)
}
/**
* Deletes a row normally.
* @returns The updated slots array or null.
*/
deleteRowNormal(): Array<SlotDescriptor> | 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<SlotDescriptor> | null {
return this.deleteColumn(true)
}
/**
* Deletes a column normally.
* @returns The updated slots array or null.
*/
deleteColumnNormal(): Array<SlotDescriptor> | 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<SlotDescriptor> | 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<SlotDescriptor> | 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<SlotDescriptor> | 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<SlotDescriptor>,
colSlots: Array<SlotDescriptor>,
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

View File

@@ -0,0 +1,72 @@
/**
* HistoryService manages undo/redo for layout state.
*/
class HistoryService<T = any> {
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

View File

@@ -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<Array<string>>
rowHeights?: Array<string> | string
colWidths?: Array<string> | 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<Array<SlotDescriptor>>
onLayoutChange?: FunctionType
rerender: FunctionType
constructor(
descriptor: any,
rerender: FunctionType,
historyService?: HistoryService<Array<SlotDescriptor>>,
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<string>()
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

3
src/services/index.ts Normal file
View File

@@ -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"

2
src/test.scss Normal file
View File

@@ -0,0 +1,2 @@
@use "bootstrap/scss/bootstrap.scss";
@use "@armco/shared-components/es/assets/Avatar.css";

222
src/types.ts Normal file
View File

@@ -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<ISlotDescriptor> | 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<SlotDescriptor>
gridTemplate: string
}
colTools: {
slots: Array<SlotDescriptor>
gridTemplate: string
}
}
export interface IComponentInfo {
componentName: string
source?: ArComponentResources
descriptor?: {
content: Array<ISlotDescriptor>
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<SlotDescriptor>
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<SlotDescriptor>
undoEnabled?: boolean
}
export interface ResizableProps extends BaseProps {
componentName?: string
children?: ReactNode
display?: ArDisplayTypes
directions?: Array<ArDirections>
reClasses?: string
[key: string]: any
}
export interface LayoutProps extends BaseProps {
acceptTextOnClick?: boolean
colWidths?: string | Array<string>
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<string>
onLayoutChange?: FunctionType
slotDropHandler?: FunctionType
onSlotSelect?: FunctionType
showControls?: boolean
showPanels?: boolean
slotRenderer?: FunctionType
slots?: Array<SlotDescriptor>
url?: string
}
export interface SlotProps extends Omit<BaseProps, "style"> {
childRef?: RefObject<HTMLDivElement>
slotDescriptor: SlotDescriptor
containerDisplayMode?: LayoutProps["displayMode"]
hideBuildModePaddings?: boolean
isFirst?: boolean
isLast?: boolean
activeSlot?: SlotDescriptor
mode?: ArSlotViewMode
controlsEnabled?: boolean
acceptDropTypes?: Array<string>
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<Array<SlotDescriptor>>
controlsEnabled: boolean
enableControls: FunctionType
selectionOnlyModeEnabled: boolean
enableSelectionOnlyMode: FunctionType
panelsDisplayed: boolean
displayPanels: FunctionType
}
export interface LayoutProviderProps {
children: ReactNode
layoutService: LayoutService
eventsService: EventsService
historyService: HistoryService<Array<SlotDescriptor>>
showPanels?: boolean
showControls?: boolean
}

0
src/utils.ts Normal file
View File

View File

@@ -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"

21
vite-dev.config.ts Normal file
View File

@@ -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,
},
})

View File

@@ -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(),
],