report editor with text field, lines, frames, and table draw. multiselect etc.
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,3 +57,7 @@ yarn-error.log*
|
|||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# Claude Code temporary files
|
||||||
|
tmpclaude-*
|
||||||
|
nul
|
||||||
|
|||||||
+5
-1
@@ -4,6 +4,7 @@ import { AuthProvider, useAuth } from './contexts/AuthContext';
|
|||||||
// import ProtectedRoute from './components/ProtectedRoute';
|
// import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import Login from './components/Users/Login';
|
import Login from './components/Users/Login';
|
||||||
import Main from './components/Main';
|
import Main from './components/Main';
|
||||||
|
import ReportEditor from './components/ReportEditor/ReportEditor';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
import { NomenclatureProvider } from './contexts/NomenclatureContext';
|
||||||
|
|
||||||
@@ -54,7 +55,10 @@ function AppContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NomenclatureProvider>
|
<NomenclatureProvider>
|
||||||
<Main />
|
<Routes>
|
||||||
|
<Route path="/" element={<Main />} />
|
||||||
|
<Route path="/report-editor" element={<ReportEditor />} />
|
||||||
|
</Routes>
|
||||||
</NomenclatureProvider>
|
</NomenclatureProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,39 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header-left h1 {
|
.header-left h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
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 {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
|
import ChangePasswordOverlay from './Users/ChangePasswordOverlay';
|
||||||
import './Header.css';
|
import './Header.css';
|
||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
const { currentUser, logout } = useAuth();
|
const { currentUser, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
|
const [showPasswordOverlay, setShowPasswordOverlay] = useState(false);
|
||||||
|
|
||||||
const getInitials = (user) => {
|
const getInitials = (user) => {
|
||||||
@@ -23,6 +25,13 @@ function Header() {
|
|||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="header-content">
|
<div className="header-content">
|
||||||
<div className="header-left">
|
<div className="header-left">
|
||||||
|
<button
|
||||||
|
className="nav-button"
|
||||||
|
onClick={() => navigate('/report-editor')}
|
||||||
|
title="Report Editor"
|
||||||
|
>
|
||||||
|
📝
|
||||||
|
</button>
|
||||||
<h1>ScalesApp - Real-time Data Monitor</h1>
|
<h1>ScalesApp - Real-time Data Monitor</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<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
|
|
||||||
Reference in New Issue
Block a user