report editor has db text and bands (detail, subdetails, summary, header, footer) detail/subdetail work ok.
parent
fb1d4ae7ad
commit
ed35a90cc0
@ -0,0 +1,49 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Context for tracking band iteration state
|
||||
* Used to provide current data item to elements inside bands
|
||||
*/
|
||||
const BandContext = createContext();
|
||||
|
||||
export function BandProvider({ children, bandDataMap = {} }) {
|
||||
// Map of bandId -> current data item
|
||||
// Can be provided externally or managed internally
|
||||
const [internalBandData, setInternalBandData] = useState({});
|
||||
|
||||
// Use external bandDataMap if provided, otherwise use internal state
|
||||
const currentBandData = Object.keys(bandDataMap).length > 0 ? bandDataMap : internalBandData;
|
||||
|
||||
const setBandData = (bandId, data) => {
|
||||
setInternalBandData(prev => ({
|
||||
...prev,
|
||||
[bandId]: data
|
||||
}));
|
||||
};
|
||||
|
||||
const clearBandData = () => {
|
||||
setInternalBandData({});
|
||||
};
|
||||
|
||||
const removeBandData = (bandId) => {
|
||||
setInternalBandData(prev => {
|
||||
const newData = { ...prev };
|
||||
delete newData[bandId];
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BandContext.Provider value={{ currentBandData, setBandData, clearBandData, removeBandData }}>
|
||||
{children}
|
||||
</BandContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBandContext() {
|
||||
const context = useContext(BandContext);
|
||||
if (!context) {
|
||||
throw new Error('useBandContext must be used within a BandProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { useElementDrag } from './hooks/useElementDrag';
|
||||
import { useElementSelection } from './hooks/useElementSelection';
|
||||
import { getElementStyle } from './utils/elementUtils';
|
||||
import './elements.css';
|
||||
|
||||
/**
|
||||
* Band element component
|
||||
* Represents a repeating section that iterates over data arrays
|
||||
*/
|
||||
function BandElement({
|
||||
element,
|
||||
isSelected,
|
||||
isSingleSelection,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
charWidth,
|
||||
charHeight,
|
||||
toolMode,
|
||||
children // Child elements to render inside band (in design mode)
|
||||
}) {
|
||||
// Use custom hooks for drag and selection
|
||||
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
|
||||
isSelected,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
toolMode,
|
||||
constrainX: true // Only vertical dragging
|
||||
});
|
||||
|
||||
const { handleClick } = useElementSelection({
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
toolMode
|
||||
});
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
e.stopPropagation();
|
||||
if (!isSelected) {
|
||||
onSelect();
|
||||
}
|
||||
handleDragMouseDown(e, element.id);
|
||||
};
|
||||
|
||||
// Get caption text
|
||||
const captionText = element.caption ||
|
||||
`${element.bandType}: ${element.dataSource || 'no data'}`;
|
||||
|
||||
const style = {
|
||||
...getElementStyle(element, charWidth, charHeight),
|
||||
borderTop: '2px dashed #667eea',
|
||||
borderBottom: '2px dashed #667eea',
|
||||
borderLeft: 'none',
|
||||
borderRight: 'none',
|
||||
backgroundColor: isSelected
|
||||
? 'rgba(102, 126, 234, 0.08)'
|
||||
: 'rgba(200, 200, 200, 0.03)',
|
||||
boxSizing: 'border-box'
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`band-element ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
|
||||
style={style}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="band-caption">
|
||||
{captionText}
|
||||
</div>
|
||||
{/* Render child elements inside band container in design mode */}
|
||||
<div className="band-children">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BandElement;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for handling element dragging with grid snapping
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.isSelected - Whether element is selected
|
||||
* @param {Function} params.onDragStart - Callback when drag starts
|
||||
* @param {Function} params.onDrag - Callback during drag
|
||||
* @param {string} params.toolMode - Current tool mode
|
||||
* @returns {Object} Drag state and handlers
|
||||
*/
|
||||
export function useElementDrag({ isSelected, onDragStart, onDrag, toolMode }) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragData, setDragData] = useState(null);
|
||||
|
||||
const handleMouseDown = (e, elementId) => {
|
||||
// Only handle dragging in select mode
|
||||
if (toolMode !== 'select') return;
|
||||
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(true);
|
||||
const data = onDragStart(elementId, e.clientX, e.clientY);
|
||||
setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
// Add class to body to disable transitions globally during drag
|
||||
document.body.classList.add('dragging-active');
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (dragData) {
|
||||
const deltaX = e.clientX - dragData.startMouseX;
|
||||
const deltaY = e.clientY - dragData.startMouseY;
|
||||
onDrag(dragData, deltaX, deltaY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
setDragData(null);
|
||||
document.body.classList.remove('dragging-active');
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.classList.remove('dragging-active');
|
||||
};
|
||||
}, [isDragging, dragData, onDrag]);
|
||||
|
||||
return {
|
||||
isDragging,
|
||||
handleMouseDown
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Custom hook for handling element selection and deletion
|
||||
* @param {Object} params
|
||||
* @param {boolean} params.isSelected - Whether element is selected
|
||||
* @param {Function} params.onSelect - Callback when element is selected
|
||||
* @param {Function} params.onDelete - Callback when element is deleted
|
||||
* @param {string} params.toolMode - Current tool mode
|
||||
* @returns {Object} Selection handlers
|
||||
*/
|
||||
export function useElementSelection({ isSelected, onSelect, onDelete, toolMode }) {
|
||||
// Handle click for selection
|
||||
const handleClick = (e) => {
|
||||
// Only handle selection in select mode, let other modes bubble to canvas
|
||||
if (toolMode === 'select') {
|
||||
e.stopPropagation();
|
||||
onSelect(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete key press
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e) => {
|
||||
if (isSelected && e.key === 'Delete') {
|
||||
onDelete();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
return () => document.removeEventListener('keydown', handleKeyPress);
|
||||
}, [isSelected, onDelete]);
|
||||
|
||||
return {
|
||||
handleClick
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Base Element class for all report elements
|
||||
* Provides common properties and methods
|
||||
*/
|
||||
export class Element {
|
||||
constructor({ id, type, x, y }) {
|
||||
this.id = id || `${type}-${Date.now()}`;
|
||||
this.type = type;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate pixel position from grid coordinates
|
||||
*/
|
||||
getPixelPosition(charWidth, charHeight) {
|
||||
return {
|
||||
left: this.x * charWidth,
|
||||
top: this.y * charHeight
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update element properties
|
||||
*/
|
||||
update(updates) {
|
||||
Object.assign(this, updates);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element intersects with a rectangle
|
||||
*/
|
||||
intersectsRect(rect) {
|
||||
const bounds = this.getBounds();
|
||||
return !(
|
||||
bounds.maxX < rect.minX ||
|
||||
bounds.minX > rect.maxX ||
|
||||
bounds.maxY < rect.minY ||
|
||||
bounds.minY > rect.maxY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element bounds (must be overridden by subclasses)
|
||||
*/
|
||||
getBounds() {
|
||||
return {
|
||||
minX: this.x,
|
||||
minY: this.y,
|
||||
maxX: this.x,
|
||||
maxY: this.y
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element dimensions in grid units (must be overridden by subclasses)
|
||||
*/
|
||||
getDimensions() {
|
||||
return { width: 1, height: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get style object for rendering
|
||||
*/
|
||||
getStyle(charWidth, charHeight) {
|
||||
const pos = this.getPixelPosition(charWidth, charHeight);
|
||||
const dims = this.getDimensions();
|
||||
return {
|
||||
left: `${pos.left}px`,
|
||||
top: `${pos.top}px`,
|
||||
width: `${dims.width * charWidth}px`,
|
||||
height: `${dims.height * charHeight}px`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to JSON
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
x: this.x,
|
||||
y: this.y
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create element from JSON
|
||||
*/
|
||||
static fromJSON(json) {
|
||||
const elementClasses = {
|
||||
text: TextElement,
|
||||
dbtext: DBTextElement,
|
||||
frame: FrameElement,
|
||||
hline: HorizontalLineElement,
|
||||
vline: VerticalLineElement,
|
||||
symbol: SymbolElement,
|
||||
band: BandElement
|
||||
};
|
||||
|
||||
const ElementClass = elementClasses[json.type];
|
||||
if (!ElementClass) {
|
||||
throw new Error(`Unknown element type: ${json.type}`);
|
||||
}
|
||||
|
||||
return new ElementClass(json);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Database-bound text field element
|
||||
*/
|
||||
export class DBTextElement extends Element {
|
||||
constructor({ id, x, y, width = 10, height = 1, objectKey = '', fieldPath = '' }) {
|
||||
super({ id, type: 'dbtext', x, y });
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.objectKey = objectKey;
|
||||
this.fieldPath = fieldPath;
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return {
|
||||
minX: this.x,
|
||||
minY: this.y,
|
||||
maxX: this.x + this.width - 1,
|
||||
maxY: this.y + this.height - 1
|
||||
};
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return { width: this.width, height: this.height };
|
||||
}
|
||||
|
||||
isMultiLine() {
|
||||
return this.height > 1;
|
||||
}
|
||||
|
||||
getWhiteSpace() {
|
||||
return this.isMultiLine() ? 'pre-wrap' : 'nowrap';
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
objectKey: this.objectKey,
|
||||
fieldPath: this.fieldPath
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Text field element
|
||||
*/
|
||||
export class TextElement extends Element {
|
||||
constructor({ id, x, y, content = '', width = 10, height = 1 }) {
|
||||
super({ id, type: 'text', x, y });
|
||||
this.content = content;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return {
|
||||
minX: this.x,
|
||||
minY: this.y,
|
||||
maxX: this.x + this.width - 1,
|
||||
maxY: this.y + this.height - 1
|
||||
};
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return { width: this.width, height: this.height };
|
||||
}
|
||||
|
||||
isMultiLine() {
|
||||
return this.height > 1;
|
||||
}
|
||||
|
||||
getWhiteSpace() {
|
||||
return this.isMultiLine() ? 'pre-wrap' : 'nowrap';
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
content: this.content,
|
||||
width: this.width,
|
||||
height: this.height
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame element
|
||||
*/
|
||||
export class FrameElement extends Element {
|
||||
constructor({ id, x, y, width = 10, height = 5, borderStyle = 'single' }) {
|
||||
super({ id, type: 'frame', x, y });
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.borderStyle = borderStyle;
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return {
|
||||
minX: this.x,
|
||||
minY: this.y,
|
||||
maxX: this.x + this.width - 1,
|
||||
maxY: this.y + this.height - 1
|
||||
};
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return { width: this.width, height: this.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position (relative to element) is on the border
|
||||
*/
|
||||
isPositionOnBorder(relativeCol, relativeRow) {
|
||||
return (
|
||||
relativeRow === 0 ||
|
||||
relativeRow === this.height - 1 ||
|
||||
relativeCol === 0 ||
|
||||
relativeCol === this.width - 1
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
borderStyle: this.borderStyle
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal line element
|
||||
*/
|
||||
export class HorizontalLineElement extends Element {
|
||||
constructor({ id, x, y, length = 5, lineStyle = 'single' }) {
|
||||
super({ id, type: 'hline', x, y });
|
||||
this.length = length;
|
||||
this.lineStyle = lineStyle;
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return {
|
||||
minX: this.x,
|
||||
minY: this.y,
|
||||
maxX: this.x + this.length - 1,
|
||||
maxY: this.y
|
||||
};
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return { width: this.length, height: 1 };
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
length: this.length,
|
||||
lineStyle: this.lineStyle
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical line element
|
||||
*/
|
||||
export class VerticalLineElement extends Element {
|
||||
constructor({ id, x, y, length = 5, lineStyle = 'single' }) {
|
||||
super({ id, type: 'vline', x, y });
|
||||
this.length = length;
|
||||
this.lineStyle = lineStyle;
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return {
|
||||
minX: this.x,
|
||||
minY: this.y,
|
||||
maxX: this.x,
|
||||
maxY: this.y + this.length - 1
|
||||
};
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return { width: 1, height: this.length };
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
length: this.length,
|
||||
lineStyle: this.lineStyle
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol element (single character)
|
||||
*/
|
||||
export class SymbolElement extends Element {
|
||||
constructor({ id, x, y, char = '─' }) {
|
||||
super({ id, type: 'symbol', x, y });
|
||||
this.char = char;
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return {
|
||||
minX: this.x,
|
||||
minY: this.y,
|
||||
maxX: this.x,
|
||||
maxY: this.y
|
||||
};
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return { width: 1, height: 1 };
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
char: this.char
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Band element (repeating section for data arrays)
|
||||
*/
|
||||
export class BandElement extends Element {
|
||||
constructor({
|
||||
id,
|
||||
y,
|
||||
height = 3,
|
||||
bandType = 'detail',
|
||||
dataSource = '',
|
||||
caption = '',
|
||||
children = [],
|
||||
parentBandId = null
|
||||
}) {
|
||||
super({ id, type: 'band', x: 0, y });
|
||||
this.width = 80; // Always full width
|
||||
this.height = height;
|
||||
this.bandType = bandType;
|
||||
this.dataSource = dataSource;
|
||||
this.caption = caption;
|
||||
this.children = children; // Array of child element IDs
|
||||
this.parentBandId = parentBandId; // For nested bands
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return {
|
||||
minX: 0,
|
||||
minY: this.y,
|
||||
maxX: 79,
|
||||
maxY: this.y + this.height - 1
|
||||
};
|
||||
}
|
||||
|
||||
getDimensions() {
|
||||
return { width: 80, height: this.height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this band overlaps with another band
|
||||
*/
|
||||
overlaps(otherBand) {
|
||||
if (!otherBand || otherBand.type !== 'band') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const thisBounds = this.getBounds();
|
||||
const otherBounds = otherBand.getBounds();
|
||||
|
||||
return !(
|
||||
thisBounds.maxY < otherBounds.minY ||
|
||||
thisBounds.minY > otherBounds.maxY
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
bandType: this.bandType,
|
||||
dataSource: this.dataSource,
|
||||
caption: this.caption,
|
||||
children: this.children,
|
||||
parentBandId: this.parentBandId
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Utilities for rendering bands in preview mode
|
||||
* Handles band iteration, nesting, and layout calculation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve nested path in object (e.g., 'contact.phone')
|
||||
*/
|
||||
function resolveNestedPath(obj, path) {
|
||||
if (!path || !obj) return undefined;
|
||||
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object' && part in current) {
|
||||
current = current[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data array for a band
|
||||
* @param {Object} reportData - The full report data
|
||||
* @param {string} dataSource - Path to array in reportData (e.g., 'measurements', 'items')
|
||||
* @param {Object} parentBandData - Data from parent band (for nested bands)
|
||||
* @returns {Array} - Array of data items for band iteration
|
||||
*/
|
||||
export function getBandDataArray(reportData, dataSource, parentBandData = null) {
|
||||
if (!dataSource) return [];
|
||||
|
||||
// If inside parent band, use parent's current data
|
||||
if (parentBandData) {
|
||||
const data = resolveNestedPath(parentBandData, dataSource);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
// Otherwise, use reportData
|
||||
if (!reportData) return [];
|
||||
const data = reportData[dataSource];
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate band instances for preview mode
|
||||
* Returns array of band instances with positions and data
|
||||
* @param {Array} bands - Array of band elements
|
||||
* @param {Object} reportData - The full report data
|
||||
* @param {Array} allElements - All elements in the report
|
||||
* @param {Object} parentBandData - Data from parent band (for nested bands)
|
||||
* @param {number} yOffset - Y offset for nested bands (parent instance's Y position)
|
||||
* @param {number} parentDesignY - Design Y position of parent band (for relative positioning)
|
||||
* @returns {Array} - Array of band instances
|
||||
*/
|
||||
export function calculateBandInstances(bands, reportData, allElements, parentBandData = null, yOffset = null, parentDesignY = null) {
|
||||
const instances = [];
|
||||
|
||||
// Get bands at current nesting level
|
||||
const currentLevelBands = bands.filter(band =>
|
||||
parentBandData ? band.parentBandId === parentBandData.parentBandId : !band.parentBandId
|
||||
);
|
||||
|
||||
// Sort bands by Y position
|
||||
const sortedBands = [...currentLevelBands].sort((a, b) => a.y - b.y);
|
||||
|
||||
// Track where previous band ended in both design and preview mode
|
||||
let previousBandDesignEnd = null;
|
||||
let previousBandPreviewEnd = null;
|
||||
let currentY = 0;
|
||||
|
||||
for (const band of sortedBands) {
|
||||
// Calculate starting Y for this band
|
||||
if (previousBandDesignEnd === null) {
|
||||
// First band at this nesting level
|
||||
if (yOffset !== null && parentDesignY !== null) {
|
||||
// Nested band: position relative to parent
|
||||
const relativeY = band.y - parentDesignY;
|
||||
currentY = yOffset + relativeY;
|
||||
} else {
|
||||
// Top-level band: use design Y
|
||||
currentY = band.y;
|
||||
}
|
||||
} else {
|
||||
// Subsequent bands: maintain the gap between bands from design mode
|
||||
const designGap = band.y - previousBandDesignEnd - 1;
|
||||
// Start this band after the previous band's preview end + the design gap
|
||||
currentY = previousBandPreviewEnd + 1 + designGap;
|
||||
}
|
||||
|
||||
const bandStartY = currentY;
|
||||
const dataArray = getBandDataArray(reportData, band.dataSource, parentBandData);
|
||||
|
||||
// Get child elements for this band
|
||||
const childElements = allElements.filter(el => band.children && band.children.includes(el.id));
|
||||
|
||||
// Get nested bands (bands that have this band as parent)
|
||||
const nestedBands = bands.filter(b => b.parentBandId === band.id);
|
||||
|
||||
if (dataArray.length === 0) {
|
||||
// No data - render once with null data (for static bands like headers/footers)
|
||||
const bandInstance = {
|
||||
bandId: band.id,
|
||||
instanceIndex: 0,
|
||||
y: currentY,
|
||||
height: band.height,
|
||||
data: null,
|
||||
childElements: childElements,
|
||||
originalBandY: band.y,
|
||||
bandType: band.bandType
|
||||
};
|
||||
|
||||
instances.push(bandInstance);
|
||||
|
||||
// Handle nested bands with no data
|
||||
if (nestedBands.length > 0) {
|
||||
const nestedInstances = calculateBandInstances(
|
||||
nestedBands,
|
||||
reportData,
|
||||
allElements,
|
||||
null, // No parent data
|
||||
currentY, // Parent instance Y
|
||||
band.y // Parent design Y
|
||||
);
|
||||
instances.push(...nestedInstances);
|
||||
currentY = Math.max(currentY + band.height, ...nestedInstances.map(ni => ni.y + ni.height));
|
||||
} else {
|
||||
currentY += band.height;
|
||||
}
|
||||
} else {
|
||||
// Render once per data item
|
||||
dataArray.forEach((item, index) => {
|
||||
const bandInstance = {
|
||||
bandId: band.id,
|
||||
instanceIndex: index,
|
||||
y: currentY,
|
||||
height: band.height,
|
||||
data: item,
|
||||
childElements: childElements,
|
||||
originalBandY: band.y,
|
||||
bandType: band.bandType
|
||||
};
|
||||
|
||||
instances.push(bandInstance);
|
||||
|
||||
// Handle nested bands
|
||||
if (nestedBands.length > 0) {
|
||||
const nestedInstances = calculateBandInstances(
|
||||
nestedBands,
|
||||
reportData,
|
||||
allElements,
|
||||
{ ...item, parentBandId: band.id }, // Pass current item as parent data
|
||||
currentY, // Parent instance Y
|
||||
band.y // Parent design Y
|
||||
);
|
||||
instances.push(...nestedInstances);
|
||||
// Update currentY to account for nested band height
|
||||
const nestedHeight = nestedInstances.reduce((max, ni) => Math.max(max, ni.y + ni.height), currentY + band.height);
|
||||
currentY = nestedHeight;
|
||||
} else {
|
||||
currentY += band.height;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track where this band ended in both design and preview mode
|
||||
previousBandDesignEnd = band.y + band.height - 1;
|
||||
previousBandPreviewEnd = currentY - 1;
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total content height for preview mode
|
||||
* @param {Array} bands - Array of band elements
|
||||
* @param {Object} reportData - The full report data
|
||||
* @param {Array} allElements - All elements in the report
|
||||
* @param {number} minHeight - Minimum height (default canvas height)
|
||||
* @returns {number} - Total height in grid rows
|
||||
*/
|
||||
export function calculateTotalContentHeight(bands, reportData, allElements, minHeight = 66) {
|
||||
if (bands.length === 0) {
|
||||
return minHeight;
|
||||
}
|
||||
|
||||
const instances = calculateBandInstances(bands, reportData, allElements);
|
||||
|
||||
if (instances.length === 0) {
|
||||
return minHeight;
|
||||
}
|
||||
|
||||
// Find the maximum Y + height from all instances
|
||||
const maxY = instances.reduce((max, instance) => {
|
||||
return Math.max(max, instance.y + instance.height);
|
||||
}, 0);
|
||||
|
||||
// Also consider non-band elements
|
||||
const nonBandElements = allElements.filter(el => el.type !== 'band' && !bands.some(b => b.children.includes(el.id)));
|
||||
const maxElementY = nonBandElements.reduce((max, el) => {
|
||||
const bounds = el.getBounds ? el.getBounds() : { maxY: el.y };
|
||||
return Math.max(max, bounds.maxY + 1);
|
||||
}, 0);
|
||||
|
||||
return Math.max(minHeight, maxY, maxElementY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available arrays from reportData (for ObjectInspector dropdown)
|
||||
* @param {Object} reportData - The full report data
|
||||
* @param {Object} parentBand - Parent band (for nested bands)
|
||||
* @returns {Array} - Array of { key, path } objects
|
||||
*/
|
||||
export function getAvailableArrays(reportData, parentBand = null) {
|
||||
const arrays = [];
|
||||
|
||||
// If there's a parent band, get arrays from the parent's data structure
|
||||
if (parentBand && parentBand.dataSource) {
|
||||
const parentDataArray = reportData?.[parentBand.dataSource];
|
||||
if (Array.isArray(parentDataArray) && parentDataArray.length > 0) {
|
||||
// Look at first item in parent's array to find nested arrays
|
||||
const sampleItem = parentDataArray[0];
|
||||
findArraysInObject(sampleItem, arrays);
|
||||
return arrays;
|
||||
}
|
||||
return []; // Parent has no data
|
||||
}
|
||||
|
||||
// No parent band - get top-level arrays only
|
||||
function findTopLevelArrays(obj) {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
if (Array.isArray(value)) {
|
||||
arrays.push({ key, path: key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findTopLevelArrays(reportData);
|
||||
return arrays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to find arrays within an object (for nested band data sources)
|
||||
* @param {Object} obj - Object to search
|
||||
* @param {Array} arrays - Array to populate with results
|
||||
* @param {string} prefix - Path prefix for nested objects
|
||||
*/
|
||||
function findArraysInObject(obj, arrays, prefix = '') {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const value = obj[key];
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
arrays.push({ key, path });
|
||||
// Don't recurse into arrays - we only want immediate arrays
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recurse into nested objects
|
||||
findArraysInObject(value, arrays, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Resolve nested path in object (e.g., 'contact.phone')
|
||||
*/
|
||||
function resolveNestedPath(obj, path) {
|
||||
if (!path || !obj) return undefined;
|
||||
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object' && part in current) {
|
||||
current = current[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve DBTextField value from reportData
|
||||
* @param {Object} reportData - The full report data
|
||||
* @param {string} objectKey - Top-level object key (e.g., 'owner', 'vessel')
|
||||
* @param {string} fieldPath - Dot-notation path to field (e.g., 'name', 'contact.phone')
|
||||
* @param {string} parentBandId - ID of parent band (if element is inside a band)
|
||||
* @param {Object} bandContext - Band context with currentBandData map
|
||||
* @returns {string} - Resolved value as string
|
||||
*/
|
||||
export function resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId = null, bandContext = null) {
|
||||
// If inside band, use band's current data for automatic binding
|
||||
if (parentBandId && bandContext && bandContext.currentBandData && bandContext.currentBandData[parentBandId]) {
|
||||
const bandData = bandContext.currentBandData[parentBandId];
|
||||
|
||||
// If objectKey is empty, resolve directly from band data
|
||||
if (!objectKey) {
|
||||
if (!fieldPath) return '';
|
||||
const value = resolveNestedPath(bandData, fieldPath);
|
||||
return value !== undefined ? String(value) : '';
|
||||
}
|
||||
|
||||
// If objectKey is provided, resolve from objectKey in band data
|
||||
const objectData = bandData[objectKey];
|
||||
if (!objectData) return '';
|
||||
|
||||
if (!fieldPath) {
|
||||
return typeof objectData === 'object' ? '' : String(objectData);
|
||||
}
|
||||
|
||||
const value = resolveNestedPath(objectData, fieldPath);
|
||||
return value !== undefined ? String(value) : '';
|
||||
}
|
||||
|
||||
// Normal resolution (not in band or no band context)
|
||||
if (!reportData || !objectKey) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const objectData = reportData[objectKey];
|
||||
if (!objectData) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!fieldPath) {
|
||||
// If no fieldPath, try to display the object itself (if primitive)
|
||||
return typeof objectData === 'object' ? '' : String(objectData);
|
||||
}
|
||||
|
||||
const value = resolveNestedPath(objectData, fieldPath);
|
||||
return value !== undefined ? String(value) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available object keys from reportData
|
||||
*/
|
||||
export function getAvailableObjects(reportData) {
|
||||
if (!reportData || typeof reportData !== 'object') {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(reportData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available field paths from an object (recursive)
|
||||
*/
|
||||
export function getAvailableFields(obj, prefix = '') {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const fields = [];
|
||||
|
||||
for (const key of Object.keys(obj)) {
|
||||
const fullPath = prefix ? `${prefix}.${key}` : key;
|
||||
const value = obj[key];
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// Nested object - add this key and recurse
|
||||
fields.push(fullPath);
|
||||
fields.push(...getAvailableFields(value, fullPath));
|
||||
} else {
|
||||
// Primitive or array - add this key
|
||||
fields.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import { Element } from '../models/Element';
|
||||
|
||||
/**
|
||||
* Create an element instance from plain object data
|
||||
* This allows us to use class methods on plain state objects
|
||||
*/
|
||||
export function createElementInstance(elementData) {
|
||||
return Element.fromJSON(elementData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element style using class method
|
||||
*/
|
||||
export function getElementStyle(element, charWidth, charHeight) {
|
||||
const instance = createElementInstance(element);
|
||||
return instance.getStyle(charWidth, charHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element bounds using class method
|
||||
*/
|
||||
export function getElementBounds(element) {
|
||||
const instance = createElementInstance(element);
|
||||
return instance.getBounds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element dimensions using class method
|
||||
*/
|
||||
export function getElementDimensions(element) {
|
||||
const instance = createElementInstance(element);
|
||||
return instance.getDimensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if element intersects with rectangle
|
||||
*/
|
||||
export function elementIntersectsRect(element, rect) {
|
||||
const instance = createElementInstance(element);
|
||||
return instance.intersectsRect(rect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text element is multi-line
|
||||
*/
|
||||
export function isTextMultiLine(element) {
|
||||
if (element.type !== 'text') return false;
|
||||
const instance = createElementInstance(element);
|
||||
return instance.isMultiLine();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get white-space style for text element
|
||||
*/
|
||||
export function getTextWhiteSpace(element) {
|
||||
if (element.type !== 'text') return 'normal';
|
||||
const instance = createElementInstance(element);
|
||||
return instance.getWhiteSpace();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if position is on frame border
|
||||
*/
|
||||
export function isPositionOnFrameBorder(element, relativeCol, relativeRow) {
|
||||
if (element.type !== 'frame') return false;
|
||||
const instance = createElementInstance(element);
|
||||
return instance.isPositionOnBorder(relativeCol, relativeRow);
|
||||
}
|
||||
Loading…
Reference in New Issue