diff --git a/.github/workflows/frontend-react-ci.yml b/.github/workflows/frontend-react-ci.yml
index 26e1d02c..e4e03d0f 100644
--- a/.github/workflows/frontend-react-ci.yml
+++ b/.github/workflows/frontend-react-ci.yml
@@ -109,5 +109,4 @@ jobs:
run: pnpm install
- name: Run unit tests 🛃
run: |
- pnpm run build
pnpm run ci:test
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..241002c9
--- /dev/null
+++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx
@@ -0,0 +1,35 @@
+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..47ca5832
--- /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 (
+ props.onSelect?.(props.bounds)}
+ aria-hidden={true}
+ // 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..20307f54
--- /dev/null
+++ b/frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx
@@ -0,0 +1,24 @@
+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..98dadf71
--- /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, shortcut]);
+};
+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;
+}