diff --git a/frontend-react/package.json b/frontend-react/package.json index 64810652..1ddf24cb 100644 --- a/frontend-react/package.json +++ b/frontend-react/package.json @@ -92,6 +92,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 52151412..8d6e604f 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 @@ -301,6 +307,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 dfff2892..10b79408 100644 --- a/frontend-react/src/app/index.tsx +++ b/frontend-react/src/app/index.tsx @@ -1,7 +1,8 @@ -import { initTelestion, registerWidgets, UserData } from '@wuespace/telestion'; +import { registerWidgets, UserData } from '@wuespace/telestion'; import { simpleWidget } from './widgets/simple-widget'; import { errorWidget } from './widgets/error-widget'; -import { setAutoLoginCredentials } from '../lib/auth'; +import ReactDOM from 'react-dom/client'; +import { App } from './app.tsx'; const defaultUserData: UserData = { version: '0.0.1', @@ -30,14 +31,17 @@ const defaultUserData: UserData = { registerWidgets(simpleWidget, errorWidget); -setAutoLoginCredentials({ - natsUrl: 'ws://localhost:9222', - username: 'nats', - password: 'nats' -}); +// setAutoLoginCredentials({ +// natsUrl: 'ws://localhost:9222', +// username: 'nats', +// password: 'nats' +// }); +// +// 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..043ef1c2 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md @@ -0,0 +1,66 @@ +# 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..5f7503cb --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css @@ -0,0 +1,97 @@ +.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..276fe2ec --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx @@ -0,0 +1,338 @@ +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 { + 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 + ) { + 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..7d508a99 --- /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..193589ed --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts @@ -0,0 +1,2 @@ +export * from './components/layout-editor.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..a1388ce4 --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts @@ -0,0 +1,307 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { + deleteSelected, + getBounds, + getWidgetIds, + LayoutEditorState, + moveSelected, + moveSelection, + resizeSelected, + selectedWidgetId +} 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 } + }; +}); + +describe('getting the selected widget id', () => { + beforeEach(() => { + state = { + ...state, + selection: { x: 3, y: 0 } + }; + }); + + test('widget selected in top left corner', () => { + const selected = selectedWidgetId(state); + expect(selected).toEqual('a'); + }); + + test('widget selected somewhere in the middle', () => { + const selected = selectedWidgetId(moveSelection(state, { x: 1, y: 1 })); + expect(selected).toEqual('a'); + }); + + test('no widget selected', () => { + const selected = selectedWidgetId(moveSelection(state, { x: -3, y: 0 })); + expect(selected).toEqual(undefined); + }); +}); + +test('getting a list of widget instance IDs', () => { + const ids = getWidgetIds(state); + expect(ids).toEqual(['a', 'b', 'c']); +}); + +describe('get bounds of a widget instance', () => { + test('get bounds of a widget instance', () => { + const bounds = getBounds(state, 'a'); + expect(bounds).toEqual({ x: 3, y: 0, width: 2, height: 2 }); + }); + + test('get bounds of a widget instance that does not exist', () => { + expect(() => getBounds(state, 'd')).toThrow(); + }); +}); + +describe('moving a selection', () => { + test('moving a selection', () => { + const moved = moveSelection(state, { x: 1, y: 1 }); + expect(moved.selection).toEqual({ x: 5, y: 5 }); + }); + + test('moving a selection out of bounds', () => { + const moved = moveSelection(state, { x: 10, y: 10 }); + expect(moved.selection).toEqual({ x: 5, y: 5 }); + }); +}); + +describe('deleting a selection', () => { + test('deleting a widget instance', () => { + const deleted = deleteSelected(state); + expect(layoutToString(deleted.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.', '.'], + ['.', '.', '.', '.', '.', '.'] + ]) + ); + }); + + test('deleting a non-existing selection', () => { + const deleted = deleteSelected(moveSelection(state, { x: -3, y: -3 })); + expect(layoutToString(deleted.layout)).toEqual( + layoutToString(state.layout) + ); + }); +}); + +describe('moving a widget instance', () => { + describe('valid movement', () => { + test('moving a widget instance', () => { + 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 movedAgain = moveSelected(moved, { x: -1, y: -1 }); + expect(movedAgain.selection).toEqual({ x: 3, y: 3 }); + expect(layoutToString(movedAgain.layout)).toEqual( + layoutToString([ + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'a', 'a', '.'], + ['.', '.', '.', 'b', '.', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', 'c', 'c', '.'], + ['.', '.', '.', '.', '.', '.'] + ]) + ); + }); + }); + + describe('movement out of bounds', () => { + beforeEach(() => { + state = { + layout: [ + ['a', 'a'], + ['a', 'a'] + ], + selection: { x: 0, y: 0 } + }; + }); + test('moving out of left bounds', () => { + const moved = moveSelected(state, { x: -1, y: 0 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('moving out of right bounds', () => { + const moved = moveSelected(state, { x: 1, y: 0 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('moving out of top bounds', () => { + const moved = moveSelected(state, { x: 0, y: -1 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('moving out of bottom bounds', () => { + const moved = moveSelected(state, { x: 0, y: 1 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString(state.layout) + ); + }); + }); + + describe('movement into another widget', () => { + beforeEach(() => { + state = { + layout: [ + ['b', 'a', 'a'], + ['b', 'a', 'a'], + ['c', 'a', 'a'] + ], + selection: { x: 0, y: 0 } + }; + }); + describe('position conflicts', () => { + test('moving a widget instance into another widget is impossible', () => { + const moved = moveSelected(state, { x: 0, y: 1 }); + expect(moved.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(moved.layout)).toEqual( + layoutToString([ + ['b', 'a', 'a'], + ['b', 'a', 'a'], + ['c', 'a', 'a'] + ]) + ); + }); + }); + }); +}); + +describe('resizing a widget instance', () => { + beforeEach(() => { + state = { + layout: [ + ['a', '.', 'b'], + ['a', '.', 'b'], + ['a', '.', 'b'] + ], + selection: { x: 0, y: 0 } + }; + }); + + test('resizing a widget instance', () => { + const resized = resizeSelected(state, { x: 1, y: 2 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString([ + ['a', 'a', 'b'], + ['a', 'a', 'b'], + ['a', 'a', 'b'] + ]) + ); + + const resizedAgain = resizeSelected(resized, { x: -1, y: -1 }); + expect(resizedAgain.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resizedAgain.layout)).toEqual( + layoutToString([ + ['a', '.', 'b'], + ['a', '.', 'b'], + ['.', '.', 'b'] + ]) + ); + }); + + describe('resizing a widget instance out of bounds is impossible', () => { + test('resizing a widget instance out of right bounds', () => { + state = { ...state, selection: { x: 2, y: 0 } }; + const resized = resizeSelected(state, { x: 1, y: 0 }); + expect(resized.selection).toEqual({ x: 2, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('resizing a widget instance out of bottom bounds', () => { + const resized = resizeSelected(state, { x: 0, y: 1 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString(state.layout) + ); + }); + }); + + describe('collapsing a widget instance is impossible', () => { + beforeEach(() => { + state = { + layout: [['a']], + selection: { x: 0, y: 0 } + }; + }); + + test('collapsing a widget instance to 0 width', () => { + const resized = resizeSelected(state, { x: -1, y: 0 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString(state.layout) + ); + }); + + test('collapsing a widget instance to 0 height', () => { + const resized = resizeSelected(state, { x: 0, y: -1 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString(state.layout) + ); + }); + }); + + test('resizing a widget instance into another widget is impossible', () => { + const resized = resizeSelected(state, { x: 2, y: 0 }); + expect(resized.selection).toEqual({ x: 0, y: 0 }); + expect(layoutToString(resized.layout)).toEqual( + layoutToString([ + ['a', '.', 'b'], + ['a', '.', 'b'], + ['a', '.', 'b'] + ]) + ); + }); +}); 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..95ec9c0a --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts @@ -0,0 +1,304 @@ +export interface Coordinate { + x: number; + y: number; +} + +export interface Bounds extends Coordinate { + width: Bounds['x']; + height: Bounds['y']; +} + +export type WidgetInstanceId = string; + +export interface LayoutEditorState { + layout: WidgetInstanceId[][]; + selection: Coordinate; +} + +export function selectedWidgetId( + state: LayoutEditorState +): WidgetInstanceId | 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: WidgetInstanceId, + 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: WidgetInstanceId +): 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): WidgetInstanceId[] { + 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); +} + +/** + * Transforms the given bounds by the given delta. + * + * The delta is applied to the top left corner of the bounds. + * + * The width and height of the bounds are always at least 1. + * @param bounds - the old bounds + * @param delta - the delta to apply + * @returns the new bounds + */ +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 { layout } = state; + + const bounds = getBounds(state, widgetId); + const newBounds = transformBounds(bounds, { ...delta, width: 0, height: 0 }); + + // check if newBounds is within the layout + const newMaxX = newBounds.x + newBounds.width; + const newMaxY = newBounds.y + newBounds.height; + const layoutWidth = layout[0].length; + const layoutHeight = layout.length; + if ( + newMaxX > layoutWidth || + newMaxY > layoutHeight || + newBounds.x < 0 || + newBounds.y < 0 + ) { + console.warn('Cannot move widget outside of the layout'); + return state; + } + + // check if newBounds is not overlapping with other widgets + for (let y = newBounds.y; y < newMaxY; y++) { + const row = layout[y]; + + for (let x = newBounds.x; x < newMaxX; x++) { + if (row[x] !== '.' && row[x] !== widgetId) { + console.warn('Cannot move widget on top of another widget'); + return state; + } + } + } + + // no collision, move the widget + 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 oldBounds = getBounds(state, widgetId); + const newBounds = transformBounds(oldBounds, { + x: 0, + y: 0, + width: delta.x, + height: delta.y + }); + + if ( + anyInBounds( + state, + newBounds, + widgetId => widgetId !== '.' && widgetId !== selectedWidgetId(state) + ) + ) { + console.warn('Cannot resize widget on top of another widget'); + return state; + } + + state = fillWith(state, '.', oldBounds); + 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); +} + +export function anyInBounds( + state: LayoutEditorState, + bounds: Bounds, + predicate: (widgetId: WidgetInstanceId, x: number, y: number) => boolean +): boolean { + const { layout } = state; + const { x, y, width, height } = bounds; + + for (let row = y; row < y + height && row < layout.length; row++) { + for ( + let column = x; + column < x + width && column < layout[0].length; + column++ + ) { + if (predicate(layout[row][column], column, row)) { + return true; + } + } + } + + return false; +} + +export function everyInBounds( + state: LayoutEditorState, + bounds: Bounds, + predicate: (widgetId: WidgetInstanceId, x: number, y: number) => boolean +): boolean { + const { layout } = state; + const { x, y, width, height } = bounds; + + for (let row = y; row < y + height; row++) { + for (let column = x; column < x + width; column++) { + if (!predicate(layout[row][column], column, row)) { + return false; + } + } + } + + return true; +} diff --git a/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-props.ts b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-props.ts new file mode 100644 index 00000000..b870721c --- /dev/null +++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-props.ts @@ -0,0 +1,56 @@ +import type { + LayoutEditorState, + WidgetInstanceId +} from './layout-editor-model.ts'; + +/** + * The props for the layout editor. + * + * Defines the interface (and separation of concerns) between the layout editor and its parent. + */ +export interface LayoutEditorProps { + /** + * The current state of the layout editor. + * + * Contains the selection and the layout. + * @see LayoutEditorState + * @see import('./layout-editor-model').selectedWidgetId + */ + value: LayoutEditorState; + + /** + * Callback for when the user changes the layout. + * + * If not provided, the layout cannot be changed. + * @param value - The new state of the layout editor. + */ + onChange?: (value: LayoutEditorState) => void; + + /** + * Callback for when the user adds a widget instance to the layout. + * If not provided, no widget instances can be added. + * @returns The ID of the new widget instance. + * @throws If the widget instance could not be added. + */ + onCreateWidgetInstance?: () => WidgetInstanceId; + /** + * Callback for when the user clones a widget instance. + * + * If not provided, no widget instances can be cloned (i.e., copied and pasted). + * @param widgetId - The ID of the widget instance to clone. + * @returns The ID of the new widget instance. + * @throws If the widget instance could not be cloned. + */ + onCloneWidgetInstance?: (widgetId: WidgetInstanceId) => WidgetInstanceId; + + /** + * Callback for when the user clicks the undo button. + * If not provided, the undo button will not be rendered. + */ + onUndo?: () => void; + /** + * Callback for when the user clicks the redo button. + * If not provided, the redo button will not be rendered. + */ + onRedo?: () => void; +}