From e6c60f01f1a51efcd4dc0bb0ffd45f1ea303edab Mon Sep 17 00:00:00 2001 From: Zuri Klaschka Date: Sun, 14 Jan 2024 23:35:04 +0100 Subject: [PATCH] TELESTION-460 Dashboard layout editor A layout editor for dashboards to make them configurable by users. Interaction can be performed using both the mouse and the keyboard. Changes are handled completely independently from the UI, allowing for both an easier way to ensure invalid states cannot occur as well as testability. During the user interaction, no actual state changes are performed. Once the interaction finishes, the changes get interpreted to the closest possible state change and "committed" to the state, where (in compliance with any business logic) the changes are applied and, thus, updates in the UI. This also allows us to implement any interactions without absolute positioning and resize observers. Instead, the native CSS grid can be used. Due to the relative complexity and clear boundaries of this subsystem, a separate folder structure which also includes a `README.md` was created. --- .github/workflows/frontend-react-ci.yml | 1 - frontend-react/package.json | 2 + frontend-react/pnpm-lock.yaml | 37 ++ frontend-react/src/app/app.tsx | 9 + frontend-react/src/app/index.tsx | 28 +- .../dashboard-editor/layout-editor/README.md | 66 ++++ .../layout-editor/components/empty-cell.tsx | 35 ++ .../layout-editor-widget-instance.tsx | 104 ++++++ .../components/layout-editor.module.css | 97 +++++ .../components/layout-editor.tsx | 338 ++++++++++++++++++ .../components/resize-handle.tsx | 24 ++ .../layout-editor/constants.tsx | 6 + .../hooks/use-keyboard-shortcut.tsx | 68 ++++ .../dashboard-editor/layout-editor/index.ts | 2 + .../model/layout-editor-model.test.ts | 307 ++++++++++++++++ .../model/layout-editor-model.ts | 304 ++++++++++++++++ .../model/layout-editor-props.ts | 56 +++ 17 files changed, 1471 insertions(+), 13 deletions(-) create mode 100644 frontend-react/src/app/app.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/README.md create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/empty-cell.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor-widget-instance.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.module.css create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/layout-editor.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/components/resize-handle.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/constants.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/hooks/use-keyboard-shortcut.tsx create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/index.ts create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.test.ts create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-model.ts create mode 100644 frontend-react/src/lib/application/routes/dashboard-editor/layout-editor/model/layout-editor-props.ts 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; +}