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

This commit is contained in:
2026-01-26 15:15:32 +02:00
parent fb1d4ae7ad
commit ed35a90cc0
25 changed files with 2416 additions and 469 deletions
@@ -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);
}