added vehicles and vehicles extra, dynamic tables for entities, dynamic dropdowns with overlay for selecting and manage data

This commit is contained in:
2026-02-10 17:32:33 +02:00
parent ed35a90cc0
commit 6a42099169
60 changed files with 4845 additions and 81 deletions
@@ -0,0 +1,654 @@
import React, { useRef, useEffect, useMemo } from 'react';
import TextField from './TextField';
import DBTextField from './DBTextField';
import FrameElement from './FrameElement';
import HorizontalLine from './HorizontalLine';
import VerticalLine from './VerticalLine';
import SymbolElement from './SymbolElement';
import BandElement from './BandElement';
import { useReportData } from './DataContext';
import { BandProvider } from './BandContext';
import { detectCrossroads } from './utils/crossroadDetector';
import { elementIntersectsRect } from './utils/elementUtils';
import { calculateBandInstances, calculateTotalContentHeight } from './utils/bandRenderer';
import './EditorCanvas.css';
const CHAR_WIDTH = 18;
const CHAR_HEIGHT = 32;
const GRID_COLS = 80;
const GRID_ROWS = 66;
function EditorCanvas({
elements,
selectedElementIds,
onElementSelect,
onSelectMultiple,
onDeselectAll,
onElementUpdate,
onElementDelete,
toolMode,
borderStyle,
onAddElement,
previewMode = false,
selectedChar = '─'
}) {
const canvasRef = useRef(null);
const [frameStart, setFrameStart] = React.useState(null);
const [framePreview, setFramePreview] = React.useState(null);
const [lineStart, setLineStart] = React.useState(null);
const [linePreview, setLinePreview] = React.useState(null);
const [selectionRect, setSelectionRect] = React.useState(null);
const [isSelecting, setIsSelecting] = React.useState(false);
const [isPainting, setIsPainting] = React.useState(false);
const [paintedPositions, setPaintedPositions] = React.useState(new Set());
const [didDragSelect, setDidDragSelect] = React.useState(false);
// Get report data for preview mode
const { reportData } = useReportData();
// Calculate total content height in preview mode
const contentHeight = useMemo(() => {
if (previewMode) {
const bands = elements.filter(el => el.type === 'band');
const totalHeight = calculateTotalContentHeight(bands, reportData, elements);
return totalHeight * CHAR_HEIGHT;
}
return GRID_ROWS * CHAR_HEIGHT; // Default height in design mode
}, [previewMode, elements, reportData]);
const snapToGrid = (pixelX, pixelY) => {
const col = Math.max(0, Math.min(GRID_COLS - 1, Math.round(pixelX / CHAR_WIDTH)));
const row = Math.max(0, Math.min(GRID_ROWS - 1, Math.round(pixelY / CHAR_HEIGHT)));
return { col, row };
};
const snapToGridAlt = (pixelX, pixelY) => {
const col = Math.max(0, Math.min(GRID_COLS - 1, Math.floor(pixelX / CHAR_WIDTH)));
const row = Math.max(0, Math.min(GRID_ROWS - 1, Math.floor(pixelY / CHAR_HEIGHT)));
return { col, row };
};
const gridToPixels = (col, row) => ({
x: col * CHAR_WIDTH,
y: row * CHAR_HEIGHT
});
const handleCanvasMouseDown = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const pixelX = e.clientX - rect.left;
const pixelY = e.clientY - rect.top;
const { col, row } = snapToGridAlt(pixelX, pixelY);
if (toolMode === 'select') {
// Start selection rectangle
setIsSelecting(true);
setSelectionRect({ startX: col, startY: row, endX: col, endY: row });
setDidDragSelect(false);
return;
} else if (toolMode === 'drawTable') {
// Start painting symbols
setIsPainting(true);
paintSymbol(col, row);
return;
}
};
const handleCanvasClick = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const pixelX = e.clientX - rect.left;
const pixelY = e.clientY - rect.top;
const { col, row } = snapToGridAlt(pixelX, pixelY);
if (toolMode === 'addText') {
const newElement = {
id: `text-${Date.now()}`,
type: 'text',
x: col,
y: row,
width: 10,
height: 1,
content: 'Text'
};
onAddElement(newElement);
} else if (toolMode === 'addDBText') {
const newElement = {
id: `dbtext-${Date.now()}`,
type: 'dbtext',
x: col,
y: row,
width: 10,
height: 1,
objectKey: '',
fieldPath: ''
};
onAddElement(newElement);
} else if (toolMode === 'addFrame') {
if (!frameStart) {
// First click - set start position
setFrameStart({ col, row });
} else {
// Second click - create frame
const x = Math.min(frameStart.col, col);
const y = Math.min(frameStart.row, row);
const width = Math.abs(col - frameStart.col) + 1;
const height = Math.abs(row - frameStart.row) + 1;
if (width >= 3 && height >= 3) {
const newElement = {
id: `frame-${Date.now()}`,
type: 'frame',
x,
y,
width,
height,
borderStyle
};
onAddElement(newElement);
}
setFrameStart(null);
setFramePreview(null);
}
} else if (toolMode === 'addHLine') {
if (!lineStart) {
// First click - set start position
setLineStart({ col, row, type: 'hline' });
} else {
// Second click - create horizontal line
const x = Math.min(lineStart.col, col);
const y = lineStart.row;
const length = Math.abs(col - lineStart.col) + 1;
if (length >= 2) {
const newElement = {
id: `hline-${Date.now()}`,
type: 'hline',
x,
y,
length,
lineStyle: borderStyle
};
onAddElement(newElement);
}
setLineStart(null);
setLinePreview(null);
}
} else if (toolMode === 'addVLine') {
if (!lineStart) {
// First click - set start position
setLineStart({ col, row, type: 'vline' });
} else {
// Second click - create vertical line
const x = lineStart.col;
const y = Math.min(lineStart.row, row);
const length = Math.abs(row - lineStart.row) + 1;
if (length >= 2) {
const newElement = {
id: `vline-${Date.now()}`,
type: 'vline',
x,
y,
length,
lineStyle: borderStyle
};
onAddElement(newElement);
}
setLineStart(null);
setLinePreview(null);
}
} else if (toolMode === 'addBand') {
// Add band (always full width, x=0)
// Check for overlap with existing bands
const newBand = {
id: `band-${Date.now()}`,
type: 'band',
x: 0,
y: row,
width: 80,
height: 3,
bandType: 'detail',
dataSource: '',
caption: '',
children: [],
parentBandId: null
};
// Check if this overlaps with any existing band
const overlaps = elements.some(el => {
if (el.type !== 'band') return false;
const elMaxY = el.y + el.height - 1;
const newMaxY = newBand.y + newBand.height - 1;
return !(elMaxY < newBand.y || el.y > newMaxY);
});
if (overlaps) {
alert('Cannot place band here - it overlaps with another band');
} else {
onAddElement(newBand);
}
} else if (toolMode === 'select') {
// Click on canvas background - deselect all (but not if we just did a drag selection)
if (!didDragSelect) {
onDeselectAll();
}
setDidDragSelect(false);
}
};
const paintSymbol = (col, row) => {
const key = `${col},${row}`;
if (paintedPositions.has(key)) return; // Already painted this cell
// Check if symbol already exists at this position
const existingIndex = elements.findIndex(
el => el.type === 'symbol' && el.x === col && el.y === row
);
if (existingIndex >= 0) {
// Replace existing symbol
onElementUpdate(elements[existingIndex].id, { char: selectedChar });
} else {
// Create new symbol
const newElement = {
id: `symbol-${Date.now()}-${Math.random()}`,
type: 'symbol',
x: col,
y: row,
char: selectedChar
};
onAddElement(newElement);
}
setPaintedPositions(prev => new Set([...prev, key]));
};
const findElementsInRect = (rect) => {
const minX = Math.min(rect.startX, rect.endX);
const maxX = Math.max(rect.startX, rect.endX);
const minY = Math.min(rect.startY, rect.endY);
const maxY = Math.max(rect.startY, rect.endY);
// Use element class method for intersection checking
return elements
.filter(el => elementIntersectsRect(el, { minX, maxX, minY, maxY }))
.map(el => el.id);
};
const handleMouseMove = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
const pixelX = e.clientX - rect.left;
const pixelY = e.clientY - rect.top;
const { col, row } = snapToGrid(pixelX, pixelY);
const { col: altCol, row: altRow } = snapToGridAlt(pixelX, pixelY);
if (isSelecting && toolMode === 'select') {
setSelectionRect(prev => {
// Mark that we dragged if the rectangle has actually moved
if (prev && (prev.startX !== altCol || prev.startY !== altRow)) {
setDidDragSelect(true);
}
return { ...prev, endX: altCol, endY: altRow };
});
} else if (isPainting && toolMode === 'drawTable') {
paintSymbol(altCol, altRow);
} else if (toolMode === 'addFrame' && frameStart) {
const x = Math.min(frameStart.col, col);
const y = Math.min(frameStart.row, row);
const width = Math.abs(col - frameStart.col) + 1;
const height = Math.abs(row - frameStart.row) + 1;
setFramePreview({ x, y, width, height });
} else if (toolMode === 'addHLine' && lineStart) {
const x = Math.min(lineStart.col, col);
const y = lineStart.row;
const length = Math.abs(col - lineStart.col) + 1;
setLinePreview({ x, y, length, type: 'hline' });
} else if (toolMode === 'addVLine' && lineStart) {
const x = lineStart.col;
const y = Math.min(lineStart.row, row);
const length = Math.abs(row - lineStart.row) + 1;
setLinePreview({ x, y, length, type: 'vline' });
}
};
const handleElementDragStart = (elementId, startX, startY) => {
const element = elements.find(el => el.id === elementId);
return {
elementId,
startX,
startY,
initialElementX: element.x,
initialElementY: element.y
};
};
const handleElementDrag = (dragData, deltaX, deltaY) => {
const element = elements.find(el => el.id === dragData.elementId);
if (!element) return;
// Calculate new position based on initial position + delta
const newPixelX = (dragData.initialElementX * CHAR_WIDTH) + deltaX;
const newPixelY = (dragData.initialElementY * CHAR_HEIGHT) + deltaY;
const { col, row } = snapToGrid(newPixelX, newPixelY);
// Only update if position actually changed
if (col !== element.x || row !== element.y) {
// If this element is selected with others, move all selected elements
if (selectedElementIds.includes(dragData.elementId) && selectedElementIds.length > 1) {
const deltaCol = col - element.x;
const deltaRow = row - element.y;
selectedElementIds.forEach(id => {
const el = elements.find(e => e.id === id);
if (el) {
onElementUpdate(id, { x: el.x + deltaCol, y: el.y + deltaRow });
}
});
} else {
onElementUpdate(dragData.elementId, { x: col, y: row });
}
}
};
// Add global mouseup listener to handle drag completion outside canvas
useEffect(() => {
const handleGlobalMouseUp = () => {
if (isSelecting && toolMode === 'select' && selectionRect) {
const selectedIds = findElementsInRect(selectionRect);
onSelectMultiple(selectedIds);
setIsSelecting(false);
setSelectionRect(null);
} else if (isPainting && toolMode === 'drawTable') {
setIsPainting(false);
setPaintedPositions(new Set());
}
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
}, [isSelecting, isPainting, selectionRect, toolMode, elements, onSelectMultiple]);
// Render grid lines
const renderGrid = () => {
const gridLines = [];
// Vertical lines
for (let i = 0; i <= GRID_COLS; i++) {
gridLines.push(
<div
key={`v-${i}`}
className="grid-line vertical"
style={{ left: `${i * (CHAR_WIDTH)}px` }}
/>
);
}
// Horizontal lines
for (let i = 0; i <= GRID_ROWS; i++) {
gridLines.push(
<div
key={`h-${i}`}
className="grid-line horizontal"
style={{ top: `${i * CHAR_HEIGHT}px` }}
/>
);
}
return gridLines;
};
// Calculate crossroad map for intersection rendering
const crossroadMap = useMemo(() => {
return detectCrossroads(elements);
}, [elements]);
const showCrosshair = toolMode === 'addFrame' || toolMode === 'addHLine' || toolMode === 'addVLine';
// Render page break indicators (every 66 rows in preview mode)
const renderPageBreaks = () => {
if (!previewMode) return null;
const breaks = [];
const pageHeight = GRID_ROWS * CHAR_HEIGHT; // 66 rows * 32px = 2112px
const numPages = Math.ceil(contentHeight / pageHeight);
for (let i = 1; i < numPages; i++) {
breaks.push(
<div
key={`page-break-${i}`}
className="page-break-indicator"
style={{ top: `${i * pageHeight}px` }}
/>
);
}
return breaks;
};
// Helper function to render an element (used for standalone and child elements)
const renderElement = (element, parentBandId = null) => {
const commonProps = {
key: element.id,
element,
isSelected: selectedElementIds.includes(element.id),
isSingleSelection: selectedElementIds.length === 1 && selectedElementIds[0] === element.id,
onSelect: (e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey),
onUpdate: (updates) => onElementUpdate(element.id, updates),
onDelete: () => onElementDelete(element.id),
onDragStart: handleElementDragStart,
onDrag: handleElementDrag,
charWidth: CHAR_WIDTH,
charHeight: CHAR_HEIGHT,
toolMode
};
if (element.type === 'text') {
return <TextField {...commonProps} />;
} else if (element.type === 'dbtext') {
return (
<DBTextField
{...commonProps}
previewMode={previewMode}
parentBandId={parentBandId}
/>
);
} else if (element.type === 'frame') {
return <FrameElement {...commonProps} />;
} else if (element.type === 'hline') {
return <HorizontalLine {...commonProps} crossroadMap={crossroadMap} />;
} else if (element.type === 'vline') {
return <VerticalLine {...commonProps} crossroadMap={crossroadMap} />;
} else if (element.type === 'symbol') {
return <SymbolElement {...commonProps} />;
}
return null;
};
return (
<div className="canvas-container">
<div
ref={canvasRef}
className={`editor-canvas ${showCrosshair ? 'crosshair' : ''}`}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleMouseMove}
>
<div
className="editor-canvas-content"
style={{
minHeight: `${GRID_ROWS * CHAR_HEIGHT}px`,
height: previewMode ? `${contentHeight}px` : `${GRID_ROWS * CHAR_HEIGHT}px`
}}
>
{renderGrid()}
{/* Page break indicators */}
{renderPageBreaks()}
{/* Render selection rectangle */}
{selectionRect && isSelecting && (
<div
className="selection-rectangle"
style={{
left: `${Math.min(selectionRect.startX, selectionRect.endX) * CHAR_WIDTH}px`,
top: `${Math.min(selectionRect.startY, selectionRect.endY) * CHAR_HEIGHT}px`,
width: `${(Math.abs(selectionRect.endX - selectionRect.startX) + 1) * CHAR_WIDTH}px`,
height: `${(Math.abs(selectionRect.endY - selectionRect.startY) + 1) * CHAR_HEIGHT}px`
}}
/>
)}
{/* Render frame preview */}
{framePreview && (
<div
className="frame-preview"
style={{
left: `${framePreview.x * CHAR_WIDTH}px`,
top: `${framePreview.y * CHAR_HEIGHT}px`,
width: `${framePreview.width * CHAR_WIDTH}px`,
height: `${framePreview.height * CHAR_HEIGHT}px`
}}
/>
)}
{/* Render line preview */}
{linePreview && (
<div
className="line-preview"
style={{
left: `${linePreview.x * CHAR_WIDTH}px`,
top: `${linePreview.y * CHAR_HEIGHT}px`,
width: linePreview.type === 'hline' ? `${linePreview.length * CHAR_WIDTH}px` : `${CHAR_WIDTH}px`,
height: linePreview.type === 'vline' ? `${linePreview.length * CHAR_HEIGHT}px` : `${CHAR_HEIGHT}px`
}}
/>
)}
{/* Render elements */}
{elements.map(element => {
// Check if element is a child of a band
const isChildOfBand = elements.some(el => el.type === 'band' && el.children && el.children.includes(element.id));
// In design mode, skip child elements (they're rendered inside band containers)
if (!previewMode) {
if (isChildOfBand && element.type !== 'band') {
return null; // Skip - will be rendered as child of band
}
} else {
// In preview mode, skip ALL elements - they're rendered in dedicated sections below
// (bands get repeated with data, children render inside bands, standalone elements get shifted)
return null;
}
// Render bands specially in design mode
if (element.type === 'band') {
if (!previewMode) {
// Design mode: render band as container with children
const childElements = elements.filter(el => element.children && element.children.includes(el.id));
return (
<BandElement
key={element.id}
element={element}
isSelected={selectedElementIds.includes(element.id)}
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
onUpdate={(updates) => onElementUpdate(element.id, updates)}
onDelete={() => onElementDelete(element.id)}
onDragStart={handleElementDragStart}
onDrag={handleElementDrag}
charWidth={CHAR_WIDTH}
charHeight={CHAR_HEIGHT}
toolMode={toolMode}
>
{/* Render child elements inside band */}
{childElements.map(childEl => renderElement(childEl, element.id))}
</BandElement>
);
}
// Preview mode: skip (bands rendered separately below)
return null;
}
// Render other element types
return renderElement(element);
})}
{/* Preview mode: Render band instances with repeated data */}
{previewMode && (() => {
const bands = elements.filter(el => el.type === 'band');
const bandInstances = calculateBandInstances(bands, reportData, elements);
return bandInstances.map(instance => {
// Build band data map for this instance
const bandDataMap = instance.data ? { [instance.bandId]: instance.data } : {};
return (
<BandProvider key={`${instance.bandId}-instance-${instance.instanceIndex}`} bandDataMap={bandDataMap}>
{/* Render child elements at adjusted positions */}
{instance.childElements.map(childEl => {
// Child element Y is relative to band, so add instance Y to get absolute position
const adjustedElement = {
...childEl,
y: instance.y + childEl.y
};
return renderElement(adjustedElement, instance.bandId);
})}
</BandProvider>
);
});
})()}
{/* Preview mode: Render standalone elements that aren't in bands */}
{previewMode && (() => {
const bands = elements.filter(el => el.type === 'band');
const bandInstances = calculateBandInstances(bands, reportData, elements);
// Build a map of band Y ranges in design mode vs preview mode
// IMPORTANT: Only consider top-level bands (no parent) because nested band expansion
// is already included in their parent's expansion
const bandShifts = new Map();
bands.filter(band => !band.parentBandId).forEach(band => {
const bandDesignMaxY = band.y + band.height - 1;
// Find all instances of this band
const instances = bandInstances.filter(inst => inst.bandId === band.id);
if (instances.length > 0) {
const lastInstance = instances[instances.length - 1];
const bandPreviewMaxY = lastInstance.y + lastInstance.height - 1;
// Calculate how much this band expanded
const expansion = bandPreviewMaxY - bandDesignMaxY;
bandShifts.set(band.id, { designMaxY: bandDesignMaxY, expansion });
}
});
return elements.filter(el => {
if (el.type === 'band') return false; // Skip bands themselves
// Check if this element is a child of any band
const isChildOfBand = elements.some(band => band.type === 'band' && band.children && band.children.includes(el.id));
return !isChildOfBand; // Only render if NOT a child of a band
}).map(element => {
// Calculate cumulative shift for elements below bands
let totalShift = 0;
bandShifts.forEach(({ designMaxY, expansion }) => {
if (element.y > designMaxY) {
totalShift += expansion;
}
});
// Render with adjusted Y position
const adjustedElement = {
...element,
y: element.y + totalShift
};
return renderElement(adjustedElement);
});
})()}
</div>
</div>
</div>
);
}
export default EditorCanvas;