first commit

This commit is contained in:
2024-09-18 17:56:32 +05:30
parent de23d0b6d5
commit 490e40306a
49 changed files with 1854 additions and 19986 deletions

View File

@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<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/main.tsx"></script>
</body>
</html>

19327
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,17 @@
{
"name": "@armco/react-vite-rtk-template",
"name": "@armco/layout",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"start": "vite",
"build": "tsc && vite build",
"generate": "plop",
"atom": "plop atom",
"molecule": "plop molecule",
"component": "plop component",
"page": "plop page",
"preview": "vite preview",
"test": "vitest",
"format": "prettier --write .",
"lint": "eslint .",
"type-check": "tsc"
"lint": "eslint ."
},
"dependencies": {
"@reduxjs/toolkit": "^1.8.1",
"react": "^18.2.0",
"react-app-polyfill": "^3.0.0",
"react-dev-utils": "^12.0.1",
"react-dom": "^18.2.0",
"react-redux": "^8.0.1",
"react-router-dom": "^6.13.0"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@testing-library/dom": "^9.2.0",
@@ -40,7 +26,6 @@
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-prettier": "^4.2.1",
"jsdom": "^21.1.0",
"plop": "^3.1.2",
"prettier": "^2.7.1",
"prettier-config-nick": "^1.0.2",
"sass": "^1.63.4",
@@ -61,11 +46,15 @@
"react/jsx-no-target-blank": "off"
}
},
"peerDependencies": {
"react": ">16.8.0",
"react-dom": ">16.8.0"
},
"prettier": "prettier-config-nick",
"main": "index.tsx",
"repository": {
"type": "git",
"url": "git+https://github.com/ReStruct-Corporate-Advantage/.git"
"url": "git+https://github.com/ReStruct-Corporate-Advantage/layout.git"
},
"keywords": [
"components",
@@ -75,7 +64,7 @@
],
"license": "ISC",
"bugs": {
"url": "https://github.com/ReStruct-Corporate-Advantage/react-vite-rtk-template/issues"
"url": "https://github.com/ReStruct-Corporate-Advantage/layout/issues"
},
"homepage": "https://github.com/ReStruct-Corporate-Advantage/react-vite-rtk-template#readme"
"homepage": "https://github.com/ReStruct-Corporate-Advantage/layout#readme"
}

View File

@@ -1,3 +0,0 @@
.c-{{pascalCase name}} {
}

View File

@@ -1,8 +0,0 @@
import React from "react"
import {{pascalCase name}} from "./{{pascalCase name}}"
describe("{{pascalCase name}}", () => {
it("renders without error", () => {
})
})

View File

@@ -1,10 +0,0 @@
import React from "react"
import "./{{pascalCase name}}.component.scss"
interface {{pascalCase name}}Props {}
const {{pascalCase name}} = (props: {{pascalCase name}}Props): JSX.Element => {
return <div className="c-{{pascalCase name}}">In Component {{pascalCase name}}</div>
}
export default {{pascalCase name}}

View File

@@ -1,3 +0,0 @@
import {{pascalCase name}} from "./{{pascalCase name}}.jsx"
export default {{pascalCase name}}

View File

@@ -1,3 +0,0 @@
.c-{{pascalCase name}} {
}

View File

@@ -1,18 +0,0 @@
import { createSlice } from "@reduxjs/toolkit"
export interface {{pascalCase name}}State {}
const initialState: {{pascalCase name}}State = {}
export const {{snakeCase name}}Slice = createSlice({
name: "{{snakeCase name}}",
initialState,
reducers: {
increment: (state) => {},
},
extraReducers: (builder) => {},
})
export const { increment } = {{snakeCase name}}Slice.actions
export default {{snakeCase name}}Slice.reducer

View File

@@ -1,8 +0,0 @@
import React from "react"
import {{pascalCase name}} from "./{{pascalCase name}}"
describe("{{pascalCase name}}", () => {
it("renders without error", () => {
})
})

View File

@@ -1,10 +0,0 @@
import React from "react"
import "./{{pascalCase name}}.module.scss"
interface {{pascalCase name}}Props {}
const {{pascalCase name}} = (props: {{pascalCase name}}Props): JSX.Element => {
return <div className="c-{{pascalCase name}}">In Page {{pascalCase name}}</div>
}
export default {{pascalCase name}}

View File

@@ -1,3 +0,0 @@
import {{pascalCase name}} from "./{{pascalCase name}}.jsx"
export default {{pascalCase name}}

View File

@@ -1,5 +0,0 @@
/* PLOP_INJECT_IMPORT */
export {
/* PLOP_INJECT_EXPORT */
}

View File

@@ -1,207 +0,0 @@
module.exports = (plop) => {
plop.setGenerator("component", {
description: "Create a component",
prompts: [
{
type: "input",
name: "name",
message: "What is your component name?",
},
],
actions: [
{
type: "add",
path: "src/app/components/{{pascalCase name}}/{{pascalCase name}}.tsx",
templateFile: "plop-templates/Component/Component.tsx.hbs",
},
{
type: "add",
path: "src/app/components/{{pascalCase name}}/{{pascalCase name}}.test.ts",
templateFile: "plop-templates/Component/Component.test.ts.hbs",
},
{
type: "add",
path: "src/app/components/{{pascalCase name}}/{{pascalCase name}}.component.scss",
templateFile: "plop-templates/Component/Component.component.scss.hbs",
},
{
type: "add",
path: "src/app/components/{{pascalCase name}}/index.ts",
templateFile: "plop-templates/Component/index.ts.hbs",
},
{
type: "add",
path: "src/app/components/index.ts",
templateFile: "plop-templates/injectable-index.ts.hbs",
skipIfExists: true,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_IMPORT */`,
template: `import {{pascalCase name}} from "./{{pascalCase name}}"`,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_EXPORT */`,
template: `\t{{pascalCase name}},`,
},
],
})
plop.setGenerator("atom", {
description: "Create a component",
prompts: [
{
type: "input",
name: "name",
message: "What is your component name?",
},
],
actions: [
{
type: "add",
path: "src/app/components/atoms/{{pascalCase name}}/{{pascalCase name}}.tsx",
templateFile: "plop-templates/Component/Component.tsx.hbs",
},
{
type: "add",
path: "src/app/components/atoms/{{pascalCase name}}/{{pascalCase name}}.test.ts",
templateFile: "plop-templates/Component/Component.test.ts.hbs",
},
{
type: "add",
path: "src/app/components/atoms/{{pascalCase name}}/{{pascalCase name}}.component.scss",
templateFile: "plop-templates/Component/Component.component.scss.hbs",
},
{
type: "add",
path: "src/app/components/atoms/{{pascalCase name}}/index.ts",
templateFile: "plop-templates/Component/index.ts.hbs",
},
{
type: "add",
path: "src/app/components/index.ts",
templateFile: "plop-templates/injectable-index.ts.hbs",
skipIfExists: true,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_IMPORT */`,
template: `import {{pascalCase name}} from "./atoms/{{pascalCase name}}"`,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_EXPORT */`,
template: `\t{{pascalCase name}},`,
},
],
})
plop.setGenerator("molecule", {
description: "Create a rich component",
prompts: [
{
type: "input",
name: "name",
message: "What is your component name?",
},
],
actions: [
{
type: "add",
path: "src/app/components/molecules/{{pascalCase name}}/{{pascalCase name}}.tsx",
templateFile: "plop-templates/Component/Component.tsx.hbs",
},
{
type: "add",
path: "src/app/components/molecules/{{pascalCase name}}/{{pascalCase name}}.test.ts",
templateFile: "plop-templates/Component/Component.test.ts.hbs",
},
{
type: "add",
path: "src/app/components/molecules/{{pascalCase name}}/{{pascalCase name}}.component.scss",
templateFile: "plop-templates/Component/Component.component.scss.hbs",
},
{
type: "add",
path: "src/app/components/molecules/{{pascalCase name}}/index.ts",
templateFile: "plop-templates/Component/index.ts.hbs",
},
{
type: "add",
path: "src/app/components/index.ts",
templateFile: "plop-templates/injectable-index.ts.hbs",
skipIfExists: true,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_IMPORT */`,
template: `import {{pascalCase name}} from "./molecules/{{pascalCase name}}"`,
},
{
type: "append",
path: "src/app/components/index.ts",
pattern: `/* PLOP_INJECT_EXPORT */`,
template: `\t{{pascalCase name}},`,
},
],
})
plop.setGenerator("page", {
description: "Create a page",
prompts: [
{
type: "input",
name: "name",
message: "What is your page name?",
},
],
actions: [
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/{{pascalCase name}}.tsx",
templateFile: "plop-templates/Page/Page.tsx.hbs",
},
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/{{pascalCase name}}.test.ts",
templateFile: "plop-templates/Page/Page.test.ts.hbs",
},
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/{{pascalCase name}}.module.scss",
templateFile: "plop-templates/Page/Page.module.scss.hbs",
},
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/index.ts",
templateFile: "plop-templates/Page/index.ts.hbs",
},
{
type: "add",
path: "src/app/pages/{{pascalCase name}}/{{pascalCase name}}.slice.ts",
templateFile: "plop-templates/Page/Page.slice.ts.hbs",
},
{
type: "add",
path: "src/app/pages/index.ts",
templateFile: "plop-templates/injectable-index.ts.hbs",
skipIfExists: true,
},
{
type: "append",
path: "src/app/pages/index.ts",
pattern: `/* PLOP_INJECT_IMPORT */`,
template: `import {{pascalCase name}} from "./{{pascalCase name}}"`,
},
{
type: "append",
path: "src/app/pages/index.ts",
pattern: `/* PLOP_INJECT_EXPORT */`,
template: `\t{{pascalCase name}},`,
},
],
})
}

View File

@@ -0,0 +1,55 @@
.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;
}
}
}
}

774
src/BuilderLayout.tsx Executable file
View File

@@ -0,0 +1,774 @@
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 {
GridHelper,
Slot,
DomHelper,
LoadableIcon,
Tooltip,
LayoutControlPanel,
useAppSelector,
getCurrentTheme,
useStateWithHistory,
} from "../.."
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 = useAppSelector<string>(getCurrentTheme)
const cmdCtrl = useMemo(
() => (
<>
<LoadableIcon icon="ai.AiFillMacCommand" size="0.8rem" /> /
<LoadableIcon icon="md.MdOutlineKeyboardControlKey" size="0.8rem" />
</>
),
[],
)
const isGrid = !displayMode || displayMode === "grid"
useEffect(() => {
const doc = DomHelper.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) {
GridHelper.mergeHandler(setSlots, slots, minMaxSelections)
}
} else if ((event.ctrlKey || event.metaKey) && event.key === "h") {
if (splitEnabled && slots) {
GridHelper.splitHandler(setSlots, slots, "horizontal", "after")
}
} else if ((event.ctrlKey || event.metaKey) && event.key === "v") {
if (splitEnabled && slots) {
GridHelper.splitHandler(setSlots, slots, "vertical", "after")
}
} else if ((event.ctrlKey || event.metaKey) && event.key === "r") {
if (splitEnabled && slots && gridToolbarSpecs) {
GridHelper.insertDimension(
"row",
gridToolbarSpecs,
slots,
setSlots,
"after",
)
}
} else if ((event.ctrlKey || event.metaKey) && event.key === "c") {
if (splitEnabled && slots && gridToolbarSpecs) {
GridHelper.insertDimension(
"column",
gridToolbarSpecs,
slots,
setSlots,
"after",
)
}
}
}
}
doc?.addEventListener("keydown", handleKeyDown)
return () => {
doc?.removeEventListener("keydown", handleKeyDown)
}
}, [
demo,
gridToolbarSpecs,
mergeEnabled,
minMaxSelections,
redoSlots,
showControls,
slots,
splitEnabled,
undoSlots,
])
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 } =
GridHelper.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 } = GridHelper.generateGridAreaAndSizes(
slots,
externalRowHeights,
)
setGridArea(gridArea)
setRowHeights(rowHeights)
setPrevSlotsCount(slots.length)
}
const { areAdjacent, ...rest } = GridHelper.checkIfAdjacent(slots)
setMinMaxSelections(rest)
enableSplit(slots.filter((sc) => sc.isSelected).length >= 1)
enableMerge(areAdjacent)
}
}, [slots, prevSlotCount, externalRowHeights])
useEffect(() => {
if (gridArea) {
const gridTemplate = GridHelper.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 = GridHelper.calculateRowHeights(
slots,
rowHeights,
demo,
)
setGridToolbarSpecs(
GridHelper.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 = DomHelper.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(
GridHelper.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(
GridHelper.selectCellsInSelectedRowCol(
gridToolbarSpecs,
slots,
selectForDelete,
),
true,
)
}
}
const onRowDelete = (isInlineDelete?: boolean) => {
specUpdateAction.current = false
slots &&
gridToolbarSpecs &&
setSlots(
GridHelper.removeHandler(
slots,
gridToolbarSpecs,
"row",
isInlineDelete,
),
)
}
const onColumnDelete = (isInlineDelete?: boolean) => {
specUpdateAction.current = false
slots &&
gridToolbarSpecs &&
setSlots(
GridHelper.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 &&
GridHelper.splitHandler(setSlots, slots, "horizontal", "after")
}
verticalSplitHandler={() =>
slots &&
GridHelper.splitHandler(setSlots, slots, "vertical", "after")
}
mergeHandler={() => {
slots &&
minMaxSelections &&
GridHelper.mergeHandler(setSlots, slots, minMaxSelections)
enableMerge(false)
}}
rowInsertHandler={(placement: "before" | "after") =>
slots &&
gridToolbarSpecs &&
GridHelper.insertDimension(
"row",
gridToolbarSpecs,
slots,
setSlots,
placement,
)
}
columnInsertHandler={(placement: "before" | "after") =>
slots &&
gridToolbarSpecs &&
GridHelper.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 &&
GridHelper.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 &&
GridHelper.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 &&
GridHelper.splitHandler(
setSlots,
slots,
"horizontal",
placement,
sConfig,
),
verticalSplitHandler: (placement: "before" | "after") =>
slots &&
GridHelper.splitHandler(
setSlots,
slots,
"vertical",
placement,
sConfig,
),
mergeHandler: () => {
slots &&
minMaxSelections &&
mergeEnabled &&
GridHelper.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}>
<LoadableIcon
classes="me-3"
color="white"
slot={ArPopoverSlots.ANCHOR}
icon="fa.FaKeyboard"
/>
<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}>
<LoadableIcon
slot={ArPopoverSlots.ANCHOR}
icon="md.MdInfoOutline"
color={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>
)
}
export default BuilderLayout

54
src/BuilderSlot.component.scss Executable file
View File

@@ -0,0 +1,54 @@
.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;
}
}
}
}

96
src/BuilderSlot.tsx Executable file
View File

@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react"
import { ArDndItemTypes, ArSlotViewMode, BuilderSlotProps } from "@armco/types"
import { Draggable, Droppable, SlotTools } from ".."
import "./BuilderSlot.component.scss"
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
useEffect(() => {
setSelected(config.isSelected)
}, [config.isSelected])
const finalSlotStyles = {
gridArea: config.gridArea,
...slotStyles,
...(style || {}),
}
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" : ""} ${
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>
)
}
export default BuilderSlot

1
src/Layout.component.scss Executable file
View File

@@ -0,0 +1 @@
.ar-Layout {}

6
src/Layout.test.ts Executable file
View File

@@ -0,0 +1,6 @@
import React from "react"
import Layout from "./Layout"
describe("Layout", () => {
it("renders without error", () => {})
})

73
src/Layout.tsx Executable file
View File

@@ -0,0 +1,73 @@
import { v4 as uuid } from "uuid"
import { useEffect, useState } from "react"
import { ArAlertType, LayoutProps, SlotDescriptor } from "@armco/types"
import {
BuilderLayout,
ReleaseLayout,
Network,
useAppDispatch,
notify,
} from "../.."
import "./Layout.component.scss"
const dummyGridSpecs = { rows: 5, columns: 5 }
const Layout = (props: LayoutProps) => {
const { mode, demo, gridSpecs, url } = props
const propsClone = { ...props }
propsClone.gridSpecs = demo ? gridSpecs || dummyGridSpecs : gridSpecs
const [localSlots, setLocalSlots] = useState<
Array<SlotDescriptor> | undefined
>()
const [localGridTemplate, setLocalGridTemplate] = useState<
string | undefined
>()
propsClone.slots = localSlots
propsClone.gridTemplate = localGridTemplate
const dispatch = useAppDispatch()
useEffect(() => {
JSON.stringify(props.slots) !== JSON.stringify(localSlots) &&
setLocalSlots(props.slots)
}, [props.slots])
useEffect(() => {
setLocalGridTemplate(props.gridTemplate)
}, [props.gridTemplate])
useEffect(() => {
!(localSlots || localGridTemplate) &&
url &&
Network.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) =>
dispatch(
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}
/>
)
}
export default Layout

View File

@@ -0,0 +1,18 @@
.ar-LayoutControlPanel {
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;
}
}
}

267
src/LayoutControlPanel.tsx Executable file
View File

@@ -0,0 +1,267 @@
import { Ref, forwardRef } from "react"
import {
ArPopoverSlots,
ArThemes,
FunctionType,
LayoutControlPanelProps,
} from "@armco/types"
import { LoadableIcon, Tooltip, getCurrentTheme, useAppSelector } from "../.."
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 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 }}
>
<LoadableIcon icon={icon} color={iconColor} />
</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 = useAppSelector<string>(getCurrentTheme)
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>
)
},
)
export default LayoutControlPanel

30
src/ReleaseLayout.tsx Executable file
View File

@@ -0,0 +1,30 @@
import { ArSlotViewMode, ReleaseLayoutProps, SlotProps } from "@armco/types"
import { Slot } from ".."
import "./ReleaseLayout.component.scss"
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 default ReleaseLayout

25
src/ReleaseSlot.tsx Executable file
View File

@@ -0,0 +1,25 @@
import { ReleaseSlotProps } from "@armco/types"
import "./ReleaseSlot.component.scss"
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>
)
}
export default ReleaseSlot

54
src/Resizable.component.scss Executable file
View File

@@ -0,0 +1,54 @@
.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);
}
}
}
}

121
src/Resizable.tsx Executable file
View File

@@ -0,0 +1,121 @@
import {
ComponentType,
ForwardRefExoticComponent,
LazyExoticComponent,
PropsWithoutRef,
ReactElement,
RefAttributes,
Suspense,
cloneElement,
lazy,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { ArDirections, ResizableProps } from "@armco/types"
import "./Resizable.component.scss"
type ComponentOrForwardRef =
| ComponentType<any>
| ForwardRefExoticComponent<PropsWithoutRef<any> & RefAttributes<any>>
const components: {
[key: string]: LazyExoticComponent<ComponentOrForwardRef>
} = {
Slot: lazy(() => import("../Slot")),
// Add more components as needed
}
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 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 newWidth = childRect.width + dragOffset
const newWidth = e.clientX - childRect.left
const newFrValue = newWidth / childRect.width
// 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
},
[],
)
useEffect(() => {
document.addEventListener("mousemove", mouseMoveHandler)
document.addEventListener("mouseup", mouseUpHandler)
return () => {
document.removeEventListener("mousemove", mouseMoveHandler)
document.removeEventListener("mouseup", mouseUpHandler)
}
}, [mouseMoveHandler, mouseUpHandler])
useEffect(() => {
if (childRef.current) {
const rect = childRef.current.getBoundingClientRect()
}
}, [])
const Component = (componentName && components[componentName]) as
| React.ComponentType<any>
| undefined
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>
)
}
export default Resizable

95
src/Slot.tsx Executable file
View File

@@ -0,0 +1,95 @@
import { ReactNode, useEffect, useMemo, useState } from "react"
import {
ArComponentResources,
ArSlotViewMode,
LibRepoType,
SlotDescriptor,
SlotProps,
} from "@armco/types"
import * as atoms from ".."
import * as molecules from "../../molecules"
import { BuilderSlot, GridHelper, Layout, ReleaseSlot } from "../.."
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 repo: LibRepoType = useMemo(() => ({ ...atoms, ...molecules }), [])
useEffect(() => {
const content = config.content as {
componentName?: string
source?: ArComponentResources
descriptor?: { content: Array<SlotDescriptor> }
props?: { [key: string]: any }
}
if (content) {
if (content.componentName && repo[content.componentName]) {
// const source = content.source || ArComponentResources.STUFFLE
const Component = 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 &&
GridHelper.generateGridAreaAndSizes(
content?.descriptor?.content,
"auto",
)
const layout =
gaResponse &&
GridHelper.generateGridTemplate(gaResponse.gridArea, "auto")
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)
}
}, [
config.content,
layoutMode,
mode,
repo,
props.lastSelected,
props.hideBuildModePaddings,
])
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 }}
/>
)
}
export default Slot

172
src/SlotTools.tsx Executable file
View File

@@ -0,0 +1,172 @@
import { useEffect, useState } from "react"
import {
ArPopoverSlots,
ArSlotViewMode,
SlotControlProps,
SlotToolsProps,
} from "@armco/types"
import { Tooltip } from ".."
import "./SlotTools.component.scss"
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 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)
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,
])
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>
)
}
export default SlotTools

View File

@@ -1,12 +0,0 @@
import { useRoutes } from "react-router-dom"
import * as pages from "./pages"
import Helper from "./utils/helper"
import ROUTES from "./routes"
Helper.populateComponentsInRoutes(ROUTES, pages)
interface RouterProps {}
const Router = (props: RouterProps): JSX.Element | null => useRoutes(ROUTES)
export default Router

View File

@@ -1,6 +0,0 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux"
import type { RootState, AppDispatch } from "./store"
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

View File

@@ -1,3 +0,0 @@
.c-Home {
}

View File

@@ -1,18 +0,0 @@
import { createSlice } from "@reduxjs/toolkit"
export interface HomeState {}
const initialState: HomeState = {}
export const homeSlice = createSlice({
name: "home",
initialState,
reducers: {
increment: (state) => {},
},
extraReducers: (builder) => {},
})
export const { increment } = homeSlice.actions
export default homeSlice.reducer

View File

@@ -1,8 +0,0 @@
import React from "react"
import Home from "./Home"
describe("Home", () => {
it("renders without error", () => {
})
})

View File

@@ -1,14 +0,0 @@
import React from "react"
import "./Home.module.scss"
interface HomeProps {}
const Home = props => {
return (
<div className="c-Home">
In Page Home
</div>
)
}
export default Home

View File

@@ -1,3 +0,0 @@
import Home from "./Home.jsx"
export default Home

View File

@@ -1,7 +0,0 @@
/* PLOP_INJECT_IMPORT */
import Home from "./Home"
export {
/* PLOP_INJECT_EXPORT */
Home,
}

View File

@@ -1,9 +0,0 @@
const ROUTES = [
{
path: "/",
class: "landing",
element: "Home",
},
]
export default ROUTES

View File

@@ -1,18 +0,0 @@
html, body, #root {
height: 100%;
width: 100%;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@@ -1,17 +0,0 @@
import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"
import counterReducer from "../features/counter/counterSlice"
export const store = configureStore({
reducer: {
counter: counterReducer,
},
})
export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>

View File

@@ -1,6 +0,0 @@
interface RouteConfig {
path: String
class?: String
element: String | JSX.Element | null
children?: Array<RouteConfig>
}

View File

@@ -1,15 +0,0 @@
class Helper {
static populateComponentsInRoutes(routes: RouteConfig[], components: any) {
routes &&
routes.forEach((route) => {
const Component: JSX.ElementType =
components[route.element as keyof object]
route.element = <Component />
if (route.children) {
Helper.populateComponentsInRoutes(route.children, components)
}
})
}
}
export default Helper

View File

@@ -1,67 +0,0 @@
import { useState } from "react"
import { useAppSelector, useAppDispatch } from "../../app/hooks"
import {
decrement,
increment,
incrementByAmount,
incrementAsync,
incrementIfOdd,
selectCount,
} from "./counterSlice"
export function Counter() {
const count = useAppSelector(selectCount)
const dispatch = useAppDispatch()
const [incrementAmount, setIncrementAmount] = useState("2")
const incrementValue = Number(incrementAmount) || 0
return (
<div>
<div className="row">
<button
className="button"
aria-label="Decrement value"
onClick={() => dispatch(decrement())}
>
-
</button>
<span className="value">{count}</span>
<button
className="button"
aria-label="Increment value"
onClick={() => dispatch(increment())}
>
+
</button>
</div>
<div className="row">
<input
className="textbox"
aria-label="Set increment amount"
value={incrementAmount}
onChange={(e) => setIncrementAmount(e.target.value)}
/>
<button
className="button"
onClick={() => dispatch(incrementByAmount(incrementValue))}
>
Add Amount
</button>
<button
className="asyncButton"
onClick={() => dispatch(incrementAsync(incrementValue))}
>
Add Async
</button>
<button
className="button"
onClick={() => dispatch(incrementIfOdd(incrementValue))}
>
Add If Odd
</button>
</div>
</div>
)
}

View File

@@ -1,6 +0,0 @@
// A mock function to mimic making an async request for data
export function fetchCount(amount = 1) {
return new Promise<{ data: number }>((resolve) =>
setTimeout(() => resolve({ data: amount }), 500),
)
}

View File

@@ -1,34 +0,0 @@
import counterReducer, {
CounterState,
increment,
decrement,
incrementByAmount,
} from "./counterSlice"
describe("counter reducer", () => {
const initialState: CounterState = {
value: 3,
status: "idle",
}
it("should handle initial state", () => {
expect(counterReducer(undefined, { type: "unknown" })).toEqual({
value: 0,
status: "idle",
})
})
it("should handle increment", () => {
const actual = counterReducer(initialState, increment())
expect(actual.value).toEqual(4)
})
it("should handle decrement", () => {
const actual = counterReducer(initialState, decrement())
expect(actual.value).toEqual(2)
})
it("should handle incrementByAmount", () => {
const actual = counterReducer(initialState, incrementByAmount(2))
expect(actual.value).toEqual(5)
})
})

View File

@@ -1,84 +0,0 @@
import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"
import { RootState, AppThunk } from "../../app/store"
import { fetchCount } from "./counterAPI"
export interface CounterState {
value: number
status: "idle" | "loading" | "failed"
}
const initialState: CounterState = {
value: 0,
status: "idle",
}
// The function below is called a thunk and allows us to perform async logic. It
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
// will call the thunk with the `dispatch` function as the first argument. Async
// code can then be executed and other actions can be dispatched. Thunks are
// typically used to make async requests.
export const incrementAsync = createAsyncThunk(
"counter/fetchCount",
async (amount: number) => {
const response = await fetchCount(amount)
// The value we return becomes the `fulfilled` action payload
return response.data
},
)
export const counterSlice = createSlice({
name: "counter",
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: (state) => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn"t actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
decrement: (state) => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
},
},
// The `extraReducers` field lets the slice handle actions defined elsewhere,
// including actions generated by createAsyncThunk or in other slices.
extraReducers: (builder) => {
builder
.addCase(incrementAsync.pending, (state) => {
state.status = "loading"
})
.addCase(incrementAsync.fulfilled, (state, action) => {
state.status = "idle"
state.value += action.payload
})
.addCase(incrementAsync.rejected, (state) => {
state.status = "failed"
})
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
// The function below is called a selector and allows us to select a value from
// the state. Selectors can also be defined inline where they"re used instead of
// in the slice file. For example: `useSelector((state: RootState) => state.counter.value)`
export const selectCount = (state: RootState) => state.counter.value
// We can also write thunks by hand, which may contain both sync and async logic.
// Here"s an example of conditionally dispatching actions based on current state.
export const incrementIfOdd =
(amount: number): AppThunk =>
(dispatch, getState) => {
const currentValue = selectCount(getState())
if (currentValue % 2 === 1) {
dispatch(incrementByAmount(amount))
}
}
export default counterSlice.reducer

3
src/index.ts Executable file
View File

@@ -0,0 +1,3 @@
import Layout from "./Layout"
export default Layout

View File

@@ -1,19 +0,0 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { BrowserRouter } from "react-router-dom"
import { Provider } from "react-redux"
import { store } from "./app/store"
import Router from "./app/Router"
import "./app/static/styles/global.scss"
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
root.render(
<React.StrictMode>
<BrowserRouter>
<Provider store={store}>
<Router />
</Provider>
</BrowserRouter>
</React.StrictMode>,
)