feat(dashboard): auf jedem Bildschirm dasselbe Bild – Leinwand je Reiter, massstaeblich skaliert
Je Reiter wird die Flaeche des ersten Desktop-Bildschirms als __canvas im Layout-JSON gespeichert (ohne API/DB-Aenderung, GRID_VERSION bleibt 3). Das Raster rendert in Leinwandbreite und wird per transform: scale(min(bw/cw, bh/ch)) eingepasst, Schrift eingeschlossen, ohne Scrollen; unter 768 px wie bisher. Ziehen/Groesse aendern unter Skalierung ueber eine eigene Positionsstrategie (createScaledStrategy aus react-grid-layout 2.2.3 rechnet den Rasterversatz falsch). Im Browser nachgewiesen: 1920x1080 -> 1366x768 (Faktor 0,66), Ziehen +200 px folgt der Maus, 700 px ohne Skalierung. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,17 @@
|
||||
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, type LayoutConstraint } from 'react-grid-layout/core';
|
||||
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,
|
||||
@@ -101,6 +108,66 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -121,6 +188,10 @@ interface DashboardGridProps {
|
||||
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. */
|
||||
@@ -224,9 +295,15 @@ export function DashboardGrid({
|
||||
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.
|
||||
@@ -265,6 +342,16 @@ export function DashboardGrid({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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
|
||||
@@ -277,27 +364,64 @@ export function DashboardGrid({
|
||||
|
||||
// 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],
|
||||
[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]);
|
||||
}, [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;
|
||||
const scale = scaled ? computeCanvasScale({ w: width, h: availableHeight }, canvas) : 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
|
||||
@@ -374,56 +498,72 @@ export function DashboardGrid({
|
||||
);
|
||||
}
|
||||
|
||||
// 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}>
|
||||
<Responsive
|
||||
width={width}
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user