Files
web-cad/frontend/src/components/LayoutBar.tsx
T

221 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Task G2 LayoutBar: Layout-Verwaltung + Viewport-Minimap-Widget.
*
* - Listet Layouts des aktiven Dokuments (documentService-Fallback)
* - Klick auf Eintrag = zoom-to gespeichertem Viewport (Bridge)
* - „+“ speichert aktuellen Viewport als neues Layout
* - „🗑“ löscht Layout; je Eintrag Minimap (Modellbereich + Viewport-Rechteck)
*/
import React, { useCallback, useEffect, useState } from 'react';
import type { LayoutDefinition } from '../types/cad.types';
import { getActiveDocument, onActiveDocumentChanged } from '../kernel/document/documentService';
import { insertTitleBlockForLayout } from '../services/titleBlockService';
import { printLayout } from '../services/printService';
import { useT } from '../i18n/strings';
export interface LayoutView {
center: { x: number; y: number };
scale: number;
}
export interface LayoutBridge {
zoomTo(center: { x: number; y: number }, scale: number): void;
getView(): LayoutView;
}
interface LayoutBarProps {
/** Aktives Dokument (Fallback: documentService, Task G2 UI-Unabhängigkeit). */
doc?: {
getAllLayouts(): LayoutDefinition[];
addLayout(l: LayoutDefinition): void;
deleteLayout(id: string): void;
getAllElements?(): Array<{ x: number; y: number; width: number; height: number }>;
onChanged?(cb: () => void): () => void;
} | null;
/** Canvas-Bridge (Fallback: window.__v2LayoutBridge aus CanvasArea). */
bridge?: LayoutBridge | null;
}
interface Rect { x: number; y: number; w: number; h: number }
/**
* Minimap-Geometrie (Task G2, rein & getestet):
* Modell-BBox füllt das Widget (Stretch mit 6px Padding); ohne Elemente
* Volles Widget. Viewport-Rechteck = nominale Ansicht 800×600/scale Welt.
*/
export function computeMinimapRects(
elements: Array<{ x: number; y: number; width: number; height: number }>,
view: LayoutView,
widgetW: number,
widgetH: number,
): { model: Rect; view: Rect } {
if (!Array.isArray(elements) || elements.length === 0) {
return { model: { x: 0, y: 0, w: widgetW, h: widgetH }, view: { x: 0, y: 0, w: widgetW, h: widgetH } };
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const el of elements) {
minX = Math.min(minX, el.x - el.width / 2);
minY = Math.min(minY, el.y - el.height / 2);
maxX = Math.max(maxX, el.x + el.width / 2);
maxY = Math.max(maxY, el.y + el.height / 2);
}
if (!isFinite(minX) || maxX - minX <= 0 || maxY - minY <= 0) {
return { model: { x: 0, y: 0, w: widgetW, h: widgetH }, view: { x: 0, y: 0, w: widgetW, h: widgetH } };
}
const PAD = 6;
const model: Rect = { x: PAD, y: PAD, w: widgetW - 2 * PAD, h: widgetH - 2 * PAD };
const wx = (w: number) => model.x + ((w - minX) / (maxX - minX)) * model.w;
const wy = (w: number) => model.y + ((w - minY) / (maxY - minY)) * model.h;
const viewW = 800 / view.scale;
const viewH = 600 / view.scale;
const viewRect: Rect = {
x: wx(view.center.x - viewW / 2),
y: wy(view.center.y - viewH / 2),
w: (viewW / (maxX - minX)) * model.w,
h: (viewH / (maxY - minY)) * model.h,
};
return { model, view: viewRect };
}
const WIDGET_W = 100;
const WIDGET_H = 80;
const LayoutBar: React.FC<LayoutBarProps> = ({ doc: docProp, bridge: bridgeProp }) => {
const t = useT();
const [doc, setDoc] = useState(docProp ?? getActiveDocument());
const [layouts, setLayouts] = useState<LayoutDefinition[]>([]);
const bridge: LayoutBridge | null = bridgeProp ??
((typeof window !== 'undefined' ? (window as unknown as { __v2LayoutBridge?: LayoutBridge }).__v2LayoutBridge : undefined) ?? null);
const reload = useCallback(() => {
setLayouts(doc ? doc.getAllLayouts() : []);
}, [doc]);
useEffect(() => {
reload();
// Live-Updates (auch Remote via CRDT), falls das Doc es anbietet
const off = doc?.onChanged?.(reload);
return () => { if (off) off(); };
}, [reload]);
// Fallback-Verdrahtung: aktives Dokument aus dem documentService
useEffect(() => {
if (docProp) return;
setDoc(getActiveDocument());
const off = onActiveDocumentChanged(() => setDoc(getActiveDocument()));
return off;
}, [docProp]);
const handleAdd = () => {
if (!bridge || !doc) return;
const view = bridge.getView();
doc.addLayout({
id: `layout-${Date.now()}`,
name: `Layout ${layouts.length + 1}`,
viewport: { center: { ...view.center }, scale: view.scale },
});
reload();
};
const handleDelete = (id: string) => {
doc?.deleteLayout(id);
reload();
};
const handlePrint = () => {
void printLayout();
};
const handleZoomTo = (l: LayoutDefinition) => {
bridge?.zoomTo(l.viewport.center, l.viewport.scale);
};
const elements = doc?.getAllElements?.() ?? [];
return (
<div className="layout-bar" data-testid="layout-bar">
<div className="layout-bar-header">
<span className="layout-bar-title">{t('layouts.title')}</span>
<button
type="button"
className="layout-bar-add"
title={t('layouts.add')}
onClick={handleAdd}
disabled={!bridge || !doc}
>+</button>
<button
type="button"
className="layout-bar-print screen-only"
title={t('layouts.print')}
onClick={handlePrint}
>🖨</button>
</div>
{layouts.length === 0 ? (
<div className="layout-bar-empty">{t('layouts.empty')}</div>
) : (
<div className="layout-bar-list">
{layouts.map((l) => {
const rects = computeMinimapRects(elements, l.viewport, WIDGET_W, WIDGET_H);
return (
<div key={l.id} className="layout-bar-item">
<button
type="button"
className="layout-bar-entry"
data-testid={`layout-entry-${l.id}`}
title={t('layouts.zoomto', l.name)}
onClick={() => handleZoomTo(l)}
>
<svg
data-testid={`layout-minimap-${l.id}`}
width={WIDGET_W}
height={WIDGET_H}
viewBox={`0 0 ${WIDGET_W} ${WIDGET_H}`}
className="layout-minimap"
>
<rect
x={rects.model.x} y={rects.model.y}
width={rects.model.w} height={rects.model.h}
fill="rgba(255,255,255,0.05)" stroke="rgba(255,255,255,0.25)" strokeWidth="1"
/>
<rect
x={rects.view.x} y={rects.view.y}
width={Math.max(2, rects.view.w)} height={Math.max(2, rects.view.h)}
fill="rgba(74,144,217,0.25)" stroke="#4a90d9" strokeWidth="1"
/>
</svg>
<span className="layout-bar-name">{l.name}</span>
</button>
<button
type="button"
className="layout-bar-tb"
title={t('layouts.titleblock', l.name)}
onClick={() => {
if (!doc) return;
insertTitleBlockForLayout(doc as never, l.id, {
project: l.name,
date: new Date().toISOString().slice(0, 10),
scale: `1:${Math.max(1, Math.round(1 / (l.viewport.scale || 1)))}`,
author: 'WebCAD',
});
reload();
}}
>🏷</button>
<button
type="button"
className="layout-bar-del"
title={t('layouts.delete', l.name)}
onClick={() => handleDelete(l.id)}
>🗑</button>
</div>
);
})}
</div>
)}
</div>
);
};
export default LayoutBar;