expression field added in document editor. a few plans in plans folder

This commit is contained in:
2026-02-24 11:38:56 +02:00
parent 74663229d8
commit ac6b1f48b4
16 changed files with 1316 additions and 3 deletions
@@ -1,6 +1,7 @@
import React, { useRef, useEffect, useMemo } from 'react';
import TextField from './TextField';
import DBTextField from './DBTextField';
import ExpressionField from './ExpressionField';
import FrameElement from './FrameElement';
import HorizontalLine from './HorizontalLine';
import VerticalLine from './VerticalLine';
@@ -123,6 +124,19 @@ function EditorCanvas({
fieldPath: ''
};
onAddElement(newElement);
} else if (toolMode === 'addExpr') {
const newElement = {
id: `expr-${Date.now()}`,
type: 'expr',
x: col,
y: row,
width: 10,
height: 1,
expression: '',
dataType: 'general',
alignment: 'left'
};
onAddElement(newElement);
} else if (toolMode === 'addFrame') {
if (!frameStart) {
// First click - set start position
@@ -451,6 +465,14 @@ function EditorCanvas({
parentBandId={parentBandId}
/>
);
} else if (element.type === 'expr') {
return (
<ExpressionField
{...commonProps}
previewMode={previewMode}
parentBandId={parentBandId}
/>
);
} else if (element.type === 'frame') {
return <FrameElement {...commonProps} />;
} else if (element.type === 'hline') {
@@ -0,0 +1,116 @@
import React, { useState, useRef } from 'react';
import { getAvailableFields, getAvailableObjects } from './utils/dataResolver';
function ExpressionEditorPanel({ element, onUpdate, reportData, allElements }) {
const [selectedField, setSelectedField] = useState('');
const textareaRef = useRef(null);
const parentBand = allElements.find(el => el.type === 'band' && el.children && el.children.includes(element.id));
let availableFields = [];
if (parentBand && parentBand.dataSource) {
let bandDataArray;
if (parentBand.parentBandId) {
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) {
const nestedData = grandparentData[0][parentBand.dataSource];
if (Array.isArray(nestedData) && nestedData.length > 0) {
bandDataArray = nestedData;
}
}
}
} else {
bandDataArray = reportData?.[parentBand.dataSource];
}
if (Array.isArray(bandDataArray) && bandDataArray.length > 0) {
availableFields = getAvailableFields(bandDataArray[0]);
}
} else {
const objectKeys = getAvailableObjects(reportData);
objectKeys.forEach(objectKey => {
const fields = getAvailableFields(reportData[objectKey]);
fields.forEach(f => availableFields.push(`${objectKey}.${f}`));
});
}
const insertAtCursor = (text) => {
const ta = textareaRef.current;
if (!ta) { onUpdate({ expression: (element.expression || '') + text }); return; }
const start = ta.selectionStart;
const end = ta.selectionEnd;
const current = element.expression || '';
const next = current.slice(0, start) + text + current.slice(end);
onUpdate({ expression: next });
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = start + text.length;
ta.focus();
});
};
return (
<div className="inspector-section">
<div className="section-title">Expression</div>
<textarea
ref={textareaRef}
value={element.expression || ''}
onChange={(e) => onUpdate({ expression: e.target.value })}
rows={3}
style={{ width: '100%', fontFamily: 'monospace', fontSize: '11px', resize: 'vertical', boxSizing: 'border-box' }}
/>
<select value={selectedField} onChange={e => setSelectedField(e.target.value)} style={{ width: '100%', marginBottom: '3px', boxSizing: 'border-box' }}>
<option value="">-- field --</option>
{availableFields.map(f => <option key={f} value={f}>{f}</option>)}
</select>
<div className="expr-btn-row">
<button onClick={() => { if (selectedField) insertAtCursor(`[${selectedField}]`); }}>Insert Field</button>
</div>
<div className="expr-btn-row">
{['+', '-', '*', '/'].map(op => <button key={op} onClick={() => insertAtCursor(` ${op} `)}>{op}</button>)}
<button onClick={() => insertAtCursor('(')}>(</button>
<button onClick={() => insertAtCursor(')')}>)</button>
</div>
<div className="expr-btn-row">
<button onClick={() => insertAtCursor('IF( , , )')}>IF( , , )</button>
</div>
<div className="expr-btn-row">
{['=', '!=', '>', '<', '>=', '<='].map(op => <button key={op} onClick={() => insertAtCursor(` ${op} `)}>{op}</button>)}
</div>
<div className="expr-btn-row">
<button onClick={() => onUpdate({ expression: '' })}>Clear</button>
</div>
<div className="property-row">
<label>Data Type:</label>
<select value={element.dataType || 'general'} onChange={e => onUpdate({ dataType: e.target.value })}>
<option value="general">General</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="time">Time</option>
<option value="datetime">Date &amp; Time</option>
</select>
</div>
<div className="property-row">
<label>Alignment:</label>
<select value={element.alignment || 'left'} onChange={e => onUpdate({ alignment: e.target.value })}>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
</div>
);
}
export default ExpressionEditorPanel;
@@ -0,0 +1,130 @@
import React from 'react';
import { useReportData } from './DataContext';
import { useBandContext } from './BandContext';
import { resolveDBTextValue } from './utils/dataResolver';
import { evaluateExpression } from './utils/expressionEvaluator';
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 formatValue(raw, dataType) {
if (raw === '' || raw === null || raw === undefined) return '';
switch (dataType) {
case 'number': {
const n = Number(raw);
return isNaN(n) ? '' : String(n);
}
case 'date': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString();
}
case 'time': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
case 'datetime': {
const d = new Date(raw);
return isNaN(d.getTime()) ? '' : d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
default:
return String(raw);
}
}
function ExpressionField({
element,
isSelected,
isSingleSelection,
onSelect,
onUpdate,
onDelete,
onDragStart,
onDrag,
charWidth,
charHeight,
previewMode = false,
toolMode,
parentBandId = null
}) {
const { reportData } = useReportData();
const { currentBandData } = useBandContext();
const { isDragging, handleMouseDown: handleDragMouseDown } = useElementDrag({
isSelected,
onDragStart,
onDrag,
toolMode
});
const { handleClick } = useElementSelection({
isSelected,
onSelect,
onDelete,
toolMode
});
const handleMouseDown = (e) => {
onSelect();
handleDragMouseDown(e, element.id);
};
const handleResize = (updates) => {
onUpdate(updates);
};
let displayContent;
if (previewMode) {
const resolveField = (ref) => {
const inner = ref.slice(1, ref.length - 1);
const dotIndex = inner.indexOf('.');
if (dotIndex === -1) {
return resolveDBTextValue(reportData, '', inner, parentBandId, { currentBandData });
}
const objectKey = inner.slice(0, dotIndex);
const fieldPath = inner.slice(dotIndex + 1);
return resolveDBTextValue(reportData, objectKey, fieldPath, parentBandId, { currentBandData });
};
const raw = evaluateExpression(element.expression, resolveField);
displayContent = formatValue(raw, element.dataType || 'general');
} else {
displayContent = element.expression || '(expr)';
}
const isUnresolved = !element.expression;
const multiLine = isTextMultiLine(element);
const whiteSpace = getTextWhiteSpace(element);
const style = {
...getElementStyle(element, charWidth, charHeight),
minWidth: `${charWidth}px`,
minHeight: `${charHeight}px`,
whiteSpace,
textAlign: element.alignment || 'left',
};
return (
<div
className={`expr-field ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''} ${isUnresolved ? 'unresolved' : ''}`}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
<span className="expr-field-content" style={{ whiteSpace }}>
{displayContent || '(No Expr)'}
</span>
{isSingleSelection && (
<ResizeHandles
element={element}
onResize={handleResize}
charWidth={charWidth}
charHeight={charHeight}
/>
)}
</div>
);
}
export default ExpressionField;
@@ -73,3 +73,24 @@
outline: none;
border-color: #667eea;
}
.expr-btn-row {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-bottom: 6px;
}
.expr-btn-row button {
padding: 2px 6px;
font-size: 11px;
border: 1px solid #ccc;
border-radius: 3px;
cursor: pointer;
background: #fff;
}
.expr-btn-row button:hover {
background: #e8eeff;
border-color: #667eea;
}
@@ -2,6 +2,7 @@ import React from 'react';
import { useReportData } from './DataContext';
import { getAvailableObjects, getAvailableFields } from './utils/dataResolver';
import { getAvailableArrays } from './utils/bandRenderer';
import ExpressionEditorPanel from './ExpressionEditorPanel';
import './ObjectInspector.css';
function ObjectInspector({ element, onUpdate, allElements = [] }) {
@@ -266,6 +267,10 @@ function ObjectInspector({ element, onUpdate, allElements = [] }) {
</div>
)}
{element.type === 'expr' && (
<ExpressionEditorPanel element={element} onUpdate={onUpdate} reportData={reportData} allElements={allElements} />
)}
{/* Frame/Line specific */}
{(element.type === 'frame' || element.type === 'hline' || element.type === 'vline') && (
<div className="inspector-section">
@@ -19,6 +19,7 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
return ['start', 'end'];
case 'text':
case 'dbtext':
case 'expr':
return ['nw', 'ne', 'sw', 'se'];
case 'band':
return ['s']; // Only bottom handle for height adjustment
@@ -45,8 +46,8 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
const contentLength = element.content ? element.content.length : 1;
width = (element.width || Math.max(contentLength, 5)) * charWidth;
height = (element.height || 1) * charHeight;
} else if (element.type === 'dbtext') {
// For DB text fields, use stored dimensions
} else if (element.type === 'dbtext' || element.type === 'expr') {
// For DB text fields and expression fields, use stored dimensions
width = (element.width || 10) * charWidth;
height = (element.height || 1) * charHeight;
} else if (element.type === 'band') {
@@ -190,7 +191,7 @@ function ResizeHandles({ element, onResize, charWidth, charHeight }) {
// Moving end point up/down
updates.length = Math.max(MIN_LINE_LENGTH, initial.length + deltaRow);
}
} else if (element.type === 'text' || element.type === 'dbtext') {
} else if (element.type === 'text' || element.type === 'dbtext' || element.type === 'expr') {
const initial = dragData.initialElement;
switch (dragData.handle) {
@@ -39,6 +39,13 @@ function Toolbar({
>
DB DB Text
</button>
<button
className={`toolbar-button ${toolMode === 'addExpr' ? 'active' : ''}`}
onClick={() => onToolChange('addExpr')}
title="Add Expression Field"
>
fx
</button>
<button
className={`toolbar-button ${toolMode === 'addFrame' ? 'active' : ''}`}
onClick={() => onToolChange('addFrame')}
@@ -277,6 +277,64 @@ body.dragging-active .db-text-field {
overflow-wrap: break-word;
}
/* Expression Field elements */
.expr-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(103, 126, 234, 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);
}
body.dragging-active .expr-field {
transition: none;
}
.expr-field:hover {
border-color: rgba(103, 126, 234, 0.3);
}
.expr-field.selected {
border-color: #667eea;
background: rgba(103, 126, 234, 0.1);
}
.expr-field.dragging {
opacity: 0.7;
cursor: grabbing;
transition: none;
}
.expr-field.unresolved {
background: rgba(255, 152, 0, 0.1);
border-color: rgba(255, 152, 0, 0.3);
}
.expr-field-content {
display: block;
pointer-events: none;
width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Band elements */
.band-element {
position: absolute;
@@ -93,6 +93,7 @@ export class Element {
const elementClasses = {
text: TextElement,
dbtext: DBTextElement,
expr: ExpressionElement,
frame: FrameElement,
hline: HorizontalLineElement,
vline: VerticalLineElement,
@@ -109,6 +110,20 @@ export class Element {
}
}
export class ExpressionElement extends Element {
constructor({ id, x, y, width = 10, height = 1, expression = '', dataType = 'general', alignment = 'left' }) {
super({ id, type: 'expr', x, y });
this.width = width;
this.height = height;
this.expression = expression;
this.dataType = dataType;
this.alignment = alignment;
}
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 }; }
toJSON() { return { ...super.toJSON(), width: this.width, height: this.height, expression: this.expression, dataType: this.dataType, alignment: this.alignment }; }
}
/**
* Database-bound text field element
*/
@@ -0,0 +1,257 @@
function tokenize(expression) {
const tokens = [];
let i = 0;
const src = expression;
while (i < src.length) {
if (/\s/.test(src[i])) {
i++;
continue;
}
if (src[i] === '[') {
let j = i + 1;
while (j < src.length && src[j] !== ']') j++;
tokens.push({ type: 'FIELD_REF', value: src.slice(i, j + 1) });
i = j + 1;
continue;
}
if (/[0-9]/.test(src[i]) || (src[i] === '.' && /[0-9]/.test(src[i + 1] || ''))) {
let j = i;
while (j < src.length && /[0-9.]/.test(src[j])) j++;
tokens.push({ type: 'NUMBER', value: src.slice(i, j) });
i = j;
continue;
}
if (src[i] === '"' || src[i] === "'") {
const quote = src[i];
let j = i + 1;
while (j < src.length && src[j] !== quote) j++;
tokens.push({ type: 'STRING', value: src.slice(i + 1, j) });
i = j + 1;
continue;
}
if (/[A-Za-z_]/.test(src[i])) {
let j = i;
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++;
const word = src.slice(i, j);
tokens.push({ type: 'IDENT', value: word });
i = j;
continue;
}
if (src[i] === '!' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '!=' });
i += 2;
continue;
}
if (src[i] === '>' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '>=' });
i += 2;
continue;
}
if (src[i] === '<' && src[i + 1] === '=') {
tokens.push({ type: 'CMP', value: '<=' });
i += 2;
continue;
}
if (src[i] === '=') { tokens.push({ type: 'CMP', value: '=' }); i++; continue; }
if (src[i] === '>') { tokens.push({ type: 'CMP', value: '>' }); i++; continue; }
if (src[i] === '<') { tokens.push({ type: 'CMP', value: '<' }); i++; continue; }
if (src[i] === '+' || src[i] === '-' || src[i] === '*' || src[i] === '/') {
tokens.push({ type: 'OP', value: src[i] });
i++;
continue;
}
if (src[i] === '(') { tokens.push({ type: 'LPAREN', value: '(' }); i++; continue; }
if (src[i] === ')') { tokens.push({ type: 'RPAREN', value: ')' }); i++; continue; }
if (src[i] === ',') { tokens.push({ type: 'COMMA', value: ',' }); i++; continue; }
i++;
}
tokens.push({ type: 'EOF', value: '' });
return tokens;
}
function parse(tokens) {
let pos = 0;
const peek = () => tokens[pos];
const consume = () => tokens[pos++];
function parseExpression() {
return parseComparison();
}
function parseComparison() {
const left = parseAdditive();
if (peek().type === 'CMP') {
const op = consume().value;
const right = parseAdditive();
return { kind: 'cmp', op, left, right };
}
return left;
}
function parseAdditive() {
let node = parseMultiplicative();
while (peek().type === 'OP' && (peek().value === '+' || peek().value === '-')) {
const op = consume().value;
const right = parseMultiplicative();
node = { kind: 'binop', op, left: node, right };
}
return node;
}
function parseMultiplicative() {
let node = parseUnary();
while (peek().type === 'OP' && (peek().value === '*' || peek().value === '/')) {
const op = consume().value;
const right = parseUnary();
node = { kind: 'binop', op, left: node, right };
}
return node;
}
function parseUnary() {
if (peek().type === 'OP' && peek().value === '-') {
consume();
const operand = parseUnary();
return { kind: 'unary', op: '-', operand };
}
return parsePrimary();
}
function parsePrimary() {
const tok = peek();
if (tok.type === 'NUMBER') {
consume();
return { kind: 'number', value: parseFloat(tok.value) };
}
if (tok.type === 'STRING') {
consume();
return { kind: 'string', value: tok.value };
}
if (tok.type === 'FIELD_REF') {
consume();
return { kind: 'field', ref: tok.value };
}
if (tok.type === 'IDENT' && tok.value.toUpperCase() === 'IF') {
consume();
if (peek().type !== 'LPAREN') throw new Error('Expected ( after IF');
consume();
const cond = parseExpression();
if (peek().type !== 'COMMA') throw new Error('Expected , in IF');
consume();
const then = parseExpression();
if (peek().type !== 'COMMA') throw new Error('Expected , in IF');
consume();
const els = parseExpression();
if (peek().type !== 'RPAREN') throw new Error('Expected ) after IF');
consume();
return { kind: 'if', cond, then, els };
}
if (tok.type === 'LPAREN') {
consume();
const node = parseExpression();
if (peek().type !== 'RPAREN') throw new Error('Expected )');
consume();
return node;
}
throw new Error(`Unexpected token: ${tok.type} "${tok.value}"`);
}
return parseExpression();
}
function evalNode(node, resolveField) {
switch (node.kind) {
case 'number':
return node.value;
case 'string':
return node.value;
case 'field': {
const raw = resolveField(node.ref);
return raw;
}
case 'unary': {
const val = evalNode(node.operand, resolveField);
const n = Number(val);
return isNaN(n) ? 0 : -n;
}
case 'binop': {
const l = evalNode(node.left, resolveField);
const r = evalNode(node.right, resolveField);
const ln = Number(l);
const rn = Number(r);
const lv = isNaN(ln) ? 0 : ln;
const rv = isNaN(rn) ? 0 : rn;
if (node.op === '+') return lv + rv;
if (node.op === '-') return lv - rv;
if (node.op === '*') return lv * rv;
if (node.op === '/') {
if (rv === 0) throw { divByZero: true };
return lv / rv;
}
return 0;
}
case 'cmp': {
const l = evalNode(node.left, resolveField);
const r = evalNode(node.right, resolveField);
const ln = Number(l);
const rn = Number(r);
const useNum = !isNaN(ln) && !isNaN(rn);
const lv = useNum ? ln : String(l);
const rv = useNum ? rn : String(r);
if (node.op === '=') return lv == rv;
if (node.op === '!=') return lv != rv;
if (node.op === '>') return lv > rv;
if (node.op === '<') return lv < rv;
if (node.op === '>=') return lv >= rv;
if (node.op === '<=') return lv <= rv;
return false;
}
case 'if': {
const condition = evalNode(node.cond, resolveField);
if (condition) {
return evalNode(node.then, resolveField);
} else {
return evalNode(node.els, resolveField);
}
}
default:
throw new Error(`Unknown node kind: ${node.kind}`);
}
}
export function evaluateExpression(expression, resolveField) {
if (!expression || !expression.trim()) return '';
try {
const tokens = tokenize(expression);
const ast = parse(tokens);
const result = evalNode(ast, resolveField);
return result !== undefined && result !== null ? String(result) : '';
} catch (e) {
if (e && e.divByZero) return '#DIV/0';
return '#ERR';
}
}