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