report editor with text field, lines, frames, and table draw. multiselect etc.

master
kikimor 1 month ago
parent 7f04566242
commit fb1d4ae7ad

@ -1,7 +1,10 @@
{
"permissions": {
"allow": [
"Bash(python manage.py migrate:*)"
"Bash(python manage.py migrate:*)",
"Bash(tree:*)",
"Bash(dir /b \"c:\\\\dev_projects\\\\ScalesApp\\\\frontend\\\\src\\\\contexts\")",
"Bash(ls:*)"
]
}
}

4
.gitignore vendored

@ -57,3 +57,7 @@ yarn-error.log*
# Environment
.env
.env.local
# Claude Code temporary files
tmpclaude-*
nul

@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
// import ProtectedRoute from './components/ProtectedRoute';
import Login from './components/Users/Login';
import Main from './components/Main';
import ReportEditor from './components/ReportEditor/ReportEditor';
import './App.css';
import { NomenclatureProvider } from './contexts/NomenclatureContext';
@ -54,7 +55,10 @@ function AppContent() {
return (
<NomenclatureProvider>
<Main />
<Routes>
<Route path="/" element={<Main />} />
<Route path="/report-editor" element={<ReportEditor />} />
</Routes>
</NomenclatureProvider>
);
}

@ -17,12 +17,39 @@
align-items: center;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.header-left h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.nav-button {
width: 42px;
height: 42px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
font-size: 20px;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.nav-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.header-right {
display: flex;
align-items: center;

@ -1,10 +1,12 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
import './Header.css';
function Header() {
const { currentUser, logout } = useAuth();
const navigate = useNavigate();
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
const getInitials = (user) => {
@ -23,6 +25,13 @@ function Header() {
<header className="app-header">
<div className="header-content">
<div className="header-left">
<button
className="nav-button"
onClick={() => navigate('/report-editor')}
title="Report Editor"
>
📝
</button>
<h1>ScalesApp - Real-time Data Monitor</h1>
</div>
<div className="header-right">

@ -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;
}

@ -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 (
<div className="character-palette">
<div className="palette-header">
<h3>Character Palette</h3>
<button className="close-button" onClick={onClose} title="Close (Esc)"></button>
</div>
<div className="palette-content">
{Object.entries(SYMBOL_PALETTE).map(([category, chars]) => (
<div key={category} className="palette-category">
<div
className={`category-header ${expandedCategory === category ? 'expanded' : ''}`}
onClick={() => toggleCategory(category)}
>
<span className="category-icon">{expandedCategory === category ? '▼' : '▶'}</span>
<span className="category-name">{category}</span>
</div>
{expandedCategory === category && (
<div className="character-grid">
{Object.entries(chars).map(([name, char]) => (
<div
key={name}
className={`char-button ${selectedChar === char ? 'active' : ''}`}
onClick={() => onSelectChar(char)}
title={name.replace(/([A-Z])/g, ' $1').trim()}
>
{char}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
export default CharacterPalette;

@ -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;
}

@ -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 (
<div className="config-panel-overlay" onClick={onClose}>
<div className="config-panel" onClick={(e) => e.stopPropagation()}>
<div className="config-panel-header">
<h2>Report Configuration</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
<div className="config-panel-content">
{/* API Endpoint Section */}
<div className="config-section">
<h3>API Endpoint</h3>
<div className="input-group">
<input
type="text"
className="config-input"
placeholder="http://localhost:8000/api/report-data"
value={localEndpoint}
onChange={(e) => setLocalEndpoint(e.target.value)}
disabled={isLoading}
/>
<button
className="config-button primary"
onClick={handleFetch}
disabled={isLoading || !localEndpoint.trim()}
>
{isLoading ? 'Fetching...' : 'Fetch Data'}
</button>
</div>
{error && (
<div className="config-error">
<strong>Error:</strong> {error}
</div>
)}
</div>
{/* Manual Data Input Section */}
<div className="config-section">
<button
className="config-button"
onClick={() => {
if (!showManualInput && !manualDataInput) {
// Pre-fill with example data when opening for the first time
setManualDataInput(JSON.stringify({
"owner": {
"name": "John Doe",
"contact": {
"phone": "555-1234"
}
},
"vessel": {
"id": "ABC123",
"type": "Truck"
},
"weight": 1500,
"timestamp": "2026-01-23T10:00:00"
}, null, 2));
}
setShowManualInput(!showManualInput);
}}
>
{showManualInput ? 'Hide' : 'Enter'} Manual Data (JSON)
</button>
{showManualInput && (
<div className="manual-input-section">
<textarea
className="manual-data-input"
placeholder='{"owner": {"name": "John Doe"}, "vessel": {"id": "ABC123"}}'
value={manualDataInput}
onChange={(e) => setManualDataInput(e.target.value)}
rows={8}
/>
<button
className="config-button primary"
onClick={handleSetManualData}
>
Set Data
</button>
</div>
)}
</div>
{/* Data Preview Section */}
{reportData && (
<div className="config-section">
<div className="section-header">
<h3>Current Data</h3>
<button className="config-button small" onClick={handleClear}>
Clear Data
</button>
</div>
<div className="data-preview">
<pre>{JSON.stringify(reportData, null, 2)}</pre>
</div>
</div>
)}
{!reportData && !isLoading && !error && (
<div className="config-info">
No data loaded. Enter an API endpoint to fetch data, or use manual input.
</div>
)}
</div>
</div>
</div>
);
}
export default ConfigPanel;

@ -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 (
<DataContext.Provider value={value}>
{children}
</DataContext.Provider>
);
}
export default DataContext;

@ -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;
}

@ -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(
<div
key={`v-${i}`}
className="grid-line vertical"
style={{ left: `${i * (CHAR_WIDTH)}px` }}
/>
);
}
// Horizontal lines
for (let i = 0; i <= GRID_ROWS; i++) {
gridLines.push(
<div
key={`h-${i}`}
className="grid-line horizontal"
style={{ top: `${i * CHAR_HEIGHT}px` }}
/>
);
}
return gridLines;
};
// Calculate crossroad map for intersection rendering
const crossroadMap = useMemo(() => {
return detectCrossroads(elements);
}, [elements]);
const showCrosshair = toolMode === 'addFrame' || toolMode === 'addHLine' || toolMode === 'addVLine';
return (
<div className="canvas-container">
<div
ref={canvasRef}
className={`editor-canvas ${showCrosshair ? 'crosshair' : ''}`}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleMouseMove}
>
{renderGrid()}
{/* Render selection rectangle */}
{selectionRect && isSelecting && (
<div
className="selection-rectangle"
style={{
left: `${Math.min(selectionRect.startX, selectionRect.endX) * CHAR_WIDTH}px`,
top: `${Math.min(selectionRect.startY, selectionRect.endY) * CHAR_HEIGHT}px`,
width: `${(Math.abs(selectionRect.endX - selectionRect.startX) + 1) * CHAR_WIDTH}px`,
height: `${(Math.abs(selectionRect.endY - selectionRect.startY) + 1) * CHAR_HEIGHT}px`
}}
/>
)}
{/* Render frame preview */}
{framePreview && (
<div
className="frame-preview"
style={{
left: `${framePreview.x * CHAR_WIDTH}px`,
top: `${framePreview.y * CHAR_HEIGHT}px`,
width: `${framePreview.width * CHAR_WIDTH}px`,
height: `${framePreview.height * CHAR_HEIGHT}px`
}}
/>
)}
{/* Render line preview */}
{linePreview && (
<div
className="line-preview"
style={{
left: `${linePreview.x * CHAR_WIDTH}px`,
top: `${linePreview.y * CHAR_HEIGHT}px`,
width: linePreview.type === 'hline' ? `${linePreview.length * CHAR_WIDTH}px` : `${CHAR_WIDTH}px`,
height: linePreview.type === 'vline' ? `${linePreview.length * CHAR_HEIGHT}px` : `${CHAR_HEIGHT}px`
}}
/>
)}
{/* Render elements */}
{elements.map(element => {
if (element.type === 'text') {
return (
<TextField
key={element.id}
element={element}
isSelected={selectedElementIds.includes(element.id)}
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
onUpdate={(updates) => onElementUpdate(element.id, updates)}
onDelete={() => onElementDelete(element.id)}
onDragStart={handleElementDragStart}
onDrag={handleElementDrag}
charWidth={CHAR_WIDTH}
charHeight={CHAR_HEIGHT}
previewMode={previewMode}
toolMode={toolMode}
/>
);
} else if (element.type === 'frame') {
return (
<FrameElement
key={element.id}
element={element}
isSelected={selectedElementIds.includes(element.id)}
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
onDelete={() => onElementDelete(element.id)}
onUpdate={(updates) => onElementUpdate(element.id, updates)}
onDragStart={handleElementDragStart}
onDrag={handleElementDrag}
charWidth={CHAR_WIDTH}
charHeight={CHAR_HEIGHT}
toolMode={toolMode}
/>
);
} else if (element.type === 'hline') {
return (
<HorizontalLine
key={element.id}
element={element}
isSelected={selectedElementIds.includes(element.id)}
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
onDelete={() => onElementDelete(element.id)}
onUpdate={(updates) => onElementUpdate(element.id, updates)}
onDragStart={handleElementDragStart}
onDrag={handleElementDrag}
charWidth={CHAR_WIDTH}
charHeight={CHAR_HEIGHT}
crossroadMap={crossroadMap}
toolMode={toolMode}
/>
);
} else if (element.type === 'vline') {
return (
<VerticalLine
key={element.id}
element={element}
isSelected={selectedElementIds.includes(element.id)}
isSingleSelection={selectedElementIds.length === 1 && selectedElementIds[0] === element.id}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
onDelete={() => onElementDelete(element.id)}
onUpdate={(updates) => onElementUpdate(element.id, updates)}
onDragStart={handleElementDragStart}
onDrag={handleElementDrag}
charWidth={CHAR_WIDTH}
charHeight={CHAR_HEIGHT}
crossroadMap={crossroadMap}
toolMode={toolMode}
/>
);
} else if (element.type === 'symbol') {
return (
<SymbolElement
key={element.id}
element={element}
isSelected={selectedElementIds.includes(element.id)}
onSelect={(e) => onElementSelect(element.id, e?.ctrlKey || e?.metaKey)}
onDelete={() => onElementDelete(element.id)}
onDragStart={handleElementDragStart}
onDrag={handleElementDrag}
charWidth={CHAR_WIDTH}
charHeight={CHAR_HEIGHT}
toolMode={toolMode}
/>
);
}
return null;
})}
</div>
</div>
);
}
export default EditorCanvas;

@ -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(
<div key={row} className="frame-line" style={{ height: `${charHeight}px` }}>
{line}
</div>
);
}
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 (
<div
className={`frame-element ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={() => setIsHoveringBorder(false)}
>
{renderFrame()}
{isSingleSelection && (
<ResizeHandles
element={element}
onResize={handleResize}
charWidth={charWidth}
charHeight={charHeight}
/>
)}
</div>
);
}
export default FrameElement;

@ -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 (
<div
className={`line-element horizontal-line ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{renderLine()}
{isSingleSelection && (
<ResizeHandles
element={element}
onResize={handleResize}
charWidth={charWidth}
charHeight={charHeight}
/>
)}
</div>
);
}
export default HorizontalLine;

@ -0,0 +1,6 @@
.report-editor {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}

@ -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 (
<div className="report-editor">
<Toolbar
toolMode={toolMode}
onToolChange={handleToolChange}
borderStyle={borderStyle}
onBorderStyleChange={setBorderStyle}
previewMode={previewMode}
onTogglePreview={handleTogglePreview}
onConfigureAPI={() => setShowConfigPanel(true)}
/>
<EditorCanvas
elements={report.elements}
selectedElementIds={selectedElementIds}
onElementSelect={handleSelectElement}
onSelectMultiple={handleSelectMultiple}
onDeselectAll={handleDeselectAll}
onElementUpdate={handleElementUpdate}
onElementDelete={handleElementDelete}
toolMode={toolMode}
borderStyle={borderStyle}
onAddElement={handleAddElement}
previewMode={previewMode}
selectedChar={selectedChar}
/>
<ConfigPanel
apiEndpoint={report.apiEndpoint}
onApiEndpointChange={handleApiEndpointChange}
isOpen={showConfigPanel}
onClose={() => setShowConfigPanel(false)}
/>
{showCharacterPalette && (
<CharacterPalette
selectedChar={selectedChar}
onSelectChar={setSelectedChar}
onClose={() => {
setShowCharacterPalette(false);
setToolMode('select');
}}
/>
)}
</div>
);
}
function ReportEditor() {
return (
<DataProvider>
<ReportEditorContent />
</DataProvider>
);
}
export default ReportEditor;

@ -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 => (
<div
key={handle}
className="resize-handle"
style={{
...getHandlePosition(handle),
cursor: getCursor(handle)
}}
onMouseDown={(e) => handleMouseDown(e, handle)}
/>
))}
</>
);
}
export default ResizeHandles;

@ -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 (
<div
className={`symbol-element ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{element.char}
</div>
);
}
export default SymbolElement;

@ -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 (
<div
className={`text-field ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''} ${fieldHasBindings && !previewMode ? 'has-bindings' : ''}`}
style={style}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
>
{isEditing ? (
<input
ref={inputRef}
type="text"
className="text-field-input"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
style={{ width: `${Math.max(editContent.length, 5) * charWidth}px` }}
/>
) : (
<span
className="text-field-content"
style={{ whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap' }}
>
{displayContent}
</span>
)}
{isSingleSelection && !isEditing && (
<ResizeHandles
element={element}
onResize={handleResize}
charWidth={charWidth}
charHeight={charHeight}
/>
)}
</div>
);
}
export default TextField;

@ -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);
}

@ -0,0 +1,100 @@
import React from 'react';
import './Toolbar.css';
function Toolbar({
toolMode,
onToolChange,
borderStyle,
onBorderStyleChange,
previewMode = false,
onTogglePreview,
onConfigureAPI
}) {
return (
<div className="toolbar">
<div className="toolbar-group">
<button
className={`toolbar-button ${toolMode === 'select' ? 'active' : ''}`}
onClick={() => onToolChange('select')}
title="Select Tool"
>
Select
</button>
<button
className={`toolbar-button ${toolMode === 'addText' ? 'active' : ''}`}
onClick={() => onToolChange('addText')}
title="Add Text Field"
>
T Add Text
</button>
<button
className={`toolbar-button ${toolMode === 'addFrame' ? 'active' : ''}`}
onClick={() => onToolChange('addFrame')}
title="Add Frame"
>
Add Frame
</button>
<button
className={`toolbar-button ${toolMode === 'addHLine' ? 'active' : ''}`}
onClick={() => onToolChange('addHLine')}
title="Add Horizontal Line"
>
H-Line
</button>
<button
className={`toolbar-button ${toolMode === 'addVLine' ? 'active' : ''}`}
onClick={() => onToolChange('addVLine')}
title="Add Vertical Line"
>
V-Line
</button>
<button
className={`toolbar-button ${toolMode === 'drawTable' ? 'active' : ''}`}
onClick={() => onToolChange('drawTable')}
title="Draw Table (D)"
>
Draw
</button>
</div>
<div className="toolbar-separator"></div>
<div className="toolbar-group">
<label className="toolbar-label">Line Style:</label>
<button
className={`toolbar-button ${borderStyle === 'single' ? 'active' : ''}`}
onClick={() => onBorderStyleChange('single')}
title="Single Line"
>
Single
</button>
<button
className={`toolbar-button ${borderStyle === 'double' ? 'active' : ''}`}
onClick={() => onBorderStyleChange('double')}
title="Double Line"
>
Double
</button>
</div>
<div className="toolbar-separator"></div>
<div className="toolbar-group">
<button
className={`toolbar-button ${previewMode ? 'active' : ''}`}
onClick={onTogglePreview}
title="Toggle Preview Mode"
>
{previewMode ? '👁️ Design Mode' : '📝 Preview Mode'}
</button>
{onConfigureAPI && (
<button
className="toolbar-button"
onClick={onConfigureAPI}
title="Configure API Endpoint"
>
Configure
</button>
)}
</div>
</div>
);
}
export default Toolbar;

@ -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(
<div key={i} className="vertical-line-char" style={{ height: `${charHeight}px` }}>
{char}
</div>
);
}
return lines;
};
const style = {
left: `${element.x * charWidth}px`,
top: `${element.y * charHeight}px`,
width: `${charWidth}px`,
height: `${element.length * charHeight}px`
};
return (
<div
className={`line-element vertical-line ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
style={style}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{renderLine()}
{isSingleSelection && (
<ResizeHandles
element={element}
onResize={handleResize}
charWidth={charWidth}
charHeight={charHeight}
/>
)}
</div>
);
}
export default VerticalLine;

@ -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);
}

@ -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;
}

@ -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;
}

@ -1 +0,0 @@
/c/dev_projects/ScalesApp/frontend
Loading…
Cancel
Save