report editor with text field, lines, frames, and table draw. multiselect etc.
parent
7f04566242
commit
fb1d4ae7ad
@ -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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,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,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,141 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import ResizeHandles from './ResizeHandles';
|
||||||
|
import './elements.css';
|
||||||
|
|
||||||
|
const LINE_CHARS = {
|
||||||
|
single: '│',
|
||||||
|
double: '║'
|
||||||
|
};
|
||||||
|
|
||||||
|
function VerticalLine({
|
||||||
|
element,
|
||||||
|
isSelected,
|
||||||
|
isSingleSelection,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
onUpdate,
|
||||||
|
onDragStart,
|
||||||
|
onDrag,
|
||||||
|
charWidth,
|
||||||
|
charHeight,
|
||||||
|
crossroadMap = {},
|
||||||
|
toolMode
|
||||||
|
}) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragData, setDragData] = useState(null);
|
||||||
|
|
||||||
|
const lineChar = LINE_CHARS[element.lineStyle || 'single'];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyPress = (e) => {
|
||||||
|
if (isSelected && e.key === 'Delete') {
|
||||||
|
onDelete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyPress);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyPress);
|
||||||
|
}, [isSelected, onDelete]);
|
||||||
|
|
||||||
|
const handleClick = (e) => {
|
||||||
|
// Only handle selection in select mode, let other modes bubble to canvas
|
||||||
|
if (toolMode === 'select') {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (e) => {
|
||||||
|
// Only handle dragging in select mode
|
||||||
|
if (toolMode !== 'select') return;
|
||||||
|
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelect(e);
|
||||||
|
|
||||||
|
setIsDragging(true);
|
||||||
|
const data = onDragStart(element.id, e.clientX, e.clientY);
|
||||||
|
setDragData({ ...data, startMouseX: e.clientX, startMouseY: e.clientY });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = (updates) => {
|
||||||
|
onUpdate(updates);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
// Add class to body to disable transitions globally during drag
|
||||||
|
document.body.classList.add('dragging-active');
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (dragData) {
|
||||||
|
const deltaX = e.clientX - dragData.startMouseX;
|
||||||
|
const deltaY = e.clientY - dragData.startMouseY;
|
||||||
|
onDrag(dragData, deltaX, deltaY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
setDragData(null);
|
||||||
|
document.body.classList.remove('dragging-active');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
document.body.classList.remove('dragging-active');
|
||||||
|
};
|
||||||
|
}, [isDragging, dragData, onDrag]);
|
||||||
|
|
||||||
|
// Render line vertically with possible crossroad overrides
|
||||||
|
const renderLine = () => {
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < element.length; i++) {
|
||||||
|
const gridX = element.x;
|
||||||
|
const gridY = element.y + i;
|
||||||
|
const crossroadKey = `${gridX},${gridY}`;
|
||||||
|
|
||||||
|
// Use crossroad character if it exists, otherwise use line character
|
||||||
|
const char = crossroadMap[crossroadKey] || lineChar;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
<div key={i} className="vertical-line-char" style={{ height: `${charHeight}px` }}>
|
||||||
|
{char}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
left: `${element.x * charWidth}px`,
|
||||||
|
top: `${element.y * charHeight}px`,
|
||||||
|
width: `${charWidth}px`,
|
||||||
|
height: `${element.length * charHeight}px`
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`line-element vertical-line ${isSelected ? 'selected' : ''} ${isDragging ? 'dragging' : ''}`}
|
||||||
|
style={style}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
{renderLine()}
|
||||||
|
{isSingleSelection && (
|
||||||
|
<ResizeHandles
|
||||||
|
element={element}
|
||||||
|
onResize={handleResize}
|
||||||
|
charWidth={charWidth}
|
||||||
|
charHeight={charHeight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerticalLine;
|
||||||
@ -0,0 +1,228 @@
|
|||||||
|
.text-field {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-sizing: content-box;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
overflow: hidden;
|
||||||
|
letter-spacing: -1.2px;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
will-change: auto;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transitions when any element is being dragged */
|
||||||
|
body.dragging-active .text-field {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field:hover {
|
||||||
|
border-color: rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field.selected {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: rgba(102, 126, 234, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field.dragging {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field.has-bindings {
|
||||||
|
background: rgba(102, 182, 234, 0.1);
|
||||||
|
border-color: rgba(102, 182, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field.has-bindings:hover {
|
||||||
|
border-color: rgba(102, 182, 234, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field-content {
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 100%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-field-input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre;
|
||||||
|
letter-spacing: -1.2px;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
-ms-user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-element {
|
||||||
|
position: absolute;
|
||||||
|
cursor: default;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-sizing: content-box;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
white-space: pre;
|
||||||
|
letter-spacing: -1.2px;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
will-change: auto;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
overflow: visible;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transitions when any element is being dragged */
|
||||||
|
body.dragging-active .frame-element {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-element:hover {
|
||||||
|
/* Hover effect removed - handled by JavaScript based on border detection */
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-element.selected {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: rgba(102, 126, 234, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-element.dragging {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-line {
|
||||||
|
white-space: pre;
|
||||||
|
overflow: visible;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line elements */
|
||||||
|
.line-element {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-sizing: content-box;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
white-space: pre;
|
||||||
|
letter-spacing: -1.2px;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
will-change: auto;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transitions when any element is being dragged */
|
||||||
|
body.dragging-active .line-element {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-element:hover {
|
||||||
|
border-color: rgba(102, 182, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-element.selected {
|
||||||
|
border-color: #66b6ea;
|
||||||
|
background: rgba(102, 182, 234, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-element.dragging {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical-line-char {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Symbol elements */
|
||||||
|
.symbol-element {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
box-sizing: content-box;
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
white-space: pre;
|
||||||
|
letter-spacing: -1.2px;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
will-change: auto;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transitions when any element is being dragged */
|
||||||
|
body.dragging-active .symbol-element {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol-element:hover {
|
||||||
|
border-color: rgba(102, 182, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol-element.selected {
|
||||||
|
border-color: #66b6ea;
|
||||||
|
background: rgba(102, 182, 234, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol-element.dragging {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handles */
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: orange;
|
||||||
|
border: 1px solid #ff8800;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover {
|
||||||
|
background: #ff6600;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Parse text content and replace {variable.field} expressions with actual data values
|
||||||
|
* @param {string} text - Text containing binding expressions like "Owner: {owner.name}"
|
||||||
|
* @param {object} data - Data object containing the values to bind
|
||||||
|
* @returns {string} - Text with bindings resolved
|
||||||
|
*/
|
||||||
|
export function parseBindings(text, data) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return text || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex to match {variable.field.nested} patterns
|
||||||
|
const bindingRegex = /\{([a-zA-Z0-9_.]+)\}/g;
|
||||||
|
|
||||||
|
return text.replace(bindingRegex, (match, path) => {
|
||||||
|
const value = resolveNestedPath(data, path);
|
||||||
|
|
||||||
|
// If value is found, return it; otherwise keep the original binding
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return match; // Keep {variable} if not found
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a nested path in an object (e.g., "owner.contact.name")
|
||||||
|
* @param {object} obj - The object to traverse
|
||||||
|
* @param {string} path - Dot-separated path (e.g., "owner.name")
|
||||||
|
* @returns {*} - The resolved value or undefined
|
||||||
|
*/
|
||||||
|
function resolveNestedPath(obj, path) {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current = obj;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current.hasOwnProperty(key)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text contains any binding expressions
|
||||||
|
* @param {string} text - Text to check
|
||||||
|
* @returns {boolean} - True if text contains bindings
|
||||||
|
*/
|
||||||
|
export function hasBindings(text) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /\{[a-zA-Z0-9_.]+\}/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all binding paths from text
|
||||||
|
* @param {string} text - Text to analyze
|
||||||
|
* @returns {string[]} - Array of binding paths (e.g., ["owner.name", "vessel.id"])
|
||||||
|
*/
|
||||||
|
export function extractBindingPaths(text) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindingRegex = /\{([a-zA-Z0-9_.]+)\}/g;
|
||||||
|
const paths = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = bindingRegex.exec(text)) !== null) {
|
||||||
|
paths.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Crossroad characters for different line intersections
|
||||||
|
*/
|
||||||
|
const CROSSROAD_CHARS = {
|
||||||
|
single: {
|
||||||
|
cross: '┼', // All 4 directions
|
||||||
|
tTop: '┴', // Left, Right, Up (T pointing up)
|
||||||
|
tBottom: '┬', // Left, Right, Down (T pointing down)
|
||||||
|
tLeft: '┤', // Up, Down, Left (T pointing left)
|
||||||
|
tRight: '├', // Up, Down, Right (T pointing right)
|
||||||
|
horizontal: '─', // Left, Right only
|
||||||
|
vertical: '│', // Up, Down only
|
||||||
|
cornerTL: '┌', // Top-left corner (Down, Right)
|
||||||
|
cornerTR: '┐', // Top-right corner (Down, Left)
|
||||||
|
cornerBL: '└', // Bottom-left corner (Up, Right)
|
||||||
|
cornerBR: '┘' // Bottom-right corner (Up, Left)
|
||||||
|
},
|
||||||
|
double: {
|
||||||
|
cross: '╬',
|
||||||
|
tTop: '╩',
|
||||||
|
tBottom: '╦',
|
||||||
|
tLeft: '╣',
|
||||||
|
tRight: '╠',
|
||||||
|
horizontal: '═',
|
||||||
|
vertical: '║',
|
||||||
|
cornerTL: '╔',
|
||||||
|
cornerTR: '╗',
|
||||||
|
cornerBL: '╚',
|
||||||
|
cornerBR: '╝'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect crossroads and return a map of positions to junction characters
|
||||||
|
* @param {Array} elements - Array of report elements (text, frame, hline, vline)
|
||||||
|
* @returns {Object} - Map of 'x,y' positions to junction characters
|
||||||
|
*/
|
||||||
|
export function detectCrossroads(elements) {
|
||||||
|
// Build occupancy map: { 'x,y': { up, down, left, right, style } }
|
||||||
|
const occupancyMap = {};
|
||||||
|
|
||||||
|
// Process each element and mark occupied positions
|
||||||
|
elements.forEach(element => {
|
||||||
|
if (element.type === 'hline') {
|
||||||
|
// Horizontal line occupies positions from x to x+length
|
||||||
|
for (let i = 0; i < element.length; i++) {
|
||||||
|
const x = element.x + i;
|
||||||
|
const y = element.y;
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
|
||||||
|
if (!occupancyMap[key]) {
|
||||||
|
occupancyMap[key] = { up: false, down: false, left: false, right: false, styles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal line means left and right connections
|
||||||
|
if (i > 0) occupancyMap[key].left = true;
|
||||||
|
if (i < element.length - 1) occupancyMap[key].right = true;
|
||||||
|
|
||||||
|
occupancyMap[key].styles.push(element.lineStyle || 'single');
|
||||||
|
}
|
||||||
|
} else if (element.type === 'vline') {
|
||||||
|
// Vertical line occupies positions from y to y+length
|
||||||
|
for (let i = 0; i < element.length; i++) {
|
||||||
|
const x = element.x;
|
||||||
|
const y = element.y + i;
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
|
||||||
|
if (!occupancyMap[key]) {
|
||||||
|
occupancyMap[key] = { up: false, down: false, left: false, right: false, styles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical line means up and down connections
|
||||||
|
if (i > 0) occupancyMap[key].up = true;
|
||||||
|
if (i < element.length - 1) occupancyMap[key].down = true;
|
||||||
|
|
||||||
|
occupancyMap[key].styles.push(element.lineStyle || 'single');
|
||||||
|
}
|
||||||
|
} else if (element.type === 'frame') {
|
||||||
|
// Frame has borders on all edges
|
||||||
|
const { x, y, width, height, borderStyle } = element;
|
||||||
|
const style = borderStyle || 'single';
|
||||||
|
|
||||||
|
for (let row = 0; row < height; row++) {
|
||||||
|
for (let col = 0; col < width; col++) {
|
||||||
|
const posX = x + col;
|
||||||
|
const posY = y + row;
|
||||||
|
const key = `${posX},${posY}`;
|
||||||
|
|
||||||
|
// Only process border positions (edges of frame)
|
||||||
|
const isTopEdge = row === 0;
|
||||||
|
const isBottomEdge = row === height - 1;
|
||||||
|
const isLeftEdge = col === 0;
|
||||||
|
const isRightEdge = col === width - 1;
|
||||||
|
|
||||||
|
if (isTopEdge || isBottomEdge || isLeftEdge || isRightEdge) {
|
||||||
|
if (!occupancyMap[key]) {
|
||||||
|
occupancyMap[key] = { up: false, down: false, left: false, right: false, styles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine connections based on position in frame
|
||||||
|
if (isTopEdge && col > 0 && col < width - 1) {
|
||||||
|
occupancyMap[key].left = true;
|
||||||
|
occupancyMap[key].right = true;
|
||||||
|
}
|
||||||
|
if (isBottomEdge && col > 0 && col < width - 1) {
|
||||||
|
occupancyMap[key].left = true;
|
||||||
|
occupancyMap[key].right = true;
|
||||||
|
}
|
||||||
|
if (isLeftEdge && row > 0 && row < height - 1) {
|
||||||
|
occupancyMap[key].up = true;
|
||||||
|
occupancyMap[key].down = true;
|
||||||
|
}
|
||||||
|
if (isRightEdge && row > 0 && row < height - 1) {
|
||||||
|
occupancyMap[key].up = true;
|
||||||
|
occupancyMap[key].down = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corners
|
||||||
|
if (isTopEdge && isLeftEdge) {
|
||||||
|
occupancyMap[key].right = true;
|
||||||
|
occupancyMap[key].down = true;
|
||||||
|
}
|
||||||
|
if (isTopEdge && isRightEdge) {
|
||||||
|
occupancyMap[key].left = true;
|
||||||
|
occupancyMap[key].down = true;
|
||||||
|
}
|
||||||
|
if (isBottomEdge && isLeftEdge) {
|
||||||
|
occupancyMap[key].right = true;
|
||||||
|
occupancyMap[key].up = true;
|
||||||
|
}
|
||||||
|
if (isBottomEdge && isRightEdge) {
|
||||||
|
occupancyMap[key].left = true;
|
||||||
|
occupancyMap[key].up = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
occupancyMap[key].styles.push(style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build crossroad map by analyzing each occupied position
|
||||||
|
const crossroadMap = {};
|
||||||
|
|
||||||
|
Object.keys(occupancyMap).forEach(key => {
|
||||||
|
const { up, down, left, right, styles } = occupancyMap[key];
|
||||||
|
|
||||||
|
// Determine the predominant style (prefer double if any double exists)
|
||||||
|
const style = styles.includes('double') ? 'double' : 'single';
|
||||||
|
const chars = CROSSROAD_CHARS[style];
|
||||||
|
|
||||||
|
// Count connections
|
||||||
|
const connectionCount = (up ? 1 : 0) + (down ? 1 : 0) + (left ? 1 : 0) + (right ? 1 : 0);
|
||||||
|
|
||||||
|
// Only create crossroad if there's an intersection (more than 2 connections)
|
||||||
|
if (connectionCount >= 3) {
|
||||||
|
// Determine the appropriate junction character
|
||||||
|
if (up && down && left && right) {
|
||||||
|
crossroadMap[key] = chars.cross;
|
||||||
|
} else if (up && left && right) {
|
||||||
|
crossroadMap[key] = chars.tTop;
|
||||||
|
} else if (down && left && right) {
|
||||||
|
crossroadMap[key] = chars.tBottom;
|
||||||
|
} else if (up && down && left) {
|
||||||
|
crossroadMap[key] = chars.tLeft;
|
||||||
|
} else if (up && down && right) {
|
||||||
|
crossroadMap[key] = chars.tRight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return crossroadMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a position has a crossroad
|
||||||
|
* @param {Object} crossroadMap - Map of crossroads
|
||||||
|
* @param {number} x - Grid x position
|
||||||
|
* @param {number} y - Grid y position
|
||||||
|
* @returns {string|null} - Crossroad character or null
|
||||||
|
*/
|
||||||
|
export function getCrossroadChar(crossroadMap, x, y) {
|
||||||
|
const key = `${x},${y}`;
|
||||||
|
return crossroadMap[key] || null;
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
/c/dev_projects/ScalesApp/frontend
|
|
||||||
Loading…
Reference in New Issue