report editor has db text and bands (detail, subdetails, summary, header, footer) detail/subdetail work ok.

master
kikimor 1 month ago
parent fb1d4ae7ad
commit ed35a90cc0

@ -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 (
<BandContext.Provider value={{ currentBandData, setBandData, clearBandData, removeBandData }}>
{children}
</BandContext.Provider>
);
}
export function useBandContext() {
const context = useContext(BandContext);
if (!context) {
throw new Error('useBandContext must be used within a BandProvider');
}
return context;
}

@ -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 (
<div
className={`band-element ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
<div className="band-caption">
{captionText}
</div>
{/* Render child elements inside band container in design mode */}
<div className="band-children">
{children}
</div>
</div>
);
}
export default BandElement;

@ -86,18 +86,152 @@ function ConfigPanel({ apiEndpoint, onApiEndpointChange, isOpen, onClose }) {
if (!showManualInput && !manualDataInput) { if (!showManualInput && !manualDataInput) {
// Pre-fill with example data when opening for the first time // Pre-fill with example data when opening for the first time
setManualDataInput(JSON.stringify({ setManualDataInput(JSON.stringify({
"reportTitle": "Weight Measurements Report",
"reportDate": "2026-01-26",
"owner": { "owner": {
"name": "John Doe", "name": "John Doe",
"contact": { "contact": {
"phone": "555-1234" "phone": "555-1234",
"email": "john.doe@example.com"
} }
}, },
"vessel": { "vessel": {
"id": "ABC123", "id": "ABC123",
"type": "Truck" "type": "Truck",
"capacity": 5000
}, },
"weight": 1500, "measurements": [
"timestamp": "2026-01-23T10:00:00" {
"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)); }, null, 2));
} }
setShowManualInput(!showManualInput); setShowManualInput(!showManualInput);

@ -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 (
<div
className={`db-text-field ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''} ${isUnresolved ? 'unresolved' : ''}`}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
<span className="db-text-field-content" style={{ whiteSpace }}>
{displayContent || '(No Data)'}
</span>
{isSingleSelection && (
<ResizeHandles
element={element}
onResize={handleResize}
charWidth={charWidth}
charHeight={charHeight}
/>
)}
</div>
);
}
export default DBTextField;

@ -25,6 +25,13 @@
-webkit-user-select: none; -webkit-user-select: none;
-moz-user-select: none; -moz-user-select: none;
-ms-user-select: none; -ms-user-select: none;
overflow-y: auto;
overflow-x: hidden;
}
.editor-canvas-content {
position: relative;
width: 100%;
} }
.editor-canvas.crosshair { .editor-canvas.crosshair {

@ -1,10 +1,16 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect, useMemo } from 'react';
import TextField from './TextField'; import TextField from './TextField';
import DBTextField from './DBTextField';
import FrameElement from './FrameElement'; import FrameElement from './FrameElement';
import HorizontalLine from './HorizontalLine'; import HorizontalLine from './HorizontalLine';
import VerticalLine from './VerticalLine'; import VerticalLine from './VerticalLine';
import SymbolElement from './SymbolElement'; import SymbolElement from './SymbolElement';
import BandElement from './BandElement';
import { useReportData } from './DataContext';
import { BandProvider } from './BandContext';
import { detectCrossroads } from './utils/crossroadDetector'; import { detectCrossroads } from './utils/crossroadDetector';
import { elementIntersectsRect } from './utils/elementUtils';
import { calculateBandInstances, calculateTotalContentHeight } from './utils/bandRenderer';
import './EditorCanvas.css'; import './EditorCanvas.css';
const CHAR_WIDTH = 18; const CHAR_WIDTH = 18;
@ -37,6 +43,19 @@ function EditorCanvas({
const [paintedPositions, setPaintedPositions] = React.useState(new Set()); const [paintedPositions, setPaintedPositions] = React.useState(new Set());
const [didDragSelect, setDidDragSelect] = React.useState(false); 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 snapToGrid = (pixelX, pixelY) => {
const col = Math.max(0, Math.min(GRID_COLS - 1, Math.round(pixelX / CHAR_WIDTH))); 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))); const row = Math.max(0, Math.min(GRID_ROWS - 1, Math.round(pixelY / CHAR_HEIGHT)));
@ -92,6 +111,18 @@ function EditorCanvas({
content: 'Text' content: 'Text'
}; };
onAddElement(newElement); 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') { } else if (toolMode === 'addFrame') {
if (!frameStart) { if (!frameStart) {
// First click - set start position // First click - set start position
@ -166,6 +197,36 @@ function EditorCanvas({
setLineStart(null); setLineStart(null);
setLinePreview(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') { } else if (toolMode === 'select') {
// Click on canvas background - deselect all (but not if we just did a drag selection) // Click on canvas background - deselect all (but not if we just did a drag selection)
if (!didDragSelect) { if (!didDragSelect) {
@ -208,15 +269,10 @@ function EditorCanvas({
const minY = Math.min(rect.startY, rect.endY); const minY = Math.min(rect.startY, rect.endY);
const maxY = Math.max(rect.startY, rect.endY); const maxY = Math.max(rect.startY, rect.endY);
return elements.filter(el => { // Use element class method for intersection checking
const elMinX = el.x; return elements
const elMinY = el.y; .filter(el => elementIntersectsRect(el, { minX, maxX, minY, maxY }))
const elMaxX = el.x + (el.width || el.length || 1) - 1; .map(el => el.id);
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);
}; };
const handleMouseMove = (e) => { const handleMouseMove = (e) => {
@ -347,6 +403,66 @@ function EditorCanvas({
const showCrosshair = toolMode === 'addFrame' || toolMode === 'addHLine' || toolMode === 'addVLine'; const showCrosshair = toolMode === 'addFrame' || toolMode === 'addHLine' || toolMode === 'addVLine';
// Render page break indicators (every 66 rows in preview mode)
const renderPageBreaks = () => {
if (!previewMode) return null;
const breaks = [];
const pageHeight = GRID_ROWS * CHAR_HEIGHT; // 66 rows * 32px = 2112px
const numPages = Math.ceil(contentHeight / pageHeight);
for (let i = 1; i < numPages; i++) {
breaks.push(
<div
key={`page-break-${i}`}
className="page-break-indicator"
style={{ top: `${i * pageHeight}px` }}
/>
);
}
return breaks;
};
// Helper function to render an element (used for standalone and child elements)
const renderElement = (element, parentBandId = null) => {
const commonProps = {
key: element.id,
element,
isSelected: selectedElementIds.includes(element.id),
isSingleSelection: selectedElementIds.length === 1 && selectedElementIds[0] === element.id,
onSelect: (e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey),
onUpdate: (updates) => onElementUpdate(element.id, updates),
onDelete: () => onElementDelete(element.id),
onDragStart: handleElementDragStart,
onDrag: handleElementDrag,
charWidth: CHAR_WIDTH,
charHeight: CHAR_HEIGHT,
toolMode
};
if (element.type === 'text') {
return <TextField {...commonProps} />;
} else if (element.type === 'dbtext') {
return (
<DBTextField
{...commonProps}
previewMode={previewMode}
parentBandId={parentBandId}
/>
);
} else if (element.type === 'frame') {
return <FrameElement {...commonProps} />;
} else if (element.type === 'hline') {
return <HorizontalLine {...commonProps} crossroadMap={crossroadMap} />;
} else if (element.type === 'vline') {
return <VerticalLine {...commonProps} crossroadMap={crossroadMap} />;
} else if (element.type === 'symbol') {
return <SymbolElement {...commonProps} />;
}
return null;
};
return ( return (
<div className="canvas-container"> <div className="canvas-container">
<div <div
@ -356,7 +472,17 @@ function EditorCanvas({
onMouseDown={handleCanvasMouseDown} onMouseDown={handleCanvasMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
> >
{renderGrid()} <div
className="editor-canvas-content"
style={{
minHeight: `${GRID_ROWS * CHAR_HEIGHT}px`,
height: previewMode ? `${contentHeight}px` : `${GRID_ROWS * CHAR_HEIGHT}px`
}}
>
{renderGrid()}
{/* Page break indicators */}
{renderPageBreaks()}
{/* Render selection rectangle */} {/* Render selection rectangle */}
{selectionRect && isSelecting && ( {selectionRect && isSelecting && (
@ -399,95 +525,127 @@ function EditorCanvas({
{/* Render elements */} {/* Render elements */}
{elements.map(element => { {elements.map(element => {
if (element.type === 'text') { // Check if element is a child of a band
return ( const isChildOfBand = elements.some(el => el.type === 'band' && el.children && el.children.includes(element.id));
<TextField
key={element.id} // In design mode, skip child elements (they're rendered inside band containers)
element={element} if (!previewMode) {
isSelected={selectedElementIds.includes(element.id)} if (isChildOfBand && element.type !== 'band') {
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id} return null; // Skip - will be rendered as child of band
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)} }
onUpdate={(updates) => onElementUpdate(element.id, updates)} } else {
onDelete={() => onElementDelete(element.id)} // In preview mode, skip ALL elements - they're rendered in dedicated sections below
onDragStart={handleElementDragStart} // (bands get repeated with data, children render inside bands, standalone elements get shifted)
onDrag={handleElementDrag} return null;
charWidth={CHAR_WIDTH} }
charHeight={CHAR_HEIGHT}
previewMode={previewMode} // Render bands specially in design mode
toolMode={toolMode} if (element.type === 'band') {
/> if (!previewMode) {
); // Design mode: render band as container with children
} else if (element.type === 'frame') { const childElements = elements.filter(el => element.children && element.children.includes(el.id));
return ( return (
<FrameElement <BandElement
key={element.id} key={element.id}
element={element} element={element}
isSelected={selectedElementIds.includes(element.id)} isSelected={selectedElementIds.includes(element.id)}
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id} isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)} onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
onDelete={() => onElementDelete(element.id)} onUpdate={(updates) => onElementUpdate(element.id, updates)}
onUpdate={(updates) => onElementUpdate(element.id, updates)} onDelete={() => onElementDelete(element.id)}
onDragStart={handleElementDragStart} onDragStart={handleElementDragStart}
onDrag={handleElementDrag} onDrag={handleElementDrag}
charWidth={CHAR_WIDTH} charWidth={CHAR_WIDTH}
charHeight={CHAR_HEIGHT} charHeight={CHAR_HEIGHT}
toolMode={toolMode} toolMode={toolMode}
/> >
); {/* Render child elements inside band */}
} else if (element.type === 'hline') { {childElements.map(childEl => renderElement(childEl, element.id))}
return ( </BandElement>
<HorizontalLine );
key={element.id} }
element={element} // Preview mode: skip (bands rendered separately below)
isSelected={selectedElementIds.includes(element.id)} return null;
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
onSelect={(e) => 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 (
<VerticalLine
key={element.id}
element={element}
isSelected={selectedElementIds.includes(element.id)}
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
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 (
<SymbolElement
key={element.id}
element={element}
isSelected={selectedElementIds.includes(element.id)}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
onDelete={() => onElementDelete(element.id)}
onDragStart={handleElementDragStart}
onDrag={handleElementDrag}
charWidth={CHAR_WIDTH}
charHeight={CHAR_HEIGHT}
toolMode={toolMode}
/>
);
} }
return null;
// Render other element types
return renderElement(element);
})} })}
{/* Preview mode: Render band instances with repeated data */}
{previewMode && (() => {
const bands = elements.filter(el => el.type === 'band');
const bandInstances = calculateBandInstances(bands, reportData, elements);
return bandInstances.map(instance => {
// Build band data map for this instance
const bandDataMap = instance.data ? { [instance.bandId]: instance.data } : {};
return (
<BandProvider key={`${instance.bandId}-instance-${instance.instanceIndex}`} bandDataMap={bandDataMap}>
{/* Render child elements at adjusted positions */}
{instance.childElements.map(childEl => {
// Child element Y is relative to band, so add instance Y to get absolute position
const adjustedElement = {
...childEl,
y: instance.y + childEl.y
};
return renderElement(adjustedElement, instance.bandId);
})}
</BandProvider>
);
});
})()}
{/* Preview mode: Render standalone elements that aren't in bands */}
{previewMode && (() => {
const bands = elements.filter(el => el.type === 'band');
const bandInstances = calculateBandInstances(bands, reportData, elements);
// Build a map of band Y ranges in design mode vs preview mode
// IMPORTANT: Only consider top-level bands (no parent) because nested band expansion
// is already included in their parent's expansion
const bandShifts = new Map();
bands.filter(band => !band.parentBandId).forEach(band => {
const bandDesignMaxY = band.y + band.height - 1;
// Find all instances of this band
const instances = bandInstances.filter(inst => inst.bandId === band.id);
if (instances.length > 0) {
const lastInstance = instances[instances.length - 1];
const bandPreviewMaxY = lastInstance.y + lastInstance.height - 1;
// Calculate how much this band expanded
const expansion = bandPreviewMaxY - bandDesignMaxY;
bandShifts.set(band.id, { designMaxY: bandDesignMaxY, expansion });
}
});
return elements.filter(el => {
if (el.type === 'band') return false; // Skip bands themselves
// Check if this element is a child of any band
const isChildOfBand = elements.some(band => band.type === 'band' && band.children && band.children.includes(el.id));
return !isChildOfBand; // Only render if NOT a child of a band
}).map(element => {
// Calculate cumulative shift for elements below bands
let totalShift = 0;
bandShifts.forEach(({ designMaxY, expansion }) => {
if (element.y > designMaxY) {
totalShift += expansion;
}
});
// Render with adjusted Y position
const adjustedElement = {
...element,
y: element.y + totalShift
};
return renderElement(adjustedElement);
});
})()}
</div>
</div> </div>
</div> </div>
); );

@ -1,5 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import ResizeHandles from './ResizeHandles'; import ResizeHandles from './ResizeHandles';
import { useElementDrag } from './hooks/useElementDrag';
import { useElementSelection } from './hooks/useElementSelection';
import { getElementStyle, isPositionOnFrameBorder } from './utils/elementUtils';
import './elements.css'; import './elements.css';
const BORDER_CHARS = { const BORDER_CHARS = {
@ -23,20 +26,22 @@ const BORDER_CHARS = {
function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDelete, onUpdate, onDragStart, onDrag, charWidth, charHeight, toolMode }) { function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDelete, onUpdate, onDragStart, onDrag, charWidth, charHeight, toolMode }) {
const chars = BORDER_CHARS[element.borderStyle || 'single']; const chars = BORDER_CHARS[element.borderStyle || 'single'];
const [isDragging, setIsDragging] = useState(false);
const [dragData, setDragData] = useState(null);
const [isHoveringBorder, setIsHoveringBorder] = useState(false); const [isHoveringBorder, setIsHoveringBorder] = useState(false);
useEffect(() => { // Use custom hooks for drag and selection
const handleKeyPress = (e) => { const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
if (isSelected && e.key === 'Delete') { isSelected,
onDelete(); onDragStart,
} onDrag,
}; toolMode
});
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress); useElementSelection({
}, [isSelected, onDelete]); isSelected,
onSelect,
onDelete,
toolMode
});
const renderFrame = () => { const renderFrame = () => {
const lines = []; const lines = [];
@ -70,11 +75,10 @@ function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDele
return lines; return lines;
}; };
// Use element class method for style calculation
const baseStyle = getElementStyle(element, charWidth, charHeight);
const style = { const style = {
left: `${element.x * charWidth}px`, ...baseStyle,
top: `${element.y * charHeight}px`,
width: `${element.width * charWidth}px`,
height: `${element.height * charHeight}px`,
cursor: (toolMode === 'select' && isHoveringBorder) ? 'pointer' : 'default' cursor: (toolMode === 'select' && isHoveringBorder) ? 'pointer' : 'default'
}; };
@ -88,9 +92,8 @@ function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDele
const col = Math.floor(relativeX / charWidth); const col = Math.floor(relativeX / charWidth);
const row = Math.floor(relativeY / charHeight); const row = Math.floor(relativeY / charHeight);
// Check if on border (first/last row or first/last column) // Use element class method to check if position is on border
const onBorder = row === 0 || row === element.height - 1 || col === 0 || col === element.width - 1; return isPositionOnFrameBorder(element, col, row);
return onBorder;
}; };
const handleClick = (e) => { const handleClick = (e) => {
@ -113,48 +116,14 @@ function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDele
// Only handle dragging in select mode and on border clicks // Only handle dragging in select mode and on border clicks
if (toolMode !== 'select' || !isClickOnBorder(e)) return; if (toolMode !== 'select' || !isClickOnBorder(e)) return;
e.stopPropagation(); onSelect(e); // Select element before dragging
onSelect(e); handleDragMouseDown(e, element.id);
setIsDragging(true);
const data = onDragStart(element.id, e.clientX, e.clientY);
setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
}; };
const handleResize = (updates) => { const handleResize = (updates) => {
onUpdate(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 ( return (
<div <div
className={`frame-element ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`} className={`frame-element ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}

@ -1,5 +1,8 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import ResizeHandles from './ResizeHandles'; import ResizeHandles from './ResizeHandles';
import { useElementDrag } from './hooks/useElementDrag';
import { useElementSelection } from './hooks/useElementSelection';
import { getElementStyle } from './utils/elementUtils';
import './elements.css'; import './elements.css';
const LINE_CHARS = { const LINE_CHARS = {
@ -21,76 +24,32 @@ function HorizontalLine({
crossroadMap = {}, crossroadMap = {},
toolMode toolMode
}) { }) {
const [isDragging, setIsDragging] = useState(false);
const [dragData, setDragData] = useState(null);
const lineChar = LINE_CHARS[element.lineStyle || 'single']; const lineChar = LINE_CHARS[element.lineStyle || 'single'];
useEffect(() => { // Use custom hooks for drag and selection
const handleKeyPress = (e) => { const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
if (isSelected && e.key === 'Delete') { isSelected,
onDelete(); onDragStart,
} onDrag,
}; toolMode
});
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress); const { handleClick } = useElementSelection({
}, [isSelected, onDelete]); isSelected,
onSelect,
const handleClick = (e) => { onDelete,
// Only handle selection in select mode, let other modes bubble to canvas toolMode
if (toolMode === 'select') { });
e.stopPropagation();
onSelect(e);
}
};
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
// Only handle dragging in select mode onSelect(e); // Select element before dragging
if (toolMode !== 'select') return; handleDragMouseDown(e, element.id);
e.stopPropagation();
onSelect(e);
setIsDragging(true);
const data = onDragStart(element.id, e.clientX, e.clientY);
setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
}; };
const handleResize = (updates) => { const handleResize = (updates) => {
onUpdate(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 // Render line with possible crossroad overrides
const renderLine = () => { const renderLine = () => {
let line = ''; let line = '';
@ -109,12 +68,8 @@ function HorizontalLine({
return line; return line;
}; };
const style = { // Use element class method for style calculation
left: `${element.x * charWidth}px`, const style = getElementStyle(element, charWidth, charHeight);
top: `${element.y * charHeight}px`,
width: `${element.length * charWidth}px`,
height: `${charHeight}px`
};
return ( return (
<div <div

@ -0,0 +1,75 @@
.object-inspector {
position: fixed;
left: 0;
top: 0;
width: 250px;
height: 100vh;
background: #f5f5f5;
border-right: 1px solid #ddd;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
overflow-y: auto;
font-family: Arial, sans-serif;
font-size: 13px;
z-index: 1000;
}
.inspector-header {
background: #e0e0e0;
padding: 10px;
font-weight: bold;
border-bottom: 1px solid #ccc;
}
.inspector-empty {
padding: 20px;
color: #999;
text-align: center;
}
.inspector-section {
border-bottom: 1px solid #ddd;
padding: 10px;
}
.section-title {
font-weight: bold;
margin-bottom: 10px;
color: #555;
}
.property-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.property-row label {
flex: 0 0 70px;
color: #666;
font-size: 12px;
}
.property-row input,
.property-row select {
flex: 1;
padding: 4px 6px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 12px;
}
.property-row input:disabled {
background: #e0e0e0;
color: #999;
}
.property-row select:disabled {
background: #e0e0e0;
color: #999;
}
.property-row input:focus,
.property-row select:focus {
outline: none;
border-color: #667eea;
}

@ -0,0 +1,341 @@
import React from 'react';
import { useReportData } from './DataContext';
import { getAvailableObjects, getAvailableFields } from './utils/dataResolver';
import { getAvailableArrays } from './utils/bandRenderer';
import './ObjectInspector.css';
function ObjectInspector({ element, onUpdate, allElements = [] }) {
const { reportData } = useReportData();
if (!element) {
return (
<div className="object-inspector">
<div className="inspector-header">Object Inspector</div>
<div className="inspector-empty">No element selected</div>
</div>
);
}
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 (
<div className="object-inspector">
<div className="inspector-header">Object Inspector</div>
<div className="inspector-section">
<div className="section-title">General</div>
<div className="property-row">
<label>Type:</label>
<input type="text" value={element.type} disabled />
</div>
<div className="property-row">
<label>X:</label>
<input
type="number"
value={element.x}
onChange={(e) => handleNumberChange('x', e.target.value)}
/>
</div>
<div className="property-row">
<label>Y:</label>
<input
type="number"
value={element.y}
onChange={(e) => handleNumberChange('y', e.target.value)}
/>
</div>
{(element.width !== undefined) && (
<div className="property-row">
<label>Width:</label>
<input
type="number"
value={element.width}
onChange={(e) => handleNumberChange('width', e.target.value)}
/>
</div>
)}
{(element.height !== undefined) && (
<div className="property-row">
<label>Height:</label>
<input
type="number"
value={element.height}
onChange={(e) => handleNumberChange('height', e.target.value)}
/>
</div>
)}
{element.length !== undefined && (
<div className="property-row">
<label>Length:</label>
<input
type="number"
value={element.length}
onChange={(e) => handleNumberChange('length', e.target.value)}
/>
</div>
)}
</div>
{/* TextField specific */}
{element.type === 'text' && (
<div className="inspector-section">
<div className="section-title">Text</div>
<div className="property-row">
<label>Content:</label>
<input
type="text"
value={element.content || ''}
onChange={(e) => handleChange('content', e.target.value)}
/>
</div>
</div>
)}
{/* DBTextField specific */}
{element.type === 'dbtext' && (
<div className="inspector-section">
<div className="section-title">Data Binding</div>
{parentBand ? (
<>
<div className="property-info" style={{
fontSize: '11px',
color: '#667eea',
marginBottom: '8px',
padding: '6px',
background: 'rgba(102, 126, 234, 0.1)',
borderRadius: '3px'
}}>
{parentBand.parentBandId ? (
<>
Inside nested band: {parentBand.dataSource}
<br />
(within {allElements.find(el => el.id === parentBand.parentBandId)?.dataSource})
</>
) : (
<>Inside band: {parentBand.dataSource}</>
)}
<br />
Fields auto-bind to current row
</div>
<div className="property-row">
<label>Field:</label>
<select
value={element.fieldPath || ''}
onChange={(e) => handleChange('fieldPath', e.target.value)}
>
<option value="">-- Select Field --</option>
{availableFields.map(path => (
<option key={path} value={path}>{path}</option>
))}
</select>
</div>
</>
) : (
<>
<div className="property-row">
<label>Object:</label>
<select
value={element.objectKey || ''}
onChange={(e) => handleChange('objectKey', e.target.value)}
>
<option value="">-- Select Object --</option>
{availableObjects.map(key => (
<option key={key} value={key}>{key}</option>
))}
</select>
</div>
<div className="property-row">
<label>Field:</label>
<select
value={element.fieldPath || ''}
onChange={(e) => handleChange('fieldPath', e.target.value)}
disabled={!element.objectKey}
>
<option value="">-- Select Field --</option>
{availableFields.map(path => (
<option key={path} value={path}>{path}</option>
))}
</select>
</div>
</>
)}
</div>
)}
{/* Frame/Line specific */}
{(element.type === 'frame' || element.type === 'hline' || element.type === 'vline') && (
<div className="inspector-section">
<div className="section-title">Style</div>
<div className="property-row">
<label>Style:</label>
<select
value={element.borderStyle || element.lineStyle || 'single'}
onChange={(e) => {
const prop = element.type === 'frame' ? 'borderStyle' : 'lineStyle';
handleChange(prop, e.target.value);
}}
>
<option value="single">Single</option>
<option value="double">Double</option>
</select>
</div>
</div>
)}
{/* Symbol specific */}
{element.type === 'symbol' && (
<div className="inspector-section">
<div className="section-title">Symbol</div>
<div className="property-row">
<label>Character:</label>
<input
type="text"
maxLength={1}
value={element.char || ''}
onChange={(e) => handleChange('char', e.target.value)}
/>
</div>
</div>
)}
{/* Band specific */}
{element.type === 'band' && (
<div className="inspector-section">
<div className="section-title">Band</div>
<div className="property-row">
<label>Type:</label>
<select
value={element.bandType || 'detail'}
onChange={(e) => handleChange('bandType', e.target.value)}
>
<option value="header">Header</option>
<option value="detail">Detail</option>
<option value="subdetail">Sub-Detail</option>
<option value="footer">Footer</option>
<option value="summary">Summary</option>
</select>
</div>
<div className="property-row">
<label>Data Source:</label>
<select
value={element.dataSource || ''}
onChange={(e) => handleChange('dataSource', e.target.value)}
>
<option value="">-- No Data --</option>
{availableArrays.map(arr => (
<option key={arr.path} value={arr.path}>{arr.path}</option>
))}
</select>
</div>
<div className="property-row">
<label>Caption:</label>
<input
type="text"
value={element.caption || ''}
onChange={(e) => handleChange('caption', e.target.value)}
placeholder="Auto-generated"
/>
</div>
<div className="property-row">
<label>Parent Band:</label>
<select
value={element.parentBandId || ''}
onChange={(e) => handleChange('parentBandId', e.target.value || null)}
>
<option value="">-- None (Root) --</option>
{availableBands.map(band => (
<option key={band.id} value={band.id}>
{band.caption || `${band.bandType}: ${band.dataSource}`}
</option>
))}
</select>
</div>
</div>
)}
</div>
);
}
export default ObjectInspector;

@ -4,3 +4,9 @@
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
.editor-layout {
display: flex;
flex: 1;
overflow: hidden;
}

@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { DataProvider } from './DataContext'; import { DataProvider } from './DataContext';
import { BandProvider } from './BandContext';
import Toolbar from './Toolbar'; import Toolbar from './Toolbar';
import EditorCanvas from './EditorCanvas'; import EditorCanvas from './EditorCanvas';
import ConfigPanel from './ConfigPanel'; import ConfigPanel from './ConfigPanel';
import CharacterPalette from './CharacterPalette'; import CharacterPalette from './CharacterPalette';
import ObjectInspector from './ObjectInspector';
import './ReportEditor.css'; import './ReportEditor.css';
function ReportEditorContent() { function ReportEditorContent() {
@ -24,10 +26,44 @@ function ReportEditorContent() {
const [showCharacterPalette, setShowCharacterPalette] = useState(false); const [showCharacterPalette, setShowCharacterPalette] = useState(false);
const handleAddElement = (element) => { const handleAddElement = (element) => {
setReport(prev => ({ setReport(prev => {
...prev, // Check if the new element is positioned inside a band
elements: [...prev.elements, element] 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]); setSelectedElementIds([element.id]);
// After adding an element, switch back to select mode (unless in drawTable mode) // After adding an element, switch back to select mode (unless in drawTable mode)
if (toolMode !== 'drawTable') { if (toolMode !== 'drawTable') {
@ -36,12 +72,40 @@ function ReportEditorContent() {
}; };
const handleElementUpdate = (elementId, updates) => { const handleElementUpdate = (elementId, updates) => {
setReport(prev => ({ setReport(prev => {
...prev, const element = prev.elements.find(el => el.id === elementId);
elements: prev.elements.map(el =>
el.id === elementId ? { ...el, ...updates } : el // 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) => { const handleElementDelete = (elementId) => {
@ -138,6 +202,11 @@ function ReportEditorContent() {
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [showConfigPanel, selectedElementIds, toolMode, report.elements]); }, [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 ( return (
<div className="report-editor"> <div className="report-editor">
<Toolbar <Toolbar
@ -149,20 +218,29 @@ function ReportEditorContent() {
onTogglePreview={handleTogglePreview} onTogglePreview={handleTogglePreview}
onConfigureAPI={() => setShowConfigPanel(true)} onConfigureAPI={() => setShowConfigPanel(true)}
/> />
<EditorCanvas <div className="editor-layout">
elements={report.elements} <EditorCanvas
selectedElementIds={selectedElementIds} elements={report.elements}
onElementSelect={handleSelectElement} selectedElementIds={selectedElementIds}
onSelectMultiple={handleSelectMultiple} onElementSelect={handleSelectElement}
onDeselectAll={handleDeselectAll} onSelectMultiple={handleSelectMultiple}
onElementUpdate={handleElementUpdate} onDeselectAll={handleDeselectAll}
onElementDelete={handleElementDelete} onElementUpdate={handleElementUpdate}
toolMode={toolMode} onElementDelete={handleElementDelete}
borderStyle={borderStyle} toolMode={toolMode}
onAddElement={handleAddElement} borderStyle={borderStyle}
previewMode={previewMode} onAddElement={handleAddElement}
selectedChar={selectedChar} previewMode={previewMode}
/> selectedChar={selectedChar}
/>
</div>
{selectedElementIds.length === 1 && (
<ObjectInspector
element={selectedElement}
allElements={report.elements}
onUpdate={(updates) => handleElementUpdate(selectedElementIds[0], updates)}
/>
)}
<ConfigPanel <ConfigPanel
apiEndpoint={report.apiEndpoint} apiEndpoint={report.apiEndpoint}
onApiEndpointChange={handleApiEndpointChange} onApiEndpointChange={handleApiEndpointChange}
@ -186,7 +264,9 @@ function ReportEditorContent() {
function ReportEditor() { function ReportEditor() {
return ( return (
<DataProvider> <DataProvider>
<ReportEditorContent /> <BandProvider>
<ReportEditorContent />
</BandProvider>
</DataProvider> </DataProvider>
); );
} }

@ -18,7 +18,10 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
case 'vline': case 'vline':
return ['start', 'end']; return ['start', 'end'];
case 'text': case 'text':
case 'dbtext':
return ['nw', 'ne', 'sw', 'se']; return ['nw', 'ne', 'sw', 'se'];
case 'band':
return ['s']; // Only bottom handle for height adjustment
default: default:
return []; return [];
} }
@ -42,6 +45,14 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
const contentLength = element.content ? element.content.length : 1; const contentLength = element.content ? element.content.length : 1;
width = (element.width || Math.max(contentLength, 5)) * charWidth; width = (element.width || Math.max(contentLength, 5)) * charWidth;
height = (element.height || 1) * charHeight; 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 { } else {
// For frames and other elements // For frames and other elements
width = (element.width || 1) * charWidth; width = (element.width || 1) * charWidth;
@ -179,7 +190,7 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
// Moving end point up/down // Moving end point up/down
updates.length = Math.max(MIN_LINE_LENGTH, initial.length + deltaRow); 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; const initial = dragData.initialElement;
switch (dragData.handle) { switch (dragData.handle) {
@ -208,6 +219,14 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
updates.height = Math.max(MIN_TEXT_SIZE.height, initial.height + deltaRow); updates.height = Math.max(MIN_TEXT_SIZE.height, initial.height + deltaRow);
break; 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) { if (Object.keys(updates).length > 0) {

@ -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'; import './elements.css';
function SymbolElement({ function SymbolElement({
@ -12,76 +15,28 @@ function SymbolElement({
charHeight, charHeight,
toolMode toolMode
}) { }) {
const [isDragging, setIsDragging] = useState(false); // Use custom hooks for drag and selection
const [dragData, setDragData] = useState(null); const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
useEffect(() => { onDragStart,
const handleKeyPress = (e) => { onDrag,
if (isSelected && e.key === 'Delete') { toolMode
onDelete(); });
}
}; const { handleClick } = useElementSelection({
isSelected,
document.addEventListener('keydown', handleKeyPress); onSelect,
return () => document.removeEventListener('keydown', handleKeyPress); onDelete,
}, [isSelected, onDelete]); toolMode
});
const handleClick = (e) => {
// Only handle selection in select mode, let other modes bubble to canvas
if (toolMode === 'select') {
e.stopPropagation();
onSelect(e);
}
};
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
// Only handle dragging in select mode onSelect(e); // Select element before dragging
if (toolMode !== 'select') return; handleDragMouseDown(e, element.id);
e.stopPropagation();
onSelect(e);
setIsDragging(true);
const data = onDragStart(element.id, e.clientX, e.clientY);
setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
}; };
useEffect(() => { // Use element class method for style calculation
if (!isDragging) return; const style = getElementStyle(element, charWidth, charHeight);
// 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`
};
return ( return (
<div <div

@ -1,7 +1,8 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useReportData } from './DataContext';
import { parseBindings, hasBindings } from './utils/bindingParser';
import ResizeHandles from './ResizeHandles'; import ResizeHandles from './ResizeHandles';
import { useElementDrag } from './hooks/useElementDrag';
import { useElementSelection } from './hooks/useElementSelection';
import { getElementStyle, isTextMultiLine, getTextWhiteSpace } from './utils/elementUtils';
import './elements.css'; import './elements.css';
function TextField({ function TextField({
@ -15,16 +16,29 @@ function TextField({
onDrag, onDrag,
charWidth, charWidth,
charHeight, charHeight,
previewMode = false,
toolMode toolMode
}) { }) {
const { reportData } = useReportData();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(element.content); const [editContent, setEditContent] = useState(element.content);
const [isDragging, setIsDragging] = useState(false);
const [dragData, setDragData] = useState(null);
const inputRef = useRef(null); const inputRef = useRef(null);
// Use custom hooks for drag and selection
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete: () => {
if (!isEditing) onDelete();
},
toolMode
});
useEffect(() => { useEffect(() => {
if (isEditing && inputRef.current) { if (isEditing && inputRef.current) {
inputRef.current.focus(); inputRef.current.focus();
@ -32,14 +46,6 @@ function TextField({
} }
}, [isEditing]); }, [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) => { const handleDoubleClick = (e) => {
// Only allow editing in select mode // Only allow editing in select mode
if (toolMode === 'select') { if (toolMode === 'select') {
@ -72,92 +78,37 @@ function TextField({
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
if (isEditing) return; if (isEditing) return;
onSelect(); // Select element before dragging
// Only handle dragging in select mode handleDragMouseDown(e, element.id);
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 });
}; };
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(() => { useEffect(() => {
if (!isSelected) { if (!isSelected) {
setIsEditing(false); setIsEditing(false);
} }
}, [isSelected]); }, [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) => { const handleResize = (updates) => {
onUpdate(updates); onUpdate(updates);
}; };
// Resolve bindings if in preview mode // Display content directly (no binding resolution)
const displayContent = previewMode const displayContent = element.content;
? parseBindings(element.content, reportData)
: element.content;
// Check if field has bindings for styling
const fieldHasBindings = hasBindings(element.content);
const isMultiLine = element.height && element.height > 1; // Use element class methods
const multiLine = isTextMultiLine(element);
const whiteSpace = getTextWhiteSpace(element);
const style = { const style = {
left: `${element.x * charWidth}px`, ...getElementStyle(element, charWidth, charHeight),
top: `${element.y * charHeight}px`,
width: element.width ? `${element.width * charWidth}px` : 'auto',
height: element.height ? `${element.height * charHeight}px` : 'auto',
minWidth: `${charWidth}px`, minWidth: `${charWidth}px`,
minHeight: `${charHeight}px`, minHeight: `${charHeight}px`,
whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap' whiteSpace
}; };
return ( return (
<div <div
className={`text-field ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''} ${fieldHasBindings && !previewMode ? 'has-bindings' : ''}`} className={`text-field ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
style={style} style={style}
onClick={handleClick} onClick={handleClick}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
@ -177,7 +128,7 @@ function TextField({
) : ( ) : (
<span <span
className="text-field-content" className="text-field-content"
style={{ whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap' }} style={{ whiteSpace }}
> >
{displayContent} {displayContent}
</span> </span>

@ -27,6 +27,13 @@ function Toolbar({
> >
T Add Text T Add Text
</button> </button>
<button
className={`toolbar-button ${toolMode === 'addDBText' ? 'active' : ''}`}
onClick={() => onToolChange('addDBText')}
title="Add Database Text Field"
>
DB DB Text
</button>
<button <button
className={`toolbar-button ${toolMode === 'addFrame' ? 'active' : ''}`} className={`toolbar-button ${toolMode === 'addFrame' ? 'active' : ''}`}
onClick={() => onToolChange('addFrame')} onClick={() => onToolChange('addFrame')}
@ -55,6 +62,13 @@ function Toolbar({
> >
Draw Draw
</button> </button>
<button
className={`toolbar-button ${toolMode === 'addBand' ? 'active' : ''}`}
onClick={() => onToolChange('addBand')}
title="Add Band (B)"
>
Band
</button>
</div> </div>
<div className="toolbar-separator"></div> <div className="toolbar-separator"></div>
<div className="toolbar-group"> <div className="toolbar-group">

@ -1,5 +1,8 @@
import React, { useState, useEffect } from 'react'; import React from 'react';
import ResizeHandles from './ResizeHandles'; import ResizeHandles from './ResizeHandles';
import { useElementDrag } from './hooks/useElementDrag';
import { useElementSelection } from './hooks/useElementSelection';
import { getElementStyle } from './utils/elementUtils';
import './elements.css'; import './elements.css';
const LINE_CHARS = { const LINE_CHARS = {
@ -21,76 +24,32 @@ function VerticalLine({
crossroadMap = {}, crossroadMap = {},
toolMode toolMode
}) { }) {
const [isDragging, setIsDragging] = useState(false);
const [dragData, setDragData] = useState(null);
const lineChar = LINE_CHARS[element.lineStyle || 'single']; const lineChar = LINE_CHARS[element.lineStyle || 'single'];
useEffect(() => { // Use custom hooks for drag and selection
const handleKeyPress = (e) => { const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
if (isSelected && e.key === 'Delete') { isSelected,
onDelete(); onDragStart,
} onDrag,
}; toolMode
});
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress); const { handleClick } = useElementSelection({
}, [isSelected, onDelete]); isSelected,
onSelect,
const handleClick = (e) => { onDelete,
// Only handle selection in select mode, let other modes bubble to canvas toolMode
if (toolMode === 'select') { });
e.stopPropagation();
onSelect(e);
}
};
const handleMouseDown = (e) => { const handleMouseDown = (e) => {
// Only handle dragging in select mode onSelect(e); // Select element before dragging
if (toolMode !== 'select') return; handleDragMouseDown(e, element.id);
e.stopPropagation();
onSelect(e);
setIsDragging(true);
const data = onDragStart(element.id, e.clientX, e.clientY);
setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
}; };
const handleResize = (updates) => { const handleResize = (updates) => {
onUpdate(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 // Render line vertically with possible crossroad overrides
const renderLine = () => { const renderLine = () => {
const lines = []; const lines = [];
@ -111,12 +70,8 @@ function VerticalLine({
return lines; return lines;
}; };
const style = { // Use element class method for style calculation
left: `${element.x * charWidth}px`, const style = getElementStyle(element, charWidth, charHeight);
top: `${element.y * charHeight}px`,
width: `${charWidth}px`,
height: `${element.length * charHeight}px`
};
return ( return (
<div <div

@ -0,0 +1,39 @@
import React from 'react';
/**
* Common wrapper component for all element types
* Handles common styling, click handlers, and resize handles
*/
function ElementWrapper({
className,
style,
isSelected,
isDragging,
onClick,
onDoubleClick,
onMouseDown,
children,
showResizeHandles,
ResizeHandles
}) {
const classes = [
className,
isSelected ? 'selected' : '',
isDragging ? 'dragging' : ''
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={style}
onClick={onClick}
onDoubleClick={onDoubleClick}
onMouseDown={onMouseDown}
>
{children}
{showResizeHandles && ResizeHandles}
</div>
);
}
export default ElementWrapper;

@ -42,15 +42,6 @@ body.dragging-active .text-field {
transition: none; 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 { .text-field-content {
display: block; display: block;
pointer-events: none; pointer-events: none;
@ -226,3 +217,123 @@ body.dragging-active .symbol-element {
background: #ff6600; background: #ff6600;
transform: scale(1.2); 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;
}

@ -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
};
}

@ -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
};
}

@ -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
};
}
}

@ -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);
}
}
}

@ -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;
}

@ -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);
}
Loading…
Cancel
Save