diff --git a/frontend/src/components/ReportEditor/CharacterPalette.css b/frontend/src/components/ReportEditor/CharacterPalette.css
new file mode 100644
index 0000000..35bf34d
--- /dev/null
+++ b/frontend/src/components/ReportEditor/CharacterPalette.css
@@ -0,0 +1,150 @@
+.character-palette {
+ position: fixed;
+ right: 0;
+ top: 0;
+ width: 300px;
+ height: 100vh;
+ background: white;
+ border-left: 1px solid #ccc;
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
+ overflow-y: auto;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+}
+
+.palette-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 15px;
+ border-bottom: 1px solid #e0e0e0;
+ background: #f9f9f9;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.palette-header h3 {
+ margin: 0;
+ font-size: 16px;
+ color: #333;
+}
+
+.close-button {
+ background: transparent;
+ border: none;
+ font-size: 20px;
+ cursor: pointer;
+ color: #666;
+ padding: 5px 10px;
+ line-height: 1;
+}
+
+.close-button:hover {
+ color: #333;
+ background: rgba(0, 0, 0, 0.05);
+ border-radius: 3px;
+}
+
+.palette-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px;
+}
+
+.palette-category {
+ margin-bottom: 10px;
+}
+
+.category-header {
+ display: flex;
+ align-items: center;
+ padding: 10px;
+ background: #f5f5f5;
+ cursor: pointer;
+ border-radius: 4px;
+ user-select: none;
+ transition: background 0.2s;
+}
+
+.category-header:hover {
+ background: #e8e8e8;
+}
+
+.category-header.expanded {
+ background: #667eea;
+ color: white;
+}
+
+.category-icon {
+ margin-right: 8px;
+ font-size: 12px;
+ width: 12px;
+ display: inline-block;
+}
+
+.category-name {
+ font-weight: 500;
+ font-size: 14px;
+}
+
+.character-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
+ gap: 5px;
+ padding: 10px;
+ background: #fafafa;
+ border-radius: 0 0 4px 4px;
+}
+
+.char-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border: 1px solid #ddd;
+ background: white;
+ cursor: pointer;
+ font-size: 24px;
+ font-family: 'Courier New', 'Consolas', monospace;
+ transition: all 0.2s;
+ border-radius: 3px;
+ user-select: none;
+}
+
+.char-button:hover {
+ border-color: #667eea;
+ background: #f0f0ff;
+ transform: scale(1.1);
+}
+
+.char-button.active {
+ background: #667eea;
+ color: white;
+ border-color: #667eea;
+ box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
+}
+
+/* Scrollbar styling */
+.character-palette::-webkit-scrollbar,
+.palette-content::-webkit-scrollbar {
+ width: 8px;
+}
+
+.character-palette::-webkit-scrollbar-track,
+.palette-content::-webkit-scrollbar-track {
+ background: #f1f1f1;
+}
+
+.character-palette::-webkit-scrollbar-thumb,
+.palette-content::-webkit-scrollbar-thumb {
+ background: #c0c0c0;
+ border-radius: 4px;
+}
+
+.character-palette::-webkit-scrollbar-thumb:hover,
+.palette-content::-webkit-scrollbar-thumb:hover {
+ background: #a0a0a0;
+}
diff --git a/frontend/src/components/ReportEditor/CharacterPalette.js b/frontend/src/components/ReportEditor/CharacterPalette.js
new file mode 100644
index 0000000..d19e743
--- /dev/null
+++ b/frontend/src/components/ReportEditor/CharacterPalette.js
@@ -0,0 +1,109 @@
+import React, { useState } from 'react';
+import './CharacterPalette.css';
+
+const SYMBOL_PALETTE = {
+ 'Single Lines': {
+ horizontal: '─',
+ vertical: '│',
+ topLeft: '┌',
+ topRight: '┐',
+ bottomLeft: '└',
+ bottomRight: '┘',
+ cross: '┼',
+ tTop: '┴',
+ tBottom: '┬',
+ tLeft: '┤',
+ tRight: '├'
+ },
+ 'Double Lines': {
+ horizontal: '═',
+ vertical: '║',
+ topLeft: '╔',
+ topRight: '╗',
+ bottomLeft: '╚',
+ bottomRight: '╝',
+ cross: '╬',
+ tTop: '╩',
+ tBottom: '╦',
+ tLeft: '╣',
+ tRight: '╠'
+ },
+ 'Mixed Junctions': {
+ crossSingleH: '╫',
+ crossSingleV: '╪',
+ doubleDownSingleH: '╤',
+ doubleUpSingleH: '╧',
+ doubleRightSingleV: '╟',
+ doubleLeftSingleV: '╢'
+ },
+ 'Rounded Corners': {
+ topLeft: '╭',
+ topRight: '╮',
+ bottomLeft: '╰',
+ bottomRight: '╯'
+ },
+ 'Heavy Lines': {
+ horizontal: '━',
+ vertical: '┃',
+ topLeft: '┏',
+ topRight: '┓',
+ bottomLeft: '┗',
+ bottomRight: '┛'
+ },
+ 'Common Symbols': {
+ space: ' ',
+ bullet: '•',
+ dash: '–',
+ equals: '=',
+ underscore: '_',
+ hash: '#'
+ }
+};
+
+function CharacterPalette({ selectedChar, onSelectChar, onClose }) {
+ const [expandedCategory, setExpandedCategory] = useState('Single Lines');
+
+ const toggleCategory = (category) => {
+ setExpandedCategory(prev => prev === category ? null : category);
+ };
+
+ return (
+
+
+
Character Palette
+
+
+
+
+ {Object.entries(SYMBOL_PALETTE).map(([category, chars]) => (
+
+
toggleCategory(category)}
+ >
+ {expandedCategory === category ? '▼' : '▶'}
+ {category}
+
+
+ {expandedCategory === category && (
+
+ {Object.entries(chars).map(([name, char]) => (
+
onSelectChar(char)}
+ title={name.replace(/([A-Z])/g, ' $1').trim()}
+ >
+ {char}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+export default CharacterPalette;
diff --git a/frontend/src/components/ReportEditor/ConfigPanel.css b/frontend/src/components/ReportEditor/ConfigPanel.css
new file mode 100644
index 0000000..c7ed8ca
--- /dev/null
+++ b/frontend/src/components/ReportEditor/ConfigPanel.css
@@ -0,0 +1,203 @@
+.config-panel-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.config-panel {
+ background: white;
+ border-radius: 8px;
+ width: 90%;
+ max-width: 600px;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+}
+
+.config-panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 20px;
+ border-bottom: 1px solid #e0e0e0;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border-radius: 8px 8px 0 0;
+}
+
+.config-panel-header h2 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 600;
+}
+
+.close-button {
+ background: none;
+ border: none;
+ color: white;
+ font-size: 32px;
+ cursor: pointer;
+ padding: 0;
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+
+.close-button:hover {
+ background: rgba(255, 255, 255, 0.2);
+}
+
+.config-panel-content {
+ padding: 20px;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.config-section {
+ margin-bottom: 24px;
+}
+
+.config-section h3 {
+ margin: 0 0 12px 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: #333;
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+}
+
+.input-group {
+ display: flex;
+ gap: 8px;
+}
+
+.config-input {
+ flex: 1;
+ padding: 10px 12px;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 14px;
+ font-family: 'Courier New', monospace;
+}
+
+.config-input:focus {
+ outline: none;
+ border-color: #667eea;
+}
+
+.config-input:disabled {
+ background: #f5f5f5;
+ cursor: not-allowed;
+}
+
+.config-button {
+ padding: 10px 16px;
+ background: #f0f0f0;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.config-button:hover:not(:disabled) {
+ background: #e0e0e0;
+ transform: translateY(-1px);
+}
+
+.config-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.config-button.primary {
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ border-color: transparent;
+}
+
+.config-button.primary:hover:not(:disabled) {
+ opacity: 0.9;
+}
+
+.config-button.small {
+ padding: 6px 12px;
+ font-size: 12px;
+}
+
+.config-error {
+ margin-top: 8px;
+ padding: 12px;
+ background: #fee;
+ border: 1px solid #fcc;
+ border-radius: 6px;
+ color: #c33;
+ font-size: 14px;
+}
+
+.config-info {
+ padding: 16px;
+ background: #f0f8ff;
+ border: 1px solid #d0e8ff;
+ border-radius: 6px;
+ color: #0066cc;
+ font-size: 14px;
+ text-align: center;
+}
+
+.manual-input-section {
+ margin-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.manual-data-input {
+ padding: 10px 12px;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ font-size: 14px;
+ font-family: 'Courier New', monospace;
+ resize: vertical;
+ min-height: 120px;
+}
+
+.manual-data-input:focus {
+ outline: none;
+ border-color: #667eea;
+}
+
+.data-preview {
+ max-height: 300px;
+ overflow-y: auto;
+ padding: 12px;
+ background: #f5f5f5;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+}
+
+.data-preview pre {
+ margin: 0;
+ font-size: 12px;
+ font-family: 'Courier New', monospace;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
diff --git a/frontend/src/components/ReportEditor/ConfigPanel.js b/frontend/src/components/ReportEditor/ConfigPanel.js
new file mode 100644
index 0000000..02e5fc6
--- /dev/null
+++ b/frontend/src/components/ReportEditor/ConfigPanel.js
@@ -0,0 +1,154 @@
+import React, { useState } from 'react';
+import { useReportData } from './DataContext';
+import './ConfigPanel.css';
+
+function ConfigPanel({ apiEndpoint, onApiEndpointChange, isOpen, onClose }) {
+ const { reportData, isLoading, error, fetchData, setData, clearData } = useReportData();
+ const [localEndpoint, setLocalEndpoint] = useState(apiEndpoint || '');
+ const [manualDataInput, setManualDataInput] = useState('');
+ const [showManualInput, setShowManualInput] = useState(false);
+
+ const handleFetch = async () => {
+ if (localEndpoint.trim()) {
+ onApiEndpointChange(localEndpoint);
+ await fetchData(localEndpoint);
+ }
+ };
+
+ const handleSetManualData = () => {
+ const trimmedInput = manualDataInput.trim();
+
+ if (!trimmedInput) {
+ alert('Please enter some JSON data first');
+ return;
+ }
+
+ try {
+ const data = JSON.parse(trimmedInput);
+ setData(data);
+ setShowManualInput(false);
+ setManualDataInput('');
+ } catch (err) {
+ alert('Invalid JSON: ' + err.message);
+ }
+ };
+
+ const handleClear = () => {
+ clearData();
+ setLocalEndpoint('');
+ onApiEndpointChange('');
+ };
+
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
Report Configuration
+
+
+
+
+ {/* API Endpoint Section */}
+
+
API Endpoint
+
+ setLocalEndpoint(e.target.value)}
+ disabled={isLoading}
+ />
+
+
+
+ {error && (
+
+ Error: {error}
+
+ )}
+
+
+ {/* Manual Data Input Section */}
+
+
+
+ {showManualInput && (
+
+
+ )}
+
+
+ {/* Data Preview Section */}
+ {reportData && (
+
+
+
Current Data
+
+
+
+
{JSON.stringify(reportData, null, 2)}
+
+
+ )}
+
+ {!reportData && !isLoading && !error && (
+
+ No data loaded. Enter an API endpoint to fetch data, or use manual input.
+
+ )}
+
+
+
+ );
+}
+
+export default ConfigPanel;
diff --git a/frontend/src/components/ReportEditor/DataContext.js b/frontend/src/components/ReportEditor/DataContext.js
new file mode 100644
index 0000000..b3eafd2
--- /dev/null
+++ b/frontend/src/components/ReportEditor/DataContext.js
@@ -0,0 +1,68 @@
+import React, { createContext, useContext, useState } from 'react';
+import axios from 'axios';
+
+const DataContext = createContext();
+
+export function useReportData() {
+ const context = useContext(DataContext);
+ if (!context) {
+ throw new Error('useReportData must be used within DataProvider');
+ }
+ return context;
+}
+
+export function DataProvider({ children }) {
+ const [reportData, setReportData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchData = async (apiEndpoint) => {
+ if (!apiEndpoint || apiEndpoint.trim() === '') {
+ setError('API endpoint URL is required');
+ return;
+ }
+
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const response = await axios.get(apiEndpoint);
+ setReportData(response.data);
+ setError(null);
+ } catch (err) {
+ console.error('Error fetching report data:', err);
+ setError(err.response?.data?.message || err.message || 'Failed to fetch data');
+ setReportData(null);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const setData = (data) => {
+ setReportData(data);
+ setError(null);
+ };
+
+ const clearData = () => {
+ setReportData(null);
+ setError(null);
+ setIsLoading(false);
+ };
+
+ const value = {
+ reportData,
+ isLoading,
+ error,
+ fetchData,
+ setData,
+ clearData
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default DataContext;
diff --git a/frontend/src/components/ReportEditor/EditorCanvas.css b/frontend/src/components/ReportEditor/EditorCanvas.css
new file mode 100644
index 0000000..bb924ad
--- /dev/null
+++ b/frontend/src/components/ReportEditor/EditorCanvas.css
@@ -0,0 +1,75 @@
+.canvas-container {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 20px;
+ background: #f5f5f5;
+ overflow: auto;
+}
+
+.editor-canvas {
+ position: relative;
+ width: 1440px;
+ height: 2112px;
+ background: white;
+ border: 1px solid #ccc;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ font-family: 'Courier New', 'Consolas', monospace;
+ font-size: 32px;
+ line-height: 32px;
+ white-space: pre;
+ letter-spacing: -1.2px;
+ font-variant-ligatures: none;
+ user-select: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+}
+
+.editor-canvas.crosshair {
+ cursor: crosshair;
+}
+
+.grid-line {
+ position: absolute;
+ background: rgba(0, 0, 0, 0.05);
+ pointer-events: none;
+}
+
+.grid-line.vertical {
+ width: 1px;
+ height: 100%;
+ top: 0;
+}
+
+.grid-line.horizontal {
+ height: 1px;
+ width: 100%;
+ left: 0;
+}
+
+.frame-preview {
+ position: absolute;
+ border: 2px dashed #667eea;
+ background: rgba(102, 126, 234, 0.1);
+ pointer-events: none;
+ box-sizing: border-box;
+}
+
+.line-preview {
+ position: absolute;
+ border: 2px dashed #66b6ea;
+ background: rgba(102, 182, 234, 0.1);
+ pointer-events: none;
+ box-sizing: border-box;
+}
+
+.selection-rectangle {
+ position: absolute;
+ border: 2px dashed #667eea;
+ background: rgba(102, 126, 234, 0.1);
+ pointer-events: none;
+ box-sizing: border-box;
+ z-index: 100;
+}
diff --git a/frontend/src/components/ReportEditor/EditorCanvas.js b/frontend/src/components/ReportEditor/EditorCanvas.js
new file mode 100644
index 0000000..45df994
--- /dev/null
+++ b/frontend/src/components/ReportEditor/EditorCanvas.js
@@ -0,0 +1,496 @@
+import React, { useRef, useEffect, useMemo } from 'react';
+import TextField from './TextField';
+import FrameElement from './FrameElement';
+import HorizontalLine from './HorizontalLine';
+import VerticalLine from './VerticalLine';
+import SymbolElement from './SymbolElement';
+import { detectCrossroads } from './utils/crossroadDetector';
+import './EditorCanvas.css';
+
+const CHAR_WIDTH = 18;
+const CHAR_HEIGHT = 32;
+const GRID_COLS = 80;
+const GRID_ROWS = 66;
+
+function EditorCanvas({
+ elements,
+ selectedElementIds,
+ onElementSelect,
+ onSelectMultiple,
+ onDeselectAll,
+ onElementUpdate,
+ onElementDelete,
+ toolMode,
+ borderStyle,
+ onAddElement,
+ previewMode = false,
+ selectedChar = '─'
+}) {
+ const canvasRef = useRef(null);
+ const [frameStart, setFrameStart] = React.useState(null);
+ const [framePreview, setFramePreview] = React.useState(null);
+ const [lineStart, setLineStart] = React.useState(null);
+ const [linePreview, setLinePreview] = React.useState(null);
+ const [selectionRect, setSelectionRect] = React.useState(null);
+ const [isSelecting, setIsSelecting] = React.useState(false);
+ const [isPainting, setIsPainting] = React.useState(false);
+ const [paintedPositions, setPaintedPositions] = React.useState(new Set());
+ const [didDragSelect, setDidDragSelect] = React.useState(false);
+
+ const snapToGrid = (pixelX, pixelY) => {
+ 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)));
+ return { col, row };
+ };
+
+ const snapToGridAlt = (pixelX, pixelY) => {
+ const col = Math.max(0, Math.min(GRID_COLS - 1, Math.floor(pixelX / CHAR_WIDTH)));
+ const row = Math.max(0, Math.min(GRID_ROWS - 1, Math.floor(pixelY / CHAR_HEIGHT)));
+ return { col, row };
+ };
+
+
+ const gridToPixels = (col, row) => ({
+ x: col * CHAR_WIDTH,
+ y: row * CHAR_HEIGHT
+ });
+
+ const handleCanvasMouseDown = (e) => {
+ const rect = canvasRef.current.getBoundingClientRect();
+ const pixelX = e.clientX - rect.left;
+ const pixelY = e.clientY - rect.top;
+ const { col, row } = snapToGridAlt(pixelX, pixelY);
+
+ if (toolMode === 'select') {
+ // Start selection rectangle
+ setIsSelecting(true);
+ setSelectionRect({ startX: col, startY: row, endX: col, endY: row });
+ setDidDragSelect(false);
+ return;
+ } else if (toolMode === 'drawTable') {
+ // Start painting symbols
+ setIsPainting(true);
+ paintSymbol(col, row);
+ return;
+ }
+ };
+
+ const handleCanvasClick = (e) => {
+ const rect = canvasRef.current.getBoundingClientRect();
+ const pixelX = e.clientX - rect.left;
+ const pixelY = e.clientY - rect.top;
+ const { col, row } = snapToGridAlt(pixelX, pixelY);
+
+ if (toolMode === 'addText') {
+ const newElement = {
+ id: `text-${Date.now()}`,
+ type: 'text',
+ x: col,
+ y: row,
+ width: 10,
+ height: 1,
+ content: 'Text'
+ };
+ onAddElement(newElement);
+ } else if (toolMode === 'addFrame') {
+ if (!frameStart) {
+ // First click - set start position
+ setFrameStart({ col, row });
+ } else {
+ // Second click - create frame
+ const x = Math.min(frameStart.col, col);
+ const y = Math.min(frameStart.row, row);
+ const width = Math.abs(col - frameStart.col) + 1;
+ const height = Math.abs(row - frameStart.row) + 1;
+
+ if (width >= 3 && height >= 3) {
+ const newElement = {
+ id: `frame-${Date.now()}`,
+ type: 'frame',
+ x,
+ y,
+ width,
+ height,
+ borderStyle
+ };
+ onAddElement(newElement);
+ }
+ setFrameStart(null);
+ setFramePreview(null);
+ }
+ } else if (toolMode === 'addHLine') {
+ if (!lineStart) {
+ // First click - set start position
+ setLineStart({ col, row, type: 'hline' });
+ } else {
+ // Second click - create horizontal line
+ const x = Math.min(lineStart.col, col);
+ const y = lineStart.row;
+ const length = Math.abs(col - lineStart.col) + 1;
+
+ if (length >= 2) {
+ const newElement = {
+ id: `hline-${Date.now()}`,
+ type: 'hline',
+ x,
+ y,
+ length,
+ lineStyle: borderStyle
+ };
+ onAddElement(newElement);
+ }
+ setLineStart(null);
+ setLinePreview(null);
+ }
+ } else if (toolMode === 'addVLine') {
+ if (!lineStart) {
+ // First click - set start position
+ setLineStart({ col, row, type: 'vline' });
+ } else {
+ // Second click - create vertical line
+ const x = lineStart.col;
+ const y = Math.min(lineStart.row, row);
+ const length = Math.abs(row - lineStart.row) + 1;
+
+ if (length >= 2) {
+ const newElement = {
+ id: `vline-${Date.now()}`,
+ type: 'vline',
+ x,
+ y,
+ length,
+ lineStyle: borderStyle
+ };
+ onAddElement(newElement);
+ }
+ setLineStart(null);
+ setLinePreview(null);
+ }
+ } else if (toolMode === 'select') {
+ // Click on canvas background - deselect all (but not if we just did a drag selection)
+ if (!didDragSelect) {
+ onDeselectAll();
+ }
+ setDidDragSelect(false);
+ }
+ };
+
+ const paintSymbol = (col, row) => {
+ const key = `${col},${row}`;
+ if (paintedPositions.has(key)) return; // Already painted this cell
+
+ // Check if symbol already exists at this position
+ const existingIndex = elements.findIndex(
+ el => el.type === 'symbol' && el.x === col && el.y === row
+ );
+
+ if (existingIndex >= 0) {
+ // Replace existing symbol
+ onElementUpdate(elements[existingIndex].id, { char: selectedChar });
+ } else {
+ // Create new symbol
+ const newElement = {
+ id: `symbol-${Date.now()}-${Math.random()}`,
+ type: 'symbol',
+ x: col,
+ y: row,
+ char: selectedChar
+ };
+ onAddElement(newElement);
+ }
+
+ setPaintedPositions(prev => new Set([...prev, key]));
+ };
+
+ const findElementsInRect = (rect) => {
+ const minX = Math.min(rect.startX, rect.endX);
+ const maxX = Math.max(rect.startX, rect.endX);
+ const minY = Math.min(rect.startY, rect.endY);
+ const maxY = Math.max(rect.startY, rect.endY);
+
+ return elements.filter(el => {
+ const elMinX = el.x;
+ const elMinY = el.y;
+ const elMaxX = el.x + (el.width || el.length || 1) - 1;
+ 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 rect = canvasRef.current.getBoundingClientRect();
+ const pixelX = e.clientX - rect.left;
+ const pixelY = e.clientY - rect.top;
+ const { col, row } = snapToGrid(pixelX, pixelY);
+ const { col: altCol, row: altRow } = snapToGridAlt(pixelX, pixelY);
+
+ if (isSelecting && toolMode === 'select') {
+ setSelectionRect(prev => {
+ // Mark that we dragged if the rectangle has actually moved
+ if (prev && (prev.startX !== altCol || prev.startY !== altRow)) {
+ setDidDragSelect(true);
+ }
+ return { ...prev, endX: altCol, endY: altRow };
+ });
+ } else if (isPainting && toolMode === 'drawTable') {
+ paintSymbol(altCol, altRow);
+ } else if (toolMode === 'addFrame' && frameStart) {
+ const x = Math.min(frameStart.col, col);
+ const y = Math.min(frameStart.row, row);
+ const width = Math.abs(col - frameStart.col) + 1;
+ const height = Math.abs(row - frameStart.row) + 1;
+
+ setFramePreview({ x, y, width, height });
+ } else if (toolMode === 'addHLine' && lineStart) {
+ const x = Math.min(lineStart.col, col);
+ const y = lineStart.row;
+ const length = Math.abs(col - lineStart.col) + 1;
+
+ setLinePreview({ x, y, length, type: 'hline' });
+ } else if (toolMode === 'addVLine' && lineStart) {
+ const x = lineStart.col;
+ const y = Math.min(lineStart.row, row);
+ const length = Math.abs(row - lineStart.row) + 1;
+
+ setLinePreview({ x, y, length, type: 'vline' });
+ }
+ };
+
+ const handleElementDragStart = (elementId, startX, startY) => {
+ const element = elements.find(el => el.id === elementId);
+ return {
+ elementId,
+ startX,
+ startY,
+ initialElementX: element.x,
+ initialElementY: element.y
+ };
+ };
+
+ const handleElementDrag = (dragData, deltaX, deltaY) => {
+ const element = elements.find(el => el.id === dragData.elementId);
+ if (!element) return;
+
+ // Calculate new position based on initial position + delta
+ const newPixelX = (dragData.initialElementX * CHAR_WIDTH) + deltaX;
+ const newPixelY = (dragData.initialElementY * CHAR_HEIGHT) + deltaY;
+ const { col, row } = snapToGrid(newPixelX, newPixelY);
+
+ // Only update if position actually changed
+ if (col !== element.x || row !== element.y) {
+ // If this element is selected with others, move all selected elements
+ if (selectedElementIds.includes(dragData.elementId) && selectedElementIds.length > 1) {
+ const deltaCol = col - element.x;
+ const deltaRow = row - element.y;
+ selectedElementIds.forEach(id => {
+ const el = elements.find(e => e.id === id);
+ if (el) {
+ onElementUpdate(id, { x: el.x + deltaCol, y: el.y + deltaRow });
+ }
+ });
+ } else {
+ onElementUpdate(dragData.elementId, { x: col, y: row });
+ }
+ }
+ };
+
+ // Add global mouseup listener to handle drag completion outside canvas
+ useEffect(() => {
+ const handleGlobalMouseUp = () => {
+ if (isSelecting && toolMode === 'select' && selectionRect) {
+ const selectedIds = findElementsInRect(selectionRect);
+ onSelectMultiple(selectedIds);
+ setIsSelecting(false);
+ setSelectionRect(null);
+ } else if (isPainting && toolMode === 'drawTable') {
+ setIsPainting(false);
+ setPaintedPositions(new Set());
+ }
+ };
+
+ document.addEventListener('mouseup', handleGlobalMouseUp);
+ return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
+ }, [isSelecting, isPainting, selectionRect, toolMode, elements, onSelectMultiple]);
+
+ // Render grid lines
+ const renderGrid = () => {
+ const gridLines = [];
+ // Vertical lines
+ for (let i = 0; i <= GRID_COLS; i++) {
+ gridLines.push(
+
+ );
+ }
+ // Horizontal lines
+ for (let i = 0; i <= GRID_ROWS; i++) {
+ gridLines.push(
+
+ );
+ }
+ return gridLines;
+ };
+
+ // Calculate crossroad map for intersection rendering
+ const crossroadMap = useMemo(() => {
+ return detectCrossroads(elements);
+ }, [elements]);
+
+ const showCrosshair = toolMode === 'addFrame' || toolMode === 'addHLine' || toolMode === 'addVLine';
+
+ return (
+
+
+ {renderGrid()}
+
+ {/* Render selection rectangle */}
+ {selectionRect && isSelecting && (
+
+ )}
+
+ {/* Render frame preview */}
+ {framePreview && (
+
+ )}
+
+ {/* Render line preview */}
+ {linePreview && (
+
+ )}
+
+ {/* Render elements */}
+ {elements.map(element => {
+ if (element.type === 'text') {
+ return (
+
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}
+ previewMode={previewMode}
+ toolMode={toolMode}
+ />
+ );
+ } else if (element.type === 'frame') {
+ return (
+ 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 (
+ 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 (
+ 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 (
+ 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;
+ })}
+
+
+ );
+}
+
+export default EditorCanvas;
diff --git a/frontend/src/components/ReportEditor/FrameElement.js b/frontend/src/components/ReportEditor/FrameElement.js
new file mode 100644
index 0000000..de702fc
--- /dev/null
+++ b/frontend/src/components/ReportEditor/FrameElement.js
@@ -0,0 +1,180 @@
+import React, { useState, useEffect } from 'react';
+import ResizeHandles from './ResizeHandles';
+import './elements.css';
+
+const BORDER_CHARS = {
+ single: {
+ topLeft: '┌',
+ topRight: '┐',
+ bottomLeft: '└',
+ bottomRight: '┘',
+ horizontal: '─',
+ vertical: '│'
+ },
+ double: {
+ topLeft: '╔',
+ topRight: '╗',
+ bottomLeft: '╚',
+ bottomRight: '╝',
+ horizontal: '═',
+ vertical: '║'
+ }
+};
+
+function FrameElement({ element, isSelected, isSingleSelection, onSelect, onDelete, onUpdate, onDragStart, onDrag, charWidth, charHeight, toolMode }) {
+ const chars = BORDER_CHARS[element.borderStyle || 'single'];
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragData, setDragData] = useState(null);
+ const [isHoveringBorder, setIsHoveringBorder] = useState(false);
+
+ useEffect(() => {
+ const handleKeyPress = (e) => {
+ if (isSelected && e.key === 'Delete') {
+ onDelete();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyPress);
+ return () => document.removeEventListener('keydown', handleKeyPress);
+ }, [isSelected, onDelete]);
+
+ const renderFrame = () => {
+ const lines = [];
+ const { width, height } = element;
+
+ for (let row = 0; row < height; row++) {
+ let line = '';
+ for (let col = 0; col < width; col++) {
+ if (row === 0 && col === 0) {
+ line += chars.topLeft;
+ } else if (row === 0 && col === width - 1) {
+ line += chars.topRight;
+ } else if (row === height - 1 && col === 0) {
+ line += chars.bottomLeft;
+ } else if (row === height - 1 && col === width - 1) {
+ line += chars.bottomRight;
+ } else if (row === 0 || row === height - 1) {
+ line += chars.horizontal;
+ } else if (col === 0 || col === width - 1) {
+ line += chars.vertical;
+ } else {
+ line += ' ';
+ }
+ }
+ lines.push(
+
+ {line}
+
+ );
+ }
+ return lines;
+ };
+
+ const style = {
+ left: `${element.x * charWidth}px`,
+ top: `${element.y * charHeight}px`,
+ width: `${element.width * charWidth}px`,
+ height: `${element.height * charHeight}px`,
+ cursor: (toolMode === 'select' && isHoveringBorder) ? 'pointer' : 'default'
+ };
+
+ // Check if a click position is on the border (not interior)
+ const isClickOnBorder = (e) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const relativeX = e.clientX - rect.left;
+ const relativeY = e.clientY - rect.top;
+
+ // Calculate which character cell was clicked
+ const col = Math.floor(relativeX / charWidth);
+ const row = Math.floor(relativeY / charHeight);
+
+ // Check if on border (first/last row or first/last column)
+ const onBorder = row === 0 || row === element.height - 1 || col === 0 || col === element.width - 1;
+ return onBorder;
+ };
+
+ const handleClick = (e) => {
+ // Only handle selection in select mode and on border clicks
+ if (toolMode === 'select' && isClickOnBorder(e)) {
+ e.stopPropagation();
+ onSelect(e);
+ }
+ };
+
+ const handleMouseMove = (e) => {
+ // Update hover state based on whether mouse is over border
+ const onBorder = isClickOnBorder(e);
+ if (onBorder !== isHoveringBorder) {
+ setIsHoveringBorder(onBorder);
+ }
+ };
+
+ const handleMouseDown = (e) => {
+ // Only handle dragging in select mode and on border clicks
+ if (toolMode !== 'select' || !isClickOnBorder(e)) return;
+
+ 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) => {
+ 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 (
+
setIsHoveringBorder(false)}
+ >
+ {renderFrame()}
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default FrameElement;
diff --git a/frontend/src/components/ReportEditor/HorizontalLine.js b/frontend/src/components/ReportEditor/HorizontalLine.js
new file mode 100644
index 0000000..0a985b1
--- /dev/null
+++ b/frontend/src/components/ReportEditor/HorizontalLine.js
@@ -0,0 +1,139 @@
+import React, { useState, useEffect } from 'react';
+import ResizeHandles from './ResizeHandles';
+import './elements.css';
+
+const LINE_CHARS = {
+ single: '─',
+ double: '═'
+};
+
+function HorizontalLine({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onDelete,
+ onUpdate,
+ onDragStart,
+ onDrag,
+ charWidth,
+ charHeight,
+ crossroadMap = {},
+ toolMode
+}) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragData, setDragData] = useState(null);
+
+ const lineChar = LINE_CHARS[element.lineStyle || 'single'];
+
+ useEffect(() => {
+ const handleKeyPress = (e) => {
+ if (isSelected && e.key === 'Delete') {
+ onDelete();
+ }
+ };
+
+ 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) => {
+ // Only handle dragging in select mode
+ if (toolMode !== 'select') return;
+
+ 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) => {
+ 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
+ const renderLine = () => {
+ let line = '';
+ for (let i = 0; i < element.length; i++) {
+ const gridX = element.x + i;
+ const gridY = element.y;
+ const crossroadKey = `${gridX},${gridY}`;
+
+ // Use crossroad character if it exists, otherwise use line character
+ if (crossroadMap[crossroadKey]) {
+ line += crossroadMap[crossroadKey];
+ } else {
+ line += lineChar;
+ }
+ }
+ return line;
+ };
+
+ const style = {
+ left: `${element.x * charWidth}px`,
+ top: `${element.y * charHeight}px`,
+ width: `${element.length * charWidth}px`,
+ height: `${charHeight}px`
+ };
+
+ return (
+
+ {renderLine()}
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default HorizontalLine;
diff --git a/frontend/src/components/ReportEditor/ReportEditor.css b/frontend/src/components/ReportEditor/ReportEditor.css
new file mode 100644
index 0000000..ddbb4c9
--- /dev/null
+++ b/frontend/src/components/ReportEditor/ReportEditor.css
@@ -0,0 +1,6 @@
+.report-editor {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ overflow: hidden;
+}
diff --git a/frontend/src/components/ReportEditor/ReportEditor.js b/frontend/src/components/ReportEditor/ReportEditor.js
new file mode 100644
index 0000000..68a2d28
--- /dev/null
+++ b/frontend/src/components/ReportEditor/ReportEditor.js
@@ -0,0 +1,194 @@
+import React, { useState, useEffect } from 'react';
+import { DataProvider } from './DataContext';
+import Toolbar from './Toolbar';
+import EditorCanvas from './EditorCanvas';
+import ConfigPanel from './ConfigPanel';
+import CharacterPalette from './CharacterPalette';
+import './ReportEditor.css';
+
+function ReportEditorContent() {
+ const [report, setReport] = useState({
+ name: 'Untitled Report',
+ pageWidth: 80,
+ pageHeight: 66,
+ apiEndpoint: '',
+ elements: []
+ });
+
+ const [selectedElementIds, setSelectedElementIds] = useState([]);
+ const [toolMode, setToolMode] = useState('select');
+ const [borderStyle, setBorderStyle] = useState('single');
+ const [previewMode, setPreviewMode] = useState(false);
+ const [showConfigPanel, setShowConfigPanel] = useState(false);
+ const [selectedChar, setSelectedChar] = useState('─');
+ const [showCharacterPalette, setShowCharacterPalette] = useState(false);
+
+ const handleAddElement = (element) => {
+ setReport(prev => ({
+ ...prev,
+ elements: [...prev.elements, element]
+ }));
+ setSelectedElementIds([element.id]);
+ // After adding an element, switch back to select mode (unless in drawTable mode)
+ if (toolMode !== 'drawTable') {
+ setToolMode('select');
+ }
+ };
+
+ const handleElementUpdate = (elementId, updates) => {
+ setReport(prev => ({
+ ...prev,
+ elements: prev.elements.map(el =>
+ el.id === elementId ? { ...el, ...updates } : el
+ )
+ }));
+ };
+
+ const handleElementDelete = (elementId) => {
+ setReport(prev => ({
+ ...prev,
+ elements: prev.elements.filter(el => el.id !== elementId)
+ }));
+ setSelectedElementIds(prev => prev.filter(id => id !== elementId));
+ };
+
+ const handleDeleteSelected = () => {
+ setReport(prev => ({
+ ...prev,
+ elements: prev.elements.filter(el => !selectedElementIds.includes(el.id))
+ }));
+ setSelectedElementIds([]);
+ };
+
+ const handleSelectElement = (elementId, addToSelection = false) => {
+ if (addToSelection) {
+ setSelectedElementIds(prev => {
+ if (prev.includes(elementId)) {
+ return prev.filter(id => id !== elementId);
+ } else {
+ return [...prev, elementId];
+ }
+ });
+ } else {
+ // If clicking on an already-selected element in a multi-selection, keep the multi-selection
+ // This allows dragging the entire selection
+ setSelectedElementIds(prev => {
+ if (prev.length > 1 && prev.includes(elementId)) {
+ return prev; // Keep existing multi-selection
+ }
+ return [elementId]; // Otherwise, select just this element
+ });
+ }
+ };
+
+ const handleSelectMultiple = (elementIds) => {
+ setSelectedElementIds(elementIds);
+ };
+
+ const handleDeselectAll = () => {
+ setSelectedElementIds([]);
+ };
+
+ const handleToolChange = (newTool) => {
+ setToolMode(newTool);
+ // Deselect when changing tools (except for drawTable)
+ if (newTool !== 'select' && newTool !== 'drawTable') {
+ setSelectedElementIds([]);
+ }
+ // Show/hide character palette for drawTable mode
+ if (newTool === 'drawTable') {
+ setShowCharacterPalette(true);
+ } else {
+ setShowCharacterPalette(false);
+ }
+ };
+
+ const handleTogglePreview = () => {
+ setPreviewMode(prev => !prev);
+ };
+
+ const handleApiEndpointChange = (endpoint) => {
+ setReport(prev => ({
+ ...prev,
+ apiEndpoint: endpoint
+ }));
+ };
+
+ // Handle keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === 'Escape') {
+ setSelectedElementIds([]);
+ setToolMode('select');
+ setShowCharacterPalette(false);
+ if (showConfigPanel) {
+ setShowConfigPanel(false);
+ }
+ } else if (e.key.toLowerCase() === 'd' && !e.ctrlKey && !e.metaKey && e.target.tagName !== 'INPUT') {
+ handleToolChange('drawTable');
+ } else if (e.key === 'Delete' && selectedElementIds.length > 0) {
+ handleDeleteSelected();
+ } else if (e.key.toLowerCase() === 'a' && (e.ctrlKey || e.metaKey) && toolMode === 'select') {
+ e.preventDefault();
+ setSelectedElementIds(report.elements.map(el => el.id));
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [showConfigPanel, selectedElementIds, toolMode, report.elements]);
+
+ return (
+
+ setShowConfigPanel(true)}
+ />
+
+ setShowConfigPanel(false)}
+ />
+ {showCharacterPalette && (
+ {
+ setShowCharacterPalette(false);
+ setToolMode('select');
+ }}
+ />
+ )}
+
+ );
+}
+
+function ReportEditor() {
+ return (
+
+
+
+ );
+}
+
+export default ReportEditor;
diff --git a/frontend/src/components/ReportEditor/ResizeHandles.js b/frontend/src/components/ReportEditor/ResizeHandles.js
new file mode 100644
index 0000000..c4ee312
--- /dev/null
+++ b/frontend/src/components/ReportEditor/ResizeHandles.js
@@ -0,0 +1,249 @@
+import React, { useState, useEffect } from 'react';
+import './elements.css';
+
+const MIN_FRAME_SIZE = { width: 3, height: 3 };
+const MIN_LINE_LENGTH = 2;
+const MIN_TEXT_SIZE = { width: 1, height: 1 };
+
+function ResizeHandles({ element, onResize, charWidth, charHeight }) {
+ const [activeHandle, setActiveHandle] = useState(null);
+ const [dragData, setDragData] = useState(null);
+
+ // Determine which handles to show based on element type
+ const getHandles = () => {
+ switch (element.type) {
+ case 'frame':
+ return ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'];
+ case 'hline':
+ case 'vline':
+ return ['start', 'end'];
+ case 'text':
+ return ['nw', 'ne', 'sw', 'se'];
+ default:
+ return [];
+ }
+ };
+
+ // Calculate handle position in pixels (relative to parent element)
+ const getHandlePosition = (handle) => {
+ // Since handles are rendered inside the element div (which is already positioned),
+ // we use relative positioning starting from 0,0 at the parent's top-left
+
+ // Calculate width and height based on element type
+ let width, height;
+ if (element.type === 'hline') {
+ width = element.length * charWidth;
+ height = charHeight;
+ } else if (element.type === 'vline') {
+ width = charWidth;
+ height = element.length * charHeight;
+ } else if (element.type === 'text') {
+ // For text fields, calculate based on content length or stored dimensions
+ const contentLength = element.content ? element.content.length : 1;
+ width = (element.width || Math.max(contentLength, 5)) * charWidth;
+ height = (element.height || 1) * charHeight;
+ } else {
+ // For frames and other elements
+ width = (element.width || 1) * charWidth;
+ height = (element.height || 1) * charHeight;
+ }
+
+ const positions = {
+ // Frame and text handles
+ nw: { left: -4, top: -4 },
+ n: { left: width / 2 - 4, top: -4 },
+ ne: { left: width - 4, top: -4 },
+ w: { left: -4, top: height / 2 - 4 },
+ e: { left: width - 4, top: height / 2 - 4 },
+ sw: { left: -4, top: height - 4 },
+ s: { left: width / 2 - 4, top: height - 4 },
+ se: { left: width - 4, top: height - 4 },
+
+ // Line endpoint handles
+ start: element.type === 'hline'
+ ? { left: -4, top: height / 2 - 4 }
+ : { left: width / 2 - 4, top: -4 },
+ end: element.type === 'hline'
+ ? { left: width - 4, top: height / 2 - 4 }
+ : { left: width / 2 - 4, top: height - 4 }
+ };
+
+ return positions[handle];
+ };
+
+ // Get cursor style for handle
+ const getCursor = (handle) => {
+ const cursors = {
+ nw: 'nw-resize',
+ n: 'n-resize',
+ ne: 'ne-resize',
+ w: 'w-resize',
+ e: 'e-resize',
+ sw: 'sw-resize',
+ s: 's-resize',
+ se: 'se-resize',
+ start: element.type === 'hline' ? 'ew-resize' : 'ns-resize',
+ end: element.type === 'hline' ? 'ew-resize' : 'ns-resize'
+ };
+ return cursors[handle] || 'default';
+ };
+
+ const handleMouseDown = (e, handle) => {
+ e.stopPropagation();
+ setActiveHandle(handle);
+ setDragData({
+ handle,
+ startX: e.clientX,
+ startY: e.clientY,
+ initialElement: { ...element }
+ });
+ };
+
+ useEffect(() => {
+ if (!activeHandle || !dragData) return;
+
+ const handleMouseMove = (e) => {
+ const deltaX = e.clientX - dragData.startX;
+ const deltaY = e.clientY - dragData.startY;
+ const deltaCol = Math.round(deltaX / charWidth);
+ const deltaRow = Math.round(deltaY / charHeight);
+
+ let updates = {};
+
+ if (element.type === 'frame') {
+ const initial = dragData.initialElement;
+
+ switch (dragData.handle) {
+ case 'se': // South-East (bottom-right)
+ updates.width = Math.max(MIN_FRAME_SIZE.width, initial.width + deltaCol);
+ updates.height = Math.max(MIN_FRAME_SIZE.height, initial.height + deltaRow);
+ break;
+ case 'nw': // North-West (top-left)
+ const newWidthNW = Math.max(MIN_FRAME_SIZE.width, initial.width - deltaCol);
+ const newHeightNW = Math.max(MIN_FRAME_SIZE.height, initial.height - deltaRow);
+ updates.x = initial.x + (initial.width - newWidthNW);
+ updates.y = initial.y + (initial.height - newHeightNW);
+ updates.width = newWidthNW;
+ updates.height = newHeightNW;
+ break;
+ case 'ne': // North-East (top-right)
+ const newHeightNE = Math.max(MIN_FRAME_SIZE.height, initial.height - deltaRow);
+ updates.y = initial.y + (initial.height - newHeightNE);
+ updates.width = Math.max(MIN_FRAME_SIZE.width, initial.width + deltaCol);
+ updates.height = newHeightNE;
+ break;
+ case 'sw': // South-West (bottom-left)
+ const newWidthSW = Math.max(MIN_FRAME_SIZE.width, initial.width - deltaCol);
+ updates.x = initial.x + (initial.width - newWidthSW);
+ updates.width = newWidthSW;
+ updates.height = Math.max(MIN_FRAME_SIZE.height, initial.height + deltaRow);
+ break;
+ case 'n': // North (top edge)
+ const newHeightN = Math.max(MIN_FRAME_SIZE.height, initial.height - deltaRow);
+ updates.y = initial.y + (initial.height - newHeightN);
+ updates.height = newHeightN;
+ break;
+ case 's': // South (bottom edge)
+ updates.height = Math.max(MIN_FRAME_SIZE.height, initial.height + deltaRow);
+ break;
+ case 'w': // West (left edge)
+ const newWidthW = Math.max(MIN_FRAME_SIZE.width, initial.width - deltaCol);
+ updates.x = initial.x + (initial.width - newWidthW);
+ updates.width = newWidthW;
+ break;
+ case 'e': // East (right edge)
+ updates.width = Math.max(MIN_FRAME_SIZE.width, initial.width + deltaCol);
+ break;
+ }
+ } else if (element.type === 'hline') {
+ const initial = dragData.initialElement;
+
+ if (dragData.handle === 'start') {
+ // Moving start point left/right
+ const newLength = Math.max(MIN_LINE_LENGTH, initial.length - deltaCol);
+ updates.x = initial.x + (initial.length - newLength);
+ updates.length = newLength;
+ } else {
+ // Moving end point left/right
+ updates.length = Math.max(MIN_LINE_LENGTH, initial.length + deltaCol);
+ }
+ } else if (element.type === 'vline') {
+ const initial = dragData.initialElement;
+
+ if (dragData.handle === 'start') {
+ // Moving start point up/down
+ const newLength = Math.max(MIN_LINE_LENGTH, initial.length - deltaRow);
+ updates.y = initial.y + (initial.length - newLength);
+ updates.length = newLength;
+ } else {
+ // Moving end point up/down
+ updates.length = Math.max(MIN_LINE_LENGTH, initial.length + deltaRow);
+ }
+ } else if (element.type === 'text') {
+ const initial = dragData.initialElement;
+
+ switch (dragData.handle) {
+ case 'se': // South-East
+ updates.width = Math.max(MIN_TEXT_SIZE.width, initial.width + deltaCol);
+ updates.height = Math.max(MIN_TEXT_SIZE.height, initial.height + deltaRow);
+ break;
+ case 'nw': // North-West
+ const newWidthNW = Math.max(MIN_TEXT_SIZE.width, initial.width - deltaCol);
+ const newHeightNW = Math.max(MIN_TEXT_SIZE.height, initial.height - deltaRow);
+ updates.x = initial.x + (initial.width - newWidthNW);
+ updates.y = initial.y + (initial.height - newHeightNW);
+ updates.width = newWidthNW;
+ updates.height = newHeightNW;
+ break;
+ case 'ne': // North-East
+ const newHeightNE = Math.max(MIN_TEXT_SIZE.height, initial.height - deltaRow);
+ updates.y = initial.y + (initial.height - newHeightNE);
+ updates.width = Math.max(MIN_TEXT_SIZE.width, initial.width + deltaCol);
+ updates.height = newHeightNE;
+ break;
+ case 'sw': // South-West
+ const newWidthSW = Math.max(MIN_TEXT_SIZE.width, initial.width - deltaCol);
+ updates.x = initial.x + (initial.width - newWidthSW);
+ updates.width = newWidthSW;
+ updates.height = Math.max(MIN_TEXT_SIZE.height, initial.height + deltaRow);
+ break;
+ }
+ }
+
+ if (Object.keys(updates).length > 0) {
+ onResize(updates);
+ }
+ };
+
+ const handleMouseUp = () => {
+ setActiveHandle(null);
+ setDragData(null);
+ };
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [activeHandle, dragData, element, onResize, charWidth, charHeight]);
+
+ return (
+ <>
+ {getHandles().map(handle => (
+
handleMouseDown(e, handle)}
+ />
+ ))}
+ >
+ );
+}
+
+export default ResizeHandles;
diff --git a/frontend/src/components/ReportEditor/SymbolElement.js b/frontend/src/components/ReportEditor/SymbolElement.js
new file mode 100644
index 0000000..dff32f7
--- /dev/null
+++ b/frontend/src/components/ReportEditor/SymbolElement.js
@@ -0,0 +1,98 @@
+import React, { useState, useEffect } from 'react';
+import './elements.css';
+
+function SymbolElement({
+ element,
+ isSelected,
+ onSelect,
+ onDelete,
+ onDragStart,
+ onDrag,
+ charWidth,
+ charHeight,
+ toolMode
+}) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragData, setDragData] = useState(null);
+
+ useEffect(() => {
+ const handleKeyPress = (e) => {
+ if (isSelected && e.key === 'Delete') {
+ onDelete();
+ }
+ };
+
+ 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) => {
+ // Only handle dragging in select mode
+ if (toolMode !== 'select') return;
+
+ e.stopPropagation();
+ onSelect(e);
+
+ 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]);
+
+ const style = {
+ left: `${element.x * charWidth}px`,
+ top: `${element.y * charHeight}px`,
+ width: `${charWidth}px`,
+ height: `${charHeight}px`
+ };
+
+ return (
+
+ {element.char}
+
+ );
+}
+
+export default SymbolElement;
diff --git a/frontend/src/components/ReportEditor/TextField.js b/frontend/src/components/ReportEditor/TextField.js
new file mode 100644
index 0000000..04a7b1f
--- /dev/null
+++ b/frontend/src/components/ReportEditor/TextField.js
@@ -0,0 +1,197 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { useReportData } from './DataContext';
+import { parseBindings, hasBindings } from './utils/bindingParser';
+import ResizeHandles from './ResizeHandles';
+import './elements.css';
+
+function TextField({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onUpdate,
+ onDelete,
+ onDragStart,
+ onDrag,
+ charWidth,
+ charHeight,
+ previewMode = false,
+ toolMode
+}) {
+ const { reportData } = useReportData();
+ const [isEditing, setIsEditing] = useState(false);
+ const [editContent, setEditContent] = useState(element.content);
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragData, setDragData] = useState(null);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [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) => {
+ // Only allow editing in select mode
+ if (toolMode === 'select') {
+ e.stopPropagation();
+ if (!isEditing) {
+ setIsEditing(true);
+ setEditContent(element.content);
+ }
+ }
+ };
+
+ const handleBlur = () => {
+ if (isEditing) {
+ setIsEditing(false);
+ if (editContent !== element.content) {
+ onUpdate({ content: editContent });
+ }
+ }
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleBlur();
+ } else if (e.key === 'Escape') {
+ setIsEditing(false);
+ setEditContent(element.content);
+ }
+ };
+
+ const handleMouseDown = (e) => {
+ if (isEditing) return;
+
+ // Only handle dragging in select mode
+ 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(() => {
+ if (!isSelected) {
+ setIsEditing(false);
+ }
+ }, [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) => {
+ onUpdate(updates);
+ };
+
+ // Resolve bindings if in preview mode
+ const displayContent = previewMode
+ ? parseBindings(element.content, reportData)
+ : element.content;
+
+ // Check if field has bindings for styling
+ const fieldHasBindings = hasBindings(element.content);
+
+ const isMultiLine = element.height && element.height > 1;
+
+ const style = {
+ left: `${element.x * charWidth}px`,
+ top: `${element.y * charHeight}px`,
+ width: element.width ? `${element.width * charWidth}px` : 'auto',
+ height: element.height ? `${element.height * charHeight}px` : 'auto',
+ minWidth: `${charWidth}px`,
+ minHeight: `${charHeight}px`,
+ whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap'
+ };
+
+ return (
+
+ {isEditing ? (
+ setEditContent(e.target.value)}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ style={{ width: `${Math.max(editContent.length, 5) * charWidth}px` }}
+ />
+ ) : (
+
+ {displayContent}
+
+ )}
+ {isSingleSelection && !isEditing && (
+
+ )}
+
+ );
+}
+
+export default TextField;
diff --git a/frontend/src/components/ReportEditor/Toolbar.css b/frontend/src/components/ReportEditor/Toolbar.css
new file mode 100644
index 0000000..2f3dd8e
--- /dev/null
+++ b/frontend/src/components/ReportEditor/Toolbar.css
@@ -0,0 +1,52 @@
+.toolbar {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-bottom: 2px solid rgba(0, 0, 0, 0.1);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ gap: 16px;
+}
+
+.toolbar-group {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.toolbar-separator {
+ width: 1px;
+ height: 32px;
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.toolbar-label {
+ color: white;
+ font-size: 14px;
+ font-weight: 500;
+ margin-right: 4px;
+}
+
+.toolbar-button {
+ padding: 8px 16px;
+ background: rgba(255, 255, 255, 0.2);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ color: white;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+
+.toolbar-button:hover {
+ background: rgba(255, 255, 255, 0.3);
+ transform: translateY(-1px);
+}
+
+.toolbar-button.active {
+ background: rgba(255, 255, 255, 0.4);
+ border-color: rgba(255, 255, 255, 0.5);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
diff --git a/frontend/src/components/ReportEditor/Toolbar.js b/frontend/src/components/ReportEditor/Toolbar.js
new file mode 100644
index 0000000..95b8b26
--- /dev/null
+++ b/frontend/src/components/ReportEditor/Toolbar.js
@@ -0,0 +1,100 @@
+import React from 'react';
+import './Toolbar.css';
+
+function Toolbar({
+ toolMode,
+ onToolChange,
+ borderStyle,
+ onBorderStyleChange,
+ previewMode = false,
+ onTogglePreview,
+ onConfigureAPI
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {onConfigureAPI && (
+
+ )}
+
+
+ );
+}
+
+export default Toolbar;
diff --git a/frontend/src/components/ReportEditor/VerticalLine.js b/frontend/src/components/ReportEditor/VerticalLine.js
new file mode 100644
index 0000000..72145af
--- /dev/null
+++ b/frontend/src/components/ReportEditor/VerticalLine.js
@@ -0,0 +1,141 @@
+import React, { useState, useEffect } from 'react';
+import ResizeHandles from './ResizeHandles';
+import './elements.css';
+
+const LINE_CHARS = {
+ single: '│',
+ double: '║'
+};
+
+function VerticalLine({
+ element,
+ isSelected,
+ isSingleSelection,
+ onSelect,
+ onDelete,
+ onUpdate,
+ onDragStart,
+ onDrag,
+ charWidth,
+ charHeight,
+ crossroadMap = {},
+ toolMode
+}) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragData, setDragData] = useState(null);
+
+ const lineChar = LINE_CHARS[element.lineStyle || 'single'];
+
+ useEffect(() => {
+ const handleKeyPress = (e) => {
+ if (isSelected && e.key === 'Delete') {
+ onDelete();
+ }
+ };
+
+ 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) => {
+ // Only handle dragging in select mode
+ if (toolMode !== 'select') return;
+
+ 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) => {
+ 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
+ const renderLine = () => {
+ const lines = [];
+ for (let i = 0; i < element.length; i++) {
+ const gridX = element.x;
+ const gridY = element.y + i;
+ const crossroadKey = `${gridX},${gridY}`;
+
+ // Use crossroad character if it exists, otherwise use line character
+ const char = crossroadMap[crossroadKey] || lineChar;
+
+ lines.push(
+
+ {char}
+
+ );
+ }
+ return lines;
+ };
+
+ const style = {
+ left: `${element.x * charWidth}px`,
+ top: `${element.y * charHeight}px`,
+ width: `${charWidth}px`,
+ height: `${element.length * charHeight}px`
+ };
+
+ return (
+
+ {renderLine()}
+ {isSingleSelection && (
+
+ )}
+
+ );
+}
+
+export default VerticalLine;
diff --git a/frontend/src/components/ReportEditor/elements.css b/frontend/src/components/ReportEditor/elements.css
new file mode 100644
index 0000000..6edb65e
--- /dev/null
+++ b/frontend/src/components/ReportEditor/elements.css
@@ -0,0 +1,228 @@
+.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;
+ transition: border-color 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 .text-field {
+ transition: none;
+}
+
+.text-field:hover {
+ border-color: rgba(102, 126, 234, 0.3);
+}
+
+.text-field.selected {
+ border-color: #667eea;
+ background: rgba(102, 126, 234, 0.05);
+}
+
+.text-field.dragging {
+ opacity: 0.7;
+ cursor: grabbing;
+ 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 {
+ display: block;
+ pointer-events: none;
+ width: 100%;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+}
+
+.text-field-input {
+ background: transparent;
+ border: none;
+ outline: none;
+ font-family: 'Courier New', 'Consolas', monospace;
+ font-size: 32px;
+ line-height: 32px;
+ padding: 0;
+ margin: 0;
+ white-space: pre;
+ letter-spacing: -1.2px;
+ user-select: text;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+}
+
+.frame-element {
+ position: absolute;
+ cursor: default;
+ border: 2px solid transparent;
+ box-sizing: content-box;
+ font-family: 'Courier New', 'Consolas', monospace;
+ font-size: 32px;
+ line-height: 32px;
+ white-space: pre;
+ letter-spacing: -1.2px;
+ font-variant-ligatures: none;
+ transition: border-color 0.2s;
+ will-change: auto;
+ user-select: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ overflow: visible;
+ transform: translateX(-2px);
+}
+
+/* Disable transitions when any element is being dragged */
+body.dragging-active .frame-element {
+ transition: none;
+}
+
+.frame-element:hover {
+ /* Hover effect removed - handled by JavaScript based on border detection */
+}
+
+.frame-element.selected {
+ border-color: #667eea;
+ background: rgba(102, 126, 234, 0.05);
+}
+
+.frame-element.dragging {
+ opacity: 0.7;
+ cursor: grabbing;
+ transition: none;
+}
+
+.frame-line {
+ white-space: pre;
+ overflow: visible;
+ line-height: 32px;
+}
+
+/* Line elements */
+.line-element {
+ position: absolute;
+ cursor: pointer;
+ border: 2px solid transparent;
+ box-sizing: content-box;
+ font-family: 'Courier New', 'Consolas', monospace;
+ font-size: 32px;
+ line-height: 32px;
+ white-space: pre;
+ letter-spacing: -1.2px;
+ font-variant-ligatures: none;
+ transition: border-color 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 .line-element {
+ transition: none;
+}
+
+.line-element:hover {
+ border-color: rgba(102, 182, 234, 0.3);
+}
+
+.line-element.selected {
+ border-color: #66b6ea;
+ background: rgba(102, 182, 234, 0.05);
+}
+
+.line-element.dragging {
+ opacity: 0.7;
+ cursor: grabbing;
+ transition: none;
+}
+
+.vertical-line-char {
+ white-space: pre;
+}
+
+/* Symbol elements */
+.symbol-element {
+ 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: pre;
+ letter-spacing: -1.2px;
+ font-variant-ligatures: none;
+ transition: border-color 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 .symbol-element {
+ transition: none;
+}
+
+.symbol-element:hover {
+ border-color: rgba(102, 182, 234, 0.3);
+}
+
+.symbol-element.selected {
+ border-color: #66b6ea;
+ background: rgba(102, 182, 234, 0.05);
+}
+
+.symbol-element.dragging {
+ opacity: 0.7;
+ cursor: grabbing;
+ transition: none;
+}
+
+/* Resize handles */
+.resize-handle {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ box-sizing: border-box;
+ border-radius: 2px;
+ background: orange;
+ border: 1px solid #ff8800;
+ z-index: 1000;
+}
+
+.resize-handle:hover {
+ background: #ff6600;
+ transform: scale(1.2);
+}
diff --git a/frontend/src/components/ReportEditor/utils/bindingParser.js b/frontend/src/components/ReportEditor/utils/bindingParser.js
new file mode 100644
index 0000000..ed79de3
--- /dev/null
+++ b/frontend/src/components/ReportEditor/utils/bindingParser.js
@@ -0,0 +1,88 @@
+/**
+ * Parse text content and replace {variable.field} expressions with actual data values
+ * @param {string} text - Text containing binding expressions like "Owner: {owner.name}"
+ * @param {object} data - Data object containing the values to bind
+ * @returns {string} - Text with bindings resolved
+ */
+export function parseBindings(text, data) {
+ if (!text || typeof text !== 'string') {
+ return text || '';
+ }
+
+ if (!data || typeof data !== 'object') {
+ return text;
+ }
+
+ // Regex to match {variable.field.nested} patterns
+ const bindingRegex = /\{([a-zA-Z0-9_.]+)\}/g;
+
+ return text.replace(bindingRegex, (match, path) => {
+ const value = resolveNestedPath(data, path);
+
+ // If value is found, return it; otherwise keep the original binding
+ if (value !== undefined && value !== null) {
+ return String(value);
+ }
+
+ return match; // Keep {variable} if not found
+ });
+}
+
+/**
+ * Resolve a nested path in an object (e.g., "owner.contact.name")
+ * @param {object} obj - The object to traverse
+ * @param {string} path - Dot-separated path (e.g., "owner.name")
+ * @returns {*} - The resolved value or undefined
+ */
+function resolveNestedPath(obj, path) {
+ const keys = path.split('.');
+ let current = obj;
+
+ for (const key of keys) {
+ if (current === null || current === undefined) {
+ return undefined;
+ }
+
+ if (!current.hasOwnProperty(key)) {
+ return undefined;
+ }
+
+ current = current[key];
+ }
+
+ return current;
+}
+
+/**
+ * Check if text contains any binding expressions
+ * @param {string} text - Text to check
+ * @returns {boolean} - True if text contains bindings
+ */
+export function hasBindings(text) {
+ if (!text || typeof text !== 'string') {
+ return false;
+ }
+
+ return /\{[a-zA-Z0-9_.]+\}/.test(text);
+}
+
+/**
+ * Extract all binding paths from text
+ * @param {string} text - Text to analyze
+ * @returns {string[]} - Array of binding paths (e.g., ["owner.name", "vessel.id"])
+ */
+export function extractBindingPaths(text) {
+ if (!text || typeof text !== 'string') {
+ return [];
+ }
+
+ const bindingRegex = /\{([a-zA-Z0-9_.]+)\}/g;
+ const paths = [];
+ let match;
+
+ while ((match = bindingRegex.exec(text)) !== null) {
+ paths.push(match[1]);
+ }
+
+ return paths;
+}
diff --git a/frontend/src/components/ReportEditor/utils/crossroadDetector.js b/frontend/src/components/ReportEditor/utils/crossroadDetector.js
new file mode 100644
index 0000000..60f3bc6
--- /dev/null
+++ b/frontend/src/components/ReportEditor/utils/crossroadDetector.js
@@ -0,0 +1,186 @@
+/**
+ * Crossroad characters for different line intersections
+ */
+const CROSSROAD_CHARS = {
+ single: {
+ cross: '┼', // All 4 directions
+ tTop: '┴', // Left, Right, Up (T pointing up)
+ tBottom: '┬', // Left, Right, Down (T pointing down)
+ tLeft: '┤', // Up, Down, Left (T pointing left)
+ tRight: '├', // Up, Down, Right (T pointing right)
+ horizontal: '─', // Left, Right only
+ vertical: '│', // Up, Down only
+ cornerTL: '┌', // Top-left corner (Down, Right)
+ cornerTR: '┐', // Top-right corner (Down, Left)
+ cornerBL: '└', // Bottom-left corner (Up, Right)
+ cornerBR: '┘' // Bottom-right corner (Up, Left)
+ },
+ double: {
+ cross: '╬',
+ tTop: '╩',
+ tBottom: '╦',
+ tLeft: '╣',
+ tRight: '╠',
+ horizontal: '═',
+ vertical: '║',
+ cornerTL: '╔',
+ cornerTR: '╗',
+ cornerBL: '╚',
+ cornerBR: '╝'
+ }
+};
+
+/**
+ * Detect crossroads and return a map of positions to junction characters
+ * @param {Array} elements - Array of report elements (text, frame, hline, vline)
+ * @returns {Object} - Map of 'x,y' positions to junction characters
+ */
+export function detectCrossroads(elements) {
+ // Build occupancy map: { 'x,y': { up, down, left, right, style } }
+ const occupancyMap = {};
+
+ // Process each element and mark occupied positions
+ elements.forEach(element => {
+ if (element.type === 'hline') {
+ // Horizontal line occupies positions from x to x+length
+ for (let i = 0; i < element.length; i++) {
+ const x = element.x + i;
+ const y = element.y;
+ const key = `${x},${y}`;
+
+ if (!occupancyMap[key]) {
+ occupancyMap[key] = { up: false, down: false, left: false, right: false, styles: [] };
+ }
+
+ // Horizontal line means left and right connections
+ if (i > 0) occupancyMap[key].left = true;
+ if (i < element.length - 1) occupancyMap[key].right = true;
+
+ occupancyMap[key].styles.push(element.lineStyle || 'single');
+ }
+ } else if (element.type === 'vline') {
+ // Vertical line occupies positions from y to y+length
+ for (let i = 0; i < element.length; i++) {
+ const x = element.x;
+ const y = element.y + i;
+ const key = `${x},${y}`;
+
+ if (!occupancyMap[key]) {
+ occupancyMap[key] = { up: false, down: false, left: false, right: false, styles: [] };
+ }
+
+ // Vertical line means up and down connections
+ if (i > 0) occupancyMap[key].up = true;
+ if (i < element.length - 1) occupancyMap[key].down = true;
+
+ occupancyMap[key].styles.push(element.lineStyle || 'single');
+ }
+ } else if (element.type === 'frame') {
+ // Frame has borders on all edges
+ const { x, y, width, height, borderStyle } = element;
+ const style = borderStyle || 'single';
+
+ for (let row = 0; row < height; row++) {
+ for (let col = 0; col < width; col++) {
+ const posX = x + col;
+ const posY = y + row;
+ const key = `${posX},${posY}`;
+
+ // Only process border positions (edges of frame)
+ const isTopEdge = row === 0;
+ const isBottomEdge = row === height - 1;
+ const isLeftEdge = col === 0;
+ const isRightEdge = col === width - 1;
+
+ if (isTopEdge || isBottomEdge || isLeftEdge || isRightEdge) {
+ if (!occupancyMap[key]) {
+ occupancyMap[key] = { up: false, down: false, left: false, right: false, styles: [] };
+ }
+
+ // Determine connections based on position in frame
+ if (isTopEdge && col > 0 && col < width - 1) {
+ occupancyMap[key].left = true;
+ occupancyMap[key].right = true;
+ }
+ if (isBottomEdge && col > 0 && col < width - 1) {
+ occupancyMap[key].left = true;
+ occupancyMap[key].right = true;
+ }
+ if (isLeftEdge && row > 0 && row < height - 1) {
+ occupancyMap[key].up = true;
+ occupancyMap[key].down = true;
+ }
+ if (isRightEdge && row > 0 && row < height - 1) {
+ occupancyMap[key].up = true;
+ occupancyMap[key].down = true;
+ }
+
+ // Corners
+ if (isTopEdge && isLeftEdge) {
+ occupancyMap[key].right = true;
+ occupancyMap[key].down = true;
+ }
+ if (isTopEdge && isRightEdge) {
+ occupancyMap[key].left = true;
+ occupancyMap[key].down = true;
+ }
+ if (isBottomEdge && isLeftEdge) {
+ occupancyMap[key].right = true;
+ occupancyMap[key].up = true;
+ }
+ if (isBottomEdge && isRightEdge) {
+ occupancyMap[key].left = true;
+ occupancyMap[key].up = true;
+ }
+
+ occupancyMap[key].styles.push(style);
+ }
+ }
+ }
+ }
+ });
+
+ // Build crossroad map by analyzing each occupied position
+ const crossroadMap = {};
+
+ Object.keys(occupancyMap).forEach(key => {
+ const { up, down, left, right, styles } = occupancyMap[key];
+
+ // Determine the predominant style (prefer double if any double exists)
+ const style = styles.includes('double') ? 'double' : 'single';
+ const chars = CROSSROAD_CHARS[style];
+
+ // Count connections
+ const connectionCount = (up ? 1 : 0) + (down ? 1 : 0) + (left ? 1 : 0) + (right ? 1 : 0);
+
+ // Only create crossroad if there's an intersection (more than 2 connections)
+ if (connectionCount >= 3) {
+ // Determine the appropriate junction character
+ if (up && down && left && right) {
+ crossroadMap[key] = chars.cross;
+ } else if (up && left && right) {
+ crossroadMap[key] = chars.tTop;
+ } else if (down && left && right) {
+ crossroadMap[key] = chars.tBottom;
+ } else if (up && down && left) {
+ crossroadMap[key] = chars.tLeft;
+ } else if (up && down && right) {
+ crossroadMap[key] = chars.tRight;
+ }
+ }
+ });
+
+ return crossroadMap;
+}
+
+/**
+ * Check if a position has a crossroad
+ * @param {Object} crossroadMap - Map of crossroads
+ * @param {number} x - Grid x position
+ * @param {number} y - Grid y position
+ * @returns {string|null} - Crossroad character or null
+ */
+export function getCrossroadChar(crossroadMap, x, y) {
+ const key = `${x},${y}`;
+ return crossroadMap[key] || null;
+}
diff --git a/frontend/tmpclaude-590b-cwd b/frontend/tmpclaude-590b-cwd
deleted file mode 100644
index 9270357..0000000
--- a/frontend/tmpclaude-590b-cwd
+++ /dev/null
@@ -1 +0,0 @@
-/c/dev_projects/ScalesApp/frontend