221 lines
7.8 KiB
TypeScript
221 lines
7.8 KiB
TypeScript
/**
|
||
* 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;
|