f78b422abf
Die Einpassung nimmt max(Leinwandhoehe, Rasterhoehe) (fitCanvasToContent). Anlass: Dashboard des Nutzers 940 px hoch bei Leinwand 849 px, dadurch scrollte es ueberall. Im Bearbeitungsmodus bleibt die Hoehe vom Beginn des Bearbeitens stehen, damit der Massstab beim Ziehen nicht wandert. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
592 lines
26 KiB
TypeScript
592 lines
26 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
|
import { noCompactor, Responsive } from 'react-grid-layout';
|
|
import {
|
|
defaultConstraints,
|
|
defaultPositionStrategy,
|
|
type LayoutConstraint,
|
|
type PositionStrategy,
|
|
transformStrategy,
|
|
} from 'react-grid-layout/core';
|
|
import 'react-grid-layout/css/styles.css';
|
|
import 'react-resizable/css/styles.css';
|
|
import { useTranslations } from 'next-intl';
|
|
import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
|
|
import {
|
|
clampWidthToCols,
|
|
GRID_COLS,
|
|
WIDGET_CONSTRAINTS,
|
|
WIDGET_REGISTRY,
|
|
type WidgetType,
|
|
} from './widget-registry';
|
|
import { WidgetIcon } from './widgets/widget-icon';
|
|
import { WidgetWrapper } from './widgets/widget-wrapper';
|
|
|
|
// quick-260916-bwo: Raster doppelt so fein wie zuvor (24 statt 12 Spalten,
|
|
// 20 statt 40 px Zeilenhoehe, 8 statt 16 px Abstand). Gespeicherte Anordnungen
|
|
// in alten Einheiten werden in `@/lib/grid-layout-migration` einmalig
|
|
// umgerechnet und mit `__gridVersion` markiert. BREAKPOINTS unveraendert.
|
|
//
|
|
// quick-260929-dmx (Version 3): Raster in der BREITE noch einmal doppelt so
|
|
// fein (48 statt 24 Spalten am lg-Breakpoint, uebrige Breakpoints ebenso
|
|
// verdoppelt). Zeilenhoehe (20 px) und Abstand (12 px) bleiben. Alle
|
|
// Breitenwerte (x, w, minW, maxW, WIDGET_CONSTRAINTS, Rueckfallwerte) sind
|
|
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
|
|
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
|
|
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
|
// Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
|
|
// weil auch der Store sie fuer neue Kacheln braucht.
|
|
const COLS = GRID_COLS;
|
|
|
|
// quick-260916-dyv: Ziehen zuverlaessig.
|
|
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
|
|
// - `cancel` verhindert den Drag-Start an Eingabefeldern, Knoepfen, Links,
|
|
// [contenteditable], [data-no-drag] und der Klasse `widgetNoDrag`
|
|
// (Favoriten-Widget). react-draggable 4.7.0 prueft
|
|
// `cancel` NACH `handle` und vom Ereignisziel aufwaerts bis zum RGL-Element
|
|
// (Draggable.js:417 + matchesSelectorAndParentsTo) — cancel gewinnt also auch
|
|
// INNERHALB des Griffs. RGL haengt `.react-resizable-handle` selbst voran
|
|
// (chunk-WGL5FSZH.mjs:526), der Groessen-Griff funktioniert weiter.
|
|
// - `threshold: 3` = RGL-Standard (Klick vs. Ziehen).
|
|
export const WIDGET_DRAG_HANDLE_SELECTOR = '.widget-drag-handle';
|
|
export const WIDGET_DRAG_CANCEL_SELECTOR =
|
|
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
|
|
|
|
// quick-260916-dyv: Kein Ueberlappen beim Ablegen, freie Platzierung bleibt.
|
|
// `noCompactor` (Commit c8f3361, "prevent auto-compaction on drag") ist gewollt:
|
|
// Luecken werden nicht geschlossen. OHNE `preventCollision` springt beim Ziehen
|
|
// auf ein belegtes Feld das gezogene Widget auf die Zeile des getroffenen und
|
|
// das getroffene rutscht um seine EIGENE Hoehe nach unten — ohne Kaskade und
|
|
// ohne Aufloesung, Ueberlappungen bleiben, weil `noCompactor.compact` die
|
|
// Identitaet ist (gemessen chunk-76RTO6EO.mjs:279-328); beim Vergroessern in
|
|
// einen Nachbarn entsteht die Ueberlappung stumm (chunk-WGL5FSZH.mjs:872-885).
|
|
// MIT `preventCollision: true` bleibt das gezogene Widget am Ausgangsort
|
|
// (`l.x = oldX; l.y = oldY`), und Vergroessern stoppt am Nachbarn.
|
|
// `preventCollision` lebt am Compactor-Objekt (chunk-WGL5FSZH.mjs:666).
|
|
export const FREE_PLACEMENT_COMPACTOR: Compactor = { ...noCompactor, preventCollision: true };
|
|
|
|
/**
|
|
* Groesse aendern trotz Nachbarn (Design „Mosaik“, Runde 3).
|
|
*
|
|
* Mit `preventCollision` verwirft react-grid-layout 2.2.3 beim Ziehen am
|
|
* Groessengriff die GANZE neue Groesse, sobald sie irgendeinen Nachbarn
|
|
* beruehrt (chunk-WGL5FSZH.mjs, onResize: w UND h zurueck auf alt). Wer eine
|
|
* Kachel nur schmaler ziehen will und dabei die Maus eine Rasterzeile nach
|
|
* unten wackelt, stoesst an die Kachel darunter — und die Breite bleibt
|
|
* stehen. Genau so liess sich die Favoriten-Kachel oft nicht schmaler machen.
|
|
*
|
|
* Diese Regel laeuft vor der Kollisionspruefung: kollidiert die gewuenschte
|
|
* Groesse, wird zuerst nur die Breite (alte Hoehe), dann nur die Hoehe (alte
|
|
* Breite) uebernommen — was frei ist, gewinnt. Nur fuer Griffe, die x/y nicht
|
|
* verschieben (se, e, s); unser Raster zeigt nur den se-Griff.
|
|
*/
|
|
function overlaps(
|
|
a: { x: number; y: number; w: number; h: number },
|
|
b: { x: number; y: number; w: number; h: number },
|
|
): boolean {
|
|
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
}
|
|
|
|
export const RESIZE_AXIS_FALLBACK: LayoutConstraint = {
|
|
name: 'resizeAxisFallback',
|
|
constrainSize(item, w, h, handle, context) {
|
|
if (handle !== 'se' && handle !== 'e' && handle !== 's') return { w, h };
|
|
const current = context.layout.find((l) => l.i === item.i) ?? item;
|
|
const others = context.layout.filter((l) => l.i !== item.i);
|
|
const free = (cw: number, ch: number) =>
|
|
!others.some((o) => overlaps({ x: current.x, y: current.y, w: cw, h: ch }, o));
|
|
if (free(w, h)) return { w, h };
|
|
if (free(w, current.h)) return { w, h: current.h };
|
|
if (free(current.w, h)) return { w: current.w, h };
|
|
return { w, h };
|
|
},
|
|
};
|
|
|
|
const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS_FALLBACK];
|
|
|
|
const GRID_MARGIN = 12;
|
|
|
|
/**
|
|
* Dashboard wie ein Bild mitskalieren (Leinwand).
|
|
*
|
|
* Wunsch: an PC 1 eingerichtet, an PC 2 mit anderer Aufloesung GENAU gleich
|
|
* aussehen — alles (auch die Schrift) gleichmaessig kleiner oder groesser,
|
|
* nie scrollen; passt die Bildschirmform nicht, bleibt rechts oder unten
|
|
* Platz frei.
|
|
*
|
|
* Umsetzung: Jeder Reiter hat eine Leinwand `{ w, h }` (gespeichert als
|
|
* `__canvas`, siehe grid-layout-migration.ts) — die verfuegbare Flaeche, auf
|
|
* der er eingerichtet wurde (erfasst beim ersten Oeffnen am Desktop, danach
|
|
* fest). Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus. Auf
|
|
* jedem Desktop-Bildschirm zeichnet react-grid-layout das Raster mit der
|
|
* VIRTUELLEN Breite `canvas.w` — Breakpoint und Spaltenbreite sind damit
|
|
* ueberall identisch — und ein umgebendes Element verkleinert/vergroessert
|
|
* das Ergebnis per `transform: scale(s)` mit
|
|
* `s = min(Breite / canvas.w, Hoehe / canvas.h)` (computeCanvasScale),
|
|
* links oben ausgerichtet (nicht zentriert, bewusst entfernt in bc4c011).
|
|
*
|
|
* Unter CANVAS_MIN_WIDTH (Handy/schmal) bleibt alles wie zuvor: Breite =
|
|
* gemessene Containerbreite, kein transform, keine Erfassung.
|
|
*/
|
|
/** Abstand unter dem Raster bis zur Fensterkante: main p-3 (12) + Wrapper p-2 (8). */
|
|
export const DASHBOARD_BOTTOM_GAP_PX = 20;
|
|
|
|
/**
|
|
* Skalierungsfaktor der Leinwand fuer die verfuegbare Flaeche: das kleinere
|
|
* der beiden Verhaeltnisse, damit alles hineinpasst (freier Platz rechts
|
|
* ODER unten). Eine unbrauchbare Hoehe (<= 0, nicht endlich) begrenzt nicht —
|
|
* dann entscheidet nur die Breite.
|
|
*/
|
|
export function computeCanvasScale(available: GridCanvas, canvas: GridCanvas): number {
|
|
const byWidth = available.w / canvas.w;
|
|
const byHeight = available.h / canvas.h;
|
|
const heightUsable = Number.isFinite(byHeight) && byHeight > 0;
|
|
const s = heightUsable ? Math.min(byWidth, byHeight) : byWidth;
|
|
return Number.isFinite(s) && s > 0 ? s : 1;
|
|
}
|
|
|
|
/**
|
|
* Leinwand fuer die Einpassung: ist der Inhalt (unskalierte Rasterhoehe)
|
|
* hoeher als die Leinwand, gilt die Inhaltshoehe, sonst die Leinwand selbst —
|
|
* so bleibt es am Original-PC bei 1:1, solange alles draufpasst.
|
|
*/
|
|
export function fitCanvasToContent(canvas: GridCanvas, contentHeight: number): GridCanvas {
|
|
if (!Number.isFinite(contentHeight) || contentHeight <= canvas.h) return canvas;
|
|
return { w: canvas.w, h: contentHeight };
|
|
}
|
|
|
|
/**
|
|
* Positionsstrategie fuer ein per `transform: scale(s)` skaliertes Raster.
|
|
*
|
|
* Bewusst NICHT `createScaledStrategy(s)` aus react-grid-layout 2.2.3: deren
|
|
* `calcDragPosition` rechnet `(clientX - offsetX) / scale` =
|
|
* `clientRect.left / scale` (chunk-KDANGDDL.mjs:315-320) — eine FENSTER-
|
|
* Koordinate ohne Abzug der Raster-Oberkante/-Linkskante. Im Portal liegt das
|
|
* Raster rechts neben der Seitenleiste; die Kachel spraenge beim Ziehstart um
|
|
* (Seitenleiste + Abstand) / s Pixel nach rechts unten. Ohne
|
|
* `calcDragPosition` nimmt react-grid-layout den eigenen Weg
|
|
* (chunk-WGL5FSZH.mjs:146-163): `(clientRect.left - parentRect.left) /
|
|
* transformScale` — relativ zum Raster und korrekt entskaliert. Die
|
|
* Mausbewegungen teilen `DraggableCore` (Prop `scale`) bzw. `Resizable`
|
|
* (`deltaX / transformScale`) selbst durch `scale`; beide lesen ihn aus
|
|
* `positionStrategy.scale` (chunk-WGL5FSZH.mjs:675, 512, 527).
|
|
*/
|
|
export function scaledPositionStrategy(scale: number): PositionStrategy {
|
|
if (scale === 1) return defaultPositionStrategy;
|
|
return { type: 'transform', scale, calcStyle: transformStrategy.calcStyle };
|
|
}
|
|
|
|
interface LayoutItemShape {
|
|
i: string;
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
minW?: number;
|
|
minH?: number;
|
|
}
|
|
|
|
interface DashboardGridProps {
|
|
layouts: Record<string, Array<LayoutItemShape>>;
|
|
widgets: Array<{ id: string; widgetType: string; config: Record<string, unknown> }>;
|
|
isEditMode: boolean;
|
|
onLayoutChange: (allLayouts: ResponsiveLayouts) => void;
|
|
onRemoveWidget: (id: string) => void;
|
|
/** Leerer Zustand: oeffnet den Widget-Katalog. */
|
|
onOpenCatalog?: () => void;
|
|
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
|
|
onQuickAdd?: (type: WidgetType) => void;
|
|
/** Leinwand des aktiven Reiters; null = noch nicht erfasst (dann unskaliert). */
|
|
canvas?: GridCanvas | null;
|
|
/** Wird mit der verfuegbaren Flaeche gerufen, solange `canvas` fehlt (nur Desktop). */
|
|
onCaptureCanvas?: (area: GridCanvas) => void;
|
|
}
|
|
|
|
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
|
|
const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
|
|
|
|
/**
|
|
* quick-260916-dyv: Gespeicherte minW/minH aus den Konstanten ueberschreiben.
|
|
*
|
|
* Warum: react-grid-layout 2.2.3 nimmt in `synchronizeLayoutWithChildren`
|
|
* (chunk-WGL5FSZH.mjs:559-562) fuer jedes Kind MIT Eintrag im `layout` den
|
|
* Eintrag per `cloneLayoutItem` woertlich — inklusive minW/minH — und liest
|
|
* `data-grid` nur fuer Kinder OHNE Eintrag. Gespeicherte Anordnungen tragen
|
|
* minW/minH (RGL liefert sie in `onLayoutChange`, 260916-bwo hat sie verdoppelt
|
|
* persistiert). Eine Aenderung von WIDGET_CONSTRAINTS allein aendert fuer
|
|
* bestehende Widgets also NICHTS. Deshalb: in JEDEM Breakpoint minW/minH jedes
|
|
* Eintrags eines bekannten Typs aus der Tabelle setzen; die Konstanten sind
|
|
* die einzige Quelle (T-DYV-01: manipulierte Minima wirken nicht).
|
|
*
|
|
* Liegt die gespeicherte Groesse UNTER dem neuen Minimum (z. B. Rechner mit
|
|
* h 8, neues minH 10), wird w/h auf das Minimum angehoben: RGL selbst klemmt
|
|
* w/h nur beim Vergroessern/Verkleinern (`minMaxSize.constrainSize`,
|
|
* chunk-KDANGDDL.mjs:26-31; `minConstraints` an `Resizable`,
|
|
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
|
|
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
|
|
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
|
|
*
|
|
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
|
|
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
|
|
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
|
|
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
|
|
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
|
|
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
|
|
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
|
|
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
|
|
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
|
|
* verschoben ist.
|
|
*/
|
|
function applyConstraintMinima(
|
|
layouts: Record<string, Array<LayoutItemShape>>,
|
|
widgets: Array<{ id: string; widgetType: string }>,
|
|
): Record<string, Array<LayoutItemShape>> {
|
|
const typeById = new Map<string, string>();
|
|
for (const w of widgets) typeById.set(w.id, w.widgetType);
|
|
|
|
const result: Record<string, Array<LayoutItemShape>> = {};
|
|
for (const key of Object.keys(layouts)) {
|
|
const entries = layouts[key];
|
|
if (!Array.isArray(entries)) continue;
|
|
const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
|
|
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
|
|
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
|
|
const targetW = new Map<string, number>();
|
|
const next = entries.map((entry) => {
|
|
const type = typeById.get(entry.i);
|
|
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
|
|
if (!constraints) return { ...entry };
|
|
const minW = clampWidthToCols(constraints.minW, key);
|
|
const w = Math.min(entry.w, cols);
|
|
if (w < minW) targetW.set(entry.i, minW);
|
|
return {
|
|
...entry,
|
|
w,
|
|
h: Math.max(entry.h, constraints.minH),
|
|
minW,
|
|
minH: constraints.minH,
|
|
};
|
|
});
|
|
|
|
for (const item of next) {
|
|
const target = targetW.get(item.i);
|
|
if (target === undefined) continue;
|
|
const others = next.filter((o) => o !== item);
|
|
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
|
|
// entstehen nicht durch das Anheben.
|
|
const blockers = others.filter((o) => !overlaps(item, o));
|
|
while (
|
|
item.w < target &&
|
|
item.x + item.w + 1 <= cols &&
|
|
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
|
|
) {
|
|
item.w += 1;
|
|
}
|
|
}
|
|
result[key] = next;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Dashboard grid wrapper using react-grid-layout v2 Responsive component.
|
|
* Renders widgets in a draggable/resizable grid when in edit mode.
|
|
* Shows empty state when no widgets exist.
|
|
*
|
|
* v2 API: uses dragConfig/resizeConfig instead of isDraggable/isResizable.
|
|
*/
|
|
export function DashboardGrid({
|
|
layouts,
|
|
widgets,
|
|
isEditMode,
|
|
onLayoutChange,
|
|
onRemoveWidget,
|
|
onOpenCatalog,
|
|
onQuickAdd,
|
|
canvas = null,
|
|
onCaptureCanvas,
|
|
}: DashboardGridProps) {
|
|
const t = useTranslations('widgets');
|
|
const [width, setWidth] = useState(1200);
|
|
// Verfuegbare Hoehe bis zur unteren Fensterkante (Leinwand). 0 = unbekannt.
|
|
const [availableHeight, setAvailableHeight] = useState(0);
|
|
// Unskalierte Hoehe des Rasters (inneres Element) fuer die sichtbare Hoehe.
|
|
const [innerHeight, setInnerHeight] = useState(0);
|
|
|
|
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
|
|
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
|
|
//
|
|
// Warum: der fruehe Ruecksprung in den Leerzustand rendert das gemessene
|
|
// <div> gar nicht erst. Haengt DashboardGrid mit null Kacheln ein, sieht der
|
|
// alte Effekt (leere Abhaengigkeitsliste, laeuft genau einmal beim
|
|
// Einhaengen) den Ref als leer, bricht ab und laeuft nie wieder — auch nicht,
|
|
// wenn spaeter die erste Kachel erscheint und das <div> tatsaechlich
|
|
// entsteht. Die Breite blieb dann fuer die ganze Sitzung beim Startwert
|
|
// 1200, react-grid-layout vergleicht den Breakpoint strikt groesser als
|
|
// (`width > breakpoint`), 1200 ist damit NICHT `lg` sondern `md` -> 20 statt
|
|
// 24 Spalten, 51,6 statt 50 px Spaltenbreite, ein toter Streifen rechts.
|
|
//
|
|
// Der Ref-Rueckruf `measureRef` folgt dem Knoten ueber Aus- und Einhaengen
|
|
// hinweg (Leerzustand <-> gefuellt) und laeuft in der Commit-Phase — die dort
|
|
// ausgeloeste Zustandsaenderung wird vor dem Zeichnen abgearbeitet, ein
|
|
// zusaetzlicher useLayoutEffect ist damit ueberfluessig. Der Startwert 1200
|
|
// lebt deshalb nur noch bis zur Commit-Phase desselben Einhaengens; genau
|
|
// diesen Uebergang macht der Pfad "Neuladen mit vorhandenen Kacheln" heute
|
|
// schon in Produktion und er ist nachweislich richtig (459 px in 1176 px
|
|
// gemessen) — ein anderer Startwert wuerde eine bisher unerprobte
|
|
// Breakpoint-Folge einfuehren, ohne etwas zu verbessern.
|
|
//
|
|
// Der Fenster-Horcher ist ein zusaetzliches Netz fuer Faelle, in denen der
|
|
// ResizeObserver nichts meldet — nicht sein Ersatz. `applyWidth` verwirft 0
|
|
// und nicht endliche Werte (T-VDK-01/T-VDK-03), damit eine kurzzeitig
|
|
// zusammengefallene Flaeche das Raster nicht auf Null setzt und keine
|
|
// Rueckkopplungsschleife entsteht; React verwirft gleiche Werte selbst.
|
|
const nodeRef = useRef<HTMLDivElement | null>(null);
|
|
const observerRef = useRef<ResizeObserver | null>(null);
|
|
|
|
const applyWidth = useCallback((next: number) => {
|
|
if (Number.isFinite(next) && next > 0) {
|
|
setWidth(next);
|
|
}
|
|
}, []);
|
|
|
|
// Leinwand: Hoehe = Fensterhoehe minus Oberkante des Rasters im DOKUMENT
|
|
// (rect.top + scrollY, also unabhaengig vom Scrollstand) minus Abstand
|
|
// unten. Das aeussere Element ist selbst nicht transformiert, seine
|
|
// Oberkante ist also die echte.
|
|
const applyHeight = useCallback((node: HTMLElement) => {
|
|
const top = node.getBoundingClientRect().top + window.scrollY;
|
|
const next = window.innerHeight - top - DASHBOARD_BOTTOM_GAP_PX;
|
|
setAvailableHeight(Number.isFinite(next) && next > 0 ? Math.floor(next) : 0);
|
|
}, []);
|
|
|
|
const measureRef = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
// Ein eventuell laufender Beobachter zuerst trennen — auch der
|
|
// null-Zweig (Aushaengen) durchlaeuft diese Zeilen, das ist hier der
|
|
// Aufraeumpfad (T-VDK-02).
|
|
observerRef.current?.disconnect();
|
|
observerRef.current = null;
|
|
nodeRef.current = node;
|
|
if (!node) return;
|
|
|
|
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
|
|
applyWidth(node.getBoundingClientRect().width);
|
|
applyHeight(node);
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
applyWidth(entries[0].contentRect.width);
|
|
applyHeight(node);
|
|
});
|
|
observer.observe(node);
|
|
observerRef.current = observer;
|
|
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
|
|
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
|
|
},
|
|
[applyWidth, applyHeight],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const onResize = () => {
|
|
if (nodeRef.current) {
|
|
applyWidth(nodeRef.current.getBoundingClientRect().width);
|
|
applyHeight(nodeRef.current);
|
|
}
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, [applyWidth, applyHeight]);
|
|
|
|
// Unskalierte Hoehe des inneren Elements (react-grid-layout setzt die
|
|
// Rasterhoehe selbst). `contentRect`/`offsetHeight` sind Layout-Masse und
|
|
// vom transform unberuehrt — genau das Gewuenschte.
|
|
const innerObserverRef = useRef<ResizeObserver | null>(null);
|
|
const innerRef = useCallback((node: HTMLDivElement | null) => {
|
|
innerObserverRef.current?.disconnect();
|
|
innerObserverRef.current = null;
|
|
if (!node) return;
|
|
if (node.offsetHeight > 0) setInnerHeight(node.offsetHeight);
|
|
const observer = new ResizeObserver((entries) => {
|
|
const h = entries[0].contentRect.height;
|
|
if (Number.isFinite(h) && h > 0) setInnerHeight(h);
|
|
});
|
|
observer.observe(node);
|
|
innerObserverRef.current = observer;
|
|
}, []);
|
|
|
|
// Desktop = verfuegbare Breite ab CANVAS_MIN_WIDTH; nur dort Leinwand.
|
|
const isDesktop = width >= CANVAS_MIN_WIDTH;
|
|
const scaled = isDesktop && canvas !== null;
|
|
// "Nie scrollen" (Nutzerentscheidung): ist der Inhalt hoeher als die
|
|
// Leinwand (schon am Original-PC laenger als der Bildschirm), zaehlt die
|
|
// Inhaltshoehe — dann passt auch dort alles hinein, und jeder andere
|
|
// Bildschirm zeigt dasselbe Bild. Im Bearbeitungsmodus bleibt die Hoehe vom
|
|
// Beginn des Bearbeitens stehen, sonst wuerde das Raster beim Ziehen nach
|
|
// unten laufend kleiner und die Kachel liefe dem Mauszeiger davon.
|
|
const editHeightRef = useRef<number | null>(null);
|
|
if (isEditMode && editHeightRef.current === null) editHeightRef.current = innerHeight;
|
|
if (!isEditMode) editHeightRef.current = null;
|
|
const contentHeight = isEditMode ? (editHeightRef.current ?? innerHeight) : innerHeight;
|
|
const scale = scaled
|
|
? computeCanvasScale({ w: width, h: availableHeight }, fitCanvasToContent(canvas, contentHeight))
|
|
: 1;
|
|
const positionStrategy = useMemo(() => scaledPositionStrategy(scale), [scale]);
|
|
|
|
// Leinwand erfassen: nur mit eingehaengtem Raster (also mit Kacheln), am
|
|
// Desktop und mit brauchbarer Hoehe. Der Store uebernimmt sie einmal und
|
|
// ignoriert weitere Aufrufe; danach ist `canvas` gesetzt und dieser
|
|
// Effekt tut nichts mehr.
|
|
const hasWidgets = widgets.length > 0;
|
|
useEffect(() => {
|
|
if (!onCaptureCanvas || canvas !== null || !hasWidgets) return;
|
|
if (!isDesktop || availableHeight < CANVAS_MIN_HEIGHT) return;
|
|
onCaptureCanvas({ w: width, h: availableHeight });
|
|
}, [onCaptureCanvas, canvas, hasWidgets, isDesktop, width, availableHeight]);
|
|
|
|
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
|
|
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
|
// stabil bleibt.
|
|
const effectiveLayouts = useMemo(
|
|
() => applyConstraintMinima(layouts, widgets),
|
|
[layouts, widgets],
|
|
);
|
|
|
|
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
|
|
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
|
|
// Hinzufuegen und der Hauptknopf zum Katalog.
|
|
if (widgets.length === 0) {
|
|
return (
|
|
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
|
|
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
|
|
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.75"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<rect x="3" y="3" width="7" height="9" rx="1.5" />
|
|
<rect x="14" y="3" width="7" height="5" rx="1.5" />
|
|
<rect x="14" y="12" width="7" height="9" rx="1.5" />
|
|
<rect x="3" y="16" width="7" height="5" rx="1.5" />
|
|
</svg>
|
|
</span>
|
|
<h2 className="text-base font-semibold text-foreground">{t('emptyState.title')}</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">{t('emptyState.body')}</p>
|
|
{onQuickAdd && (
|
|
<ul className="mt-4 flex flex-wrap justify-center gap-2">
|
|
{QUICK_ADD_TYPES.map((type) => (
|
|
<li key={type}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onQuickAdd(type)}
|
|
className="inline-flex h-8 items-center gap-2 rounded-full border border-border bg-card px-3 text-sm text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<WidgetIcon type={type} variant="plain" />
|
|
{t(WIDGET_REGISTRY[type].nameKey)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{onOpenCatalog && (
|
|
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
|
|
<svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
>
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
{t('addWidget')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Gleiche DOM-Struktur in beiden Modi, damit react-grid-layout beim
|
|
// Wechsel (etwa direkt nach dem Erfassen, s = 1) nicht neu einhaengt.
|
|
// Aeusseres Element: volle Breite (Messung), im skalierten Modus die
|
|
// sichtbare Hoehe = unskalierte Rasterhoehe x s und `overflow: hidden` —
|
|
// der transform aendert die Layout-Box nicht, ohne beides bliebe toter
|
|
// Scrollraum (s < 1) bzw. ragte die 1:1-Breite seitlich hinaus.
|
|
const outerStyle: React.CSSProperties | undefined = scaled
|
|
? { overflow: 'hidden', ...(innerHeight > 0 ? { height: innerHeight * scale } : {}) }
|
|
: undefined;
|
|
const innerStyle: React.CSSProperties | undefined = scaled
|
|
? { width: canvas.w, transform: `scale(${scale})`, transformOrigin: 'top left' }
|
|
: undefined;
|
|
|
|
return (
|
|
<div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
|
|
<div ref={innerRef} style={innerStyle}>
|
|
<Responsive
|
|
width={scaled ? canvas.w : width}
|
|
positionStrategy={positionStrategy}
|
|
breakpoints={BREAKPOINTS}
|
|
cols={COLS}
|
|
layouts={effectiveLayouts as ResponsiveLayouts}
|
|
rowHeight={20}
|
|
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
|
|
constraints={GRID_CONSTRAINTS}
|
|
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
|
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
|
dragConfig={{
|
|
enabled: isEditMode,
|
|
handle: WIDGET_DRAG_HANDLE_SELECTOR,
|
|
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
|
threshold: 3,
|
|
}}
|
|
resizeConfig={{
|
|
enabled: isEditMode,
|
|
}}
|
|
compactor={FREE_PLACEMENT_COMPACTOR}
|
|
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
|
>
|
|
{widgets.map((widget, index) => {
|
|
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
|
return (
|
|
<div
|
|
key={widget.id}
|
|
data-grid={{
|
|
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
|
x: 0,
|
|
y: 0,
|
|
w: constraints?.defaultW ?? 8,
|
|
h: constraints?.defaultH ?? 4,
|
|
}),
|
|
minW: constraints?.minW ?? 8,
|
|
minH: constraints?.minH ?? 4,
|
|
}}
|
|
>
|
|
<WidgetWrapper
|
|
widget={widget}
|
|
isEditMode={isEditMode}
|
|
onRemove={onRemoveWidget}
|
|
enterIndex={index}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</Responsive>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|