report editor has db text and bands (detail, subdetails, summary, header, footer) detail/subdetail work ok.
This commit is contained in:
@@ -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}
|
|
||||||
toolMode={toolMode}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (element.type === 'frame') {
|
|
||||||
return (
|
|
||||||
<FrameElement
|
|
||||||
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}
|
|
||||||
toolMode={toolMode}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (element.type === 'hline') {
|
|
||||||
return (
|
|
||||||
<HorizontalLine
|
|
||||||
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 === '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 bands specially in design mode
|
||||||
|
if (element.type === 'band') {
|
||||||
|
if (!previewMode) {
|
||||||
|
// Design mode: render band as container with children
|
||||||
|
const childElements = elements.filter(el => element.children && element.children.includes(el.id));
|
||||||
|
return (
|
||||||
|
<BandElement
|
||||||
|
key={element.id}
|
||||||
|
element={element}
|
||||||
|
isSelected={selectedElementIds.includes(element.id)}
|
||||||
|
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
|
||||||
|
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
|
||||||
|
onUpdate={(updates) => onElementUpdate(element.id, updates)}
|
||||||
|
onDelete={() => onElementDelete(element.id)}
|
||||||
|
onDragStart={handleElementDragStart}
|
||||||
|
onDrag={handleElementDrag}
|
||||||
|
charWidth={CHAR_WIDTH}
|
||||||
|
charHeight={CHAR_HEIGHT}
|
||||||
|
toolMode={toolMode}
|
||||||
|
>
|
||||||
|
{/* Render child elements inside band */}
|
||||||
|
{childElements.map(childEl => renderElement(childEl, element.id))}
|
||||||
|
</BandElement>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Preview mode: skip (bands rendered separately below)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render other element types
|
||||||
|
return renderElement(element);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Preview mode: Render band instances with repeated data */}
|
||||||
|
{previewMode && (() => {
|
||||||
|
const bands = elements.filter(el => el.type === 'band');
|
||||||
|
const bandInstances = calculateBandInstances(bands, reportData, elements);
|
||||||
|
|
||||||
|
return bandInstances.map(instance => {
|
||||||
|
// Build band data map for this instance
|
||||||
|
const bandDataMap = instance.data ? { [instance.bandId]: instance.data } : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BandProvider key={`${instance.bandId}-instance-${instance.instanceIndex}`} bandDataMap={bandDataMap}>
|
||||||
|
{/* Render child elements at adjusted positions */}
|
||||||
|
{instance.childElements.map(childEl => {
|
||||||
|
// Child element Y is relative to band, so add instance Y to get absolute position
|
||||||
|
const adjustedElement = {
|
||||||
|
...childEl,
|
||||||
|
y: instance.y + childEl.y
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderElement(adjustedElement, instance.bandId);
|
||||||
|
})}
|
||||||
|
</BandProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Preview mode: Render standalone elements that aren't in bands */}
|
||||||
|
{previewMode && (() => {
|
||||||
|
const bands = elements.filter(el => el.type === 'band');
|
||||||
|
const bandInstances = calculateBandInstances(bands, reportData, elements);
|
||||||
|
|
||||||
|
// Build a map of band Y ranges in design mode vs preview mode
|
||||||
|
// IMPORTANT: Only consider top-level bands (no parent) because nested band expansion
|
||||||
|
// is already included in their parent's expansion
|
||||||
|
const bandShifts = new Map();
|
||||||
|
bands.filter(band => !band.parentBandId).forEach(band => {
|
||||||
|
const bandDesignMaxY = band.y + band.height - 1;
|
||||||
|
|
||||||
|
// Find all instances of this band
|
||||||
|
const instances = bandInstances.filter(inst => inst.bandId === band.id);
|
||||||
|
if (instances.length > 0) {
|
||||||
|
const lastInstance = instances[instances.length - 1];
|
||||||
|
const bandPreviewMaxY = lastInstance.y + lastInstance.height - 1;
|
||||||
|
|
||||||
|
// Calculate how much this band expanded
|
||||||
|
const expansion = bandPreviewMaxY - bandDesignMaxY;
|
||||||
|
bandShifts.set(band.id, { designMaxY: bandDesignMaxY, expansion });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return elements.filter(el => {
|
||||||
|
if (el.type === 'band') return false; // Skip bands themselves
|
||||||
|
// Check if this element is a child of any band
|
||||||
|
const isChildOfBand = elements.some(band => band.type === 'band' && band.children && band.children.includes(el.id));
|
||||||
|
return !isChildOfBand; // Only render if NOT a child of a band
|
||||||
|
}).map(element => {
|
||||||
|
// Calculate cumulative shift for elements below bands
|
||||||
|
let totalShift = 0;
|
||||||
|
bandShifts.forEach(({ designMaxY, expansion }) => {
|
||||||
|
if (element.y > designMaxY) {
|
||||||
|
totalShift += expansion;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render with adjusted Y position
|
||||||
|
const adjustedElement = {
|
||||||
|
...element,
|
||||||
|
y: element.y + totalShift
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderElement(adjustedElement);
|
||||||
|
});
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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);
|
useElementSelection({
|
||||||
return () => document.removeEventListener('keydown', handleKeyPress);
|
isSelected,
|
||||||
}, [isSelected, onDelete]);
|
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);
|
const { handleClick } = useElementSelection({
|
||||||
return () => document.removeEventListener('keydown', handleKeyPress);
|
isSelected,
|
||||||
}, [isSelected, onDelete]);
|
onSelect,
|
||||||
|
onDelete,
|
||||||
const handleClick = (e) => {
|
toolMode
|
||||||
// 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 });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
|
onDragStart,
|
||||||
|
onDrag,
|
||||||
|
toolMode
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const { handleClick } = useElementSelection({
|
||||||
const handleKeyPress = (e) => {
|
isSelected,
|
||||||
if (isSelected && e.key === 'Delete') {
|
onSelect,
|
||||||
onDelete();
|
onDelete,
|
||||||
}
|
toolMode
|
||||||
};
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
// Use element class methods
|
||||||
const fieldHasBindings = hasBindings(element.content);
|
const multiLine = isTextMultiLine(element);
|
||||||
|
const whiteSpace = getTextWhiteSpace(element);
|
||||||
const isMultiLine = element.height && element.height > 1;
|
|
||||||
|
|
||||||
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);
|
const { handleClick } = useElementSelection({
|
||||||
return () => document.removeEventListener('keydown', handleKeyPress);
|
isSelected,
|
||||||
}, [isSelected, onDelete]);
|
onSelect,
|
||||||
|
onDelete,
|
||||||
const handleClick = (e) => {
|
toolMode
|
||||||
// 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 });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user