diff --git a/frontend/src/components/ReportEditor/BandContext.js b/frontend/src/components/ReportEditor/BandContext.js new file mode 100644 index 0000000..4c62919 --- /dev/null +++ b/frontend/src/components/ReportEditor/BandContext.js @@ -0,0 +1,49 @@ +import React, { createContext, useContext, useState } from 'react'; + +/** + * Context for tracking band iteration state + * Used to provide current data item to elements inside bands + */ +const BandContext = createContext(); + +export function BandProvider({ children, bandDataMap = {} }) { + // Map of bandId -> current data item + // Can be provided externally or managed internally + const [internalBandData, setInternalBandData] = useState({}); + + // Use external bandDataMap if provided, otherwise use internal state + const currentBandData = Object.keys(bandDataMap).length > 0 ? bandDataMap : internalBandData; + + const setBandData = (bandId, data) => { + setInternalBandData(prev => ({ + ...prev, + [bandId]: data + })); + }; + + const clearBandData = () => { + setInternalBandData({}); + }; + + const removeBandData = (bandId) => { + setInternalBandData(prev => { + const newData = { ...prev }; + delete newData[bandId]; + return newData; + }); + }; + + return ( + + {children} + + ); +} + +export function useBandContext() { + const context = useContext(BandContext); + if (!context) { + throw new Error('useBandContext must be used within a BandProvider'); + } + return context; +} diff --git a/frontend/src/components/ReportEditor/BandElement.js b/frontend/src/components/ReportEditor/BandElement.js new file mode 100644 index 0000000..790818f --- /dev/null +++ b/frontend/src/components/ReportEditor/BandElement.js @@ -0,0 +1,83 @@ +import React from 'react'; +import { useElementDrag } from './hooks/useElementDrag'; +import { useElementSelection } from './hooks/useElementSelection'; +import { getElementStyle } from './utils/elementUtils'; +import './elements.css'; + +/** + * Band element component + * Represents a repeating section that iterates over data arrays + */ +function BandElement({ + element, + isSelected, + isSingleSelection, + onSelect, + onUpdate, + onDelete, + onDragStart, + onDrag, + charWidth, + charHeight, + toolMode, + children // Child elements to render inside band (in design mode) +}) { + // Use custom hooks for drag and selection + const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({ + isSelected, + onDragStart, + onDrag, + toolMode, + constrainX: true // Only vertical dragging + }); + + const { handleClick } = useElementSelection({ + isSelected, + onSelect, + onDelete, + toolMode + }); + + const handleMouseDown = (e) => { + e.stopPropagation(); + if (!isSelected) { + onSelect(); + } + handleDragMouseDown(e, element.id); + }; + + // Get caption text + const captionText = element.caption || + `${element.bandType}: ${element.dataSource || 'no data'}`; + + const style = { + ...getElementStyle(element, charWidth, charHeight), + borderTop: '2px dashed #667eea', + borderBottom: '2px dashed #667eea', + borderLeft: 'none', + borderRight: 'none', + backgroundColor: isSelected + ? 'rgba(102, 126, 234, 0.08)' + : 'rgba(200, 200, 200, 0.03)', + boxSizing: 'border-box' + }; + + return ( +
+
+ {captionText} +
+ {/* Render child elements inside band container in design mode */} +
+ {children} +
+
+ ); +} + +export default BandElement; diff --git a/frontend/src/components/ReportEditor/ConfigPanel.js b/frontend/src/components/ReportEditor/ConfigPanel.js index 02e5fc6..3a1b6b7 100644 --- a/frontend/src/components/ReportEditor/ConfigPanel.js +++ b/frontend/src/components/ReportEditor/ConfigPanel.js @@ -86,18 +86,152 @@ function ConfigPanel({ apiEndpoint, onApiEndpointChange, isOpen, onClose }) { if (!showManualInput && !manualDataInput) { // Pre-fill with example data when opening for the first time setManualDataInput(JSON.stringify({ + "reportTitle": "Weight Measurements Report", + "reportDate": "2026-01-26", "owner": { "name": "John Doe", "contact": { - "phone": "555-1234" + "phone": "555-1234", + "email": "john.doe@example.com" } }, "vessel": { "id": "ABC123", - "type": "Truck" + "type": "Truck", + "capacity": 5000 }, - "weight": 1500, - "timestamp": "2026-01-23T10:00:00" + "measurements": [ + { + "id": 1, + "weight": 1500, + "timestamp": "2026-01-26T08:00:00", + "operator": "Alice", + "items": [ + { "name": "Box A", "quantity": 10, "unitWeight": 50 }, + { "name": "Box B", "quantity": 20, "unitWeight": 25 } + ] + }, + { + "id": 2, + "weight": 1650, + "timestamp": "2026-01-26T09:30:00", + "operator": "Bob", + "items": [ + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Pallet D", "quantity": 15, "unitWeight": 30 } + ] + }, + { + "id": 3, + "weight": 1820, + "timestamp": "2026-01-26T11:15:00", + "operator": "Charlie", + "items": [ + { "name": "Container E", "quantity": 8, "unitWeight": 75 } + ] + }, + { + "id": 4, + "weight": 41820, + "timestamp": "2026-01-26T11:15:00", + "operator": "4Charlie", + "items": [ + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Crate C", "quantity": 5, "unitWeight": 100 }, + { "name": "Container E", "quantity": 8, "unitWeight": 75 } + ] + } + + ], + "summary": { + "totalMeasurements": 3, + "averageWeight": 1656.67, + "maxWeight": 1820, + "minWeight": 1500 + } }, null, 2)); } setShowManualInput(!showManualInput); diff --git a/frontend/src/components/ReportEditor/DBTextField.js b/frontend/src/components/ReportEditor/DBTextField.js new file mode 100644 index 0000000..2377a80 --- /dev/null +++ b/frontend/src/components/ReportEditor/DBTextField.js @@ -0,0 +1,99 @@ +import React from 'react'; +import { useReportData } from './DataContext'; +import { useBandContext } from './BandContext'; +import { resolveDBTextValue } from './utils/dataResolver'; +import ResizeHandles from './ResizeHandles'; +import { useElementDrag } from './hooks/useElementDrag'; +import { useElementSelection } from './hooks/useElementSelection'; +import { getElementStyle, isTextMultiLine, getTextWhiteSpace } from './utils/elementUtils'; +import './elements.css'; + +function DBTextField({ + element, + isSelected, + isSingleSelection, + onSelect, + onUpdate, + onDelete, + onDragStart, + onDrag, + charWidth, + charHeight, + previewMode = false, + toolMode, + parentBandId = null // ID of parent band (for automatic data binding) +}) { + const { reportData } = useReportData(); + const { currentBandData } = useBandContext(); + + // Use custom hooks for drag and selection + const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({ + isSelected, + onDragStart, + onDrag, + toolMode + }); + + const { handleClick } = useElementSelection({ + isSelected, + onSelect, + onDelete, + toolMode + }); + + const handleMouseDown = (e) => { + onSelect(); // Select element before dragging + handleDragMouseDown(e, element.id); + }; + + const handleResize = (updates) => { + onUpdate(updates); + }; + + // Resolve data value + const displayContent = previewMode + ? resolveDBTextValue(reportData, element.objectKey, element.fieldPath, parentBandId, { currentBandData }) + : parentBandId + ? `{${element.fieldPath}}` // Inside band: show field only + : `{${element.objectKey}.${element.fieldPath}}`; // Outside band: show object.field + + // Check if data is resolved + // If inside a band, only fieldPath is required; otherwise both objectKey and fieldPath are required + const isUnresolved = parentBandId + ? (!element.fieldPath || (previewMode && !displayContent)) + : (!element.objectKey || !element.fieldPath || (previewMode && !displayContent)); + + // Use element class methods + const multiLine = isTextMultiLine(element); + const whiteSpace = getTextWhiteSpace(element); + + const style = { + ...getElementStyle(element, charWidth, charHeight), + minWidth: `${charWidth}px`, + minHeight: `${charHeight}px`, + whiteSpace + }; + + return ( +
+ + {displayContent || '(No Data)'} + + {isSingleSelection && ( + + )} +
+ ); +} + +export default DBTextField; diff --git a/frontend/src/components/ReportEditor/EditorCanvas.css b/frontend/src/components/ReportEditor/EditorCanvas.css index bb924ad..4726ac1 100644 --- a/frontend/src/components/ReportEditor/EditorCanvas.css +++ b/frontend/src/components/ReportEditor/EditorCanvas.css @@ -25,6 +25,13 @@ -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; + overflow-y: auto; + overflow-x: hidden; +} + +.editor-canvas-content { + position: relative; + width: 100%; } .editor-canvas.crosshair { diff --git a/frontend/src/components/ReportEditor/EditorCanvas.js b/frontend/src/components/ReportEditor/EditorCanvas.js index 45df994..b560366 100644 --- a/frontend/src/components/ReportEditor/EditorCanvas.js +++ b/frontend/src/components/ReportEditor/EditorCanvas.js @@ -1,10 +1,16 @@ 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; @@ -37,6 +43,19 @@ function EditorCanvas({ 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))); @@ -92,6 +111,18 @@ function EditorCanvas({ 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 @@ -166,6 +197,36 @@ function EditorCanvas({ 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) { @@ -208,15 +269,10 @@ function EditorCanvas({ const minY = Math.min(rect.startY, rect.endY); const maxY = Math.max(rect.startY, rect.endY); - return elements.filter(el => { - const elMinX = el.x; - const elMinY = el.y; - const elMaxX = el.x + (el.width || el.length || 1) - 1; - const elMaxY = el.y + (el.height || (el.type === 'vline' ? el.length : 1)) - 1; - - // Check if element intersects with selection rectangle - return !(elMaxX < minX || elMinX > maxX || elMaxY < minY || elMinY > maxY); - }).map(el => el.id); + // Use element class method for intersection checking + return elements + .filter(el => elementIntersectsRect(el, { minX, maxX, minY, maxY })) + .map(el => el.id); }; const handleMouseMove = (e) => { @@ -347,6 +403,66 @@ function EditorCanvas({ 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 === 'frame') { + return ; + } else if (element.type === 'hline') { + return ; + } else if (element.type === 'vline') { + return ; + } else if (element.type === 'symbol') { + return ; + } + return null; + }; + return (
- {renderGrid()} +
+ {renderGrid()} + + {/* Page break indicators */} + {renderPageBreaks()} {/* Render selection rectangle */} {selectionRect && isSelecting && ( @@ -399,95 +525,127 @@ function EditorCanvas({ {/* Render elements */} {elements.map(element => { - if (element.type === 'text') { - 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} - previewMode={previewMode} - toolMode={toolMode} - /> - ); - } else if (element.type === 'frame') { - return ( - onElementSelect(element.id, e?.ctrlKey || e?.metaKey)} - onDelete={() => onElementDelete(element.id)} - onUpdate={(updates) => onElementUpdate(element.id, updates)} - onDragStart={handleElementDragStart} - onDrag={handleElementDrag} - charWidth={CHAR_WIDTH} - charHeight={CHAR_HEIGHT} - toolMode={toolMode} - /> - ); - } else if (element.type === 'hline') { - return ( - onElementSelect(element.id, e?.ctrlKey || e?.metaKey)} - onDelete={() => onElementDelete(element.id)} - onUpdate={(updates) => onElementUpdate(element.id, updates)} - onDragStart={handleElementDragStart} - onDrag={handleElementDrag} - charWidth={CHAR_WIDTH} - charHeight={CHAR_HEIGHT} - crossroadMap={crossroadMap} - toolMode={toolMode} - /> - ); - } else if (element.type === 'vline') { - return ( - onElementSelect(element.id, e?.ctrlKey || e?.metaKey)} - onDelete={() => onElementDelete(element.id)} - onUpdate={(updates) => onElementUpdate(element.id, updates)} - onDragStart={handleElementDragStart} - onDrag={handleElementDrag} - charWidth={CHAR_WIDTH} - charHeight={CHAR_HEIGHT} - crossroadMap={crossroadMap} - toolMode={toolMode} - /> - ); - } else if (element.type === 'symbol') { - return ( - onElementSelect(element.id, e?.ctrlKey || e?.metaKey)} - onDelete={() => onElementDelete(element.id)} - onDragStart={handleElementDragStart} - onDrag={handleElementDrag} - charWidth={CHAR_WIDTH} - charHeight={CHAR_HEIGHT} - toolMode={toolMode} - /> - ); + // 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; } - 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); + }); + })()} +
); diff --git a/frontend/src/components/ReportEditor/FrameElement.js b/frontend/src/components/ReportEditor/FrameElement.js index de702fc..56f24ec 100644 --- a/frontend/src/components/ReportEditor/FrameElement.js +++ b/frontend/src/components/ReportEditor/FrameElement.js @@ -1,5 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import ResizeHandles from './ResizeHandles'; +import { useElementDrag } from './hooks/useElementDrag'; +import { useElementSelection } from './hooks/useElementSelection'; +import { getElementStyle, isPositionOnFrameBorder } from './utils/elementUtils'; import './elements.css'; const BORDER_CHARS = { @@ -23,20 +26,22 @@ const BORDER_CHARS = { function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDelete, onUpdate, onDragStart, onDrag, charWidth, charHeight, toolMode }) { const chars = BORDER_CHARS[element.borderStyle || 'single']; - const [isDragging, setIsDragging] = useState(false); - const [dragData, setDragData] = useState(null); const [isHoveringBorder, setIsHoveringBorder] = useState(false); - useEffect(() => { - const handleKeyPress = (e) => { - if (isSelected && e.key === 'Delete') { - onDelete(); - } - }; - - document.addEventListener('keydown', handleKeyPress); - return () => document.removeEventListener('keydown', handleKeyPress); - }, [isSelected, onDelete]); + // Use custom hooks for drag and selection + const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({ + isSelected, + onDragStart, + onDrag, + toolMode + }); + + useElementSelection({ + isSelected, + onSelect, + onDelete, + toolMode + }); const renderFrame = () => { const lines = []; @@ -70,11 +75,10 @@ function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDele return lines; }; + // Use element class method for style calculation + const baseStyle = getElementStyle(element, charWidth, charHeight); const style = { - left: `${element.x * charWidth}px`, - top: `${element.y * charHeight}px`, - width: `${element.width * charWidth}px`, - height: `${element.height * charHeight}px`, + ...baseStyle, cursor: (toolMode === 'select' && isHoveringBorder) ? 'pointer' : 'default' }; @@ -88,9 +92,8 @@ function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDele const col = Math.floor(relativeX / charWidth); const row = Math.floor(relativeY / charHeight); - // Check if on border (first/last row or first/last column) - const onBorder = row === 0 || row === element.height - 1 || col === 0 || col === element.width - 1; - return onBorder; + // Use element class method to check if position is on border + return isPositionOnFrameBorder(element, col, row); }; const handleClick = (e) => { @@ -113,48 +116,14 @@ function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDele // Only handle dragging in select mode and on border clicks if (toolMode !== 'select' || !isClickOnBorder(e)) return; - e.stopPropagation(); - onSelect(e); - - setIsDragging(true); - const data = onDragStart(element.id, e.clientX, e.clientY); - setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY }); + onSelect(e); // Select element before dragging + handleDragMouseDown(e, element.id); }; const handleResize = (updates) => { onUpdate(updates); }; - useEffect(() => { - if (!isDragging) return; - - // Add class to body to disable transitions globally during drag - document.body.classList.add('dragging-active'); - - const handleMouseMove = (e) => { - if (dragData) { - const deltaX = e.clientX - dragData.startMouseX; - const deltaY = e.clientY - dragData.startMouseY; - onDrag(dragData, deltaX, deltaY); - } - }; - - const handleMouseUp = () => { - setIsDragging(false); - setDragData(null); - document.body.classList.remove('dragging-active'); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.classList.remove('dragging-active'); - }; - }, [isDragging, dragData, onDrag]); - return (
{ - const handleKeyPress = (e) => { - if (isSelected && e.key === 'Delete') { - onDelete(); - } - }; - - document.addEventListener('keydown', handleKeyPress); - return () => document.removeEventListener('keydown', handleKeyPress); - }, [isSelected, onDelete]); - - const handleClick = (e) => { - // Only handle selection in select mode, let other modes bubble to canvas - if (toolMode === 'select') { - e.stopPropagation(); - onSelect(e); - } - }; + // Use custom hooks for drag and selection + const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({ + isSelected, + onDragStart, + onDrag, + toolMode + }); + + const { handleClick } = useElementSelection({ + isSelected, + onSelect, + onDelete, + toolMode + }); const handleMouseDown = (e) => { - // Only handle dragging in select mode - if (toolMode !== 'select') return; - - e.stopPropagation(); - onSelect(e); - - setIsDragging(true); - const data = onDragStart(element.id, e.clientX, e.clientY); - setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY }); + onSelect(e); // Select element before dragging + handleDragMouseDown(e, element.id); }; const handleResize = (updates) => { onUpdate(updates); }; - useEffect(() => { - if (!isDragging) return; - - // Add class to body to disable transitions globally during drag - document.body.classList.add('dragging-active'); - - const handleMouseMove = (e) => { - if (dragData) { - const deltaX = e.clientX - dragData.startMouseX; - const deltaY = e.clientY - dragData.startMouseY; - onDrag(dragData, deltaX, deltaY); - } - }; - - const handleMouseUp = () => { - setIsDragging(false); - setDragData(null); - document.body.classList.remove('dragging-active'); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.classList.remove('dragging-active'); - }; - }, [isDragging, dragData, onDrag]); - // Render line with possible crossroad overrides const renderLine = () => { let line = ''; @@ -109,12 +68,8 @@ function HorizontalLine({ return line; }; - const style = { - left: `${element.x * charWidth}px`, - top: `${element.y * charHeight}px`, - width: `${element.length * charWidth}px`, - height: `${charHeight}px` - }; + // Use element class method for style calculation + const style = getElementStyle(element, charWidth, charHeight); return (
+
Object Inspector
+
No element selected
+
+ ); + } + + const handleChange = (property, value) => { + onUpdate({ [property]: value }); + }; + + const handleNumberChange = (property, value) => { + const numValue = parseInt(value, 10); + if (!isNaN(numValue)) { + onUpdate({ [property]: numValue }); + } + }; + + // Get available objects and fields for DBTextField + // If DBTextField is inside a band, find the band and get fields from its data structure + let availableObjects = []; + let availableFields = []; + let parentBand = null; + + if (element.type === 'dbtext') { + // Check if this element is a child of a band + parentBand = allElements.find(el => el.type === 'band' && el.children && el.children.includes(element.id)); + + if (parentBand && parentBand.dataSource) { + // Inside a band - get fields from band's data array structure + let bandDataArray; + + if (parentBand.parentBandId) { + // Nested band - resolve through parent chain + const grandparentBand = allElements.find(el => el.id === parentBand.parentBandId); + if (grandparentBand && grandparentBand.dataSource) { + const grandparentData = reportData?.[grandparentBand.dataSource]; + if (Array.isArray(grandparentData) && grandparentData.length > 0) { + // Navigate to nested array using parentBand.dataSource + const nestedData = grandparentData[0][parentBand.dataSource]; + if (Array.isArray(nestedData) && nestedData.length > 0) { + bandDataArray = nestedData; + } + } + } + } else { + // Top-level band - get data directly + bandDataArray = reportData?.[parentBand.dataSource]; + } + + if (Array.isArray(bandDataArray) && bandDataArray.length > 0) { + // Get fields from first item in array + availableFields = getAvailableFields(bandDataArray[0]); + } + // Don't show object dropdown when inside band + availableObjects = []; + } else { + // Not inside a band - use normal object/field selection + availableObjects = getAvailableObjects(reportData); + availableFields = element.objectKey ? getAvailableFields(reportData[element.objectKey]) : []; + } + } + + // Get available arrays for Band data source + // If this band has a parent, get arrays from parent's data structure + let availableArrays = []; + let currentBandParent = null; + if (element.type === 'band') { + if (element.parentBandId) { + currentBandParent = allElements.find(el => el.id === element.parentBandId); + } + availableArrays = getAvailableArrays(reportData, currentBandParent); + } + + // Get all bands for parent band dropdown (excluding current element) + const availableBands = element.type === 'band' + ? allElements.filter(el => el.type === 'band' && el.id !== element.id) + : []; + + return ( +
+
Object Inspector
+ +
+
General
+ +
+ + +
+ +
+ + handleNumberChange('x', e.target.value)} + /> +
+ +
+ + handleNumberChange('y', e.target.value)} + /> +
+ + {(element.width !== undefined) && ( +
+ + handleNumberChange('width', e.target.value)} + /> +
+ )} + + {(element.height !== undefined) && ( +
+ + handleNumberChange('height', e.target.value)} + /> +
+ )} + + {element.length !== undefined && ( +
+ + handleNumberChange('length', e.target.value)} + /> +
+ )} +
+ + {/* TextField specific */} + {element.type === 'text' && ( +
+
Text
+
+ + handleChange('content', e.target.value)} + /> +
+
+ )} + + {/* DBTextField specific */} + {element.type === 'dbtext' && ( +
+
Data Binding
+ + {parentBand ? ( + <> +
+ {parentBand.parentBandId ? ( + <> + Inside nested band: {parentBand.dataSource} +
+ (within {allElements.find(el => el.id === parentBand.parentBandId)?.dataSource}) + + ) : ( + <>Inside band: {parentBand.dataSource} + )} +
+ Fields auto-bind to current row +
+
+ + +
+ + ) : ( + <> +
+ + +
+ +
+ + +
+ + )} +
+ )} + + {/* Frame/Line specific */} + {(element.type === 'frame' || element.type === 'hline' || element.type === 'vline') && ( +
+
Style
+
+ + +
+
+ )} + + {/* Symbol specific */} + {element.type === 'symbol' && ( +
+
Symbol
+
+ + handleChange('char', e.target.value)} + /> +
+
+ )} + + {/* Band specific */} + {element.type === 'band' && ( +
+
Band
+ +
+ + +
+ +
+ + +
+ +
+ + handleChange('caption', e.target.value)} + placeholder="Auto-generated" + /> +
+ +
+ + +
+
+ )} +
+ ); +} + +export default ObjectInspector; diff --git a/frontend/src/components/ReportEditor/ReportEditor.css b/frontend/src/components/ReportEditor/ReportEditor.css index ddbb4c9..952351d 100644 --- a/frontend/src/components/ReportEditor/ReportEditor.css +++ b/frontend/src/components/ReportEditor/ReportEditor.css @@ -4,3 +4,9 @@ height: 100vh; overflow: hidden; } + +.editor-layout { + display: flex; + flex: 1; + overflow: hidden; +} diff --git a/frontend/src/components/ReportEditor/ReportEditor.js b/frontend/src/components/ReportEditor/ReportEditor.js index 68a2d28..7c0fb0e 100644 --- a/frontend/src/components/ReportEditor/ReportEditor.js +++ b/frontend/src/components/ReportEditor/ReportEditor.js @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react'; import { DataProvider } from './DataContext'; +import { BandProvider } from './BandContext'; import Toolbar from './Toolbar'; import EditorCanvas from './EditorCanvas'; import ConfigPanel from './ConfigPanel'; import CharacterPalette from './CharacterPalette'; +import ObjectInspector from './ObjectInspector'; import './ReportEditor.css'; function ReportEditorContent() { @@ -24,10 +26,44 @@ function ReportEditorContent() { const [showCharacterPalette, setShowCharacterPalette] = useState(false); const handleAddElement = (element) => { - setReport(prev => ({ - ...prev, - elements: [...prev.elements, element] - })); + setReport(prev => { + // Check if the new element is positioned inside a band + const containingBand = prev.elements.find(el => { + if (el.type !== 'band') return false; + // Check if element is within band's Y range + const bandMinY = el.y; + const bandMaxY = el.y + el.height - 1; + const elementY = element.y; + return elementY >= bandMinY && elementY <= bandMaxY; + }); + + // Adjust element position to be relative to band if inside one + let adjustedElement = { ...element }; + if (containingBand) { + adjustedElement.y = element.y - containingBand.y; + } + + let updatedElements = [...prev.elements, adjustedElement]; + + // If inside a band, add element ID to band's children array + if (containingBand) { + updatedElements = updatedElements.map(el => { + if (el.id === containingBand.id) { + return { + ...el, + children: [...(el.children || []), adjustedElement.id] + }; + } + return el; + }); + } + + return { + ...prev, + elements: updatedElements + }; + }); + setSelectedElementIds([element.id]); // After adding an element, switch back to select mode (unless in drawTable mode) if (toolMode !== 'drawTable') { @@ -36,12 +72,40 @@ function ReportEditorContent() { }; const handleElementUpdate = (elementId, updates) => { - setReport(prev => ({ - ...prev, - elements: prev.elements.map(el => - el.id === elementId ? { ...el, ...updates } : el - ) - })); + setReport(prev => { + const element = prev.elements.find(el => el.id === elementId); + + // Check if this is a band height change + if (element && element.type === 'band' && updates.height !== undefined && updates.height !== element.height) { + const heightDelta = updates.height - element.height; + const bandMaxY = element.y + element.height - 1; + + // Shift all elements (including other bands) below this band + return { + ...prev, + elements: prev.elements.map(el => { + if (el.id === elementId) { + // Apply updates to the band itself + return { ...el, ...updates }; + } + // Shift elements that are below the band (not children of the band) + const isChildOfThisBand = element.children && element.children.includes(el.id); + if (!isChildOfThisBand && el.y > bandMaxY) { + return { ...el, y: el.y + heightDelta }; + } + return el; + }) + }; + } + + // Normal update (not a band height change) + return { + ...prev, + elements: prev.elements.map(el => + el.id === elementId ? { ...el, ...updates } : el + ) + }; + }); }; const handleElementDelete = (elementId) => { @@ -138,6 +202,11 @@ function ReportEditorContent() { return () => document.removeEventListener('keydown', handleKeyDown); }, [showConfigPanel, selectedElementIds, toolMode, report.elements]); + // Get the selected element for the Object Inspector + const selectedElement = selectedElementIds.length === 1 + ? report.elements.find(el => el.id === selectedElementIds[0]) + : null; + return (
setShowConfigPanel(true)} /> - +
+ +
+ {selectedElementIds.length === 1 && ( + handleElementUpdate(selectedElementIds[0], updates)} + /> + )} - + + + ); } diff --git a/frontend/src/components/ReportEditor/ResizeHandles.js b/frontend/src/components/ReportEditor/ResizeHandles.js index c4ee312..0ed8f13 100644 --- a/frontend/src/components/ReportEditor/ResizeHandles.js +++ b/frontend/src/components/ReportEditor/ResizeHandles.js @@ -18,7 +18,10 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) { case 'vline': return ['start', 'end']; case 'text': + case 'dbtext': return ['nw', 'ne', 'sw', 'se']; + case 'band': + return ['s']; // Only bottom handle for height adjustment default: return []; } @@ -42,6 +45,14 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) { const contentLength = element.content ? element.content.length : 1; width = (element.width || Math.max(contentLength, 5)) * charWidth; height = (element.height || 1) * charHeight; + } else if (element.type === 'dbtext') { + // For DB text fields, use stored dimensions + width = (element.width || 10) * charWidth; + height = (element.height || 1) * charHeight; + } else if (element.type === 'band') { + // For bands, always full width + width = 80 * charWidth; + height = (element.height || 3) * charHeight; } else { // For frames and other elements width = (element.width || 1) * charWidth; @@ -179,7 +190,7 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) { // Moving end point up/down updates.length = Math.max(MIN_LINE_LENGTH, initial.length + deltaRow); } - } else if (element.type === 'text') { + } else if (element.type === 'text' || element.type === 'dbtext') { const initial = dragData.initialElement; switch (dragData.handle) { @@ -208,6 +219,14 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) { updates.height = Math.max(MIN_TEXT_SIZE.height, initial.height + deltaRow); break; } + } else if (element.type === 'band') { + const initial = dragData.initialElement; + const MIN_BAND_HEIGHT = 1; + + // Bands only resize vertically (height) + if (dragData.handle === 's') { + updates.height = Math.max(MIN_BAND_HEIGHT, initial.height + deltaRow); + } } if (Object.keys(updates).length > 0) { diff --git a/frontend/src/components/ReportEditor/SymbolElement.js b/frontend/src/components/ReportEditor/SymbolElement.js index dff32f7..b902d51 100644 --- a/frontend/src/components/ReportEditor/SymbolElement.js +++ b/frontend/src/components/ReportEditor/SymbolElement.js @@ -1,4 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; +import { useElementDrag } from './hooks/useElementDrag'; +import { useElementSelection } from './hooks/useElementSelection'; +import { getElementStyle } from './utils/elementUtils'; import './elements.css'; function SymbolElement({ @@ -12,76 +15,28 @@ function SymbolElement({ charHeight, toolMode }) { - const [isDragging, setIsDragging] = useState(false); - const [dragData, setDragData] = useState(null); - - useEffect(() => { - const handleKeyPress = (e) => { - if (isSelected && e.key === 'Delete') { - onDelete(); - } - }; - - document.addEventListener('keydown', handleKeyPress); - return () => document.removeEventListener('keydown', handleKeyPress); - }, [isSelected, onDelete]); - - const handleClick = (e) => { - // Only handle selection in select mode, let other modes bubble to canvas - if (toolMode === 'select') { - e.stopPropagation(); - onSelect(e); - } - }; + // Use custom hooks for drag and selection + const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({ + isSelected, + onDragStart, + onDrag, + toolMode + }); + + const { handleClick } = useElementSelection({ + isSelected, + onSelect, + onDelete, + toolMode + }); const handleMouseDown = (e) => { - // Only handle dragging in select mode - if (toolMode !== 'select') return; - - e.stopPropagation(); - onSelect(e); - - setIsDragging(true); - const data = onDragStart(element.id, e.clientX, e.clientY); - setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY }); + onSelect(e); // Select element before dragging + handleDragMouseDown(e, element.id); }; - useEffect(() => { - if (!isDragging) return; - - // Add class to body to disable transitions globally during drag - document.body.classList.add('dragging-active'); - - const handleMouseMove = (e) => { - if (dragData) { - const deltaX = e.clientX - dragData.startMouseX; - const deltaY = e.clientY - dragData.startMouseY; - onDrag(dragData, deltaX, deltaY); - } - }; - - const handleMouseUp = () => { - setIsDragging(false); - setDragData(null); - document.body.classList.remove('dragging-active'); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.classList.remove('dragging-active'); - }; - }, [isDragging, dragData, onDrag]); - - const style = { - left: `${element.x * charWidth}px`, - top: `${element.y * charHeight}px`, - width: `${charWidth}px`, - height: `${charHeight}px` - }; + // Use element class method for style calculation + const style = getElementStyle(element, charWidth, charHeight); return (
{ + if (!isEditing) onDelete(); + }, + toolMode + }); + useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); @@ -32,14 +46,6 @@ function TextField({ } }, [isEditing]); - const handleClick = (e) => { - // Only handle selection in select mode, let other modes bubble to canvas - if (toolMode === 'select') { - e.stopPropagation(); - onSelect(e); - } - }; - const handleDoubleClick = (e) => { // Only allow editing in select mode if (toolMode === 'select') { @@ -72,92 +78,37 @@ function TextField({ const handleMouseDown = (e) => { if (isEditing) return; - - // Only handle dragging in select mode - if (toolMode !== 'select') return; - - e.stopPropagation(); - onSelect(); - - setIsDragging(true); - const data = onDragStart(element.id, e.clientX, e.clientY); - setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY }); + onSelect(); // Select element before dragging + handleDragMouseDown(e, element.id); }; - useEffect(() => { - if (!isDragging) return; - - // Add class to body to disable transitions globally during drag - document.body.classList.add('dragging-active'); - - const handleMouseMove = (e) => { - if (dragData) { - const deltaX = e.clientX - dragData.startMouseX; - const deltaY = e.clientY - dragData.startMouseY; - onDrag(dragData, deltaX, deltaY); - } - }; - - const handleMouseUp = () => { - setIsDragging(false); - setDragData(null); - document.body.classList.remove('dragging-active'); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.classList.remove('dragging-active'); - }; - }, [isDragging, dragData, onDrag]); - useEffect(() => { if (!isSelected) { setIsEditing(false); } }, [isSelected]); - useEffect(() => { - const handleKeyPress = (e) => { - if (isSelected && !isEditing && e.key === 'Delete') { - onDelete(); - } - }; - - document.addEventListener('keydown', handleKeyPress); - return () => document.removeEventListener('keydown', handleKeyPress); - }, [isSelected, isEditing, onDelete]); - const handleResize = (updates) => { onUpdate(updates); }; - // Resolve bindings if in preview mode - const displayContent = previewMode - ? parseBindings(element.content, reportData) - : element.content; - - // Check if field has bindings for styling - const fieldHasBindings = hasBindings(element.content); + // Display content directly (no binding resolution) + const displayContent = element.content; - const isMultiLine = element.height && element.height > 1; + // Use element class methods + const multiLine = isTextMultiLine(element); + const whiteSpace = getTextWhiteSpace(element); const style = { - left: `${element.x * charWidth}px`, - top: `${element.y * charHeight}px`, - width: element.width ? `${element.width * charWidth}px` : 'auto', - height: element.height ? `${element.height * charHeight}px` : 'auto', + ...getElementStyle(element, charWidth, charHeight), minWidth: `${charWidth}px`, minHeight: `${charHeight}px`, - whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap' + whiteSpace }; return (
{displayContent} diff --git a/frontend/src/components/ReportEditor/Toolbar.js b/frontend/src/components/ReportEditor/Toolbar.js index 95b8b26..7f9e82b 100644 --- a/frontend/src/components/ReportEditor/Toolbar.js +++ b/frontend/src/components/ReportEditor/Toolbar.js @@ -27,6 +27,13 @@ function Toolbar({ > T Add Text + +
diff --git a/frontend/src/components/ReportEditor/VerticalLine.js b/frontend/src/components/ReportEditor/VerticalLine.js index 72145af..535ff45 100644 --- a/frontend/src/components/ReportEditor/VerticalLine.js +++ b/frontend/src/components/ReportEditor/VerticalLine.js @@ -1,5 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import ResizeHandles from './ResizeHandles'; +import { useElementDrag } from './hooks/useElementDrag'; +import { useElementSelection } from './hooks/useElementSelection'; +import { getElementStyle } from './utils/elementUtils'; import './elements.css'; const LINE_CHARS = { @@ -21,76 +24,32 @@ function VerticalLine({ crossroadMap = {}, toolMode }) { - const [isDragging, setIsDragging] = useState(false); - const [dragData, setDragData] = useState(null); - const lineChar = LINE_CHARS[element.lineStyle || 'single']; - useEffect(() => { - const handleKeyPress = (e) => { - if (isSelected && e.key === 'Delete') { - onDelete(); - } - }; - - document.addEventListener('keydown', handleKeyPress); - return () => document.removeEventListener('keydown', handleKeyPress); - }, [isSelected, onDelete]); - - const handleClick = (e) => { - // Only handle selection in select mode, let other modes bubble to canvas - if (toolMode === 'select') { - e.stopPropagation(); - onSelect(e); - } - }; + // Use custom hooks for drag and selection + const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({ + isSelected, + onDragStart, + onDrag, + toolMode + }); + + const { handleClick } = useElementSelection({ + isSelected, + onSelect, + onDelete, + toolMode + }); const handleMouseDown = (e) => { - // Only handle dragging in select mode - if (toolMode !== 'select') return; - - e.stopPropagation(); - onSelect(e); - - setIsDragging(true); - const data = onDragStart(element.id, e.clientX, e.clientY); - setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY }); + onSelect(e); // Select element before dragging + handleDragMouseDown(e, element.id); }; const handleResize = (updates) => { onUpdate(updates); }; - useEffect(() => { - if (!isDragging) return; - - // Add class to body to disable transitions globally during drag - document.body.classList.add('dragging-active'); - - const handleMouseMove = (e) => { - if (dragData) { - const deltaX = e.clientX - dragData.startMouseX; - const deltaY = e.clientY - dragData.startMouseY; - onDrag(dragData, deltaX, deltaY); - } - }; - - const handleMouseUp = () => { - setIsDragging(false); - setDragData(null); - document.body.classList.remove('dragging-active'); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.classList.remove('dragging-active'); - }; - }, [isDragging, dragData, onDrag]); - // Render line vertically with possible crossroad overrides const renderLine = () => { const lines = []; @@ -111,12 +70,8 @@ function VerticalLine({ return lines; }; - const style = { - left: `${element.x * charWidth}px`, - top: `${element.y * charHeight}px`, - width: `${charWidth}px`, - height: `${element.length * charHeight}px` - }; + // Use element class method for style calculation + const style = getElementStyle(element, charWidth, charHeight); return (
+ {children} + {showResizeHandles && ResizeHandles} +
+ ); +} + +export default ElementWrapper; diff --git a/frontend/src/components/ReportEditor/elements.css b/frontend/src/components/ReportEditor/elements.css index 6edb65e..cc03a9e 100644 --- a/frontend/src/components/ReportEditor/elements.css +++ b/frontend/src/components/ReportEditor/elements.css @@ -42,15 +42,6 @@ body.dragging-active .text-field { transition: none; } -.text-field.has-bindings { - background: rgba(102, 182, 234, 0.1); - border-color: rgba(102, 182, 234, 0.4); -} - -.text-field.has-bindings:hover { - border-color: rgba(102, 182, 234, 0.6); -} - .text-field-content { display: block; pointer-events: none; @@ -226,3 +217,123 @@ body.dragging-active .symbol-element { background: #ff6600; transform: scale(1.2); } + +/* DB Text Field elements */ +.db-text-field { + position: absolute; + cursor: pointer; + padding: 0; + border: 2px solid transparent; + box-sizing: content-box; + font-family: 'Courier New', 'Consolas', monospace; + font-size: 32px; + line-height: 32px; + white-space: nowrap; + word-wrap: break-word; + overflow-wrap: break-word; + overflow: hidden; + letter-spacing: -1.2px; + font-variant-ligatures: none; + background: rgba(76, 175, 80, 0.05); + transition: border-color 0.2s, background 0.2s; + will-change: auto; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + transform: translateX(-2px); +} + +/* Disable transitions when any element is being dragged */ +body.dragging-active .db-text-field { + transition: none; +} + +.db-text-field:hover { + border-color: rgba(76, 175, 80, 0.3); +} + +.db-text-field.selected { + border-color: #4caf50; + background: rgba(76, 175, 80, 0.1); +} + +.db-text-field.dragging { + opacity: 0.7; + cursor: grabbing; + transition: none; +} + +.db-text-field.unresolved { + background: rgba(255, 152, 0, 0.1); + border-color: rgba(255, 152, 0, 0.3); +} + +.db-text-field-content { + display: block; + pointer-events: none; + width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; +} + +/* Band elements */ +.band-element { + position: absolute; + cursor: pointer; + box-sizing: border-box; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + transition: background 0.2s; +} + +/* Disable transitions when any element is being dragged */ +body.dragging-active .band-element { + transition: none; +} + +.band-element.selected { + background: rgba(102, 126, 234, 0.1) !important; +} + +.band-element.dragging { + opacity: 0.7; + cursor: grabbing; +} + +.band-caption { + position: absolute; + top: 2px; + left: 4px; + font-family: Arial, sans-serif; + font-size: 10px; + color: #667eea; + font-weight: bold; + pointer-events: none; + z-index: 10; +} + +.band-children { + position: relative; + width: 100%; + height: 100%; + pointer-events: none; +} + +.band-children > * { + pointer-events: auto; +} + +/* Page break indicator for preview mode */ +.page-break-indicator { + position: absolute; + left: 0; + width: 100%; + height: 0; + border-top: 2px dashed #999; + pointer-events: none; + z-index: 500; + opacity: 0.5; +} diff --git a/frontend/src/components/ReportEditor/hooks/useElementDrag.js b/frontend/src/components/ReportEditor/hooks/useElementDrag.js new file mode 100644 index 0000000..cc93634 --- /dev/null +++ b/frontend/src/components/ReportEditor/hooks/useElementDrag.js @@ -0,0 +1,61 @@ +import { useState, useEffect } from 'react'; + +/** + * Custom hook for handling element dragging with grid snapping + * @param {Object} params + * @param {boolean} params.isSelected - Whether element is selected + * @param {Function} params.onDragStart - Callback when drag starts + * @param {Function} params.onDrag - Callback during drag + * @param {string} params.toolMode - Current tool mode + * @returns {Object} Drag state and handlers + */ +export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) { + const [isDragging, setIsDragging] = useState(false); + const [dragData, setDragData] = useState(null); + + const handleMouseDown = (e, elementId) => { + // Only handle dragging in select mode + if (toolMode !== 'select') return; + + e.stopPropagation(); + + setIsDragging(true); + const data = onDragStart(elementId, e.clientX, e.clientY); + setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY }); + }; + + useEffect(() => { + if (!isDragging) return; + + // Add class to body to disable transitions globally during drag + document.body.classList.add('dragging-active'); + + const handleMouseMove = (e) => { + if (dragData) { + const deltaX = e.clientX - dragData.startMouseX; + const deltaY = e.clientY - dragData.startMouseY; + onDrag(dragData, deltaX, deltaY); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setDragData(null); + document.body.classList.remove('dragging-active'); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.classList.remove('dragging-active'); + }; + }, [isDragging, dragData, onDrag]); + + return { + isDragging, + handleMouseDown + }; +} diff --git a/frontend/src/components/ReportEditor/hooks/useElementSelection.js b/frontend/src/components/ReportEditor/hooks/useElementSelection.js new file mode 100644 index 0000000..c1ce23c --- /dev/null +++ b/frontend/src/components/ReportEditor/hooks/useElementSelection.js @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; + +/** + * Custom hook for handling element selection and deletion + * @param {Object} params + * @param {boolean} params.isSelected - Whether element is selected + * @param {Function} params.onSelect - Callback when element is selected + * @param {Function} params.onDelete - Callback when element is deleted + * @param {string} params.toolMode - Current tool mode + * @returns {Object} Selection handlers + */ +export function useElementSelection({ isSelected, onSelect, onDelete, toolMode }) { + // Handle click for selection + const handleClick = (e) => { + // Only handle selection in select mode, let other modes bubble to canvas + if (toolMode === 'select') { + e.stopPropagation(); + onSelect(e); + } + }; + + // Handle delete key press + useEffect(() => { + const handleKeyPress = (e) => { + if (isSelected && e.key === 'Delete') { + onDelete(); + } + }; + + document.addEventListener('keydown', handleKeyPress); + return () => document.removeEventListener('keydown', handleKeyPress); + }, [isSelected, onDelete]); + + return { + handleClick + }; +} diff --git a/frontend/src/components/ReportEditor/models/Element.js b/frontend/src/components/ReportEditor/models/Element.js new file mode 100644 index 0000000..9661345 --- /dev/null +++ b/frontend/src/components/ReportEditor/models/Element.js @@ -0,0 +1,404 @@ +/** + * Base Element class for all report elements + * Provides common properties and methods + */ +export class Element { + constructor({ id, type, x, y }) { + this.id = id || `${type}-${Date.now()}`; + this.type = type; + this.x = x; + this.y = y; + } + + /** + * Calculate pixel position from grid coordinates + */ + getPixelPosition(charWidth, charHeight) { + return { + left: this.x * charWidth, + top: this.y * charHeight + }; + } + + /** + * Update element properties + */ + update(updates) { + Object.assign(this, updates); + return this; + } + + /** + * Check if element intersects with a rectangle + */ + intersectsRect(rect) { + const bounds = this.getBounds(); + return !( + bounds.maxX < rect.minX || + bounds.minX > rect.maxX || + bounds.maxY < rect.minY || + bounds.minY > rect.maxY + ); + } + + /** + * Get element bounds (must be overridden by subclasses) + */ + getBounds() { + return { + minX: this.x, + minY: this.y, + maxX: this.x, + maxY: this.y + }; + } + + /** + * Get element dimensions in grid units (must be overridden by subclasses) + */ + getDimensions() { + return { width: 1, height: 1 }; + } + + /** + * Get style object for rendering + */ + getStyle(charWidth, charHeight) { + const pos = this.getPixelPosition(charWidth, charHeight); + const dims = this.getDimensions(); + return { + left: `${pos.left}px`, + top: `${pos.top}px`, + width: `${dims.width * charWidth}px`, + height: `${dims.height * charHeight}px` + }; + } + + /** + * Serialize to JSON + */ + toJSON() { + return { + id: this.id, + type: this.type, + x: this.x, + y: this.y + }; + } + + /** + * Create element from JSON + */ + static fromJSON(json) { + const elementClasses = { + text: TextElement, + dbtext: DBTextElement, + frame: FrameElement, + hline: HorizontalLineElement, + vline: VerticalLineElement, + symbol: SymbolElement, + band: BandElement + }; + + const ElementClass = elementClasses[json.type]; + if (!ElementClass) { + throw new Error(`Unknown element type: ${json.type}`); + } + + return new ElementClass(json); + } +} + +/** + * Database-bound text field element + */ +export class DBTextElement extends Element { + constructor({ id, x, y, width = 10, height = 1, objectKey = '', fieldPath = '' }) { + super({ id, type: 'dbtext', x, y }); + this.width = width; + this.height = height; + this.objectKey = objectKey; + this.fieldPath = fieldPath; + } + + getBounds() { + return { + minX: this.x, + minY: this.y, + maxX: this.x + this.width - 1, + maxY: this.y + this.height - 1 + }; + } + + getDimensions() { + return { width: this.width, height: this.height }; + } + + isMultiLine() { + return this.height > 1; + } + + getWhiteSpace() { + return this.isMultiLine() ? 'pre-wrap' : 'nowrap'; + } + + toJSON() { + return { + ...super.toJSON(), + width: this.width, + height: this.height, + objectKey: this.objectKey, + fieldPath: this.fieldPath + }; + } +} + +/** + * Text field element + */ +export class TextElement extends Element { + constructor({ id, x, y, content = '', width = 10, height = 1 }) { + super({ id, type: 'text', x, y }); + this.content = content; + this.width = width; + this.height = height; + } + + getBounds() { + return { + minX: this.x, + minY: this.y, + maxX: this.x + this.width - 1, + maxY: this.y + this.height - 1 + }; + } + + getDimensions() { + return { width: this.width, height: this.height }; + } + + isMultiLine() { + return this.height > 1; + } + + getWhiteSpace() { + return this.isMultiLine() ? 'pre-wrap' : 'nowrap'; + } + + toJSON() { + return { + ...super.toJSON(), + content: this.content, + width: this.width, + height: this.height + }; + } +} + +/** + * Frame element + */ +export class FrameElement extends Element { + constructor({ id, x, y, width = 10, height = 5, borderStyle = 'single' }) { + super({ id, type: 'frame', x, y }); + this.width = width; + this.height = height; + this.borderStyle = borderStyle; + } + + getBounds() { + return { + minX: this.x, + minY: this.y, + maxX: this.x + this.width - 1, + maxY: this.y + this.height - 1 + }; + } + + getDimensions() { + return { width: this.width, height: this.height }; + } + + /** + * Check if a position (relative to element) is on the border + */ + isPositionOnBorder(relativeCol, relativeRow) { + return ( + relativeRow === 0 || + relativeRow === this.height - 1 || + relativeCol === 0 || + relativeCol === this.width - 1 + ); + } + + toJSON() { + return { + ...super.toJSON(), + width: this.width, + height: this.height, + borderStyle: this.borderStyle + }; + } +} + +/** + * Horizontal line element + */ +export class HorizontalLineElement extends Element { + constructor({ id, x, y, length = 5, lineStyle = 'single' }) { + super({ id, type: 'hline', x, y }); + this.length = length; + this.lineStyle = lineStyle; + } + + getBounds() { + return { + minX: this.x, + minY: this.y, + maxX: this.x + this.length - 1, + maxY: this.y + }; + } + + getDimensions() { + return { width: this.length, height: 1 }; + } + + toJSON() { + return { + ...super.toJSON(), + length: this.length, + lineStyle: this.lineStyle + }; + } +} + +/** + * Vertical line element + */ +export class VerticalLineElement extends Element { + constructor({ id, x, y, length = 5, lineStyle = 'single' }) { + super({ id, type: 'vline', x, y }); + this.length = length; + this.lineStyle = lineStyle; + } + + getBounds() { + return { + minX: this.x, + minY: this.y, + maxX: this.x, + maxY: this.y + this.length - 1 + }; + } + + getDimensions() { + return { width: 1, height: this.length }; + } + + toJSON() { + return { + ...super.toJSON(), + length: this.length, + lineStyle: this.lineStyle + }; + } +} + +/** + * Symbol element (single character) + */ +export class SymbolElement extends Element { + constructor({ id, x, y, char = '─' }) { + super({ id, type: 'symbol', x, y }); + this.char = char; + } + + getBounds() { + return { + minX: this.x, + minY: this.y, + maxX: this.x, + maxY: this.y + }; + } + + getDimensions() { + return { width: 1, height: 1 }; + } + + toJSON() { + return { + ...super.toJSON(), + char: this.char + }; + } +} + +/** + * Band element (repeating section for data arrays) + */ +export class BandElement extends Element { + constructor({ + id, + y, + height = 3, + bandType = 'detail', + dataSource = '', + caption = '', + children = [], + parentBandId = null + }) { + super({ id, type: 'band', x: 0, y }); + this.width = 80; // Always full width + this.height = height; + this.bandType = bandType; + this.dataSource = dataSource; + this.caption = caption; + this.children = children; // Array of child element IDs + this.parentBandId = parentBandId; // For nested bands + } + + getBounds() { + return { + minX: 0, + minY: this.y, + maxX: 79, + maxY: this.y + this.height - 1 + }; + } + + getDimensions() { + return { width: 80, height: this.height }; + } + + /** + * Check if this band overlaps with another band + */ + overlaps(otherBand) { + if (!otherBand || otherBand.type !== 'band') { + return false; + } + + const thisBounds = this.getBounds(); + const otherBounds = otherBand.getBounds(); + + return !( + thisBounds.maxY < otherBounds.minY || + thisBounds.minY > otherBounds.maxY + ); + } + + toJSON() { + return { + ...super.toJSON(), + width: this.width, + height: this.height, + bandType: this.bandType, + dataSource: this.dataSource, + caption: this.caption, + children: this.children, + parentBandId: this.parentBandId + }; + } +} diff --git a/frontend/src/components/ReportEditor/utils/bandRenderer.js b/frontend/src/components/ReportEditor/utils/bandRenderer.js new file mode 100644 index 0000000..965a5b3 --- /dev/null +++ b/frontend/src/components/ReportEditor/utils/bandRenderer.js @@ -0,0 +1,269 @@ +/** + * Utilities for rendering bands in preview mode + * Handles band iteration, nesting, and layout calculation + */ + +/** + * Resolve nested path in object (e.g., 'contact.phone') + */ +function resolveNestedPath(obj, path) { + if (!path || !obj) return undefined; + + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = current[part]; + } else { + return undefined; + } + } + + return current; +} + +/** + * Get data array for a band + * @param {Object} reportData - The full report data + * @param {string} dataSource - Path to array in reportData (e.g., 'measurements', 'items') + * @param {Object} parentBandData - Data from parent band (for nested bands) + * @returns {Array} - Array of data items for band iteration + */ +export function getBandDataArray(reportData, dataSource, parentBandData = null) { + if (!dataSource) return []; + + // If inside parent band, use parent's current data + if (parentBandData) { + const data = resolveNestedPath(parentBandData, dataSource); + return Array.isArray(data) ? data : []; + } + + // Otherwise, use reportData + if (!reportData) return []; + const data = reportData[dataSource]; + return Array.isArray(data) ? data : []; +} + +/** + * Calculate band instances for preview mode + * Returns array of band instances with positions and data + * @param {Array} bands - Array of band elements + * @param {Object} reportData - The full report data + * @param {Array} allElements - All elements in the report + * @param {Object} parentBandData - Data from parent band (for nested bands) + * @param {number} yOffset - Y offset for nested bands (parent instance's Y position) + * @param {number} parentDesignY - Design Y position of parent band (for relative positioning) + * @returns {Array} - Array of band instances + */ +export function calculateBandInstances(bands, reportData, allElements, parentBandData = null, yOffset = null, parentDesignY = null) { + const instances = []; + + // Get bands at current nesting level + const currentLevelBands = bands.filter(band => + parentBandData ? band.parentBandId === parentBandData.parentBandId : !band.parentBandId + ); + + // Sort bands by Y position + const sortedBands = [...currentLevelBands].sort((a, b) => a.y - b.y); + + // Track where previous band ended in both design and preview mode + let previousBandDesignEnd = null; + let previousBandPreviewEnd = null; + let currentY = 0; + + for (const band of sortedBands) { + // Calculate starting Y for this band + if (previousBandDesignEnd === null) { + // First band at this nesting level + if (yOffset !== null && parentDesignY !== null) { + // Nested band: position relative to parent + const relativeY = band.y - parentDesignY; + currentY = yOffset + relativeY; + } else { + // Top-level band: use design Y + currentY = band.y; + } + } else { + // Subsequent bands: maintain the gap between bands from design mode + const designGap = band.y - previousBandDesignEnd - 1; + // Start this band after the previous band's preview end + the design gap + currentY = previousBandPreviewEnd + 1 + designGap; + } + + const bandStartY = currentY; + const dataArray = getBandDataArray(reportData, band.dataSource, parentBandData); + + // Get child elements for this band + const childElements = allElements.filter(el => band.children && band.children.includes(el.id)); + + // Get nested bands (bands that have this band as parent) + const nestedBands = bands.filter(b => b.parentBandId === band.id); + + if (dataArray.length === 0) { + // No data - render once with null data (for static bands like headers/footers) + const bandInstance = { + bandId: band.id, + instanceIndex: 0, + y: currentY, + height: band.height, + data: null, + childElements: childElements, + originalBandY: band.y, + bandType: band.bandType + }; + + instances.push(bandInstance); + + // Handle nested bands with no data + if (nestedBands.length > 0) { + const nestedInstances = calculateBandInstances( + nestedBands, + reportData, + allElements, + null, // No parent data + currentY, // Parent instance Y + band.y // Parent design Y + ); + instances.push(...nestedInstances); + currentY = Math.max(currentY + band.height, ...nestedInstances.map(ni => ni.y + ni.height)); + } else { + currentY += band.height; + } + } else { + // Render once per data item + dataArray.forEach((item, index) => { + const bandInstance = { + bandId: band.id, + instanceIndex: index, + y: currentY, + height: band.height, + data: item, + childElements: childElements, + originalBandY: band.y, + bandType: band.bandType + }; + + instances.push(bandInstance); + + // Handle nested bands + if (nestedBands.length > 0) { + const nestedInstances = calculateBandInstances( + nestedBands, + reportData, + allElements, + { ...item, parentBandId: band.id }, // Pass current item as parent data + currentY, // Parent instance Y + band.y // Parent design Y + ); + instances.push(...nestedInstances); + // Update currentY to account for nested band height + const nestedHeight = nestedInstances.reduce((max, ni) => Math.max(max, ni.y + ni.height), currentY + band.height); + currentY = nestedHeight; + } else { + currentY += band.height; + } + }); + } + + // Track where this band ended in both design and preview mode + previousBandDesignEnd = band.y + band.height - 1; + previousBandPreviewEnd = currentY - 1; + } + + return instances; +} + +/** + * Calculate total content height for preview mode + * @param {Array} bands - Array of band elements + * @param {Object} reportData - The full report data + * @param {Array} allElements - All elements in the report + * @param {number} minHeight - Minimum height (default canvas height) + * @returns {number} - Total height in grid rows + */ +export function calculateTotalContentHeight(bands, reportData, allElements, minHeight = 66) { + if (bands.length === 0) { + return minHeight; + } + + const instances = calculateBandInstances(bands, reportData, allElements); + + if (instances.length === 0) { + return minHeight; + } + + // Find the maximum Y + height from all instances + const maxY = instances.reduce((max, instance) => { + return Math.max(max, instance.y + instance.height); + }, 0); + + // Also consider non-band elements + const nonBandElements = allElements.filter(el => el.type !== 'band' && !bands.some(b => b.children.includes(el.id))); + const maxElementY = nonBandElements.reduce((max, el) => { + const bounds = el.getBounds ? el.getBounds() : { maxY: el.y }; + return Math.max(max, bounds.maxY + 1); + }, 0); + + return Math.max(minHeight, maxY, maxElementY); +} + +/** + * Get available arrays from reportData (for ObjectInspector dropdown) + * @param {Object} reportData - The full report data + * @param {Object} parentBand - Parent band (for nested bands) + * @returns {Array} - Array of { key, path } objects + */ +export function getAvailableArrays(reportData, parentBand = null) { + const arrays = []; + + // If there's a parent band, get arrays from the parent's data structure + if (parentBand && parentBand.dataSource) { + const parentDataArray = reportData?.[parentBand.dataSource]; + if (Array.isArray(parentDataArray) && parentDataArray.length > 0) { + // Look at first item in parent's array to find nested arrays + const sampleItem = parentDataArray[0]; + findArraysInObject(sampleItem, arrays); + return arrays; + } + return []; // Parent has no data + } + + // No parent band - get top-level arrays only + function findTopLevelArrays(obj) { + if (!obj || typeof obj !== 'object') return; + + for (const key of Object.keys(obj)) { + const value = obj[key]; + if (Array.isArray(value)) { + arrays.push({ key, path: key }); + } + } + } + + findTopLevelArrays(reportData); + return arrays; +} + +/** + * Helper to find arrays within an object (for nested band data sources) + * @param {Object} obj - Object to search + * @param {Array} arrays - Array to populate with results + * @param {string} prefix - Path prefix for nested objects + */ +function findArraysInObject(obj, arrays, prefix = '') { + if (!obj || typeof obj !== 'object') return; + + for (const key of Object.keys(obj)) { + const value = obj[key]; + const path = prefix ? `${prefix}.${key}` : key; + + if (Array.isArray(value)) { + arrays.push({ key, path }); + // Don't recurse into arrays - we only want immediate arrays + } else if (typeof value === 'object' && value !== null) { + // Recurse into nested objects + findArraysInObject(value, arrays, path); + } + } +} diff --git a/frontend/src/components/ReportEditor/utils/dataResolver.js b/frontend/src/components/ReportEditor/utils/dataResolver.js new file mode 100644 index 0000000..3431b08 --- /dev/null +++ b/frontend/src/components/ReportEditor/utils/dataResolver.js @@ -0,0 +1,108 @@ +/** + * Resolve nested path in object (e.g., 'contact.phone') + */ +function resolveNestedPath(obj, path) { + if (!path || !obj) return undefined; + + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current && typeof current === 'object' && part in current) { + current = current[part]; + } else { + return undefined; + } + } + + return current; +} + +/** + * Resolve DBTextField value from reportData + * @param {Object} reportData - The full report data + * @param {string} objectKey - Top-level object key (e.g., 'owner', 'vessel') + * @param {string} fieldPath - Dot-notation path to field (e.g., 'name', 'contact.phone') + * @param {string} parentBandId - ID of parent band (if element is inside a band) + * @param {Object} bandContext - Band context with currentBandData map + * @returns {string} - Resolved value as string + */ +export function resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId = null, bandContext = null) { + // If inside band, use band's current data for automatic binding + if (parentBandId && bandContext && bandContext.currentBandData && bandContext.currentBandData[parentBandId]) { + const bandData = bandContext.currentBandData[parentBandId]; + + // If objectKey is empty, resolve directly from band data + if (!objectKey) { + if (!fieldPath) return ''; + const value = resolveNestedPath(bandData, fieldPath); + return value !== undefined ? String(value) : ''; + } + + // If objectKey is provided, resolve from objectKey in band data + const objectData = bandData[objectKey]; + if (!objectData) return ''; + + if (!fieldPath) { + return typeof objectData === 'object' ? '' : String(objectData); + } + + const value = resolveNestedPath(objectData, fieldPath); + return value !== undefined ? String(value) : ''; + } + + // Normal resolution (not in band or no band context) + if (!reportData || !objectKey) { + return ''; + } + + const objectData = reportData[objectKey]; + if (!objectData) { + return ''; + } + + if (!fieldPath) { + // If no fieldPath, try to display the object itself (if primitive) + return typeof objectData === 'object' ? '' : String(objectData); + } + + const value = resolveNestedPath(objectData, fieldPath); + return value !== undefined ? String(value) : ''; +} + +/** + * Get available object keys from reportData + */ +export function getAvailableObjects(reportData) { + if (!reportData || typeof reportData !== 'object') { + return []; + } + return Object.keys(reportData); +} + +/** + * Get available field paths from an object (recursive) + */ +export function getAvailableFields(obj, prefix = '') { + if (!obj || typeof obj !== 'object') { + return []; + } + + const fields = []; + + for (const key of Object.keys(obj)) { + const fullPath = prefix ? `${prefix}.${key}` : key; + const value = obj[key]; + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + // Nested object - add this key and recurse + fields.push(fullPath); + fields.push(...getAvailableFields(value, fullPath)); + } else { + // Primitive or array - add this key + fields.push(fullPath); + } + } + + return fields; +} diff --git a/frontend/src/components/ReportEditor/utils/elementUtils.js b/frontend/src/components/ReportEditor/utils/elementUtils.js new file mode 100644 index 0000000..c7ec881 --- /dev/null +++ b/frontend/src/components/ReportEditor/utils/elementUtils.js @@ -0,0 +1,68 @@ +import { Element } from '../models/Element'; + +/** + * Create an element instance from plain object data + * This allows us to use class methods on plain state objects + */ +export function createElementInstance(elementData) { + return Element.fromJSON(elementData); +} + +/** + * Get element style using class method + */ +export function getElementStyle(element, charWidth, charHeight) { + const instance = createElementInstance(element); + return instance.getStyle(charWidth, charHeight); +} + +/** + * Get element bounds using class method + */ +export function getElementBounds(element) { + const instance = createElementInstance(element); + return instance.getBounds(); +} + +/** + * Get element dimensions using class method + */ +export function getElementDimensions(element) { + const instance = createElementInstance(element); + return instance.getDimensions(); +} + +/** + * Check if element intersects with rectangle + */ +export function elementIntersectsRect(element, rect) { + const instance = createElementInstance(element); + return instance.intersectsRect(rect); +} + +/** + * Check if text element is multi-line + */ +export function isTextMultiLine(element) { + if (element.type !== 'text') return false; + const instance = createElementInstance(element); + return instance.isMultiLine(); +} + +/** + * Get white-space style for text element + */ +export function getTextWhiteSpace(element) { + if (element.type !== 'text') return 'normal'; + const instance = createElementInstance(element); + return instance.getWhiteSpace(); +} + +/** + * Check if position is on frame border + */ +export function isPositionOnFrameBorder(element, relativeCol, relativeRow) { + if (element.type !== 'frame') return false; + const instance = createElementInstance(element); + return instance.isPositionOnBorder(relativeCol, relativeRow); +}