diff --git a/frontend-react/package.json b/frontend-react/package.json index 4f8a0d66..7697e171 100644 --- a/frontend-react/package.json +++ b/frontend-react/package.json @@ -61,6 +61,8 @@ "directory": "frontend-react" }, "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/utilities": "^3.2.2", "@popperjs/core": "^2.11.8", "bootstrap": "^5.3.2", "bootstrap-icons": "^1.11.2", diff --git a/frontend-react/pnpm-lock.yaml b/frontend-react/pnpm-lock.yaml index beb9728d..a05ec956 100644 --- a/frontend-react/pnpm-lock.yaml +++ b/frontend-react/pnpm-lock.yaml @@ -5,6 +5,12 @@ settings: excludeLinksFromLockfile: false dependencies: + '@dnd-kit/core': + specifier: ^6.1.0 + version: 6.1.0(react-dom@18.2.0)(react@18.2.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.2.0) '@popperjs/core': specifier: ^2.11.8 version: 2.11.8 @@ -298,6 +304,37 @@ packages: resolution: {integrity: sha512-N43uWud8ZXuVjza423T9ZCIJsaZhFekmakt7S9bvogTxqdVGbRobjR663s0+uW0Rz9e+Pa8I6jUuWtoBLQD2Mw==} dev: true + /@dnd-kit/accessibility@3.1.0(react@18.2.0): + resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.2.0 + tslib: 2.6.2 + dev: false + + /@dnd-kit/core@6.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@dnd-kit/accessibility': 3.1.0(react@18.2.0) + '@dnd-kit/utilities': 3.2.2(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.6.2 + dev: false + + /@dnd-kit/utilities@3.2.2(react@18.2.0): + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.2.0 + tslib: 2.6.2 + dev: false + /@esbuild/android-arm64@0.19.5: resolution: {integrity: sha512-5d1OkoJxnYQfmC+Zd8NBFjkhyCNYwM4n9ODrycTFY6Jk1IGiZ+tjVJDDSwDt77nK+tfpGP4T50iMtVi4dEGzhQ==} engines: {node: '>=12'} diff --git a/frontend-react/src/app/app.tsx b/frontend-react/src/app/app.tsx new file mode 100644 index 00000000..35de473a --- /dev/null +++ b/frontend-react/src/app/app.tsx @@ -0,0 +1,9 @@ +import { LayoutEditor } from '../lib/application/routes/dashboard-editor/layout-editor'; + +export function App() { + return ( +
+ +
+ ); +} diff --git a/frontend-react/src/app/index.tsx b/frontend-react/src/app/index.tsx index 3f1d2716..f9949db9 100644 --- a/frontend-react/src/app/index.tsx +++ b/frontend-react/src/app/index.tsx @@ -1,6 +1,8 @@ -import { initTelestion, registerWidgets, UserData } from '../lib'; +import { registerWidgets, UserData } from '../lib'; import { simpleWidget } from './widgets/simple-widget'; import { errorWidget } from './widgets/error-widget'; +import ReactDOM from 'react-dom/client'; +import { App } from './app.tsx'; const defaultUserData: UserData = { version: '0.0.1', @@ -29,8 +31,11 @@ const defaultUserData: UserData = { registerWidgets(simpleWidget, errorWidget); -await initTelestion({ - version: '0.0.1', - defaultBackendUrl: 'ws://localhost:9222', - defaultUserData -}); +// await initTelestion({ +// version: '0.0.1', +// defaultBackendUrl: 'ws://localhost:9222', +// defaultUserData +// }); + +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +ReactDOM.createRoot(document.getElementById('root')!).render(); diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md new file mode 100644 index 00000000..178104e1 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md @@ -0,0 +1,65 @@ +# Layout Editor + +Date: 2024-01-14 + +Designed by: +- [Zuri Klaschka](https://github.com/pklaschka) + +The layout editor for dashboards. + +## Interface Definition + +### Inputs + +- the current layout + +### Outputs / Events + +- layout change +- widget selected + +## Behavior + +```mermaid +zenuml + title Layout Editor Behavior + + @Actor User + @Boundary editor as "Layout Editor" + @Entity state as "State" + + User->editor.beginInteraction() { + while ("edits not final") { + preview = User->editor.editLayout() + } + User->editor.endInteraction() { + newState = calculateNewState() + updatedState = state.update(newState) + return updatedState + } + } +``` + +Note that the state is not updated until the user ends the interaction. + +There are therefore two very distinct phases during an interaction: + +1. the preview phase where any changes are visualized in real-time to the user. +2. the commit phase where the changes are interpolated to the closest applicable change (rounded to full grid cell + units, etc.), and actually applied to the state. + +This means that the application to the state can be performed without any regard to the actual user interaction, and +written and tested independently. + +## State Management + +The state is considered to be immutable. Updates to the state are performed using pure functions that return the new +state based on the previous state and the new data. + +## User Interaction + +User interaction can be performed using both the mouse and the keyboard. + +## Changes + +n/a diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx new file mode 100644 index 00000000..4c852678 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx @@ -0,0 +1,34 @@ +import { forwardRef, useCallback } from 'react'; +import { Bounds } from '../model/layout-editor-model.ts'; +import { clsx } from 'clsx'; +import styles from './layout-editor.module.css'; + +export const EmptyCell = forwardRef< + HTMLDivElement, + { + y: number; + x: number; + onCreate?(bounds: Bounds): void; + } +>(function EmptyCell(props, ref) { + const onClick = useCallback(() => { + props.onCreate?.({ + x: props.x, + y: props.y, + width: 1, + height: 1 + }); + }, [props]); + + return ( +
+ ); +}); diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx new file mode 100644 index 00000000..fae0e655 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx @@ -0,0 +1,104 @@ +import { Bounds, Coordinate } from '../model/layout-editor-model.ts'; +import { DndContext, DragEndEvent, useDraggable } from '@dnd-kit/core'; +import { useCallback, useState } from 'react'; +import { CSS as CSSUtil } from '@dnd-kit/utilities'; +import { ResizeHandle } from './resize-handle.tsx'; +import styles from './layout-editor.module.css'; +import { clsx } from 'clsx'; + +export function LayoutEditorWidgetInstance(props: { + bounds: Bounds; + id: string; + selected?: boolean; + onSelect?(bounds: Bounds): void; + onResize?(bounds: Bounds, resizeDelta: Coordinate): void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + node: widgetInstanceNode + } = useDraggable({ + id: props.id, + data: { + widgetId: props.id, + bounds: props.bounds + } + }); + const [resizeDelta, setResizeDelta] = useState({ + x: 0, + y: 0 + }); + const onResizeEnd = useCallback( + (event: DragEndEvent) => { + const resizeDelta = event.delta; // delta in px + const oldBounds = props.bounds; // previous bounds to select + + if (!widgetInstanceNode.current) + throw new Error('widgetInstanceNode.current is null'); + + const originalNodeWidth = + widgetInstanceNode.current.offsetWidth - resizeDelta.x; + const originalNodeHeight = + widgetInstanceNode.current.offsetHeight - resizeDelta.y; + + const singleCellWidth = originalNodeWidth / oldBounds.width; + const singleCellHeight = originalNodeHeight / oldBounds.height; + + const onResizeDelta = { + x: Math.round(resizeDelta.x / singleCellWidth), + y: Math.round(resizeDelta.y / singleCellHeight) + }; + + props.onResize?.(oldBounds, onResizeDelta); + setResizeDelta({ x: 0, y: 0 }); + }, + [props, widgetInstanceNode] + ); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
props.onSelect?.(props.bounds)} + // disable dnd-kit keyboard shortcuts since we have our own + tabIndex={undefined} + role={undefined} + aria-describedby={undefined} + aria-disabled={undefined} + aria-roledescription={undefined} + > + {/*Label*/} +
{props.id}
+ {/*Resize handle*/} + {props.selected && ( + setResizeDelta(evt.delta)} + onDragCancel={() => setResizeDelta({ x: 0, y: 0 })} + onDragEnd={onResizeEnd} + > + + + )} +
+ ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css new file mode 100644 index 00000000..67c6a5d5 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css @@ -0,0 +1,96 @@ +.layoutEditor { + /*Input Props*/ + --width: 4; /* Number of columns */ + --height: 4; /* Number of rows */ + --gap: 4; /* Gap between cells in pixels */ + + /*Styles*/ + display: grid; + grid-template-columns: repeat(var(--width), 1fr); + grid-template-rows: repeat(var(--height), 1fr); + aspect-ratio: 16 / 9; + gap: calc(var(--gap) * 1px); + padding: 16px; + overflow: hidden; +} + +.emptyCell { + /*Input props*/ + --x: 1; /* Column Index (0-based) */ + --y: 1; /* Row Index (0-based) */ + + /*Styles*/ + grid-area: calc(var(--y) + 1) / calc(var(--x) + 1) / span 1 / span 1; + background: var(--bs-secondary-bg) +} + +.cursor { + /*Input props*/ + --x: 1; /* Column Index (0-based) */ + --y: 1; /* Row Index (0-based) */ + + /*Styles*/ + grid-area: calc(var(--y) + 1) / calc(var(--x) + 1) / span 1 / span 1; + background: var(--bs-red); + border-radius: 50%; + width: 50%; + height: 50%; + margin: auto; + opacity: 0.3; + pointer-events: none; + z-index: 2; +} + +.widgetInstance { + /*Input props*/ + --x: 1; /* Column Index (0-based) */ + --y: 1; /* Row Index (0-based) */ + --width: 1; /* Number of columns */ + --height: 1; /* Number of rows */ + + /*Styles*/ + + /*positioning*/ + grid-area: calc(var(--y) + 1) / calc(var(--x) + 1) / span var(--height) / span var(--width); + + /*styling the widget instance itself*/ + background: var(--bs-blue); + + /*center the content*/ + display: flex; + align-items: center; + justify-content: center; + + /*show the resize handle*/ + position: relative; + overflow: visible; + + cursor: pointer; + + &.isSelected, + &.isDragged { + border: 1px solid white; + cursor: move; + z-index: 1; + } + + &.isDragged { + z-index: 2; + } +} + +.widgetInstanceLabel { + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} + +.resizeHandle { + position: absolute; + bottom: -8px; + right: -8px; + width: 16px; + height: 16px; + background: var(--bs-white); + cursor: nwse-resize; +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx new file mode 100644 index 00000000..af374c82 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx @@ -0,0 +1,339 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { + Bounds, + Coordinate, + deleteSelected, + fillWith, + getBounds, + getWidgetIds, + LayoutEditorState, + moveSelected, + moveSelection, + resizeSelected, + select, + selectedWidgetId +} from '../model/layout-editor-model.ts'; +import { transition } from '../util.tsx'; +import { + Key, + ModifierKey, + useKeyboardShortcut +} from '../hooks/use-keyboard-shortcut.tsx'; +import { DndContext, DragEndEvent } from '@dnd-kit/core'; +import { gap } from '../constants.tsx'; +import { z } from 'zod'; +import { EmptyCell } from './empty-cell.tsx'; +import { LayoutEditorWidgetInstance } from './layout-editor-widget-instance.tsx'; +import { clsx } from 'clsx'; +import styles from './layout-editor.module.css'; + +interface LayoutEditorProps { + width: number; + height: number; +} + +export function LayoutEditor({ width, height }: LayoutEditorProps) { + const [state, setState] = useState({ + selection: { + x: 0, + y: 0 + }, + layout: [ + ['.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.'] + ] + } satisfies LayoutEditorState); + + const widgetInstances = useMemo(() => { + return getWidgetIds(state).map(id => { + const bounds = getBounds(state, id); + return { + id, + bounds + }; + }); + }, [state]); + + height = state.layout.length; + width = state.layout[0].length; + + function applyLayoutChange( + stateFn: (state: LayoutEditorState) => LayoutEditorState + ) { + transition(() => setState(s => stateFn(s))); + } + + // region Keyboard Shortcuts + useKeyboardShortcut( + `${Key.Down}`, + () => + applyLayoutChange(state => + moveSelection(state, { + x: 0, + y: 1 + }) + ), + () => true + ); + + useKeyboardShortcut( + `${Key.Up}`, + () => + applyLayoutChange(state => + moveSelection(state, { + x: 0, + y: -1 + }) + ), + () => true + ); + + useKeyboardShortcut( + `${Key.Left}`, + () => + applyLayoutChange(state => + moveSelection(state, { + x: -1, + y: 0 + }) + ), + () => true + ); + + useKeyboardShortcut( + `${Key.Right}`, + () => + applyLayoutChange(state => + moveSelection(state, { + x: 1, + y: 0 + }) + ), + () => true + ); + + useKeyboardShortcut( + `${ModifierKey.Alt}+${Key.Left}`, + () => { + applyLayoutChange(state => + moveSelected(state, { + x: -1, + y: 0 + }) + ); + }, + () => true + ); + + useKeyboardShortcut( + `${ModifierKey.Alt}+${Key.Right}`, + () => { + applyLayoutChange(state => + moveSelected(state, { + x: 1, + y: 0 + }) + ); + }, + () => true + ); + + useKeyboardShortcut( + `${ModifierKey.Alt}+${Key.Up}`, + () => { + applyLayoutChange(state => + moveSelected(state, { + x: 0, + y: -1 + }) + ); + }, + () => true + ); + + useKeyboardShortcut( + `${ModifierKey.Alt}+${Key.Down}`, + () => { + applyLayoutChange(state => + moveSelected(state, { + x: 0, + y: 1 + }) + ); + }, + () => true + ); + + useKeyboardShortcut( + `${ModifierKey.Alt}+${ModifierKey.Shift}+${Key.Left}`, + () => { + applyLayoutChange(state => + resizeSelected(state, { + x: -1, + y: 0 + }) + ); + }, + () => true + ); + + useKeyboardShortcut( + `${ModifierKey.Alt}+${ModifierKey.Shift}+${Key.Right}`, + () => { + applyLayoutChange(state => + resizeSelected(state, { + x: 1, + y: 0 + }) + ); + }, + () => true + ); + + useKeyboardShortcut( + `${ModifierKey.Alt}+${ModifierKey.Shift}+${Key.Up}`, + () => { + applyLayoutChange(state => + resizeSelected(state, { + x: 0, + y: -1 + }) + ); + }, + () => true + ); + + useKeyboardShortcut( + `${ModifierKey.Alt}+${ModifierKey.Shift}+${Key.Down}`, + () => { + applyLayoutChange(state => + resizeSelected(state, { + x: 0, + y: 1 + }) + ); + }, + () => true + ); + + useKeyboardShortcut( + `${Key.Delete}`, + () => { + applyLayoutChange(state => deleteSelected(state)); + }, + () => true + ); + + useKeyboardShortcut( + `${Key.Enter}`, + () => { + applyLayoutChange(state => { + return fillWith(state, window.crypto.randomUUID(), { + x: state.selection.x, + y: state.selection.y, + width: 1, + height: 1 + }); + }); + }, + () => true + ); + // endregion + + /** + * A reference to a single background cell for size calculations. + * + * Corresponds to a grid cell with a size of 1x1. + */ + const singleCellRef = useRef(null); + + const onMoveEnd = useCallback((event: DragEndEvent) => { + if (!singleCellRef.current) + throw new Error('singleCellRef.current is null'); + const cellRect = singleCellRef.current.getBoundingClientRect(); + const deltaX = Math.round(event.delta.x / (cellRect.width + gap)); + const deltaY = Math.round(event.delta.y / (cellRect.height + gap)); + + const oldCoords = z + .object({ + x: z.number(), + y: z.number() + }) + .parse(event.active.data.current?.bounds); + + console.log('deltaX', deltaX); + console.log('deltaY', deltaY); + + applyLayoutChange(state => + moveSelected(select(state, oldCoords), { + x: deltaX, + y: deltaY + }) + ); + }, []); + + const onLayoutEditorWidgetInstanceSelect = useCallback((bounds: Bounds) => { + applyLayoutChange(state => select(state, bounds)); + }, []); + + const onLayoutEditorWidgetInstanceResize = useCallback( + (bounds: Bounds, resizeDelta: Coordinate) => { + applyLayoutChange(state => + resizeSelected(select(state, bounds), resizeDelta) + ); + }, + [] + ); + const onEmptyCellCreate = useCallback((bounds: Bounds) => { + applyLayoutChange(state => { + const widgetId = window.crypto.randomUUID(); + return select(fillWith(state, widgetId, bounds), bounds); + }); + }, []); + + return ( + +
+ {/*Background cells:*/} + {state.layout.map((row, y) => + row.map((_, x) => ( + + )) + )} + {/*Widget Instances*/} + {widgetInstances.map(({ id, bounds }) => ( + + ))} + {/* Cursor*/} +
+
+ + ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx new file mode 100644 index 00000000..1bd0d216 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx @@ -0,0 +1,23 @@ +import { useDraggable } from '@dnd-kit/core'; +import styles from './layout-editor.module.css'; + +export function ResizeHandle() { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: 'resize-handle' + }); + + return ( +
+ ); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/constants.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/constants.tsx new file mode 100644 index 00000000..02ef9076 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/constants.tsx @@ -0,0 +1,6 @@ +/** + * The gap between the grid lines in pixels. + * + * Used for calculating diffs when moving elements. + */ +export const gap = 4; diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/hooks/use-keyboard-shortcut.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/hooks/use-keyboard-shortcut.tsx new file mode 100644 index 00000000..27f2d85d --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/hooks/use-keyboard-shortcut.tsx @@ -0,0 +1,68 @@ +import { useEffect } from "react"; + +/** + * Registers a keyboard shortcut while the component is mounted. + * @param shortcut - A shortcut string. See {@link shortcut} for more information. + * @param callback - A callback function that is called when the shortcut is pressed. + * @param enabled - A function that returns whether the shortcut is enabled. + */ +export const useKeyboardShortcut = ( + shortcut: shortcut, + callback: (event: KeyboardEvent) => void, + enabled = () => true +) => { + // parse shortcut string + const [...keys] = shortcut.toLowerCase().split('+'); + + // register shortcut + useEffect(() => { + const listener = (event: KeyboardEvent) => { + console.debug('useKeyboardShortcut', shortcut, event.code); + if (!enabled()) return; + console.debug('useKeyboardShortcut', shortcut, 'enabled'); + if (keys.includes(ModifierKey.Ctrl) && !event.ctrlKey) return; + if (keys.includes(ModifierKey.Alt) && !event.altKey) return; + if (keys.includes(ModifierKey.Shift) && !event.shiftKey) return; + if (keys.includes(ModifierKey.Meta) && !event.metaKey) return; + if (!keys.includes(ModifierKey.Ctrl) && event.ctrlKey) return; + if (!keys.includes(ModifierKey.Alt) && event.altKey) return; + if (!keys.includes(ModifierKey.Shift) && event.shiftKey) return; + if (!keys.includes(ModifierKey.Meta) && event.metaKey) return; + console.debug('useKeyboardShortcut', shortcut, 'modifiers match'); + if (keys.at(-1) !== event.code.toLowerCase()) return; + console.debug('useKeyboardShortcut', shortcut, 'keys match'); + event.preventDefault(); + callback(event); + }; + + window.addEventListener('keydown', listener); + return () => window.removeEventListener('keydown', listener); + }, [enabled, callback, keys]); +}; +type modifierKey = ModifierKey; +type modifier = `${modifierKey}+`; +type shortcut = + | `${Key}` + | `${modifier}${Key}` + | `${modifier}${modifier}${Key}` + | `${modifier}${modifier}${modifier}${Key}`; + +export enum ModifierKey { + Ctrl = 'ctrl', + Alt = 'alt', + Shift = 'shift', + Meta = 'meta' +} + +export enum Key { + A = 'KeyA', + S = 'KeyS', + D = 'KeyD', + W = 'KeyW', + Delete = 'Delete', + Enter = 'Enter', + Up = 'ArrowUp', + Down = 'ArrowDown', + Left = 'ArrowLeft', + Right = 'ArrowRight' +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts new file mode 100644 index 00000000..1ed2c524 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts @@ -0,0 +1,3 @@ +export * from './components/layout-editor.tsx'; +export * from './util.tsx'; +export * from './constants.tsx'; diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts new file mode 100644 index 00000000..bf682b87 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, expect, test } from 'vitest'; +import { + deleteSelected, + fillWith, + LayoutEditorState, + moveSelected +} from './layout-editor-model.ts'; + +let state: LayoutEditorState = { + layout: [ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', '.', '.', '.'] + ], + selection: { x: 3, y: 3 } +}; + +function layoutToString(layout: string[][]) { + return layout.map(row => row.join('')).join('\n'); +} + +beforeEach(() => { + state = { + layout: [ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', '.', '.', '.'] + ], + selection: { x: 4, y: 4 } + }; +}); + +test('moving a widget', () => { + const moved = moveSelected(state, { x: 1, y: 1 }); + expect(moved.selection).toEqual({ x: 4, y: 4 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', 'c', 'c'], + ['.', '.', '.', '.', 'c', 'c'] + ]) + ); + const overwritten = moveSelected(moved, { x: 0, y: -4 }); + expect(layoutToString(overwritten.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'c', 'c'], + ['.', '.', '.', 'a', 'c', 'c'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.', '.'] + ]) + ); +}); + +test('deleting a widget', () => { + const moved = moveSelected(state, { x: 1, y: 1 }); + expect(moved.selection).toEqual({ x: 4, y: 4 }); + + const deleted = deleteSelected(moved); + + expect(layoutToString(deleted.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.', '.'] + ]) + ); +}); + +test('filling a pattern', () => { + const newState = fillWith(state, 'd', { x: 1, y: 1, width: 2, height: 2 }); + expect(layoutToString(newState.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'a', '.'], + ['.', 'd', 'd', 'a', 'a', '.'], + ['.', 'd', 'd', 'b', '.', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', '.', '.', '.'] + ]) + ); +}); diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts new file mode 100644 index 00000000..a8cddcab --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts @@ -0,0 +1,211 @@ +export interface Coordinate { + x: number; + y: number; +} + +export interface Bounds extends Coordinate { + width: Bounds['x']; + height: Bounds['y']; +} + +type WidgetId = string; + +export interface LayoutEditorState { + layout: WidgetId[][]; + selection: Coordinate; +} + +export function selectedWidgetId( + state: LayoutEditorState +): WidgetId | undefined { + const { selection, layout } = state; + const { x, y } = selection; + + if (x < 0 || y < 0) { + throw new Error('Invalid selection'); + } + + if (y >= layout.length) { + throw new Error('Invalid selection'); + } + const row = layout[y]; + + if (x >= row.length) { + throw new Error('Invalid selection'); + } + const widgetId = row[x]; + + if (widgetId === '.') { + return undefined; + } + return widgetId; +} + +function isAscending(...values: number[]) { + for (let i = 1; i < values.length; i++) { + if (values[i] < values[i - 1]) { + return false; + } + } + return true; +} + +export function fillWith( + state: LayoutEditorState, + widgetId: WidgetId, + bounds: Bounds +): LayoutEditorState { + const { x, y, width, height } = bounds; + const { layout } = state; + + return { + ...state, + layout: layout.map((row, rowIndex) => + isAscending(y, rowIndex, y + height - 1) // in fill area + ? row.map((cell, columnIndex) => + isAscending(x, columnIndex, x + width - 1) // in fill area + ? widgetId + : cell + ) + : row + ) + }; +} + +export function select( + state: LayoutEditorState, + selection: Coordinate +): LayoutEditorState { + return { + ...state, + selection + }; +} + +export function moveSelection( + state: LayoutEditorState, + delta: Coordinate +): LayoutEditorState { + const { selection } = state; + const { x, y } = selection; + + return select(state, { + x: Math.max(0, Math.min(state.layout[0].length - 1, x + delta.x)), + y: Math.max(0, Math.min(state.layout.length - 1, y + delta.y)) + }); +} + +export function getBounds( + state: LayoutEditorState, + widgetId: WidgetId +): Bounds { + const { layout } = state; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (let y = 0; y < layout.length; y++) { + const row = layout[y]; + + for (let x = 0; x < row.length; x++) { + if (row[x] === widgetId) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (!Number.isFinite(minX)) { + throw new Error('Widget not found'); + } + + return { + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1 + }; +} + +export function getWidgetIds(state: LayoutEditorState): WidgetId[] { + const { layout } = state; + const widgetIds = new Set(); + + for (const row of layout) { + for (const cell of row) { + if (cell !== '.') { + widgetIds.add(cell); + } + } + } + + return Array.from(widgetIds); +} + +export function transformBounds(bounds: Bounds, delta: Bounds): Bounds { + return { + x: Math.max(0, bounds.x + delta.x), + y: Math.max(0, bounds.y + delta.y), + width: Math.max(1, bounds.width + delta.width), + height: Math.max(1, bounds.height + delta.height) + }; +} + +export function moveSelected( + state: LayoutEditorState, + delta: Coordinate +): LayoutEditorState { + const widgetId = selectedWidgetId(state); + + if (!widgetId) { + return state; + } + + const bounds = getBounds(state, widgetId); + const newBounds = transformBounds(bounds, { ...delta, width: 0, height: 0 }); + + state = fillWith(state, '.', bounds); + state = fillWith(state, widgetId, newBounds); + state = select(state, { x: newBounds.x, y: newBounds.y }); + return state; +} + +export function resizeSelected( + state: LayoutEditorState, + delta: Coordinate +): LayoutEditorState { + const widgetId = selectedWidgetId(state); + + if (!widgetId) { + return state; + } + + const bounds = getBounds(state, widgetId); + const newBounds = transformBounds(bounds, { + x: 0, + y: 0, + width: delta.x, + height: delta.y + }); + + state = fillWith(state, '.', bounds); + state = fillWith(state, widgetId, newBounds); + state = select(state, { x: newBounds.x, y: newBounds.y }); + return state; +} + +export function deleteSelected(state: LayoutEditorState): LayoutEditorState { + const widgetId = selectedWidgetId(state); + + if (!widgetId) { + return state; + } + + const bounds = getBounds(state, widgetId); + + return fillWith(state, '.', bounds); +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/util.tsx b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/util.tsx new file mode 100644 index 00000000..8f0ed499 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/util.tsx @@ -0,0 +1,15 @@ +export function transition(fn: () => void) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + // if (!document.startViewTransition) { + console.warn( + 'transition', + 'startViewTransition not available', + 'Continuing without transition' + ); + fn(); + // } + + // document.startViewTransition(() => { + // flushSync(fn); + // }); +}