import React, { useRef, useEffect, useMemo } from 'react'; import TextField from './TextField'; import DBTextField from './DBTextField'; import ExpressionField from './ExpressionField'; 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 === 'addExpr') { const newElement = { id: `expr-${Date.now()}`, type: 'expr', x: col, y: row, width: 10, height: 1, expression: '', dataType: 'general', alignment: 'left' }; 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) { // A band always spans the full page width from x=0, so it moves only up // and down; dragging one sideways would leave a gap no child can print in. const move = (el, x, y) => onElementUpdate(el.id, el.type === 'band' ? { y } : { x, 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) { move(el, el.x + deltaCol, el.y + deltaRow); } }); } else { move(element, col, 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(
); } // Horizontal lines for (let i = 0; i <= GRID_ROWS; i++) { gridLines.push(
); } 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(
); } 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 ; } else if (element.type === 'dbtext') { return ( ); } else if (element.type === 'expr') { return ( ); } else if (element.type === 'frame') { return ; } else if (element.type === 'hline') { return ; } else if (element.type === 'vline') { return ; } else if (element.type === 'symbol') { return ; } return null; }; return (
{renderGrid()} {/* Page break indicators */} {renderPageBreaks()} {/* Render selection rectangle */} {selectionRect && isSelecting && (
)} {/* Render frame preview */} {framePreview && (
)} {/* Render line preview */} {linePreview && (
)} {/* 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 ( 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))} ); } // 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 ( {/* 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); })} ); }); })()} {/* 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); }); })()}
); } export default EditorCanvas;