mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-21 05:40:26 -05:00
d9e7e82ab2
Add ability to manually trigger scheduled reports for testing purposes without modifying cron schedules. - Add API endpoint /api/reports/scheduled/<id>/trigger to manually execute a scheduled report - Add 'Trigger Now' button (play icon) in scheduled reports table for active schedules - Add JavaScript handler with loading state and user feedback - Include CSRF token protection for the trigger endpoint This feature allows users to test scheduled reports immediately without waiting for the next scheduled run or modifying cron expressions.
7670 lines
325 KiB
HTML
7670 lines
325 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ _('PDF Invoice Designer') }}{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
/* Main Layout */
|
|
.designer-layout {
|
|
display: grid;
|
|
grid-template-columns: 250px 1fr 400px;
|
|
gap: 1rem;
|
|
min-height: 700px;
|
|
}
|
|
|
|
@media (max-width: 1536px) {
|
|
.designer-layout {
|
|
grid-template-columns: 200px 1fr 350px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 1280px) {
|
|
.designer-layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.sidebar, .properties-panel {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* Sidebar - Elements */
|
|
.sidebar {
|
|
background: #FFFFFF;
|
|
border: 1px solid #E2E8F0;
|
|
border-radius: 0.75rem;
|
|
padding: 1.5rem;
|
|
color: #1F2937;
|
|
overflow-y: auto;
|
|
max-height: 700px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
}
|
|
|
|
.dark .sidebar {
|
|
background: #1F2937;
|
|
border-color: #4A5568;
|
|
color: #E2E8F0;
|
|
}
|
|
|
|
.sidebar h3 {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin: 0 0 1rem 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: #1F2937;
|
|
}
|
|
|
|
.dark .sidebar h3 {
|
|
color: #E2E8F0;
|
|
}
|
|
|
|
/* Search Box */
|
|
.search-box {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.search-box input {
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #E2E8F0;
|
|
background: #FFFFFF;
|
|
color: #1F2937;
|
|
font-size: 0.875rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.dark .search-box input {
|
|
background: #2D3748;
|
|
border-color: #4A5568;
|
|
color: #E2E8F0;
|
|
}
|
|
|
|
.search-box input::placeholder {
|
|
color: #A0AEC0;
|
|
}
|
|
|
|
.dark .search-box input::placeholder {
|
|
color: #718096;
|
|
}
|
|
|
|
.search-box input:focus {
|
|
outline: none;
|
|
border-color: #3B82F6;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
}
|
|
|
|
.dark .search-box input:focus {
|
|
border-color: #3B82F6;
|
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
|
|
}
|
|
|
|
/* Tabs */
|
|
.sidebar-tabs {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
border-bottom: 2px solid #E2E8F0;
|
|
}
|
|
|
|
.dark .sidebar-tabs {
|
|
border-bottom-color: #4A5568;
|
|
}
|
|
|
|
.sidebar-tab {
|
|
padding: 0.5rem 1rem;
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -2px;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
transition: all 0.2s;
|
|
color: #6B7280;
|
|
}
|
|
|
|
.dark .sidebar-tab {
|
|
color: #9CA3AF;
|
|
}
|
|
|
|
.sidebar-tab:hover {
|
|
color: #1F2937;
|
|
}
|
|
|
|
.dark .sidebar-tab:hover {
|
|
color: #E2E8F0;
|
|
}
|
|
|
|
.sidebar-tab.active {
|
|
color: #3B82F6;
|
|
border-bottom-color: #3B82F6;
|
|
}
|
|
|
|
.dark .sidebar-tab.active {
|
|
color: #60A5FA;
|
|
border-bottom-color: #60A5FA;
|
|
}
|
|
|
|
.sidebar-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.tab-pane {
|
|
display: none;
|
|
}
|
|
|
|
.tab-pane.active {
|
|
display: block;
|
|
}
|
|
|
|
.element-group {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.element-group-title {
|
|
font-size: 0.75rem;
|
|
text-transform: uppercase;
|
|
font-weight: 600;
|
|
color: #6B7280;
|
|
margin-bottom: 0.5rem;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.dark .element-group-title {
|
|
color: #9CA3AF;
|
|
}
|
|
|
|
.element-item {
|
|
background: #F7F9FB;
|
|
border: 1px solid #E2E8F0;
|
|
border-radius: 0.5rem;
|
|
padding: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
cursor: move;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: #1F2937;
|
|
}
|
|
|
|
.dark .element-item {
|
|
background: #2D3748;
|
|
border-color: #4A5568;
|
|
color: #E2E8F0;
|
|
}
|
|
|
|
.element-item:hover {
|
|
background: #EDF2F7;
|
|
border-color: #3B82F6;
|
|
transform: translateX(3px);
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.dark .element-item:hover {
|
|
background: #374151;
|
|
border-color: #60A5FA;
|
|
}
|
|
|
|
.element-item i {
|
|
font-size: 1.25rem;
|
|
color: #3B82F6;
|
|
}
|
|
|
|
.dark .element-item i {
|
|
color: #60A5FA;
|
|
}
|
|
|
|
.element-item span {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Variable Items */
|
|
.variable-item {
|
|
background: #F7F9FB;
|
|
border: 1px solid #E2E8F0;
|
|
border-radius: 0.375rem;
|
|
padding: 0.5rem;
|
|
margin-bottom: 0.5rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.dark .variable-item {
|
|
background: #2D3748;
|
|
border-color: #4A5568;
|
|
}
|
|
|
|
.variable-item:hover {
|
|
background: #EDF2F7;
|
|
border-color: #3B82F6;
|
|
}
|
|
|
|
.dark .variable-item:hover {
|
|
background: #374151;
|
|
border-color: #60A5FA;
|
|
}
|
|
|
|
.variable-name {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.813rem;
|
|
color: #3B82F6;
|
|
margin-bottom: 0.25rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.dark .variable-name {
|
|
color: #60A5FA;
|
|
}
|
|
|
|
.variable-desc {
|
|
font-size: 0.75rem;
|
|
color: #6B7280;
|
|
}
|
|
|
|
.dark .variable-desc {
|
|
color: #9CA3AF;
|
|
}
|
|
|
|
/* Canvas Area */
|
|
.canvas-area {
|
|
background: white;
|
|
border-radius: 0.75rem;
|
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
padding: 1.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.canvas-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 1rem;
|
|
padding: 1rem;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 2px solid #e5e7eb;
|
|
background: #f9fafb;
|
|
border-radius: 0.5rem 0.5rem 0 0;
|
|
flex-wrap: wrap;
|
|
gap: 1rem;
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.canvas-header {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.canvas-header h3 {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
}
|
|
|
|
.dark .canvas-header {
|
|
background: #374151;
|
|
border-bottom-color: #4b5563;
|
|
}
|
|
|
|
.dark .canvas-header h3 {
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.canvas-header h3 {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.canvas-header-controls {
|
|
display: flex;
|
|
gap: 1rem;
|
|
align-items: flex-start;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.canvas-header-controls {
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
width: 100%;
|
|
}
|
|
}
|
|
|
|
.page-size-selector-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.page-size-selector-group {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
width: 100%;
|
|
}
|
|
|
|
.page-size-help {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.page-size-label {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: #374151;
|
|
margin: 0;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.page-size-label {
|
|
color: #d1d5db;
|
|
}
|
|
}
|
|
|
|
.page-size-select {
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.375rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
background: white;
|
|
color: #1f2937;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.dark .page-size-select {
|
|
background: #374151;
|
|
border-color: #4b5563;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.dark .page-size-select:hover {
|
|
background: #4b5563;
|
|
border-color: #6b7280;
|
|
}
|
|
|
|
.dark .page-size-select:focus {
|
|
border-color: #667eea;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
|
}
|
|
|
|
.page-size-select:hover {
|
|
border-color: #9ca3af;
|
|
background: #f9fafb;
|
|
}
|
|
|
|
.page-size-select:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
}
|
|
|
|
.canvas-toolbar {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.canvas-toolbar {
|
|
width: 100%;
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.canvas-toolbar button {
|
|
min-width: 36px;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.canvas-toolbar #zoom-display {
|
|
margin-left: 0.5rem !important;
|
|
font-size: 0.813rem !important;
|
|
}
|
|
}
|
|
|
|
.canvas-toolbar button {
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #e5e7eb;
|
|
background: white;
|
|
color: #1f2937;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.dark .canvas-toolbar button {
|
|
background: #374151;
|
|
border-color: #4b5563;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.dark .canvas-toolbar button:hover {
|
|
background: #4b5563;
|
|
border-color: #667eea;
|
|
}
|
|
|
|
.canvas-toolbar button:hover {
|
|
background: #f3f4f6;
|
|
border-color: #667eea;
|
|
}
|
|
|
|
#canvas-container {
|
|
flex: 1;
|
|
border: 2px dashed #e5e7eb;
|
|
border-radius: 0.5rem;
|
|
position: relative;
|
|
background: #fafafa;
|
|
overflow: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 600px;
|
|
padding: 20px;
|
|
}
|
|
|
|
#canvas-container > div {
|
|
margin: auto;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
background: white;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Preview Modal - Modern Redesign */
|
|
.preview-modal {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 9999;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: opacity 0.2s ease, visibility 0.2s ease;
|
|
}
|
|
|
|
.preview-modal[style*="display: block"] {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
|
|
.preview-modal-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.75);
|
|
backdrop-filter: blur(4px);
|
|
animation: fadeIn 0.2s ease;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
.preview-modal-content {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 95%;
|
|
max-width: min(1600px, 98vw);
|
|
max-height: 95vh;
|
|
background: white;
|
|
border-radius: 1rem;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
animation: slideUp 0.3s ease;
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from {
|
|
transform: translate(-50%, -45%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translate(-50%, -50%);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.dark .preview-modal-content {
|
|
background: #1f2937;
|
|
color: #f9fafb;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
/* Enhanced Header */
|
|
.preview-modal-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
background: #f9fafb;
|
|
gap: 1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dark .preview-modal-header {
|
|
border-bottom-color: #4b5563;
|
|
background: #111827;
|
|
}
|
|
|
|
.preview-header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.preview-modal-header h3 {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
color: #1f2937;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.dark .preview-modal-header h3 {
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.preview-page-size-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 0.25rem 0.75rem;
|
|
background: #667eea;
|
|
color: white;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.dark .preview-page-size-badge {
|
|
background: #7c3aed;
|
|
}
|
|
|
|
/* Toolbar */
|
|
.preview-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.preview-toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0 0.75rem;
|
|
border-right: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.dark .preview-toolbar-group {
|
|
border-right-color: #4b5563;
|
|
}
|
|
|
|
.preview-toolbar-group:last-child {
|
|
border-right: none;
|
|
padding-right: 0;
|
|
}
|
|
|
|
.preview-toolbar-select {
|
|
padding: 0.375rem 0.75rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.5rem;
|
|
background: white;
|
|
color: #1f2937;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.preview-toolbar-select:hover {
|
|
border-color: #667eea;
|
|
}
|
|
|
|
.preview-toolbar-select:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
}
|
|
|
|
.dark .preview-toolbar-select {
|
|
background: #374151;
|
|
border-color: #4b5563;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.dark .preview-toolbar-select:hover {
|
|
border-color: #7c3aed;
|
|
}
|
|
|
|
.preview-toolbar-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
padding: 0;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.5rem;
|
|
background: white;
|
|
color: #6b7280;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.preview-toolbar-btn:hover {
|
|
background: #f3f4f6;
|
|
border-color: #9ca3af;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.preview-toolbar-btn:active {
|
|
transform: scale(0.95);
|
|
}
|
|
|
|
.dark .preview-toolbar-btn {
|
|
background: #374151;
|
|
border-color: #4b5563;
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.dark .preview-toolbar-btn:hover {
|
|
background: #4b5563;
|
|
border-color: #6b7280;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.preview-modal-close {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 2.5rem;
|
|
height: 2.5rem;
|
|
padding: 0;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
background: transparent;
|
|
color: #6b7280;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 1.25rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.preview-modal-close:hover {
|
|
background: #f3f4f6;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.preview-modal-close:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
}
|
|
|
|
.dark .preview-modal-close {
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.dark .preview-modal-close:hover {
|
|
background: #374151;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
/* Enhanced Body */
|
|
.preview-modal-body {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
background: #e5e7eb;
|
|
min-height: 0;
|
|
}
|
|
|
|
.dark .preview-modal-body {
|
|
background: #1f2937;
|
|
}
|
|
|
|
/* Controls Bar */
|
|
.preview-controls-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.75rem 1rem;
|
|
background: white;
|
|
border-bottom: 1px solid #e5e7eb;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dark .preview-controls-bar {
|
|
background: #111827;
|
|
border-bottom-color: #4b5563;
|
|
}
|
|
|
|
.preview-controls-left,
|
|
.preview-controls-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.preview-control-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.5rem;
|
|
background: white;
|
|
color: #6b7280;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.preview-control-btn:hover {
|
|
background: #f3f4f6;
|
|
border-color: #9ca3af;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.preview-control-btn:active {
|
|
transform: scale(0.98);
|
|
}
|
|
|
|
.preview-control-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.preview-control-btn-active {
|
|
background: #667eea !important;
|
|
border-color: #667eea !important;
|
|
color: white !important;
|
|
}
|
|
|
|
.preview-control-btn-active:hover {
|
|
background: #5568d3 !important;
|
|
border-color: #5568d3 !important;
|
|
}
|
|
|
|
.dark .preview-control-btn {
|
|
background: #374151;
|
|
border-color: #4b5563;
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.dark .preview-control-btn:hover:not(:disabled) {
|
|
background: #4b5563;
|
|
border-color: #6b7280;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.dark .preview-control-btn-active {
|
|
background: #7c3aed !important;
|
|
border-color: #7c3aed !important;
|
|
color: white !important;
|
|
}
|
|
|
|
.dark .preview-control-btn-active:hover {
|
|
background: #6d28d9 !important;
|
|
border-color: #6d28d9 !important;
|
|
}
|
|
|
|
/* Zoom Slider */
|
|
.preview-zoom-slider-wrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.preview-zoom-slider {
|
|
flex: 1;
|
|
height: 0.375rem;
|
|
border-radius: 0.25rem;
|
|
background: #e5e7eb;
|
|
outline: none;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
}
|
|
|
|
.preview-zoom-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 1rem;
|
|
height: 1rem;
|
|
border-radius: 50%;
|
|
background: #667eea;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.preview-zoom-slider::-webkit-slider-thumb:hover {
|
|
background: #5568d3;
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
.preview-zoom-slider::-moz-range-thumb {
|
|
width: 1rem;
|
|
height: 1rem;
|
|
border-radius: 50%;
|
|
background: #667eea;
|
|
cursor: pointer;
|
|
border: none;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.preview-zoom-slider::-moz-range-thumb:hover {
|
|
background: #5568d3;
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
.dark .preview-zoom-slider {
|
|
background: #4b5563;
|
|
}
|
|
|
|
.dark .preview-zoom-slider::-webkit-slider-thumb {
|
|
background: #7c3aed;
|
|
}
|
|
|
|
.dark .preview-zoom-slider::-webkit-slider-thumb:hover {
|
|
background: #6d28d9;
|
|
}
|
|
|
|
.dark .preview-zoom-slider::-moz-range-thumb {
|
|
background: #7c3aed;
|
|
}
|
|
|
|
.dark .preview-zoom-slider::-moz-range-thumb:hover {
|
|
background: #6d28d9;
|
|
}
|
|
|
|
.preview-zoom-display {
|
|
min-width: 3.5rem;
|
|
text-align: center;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.dark .preview-zoom-display {
|
|
color: #9ca3af;
|
|
}
|
|
|
|
/* Content Wrapper */
|
|
.preview-content-wrapper {
|
|
flex: 1;
|
|
position: relative;
|
|
overflow: auto;
|
|
min-height: 0;
|
|
background: #e5e7eb;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding-top: 0.5rem;
|
|
}
|
|
|
|
.dark .preview-content-wrapper {
|
|
background: #1f2937;
|
|
}
|
|
|
|
.preview-frame-wrapper {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
padding: 1.5rem;
|
|
padding-top: 1rem;
|
|
box-sizing: border-box;
|
|
overflow: auto;
|
|
position: relative;
|
|
}
|
|
|
|
#preview-frame {
|
|
width: 100%;
|
|
height: 100%;
|
|
min-height: 600px;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
display: block;
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow: visible;
|
|
}
|
|
|
|
.dark #preview-frame {
|
|
background: transparent;
|
|
}
|
|
|
|
/* Enhanced Loading State */
|
|
.preview-loading {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
backdrop-filter: blur(8px);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 20;
|
|
border-radius: 0.5rem;
|
|
animation: fadeIn 0.2s ease;
|
|
}
|
|
|
|
.dark .preview-loading {
|
|
background: rgba(31, 41, 55, 0.95);
|
|
}
|
|
|
|
.preview-loading.active {
|
|
display: flex;
|
|
}
|
|
|
|
.preview-loading-content {
|
|
text-align: center;
|
|
max-width: 300px;
|
|
}
|
|
|
|
.spinner-large {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 4px solid #e5e7eb;
|
|
border-top-color: #667eea;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin: 0 auto 1rem;
|
|
}
|
|
|
|
.dark .spinner-large {
|
|
border-color: #4b5563;
|
|
border-top-color: #7c3aed;
|
|
}
|
|
|
|
.preview-loading-text {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
color: #6b7280;
|
|
margin: 0 0 1rem 0;
|
|
}
|
|
|
|
.dark .preview-loading-text {
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.preview-loading-progress {
|
|
width: 100%;
|
|
height: 4px;
|
|
background: #e5e7eb;
|
|
border-radius: 2px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.dark .preview-loading-progress {
|
|
background: #4b5563;
|
|
}
|
|
|
|
.preview-loading-progress-bar {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
|
border-radius: 2px;
|
|
width: 0%;
|
|
animation: progressPulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes progressPulse {
|
|
0%, 100% { width: 0%; }
|
|
50% { width: 70%; }
|
|
}
|
|
|
|
.dark .preview-loading-progress-bar {
|
|
background: linear-gradient(90deg, #7c3aed, #9333ea);
|
|
}
|
|
|
|
/* Error State */
|
|
.preview-error {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
backdrop-filter: blur(8px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 20;
|
|
border-radius: 0.5rem;
|
|
animation: fadeIn 0.2s ease;
|
|
}
|
|
|
|
.dark .preview-error {
|
|
background: rgba(31, 41, 55, 0.95);
|
|
}
|
|
|
|
.preview-error-content {
|
|
text-align: center;
|
|
max-width: 400px;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.preview-error-icon {
|
|
font-size: 3rem;
|
|
color: #ef4444;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.preview-error-title {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
color: #1f2937;
|
|
margin: 0 0 0.75rem 0;
|
|
}
|
|
|
|
.dark .preview-error-title {
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.preview-error-message {
|
|
font-size: 0.875rem;
|
|
color: #6b7280;
|
|
margin: 0 0 1.5rem 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.dark .preview-error-message {
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.preview-error-actions {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
justify-content: center;
|
|
}
|
|
|
|
.preview-error-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.625rem 1.25rem;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.preview-error-btn:not(.preview-error-btn-secondary) {
|
|
background: #667eea;
|
|
color: white;
|
|
}
|
|
|
|
.preview-error-btn:not(.preview-error-btn-secondary):hover {
|
|
background: #5568d3;
|
|
}
|
|
|
|
.preview-error-btn-secondary {
|
|
background: #e5e7eb;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.preview-error-btn-secondary:hover {
|
|
background: #d1d5db;
|
|
}
|
|
|
|
.dark .preview-error-btn-secondary {
|
|
background: #374151;
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.dark .preview-error-btn-secondary:hover {
|
|
background: #4b5563;
|
|
}
|
|
|
|
/* Responsive Design */
|
|
@media (max-width: 768px) {
|
|
.preview-modal-content {
|
|
width: 100%;
|
|
max-width: 100%;
|
|
height: 100%;
|
|
max-height: 100%;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.preview-modal-header {
|
|
padding: 0.75rem 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.preview-toolbar {
|
|
order: 3;
|
|
width: 100%;
|
|
margin-top: 0.5rem;
|
|
padding-top: 0.5rem;
|
|
border-top: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.dark .preview-toolbar {
|
|
border-top-color: #4b5563;
|
|
}
|
|
|
|
.preview-controls-bar {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.preview-controls-left,
|
|
.preview-controls-right {
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}
|
|
|
|
.preview-zoom-slider-wrapper {
|
|
width: 100%;
|
|
min-width: auto;
|
|
}
|
|
}
|
|
|
|
/* Screen Reader Only */
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border-width: 0;
|
|
}
|
|
|
|
/* Overflow and Overlap Warning Styles */
|
|
.element-overflow {
|
|
border: 2px dashed #ef4444 !important;
|
|
box-shadow: 0 0 10px rgba(239, 68, 68, 0.3) !important;
|
|
}
|
|
|
|
.element-overlap {
|
|
border: 2px dashed #f59e0b !important;
|
|
box-shadow: 0 0 10px rgba(245, 158, 11, 0.3) !important;
|
|
}
|
|
|
|
.element-overflow.element-overlap {
|
|
border: 2px dashed #dc2626 !important;
|
|
box-shadow: 0 0 15px rgba(220, 38, 38, 0.4) !important;
|
|
}
|
|
|
|
.warning-indicator {
|
|
position: absolute;
|
|
top: -8px;
|
|
right: -8px;
|
|
width: 20px;
|
|
height: 20px;
|
|
background: #ef4444;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
z-index: 1000;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
cursor: help;
|
|
}
|
|
|
|
.warning-indicator.overlap {
|
|
background: #f59e0b;
|
|
}
|
|
|
|
.warning-indicator.overflow-overlap {
|
|
background: #dc2626;
|
|
}
|
|
|
|
.warning-tooltip {
|
|
position: absolute;
|
|
background: #1f2937;
|
|
color: white;
|
|
padding: 8px 12px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
z-index: 1001;
|
|
pointer-events: none;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
max-width: 250px;
|
|
white-space: normal;
|
|
}
|
|
|
|
.warning-tooltip::before {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: -4px;
|
|
left: 12px;
|
|
width: 0;
|
|
height: 0;
|
|
border-left: 4px solid transparent;
|
|
border-right: 4px solid transparent;
|
|
border-top: 4px solid #1f2937;
|
|
}
|
|
|
|
.preview-loading {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(255, 255, 255, 0.95);
|
|
backdrop-filter: blur(8px);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10;
|
|
border-radius: 0.5rem;
|
|
}
|
|
|
|
.dark .preview-loading {
|
|
background: rgba(31, 41, 55, 0.95);
|
|
}
|
|
|
|
.preview-loading.active {
|
|
display: flex;
|
|
}
|
|
|
|
.spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid #e5e7eb;
|
|
border-top-color: #667eea;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* Properties Panel */
|
|
.properties-panel {
|
|
background: white;
|
|
border-radius: 0.75rem;
|
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
|
padding: 1.5rem;
|
|
max-height: 700px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.dark .properties-panel {
|
|
background: #1f2937;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.dark .properties-panel h3 {
|
|
color: #f9fafb;
|
|
border-bottom-color: #4b5563;
|
|
}
|
|
|
|
.properties-panel h3 {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
margin: 0 0 1rem 0;
|
|
padding-bottom: 1rem;
|
|
border-bottom: 2px solid #e5e7eb;
|
|
color: #1f2937;
|
|
}
|
|
|
|
/* Warning Panel */
|
|
.warning-panel {
|
|
margin-bottom: 1rem;
|
|
padding: 0.75rem;
|
|
background: #fef2f2;
|
|
border: 1px solid #fecaca;
|
|
border-radius: 0.5rem;
|
|
}
|
|
|
|
.dark .warning-panel {
|
|
background: #7f1d1d;
|
|
border-color: #991b1b;
|
|
}
|
|
|
|
.warning-panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.warning-panel-title {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #991b1b;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.dark .warning-panel-title {
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.warning-panel-title i {
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.warning-panel-refresh-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #991b1b;
|
|
cursor: pointer;
|
|
font-size: 0.75rem;
|
|
padding: 2px 6px;
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.warning-panel-refresh-btn:hover {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.dark .warning-panel-refresh-btn {
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.warning-list {
|
|
max-height: 150px;
|
|
overflow-y: auto;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.warning-list-empty {
|
|
padding: 8px;
|
|
color: #666;
|
|
text-align: center;
|
|
}
|
|
|
|
.dark .warning-list-empty {
|
|
color: #9ca3af;
|
|
}
|
|
|
|
.warning-item {
|
|
padding: 8px;
|
|
margin-bottom: 4px;
|
|
background: #fee;
|
|
border-left: 3px solid #ef4444;
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
color: #1f2937;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.warning-item:hover {
|
|
background: #fdd;
|
|
}
|
|
|
|
.dark .warning-item {
|
|
background: #7f1d1d;
|
|
border-left-color: #dc2626;
|
|
color: #fca5a5;
|
|
}
|
|
|
|
.dark .warning-item:hover {
|
|
background: #991b1b;
|
|
}
|
|
|
|
/* Template Settings Panel */
|
|
.template-settings-panel {
|
|
margin-bottom: 1.5rem;
|
|
padding: 1rem;
|
|
background: #f9fafb;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.dark .template-settings-panel {
|
|
background: #2d3748;
|
|
border-color: #4a5568;
|
|
}
|
|
|
|
.template-settings-panel h4 {
|
|
margin: 0 0 0.75rem 0;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: #374151;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.dark .template-settings-panel h4 {
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.template-settings-panel h4 i {
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.template-settings-panel label {
|
|
display: block;
|
|
font-size: 0.813rem;
|
|
font-weight: 500;
|
|
margin-bottom: 0.25rem;
|
|
color: #4b5563;
|
|
}
|
|
|
|
.dark .template-settings-panel label {
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
.template-settings-panel p {
|
|
margin-top: 0.25rem;
|
|
font-size: 0.75rem;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.dark .template-settings-panel p {
|
|
color: #a0aec0;
|
|
}
|
|
|
|
.property-group {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.property-label {
|
|
font-size: 0.813rem;
|
|
font-weight: 500;
|
|
margin-bottom: 0.5rem;
|
|
color: #374151;
|
|
}
|
|
|
|
.dark .property-label {
|
|
color: #d1d5db;
|
|
}
|
|
|
|
.property-input {
|
|
width: 100%;
|
|
padding: 0.5rem;
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.875rem;
|
|
background: white;
|
|
color: #1f2937;
|
|
}
|
|
|
|
.dark .property-input {
|
|
background: #374151;
|
|
border-color: #4b5563;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.dark .property-input:disabled {
|
|
background: #1f2937;
|
|
color: #6b7280;
|
|
}
|
|
|
|
.dark .property-input:focus {
|
|
border-color: #667eea;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
|
}
|
|
|
|
.property-input:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
|
}
|
|
|
|
.property-input:disabled {
|
|
background: #f3f4f6;
|
|
color: #6b7280;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Action Bar */
|
|
.action-bar {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
margin-bottom: 1.5rem;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.action-bar {
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.action-bar .btn {
|
|
font-size: 0.813rem;
|
|
padding: 0.5rem 0.625rem;
|
|
}
|
|
|
|
.action-bar .btn i {
|
|
margin-right: 0.25rem !important;
|
|
}
|
|
|
|
.action-bar label.inline-flex {
|
|
font-size: 0.813rem;
|
|
margin-left: 0 !important;
|
|
}
|
|
}
|
|
|
|
/* Button dark mode support */
|
|
.dark .btn {
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.dark .btn-secondary {
|
|
background: #4b5563;
|
|
border-color: #6b7280;
|
|
color: #f9fafb;
|
|
}
|
|
|
|
.dark .btn-secondary:hover {
|
|
background: #6b7280;
|
|
border-color: #9ca3af;
|
|
}
|
|
|
|
.dark .btn-primary {
|
|
background: #667eea;
|
|
border-color: #667eea;
|
|
}
|
|
|
|
.dark .btn-primary:hover {
|
|
background: #5568d3;
|
|
}
|
|
|
|
.dark .btn-info {
|
|
background: #0ea5e9;
|
|
border-color: #0ea5e9;
|
|
}
|
|
|
|
.dark .btn-info:hover {
|
|
background: #0284c7;
|
|
}
|
|
|
|
.dark .btn-danger {
|
|
background: #ef4444;
|
|
border-color: #ef4444;
|
|
}
|
|
|
|
.dark .btn-danger:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
/* Info Box */
|
|
.info-box {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 1rem;
|
|
border-radius: 0.75rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.info-box p {
|
|
margin: 0;
|
|
font-size: 0.875rem;
|
|
line-height: 1.6;
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<!-- Page Header -->
|
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
|
<div>
|
|
<h1 class="text-2xl font-bold flex items-center gap-2">
|
|
<i class="fas fa-paint-brush text-purple-600"></i>
|
|
{{ _('Visual Invoice Designer') }}
|
|
</h1>
|
|
<p class="text-text-muted-light dark:text-text-muted-dark mt-1">
|
|
{{ _('Drag and drop elements to design your invoice layout') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info Box -->
|
|
<div class="info-box">
|
|
<p>
|
|
<i class="fas fa-info-circle mr-2"></i>
|
|
<strong>{{ _('How to use:') }}</strong>
|
|
{{ _('Click elements from the left sidebar to add them to the canvas. Click elements to select and customize them in the properties panel. Use the alignment tools and keyboard shortcuts for faster editing.') }}
|
|
</p>
|
|
<p style="margin-top: 0.5rem; font-size: 0.813rem;">
|
|
<strong>{{ _('Keyboard Shortcuts:') }}</strong>
|
|
<span style="opacity: 0.9;">
|
|
Delete/Backspace = Remove | Ctrl+C = Copy | Ctrl+V = Paste | Ctrl+D = Duplicate | Arrow Keys = Move (+ Shift for 10px steps)
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Action Bar -->
|
|
<div class="action-bar">
|
|
<button id="btn-clear" type="button" class="btn btn-secondary">
|
|
<i class="fas fa-eraser mr-2"></i>{{ _('Clear Canvas') }}
|
|
</button>
|
|
<button id="btn-preview" type="button" class="btn btn-info">
|
|
<i class="fas fa-eye mr-2"></i>{{ _('Generate Preview') }}
|
|
</button>
|
|
<button id="btn-save" type="button" class="btn btn-primary">
|
|
<i class="fas fa-save mr-2"></i>{{ _('Save Design') }}
|
|
</button>
|
|
<button id="btn-code" type="button" class="btn btn-secondary">
|
|
<i class="fas fa-code mr-2"></i>{{ _('View Code') }}
|
|
</button>
|
|
<a href="{{ url_for('admin.pdf_layout_export_json', page_size=page_size) }}" class="btn btn-secondary" id="btn-export-json">
|
|
<i class="fas fa-download mr-2"></i>{{ _('Export JSON') }}
|
|
</a>
|
|
<label for="import-json-file" class="btn btn-secondary" style="cursor: pointer; margin: 0;">
|
|
<i class="fas fa-upload mr-2"></i>{{ _('Import JSON') }}
|
|
</label>
|
|
<input type="file" id="import-json-file" accept=".json" style="display: none;">
|
|
<label class="inline-flex items-center ml-4">
|
|
<input type="checkbox" id="snap-to-grid" class="mr-2" checked>
|
|
<span>{{ _('Snap to Grid (10px)') }}</span>
|
|
</label>
|
|
<form id="form-reset" method="POST" action="{{ url_for('admin.pdf_layout_reset') }}" class="inline">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<button type="button" onclick="confirmResetPdfLayout()" class="btn btn-danger">
|
|
<i class="fas fa-undo mr-2"></i>{{ _('Reset') }}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Main Designer Layout -->
|
|
<div class="designer-layout">
|
|
<!-- LEFT: Elements Sidebar -->
|
|
<div class="sidebar">
|
|
<h3>
|
|
<i class="fas fa-cube"></i>
|
|
{{ _('Toolbox') }}
|
|
</h3>
|
|
|
|
<!-- Search Box -->
|
|
<div class="search-box">
|
|
<input type="text" id="sidebar-search" placeholder="{{ _('Search elements & variables...') }}">
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="sidebar-tabs">
|
|
<div class="sidebar-tab active" data-tab="elements">
|
|
<i class="fas fa-shapes mr-1"></i>{{ _('Elements') }}
|
|
</div>
|
|
<div class="sidebar-tab" data-tab="variables">
|
|
<i class="fas fa-code mr-1"></i>{{ _('Variables') }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar Content -->
|
|
<div class="sidebar-content">
|
|
<!-- Elements Tab -->
|
|
<div id="tab-elements" class="tab-pane active">
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Basic Elements') }}</div>
|
|
<div class="element-item" data-type="custom-text">
|
|
<i class="fas fa-edit"></i>
|
|
<span>{{ _('Custom Text') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="text" data-content="Sample Text">
|
|
<i class="fas fa-font"></i>
|
|
<span>{{ _('Text') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="heading" data-content="INVOICE">
|
|
<i class="fas fa-heading"></i>
|
|
<span>{{ _('Heading') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="line">
|
|
<i class="fas fa-minus"></i>
|
|
<span>{{ _('Line') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="rectangle">
|
|
<i class="fas fa-square"></i>
|
|
<span>{{ _('Rectangle') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="circle">
|
|
<i class="fas fa-circle"></i>
|
|
<span>{{ _('Circle') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Company Info') }}</div>
|
|
<div class="element-item" data-type="logo">
|
|
<i class="fas fa-image"></i>
|
|
<span>{{ _('Company Logo') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="company-name">
|
|
<i class="fas fa-building"></i>
|
|
<span>{{ _('Company Name') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="company-info">
|
|
<i class="fas fa-address-card"></i>
|
|
<span>{{ _('Company Details') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="company-address">
|
|
<i class="fas fa-map-marker-alt"></i>
|
|
<span>{{ _('Address') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="company-email">
|
|
<i class="fas fa-envelope"></i>
|
|
<span>{{ _('Email') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="company-phone">
|
|
<i class="fas fa-phone"></i>
|
|
<span>{{ _('Phone') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="company-website">
|
|
<i class="fas fa-globe"></i>
|
|
<span>{{ _('Website') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="company-tax-id">
|
|
<i class="fas fa-file-invoice"></i>
|
|
<span>{{ _('Tax ID') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Invoice Data') }}</div>
|
|
<div class="element-item" data-type="invoice-number">
|
|
<i class="fas fa-hashtag"></i>
|
|
<span>{{ _('Invoice Number') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="invoice-date">
|
|
<i class="fas fa-calendar"></i>
|
|
<span>{{ _('Invoice Date') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="due-date">
|
|
<i class="fas fa-calendar-check"></i>
|
|
<span>{{ _('Due Date') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="invoice-status">
|
|
<i class="fas fa-info-circle"></i>
|
|
<span>{{ _('Status') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="client-info">
|
|
<i class="fas fa-user"></i>
|
|
<span>{{ _('Client Info') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="client-name">
|
|
<i class="fas fa-user-circle"></i>
|
|
<span>{{ _('Client Name') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="client-address">
|
|
<i class="fas fa-map-marker"></i>
|
|
<span>{{ _('Client Address') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="items-table">
|
|
<i class="fas fa-table"></i>
|
|
<span>{{ _('Items Table') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="expenses-table">
|
|
<i class="fas fa-table"></i>
|
|
<span>{{ _('Expenses Table') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="subtotal">
|
|
<i class="fas fa-coins"></i>
|
|
<span>{{ _('Subtotal') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="tax">
|
|
<i class="fas fa-percent"></i>
|
|
<span>{{ _('Tax') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="totals">
|
|
<i class="fas fa-calculator"></i>
|
|
<span>{{ _('Total Amount') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="notes">
|
|
<i class="fas fa-sticky-note"></i>
|
|
<span>{{ _('Notes') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="terms">
|
|
<i class="fas fa-file-contract"></i>
|
|
<span>{{ _('Terms') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Payment & Project') }}</div>
|
|
<div class="element-item" data-type="payment-date">
|
|
<i class="fas fa-calendar-check"></i>
|
|
<span>{{ _('Payment Date') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="payment-method">
|
|
<i class="fas fa-credit-card"></i>
|
|
<span>{{ _('Payment Method') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="payment-status">
|
|
<i class="fas fa-check-circle"></i>
|
|
<span>{{ _('Payment Status') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="amount-paid">
|
|
<i class="fas fa-dollar-sign"></i>
|
|
<span>{{ _('Amount Paid') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="outstanding-amount">
|
|
<i class="fas fa-exclamation-circle"></i>
|
|
<span>{{ _('Outstanding') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="project-name">
|
|
<i class="fas fa-project-diagram"></i>
|
|
<span>{{ _('Project Name') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="client-email">
|
|
<i class="fas fa-at"></i>
|
|
<span>{{ _('Client Email') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="client-phone">
|
|
<i class="fas fa-mobile-alt"></i>
|
|
<span>{{ _('Client Phone') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Advanced') }}</div>
|
|
<div class="element-item" data-type="qr-code">
|
|
<i class="fas fa-qrcode"></i>
|
|
<span>{{ _('QR Code') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="barcode">
|
|
<i class="fas fa-barcode"></i>
|
|
<span>{{ _('Barcode') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="page-number">
|
|
<i class="fas fa-file-alt"></i>
|
|
<span>{{ _('Page Number') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="date-now">
|
|
<i class="fas fa-clock"></i>
|
|
<span>{{ _('Current Date') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="watermark">
|
|
<i class="fas fa-stamp"></i>
|
|
<span>{{ _('Watermark') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="bank-info">
|
|
<i class="fas fa-university"></i>
|
|
<span>{{ _('Bank Info') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="currency">
|
|
<i class="fas fa-money-bill-wave"></i>
|
|
<span>{{ _('Currency') }}</span>
|
|
</div>
|
|
<div class="element-item" data-type="decorative-image">
|
|
<i class="fas fa-image"></i>
|
|
<span>{{ _('Decorative Image') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Variables Tab -->
|
|
<div id="tab-variables" class="tab-pane">
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Invoice Fields') }}</div>
|
|
<div class="variable-item" data-variable="invoice.invoice_number">
|
|
<div class="variable-name">{{ '{{' }} invoice.invoice_number {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Invoice number') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.status">
|
|
<div class="variable-name">{{ '{{' }} invoice.status {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Invoice status (draft/sent/paid/overdue)') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_date(invoice.issue_date)">
|
|
<div class="variable-name">{{ '{{' }} format_date(invoice.issue_date) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Issue date') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_date(invoice.due_date)">
|
|
<div class="variable-name">{{ '{{' }} format_date(invoice.due_date) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Due date') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_money(invoice.subtotal)">
|
|
<div class="variable-name">{{ '{{' }} format_money(invoice.subtotal) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Subtotal amount') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.tax_rate">
|
|
<div class="variable-name">{{ '{{' }} invoice.tax_rate {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Tax rate (%)') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_money(invoice.tax_amount)">
|
|
<div class="variable-name">{{ '{{' }} format_money(invoice.tax_amount) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Tax amount') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_money(invoice.total_amount)">
|
|
<div class="variable-name">{{ '{{' }} format_money(invoice.total_amount) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Total amount') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.currency_code">
|
|
<div class="variable-name">{{ '{{' }} invoice.currency_code {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Currency code (EUR, USD, etc)') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.notes">
|
|
<div class="variable-name">{{ '{{' }} invoice.notes {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Invoice notes') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.terms">
|
|
<div class="variable-name">{{ '{{' }} invoice.terms {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Terms & conditions') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Client Fields') }}</div>
|
|
<div class="variable-item" data-variable="invoice.client_name">
|
|
<div class="variable-name">{{ '{{' }} invoice.client_name {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Client company name') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.client_email">
|
|
<div class="variable-name">{{ '{{' }} invoice.client_email {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Client email address') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.client_address">
|
|
<div class="variable-name">{{ '{{' }} invoice.client_address {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Client full address') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.client.contact_person">
|
|
<div class="variable-name">{{ '{{' }} invoice.client.contact_person {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Client contact person') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.client.phone">
|
|
<div class="variable-name">{{ '{{' }} invoice.client.phone {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Client phone number') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Payment Fields') }}</div>
|
|
<div class="variable-item" data-variable="invoice.payment_status">
|
|
<div class="variable-name">{{ '{{' }} invoice.payment_status {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Payment status') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_date(invoice.payment_date)">
|
|
<div class="variable-name">{{ '{{' }} format_date(invoice.payment_date) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Payment date') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.payment_method">
|
|
<div class="variable-name">{{ '{{' }} invoice.payment_method {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Payment method') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.payment_reference">
|
|
<div class="variable-name">{{ '{{' }} invoice.payment_reference {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Payment reference number') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_money(invoice.amount_paid)">
|
|
<div class="variable-name">{{ '{{' }} format_money(invoice.amount_paid) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Amount paid') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_money(invoice.outstanding_amount)">
|
|
<div class="variable-name">{{ '{{' }} format_money(invoice.outstanding_amount) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Outstanding amount') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.payment_notes">
|
|
<div class="variable-name">{{ '{{' }} invoice.payment_notes {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Payment notes') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Project Fields') }}</div>
|
|
<div class="variable-item" data-variable="invoice.project.name">
|
|
<div class="variable-name">{{ '{{' }} invoice.project.name {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Project name') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.project.code">
|
|
<div class="variable-name">{{ '{{' }} invoice.project.code {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Project code') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.project.description">
|
|
<div class="variable-name">{{ '{{' }} invoice.project.description {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Project description') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="invoice.project.billing_ref">
|
|
<div class="variable-name">{{ '{{' }} invoice.project.billing_ref {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Project billing reference') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Company/Settings Fields') }}</div>
|
|
<div class="variable-item" data-variable="settings.company_name">
|
|
<div class="variable-name">{{ '{{' }} settings.company_name {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Your company name') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="settings.company_address">
|
|
<div class="variable-name">{{ '{{' }} settings.company_address {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Your company address') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="settings.company_email">
|
|
<div class="variable-name">{{ '{{' }} settings.company_email {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Your company email') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="settings.company_phone">
|
|
<div class="variable-name">{{ '{{' }} settings.company_phone {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Your company phone') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="settings.company_website">
|
|
<div class="variable-name">{{ '{{' }} settings.company_website {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Your company website') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="settings.company_tax_id">
|
|
<div class="variable-name">{{ '{{' }} settings.company_tax_id {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Your tax ID number') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="settings.company_bank_info">
|
|
<div class="variable-name">{{ '{{' }} settings.company_bank_info {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Your bank account info') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="settings.invoice_terms">
|
|
<div class="variable-name">{{ '{{' }} settings.invoice_terms {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Default invoice terms') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="settings.invoice_notes">
|
|
<div class="variable-name">{{ '{{' }} settings.invoice_notes {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Default invoice notes') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Date/Time Fields') }}</div>
|
|
<div class="variable-item" data-variable="format_date(now)">
|
|
<div class="variable-name">{{ '{{' }} format_date(now) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Current date') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_date(invoice.created_at)">
|
|
<div class="variable-name">{{ '{{' }} format_date(invoice.created_at) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Invoice creation date') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_date(invoice.updated_at)">
|
|
<div class="variable-name">{{ '{{' }} format_date(invoice.updated_at) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Invoice last update date') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="element-group">
|
|
<div class="element-group-title">{{ _('Invoice Items Loop') }}</div>
|
|
<div class="variable-item" data-variable="for-loop-start">
|
|
<div class="variable-name">{{ '{%' }} for item in invoice.items {{ '%}' }}</div>
|
|
<div class="variable-desc">{{ _('Loop through invoice items') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="item.description">
|
|
<div class="variable-name">{{ '{{' }} item.description {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Item description') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="item.quantity">
|
|
<div class="variable-name">{{ '{{' }} item.quantity {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Item quantity') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_money(item.unit_price)">
|
|
<div class="variable-name">{{ '{{' }} format_money(item.unit_price) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Item unit price') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="format_money(item.total_amount)">
|
|
<div class="variable-name">{{ '{{' }} format_money(item.total_amount) {{ '}}' }}</div>
|
|
<div class="variable-desc">{{ _('Item total amount') }}</div>
|
|
</div>
|
|
<div class="variable-item" data-variable="for-loop-end">
|
|
<div class="variable-name">{{ '{%' }} endfor {{ '%}' }}</div>
|
|
<div class="variable-desc">{{ _('End loop') }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CENTER: Canvas Area -->
|
|
<div class="canvas-area">
|
|
<div class="canvas-header">
|
|
<h3>
|
|
<i class="fas fa-palette"></i>
|
|
{{ _('Design Canvas') }}
|
|
</h3>
|
|
<div class="canvas-header-controls">
|
|
<div class="page-size-selector-group">
|
|
<label class="page-size-label">{{ _('Page Size:') }}</label>
|
|
<select id="page-size-selector" class="page-size-select">
|
|
{% if all_templates %}
|
|
{% for template in all_templates %}
|
|
<option value="{{ template.page_size }}" {% if template.page_size == page_size %}selected{% endif %}>
|
|
{{ template.page_size }}
|
|
</option>
|
|
{% endfor %}
|
|
{% else %}
|
|
<option value="A4" {% if page_size == 'A4' %}selected{% endif %}>A4</option>
|
|
<option value="Letter" {% if page_size == 'Letter' %}selected{% endif %}>Letter</option>
|
|
<option value="Legal" {% if page_size == 'Legal' %}selected{% endif %}>Legal</option>
|
|
<option value="A3" {% if page_size == 'A3' %}selected{% endif %}>A3</option>
|
|
<option value="A5" {% if page_size == 'A5' %}selected{% endif %}>A5</option>
|
|
<option value="Tabloid" {% if page_size == 'Tabloid' %}selected{% endif %}>Tabloid</option>
|
|
{% endif %}
|
|
</select>
|
|
<div class="page-size-help" style="font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem;">
|
|
The blue border marks the page boundaries. Zoom changes the view, not the page size.
|
|
</div>
|
|
</div>
|
|
<div class="canvas-toolbar">
|
|
<button type="button" id="btn-zoom-in" title="{{ _('Zoom In') }}">
|
|
<i class="fas fa-search-plus"></i>
|
|
</button>
|
|
<button type="button" id="btn-zoom-out" title="{{ _('Zoom Out') }}">
|
|
<i class="fas fa-search-minus"></i>
|
|
</button>
|
|
<span id="zoom-display" style="margin-left: 10px; font-size: 0.875rem; color: #6b7280; align-self: center;"></span>
|
|
<button type="button" id="btn-delete" title="{{ _('Delete Selected') }}">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="canvas-container"></div>
|
|
</div>
|
|
|
|
<!-- RIGHT: Properties Panel -->
|
|
<div class="properties-panel">
|
|
<h3>
|
|
<i class="fas fa-sliders-h"></i>
|
|
{{ _('Properties') }}
|
|
</h3>
|
|
<!-- Warning Panel -->
|
|
<div id="warning-panel" class="warning-panel">
|
|
<div class="warning-panel-header">
|
|
<h4 class="warning-panel-title">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
{{ _('Warnings') }}
|
|
</h4>
|
|
<button id="refresh-warnings" class="warning-panel-refresh-btn" title="{{ _('Refresh warnings') }}">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
</div>
|
|
<div id="warning-list" class="warning-list">
|
|
<div class="warning-list-empty">{{ _('No warnings') }}</div>
|
|
</div>
|
|
</div>
|
|
<!-- Template Settings -->
|
|
<div class="template-settings-panel">
|
|
<h4>
|
|
<i class="fas fa-cog"></i>
|
|
{{ _('Template Settings') }}
|
|
</h4>
|
|
<div style="margin-bottom: 0.75rem;">
|
|
<label for="template-date-format">
|
|
{{ _('Date Format') }}
|
|
</label>
|
|
<input type="text" id="template-date-format" class="form-input" value="{{ date_format if date_format else '%d.%m.%Y' }}"
|
|
style="font-size: 0.813rem; font-family: monospace;"
|
|
placeholder="%d.%m.%Y">
|
|
<p>
|
|
{{ _('Use strftime format (e.g., %d.%m.%Y for 12.09.2025)') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div id="properties-content">
|
|
<p class="text-sm text-gray-500 italic">{{ _('Select an element to edit its properties') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview Modal -->
|
|
<div id="preview-modal" class="preview-modal" style="display: none;" role="dialog" aria-labelledby="preview-modal-title" aria-modal="true">
|
|
<div class="preview-modal-overlay" id="preview-modal-overlay"></div>
|
|
<div class="preview-modal-content">
|
|
<!-- Enhanced Header with Toolbar -->
|
|
<div class="preview-modal-header">
|
|
<div class="preview-header-left">
|
|
<h3 id="preview-modal-title">{{ _('PDF Preview') }}</h3>
|
|
<span class="preview-page-size-badge" id="preview-page-size-badge">A4</span>
|
|
</div>
|
|
<div class="preview-toolbar">
|
|
<div class="preview-toolbar-group">
|
|
<label for="preview-page-size-select" class="sr-only">{{ _('Page Size') }}</label>
|
|
<select id="preview-page-size-select" class="preview-toolbar-select" aria-label="{{ _('Page Size') }}">
|
|
<option value="A4">A4</option>
|
|
<option value="Letter">Letter</option>
|
|
<option value="Legal">Legal</option>
|
|
<option value="A3">A3</option>
|
|
<option value="A5">A5</option>
|
|
<option value="Tabloid">Tabloid</option>
|
|
</select>
|
|
</div>
|
|
<div class="preview-toolbar-group">
|
|
<button type="button" id="preview-btn-refresh" class="preview-toolbar-btn" title="{{ _('Refresh Preview') }}" aria-label="{{ _('Refresh Preview') }}">
|
|
<i class="fas fa-sync-alt"></i>
|
|
</button>
|
|
<button type="button" id="preview-btn-fullscreen" class="preview-toolbar-btn" title="{{ _('Toggle Fullscreen') }}" aria-label="{{ _('Toggle Fullscreen') }}">
|
|
<i class="fas fa-expand"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button class="preview-modal-close" id="preview-modal-close" type="button" aria-label="{{ _('Close Preview') }}">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Enhanced Body with Controls -->
|
|
<div class="preview-modal-body">
|
|
<!-- Controls Bar -->
|
|
<div class="preview-controls-bar">
|
|
<div class="preview-controls-left">
|
|
<button type="button" id="preview-btn-zoom-out" class="preview-control-btn" title="{{ _('Zoom Out') }}" aria-label="{{ _('Zoom Out') }}">
|
|
<i class="fas fa-search-minus"></i>
|
|
</button>
|
|
<div class="preview-zoom-slider-wrapper">
|
|
<input type="range" id="preview-zoom-slider" class="preview-zoom-slider" min="25" max="200" value="100" step="5" aria-label="{{ _('Zoom Level') }}">
|
|
<span id="preview-zoom-display" class="preview-zoom-display">100%</span>
|
|
</div>
|
|
<button type="button" id="preview-btn-zoom-in" class="preview-control-btn" title="{{ _('Zoom In') }}" aria-label="{{ _('Zoom In') }}">
|
|
<i class="fas fa-search-plus"></i>
|
|
</button>
|
|
<button type="button" id="preview-btn-zoom-reset" class="preview-control-btn" title="{{ _('Reset Zoom') }}" aria-label="{{ _('Reset Zoom') }}">
|
|
<i class="fas fa-undo"></i>
|
|
</button>
|
|
</div>
|
|
<div class="preview-controls-right">
|
|
<button type="button" id="preview-btn-fit-width" class="preview-control-btn" title="{{ _('Fit to Width') }}" aria-label="{{ _('Fit to Width') }}">
|
|
<i class="fas fa-arrows-alt-h"></i> {{ _('Fit Width') }}
|
|
</button>
|
|
<button type="button" id="preview-btn-fit-height" class="preview-control-btn" title="{{ _('Fit to Height') }}" aria-label="{{ _('Fit to Height') }}">
|
|
<i class="fas fa-arrows-alt-v"></i> {{ _('Fit Height') }}
|
|
</button>
|
|
<button type="button" id="preview-btn-actual-size" class="preview-control-btn" title="{{ _('Actual Size') }}" aria-label="{{ _('Actual Size') }}">
|
|
<i class="fas fa-expand-arrows-alt"></i> {{ _('Actual Size') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content Wrapper -->
|
|
<div class="preview-content-wrapper" id="preview-content-wrapper">
|
|
<!-- Loading State -->
|
|
<div class="preview-loading" id="preview-loading">
|
|
<div class="preview-loading-content">
|
|
<div class="spinner-large"></div>
|
|
<p class="preview-loading-text">{{ _('Generating preview...') }}</p>
|
|
<div class="preview-loading-progress">
|
|
<div class="preview-loading-progress-bar" id="preview-loading-progress-bar"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div class="preview-error" id="preview-error" style="display: none;">
|
|
<div class="preview-error-content">
|
|
<i class="fas fa-exclamation-triangle preview-error-icon"></i>
|
|
<h4 class="preview-error-title">{{ _('Preview Error') }}</h4>
|
|
<p class="preview-error-message" id="preview-error-message"></p>
|
|
<div class="preview-error-actions">
|
|
<button type="button" id="preview-btn-retry" class="preview-error-btn">
|
|
<i class="fas fa-redo"></i> {{ _('Retry') }}
|
|
</button>
|
|
<button type="button" id="preview-btn-close-error" class="preview-error-btn preview-error-btn-secondary">
|
|
{{ _('Close') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview Frame -->
|
|
<div class="preview-frame-wrapper" id="preview-frame-wrapper">
|
|
<iframe id="preview-frame" style="width: 100%; height: 100%; border: none; display: block; background: transparent;" title="{{ _('PDF Preview') }}"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden Save Form -->
|
|
<form id="form-save" method="POST" class="hidden">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="page_size" id="save-page-size" value="{{ page_size }}">
|
|
<textarea id="save-html" name="invoice_pdf_template_html"></textarea>
|
|
<textarea id="save-css" name="invoice_pdf_template_css"></textarea>
|
|
<textarea id="save-design-json" name="design_json"></textarea>
|
|
<textarea id="save-template-json" name="template_json"></textarea>
|
|
<input type="text" id="save-date-format" name="date_format" value="{{ date_format or '%d.%m.%Y' }}">
|
|
</form>
|
|
|
|
<!-- Code Modal (initially hidden) -->
|
|
<div id="code-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
|
<div class="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[80vh] overflow-auto">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-semibold">{{ _('Generated Code') }}</h3>
|
|
<button type="button" id="close-modal" class="text-gray-500 hover:text-gray-700">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">HTML:</label>
|
|
<textarea id="code-html" class="w-full h-48 p-3 border rounded font-mono text-sm" readonly></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="block text-sm font-medium mb-2">CSS:</label>
|
|
<textarea id="code-css" class="w-full h-48 p-3 border rounded font-mono text-sm" readonly></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts_extra %}
|
|
<!-- Load Konva.js from CDN (jsDelivr is whitelisted in CSP) -->
|
|
<script src="https://cdn.jsdelivr.net/npm/konva@9/konva.min.js" crossorigin="anonymous"></script>
|
|
|
|
<script>
|
|
console.log('PDF Editor script starting...');
|
|
|
|
const CSRF_TOKEN = {{ csrf_token()|tojson }};
|
|
const PREVIEW_URL = {{ url_for('admin.pdf_layout_preview')|tojson }};
|
|
const LOGO_URL = {{ (settings.get_logo_url() if settings.has_logo() else '')|tojson }};
|
|
const SAVED_DESIGN_JSON = {{ (design_json if design_json else '')|tojson }};
|
|
const CURRENT_PAGE_SIZE = {{ page_size|tojson }};
|
|
|
|
// Page size dimensions in pixels at 72 DPI
|
|
const PAGE_SIZE_DIMENSIONS = {
|
|
'A4': { width: 595, height: 842 },
|
|
'Letter': { width: 612, height: 792 },
|
|
'Legal': { width: 612, height: 1008 },
|
|
'A3': { width: 842, height: 1191 },
|
|
'A5': { width: 420, height: 595 },
|
|
'Tabloid': { width: 792, height: 1224 }
|
|
};
|
|
|
|
// Overflow and overlap detection functions
|
|
function getElementBoundingBox(element) {
|
|
// Get element's bounding box in page coordinates
|
|
if (!element) return null;
|
|
|
|
const box = element.getClientRect();
|
|
const x = element.x();
|
|
const y = element.y();
|
|
const width = box.width || element.width() || 0;
|
|
const height = box.height || element.height() || 0;
|
|
|
|
// For groups, get the actual bounding box
|
|
if (element.className === 'Group' || element.getType() === 'Group') {
|
|
const groupBox = element.getClientRect();
|
|
return {
|
|
x: groupBox.x,
|
|
y: groupBox.y,
|
|
width: groupBox.width,
|
|
height: groupBox.height,
|
|
right: groupBox.x + groupBox.width,
|
|
bottom: groupBox.y + groupBox.height
|
|
};
|
|
}
|
|
|
|
return {
|
|
x: x,
|
|
y: y,
|
|
width: width,
|
|
height: height,
|
|
right: x + width,
|
|
bottom: y + height
|
|
};
|
|
}
|
|
|
|
function checkPageBoundaries(element) {
|
|
// Get current page size dimensions
|
|
const pageSizeSelector = document.getElementById('page-size-selector');
|
|
const currentSize = (pageSizeSelector && pageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
const pageWidth = dimensions.width;
|
|
const pageHeight = dimensions.height;
|
|
|
|
const box = getElementBoundingBox(element);
|
|
if (!box) return { isValid: true, overflowLeft: 0, overflowRight: 0, overflowTop: 0, overflowBottom: 0 };
|
|
|
|
const overflowLeft = Math.max(0, -box.x);
|
|
const overflowRight = Math.max(0, box.right - pageWidth);
|
|
const overflowTop = Math.max(0, -box.y);
|
|
const overflowBottom = Math.max(0, box.bottom - pageHeight);
|
|
|
|
const isValid = overflowLeft === 0 && overflowRight === 0 && overflowTop === 0 && overflowBottom === 0;
|
|
|
|
return {
|
|
isValid: isValid,
|
|
overflowLeft: overflowLeft,
|
|
overflowRight: overflowRight,
|
|
overflowTop: overflowTop,
|
|
overflowBottom: overflowBottom,
|
|
box: box
|
|
};
|
|
}
|
|
|
|
function checkOverlaps(element, allElements) {
|
|
// Check if element overlaps with other elements
|
|
if (!element || !allElements) return [];
|
|
|
|
const elementBox = getElementBoundingBox(element);
|
|
if (!elementBox) return [];
|
|
|
|
const overlaps = [];
|
|
|
|
for (let i = 0; i < allElements.length; i++) {
|
|
const otherElement = allElements[i];
|
|
const elementName = otherElement.attrs && otherElement.attrs.name;
|
|
if (otherElement === element ||
|
|
elementName === 'background' ||
|
|
elementName === 'page-border' ||
|
|
otherElement.className === 'Transformer') {
|
|
continue;
|
|
}
|
|
|
|
const otherBox = getElementBoundingBox(otherElement);
|
|
if (!otherBox) continue;
|
|
|
|
// Check for bounding box intersection
|
|
const intersects = !(
|
|
elementBox.right < otherBox.x ||
|
|
elementBox.x > otherBox.right ||
|
|
elementBox.bottom < otherBox.y ||
|
|
elementBox.y > otherBox.bottom
|
|
);
|
|
|
|
if (intersects) {
|
|
// Calculate overlap area
|
|
const overlapX = Math.max(0, Math.min(elementBox.right, otherBox.right) - Math.max(elementBox.x, otherBox.x));
|
|
const overlapY = Math.max(0, Math.min(elementBox.bottom, otherBox.bottom) - Math.max(elementBox.y, otherBox.y));
|
|
const overlapArea = overlapX * overlapY;
|
|
|
|
overlaps.push({
|
|
element: otherElement,
|
|
overlapArea: overlapArea,
|
|
overlapX: overlapX,
|
|
overlapY: overlapY
|
|
});
|
|
}
|
|
}
|
|
|
|
return overlaps;
|
|
}
|
|
|
|
// Wait for Konva.js to load
|
|
let konvaLoadAttempts = 0;
|
|
const maxKonvaLoadAttempts = 100; // 5 seconds max wait
|
|
|
|
function initializePDFEditor() {
|
|
konvaLoadAttempts++;
|
|
|
|
if (typeof Konva === 'undefined') {
|
|
if (konvaLoadAttempts >= maxKonvaLoadAttempts) {
|
|
console.error('❌ Konva.js failed to load after 5 seconds');
|
|
const container = document.getElementById('canvas-container');
|
|
if (container) {
|
|
container.innerHTML = '<div style="padding: 40px; text-align: center; background: #fee; border: 2px solid red; border-radius: 8px; margin: 20px;">' +
|
|
'<h3 style="color: #d00; margin-bottom: 10px;">⚠️ Konva.js Library Failed to Load</h3>' +
|
|
'<p>The canvas library could not be loaded. This might be due to:</p>' +
|
|
'<ul style="text-align: left; display: inline-block; margin: 10px auto;">' +
|
|
'<li>Network connectivity issues</li>' +
|
|
'<li>CDN being blocked</li>' +
|
|
'<li>Firewall/proxy restrictions</li>' +
|
|
'</ul>' +
|
|
'<p><strong>Try:</strong></p>' +
|
|
'<ul style="text-align: left; display: inline-block; margin: 10px auto;">' +
|
|
'<li>Refresh the page (Ctrl+F5)</li>' +
|
|
'<li>Check your internet connection</li>' +
|
|
'<li>Try a different network</li>' +
|
|
'<li>Check browser console for errors (F12)</li>' +
|
|
'</ul>' +
|
|
'</div>';
|
|
}
|
|
return;
|
|
}
|
|
console.log('Waiting for Konva.js to load... (attempt ' + konvaLoadAttempts + '/' + maxKonvaLoadAttempts + ')');
|
|
setTimeout(initializePDFEditor, 50);
|
|
return;
|
|
}
|
|
|
|
console.log('✅ Konva.js loaded successfully, initializing editor...');
|
|
|
|
// Initialize Konva Stage
|
|
const container = document.getElementById('canvas-container');
|
|
// Get page size from selector if available, otherwise use CURRENT_PAGE_SIZE
|
|
// Declare pageSizeSelector once at the top level to avoid redeclaration errors
|
|
const pageSizeSelector = document.getElementById('page-size-selector');
|
|
const currentSize = (pageSizeSelector && pageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
let width = dimensions.width;
|
|
let height = dimensions.height;
|
|
|
|
console.log('Initializing canvas with page size:', currentSize, 'dimensions:', width, 'x', height);
|
|
|
|
let stage = new Konva.Stage({
|
|
container: 'canvas-container',
|
|
width: width,
|
|
height: height,
|
|
draggable: false
|
|
});
|
|
|
|
let layer = new Konva.Layer();
|
|
stage.add(layer);
|
|
|
|
// Store elements for export
|
|
const elements = [];
|
|
let selectedElement = null;
|
|
let snapToGrid = true;
|
|
const gridSize = 10;
|
|
|
|
// Base fit scale (for auto-fitting to container)
|
|
let baseFitScale = 1;
|
|
let zoomScale = 1; // Zoom scale applied on top of base fit scale
|
|
|
|
// Auto-fit scaling function to fit canvas within container
|
|
function fitCanvasToContainer() {
|
|
if (!container || !stage) return;
|
|
|
|
// Get container dimensions (accounting for padding/borders)
|
|
const containerRect = container.getBoundingClientRect();
|
|
const containerWidth = containerRect.width - 40; // Account for padding
|
|
const containerHeight = containerRect.height - 40;
|
|
|
|
// Skip if container not ready
|
|
if (containerWidth <= 0 || containerHeight <= 0) return;
|
|
|
|
// Get actual page dimensions dynamically from current page size selector
|
|
const currentPageSizeSelector = document.getElementById('page-size-selector');
|
|
const currentPageSize = (currentPageSizeSelector && currentPageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const currentDimensions = PAGE_SIZE_DIMENSIONS[currentPageSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
const pageWidth = currentDimensions.width;
|
|
const pageHeight = currentDimensions.height;
|
|
|
|
// Calculate scale to fit within container while maintaining aspect ratio
|
|
const scaleX = containerWidth / pageWidth;
|
|
const scaleY = containerHeight / pageHeight;
|
|
baseFitScale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down
|
|
|
|
// Reset zoom when fitting
|
|
zoomScale = 1;
|
|
|
|
// Apply base fit scale
|
|
stage.scale({ x: baseFitScale, y: baseFitScale });
|
|
|
|
// Redraw
|
|
layer.draw();
|
|
|
|
console.log(`Canvas fitted: scale=${baseFitScale.toFixed(2)}, container=${containerWidth}x${containerHeight}, page=${pageWidth}x${pageHeight}`);
|
|
}
|
|
|
|
// Fit canvas on initialization (after a short delay to ensure container is rendered)
|
|
setTimeout(() => {
|
|
fitCanvasToContainer();
|
|
}, 100);
|
|
|
|
// Fit canvas on window resize
|
|
let resizeTimeout;
|
|
window.addEventListener('resize', function() {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(fitCanvasToContainer, 250);
|
|
});
|
|
|
|
// Add background with grid
|
|
// Use current page size dimensions to ensure correct size
|
|
const currentPageSizeSelector = document.getElementById('page-size-selector');
|
|
const currentPageSize = (currentPageSizeSelector && currentPageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const currentDimensions = PAGE_SIZE_DIMENSIONS[currentPageSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
const backgroundWidth = currentDimensions.width;
|
|
const backgroundHeight = currentDimensions.height;
|
|
|
|
let background = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: backgroundWidth,
|
|
height: backgroundHeight,
|
|
fill: 'white',
|
|
name: 'background'
|
|
});
|
|
layer.add(background);
|
|
|
|
// Add page border to clearly mark page boundaries
|
|
let pageBorder = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: backgroundWidth,
|
|
height: backgroundHeight,
|
|
stroke: '#667eea', // Blue border matching the UI theme
|
|
strokeWidth: 2,
|
|
fill: 'transparent',
|
|
name: 'page-border',
|
|
listening: false, // Don't interfere with interactions
|
|
perfectDrawEnabled: false // Better performance
|
|
});
|
|
layer.add(pageBorder);
|
|
pageBorder.moveToBottom(); // Keep it behind content but visible
|
|
|
|
// Add grid lines
|
|
function drawGrid() {
|
|
// Get current page size dimensions dynamically (not scaled stage dimensions)
|
|
const currentPageSizeSelector = document.getElementById('page-size-selector');
|
|
const currentPageSize = (currentPageSizeSelector && currentPageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const currentDimensions = PAGE_SIZE_DIMENSIONS[currentPageSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
const stageWidth = currentDimensions.width;
|
|
const stageHeight = currentDimensions.height;
|
|
|
|
// Remove old grid if exists
|
|
layer.find('.grid-line').forEach(line => line.destroy());
|
|
|
|
// Draw vertical lines
|
|
for (let i = 0; i < stageWidth / gridSize; i++) {
|
|
const line = new Konva.Line({
|
|
points: [i * gridSize, 0, i * gridSize, stageHeight],
|
|
stroke: '#e0e0e0',
|
|
strokeWidth: i % 5 === 0 ? 0.5 : 0.25,
|
|
name: 'grid-line',
|
|
listening: false
|
|
});
|
|
layer.add(line);
|
|
line.moveToBottom();
|
|
}
|
|
|
|
// Draw horizontal lines
|
|
for (let i = 0; i < stageHeight / gridSize; i++) {
|
|
const line = new Konva.Line({
|
|
points: [0, i * gridSize, stageWidth, i * gridSize],
|
|
stroke: '#e0e0e0',
|
|
strokeWidth: i % 5 === 0 ? 0.5 : 0.25,
|
|
name: 'grid-line',
|
|
listening: false
|
|
});
|
|
layer.add(line);
|
|
line.moveToBottom();
|
|
}
|
|
|
|
if (background) {
|
|
background.moveToBottom();
|
|
}
|
|
// Keep page border behind content but above background
|
|
const pageBorderInGrid = layer.findOne('[name="page-border"]');
|
|
if (pageBorderInGrid) {
|
|
pageBorderInGrid.moveToBottom();
|
|
if (background) {
|
|
background.moveToBottom();
|
|
}
|
|
}
|
|
layer.draw();
|
|
}
|
|
|
|
drawGrid();
|
|
|
|
// Element templates
|
|
{% raw %}
|
|
const templates = {
|
|
// Basic elements
|
|
'text': { text: 'Sample Text', fontSize: 14, fontFamily: 'Arial' },
|
|
'heading': { text: 'INVOICE', fontSize: 28, fontFamily: 'Arial', fontStyle: 'bold' },
|
|
|
|
// Company info
|
|
'company-name': { text: '{{ settings.company_name }}', fontSize: 20, fontFamily: 'Arial', fontStyle: 'bold' },
|
|
'company-info': { text: '{{ settings.company_address }}\\n{{ settings.company_email }}\\n{{ settings.company_phone }}', fontSize: 12 },
|
|
'company-address': { text: '{{ settings.company_address }}', fontSize: 12 },
|
|
'company-email': { text: 'Email: {{ settings.company_email }}', fontSize: 12 },
|
|
'company-phone': { text: 'Phone: {{ settings.company_phone }}', fontSize: 12 },
|
|
'company-website': { text: '{{ settings.company_website }}', fontSize: 12 },
|
|
'company-tax-id': { text: 'Tax ID: {{ settings.company_tax_id }}', fontSize: 12 },
|
|
|
|
// Invoice data
|
|
'invoice-number': { text: 'Invoice #: {{ invoice.invoice_number }}', fontSize: 14, fontStyle: 'bold' },
|
|
'invoice-date': { text: 'Date: {{ format_date(invoice.issue_date) }}', fontSize: 12 },
|
|
'due-date': { text: 'Due Date: {{ format_date(invoice.due_date) }}', fontSize: 12 },
|
|
'invoice-status': { text: 'Status: {{ invoice.status|upper }}', fontSize: 12 },
|
|
'client-info': { text: 'Bill To:\\n{{ invoice.client_name }}\\n{{ invoice.client_address }}', fontSize: 12 },
|
|
'client-name': { text: '{{ invoice.client_name }}', fontSize: 14, fontStyle: 'bold' },
|
|
'client-address': { text: '{{ invoice.client_address }}', fontSize: 12 },
|
|
'subtotal': { text: 'Subtotal: {{ format_money(invoice.subtotal) }}', fontSize: 14 },
|
|
'tax': { text: 'Tax ({{ invoice.tax_rate }}%): {{ format_money(invoice.tax_amount) }}', fontSize: 14 },
|
|
'totals': { text: 'Total: {{ format_money(invoice.total_amount) }}', fontSize: 16, fontStyle: 'bold' },
|
|
'notes': { text: 'Notes: {{ invoice.notes }}', fontSize: 11 },
|
|
'terms': { text: 'Terms: {{ invoice.terms }}', fontSize: 10 },
|
|
|
|
// Payment & Project
|
|
'payment-date': { text: 'Payment Date: {{ format_date(invoice.payment_date) if invoice.payment_date else "Pending" }}', fontSize: 12 },
|
|
'payment-method': { text: 'Payment Method: {{ invoice.payment_method or "N/A" }}', fontSize: 12 },
|
|
'payment-status': { text: 'Payment Status: {{ invoice.payment_status|upper }}', fontSize: 12 },
|
|
'amount-paid': { text: 'Amount Paid: {{ format_money(invoice.amount_paid) if invoice.amount_paid else "0.00" }}', fontSize: 12 },
|
|
'outstanding-amount': { text: 'Outstanding: {{ format_money(invoice.outstanding_amount) }}', fontSize: 12 },
|
|
'project-name': { text: 'Project: {{ invoice.project.name }}', fontSize: 12 },
|
|
'client-email': { text: 'Email: {{ invoice.client_email or invoice.client.email }}', fontSize: 12 },
|
|
'client-phone': { text: 'Phone: {{ invoice.client.phone or "" }}', fontSize: 12 },
|
|
|
|
// Advanced
|
|
'qr-code': { text: '[QR Code: {{ invoice.invoice_number }}]', fontSize: 10 },
|
|
'barcode': { text: '{{ invoice.invoice_number }}', fontSize: 10 },
|
|
'page-number': { text: 'Page 1', fontSize: 10 },
|
|
'date-now': { text: '{{ format_date(now) }}', fontSize: 10 },
|
|
'watermark': { text: 'CONFIDENTIAL', fontSize: 48, fontStyle: 'bold', opacity: 0.1 },
|
|
'bank-info': { text: 'Bank Account:\\n{{ settings.company_bank_info }}', fontSize: 11 },
|
|
'currency': { text: 'Currency: {{ invoice.currency_code }}', fontSize: 12 }
|
|
};
|
|
{% endraw %}
|
|
|
|
// Tab switching
|
|
document.querySelectorAll('.sidebar-tab').forEach(tab => {
|
|
tab.addEventListener('click', function() {
|
|
const targetTab = this.dataset.tab;
|
|
|
|
// Update tabs
|
|
document.querySelectorAll('.sidebar-tab').forEach(t => t.classList.remove('active'));
|
|
this.classList.add('active');
|
|
|
|
// Update content
|
|
document.querySelectorAll('.tab-pane').forEach(pane => pane.classList.remove('active'));
|
|
document.getElementById('tab-' + targetTab).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Search functionality
|
|
const searchBox = document.getElementById('sidebar-search');
|
|
searchBox.addEventListener('input', function() {
|
|
const searchTerm = this.value.toLowerCase().trim();
|
|
|
|
// Search in elements tab
|
|
const elementItems = document.querySelectorAll('#tab-elements .element-item');
|
|
elementItems.forEach(item => {
|
|
const text = item.textContent.toLowerCase();
|
|
if (text.includes(searchTerm) || searchTerm === '') {
|
|
item.style.display = 'flex';
|
|
} else {
|
|
item.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Search in variables tab
|
|
const variableItems = document.querySelectorAll('#tab-variables .variable-item');
|
|
variableItems.forEach(item => {
|
|
const text = item.textContent.toLowerCase();
|
|
if (text.includes(searchTerm) || searchTerm === '') {
|
|
item.style.display = 'block';
|
|
} else {
|
|
item.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Show/hide group titles
|
|
document.querySelectorAll('.element-group').forEach(group => {
|
|
const visibleItems = Array.from(group.querySelectorAll('.element-item, .variable-item'))
|
|
.filter(item => item.style.display !== 'none');
|
|
group.style.display = visibleItems.length > 0 ? 'block' : 'none';
|
|
});
|
|
});
|
|
|
|
// Handle variable clicks (add as custom text)
|
|
document.querySelectorAll('.variable-item').forEach(item => {
|
|
item.addEventListener('click', function() {
|
|
const variable = this.dataset.variable;
|
|
let text;
|
|
|
|
// Handle special loop variables
|
|
if (variable === 'for-loop-start') {
|
|
{% raw %}
|
|
text = '{% for item in invoice.items %}';
|
|
{% endraw %}
|
|
} else if (variable === 'for-loop-end') {
|
|
{% raw %}
|
|
text = '{% endfor %}';
|
|
{% endraw %}
|
|
} else {
|
|
{% raw %}
|
|
text = '{{ ' + variable + ' }}';
|
|
{% endraw %}
|
|
}
|
|
|
|
addElement('custom-text', 100, 100, text);
|
|
});
|
|
});
|
|
|
|
// Drag from sidebar
|
|
document.querySelectorAll('.element-item').forEach(item => {
|
|
item.addEventListener('click', function() {
|
|
const type = this.dataset.type;
|
|
|
|
// Handle custom text with prompt
|
|
if (type === 'custom-text') {
|
|
{% raw %}
|
|
const customText = prompt('Enter text (you can use variables like {{ invoice.invoice_number }}):', 'Custom Text');
|
|
{% endraw %}
|
|
if (customText !== null) {
|
|
addElement('custom-text', 100, 100, customText);
|
|
}
|
|
return;
|
|
}
|
|
|
|
addElement(type, 100, 100);
|
|
});
|
|
});
|
|
|
|
function addElement(type, x, y, customText) {
|
|
// Handle custom text
|
|
if (type === 'custom-text' || customText) {
|
|
const text = new Konva.Text({
|
|
x: x,
|
|
y: y,
|
|
text: customText || 'Custom Text',
|
|
fontSize: 14,
|
|
fontFamily: 'Arial',
|
|
fill: 'black',
|
|
draggable: true,
|
|
width: 400,
|
|
name: 'custom-text'
|
|
});
|
|
|
|
layer.add(text);
|
|
elements.push({ type: 'custom-text', node: text });
|
|
setupSelection(text);
|
|
layer.draw();
|
|
return;
|
|
}
|
|
|
|
const template = templates[type];
|
|
|
|
// Handle special cases first
|
|
if (type === 'line') {
|
|
const line = new Konva.Line({
|
|
points: [x, y, x + 200, y],
|
|
stroke: 'black',
|
|
strokeWidth: 2,
|
|
draggable: true,
|
|
name: 'line'
|
|
});
|
|
layer.add(line);
|
|
elements.push({ type: 'line', node: line });
|
|
setupSelection(line);
|
|
layer.draw();
|
|
return;
|
|
}
|
|
|
|
if (type === 'rectangle') {
|
|
const rect = new Konva.Rect({
|
|
x: x,
|
|
y: y,
|
|
width: 150,
|
|
height: 100,
|
|
fill: 'transparent',
|
|
stroke: 'black',
|
|
strokeWidth: 2,
|
|
draggable: true,
|
|
name: 'rectangle'
|
|
});
|
|
layer.add(rect);
|
|
elements.push({ type: 'rectangle', node: rect });
|
|
setupSelection(rect);
|
|
layer.draw();
|
|
return;
|
|
}
|
|
|
|
if (type === 'circle') {
|
|
const circle = new Konva.Circle({
|
|
x: x + 50,
|
|
y: y + 50,
|
|
radius: 50,
|
|
fill: 'transparent',
|
|
stroke: 'black',
|
|
strokeWidth: 2,
|
|
draggable: true,
|
|
name: 'circle'
|
|
});
|
|
layer.add(circle);
|
|
elements.push({ type: 'circle', node: circle });
|
|
setupSelection(circle);
|
|
layer.draw();
|
|
return;
|
|
}
|
|
|
|
if (type === 'logo' && LOGO_URL) {
|
|
Konva.Image.fromURL(LOGO_URL, function(image) {
|
|
image.setAttrs({
|
|
x: x,
|
|
y: y,
|
|
width: 100,
|
|
height: 50,
|
|
draggable: true,
|
|
name: 'logo'
|
|
});
|
|
layer.add(image);
|
|
elements.push({ type: 'logo', node: image });
|
|
setupSelection(image);
|
|
layer.draw();
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (type === 'items-table') {
|
|
addTable(x, y);
|
|
return;
|
|
}
|
|
|
|
if (type === 'expenses-table') {
|
|
addExpensesTable(x, y);
|
|
return;
|
|
}
|
|
|
|
if (type === 'decorative-image') {
|
|
// Create a placeholder for decorative images
|
|
const placeholder = new Konva.Group({
|
|
x: x,
|
|
y: y,
|
|
draggable: true,
|
|
name: 'decorative-image',
|
|
imageUrl: '' // Store image URL here
|
|
});
|
|
|
|
// Ensure name is set via multiple methods for proper serialization
|
|
placeholder.setAttr('name', 'decorative-image');
|
|
placeholder.name('decorative-image');
|
|
|
|
const rect = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
fill: '#f0f0f0',
|
|
stroke: '#999',
|
|
strokeWidth: 2,
|
|
dash: [5, 5]
|
|
});
|
|
|
|
const text = new Konva.Text({
|
|
x: 10,
|
|
y: 40,
|
|
text: 'Decorative\nImage',
|
|
fontSize: 12,
|
|
fontFamily: 'Arial',
|
|
fill: '#666',
|
|
align: 'center',
|
|
width: 80
|
|
});
|
|
|
|
const icon = new Konva.Text({
|
|
x: 35,
|
|
y: 15,
|
|
text: '🖼️',
|
|
fontSize: 24,
|
|
width: 30,
|
|
align: 'center'
|
|
});
|
|
|
|
placeholder.add(rect);
|
|
placeholder.add(icon);
|
|
placeholder.add(text);
|
|
|
|
layer.add(placeholder);
|
|
elements.push({ type: 'decorative-image', node: placeholder });
|
|
setupSelection(placeholder);
|
|
layer.draw();
|
|
return;
|
|
}
|
|
|
|
// Handle text-based elements
|
|
if (!template) return;
|
|
|
|
const text = new Konva.Text({
|
|
x: x,
|
|
y: y,
|
|
text: template.text,
|
|
fontSize: template.fontSize || 14,
|
|
fontFamily: template.fontFamily || 'Arial',
|
|
fontStyle: template.fontStyle || 'normal',
|
|
fill: 'black',
|
|
draggable: true,
|
|
width: 400,
|
|
opacity: template.opacity !== undefined ? template.opacity : 1,
|
|
name: type
|
|
});
|
|
|
|
layer.add(text);
|
|
elements.push({ type: type, node: text, template: template });
|
|
setupSelection(text);
|
|
checkElementWarnings(text);
|
|
updateWarningPanel();
|
|
layer.draw();
|
|
}
|
|
|
|
function addTable(x, y) {
|
|
const tableGroup = new Konva.Group({
|
|
x: x,
|
|
y: y,
|
|
draggable: true,
|
|
name: 'items-table'
|
|
});
|
|
|
|
const header = new Konva.Text({
|
|
text: 'Description | Qty | Price | Total',
|
|
fontSize: 12,
|
|
fontStyle: 'bold',
|
|
fill: 'black',
|
|
width: 500
|
|
});
|
|
|
|
const line = new Konva.Line({
|
|
points: [0, 20, 500, 20],
|
|
stroke: 'black',
|
|
strokeWidth: 1
|
|
});
|
|
|
|
{% raw %}
|
|
const items = new Konva.Text({
|
|
y: 25,
|
|
text: '{% for item in invoice.items %}\\n{{ item.description }} | {{ item.quantity }} | {{ format_money(item.unit_price) }} | {{ format_money(item.total_amount) }}\\n{% endfor %}',
|
|
fontSize: 11,
|
|
fill: 'black',
|
|
width: 500
|
|
});
|
|
{% endraw %}
|
|
|
|
tableGroup.add(header, line, items);
|
|
layer.add(tableGroup);
|
|
elements.push({ type: 'items-table', node: tableGroup });
|
|
setupSelection(tableGroup);
|
|
// Also setup selection on children so clicks on them select the parent Group
|
|
setupSelection(header);
|
|
setupSelection(line);
|
|
setupSelection(items);
|
|
checkElementWarnings(tableGroup);
|
|
updateWarningPanel();
|
|
layer.draw();
|
|
}
|
|
|
|
function addExpensesTable(x, y) {
|
|
const tableGroup = new Konva.Group({
|
|
x: x,
|
|
y: y,
|
|
draggable: true,
|
|
name: 'expenses-table'
|
|
});
|
|
|
|
const header = new Konva.Text({
|
|
text: 'Expense | Date | Category | Amount',
|
|
fontSize: 12,
|
|
fontStyle: 'bold',
|
|
fill: '#856404',
|
|
width: 500
|
|
});
|
|
|
|
const line = new Konva.Line({
|
|
points: [0, 20, 500, 20],
|
|
stroke: '#856404',
|
|
strokeWidth: 1
|
|
});
|
|
|
|
{% raw %}
|
|
const items = new Konva.Text({
|
|
y: 25,
|
|
text: '{% for expense in invoice.expenses %}\\n{{ expense.title }} | {{ expense.expense_date }} | {{ expense.category }} | {{ format_money(expense.total_amount) }}\\n{% endfor %}',
|
|
fontSize: 11,
|
|
fill: '#856404',
|
|
width: 500
|
|
});
|
|
{% endraw %}
|
|
|
|
tableGroup.add(header, line, items);
|
|
layer.add(tableGroup);
|
|
elements.push({ type: 'expenses-table', node: tableGroup });
|
|
setupSelection(tableGroup);
|
|
// Also setup selection on children so clicks on them select the parent Group
|
|
setupSelection(header);
|
|
setupSelection(line);
|
|
setupSelection(items);
|
|
layer.draw();
|
|
}
|
|
|
|
function setupSelection(node) {
|
|
node.on('click', function(e) {
|
|
// If clicking on a child of a table Group, select the Group instead
|
|
// UNLESS Ctrl/Cmd is held, which allows selecting the individual element
|
|
let targetNode = node;
|
|
if (node.parent && node.parent.attrs && (node.parent.attrs.name === 'items-table' || node.parent.attrs.name === 'expenses-table')) {
|
|
// Allow direct selection if Ctrl/Cmd is held
|
|
if (e.evt.ctrlKey || e.evt.metaKey) {
|
|
targetNode = node;
|
|
} else {
|
|
targetNode = node.parent;
|
|
}
|
|
}
|
|
selectElement(targetNode);
|
|
e.cancelBubble = true;
|
|
});
|
|
|
|
// Add double-click handler for text elements in tables to enable direct editing
|
|
// Check multiple ways to detect Text elements
|
|
const isTextNode = node.className === 'Text' || (node.getType && node.getType() === 'Text') ||
|
|
(node.constructor && node.constructor.name === 'Text');
|
|
if (isTextNode && node.parent && node.parent.attrs &&
|
|
(node.parent.attrs.name === 'items-table' || node.parent.attrs.name === 'expenses-table')) {
|
|
// Change cursor on hover to indicate editability
|
|
node.on('mouseenter', function() {
|
|
stage.container().style.cursor = 'text';
|
|
});
|
|
node.on('mouseleave', function() {
|
|
stage.container().style.cursor = 'default';
|
|
});
|
|
|
|
node.on('dblclick', function(e) {
|
|
// Select the individual text element for editing
|
|
selectElement(node);
|
|
e.cancelBubble = true;
|
|
// Focus the text content textarea in properties panel if available
|
|
setTimeout(() => {
|
|
const propText = document.getElementById('prop-text');
|
|
if (propText) {
|
|
propText.disabled = false;
|
|
propText.readOnly = false;
|
|
propText.focus();
|
|
propText.select();
|
|
}
|
|
}, 150);
|
|
});
|
|
}
|
|
|
|
// Add snap to grid on drag
|
|
node.on('dragmove', function() {
|
|
if (snapToGrid) {
|
|
node.position({
|
|
x: Math.round(node.x() / gridSize) * gridSize,
|
|
y: Math.round(node.y() / gridSize) * gridSize
|
|
});
|
|
}
|
|
// Check for overflow and overlap in real-time
|
|
checkElementWarnings(node);
|
|
});
|
|
|
|
node.on('dragend', function() {
|
|
// Update properties panel if visible
|
|
const propX = document.getElementById('prop-x');
|
|
const propY = document.getElementById('prop-y');
|
|
if (propX) propX.value = Math.round(node.x());
|
|
if (propY) propY.value = Math.round(node.y());
|
|
// Final check after drag ends
|
|
checkElementWarnings(node);
|
|
updateWarningPanel();
|
|
});
|
|
}
|
|
|
|
// Warning management functions
|
|
const elementWarnings = new Map(); // Store warnings for each element
|
|
const warningIndicators = new Map(); // Store warning indicators for each element
|
|
|
|
function checkElementWarnings(element) {
|
|
if (!element || element === background || element.className === 'Transformer') return;
|
|
|
|
const boundaryCheck = checkPageBoundaries(element);
|
|
const overlaps = checkOverlaps(element, layer.children);
|
|
|
|
const hasOverflow = !boundaryCheck.isValid;
|
|
const hasOverlap = overlaps.length > 0;
|
|
|
|
// Store warnings
|
|
elementWarnings.set(element, {
|
|
overflow: hasOverflow,
|
|
overlap: hasOverlap,
|
|
boundaryDetails: boundaryCheck,
|
|
overlaps: overlaps
|
|
});
|
|
|
|
// Apply visual warnings
|
|
applyVisualWarnings(element, hasOverflow, hasOverlap);
|
|
|
|
return { hasOverflow, hasOverlap, boundaryCheck, overlaps };
|
|
}
|
|
|
|
function applyVisualWarnings(element, hasOverflow, hasOverlap) {
|
|
// Remove existing warning names
|
|
if (element.hasName) {
|
|
if (element.hasName('element-overflow')) element.removeName('element-overflow');
|
|
if (element.hasName('element-overlap')) element.removeName('element-overlap');
|
|
}
|
|
|
|
// Remove existing warning indicator
|
|
const existingIndicator = warningIndicators.get(element);
|
|
if (existingIndicator) {
|
|
existingIndicator.destroy();
|
|
warningIndicators.delete(element);
|
|
}
|
|
|
|
if (hasOverflow || hasOverlap) {
|
|
// Add warning names for styling
|
|
if (hasOverflow) {
|
|
element.addName('element-overflow');
|
|
}
|
|
if (hasOverlap) {
|
|
element.addName('element-overlap');
|
|
}
|
|
|
|
// Warning indicator dots removed - warnings are now only shown in the warning panel
|
|
}
|
|
|
|
layer.draw();
|
|
}
|
|
|
|
function updateWarningPanel() {
|
|
const warningPanel = document.getElementById('warning-panel');
|
|
if (!warningPanel) return;
|
|
|
|
const warningList = document.getElementById('warning-list');
|
|
if (!warningList) return;
|
|
|
|
warningList.innerHTML = '';
|
|
|
|
let hasWarnings = false;
|
|
elementWarnings.forEach((warnings, element) => {
|
|
if (warnings.overflow || warnings.overlap) {
|
|
hasWarnings = true;
|
|
const item = document.createElement('div');
|
|
item.className = 'warning-item';
|
|
|
|
const elementName = element.attrs.name || element.className || 'Element';
|
|
let warningText = `${elementName}: `;
|
|
const issues = [];
|
|
|
|
if (warnings.overflow) {
|
|
const b = warnings.boundaryDetails;
|
|
if (b.overflowLeft > 0) issues.push(`Overflows left by ${Math.round(b.overflowLeft)}px`);
|
|
if (b.overflowRight > 0) issues.push(`Overflows right by ${Math.round(b.overflowRight)}px`);
|
|
if (b.overflowTop > 0) issues.push(`Overflows top by ${Math.round(b.overflowTop)}px`);
|
|
if (b.overflowBottom > 0) issues.push(`Overflows bottom by ${Math.round(b.overflowBottom)}px`);
|
|
}
|
|
|
|
if (warnings.overlap) {
|
|
issues.push(`Overlaps with ${warnings.overlaps.length} element(s)`);
|
|
}
|
|
|
|
warningText += issues.join(', ');
|
|
item.textContent = warningText;
|
|
|
|
item.addEventListener('click', function() {
|
|
selectElement(element);
|
|
// Bring element to front without changing stage position
|
|
element.moveToTop();
|
|
layer.draw();
|
|
});
|
|
|
|
warningList.appendChild(item);
|
|
}
|
|
});
|
|
|
|
if (!hasWarnings) {
|
|
warningList.innerHTML = '<div class="warning-list-empty">No warnings</div>';
|
|
}
|
|
}
|
|
|
|
// Check all elements for warnings
|
|
function checkAllElementWarnings() {
|
|
if (!layer) return;
|
|
layer.children.forEach(child => {
|
|
if (child !== background && child.className !== 'Transformer') {
|
|
checkElementWarnings(child);
|
|
}
|
|
});
|
|
updateWarningPanel();
|
|
}
|
|
|
|
// Refresh warnings button handler
|
|
document.getElementById('refresh-warnings')?.addEventListener('click', function() {
|
|
checkAllElementWarnings();
|
|
});
|
|
|
|
// Snap to grid toggle
|
|
document.getElementById('snap-to-grid')?.addEventListener('change', function(e) {
|
|
snapToGrid = e.target.checked;
|
|
});
|
|
|
|
function selectElement(node) {
|
|
// Remove previous selection
|
|
layer.find('Transformer').forEach(t => t.destroy());
|
|
|
|
selectedElement = node;
|
|
|
|
const transformer = new Konva.Transformer({
|
|
nodes: [node],
|
|
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
|
|
rotateEnabled: false,
|
|
borderStroke: '#667eea',
|
|
borderStrokeWidth: 2,
|
|
anchorFill: '#667eea',
|
|
anchorStroke: '#ffffff',
|
|
anchorStrokeWidth: 2,
|
|
anchorSize: 8,
|
|
boundBoxFunc: function(oldBox, newBox) {
|
|
if (newBox.width < 20 || newBox.height < 20) {
|
|
return oldBox;
|
|
}
|
|
return newBox;
|
|
}
|
|
});
|
|
|
|
// For Image nodes, convert scale changes to width/height changes
|
|
// This ensures the size is stored correctly when resizing
|
|
if (node.className === 'Image') {
|
|
// Get the actual displayed dimensions (accounting for any existing scale)
|
|
const image = node;
|
|
const currentScaleX = image.scaleX();
|
|
const currentScaleY = image.scaleY();
|
|
let originalWidth = image.width() * currentScaleX;
|
|
let originalHeight = image.height() * currentScaleY;
|
|
|
|
// If image already has scale, normalize it first
|
|
if (currentScaleX !== 1 || currentScaleY !== 1) {
|
|
image.width(originalWidth);
|
|
image.height(originalHeight);
|
|
image.scaleX(1);
|
|
image.scaleY(1);
|
|
layer.draw();
|
|
}
|
|
|
|
// Handle transform end - convert scale to width/height
|
|
const handleTransformEnd = function() {
|
|
const scaleX = image.scaleX();
|
|
const scaleY = image.scaleY();
|
|
|
|
// Only update if scale has changed
|
|
if (scaleX !== 1 || scaleY !== 1) {
|
|
// Update width and height based on scale
|
|
const newWidth = originalWidth * scaleX;
|
|
const newHeight = originalHeight * scaleY;
|
|
|
|
// Reset scale to 1 and apply the new dimensions
|
|
image.width(newWidth);
|
|
image.height(newHeight);
|
|
image.scaleX(1);
|
|
image.scaleY(1);
|
|
|
|
// Update original dimensions for next transform
|
|
originalWidth = newWidth;
|
|
originalHeight = newHeight;
|
|
|
|
// Update properties panel if visible
|
|
if (selectedElement === image) {
|
|
updatePropertiesPanel(image);
|
|
}
|
|
|
|
layer.draw();
|
|
}
|
|
};
|
|
|
|
transformer.on('transformend', handleTransformEnd);
|
|
|
|
// Also update on transform (during drag) to keep properties panel in sync
|
|
transformer.on('transform', function() {
|
|
if (selectedElement === image) {
|
|
updatePropertiesPanel(image);
|
|
}
|
|
// Check warnings during transform
|
|
if (selectedElement) {
|
|
checkElementWarnings(selectedElement);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add transformend handler for all elements
|
|
transformer.on('transformend', function() {
|
|
if (selectedElement) {
|
|
checkElementWarnings(selectedElement);
|
|
updateWarningPanel();
|
|
}
|
|
});
|
|
|
|
layer.add(transformer);
|
|
|
|
// Explicitly hide rotation handle if it exists (backup for rotateEnabled: false)
|
|
transformer.forceUpdate();
|
|
const rotationHandle = transformer.findOne('.rotater');
|
|
if (rotationHandle) {
|
|
rotationHandle.visible(false);
|
|
}
|
|
|
|
layer.draw();
|
|
|
|
// Update properties panel
|
|
updatePropertiesPanel(node);
|
|
|
|
// Check warnings for selected element
|
|
if (node) {
|
|
checkElementWarnings(node);
|
|
updateWarningPanel();
|
|
}
|
|
}
|
|
|
|
function updatePropertiesPanel(node) {
|
|
const propsContent = document.getElementById('properties-content');
|
|
if (!propsContent) {
|
|
console.error('Properties content element not found!');
|
|
return;
|
|
}
|
|
|
|
if (!node) {
|
|
console.error('Node is null or undefined!');
|
|
propsContent.innerHTML = '<p class="text-sm text-red-500">Error: No element selected</p>';
|
|
return;
|
|
}
|
|
|
|
const attrs = node.attrs || {};
|
|
// Try multiple ways to detect Text elements for better compatibility
|
|
const className = node.className || (node.getType && node.getType()) || 'Unknown';
|
|
const isTextElement = className === 'Text' || (node.getType && node.getType() === 'Text') ||
|
|
(node.constructor && node.constructor.name === 'Text');
|
|
|
|
// Debug logging
|
|
console.log('updatePropertiesPanel called:', {
|
|
className: className,
|
|
getType: node.getType ? node.getType() : 'N/A',
|
|
constructorName: node.constructor ? node.constructor.name : 'unknown',
|
|
isTextElement: isTextElement,
|
|
name: attrs.name,
|
|
attrs: attrs,
|
|
node: node
|
|
});
|
|
|
|
let html = '<div class="space-y-4">';
|
|
|
|
// Element type
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Element Type</div>';
|
|
html += `<input type="text" value="${attrs.name || className}" disabled class="property-input bg-gray-100">`;
|
|
html += '</div>';
|
|
|
|
// Position
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Position X</div>';
|
|
html += `<input type="number" id="prop-x" value="${Math.round(attrs.x || 0)}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Position Y</div>';
|
|
html += `<input type="number" id="prop-y" value="${Math.round(attrs.y || 0)}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
// Text-specific properties - check if it's a Text element (even if inside a table group)
|
|
if (isTextElement) {
|
|
// Properly escape text for HTML and convert \n to actual newlines
|
|
const textContent = (attrs.text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\\n/g, '\n');
|
|
// Use more rows for table items which might be longer
|
|
const isTableText = node.parent && node.parent.attrs && (node.parent.attrs.name === 'items-table' || node.parent.attrs.name === 'expenses-table');
|
|
const textareaRows = isTableText ? 8 : 3;
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Text Content</div>';
|
|
html += `<textarea id="prop-text" class="property-input" rows="${textareaRows}">${textContent}</textarea>`;
|
|
if (isTableText) {
|
|
html += '<p class="text-xs text-gray-500 mt-1">Double-click table text to edit, or edit here in the properties panel.</p>';
|
|
}
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Font Size</div>';
|
|
html += `<input type="number" id="prop-fontSize" value="${attrs.fontSize || 14}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Font Family</div>';
|
|
html += `<select id="prop-fontFamily" class="property-input">`;
|
|
const fonts = ['Arial', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana', 'Helvetica'];
|
|
fonts.forEach(font => {
|
|
const selected = (attrs.fontFamily === font) ? 'selected' : '';
|
|
html += `<option value="${font}" ${selected}>${font}</option>`;
|
|
});
|
|
html += '</select>';
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Font Style</div>';
|
|
html += `<select id="prop-fontStyle" class="property-input">`;
|
|
html += `<option value="normal" ${attrs.fontStyle === 'normal' ? 'selected' : ''}>Normal</option>`;
|
|
html += `<option value="bold" ${attrs.fontStyle === 'bold' ? 'selected' : ''}>Bold</option>`;
|
|
html += `<option value="italic" ${attrs.fontStyle === 'italic' ? 'selected' : ''}>Italic</option>`;
|
|
html += '</select>';
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Text Color</div>';
|
|
html += `<input type="color" id="prop-fill" value="${rgbToHex(attrs.fill || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Width</div>';
|
|
html += `<input type="number" id="prop-width" value="${attrs.width || 400}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Opacity</div>';
|
|
html += `<input type="range" id="prop-opacity" min="0" max="1" step="0.1" value="${attrs.opacity !== undefined ? attrs.opacity : 1}" class="property-input">`;
|
|
html += `<span id="opacity-value">${(attrs.opacity !== undefined ? attrs.opacity : 1) * 100}%</span>`;
|
|
html += '</div>';
|
|
}
|
|
|
|
// Shape-specific properties
|
|
if (className === 'Rect' || className === 'Circle') {
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Fill Color</div>';
|
|
html += `<input type="color" id="prop-fill" value="${rgbToHex(attrs.fill || '#ffffff')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Stroke Color</div>';
|
|
html += `<input type="color" id="prop-stroke" value="${rgbToHex(attrs.stroke || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Stroke Width</div>';
|
|
html += `<input type="number" id="prop-strokeWidth" value="${attrs.strokeWidth || 1}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
if (className === 'Rect') {
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Width</div>';
|
|
html += `<input type="number" id="prop-width" value="${attrs.width || 100}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Height</div>';
|
|
html += `<input type="number" id="prop-height" value="${attrs.height || 100}" class="property-input">`;
|
|
html += '</div>';
|
|
}
|
|
|
|
if (className === 'Circle') {
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Radius</div>';
|
|
html += `<input type="number" id="prop-radius" value="${attrs.radius || 50}" class="property-input">`;
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
// Line-specific properties
|
|
if (className === 'Line') {
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Stroke Color</div>';
|
|
html += `<input type="color" id="prop-stroke" value="${rgbToHex(attrs.stroke || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Stroke Width</div>';
|
|
html += `<input type="number" id="prop-strokeWidth" value="${attrs.strokeWidth || 1}" class="property-input">`;
|
|
html += '</div>';
|
|
}
|
|
|
|
// Decorative image properties
|
|
// Use includes() to handle cases where name might be modified (e.g., 'decorative-image element-overlap')
|
|
if (attrs.name && attrs.name.includes('decorative-image')) {
|
|
// Use getAttr to ensure we get the imageUrl
|
|
const imageUrl = node.getAttr('imageUrl') || attrs.imageUrl || '';
|
|
console.log('Properties panel - decorative image URL:', imageUrl);
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Decorative Image</div>';
|
|
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
html += '<div style="margin-bottom: 1rem;">';
|
|
html += `<img src="${imageUrl}" alt="Decorative image" style="max-width: 100%; max-height: 150px; border: 1px solid #ddd; border-radius: 4px;">`;
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<label for="decorative-image-upload" class="property-label">Upload Image</label>';
|
|
html += '<input type="file" id="decorative-image-upload" accept="image/png,image/jpeg,image/jpg,image/gif,image/webp" style="display: none;">';
|
|
html += `<button type="button" id="btn-upload-decorative-image" class="property-input" style="background: #667eea; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; width: 100%;">`;
|
|
html += '<i class="fas fa-upload mr-2"></i>';
|
|
html += imageUrl ? '{{ _("Change Image") }}' : '{{ _("Upload Image") }}';
|
|
html += '</button>';
|
|
html += '</div>';
|
|
|
|
if (imageUrl) {
|
|
html += '<div class="property-group">';
|
|
html += '<button type="button" id="btn-remove-decorative-image" class="property-input" style="background: #ef4444; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; width: 100%;">';
|
|
html += '<i class="fas fa-trash mr-2"></i>{{ _("Remove Image") }}';
|
|
html += '</button>';
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '<input type="hidden" id="prop-image-url" value="' + (imageUrl || '') + '">';
|
|
html += '</div>';
|
|
}
|
|
|
|
// Group-specific properties (for items-table and expenses-table)
|
|
// Check both className and constructor name for Groups
|
|
const isGroup = className === 'Group' || (node.constructor && node.constructor.name === 'Group');
|
|
const isTableGroup = isGroup && (attrs.name === 'items-table' || attrs.name === 'expenses-table');
|
|
console.log('Group check:', {
|
|
className: className,
|
|
constructorName: node.constructor ? node.constructor.name : 'unknown',
|
|
isGroup: isGroup,
|
|
name: attrs.name,
|
|
isTableGroup: isTableGroup
|
|
});
|
|
|
|
if (isTableGroup) {
|
|
try {
|
|
// Find child elements - use getChildren() to get direct children
|
|
const children = node.getChildren();
|
|
const textElements = children.filter(child => child.className === 'Text');
|
|
const lineElements = children.filter(child => child.className === 'Line');
|
|
|
|
const headerText = textElements[0]; // First text is header
|
|
const line = lineElements[0];
|
|
const itemsText = textElements[1]; // Second text is items
|
|
|
|
const headerAttrs = headerText ? headerText.attrs : {};
|
|
const lineAttrs = line ? line.attrs : {};
|
|
const itemsAttrs = itemsText ? itemsText.attrs : {};
|
|
|
|
// Debug logging
|
|
console.log('Table Group detected:', attrs.name);
|
|
console.log('Children count:', children.length);
|
|
console.log('Text elements:', textElements.length);
|
|
console.log('Line elements:', lineElements.length);
|
|
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Table Header</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Header Text</div>';
|
|
html += `<textarea id="prop-table-header-text" class="property-input" rows="2">${(headerAttrs.text || '').replace(/\\n/g, '\n')}</textarea>`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Header Font Size</div>';
|
|
html += `<input type="number" id="prop-table-header-fontSize" value="${headerAttrs.fontSize || 12}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Header Font Style</div>';
|
|
html += `<select id="prop-table-header-fontStyle" class="property-input">`;
|
|
html += `<option value="normal" ${headerAttrs.fontStyle === 'normal' ? 'selected' : ''}>Normal</option>`;
|
|
html += `<option value="bold" ${headerAttrs.fontStyle === 'bold' ? 'selected' : ''}>Bold</option>`;
|
|
html += `<option value="italic" ${headerAttrs.fontStyle === 'italic' ? 'selected' : ''}>Italic</option>`;
|
|
html += '</select>';
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Header Color</div>';
|
|
html += `<input type="color" id="prop-table-header-fill" value="${rgbToHex(headerAttrs.fill || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Table Items</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Items Template</div>';
|
|
html += `<textarea id="prop-table-items-text" class="property-input" rows="4">${(itemsAttrs.text || '').replace(/\\n/g, '\n')}</textarea>`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Items Font Size</div>';
|
|
html += `<input type="number" id="prop-table-items-fontSize" value="${itemsAttrs.fontSize || 11}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Items Color</div>';
|
|
html += `<input type="color" id="prop-table-items-fill" value="${rgbToHex(itemsAttrs.fill || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Table Separator Line</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Line Color</div>';
|
|
html += `<input type="color" id="prop-table-line-stroke" value="${rgbToHex(lineAttrs.stroke || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Line Width</div>';
|
|
html += `<input type="number" id="prop-table-line-strokeWidth" value="${lineAttrs.strokeWidth || 1}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Table Dimensions</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Table Width</div>';
|
|
const tableWidth = headerAttrs.width || itemsAttrs.width || 500;
|
|
html += `<input type="number" id="prop-table-width" value="${tableWidth}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
} catch (error) {
|
|
console.error('Error processing table group:', error);
|
|
html += '<div class="property-group"><p class="text-sm text-red-500">Error loading table properties: ' + error.message + '</p></div>';
|
|
}
|
|
} else if (isGroup) {
|
|
// Fallback for Groups that aren't recognized as tables
|
|
// Show at least basic info
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Group Information</div>';
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Group Name</div>';
|
|
html += `<input type="text" value="${attrs.name || 'Unnamed Group'}" disabled class="property-input bg-gray-100">`;
|
|
html += '</div>';
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Children Count</div>';
|
|
try {
|
|
const childrenCount = node.getChildren ? node.getChildren().length : (node.children ? node.children.length : 0);
|
|
html += `<input type="text" value="${childrenCount} children" disabled class="property-input bg-gray-100">`;
|
|
} catch (e) {
|
|
html += `<input type="text" value="Unknown" disabled class="property-input bg-gray-100">`;
|
|
}
|
|
html += '</div>';
|
|
html += '<p class="text-sm text-gray-500 italic mt-2">This is a Group element. If this is a table, make sure it has the name "items-table" or "expenses-table".</p>';
|
|
html += '</div>';
|
|
}
|
|
|
|
// Layer controls
|
|
html += '<div class="property-group" style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label">Layer Order</div>';
|
|
html += '<div style="display: flex; gap: 0.5rem;">';
|
|
html += '<button onclick="moveLayer(\'up\')" class="btn btn-sm btn-secondary flex-1"><i class="fas fa-arrow-up"></i></button>';
|
|
html += '<button onclick="moveLayer(\'down\')" class="btn btn-sm btn-secondary flex-1"><i class="fas fa-arrow-down"></i></button>';
|
|
html += '<button onclick="moveLayer(\'top\')" class="btn btn-sm btn-secondary flex-1"><i class="fas fa-angle-double-up"></i></button>';
|
|
html += '<button onclick="moveLayer(\'bottom\')" class="btn btn-sm btn-secondary flex-1"><i class="fas fa-angle-double-down"></i></button>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
console.log('Setting properties HTML, length:', html.length);
|
|
console.log('HTML preview (first 500 chars):', html.substring(0, 500));
|
|
propsContent.innerHTML = html;
|
|
console.log('Properties HTML set successfully');
|
|
console.log('Properties content element exists:', !!propsContent);
|
|
console.log('Properties content innerHTML length after setting:', propsContent.innerHTML.length);
|
|
|
|
// Attach event listeners
|
|
try {
|
|
attachPropertyListeners();
|
|
console.log('Property listeners attached successfully');
|
|
} catch (error) {
|
|
console.error('Error attaching property listeners:', error);
|
|
}
|
|
}
|
|
|
|
function rgbToHex(color) {
|
|
if (color.startsWith('#')) return color;
|
|
if (color === 'transparent') return '#ffffff';
|
|
if (color === 'black') return '#000000';
|
|
if (color === 'white') return '#ffffff';
|
|
return color;
|
|
}
|
|
|
|
function attachPropertyListeners() {
|
|
if (!selectedElement) return;
|
|
|
|
const propX = document.getElementById('prop-x');
|
|
const propY = document.getElementById('prop-y');
|
|
const propText = document.getElementById('prop-text');
|
|
const propFontSize = document.getElementById('prop-fontSize');
|
|
const propFontFamily = document.getElementById('prop-fontFamily');
|
|
const propFontStyle = document.getElementById('prop-fontStyle');
|
|
const propFill = document.getElementById('prop-fill');
|
|
const propStroke = document.getElementById('prop-stroke');
|
|
const propStrokeWidth = document.getElementById('prop-strokeWidth');
|
|
const propWidth = document.getElementById('prop-width');
|
|
const propHeight = document.getElementById('prop-height');
|
|
const propRadius = document.getElementById('prop-radius');
|
|
const propOpacity = document.getElementById('prop-opacity');
|
|
|
|
if (propX) propX.addEventListener('input', () => { selectedElement.x(parseFloat(propX.value)); layer.draw(); });
|
|
if (propY) propY.addEventListener('input', () => { selectedElement.y(parseFloat(propY.value)); layer.draw(); });
|
|
if (propText) {
|
|
// Add both input and change listeners to ensure changes are captured
|
|
propText.addEventListener('input', function() {
|
|
if (selectedElement && selectedElement.text) {
|
|
selectedElement.text(this.value);
|
|
layer.draw();
|
|
}
|
|
});
|
|
propText.addEventListener('change', function() {
|
|
if (selectedElement && selectedElement.text) {
|
|
selectedElement.text(this.value);
|
|
layer.draw();
|
|
}
|
|
});
|
|
// Also handle paste events
|
|
propText.addEventListener('paste', function() {
|
|
setTimeout(() => {
|
|
if (selectedElement && selectedElement.text) {
|
|
selectedElement.text(this.value);
|
|
layer.draw();
|
|
}
|
|
}, 10);
|
|
});
|
|
}
|
|
if (propFontSize) propFontSize.addEventListener('input', () => { selectedElement.fontSize(parseFloat(propFontSize.value)); layer.draw(); });
|
|
if (propFontFamily) propFontFamily.addEventListener('change', () => { selectedElement.fontFamily(propFontFamily.value); layer.draw(); });
|
|
if (propFontStyle) propFontStyle.addEventListener('change', () => { selectedElement.fontStyle(propFontStyle.value); layer.draw(); });
|
|
if (propFill) propFill.addEventListener('input', () => { selectedElement.fill(propFill.value); layer.draw(); });
|
|
if (propStroke) propStroke.addEventListener('input', () => { selectedElement.stroke(propStroke.value); layer.draw(); });
|
|
if (propStrokeWidth) propStrokeWidth.addEventListener('input', () => { selectedElement.strokeWidth(parseFloat(propStrokeWidth.value)); layer.draw(); });
|
|
if (propWidth) propWidth.addEventListener('input', () => { selectedElement.width(parseFloat(propWidth.value)); layer.draw(); });
|
|
if (propHeight) propHeight.addEventListener('input', () => { selectedElement.height(parseFloat(propHeight.value)); layer.draw(); });
|
|
if (propRadius) propRadius.addEventListener('input', () => { selectedElement.radius(parseFloat(propRadius.value)); layer.draw(); });
|
|
if (propOpacity) {
|
|
propOpacity.addEventListener('input', () => {
|
|
selectedElement.opacity(parseFloat(propOpacity.value));
|
|
document.getElementById('opacity-value').textContent = (propOpacity.value * 100) + '%';
|
|
layer.draw();
|
|
});
|
|
}
|
|
|
|
// Function to update decorative image element with uploaded image (moved outside to be accessible)
|
|
window.updateDecorativeImageElement = function(element, imageUrl) {
|
|
const elementName = element && element.attrs ? element.attrs.name : '';
|
|
if (!element || !elementName || !elementName.includes('decorative-image')) {
|
|
// Silently return if element is not a decorative image (this can happen when buttons exist but wrong element is selected)
|
|
return;
|
|
}
|
|
|
|
console.log('[INVOICE] updateDecorativeImageElement called with URL:', imageUrl);
|
|
|
|
// Store image URL in element attributes - use setAttr to ensure it's saved
|
|
element.setAttr('imageUrl', imageUrl || '');
|
|
// Also ensure it's in attrs for proper serialization
|
|
element.attrs.imageUrl = imageUrl || '';
|
|
|
|
// Verify it was set correctly
|
|
const verifyUrl = element.getAttr('imageUrl') || element.attrs.imageUrl || '';
|
|
console.log('[INVOICE] ✅ imageUrl set and verified:', verifyUrl, 'matches:', verifyUrl === imageUrl);
|
|
|
|
// Remove existing image if any
|
|
const existingImage = element.findOne('Image');
|
|
if (existingImage) {
|
|
existingImage.destroy();
|
|
}
|
|
|
|
// Remove all placeholder elements
|
|
const rect = element.findOne('Rect');
|
|
const allTexts = element.find('Text');
|
|
allTexts.forEach(textNode => {
|
|
const text = textNode.text();
|
|
if (text.includes('Decorative') || text.includes('🖼️')) {
|
|
textNode.destroy();
|
|
}
|
|
});
|
|
|
|
if (rect) {
|
|
rect.destroy();
|
|
}
|
|
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
console.log('Loading image from URL:', imageUrl);
|
|
|
|
// Create a temporary image to check if it loads
|
|
const tempImg = new Image();
|
|
tempImg.crossOrigin = 'anonymous';
|
|
|
|
tempImg.onload = function() {
|
|
console.log('Image verified, loading into Konva. Dimensions:', tempImg.width, 'x', tempImg.height);
|
|
|
|
// Load and display the actual image
|
|
Konva.Image.fromURL(imageUrl, function(konvaImage) {
|
|
console.log('Konva image loaded successfully, dimensions:', konvaImage.width(), 'x', konvaImage.height());
|
|
|
|
// Use actual image dimensions or default to 100x100
|
|
let width = konvaImage.width() || tempImg.width || 100;
|
|
let height = konvaImage.height() || tempImg.height || 100;
|
|
const aspectRatio = width / height;
|
|
|
|
// Scale to fit within 200x200 but maintain aspect ratio
|
|
const maxSize = 200;
|
|
if (width > maxSize || height > maxSize) {
|
|
if (width > height) {
|
|
width = maxSize;
|
|
height = maxSize / aspectRatio;
|
|
} else {
|
|
height = maxSize;
|
|
width = maxSize * aspectRatio;
|
|
}
|
|
}
|
|
|
|
// Preserve transparency - don't set fill or opacity that would override image alpha
|
|
konvaImage.setAttrs({
|
|
x: 0,
|
|
y: 0,
|
|
width: width,
|
|
height: height,
|
|
// Ensure transparency is preserved - Konva.Image preserves alpha by default
|
|
opacity: 1.0, // Don't override image's built-in alpha channel
|
|
visible: true, // Explicitly make it visible
|
|
listening: true
|
|
});
|
|
|
|
// Update group size
|
|
element.width(width);
|
|
element.height(height);
|
|
element.visible(true); // Ensure group is visible
|
|
|
|
// Add image
|
|
element.add(konvaImage);
|
|
layer.draw();
|
|
stage.draw();
|
|
console.log('Image added to element, new dimensions:', width, 'x', height);
|
|
|
|
// Force a redraw of the properties panel to show the image
|
|
if (selectedElement === element) {
|
|
setTimeout(() => {
|
|
updatePropertiesPanel(element);
|
|
attachPropertyListeners();
|
|
}, 100);
|
|
}
|
|
}, function(error) {
|
|
console.error('Konva failed to load image:', error);
|
|
alert('Failed to load image into canvas. The image URL is saved but may not display correctly.');
|
|
});
|
|
};
|
|
|
|
tempImg.onerror = function() {
|
|
console.error('Failed to load image (onerror):', imageUrl);
|
|
alert('Failed to load image. Please check the image URL: ' + imageUrl);
|
|
};
|
|
|
|
tempImg.src = imageUrl;
|
|
} else {
|
|
// Restore placeholder if image removed
|
|
const newRect = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
fill: '#f0f0f0',
|
|
stroke: '#999',
|
|
strokeWidth: 2,
|
|
dash: [5, 5]
|
|
});
|
|
element.add(newRect);
|
|
|
|
const newText = new Konva.Text({
|
|
x: 10,
|
|
y: 40,
|
|
text: 'Decorative\nImage',
|
|
fontSize: 12,
|
|
fontFamily: 'Arial',
|
|
fill: '#666',
|
|
align: 'center',
|
|
width: 80
|
|
});
|
|
element.add(newText);
|
|
|
|
const newIcon = new Konva.Text({
|
|
x: 35,
|
|
y: 15,
|
|
text: '🖼️',
|
|
fontSize: 24,
|
|
width: 30,
|
|
align: 'center'
|
|
});
|
|
element.add(newIcon);
|
|
|
|
element.width(100);
|
|
element.height(100);
|
|
element.visible(true);
|
|
layer.draw();
|
|
stage.draw();
|
|
}
|
|
};
|
|
|
|
// Decorative image upload handler
|
|
const decorativeImageUpload = document.getElementById('decorative-image-upload');
|
|
const btnUploadDecorativeImage = document.getElementById('btn-upload-decorative-image');
|
|
const btnRemoveDecorativeImage = document.getElementById('btn-remove-decorative-image');
|
|
|
|
if (btnUploadDecorativeImage) {
|
|
btnUploadDecorativeImage.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log('Upload button clicked');
|
|
console.log('Selected element:', selectedElement);
|
|
const elementName = selectedElement ? (selectedElement.attrs ? selectedElement.attrs.name : 'no attrs') : 'no element';
|
|
console.log('Selected element name:', elementName);
|
|
|
|
// Only proceed if selected element is a decorative image
|
|
// Check if name includes 'decorative-image' (it might be 'decorative-image element-overlap' or similar)
|
|
if (!selectedElement || !selectedElement.attrs || !elementName || !elementName.includes('decorative-image')) {
|
|
console.warn('Upload button clicked but selected element is not a decorative image. Name:', elementName);
|
|
// Silently return - button might exist in DOM from previous selection
|
|
return;
|
|
}
|
|
// Get fresh reference to file input in case DOM was recreated
|
|
const currentInput = document.getElementById('decorative-image-upload');
|
|
console.log('File input element:', currentInput);
|
|
if (currentInput) {
|
|
console.log('Triggering file input click');
|
|
currentInput.click();
|
|
} else {
|
|
console.error('File input element not found');
|
|
}
|
|
});
|
|
}
|
|
|
|
if (decorativeImageUpload) {
|
|
decorativeImageUpload.addEventListener('change', function(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
console.log('File input change event, file:', file);
|
|
console.log('Selected element:', selectedElement);
|
|
const elementName = selectedElement ? (selectedElement.attrs ? selectedElement.attrs.name : 'no attrs') : 'no element';
|
|
console.log('Selected element name:', elementName);
|
|
|
|
// Only proceed if selected element is a decorative image
|
|
// Check if name includes 'decorative-image' (it might be 'decorative-image element-overlap' or similar)
|
|
if (!selectedElement || !selectedElement.attrs || !elementName || !elementName.includes('decorative-image')) {
|
|
console.warn('File input change but selected element is not a decorative image. Name:', elementName);
|
|
// Silently return - file input might be triggered when wrong element is selected
|
|
return;
|
|
}
|
|
|
|
// Validate file type
|
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
|
if (!allowedTypes.includes(file.type)) {
|
|
alert('{{ _("Only image files (PNG, JPG, JPEG, GIF, WEBP) are allowed") }}');
|
|
return;
|
|
}
|
|
|
|
// Validate file size (5MB)
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
alert('{{ _("File size must be less than 5MB") }}');
|
|
return;
|
|
}
|
|
|
|
// Upload image
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('csrf_token', '{{ csrf_token() }}');
|
|
|
|
// Get fresh references since buttons might be recreated
|
|
const currentBtnUpload = document.getElementById('btn-upload-decorative-image');
|
|
if (currentBtnUpload) {
|
|
currentBtnUpload.disabled = true;
|
|
currentBtnUpload.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>{{ _("Uploading...") }}';
|
|
}
|
|
|
|
fetch('{{ url_for("admin.upload_template_image") }}', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
console.log('Upload response:', data);
|
|
if (data.success && data.image_url) {
|
|
// Update the decorative image element with the uploaded image
|
|
const elementName = selectedElement && selectedElement.attrs ? selectedElement.attrs.name : '';
|
|
if (window.updateDecorativeImageElement && selectedElement && elementName && elementName.includes('decorative-image')) {
|
|
window.updateDecorativeImageElement(selectedElement, data.image_url);
|
|
// Refresh properties panel after image loads (wait longer for image to actually load)
|
|
setTimeout(() => {
|
|
updatePropertiesPanel(selectedElement);
|
|
// Re-attach event listeners after properties panel refresh
|
|
attachPropertyListeners();
|
|
}, 1000);
|
|
} else {
|
|
console.error('updateDecorativeImageElement function not found or invalid element');
|
|
alert('{{ _("Error: Image update function not found") }}');
|
|
}
|
|
} else {
|
|
alert(data.error || '{{ _("Failed to upload image") }}');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Upload error:', error);
|
|
alert('{{ _("Failed to upload image") }}');
|
|
})
|
|
.finally(() => {
|
|
const currentBtnUpload = document.getElementById('btn-upload-decorative-image');
|
|
if (currentBtnUpload) {
|
|
currentBtnUpload.disabled = false;
|
|
currentBtnUpload.innerHTML = '<i class="fas fa-upload mr-2"></i>{{ _("Change Image") }}';
|
|
}
|
|
const currentInput = document.getElementById('decorative-image-upload');
|
|
if (currentInput) {
|
|
currentInput.value = '';
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
if (btnRemoveDecorativeImage) {
|
|
btnRemoveDecorativeImage.addEventListener('click', () => {
|
|
// Only proceed if selected element is a decorative image
|
|
const elementName = selectedElement && selectedElement.attrs ? selectedElement.attrs.name : '';
|
|
if (!selectedElement || !elementName || !elementName.includes('decorative-image')) {
|
|
// Silently return - button might exist in DOM from previous selection
|
|
return;
|
|
}
|
|
if (confirm('{{ _("Are you sure you want to remove this image?") }}')) {
|
|
if (window.updateDecorativeImageElement) {
|
|
window.updateDecorativeImageElement(selectedElement, null);
|
|
}
|
|
updatePropertiesPanel(selectedElement);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Table-specific property listeners
|
|
const propTableHeaderText = document.getElementById('prop-table-header-text');
|
|
const propTableHeaderFontSize = document.getElementById('prop-table-header-fontSize');
|
|
const propTableHeaderFontStyle = document.getElementById('prop-table-header-fontStyle');
|
|
const propTableHeaderFill = document.getElementById('prop-table-header-fill');
|
|
const propTableItemsText = document.getElementById('prop-table-items-text');
|
|
const propTableItemsFontSize = document.getElementById('prop-table-items-fontSize');
|
|
const propTableItemsFill = document.getElementById('prop-table-items-fill');
|
|
const propTableLineStroke = document.getElementById('prop-table-line-stroke');
|
|
const propTableLineStrokeWidth = document.getElementById('prop-table-line-strokeWidth');
|
|
const propTableWidth = document.getElementById('prop-table-width');
|
|
|
|
if (propTableHeaderText) {
|
|
propTableHeaderText.addEventListener('input', () => {
|
|
const headerText = selectedElement.find('Text')[0];
|
|
if (headerText) {
|
|
headerText.text(propTableHeaderText.value.replace(/\n/g, '\\n'));
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableHeaderFontSize) {
|
|
propTableHeaderFontSize.addEventListener('input', () => {
|
|
const headerText = selectedElement.find('Text')[0];
|
|
if (headerText) {
|
|
headerText.fontSize(parseFloat(propTableHeaderFontSize.value));
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableHeaderFontStyle) {
|
|
propTableHeaderFontStyle.addEventListener('change', () => {
|
|
const headerText = selectedElement.find('Text')[0];
|
|
if (headerText) {
|
|
headerText.fontStyle(propTableHeaderFontStyle.value);
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableHeaderFill) {
|
|
propTableHeaderFill.addEventListener('input', () => {
|
|
const headerText = selectedElement.find('Text')[0];
|
|
if (headerText) {
|
|
headerText.fill(propTableHeaderFill.value);
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableItemsText) {
|
|
propTableItemsText.addEventListener('input', () => {
|
|
const itemsText = selectedElement.find('Text')[1];
|
|
if (itemsText) {
|
|
itemsText.text(propTableItemsText.value.replace(/\n/g, '\\n'));
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableItemsFontSize) {
|
|
propTableItemsFontSize.addEventListener('input', () => {
|
|
const itemsText = selectedElement.find('Text')[1];
|
|
if (itemsText) {
|
|
itemsText.fontSize(parseFloat(propTableItemsFontSize.value));
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableItemsFill) {
|
|
propTableItemsFill.addEventListener('input', () => {
|
|
const itemsText = selectedElement.find('Text')[1];
|
|
if (itemsText) {
|
|
itemsText.fill(propTableItemsFill.value);
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableLineStroke) {
|
|
propTableLineStroke.addEventListener('input', () => {
|
|
const line = selectedElement.find('Line')[0];
|
|
if (line) {
|
|
line.stroke(propTableLineStroke.value);
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableLineStrokeWidth) {
|
|
propTableLineStrokeWidth.addEventListener('input', () => {
|
|
const line = selectedElement.find('Line')[0];
|
|
if (line) {
|
|
line.strokeWidth(parseFloat(propTableLineStrokeWidth.value));
|
|
layer.draw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (propTableWidth) {
|
|
propTableWidth.addEventListener('input', () => {
|
|
const width = parseFloat(propTableWidth.value);
|
|
const headerText = selectedElement.find('Text')[0];
|
|
const itemsText = selectedElement.find('Text')[1];
|
|
const line = selectedElement.find('Line')[0];
|
|
|
|
if (headerText) headerText.width(width);
|
|
if (itemsText) itemsText.width(width);
|
|
if (line) {
|
|
const currentY = line.points()[1];
|
|
line.points([0, currentY, width, currentY]);
|
|
}
|
|
layer.draw();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Layer management functions
|
|
window.moveLayer = function(direction) {
|
|
if (!selectedElement) return;
|
|
|
|
if (direction === 'up') {
|
|
selectedElement.moveUp();
|
|
} else if (direction === 'down') {
|
|
selectedElement.moveDown();
|
|
} else if (direction === 'top') {
|
|
selectedElement.moveToTop();
|
|
} else if (direction === 'bottom') {
|
|
selectedElement.moveToBottom();
|
|
}
|
|
|
|
layer.draw();
|
|
};
|
|
|
|
// Toolbar actions
|
|
document.getElementById('btn-delete').addEventListener('click', function() {
|
|
if (selectedElement) {
|
|
selectedElement.destroy();
|
|
layer.find('Transformer').forEach(t => t.destroy());
|
|
selectedElement = null;
|
|
layer.draw();
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-clear').addEventListener('click', async function() {
|
|
const confirmed = await showConfirm(
|
|
'{{ _("Clear all elements?") }}',
|
|
{
|
|
title: '{{ _("Clear Canvas") }}',
|
|
confirmText: '{{ _("Clear") }}',
|
|
cancelText: '{{ _("Cancel") }}',
|
|
variant: 'warning'
|
|
}
|
|
);
|
|
if (confirmed) {
|
|
layer.destroyChildren();
|
|
layer.add(background);
|
|
// Re-add page border after clearing
|
|
const pageBorderAfterClear = layer.findOne('[name="page-border"]');
|
|
if (!pageBorderAfterClear) {
|
|
const currentPageSizeSelector = document.getElementById('page-size-selector');
|
|
const currentPageSize = (currentPageSizeSelector && currentPageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const currentDimensions = PAGE_SIZE_DIMENSIONS[currentPageSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
const newPageBorder = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: currentDimensions.width,
|
|
height: currentDimensions.height,
|
|
stroke: '#667eea',
|
|
strokeWidth: 2,
|
|
fill: 'transparent',
|
|
name: 'page-border',
|
|
listening: false,
|
|
perfectDrawEnabled: false
|
|
});
|
|
layer.add(newPageBorder);
|
|
newPageBorder.moveToBottom();
|
|
}
|
|
elements.length = 0;
|
|
selectedElement = null;
|
|
layer.draw();
|
|
}
|
|
});
|
|
|
|
// Zoom controls (zoom scale is declared above with baseFitScale)
|
|
const zoomDisplay = document.getElementById('zoom-display');
|
|
|
|
function updateZoomDisplay() {
|
|
if (zoomDisplay) {
|
|
const percentage = Math.round((baseFitScale * zoomScale) * 100);
|
|
zoomDisplay.textContent = `${percentage}%`;
|
|
}
|
|
}
|
|
|
|
// Initialize zoom display after initial fit
|
|
setTimeout(() => {
|
|
updateZoomDisplay();
|
|
}, 150);
|
|
|
|
document.getElementById('btn-zoom-in').addEventListener('click', function() {
|
|
zoomScale = Math.min(zoomScale + 0.1, 2);
|
|
stage.scale({ x: baseFitScale * zoomScale, y: baseFitScale * zoomScale });
|
|
layer.draw();
|
|
updateZoomDisplay();
|
|
});
|
|
|
|
document.getElementById('btn-zoom-out').addEventListener('click', function() {
|
|
zoomScale = Math.max(zoomScale - 0.1, 0.5);
|
|
stage.scale({ x: baseFitScale * zoomScale, y: baseFitScale * zoomScale });
|
|
layer.draw();
|
|
updateZoomDisplay();
|
|
});
|
|
|
|
// Generate preview
|
|
// ============================================
|
|
// Enhanced PDF Preview Modal - Complete Rewrite
|
|
// ============================================
|
|
|
|
try {
|
|
// State Management
|
|
const previewState = {
|
|
isOpen: false,
|
|
zoomLevel: 100,
|
|
pageSize: CURRENT_PAGE_SIZE || 'A4',
|
|
isLoading: false,
|
|
hasError: false,
|
|
errorMessage: '',
|
|
currentRequest: null,
|
|
isFullscreen: false,
|
|
fitMode: 'auto' // 'auto', 'width', 'height', 'actual'
|
|
};
|
|
|
|
// DOM Elements - with safety checks
|
|
const previewModal = document.getElementById('preview-modal');
|
|
const previewModalOverlay = document.getElementById('preview-modal-overlay');
|
|
const previewModalClose = document.getElementById('preview-modal-close');
|
|
const previewFrame = document.getElementById('preview-frame');
|
|
const previewFrameWrapper = document.getElementById('preview-frame-wrapper');
|
|
const previewLoading = document.getElementById('preview-loading');
|
|
const previewError = document.getElementById('preview-error');
|
|
const previewErrorMessage = document.getElementById('preview-error-message');
|
|
const previewPageSizeBadge = document.getElementById('preview-page-size-badge');
|
|
const previewPageSizeSelect = document.getElementById('preview-page-size-select');
|
|
const previewZoomSlider = document.getElementById('preview-zoom-slider');
|
|
const previewZoomDisplay = document.getElementById('preview-zoom-display');
|
|
const previewBtnZoomIn = document.getElementById('preview-btn-zoom-in');
|
|
const previewBtnZoomOut = document.getElementById('preview-btn-zoom-out');
|
|
const previewBtnZoomReset = document.getElementById('preview-btn-zoom-reset');
|
|
const previewBtnFitWidth = document.getElementById('preview-btn-fit-width');
|
|
const previewBtnFitHeight = document.getElementById('preview-btn-fit-height');
|
|
const previewBtnActualSize = document.getElementById('preview-btn-actual-size');
|
|
const previewBtnRefresh = document.getElementById('preview-btn-refresh');
|
|
const previewBtnFullscreen = document.getElementById('preview-btn-fullscreen');
|
|
const previewBtnRetry = document.getElementById('preview-btn-retry');
|
|
const previewBtnCloseError = document.getElementById('preview-btn-close-error');
|
|
|
|
// Early return if essential elements are missing
|
|
if (!previewModal || !previewFrame) {
|
|
console.error('Preview modal essential elements not found. Modal may not work correctly.');
|
|
}
|
|
|
|
// Utility Functions
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
function updateZoomDisplay() {
|
|
if (previewZoomDisplay) {
|
|
previewZoomDisplay.textContent = previewState.zoomLevel + '%';
|
|
}
|
|
if (previewZoomSlider) {
|
|
previewZoomSlider.value = previewState.zoomLevel;
|
|
}
|
|
}
|
|
|
|
function applyZoom() {
|
|
if (!previewFrame || !previewFrame.contentDocument) return;
|
|
const zoom = previewState.zoomLevel / 100;
|
|
|
|
// Apply zoom to the preview-container inside the iframe, not the wrapper
|
|
const container = previewFrame.contentDocument.querySelector('.preview-container');
|
|
if (container) {
|
|
container.style.transform = `scale(${zoom})`;
|
|
// Use top center origin so content expands from top, allowing scrolling to top
|
|
container.style.transformOrigin = 'top center';
|
|
}
|
|
|
|
// Ensure iframe body and html allow scrolling and can expand
|
|
const iframeDoc = previewFrame.contentDocument;
|
|
const iframeBody = iframeDoc.body;
|
|
const iframeHtml = iframeDoc.documentElement;
|
|
if (iframeBody) {
|
|
iframeBody.style.overflow = 'auto';
|
|
iframeBody.style.overflowX = 'auto';
|
|
iframeBody.style.overflowY = 'auto';
|
|
// Remove height constraints to allow expansion
|
|
iframeBody.style.height = 'auto';
|
|
iframeBody.style.minHeight = '100vh';
|
|
}
|
|
if (iframeHtml) {
|
|
iframeHtml.style.overflow = 'auto';
|
|
iframeHtml.style.overflowX = 'auto';
|
|
iframeHtml.style.overflowY = 'auto';
|
|
// Remove height constraint to allow expansion
|
|
iframeHtml.style.height = 'auto';
|
|
}
|
|
|
|
// Reset wrapper transform - we don't want to scale the iframe itself
|
|
if (previewFrameWrapper) {
|
|
previewFrameWrapper.style.transform = 'none';
|
|
previewFrameWrapper.style.transformOrigin = 'initial';
|
|
}
|
|
}
|
|
|
|
function setZoom(level) {
|
|
previewState.zoomLevel = Math.max(25, Math.min(200, level));
|
|
updateZoomDisplay();
|
|
applyZoom();
|
|
}
|
|
|
|
function zoomIn() {
|
|
const steps = [25, 50, 75, 100, 125, 150, 175, 200];
|
|
const current = previewState.zoomLevel;
|
|
const next = steps.find(s => s > current) || 200;
|
|
setZoom(next);
|
|
}
|
|
|
|
function zoomOut() {
|
|
const steps = [25, 50, 75, 100, 125, 150, 175, 200];
|
|
const current = previewState.zoomLevel;
|
|
const next = [...steps].reverse().find(s => s < current) || 25;
|
|
setZoom(next);
|
|
}
|
|
|
|
function resetZoom() {
|
|
setZoom(100);
|
|
previewState.fitMode = 'auto';
|
|
updateFitButtons();
|
|
}
|
|
|
|
function fitToPage() {
|
|
// Fit the entire page (both width and height) to fit in viewport
|
|
try {
|
|
if (!previewFrame || !previewFrameWrapper) {
|
|
previewState.fitMode = 'auto';
|
|
updateFitButtons();
|
|
return;
|
|
}
|
|
if (!previewFrame.contentDocument) {
|
|
previewState.fitMode = 'auto';
|
|
updateFitButtons();
|
|
return;
|
|
}
|
|
const wrapper = previewFrameWrapper;
|
|
if (!wrapper.parentElement) return;
|
|
|
|
// Get available dimensions accounting for padding
|
|
const parentPadding = 48; // 1.5rem * 2 = 48px
|
|
const availableWidth = wrapper.parentElement.clientWidth - parentPadding;
|
|
const availableHeight = wrapper.parentElement.clientHeight - parentPadding;
|
|
|
|
const frameDoc = previewFrame.contentDocument;
|
|
const container = frameDoc.querySelector('.preview-container');
|
|
if (container) {
|
|
// Get base dimensions (before any zoom)
|
|
const containerWidth = container.offsetWidth || container.scrollWidth || container.clientWidth;
|
|
const containerHeight = container.offsetHeight || container.scrollHeight || container.clientHeight;
|
|
const currentZoom = previewState.zoomLevel / 100;
|
|
const baseWidth = containerWidth / (currentZoom || 1);
|
|
const baseHeight = containerHeight / (currentZoom || 1);
|
|
|
|
if (baseWidth > 0 && baseHeight > 0 && availableWidth > 0 && availableHeight > 0) {
|
|
// Calculate zoom to fit both dimensions with margin
|
|
const margin = 40; // Margin on all sides
|
|
const zoomX = ((availableWidth - margin) / baseWidth) * 100;
|
|
const zoomY = ((availableHeight - margin) / baseHeight) * 100;
|
|
// Use the smaller zoom to ensure everything fits
|
|
const zoom = Math.min(zoomX, zoomY, 200); // Cap at 200%
|
|
setZoom(Math.max(25, Math.round(zoom))); // Min 25%
|
|
previewState.fitMode = 'auto';
|
|
updateFitButtons();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Error in fitToPage:', error);
|
|
// Fallback
|
|
setZoom(100);
|
|
previewState.fitMode = 'auto';
|
|
updateFitButtons();
|
|
}
|
|
}
|
|
|
|
function fitToWidth() {
|
|
try {
|
|
if (!previewFrame || !previewFrameWrapper) {
|
|
previewState.fitMode = 'width';
|
|
updateFitButtons();
|
|
return;
|
|
}
|
|
if (!previewFrame.contentDocument) {
|
|
previewState.fitMode = 'width';
|
|
updateFitButtons();
|
|
return;
|
|
}
|
|
const wrapper = previewFrameWrapper;
|
|
if (!wrapper.parentElement) return;
|
|
|
|
const parentPadding = 48;
|
|
const availableWidth = wrapper.parentElement.clientWidth - parentPadding;
|
|
|
|
const frameDoc = previewFrame.contentDocument;
|
|
const container = frameDoc.querySelector('.preview-container');
|
|
if (container) {
|
|
const containerWidth = container.offsetWidth || container.scrollWidth || container.clientWidth;
|
|
const currentZoom = previewState.zoomLevel / 100;
|
|
const baseWidth = containerWidth / (currentZoom || 1);
|
|
|
|
if (baseWidth > 0 && availableWidth > 0) {
|
|
const margin = 20;
|
|
const zoom = ((availableWidth - margin) / baseWidth) * 100;
|
|
setZoom(Math.min(200, Math.max(25, Math.round(zoom))));
|
|
previewState.fitMode = 'width';
|
|
updateFitButtons();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Error in fitToWidth:', error);
|
|
setZoom(100);
|
|
previewState.fitMode = 'width';
|
|
updateFitButtons();
|
|
}
|
|
}
|
|
|
|
function fitToHeight() {
|
|
try {
|
|
if (!previewFrame || !previewFrameWrapper) {
|
|
previewState.fitMode = 'height';
|
|
updateFitButtons();
|
|
return;
|
|
}
|
|
if (!previewFrame.contentDocument) {
|
|
previewState.fitMode = 'height';
|
|
updateFitButtons();
|
|
return;
|
|
}
|
|
const wrapper = previewFrameWrapper;
|
|
if (!wrapper.parentElement) return;
|
|
|
|
const parentPadding = 48;
|
|
const availableHeight = wrapper.parentElement.clientHeight - parentPadding;
|
|
|
|
const frameDoc = previewFrame.contentDocument;
|
|
const container = frameDoc.querySelector('.preview-container');
|
|
if (container) {
|
|
const containerHeight = container.offsetHeight || container.scrollHeight || container.clientHeight;
|
|
const currentZoom = previewState.zoomLevel / 100;
|
|
const baseHeight = containerHeight / (currentZoom || 1);
|
|
|
|
if (baseHeight > 0 && availableHeight > 0) {
|
|
const margin = 20;
|
|
const zoom = ((availableHeight - margin) / baseHeight) * 100;
|
|
setZoom(Math.min(200, Math.max(25, Math.round(zoom))));
|
|
previewState.fitMode = 'height';
|
|
updateFitButtons();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Error in fitToHeight:', error);
|
|
setZoom(100);
|
|
previewState.fitMode = 'height';
|
|
updateFitButtons();
|
|
}
|
|
}
|
|
|
|
function actualSize() {
|
|
setZoom(100);
|
|
previewState.fitMode = 'actual';
|
|
updateFitButtons();
|
|
}
|
|
|
|
function updateFitButtons() {
|
|
const activeClass = 'preview-control-btn-active';
|
|
[previewBtnFitWidth, previewBtnFitHeight, previewBtnActualSize].forEach(btn => {
|
|
if (btn) btn.classList.remove(activeClass);
|
|
});
|
|
|
|
if (previewState.fitMode === 'width' && previewBtnFitWidth) {
|
|
previewBtnFitWidth.classList.add(activeClass);
|
|
} else if (previewState.fitMode === 'height' && previewBtnFitHeight) {
|
|
previewBtnFitHeight.classList.add(activeClass);
|
|
} else if (previewState.fitMode === 'actual' && previewBtnActualSize) {
|
|
previewBtnActualSize.classList.add(activeClass);
|
|
}
|
|
}
|
|
|
|
function showLoading() {
|
|
previewState.isLoading = true;
|
|
previewState.hasError = false;
|
|
if (previewLoading) previewLoading.classList.add('active');
|
|
if (previewError) previewError.style.display = 'none';
|
|
if (previewFrame) previewFrame.style.display = 'none';
|
|
|
|
const progressBar = document.getElementById('preview-loading-progress-bar');
|
|
if (progressBar) {
|
|
progressBar.style.width = '0%';
|
|
let progress = 0;
|
|
const interval = setInterval(() => {
|
|
if (!previewState.isLoading) {
|
|
clearInterval(interval);
|
|
return;
|
|
}
|
|
progress = Math.min(90, progress + Math.random() * 10);
|
|
progressBar.style.width = progress + '%';
|
|
}, 200);
|
|
}
|
|
}
|
|
|
|
function hideLoading() {
|
|
previewState.isLoading = false;
|
|
if (previewLoading) previewLoading.classList.remove('active');
|
|
if (previewFrame) previewFrame.style.display = 'block';
|
|
}
|
|
|
|
function showError(message) {
|
|
previewState.hasError = true;
|
|
previewState.errorMessage = message;
|
|
if (previewErrorMessage) previewErrorMessage.textContent = message;
|
|
if (previewError) previewError.style.display = 'flex';
|
|
if (previewLoading) previewLoading.classList.remove('active');
|
|
if (previewFrame) previewFrame.style.display = 'none';
|
|
}
|
|
|
|
function hideError() {
|
|
previewState.hasError = false;
|
|
if (previewError) previewError.style.display = 'none';
|
|
}
|
|
|
|
function updatePageSizeBadge(size) {
|
|
if (previewPageSizeBadge) previewPageSizeBadge.textContent = size;
|
|
if (previewPageSizeSelect) {
|
|
previewPageSizeSelect.value = size;
|
|
}
|
|
}
|
|
|
|
function openPreviewModal() {
|
|
if (!previewModal) {
|
|
console.error('Preview modal element not found');
|
|
return;
|
|
}
|
|
previewState.isOpen = true;
|
|
previewModal.style.display = 'block';
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
// Sync preview page size with main page size selector
|
|
const mainPageSizeSelector = document.getElementById('page-size-selector');
|
|
if (mainPageSizeSelector) {
|
|
previewState.pageSize = mainPageSizeSelector.value || CURRENT_PAGE_SIZE || 'A4';
|
|
} else {
|
|
previewState.pageSize = CURRENT_PAGE_SIZE || 'A4';
|
|
}
|
|
|
|
setZoom(100);
|
|
updatePageSizeBadge(previewState.pageSize);
|
|
|
|
if (previewPageSizeSelect) {
|
|
previewPageSizeSelect.value = previewState.pageSize;
|
|
}
|
|
|
|
previewModal.setAttribute('aria-hidden', 'false');
|
|
setTimeout(() => {
|
|
if (previewBtnRefresh) {
|
|
previewBtnRefresh.focus();
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
function closePreviewModal() {
|
|
if (!previewModal) return;
|
|
previewState.isOpen = false;
|
|
previewModal.style.display = 'none';
|
|
document.body.style.overflow = '';
|
|
previewModal.setAttribute('aria-hidden', 'true');
|
|
|
|
// Cancel any ongoing requests
|
|
if (previewState.currentRequest && typeof previewState.currentRequest.abort === 'function') {
|
|
try {
|
|
previewState.currentRequest.abort();
|
|
} catch (e) {
|
|
console.warn('Error aborting request:', e);
|
|
}
|
|
previewState.currentRequest = null;
|
|
}
|
|
|
|
hideError();
|
|
hideLoading();
|
|
previewState.isFullscreen = false;
|
|
updateFullscreenButton();
|
|
}
|
|
|
|
function toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
const modalContent = document.querySelector('.preview-modal-content');
|
|
if (modalContent && modalContent.requestFullscreen) {
|
|
modalContent.requestFullscreen().then(() => {
|
|
previewState.isFullscreen = true;
|
|
updateFullscreenButton();
|
|
}).catch(err => {
|
|
console.error('Error entering fullscreen:', err);
|
|
});
|
|
} else {
|
|
previewModal.requestFullscreen().then(() => {
|
|
previewState.isFullscreen = true;
|
|
updateFullscreenButton();
|
|
}).catch(err => {
|
|
console.error('Error entering fullscreen:', err);
|
|
});
|
|
}
|
|
} else {
|
|
document.exitFullscreen().then(() => {
|
|
previewState.isFullscreen = false;
|
|
updateFullscreenButton();
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateFullscreenButton() {
|
|
if (!previewBtnFullscreen) return;
|
|
const icon = previewBtnFullscreen.querySelector('i');
|
|
if (icon) {
|
|
if (previewState.isFullscreen) {
|
|
icon.className = 'fas fa-compress';
|
|
previewBtnFullscreen.setAttribute('title', '{{ _("Exit Fullscreen") }}');
|
|
} else {
|
|
icon.className = 'fas fa-expand';
|
|
previewBtnFullscreen.setAttribute('title', '{{ _("Toggle Fullscreen") }}');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadPreview(pageSize = null) {
|
|
if (pageSize) {
|
|
previewState.pageSize = pageSize;
|
|
updatePageSizeBadge(pageSize);
|
|
// Update preview selector to match
|
|
if (previewPageSizeSelect) {
|
|
previewPageSizeSelect.value = pageSize;
|
|
}
|
|
}
|
|
|
|
showLoading();
|
|
hideError();
|
|
|
|
// For preview, we want to load the saved template for the selected page size
|
|
// So we send the page_size and let the backend load the correct template
|
|
// We still send the current canvas JSON as a fallback, but backend will use saved template if available
|
|
const { html, css, json } = generateCode();
|
|
|
|
const fd = new FormData();
|
|
fd.append('html', html);
|
|
fd.append('css', css);
|
|
// Send template_json only if we're previewing the current page size
|
|
// Otherwise, let backend load the template for the selected page size
|
|
const mainPageSizeSelector = document.getElementById('page-size-selector');
|
|
const currentMainPageSize = (mainPageSizeSelector && mainPageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const pageSizeForPreview = previewState.pageSize;
|
|
|
|
// Only send template_json if previewing the same size as the current editor
|
|
// Otherwise, backend will load the saved template for the selected page size
|
|
if (pageSizeForPreview === currentMainPageSize && json && json.trim()) {
|
|
fd.append('template_json', json);
|
|
}
|
|
|
|
fd.append('page_size', pageSizeForPreview);
|
|
fd.append('csrf_token', CSRF_TOKEN);
|
|
|
|
const previewUrl = PREVIEW_URL + '?t=' + Date.now();
|
|
|
|
// Create abort controller for request cancellation (if supported)
|
|
let controller = null;
|
|
let signal = null;
|
|
if (typeof AbortController !== 'undefined' && typeof AbortSignal !== 'undefined') {
|
|
try {
|
|
controller = new AbortController();
|
|
if (controller && controller.signal) {
|
|
// Verify signal is a proper AbortSignal instance
|
|
const testSignal = controller.signal;
|
|
if (testSignal instanceof AbortSignal) {
|
|
signal = testSignal;
|
|
previewState.currentRequest = controller;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('AbortController not available:', e);
|
|
}
|
|
}
|
|
|
|
// Build fetch options
|
|
const fetchOptions = {
|
|
method: 'POST',
|
|
body: fd,
|
|
cache: 'no-cache',
|
|
headers: {
|
|
'Cache-Control': 'no-cache'
|
|
}
|
|
};
|
|
|
|
// Only add signal if it's a valid AbortSignal instance and not aborted
|
|
if (signal instanceof AbortSignal && !signal.aborted) {
|
|
fetchOptions.signal = signal;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(previewUrl, fetchOptions);
|
|
|
|
if (!response || !response.ok) {
|
|
throw new Error(`HTTP error! status: ${response ? response.status : 'unknown'}`);
|
|
}
|
|
|
|
const previewHtml = await response.text();
|
|
|
|
// Check if request was cancelled
|
|
if (controller && controller.signal && controller.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
|
|
doc.open();
|
|
doc.write(previewHtml);
|
|
doc.close();
|
|
|
|
// Wait for iframe to load
|
|
previewFrame.onload = () => {
|
|
hideLoading();
|
|
setTimeout(() => {
|
|
try {
|
|
// Always fit to page on initial load to show complete quote
|
|
setTimeout(() => {
|
|
try {
|
|
fitToPage();
|
|
} catch (fitError) {
|
|
console.warn('Error fitting to page:', fitError);
|
|
// Fallback to 100% zoom
|
|
setZoom(100);
|
|
}
|
|
}, 300);
|
|
} catch (zoomError) {
|
|
console.warn('Error applying zoom:', zoomError);
|
|
hideLoading();
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
// Also handle load event if already loaded
|
|
if (previewFrame.contentDocument && previewFrame.contentDocument.readyState === 'complete') {
|
|
try {
|
|
previewFrame.onload();
|
|
} catch (e) {
|
|
console.warn('Error calling onload handler:', e);
|
|
}
|
|
}
|
|
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') {
|
|
console.log('Preview request cancelled');
|
|
return;
|
|
}
|
|
console.error('Preview error:', err);
|
|
showError(err.message || '{{ _("Failed to load preview. Please try again.") }}');
|
|
} finally {
|
|
previewState.currentRequest = null;
|
|
}
|
|
}
|
|
|
|
// Event Listeners - with safety checks
|
|
if (previewModalOverlay) {
|
|
previewModalOverlay.addEventListener('click', closePreviewModal);
|
|
}
|
|
if (previewModalClose) {
|
|
previewModalClose.addEventListener('click', closePreviewModal);
|
|
}
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', function(e) {
|
|
if (!previewState.isOpen) return;
|
|
|
|
if (e.key === 'Escape') {
|
|
if (previewState.isFullscreen) {
|
|
toggleFullscreen();
|
|
} else {
|
|
closePreviewModal();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
|
if (e.key === '+' || e.key === '=') {
|
|
e.preventDefault();
|
|
zoomIn();
|
|
} else if (e.key === '-' || e.key === '_') {
|
|
e.preventDefault();
|
|
zoomOut();
|
|
} else if (e.key === '0') {
|
|
e.preventDefault();
|
|
resetZoom();
|
|
}
|
|
}
|
|
|
|
if (e.key === 'F11') {
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
}
|
|
});
|
|
|
|
// Zoom controls
|
|
if (previewBtnZoomIn) previewBtnZoomIn.addEventListener('click', zoomIn);
|
|
if (previewBtnZoomOut) previewBtnZoomOut.addEventListener('click', zoomOut);
|
|
if (previewBtnZoomReset) previewBtnZoomReset.addEventListener('click', resetZoom);
|
|
if (previewBtnFitWidth) previewBtnFitWidth.addEventListener('click', fitToWidth);
|
|
if (previewBtnFitHeight) previewBtnFitHeight.addEventListener('click', fitToHeight);
|
|
if (previewBtnActualSize) previewBtnActualSize.addEventListener('click', actualSize);
|
|
|
|
// Zoom slider
|
|
if (previewZoomSlider) {
|
|
const debouncedZoom = debounce((value) => {
|
|
setZoom(parseInt(value));
|
|
}, 50);
|
|
|
|
previewZoomSlider.addEventListener('input', (e) => {
|
|
if (previewZoomDisplay) {
|
|
previewZoomDisplay.textContent = e.target.value + '%';
|
|
}
|
|
debouncedZoom(e.target.value);
|
|
});
|
|
}
|
|
|
|
// Page size selector
|
|
if (previewPageSizeSelect) {
|
|
previewPageSizeSelect.addEventListener('change', (e) => {
|
|
const newSize = e.target.value;
|
|
if (confirm('{{ _("Changing page size will reload the preview with the template for that size. Continue?") }}')) {
|
|
const mainPageSizeSelector = document.getElementById('page-size-selector');
|
|
if (mainPageSizeSelector) {
|
|
mainPageSizeSelector.value = newSize;
|
|
}
|
|
loadPreview(newSize);
|
|
} else {
|
|
e.target.value = previewState.pageSize;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Toolbar buttons
|
|
if (previewBtnRefresh) previewBtnRefresh.addEventListener('click', () => loadPreview());
|
|
if (previewBtnFullscreen) previewBtnFullscreen.addEventListener('click', toggleFullscreen);
|
|
|
|
// Error handling
|
|
if (previewBtnRetry) {
|
|
previewBtnRetry.addEventListener('click', () => {
|
|
hideError();
|
|
loadPreview();
|
|
});
|
|
}
|
|
|
|
if (previewBtnCloseError) {
|
|
previewBtnCloseError.addEventListener('click', () => {
|
|
hideError();
|
|
});
|
|
}
|
|
|
|
// Fullscreen change detection
|
|
document.addEventListener('fullscreenchange', () => {
|
|
previewState.isFullscreen = !!document.fullscreenElement;
|
|
updateFullscreenButton();
|
|
});
|
|
|
|
// Main preview button
|
|
const btnPreview = document.getElementById('btn-preview');
|
|
if (btnPreview) {
|
|
btnPreview.addEventListener('click', function() {
|
|
openPreviewModal();
|
|
loadPreview();
|
|
});
|
|
} else {
|
|
console.warn('Preview button not found');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error initializing preview modal:', error);
|
|
const btnPreview = document.getElementById('btn-preview');
|
|
if (btnPreview) {
|
|
btnPreview.addEventListener('click', function() {
|
|
alert('{{ _("Preview functionality is currently unavailable. Please check the browser console for errors.") }}');
|
|
});
|
|
}
|
|
}
|
|
|
|
// Generate code from canvas
|
|
function generateCode() {
|
|
try {
|
|
// Get current page size and dimensions
|
|
const currentSize = (pageSizeSelector && pageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
|
|
// Convert pixels to points (1 point = 1/72 inch, at 72 DPI: 1px = 1pt)
|
|
// Konva canvas uses 72 DPI, so pixels = points
|
|
function pxToPt(px) {
|
|
return Math.round(px || 0);
|
|
}
|
|
|
|
// Build ReportLab template JSON structure
|
|
const templateJson = {
|
|
page: {
|
|
size: currentSize,
|
|
margin: {
|
|
top: 20, // 20mm margin
|
|
right: 20,
|
|
bottom: 20,
|
|
left: 20
|
|
}
|
|
},
|
|
elements: [],
|
|
styles: {
|
|
default: {
|
|
font: "Helvetica",
|
|
size: 10,
|
|
color: "#000000"
|
|
}
|
|
}
|
|
};
|
|
|
|
// Legacy HTML/CSS generation for backward compatibility (preview)
|
|
let bodyContent = '';
|
|
|
|
if (!layer || !layer.children) {
|
|
console.error('Layer or children not found');
|
|
return { html: '<div class="invoice-wrapper"></div>', css: '', json: JSON.stringify(templateJson, null, 2) };
|
|
}
|
|
|
|
layer.children.forEach((child, index) => {
|
|
if (!child || child === background || child.className === 'Transformer') return;
|
|
|
|
// Filter out invisible elements
|
|
if (child.attrs && (child.attrs.opacity === 0 || child.attrs.visible === false)) {
|
|
console.log('Skipping invisible element in code generation:', child.className, child.attrs.name || 'unnamed');
|
|
return;
|
|
}
|
|
|
|
// Filter out grid lines and page border - they should not be included in the exported template
|
|
if (child.className === 'Line' && child.attrs.name === 'grid-line') return;
|
|
if (child.className === 'Rect' && child.attrs.name === 'page-border') return;
|
|
// Filter out warning indicator dots - they should not be included in the exported template
|
|
if (child.className === 'Circle' && child.attrs.name === 'warning-indicator') return;
|
|
|
|
// Filter out background rectangles - any rectangle with name="background" or matching full page dimensions
|
|
if (child.className === 'Rect') {
|
|
const rectName = child.attrs.name || '';
|
|
const rectX = child.attrs.x || 0;
|
|
const rectY = child.attrs.y || 0;
|
|
const rectWidth = child.attrs.width || 0;
|
|
const rectHeight = child.attrs.height || 0;
|
|
const rectFill = child.attrs.fill || '';
|
|
const rectStroke = child.attrs.stroke || '';
|
|
|
|
// Filter out if name is "background" or "page-border"
|
|
if (rectName === 'background' || rectName === 'page-border') {
|
|
console.log('Skipping rectangle with name:', rectName);
|
|
return;
|
|
}
|
|
|
|
// CRITICAL FIX: Filter out rectangles that are children of decorative-image groups
|
|
// These are placeholder rectangles that should not appear in the preview
|
|
const parent = child.getParent && child.getParent();
|
|
if (parent) {
|
|
const parentName = parent.attrs ? (parent.attrs.name || '') : '';
|
|
if (parentName && parentName.includes('decorative-image')) {
|
|
console.log('Skipping rectangle inside decorative-image group:', {rectName, parentName});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Filter out zero-sized rectangles
|
|
if (rectWidth === 0 && rectHeight === 0) {
|
|
console.log('Skipping zero-sized rectangle');
|
|
return;
|
|
}
|
|
|
|
// Filter out full-page rectangles at 0,0 with white/transparent fill and black stroke (unwanted borders)
|
|
// Check if it matches page dimensions (within 5px tolerance)
|
|
const pageWidth = dimensions.width;
|
|
const pageHeight = dimensions.height;
|
|
const isFullPage = Math.abs(rectX) < 5 && Math.abs(rectY) < 5 &&
|
|
Math.abs(rectWidth - pageWidth) < 5 &&
|
|
Math.abs(rectHeight - pageHeight) < 5;
|
|
|
|
if (isFullPage && (rectFill === 'white' || rectFill === '#ffffff' || rectFill === 'transparent' || rectFill === '') &&
|
|
(rectStroke === 'black' || rectStroke === '#000000')) {
|
|
console.log('Filtering out unwanted full-page rectangle border:', {x: rectX, y: rectY, width: rectWidth, height: rectHeight, fill: rectFill, stroke: rectStroke, name: rectName});
|
|
return;
|
|
}
|
|
|
|
// Filter out rectangles that are completely outside page bounds (likely artifacts)
|
|
if (rectX + rectWidth < -10 || rectY + rectHeight < -10 ||
|
|
rectX > pageWidth + 10 || rectY > pageHeight + 10) {
|
|
console.log('Skipping rectangle outside page bounds:', {x: rectX, y: rectY, width: rectWidth, height: rectHeight});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const attrs = child.attrs;
|
|
const x = Math.round(attrs.x || 0);
|
|
const y = Math.round(attrs.y || 0);
|
|
const opacity = attrs.opacity !== undefined ? attrs.opacity : 1;
|
|
|
|
// Debug logging for elements being included
|
|
console.log(`Including element in template JSON: type=${child.className}, name=${attrs.name || 'unnamed'}, x=${x}, y=${y}, width=${attrs.width || 'N/A'}, height=${attrs.height || 'N/A'}`);
|
|
|
|
if (child.className === 'Text') {
|
|
const fontSize = attrs.fontSize || 14;
|
|
const fontFamily = attrs.fontFamily || 'Arial';
|
|
const fontStyle = attrs.fontStyle || 'normal';
|
|
const fontWeight = fontStyle === 'bold' ? 'bold' : 'normal';
|
|
const fontStyleCss = fontStyle === 'italic' ? 'italic' : 'normal';
|
|
const color = attrs.fill || 'black';
|
|
const text = attrs.text || '';
|
|
const textAlign = attrs.align || 'left';
|
|
|
|
// Map font family to ReportLab font names
|
|
let reportLabFont = 'Helvetica';
|
|
if (fontFamily.toLowerCase().includes('arial')) {
|
|
reportLabFont = 'Helvetica';
|
|
} else if (fontFamily.toLowerCase().includes('times')) {
|
|
reportLabFont = 'Times-Roman';
|
|
} else if (fontFamily.toLowerCase().includes('courier')) {
|
|
reportLabFont = 'Courier';
|
|
}
|
|
if (fontWeight === 'bold') {
|
|
reportLabFont += '-Bold';
|
|
} else if (fontStyleCss === 'italic') {
|
|
reportLabFont += '-Oblique';
|
|
}
|
|
|
|
// Add to ReportLab template JSON
|
|
templateJson.elements.push({
|
|
type: 'text',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
text: text, // Keep Jinja2 template variables as-is
|
|
width: pxToPt(attrs.width || 400),
|
|
style: {
|
|
font: reportLabFont,
|
|
size: fontSize,
|
|
color: color,
|
|
align: textAlign,
|
|
opacity: opacity
|
|
}
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
const escapedText = text.replace(/</g, '<').replace(/>/g, '>');
|
|
bodyContent += ` <div class="element text-element" style="position:absolute;left:${x}px;top:${y}px;font-size:${fontSize}px;font-family:'${fontFamily}';font-weight:${fontWeight};font-style:${fontStyleCss};color:${color};opacity:${opacity};width:${attrs.width || 400}px;text-align:${textAlign}">${escapedText}</div>\n`;
|
|
} else if (child.className === 'Image') {
|
|
const w = Math.round(attrs.width || 100);
|
|
const h = Math.round(attrs.height || 50);
|
|
|
|
// Add to ReportLab template JSON - image source needs Jinja2 template syntax
|
|
{% raw %}
|
|
const imageSource = '{{ get_logo_base64(settings.get_logo_path()) if settings.has_logo() and settings.get_logo_path() else "" }}';
|
|
{% endraw %}
|
|
templateJson.elements.push({
|
|
type: 'image',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(w),
|
|
height: pxToPt(h),
|
|
source: imageSource,
|
|
opacity: opacity
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
{% raw %}
|
|
bodyContent += ` <img src="{{ get_logo_base64(settings.get_logo_path()) if settings.has_logo() and settings.get_logo_path() else '' }}" style="position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;opacity:${opacity}" alt="Logo">\n`;
|
|
{% endraw %}
|
|
} else if (child.className === 'Rect') {
|
|
const w = Math.round(attrs.width || 100);
|
|
const h = Math.round(attrs.height || 100);
|
|
const fill = attrs.fill || 'transparent';
|
|
const stroke = attrs.stroke || 'black';
|
|
const strokeWidth = attrs.strokeWidth || 1;
|
|
|
|
// Add to ReportLab template JSON
|
|
templateJson.elements.push({
|
|
type: 'rectangle',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(w),
|
|
height: pxToPt(h),
|
|
style: {
|
|
fill: fill !== 'transparent' ? fill : null,
|
|
stroke: stroke,
|
|
strokeWidth: strokeWidth,
|
|
opacity: opacity
|
|
}
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
bodyContent += ` <div class="rectangle-element" style="position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${fill};border:${strokeWidth}px solid ${stroke};opacity:${opacity}"></div>\n`;
|
|
} else if (child.className === 'Circle') {
|
|
const radius = Math.round(attrs.radius || 50);
|
|
const fill = attrs.fill || 'transparent';
|
|
const stroke = attrs.stroke || 'black';
|
|
const strokeWidth = attrs.strokeWidth || 1;
|
|
const adjustedX = x - radius;
|
|
const adjustedY = y - radius;
|
|
|
|
// Add to ReportLab template JSON (circles use radius)
|
|
templateJson.elements.push({
|
|
type: 'circle',
|
|
x: pxToPt(x), // Center x
|
|
y: pxToPt(y), // Center y
|
|
width: pxToPt(radius * 2),
|
|
height: pxToPt(radius * 2),
|
|
style: {
|
|
fill: fill !== 'transparent' ? fill : null,
|
|
stroke: stroke,
|
|
strokeWidth: strokeWidth,
|
|
opacity: opacity
|
|
}
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
bodyContent += ` <div class="circle-element" style="position:absolute;left:${adjustedX}px;top:${adjustedY}px;width:${radius*2}px;height:${radius*2}px;background:${fill};border:${strokeWidth}px solid ${stroke};border-radius:50%;opacity:${opacity}"></div>\n`;
|
|
} else if (child.className === 'Line') {
|
|
const points = attrs.points || [];
|
|
const stroke = attrs.stroke || 'black';
|
|
const strokeWidth = attrs.strokeWidth || 1;
|
|
const lineName = attrs.name || '';
|
|
const lineX = attrs.x || 0;
|
|
const lineY = attrs.y || 0;
|
|
const lineWidth = attrs.width || 0;
|
|
|
|
// Filter out unwanted border lines at position (0,0) or very close with full page width
|
|
const pageWidth = dimensions.width;
|
|
const isAtOrigin = Math.abs(lineX) < 5 && Math.abs(lineY) < 5;
|
|
const isFullWidth = lineWidth > pageWidth * 0.9 || (points.length >= 4 && Math.abs(points[2] - points[0]) > pageWidth * 0.9);
|
|
|
|
// Filter out lines at origin with full width (border lines)
|
|
if (isAtOrigin && isFullWidth) {
|
|
console.log('Filtering out unwanted border line at origin:', {x: lineX, y: lineY, width: lineWidth, stroke: stroke, name: lineName});
|
|
return;
|
|
}
|
|
|
|
// Filter out unwanted separator lines (gray lines at top) that match full page width
|
|
if (points.length >= 4) {
|
|
const x1 = Math.round(points[0]);
|
|
const y1 = Math.round(points[1]);
|
|
const x2 = Math.round(points[2]);
|
|
const y2 = Math.round(points[3]);
|
|
const calculatedWidth = Math.abs(x2 - x1);
|
|
|
|
// Filter out full-width gray lines near the top (likely unwanted separators)
|
|
const isFullWidthLine = calculatedWidth > pageWidth * 0.9;
|
|
const isNearTop = Math.min(y1, y2) < 50; // Within 50px of top
|
|
const isGray = stroke === '#e0e0e0' || stroke === '#dee2e6' || stroke === 'gray' || stroke === 'grey' ||
|
|
stroke === '#cccccc' || stroke === '#999999' || stroke === '#667eea';
|
|
|
|
if (isFullWidthLine && isNearTop && isGray && lineName !== 'items-table-separator' && lineName !== 'expenses-table-separator') {
|
|
console.log('Filtering out unwanted gray separator line:', {width: calculatedWidth, y: Math.min(y1, y2), stroke: stroke, name: lineName});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (points.length >= 4) {
|
|
const x1 = Math.round(points[0]);
|
|
const y1 = Math.round(points[1]);
|
|
const x2 = Math.round(points[2]);
|
|
const y2 = Math.round(points[3]);
|
|
const width = Math.abs(x2 - x1);
|
|
const adjustedX = x + Math.min(x1, x2);
|
|
const adjustedY = y + Math.min(y1, y2);
|
|
|
|
// Final check: filter out lines at origin (0,0) with full width - these are border lines
|
|
const pageWidth = dimensions.width;
|
|
const isAtOrigin = Math.abs(adjustedX) < 5 && Math.abs(adjustedY) < 5;
|
|
const isFullWidth = width > pageWidth * 0.9;
|
|
const isBorderLine = isAtOrigin && isFullWidth;
|
|
|
|
if (isBorderLine) {
|
|
console.log('Final filter: Removing border line at origin in code generation:', {adjustedX, adjustedY, width, stroke});
|
|
return;
|
|
}
|
|
|
|
// Add to ReportLab template JSON
|
|
templateJson.elements.push({
|
|
type: 'line',
|
|
x: pxToPt(adjustedX),
|
|
y: pxToPt(adjustedY),
|
|
width: pxToPt(width),
|
|
style: {
|
|
stroke: stroke,
|
|
strokeWidth: strokeWidth,
|
|
opacity: opacity
|
|
}
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
bodyContent += ` <hr class="line-element" style="position:absolute;left:${adjustedX}px;top:${adjustedY}px;width:${width}px;border:none;border-top:${strokeWidth}px solid ${stroke};margin:0;opacity:${opacity}">\n`;
|
|
}
|
|
} else if (child.className === 'Group' || child.constructor.name === 'Group' || child.children) {
|
|
// It's a Group element (check multiple ways since className might be undefined after JSON restore)
|
|
// Check if this is a table by looking at the group's name
|
|
let isItemsTable = child.attrs.name === 'items-table';
|
|
let isExpensesTable = child.attrs.name === 'expenses-table';
|
|
|
|
// CRITICAL FIX: Check if this is a decorative-image group (handle name variations)
|
|
const groupName = child.attrs.name || '';
|
|
const isDecorativeImage = groupName && groupName.includes('decorative-image');
|
|
|
|
// Fallback: Check if group has multiple children (header, line, items) - likely a table
|
|
if (!isItemsTable && !isExpensesTable && !isDecorativeImage && child.children && child.children.length >= 3) {
|
|
const hasTextChildren = child.children.filter(c => c.className === 'Text').length >= 2;
|
|
const hasLine = child.children.some(c => c.className === 'Line');
|
|
if (hasTextChildren && hasLine) {
|
|
console.log('⚠ Table detected by structure (missing name attribute) - consider re-saving layout');
|
|
isItemsTable = true;
|
|
}
|
|
}
|
|
|
|
// Process decorative-image groups first (before tables)
|
|
if (isDecorativeImage) {
|
|
// Decorative image element
|
|
// Get imageUrl from attrs - try multiple methods to ensure we get it
|
|
let imageUrl = '';
|
|
try {
|
|
imageUrl = child.getAttr('imageUrl') || '';
|
|
} catch(e) {
|
|
console.warn('Could not get imageUrl via getAttr:', e);
|
|
}
|
|
if (!imageUrl) {
|
|
imageUrl = attrs.imageUrl || '';
|
|
}
|
|
// Also check if stored in the node's attrs directly
|
|
if (!imageUrl && child.attrs) {
|
|
imageUrl = child.attrs.imageUrl || '';
|
|
}
|
|
|
|
console.log('Generating code for decorative image, URL:', imageUrl, 'attrs:', attrs);
|
|
|
|
// Find the actual image in the group if it exists
|
|
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
|
const imageElement = children.find(c => c.className === 'Image');
|
|
|
|
let actualWidth = 100;
|
|
let actualHeight = 100;
|
|
|
|
if (imageElement) {
|
|
actualWidth = Math.round(imageElement.width() || imageElement.attrs.width || 100);
|
|
actualHeight = Math.round(imageElement.height() || imageElement.attrs.height || 100);
|
|
} else {
|
|
// Use group dimensions if available
|
|
const groupBox = child.getClientRect();
|
|
if (groupBox && groupBox.width > 0 && groupBox.height > 0) {
|
|
actualWidth = Math.round(groupBox.width);
|
|
actualHeight = Math.round(groupBox.height);
|
|
}
|
|
}
|
|
|
|
console.log('Decorative image dimensions:', actualWidth, 'x', actualHeight, 'URL:', imageUrl);
|
|
|
|
// Add to ReportLab template JSON
|
|
templateJson.elements.push({
|
|
type: 'image',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(actualWidth),
|
|
height: pxToPt(actualHeight),
|
|
source: imageUrl || '', // Store the image URL
|
|
opacity: opacity,
|
|
decorative: true // Mark as decorative image
|
|
});
|
|
|
|
// Legacy HTML for preview - only generate image tag, not rectangle
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
bodyContent += ` <img src="${imageUrl}" style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;opacity:${opacity}" alt="Decorative image" class="image-element">\n`;
|
|
} else {
|
|
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;background:#f0f0f0;border:2px dashed #999;opacity:${opacity};display:flex;align-items:center;justify-content:center;color:#666;font-size:12px;">Decorative Image</div>\n`;
|
|
}
|
|
// Skip processing children of decorative-image groups - they are just placeholders
|
|
return; // Use return instead of continue in forEach
|
|
}
|
|
|
|
if (isItemsTable) {
|
|
// Extract actual header text from the table group's first Text child
|
|
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
|
const textElements = children.filter(c => c.className === 'Text');
|
|
const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
|
|
|
|
// Parse header text (format: "Description | Qty | Price | Total" or German equivalent)
|
|
// Default to English if header text is empty or doesn't contain |
|
|
let headerParts = ['Description', 'Qty', 'Unit Price', 'Total'];
|
|
if (headerText && headerText.includes('|')) {
|
|
headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
|
|
// Ensure we have at least 4 parts, pad with defaults if needed
|
|
while (headerParts.length < 4) {
|
|
headerParts.push(['Description', 'Qty', 'Unit Price', 'Total'][headerParts.length]);
|
|
}
|
|
}
|
|
|
|
// Add to ReportLab template JSON - items table
|
|
{% raw %}
|
|
const itemsData = '{{ invoice.items }}';
|
|
const itemsRowTemplate = {
|
|
description: '{{ item.description }}',
|
|
quantity: '{{ item.quantity }}',
|
|
unit_price: '{{ format_money(item.unit_price) }}',
|
|
total_amount: '{{ format_money(item.total_amount) }}'
|
|
};
|
|
{% endraw %}
|
|
templateJson.elements.push({
|
|
type: 'table',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(515),
|
|
opacity: opacity,
|
|
columns: [
|
|
{width: 250, header: headerParts[0] || 'Description', field: 'description', align: 'left'},
|
|
{width: 70, header: headerParts[1] || 'Qty', field: 'quantity', align: 'center'},
|
|
{width: 110, header: headerParts[2] || 'Unit Price', field: 'unit_price', align: 'right'},
|
|
{width: 110, header: headerParts[3] || 'Total', field: 'total_amount', align: 'right'}
|
|
],
|
|
data: itemsData,
|
|
row_template: itemsRowTemplate
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
bodyContent += ` <!-- Items Table Start -->\n`;
|
|
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:515px;">\n`;
|
|
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:white;">\n`;
|
|
bodyContent += ` <thead>\n`;
|
|
bodyContent += ` <tr style="background-color:#f8f9fa;border-bottom:2px solid #333;">\n`;
|
|
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;">${(headerParts[0] || 'Description').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;">${(headerParts[1] || 'Qty').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[2] || 'Unit Price').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[3] || 'Total').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` </tr>\n`;
|
|
bodyContent += ` </thead>\n`;
|
|
bodyContent += ` <tbody>\n`;
|
|
{% raw %}
|
|
bodyContent += ` {% if invoice.items %}\n`;
|
|
bodyContent += ` {% for item in invoice.items %}\n`;
|
|
bodyContent += ` <tr style="border-bottom:1px solid #ddd;">\n`;
|
|
bodyContent += ` <td style="padding:10px;vertical-align:top;">{{ item.description }}</td>\n`;
|
|
bodyContent += ` <td style="padding:10px;text-align:center;vertical-align:top;">{{ item.quantity }}</td>\n`;
|
|
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;">{{ format_money(item.unit_price) }}</td>\n`;
|
|
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;font-weight:bold;">{{ format_money(item.total_amount) }}</td>\n`;
|
|
bodyContent += ` </tr>\n`;
|
|
bodyContent += ` {% endfor %}\n`;
|
|
bodyContent += ` {% else %}\n`;
|
|
bodyContent += ` <tr>\n`;
|
|
bodyContent += ` <td colspan="4" style="padding:10px;text-align:center;color:#999;">No items</td>\n`;
|
|
bodyContent += ` </tr>\n`;
|
|
bodyContent += ` {% endif %}\n`;
|
|
{% endraw %}
|
|
bodyContent += ` </tbody>\n`;
|
|
bodyContent += ` </table>\n`;
|
|
bodyContent += ` </div>\n`;
|
|
bodyContent += ` <!-- Items Table End -->\n`;
|
|
} else if (isExpensesTable) {
|
|
// Extract actual header text from the table group's first Text child
|
|
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
|
const textElements = children.filter(c => c.className === 'Text');
|
|
const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
|
|
|
|
// Parse header text (format: "Expense | Date | Category | Amount" or localized)
|
|
// Default to English if header text is empty or doesn't contain |
|
|
let headerParts = ['Expense', 'Date', 'Category', 'Amount'];
|
|
if (headerText && headerText.includes('|')) {
|
|
headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
|
|
// Ensure we have at least 4 parts, pad with defaults if needed
|
|
while (headerParts.length < 4) {
|
|
headerParts.push(['Expense', 'Date', 'Category', 'Amount'][headerParts.length]);
|
|
}
|
|
}
|
|
|
|
// Add to ReportLab template JSON - expenses table
|
|
{% raw %}
|
|
const expensesData = '{{ invoice.expenses }}';
|
|
const expensesRowTemplate = {
|
|
title: '{{ expense.title }}',
|
|
expense_date: '{{ expense.expense_date }}',
|
|
category: '{{ expense.category }}',
|
|
total_amount: '{{ format_money(expense.total_amount) }}'
|
|
};
|
|
{% endraw %}
|
|
templateJson.elements.push({
|
|
type: 'table',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(515),
|
|
opacity: opacity,
|
|
columns: [
|
|
{width: 200, header: headerParts[0] || 'Expense', field: 'title', align: 'left'},
|
|
{width: 100, header: headerParts[1] || 'Date', field: 'expense_date', align: 'center'},
|
|
{width: 105, header: headerParts[2] || 'Category', field: 'category', align: 'left'},
|
|
{width: 110, header: headerParts[3] || 'Amount', field: 'total_amount', align: 'right'}
|
|
],
|
|
data: expensesData,
|
|
row_template: expensesRowTemplate,
|
|
style: {
|
|
headerBackground: '#fff3cd',
|
|
headerTextColor: '#856404',
|
|
rowBackground: '#fffbf0',
|
|
rowTextColor: '#856404'
|
|
}
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
bodyContent += ` <!-- Expenses Table Start -->\n`;
|
|
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:515px;">\n`;
|
|
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:#fffbf0;">\n`;
|
|
bodyContent += ` <thead>\n`;
|
|
bodyContent += ` <tr style="background-color:#fff3cd;border-bottom:2px solid #856404;">\n`;
|
|
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;color:#856404;">${(headerParts[0] || 'Expense').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:100px;color:#856404;">${(headerParts[1] || 'Date').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">${(headerParts[2] || 'Category').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">${(headerParts[3] || 'Amount').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` </tr>\n`;
|
|
bodyContent += ` </thead>\n`;
|
|
bodyContent += ` <tbody>\n`;
|
|
{% raw %}
|
|
bodyContent += ` {% if invoice.expenses %}\n`;
|
|
bodyContent += ` {% for expense in invoice.expenses %}\n`;
|
|
bodyContent += ` <tr style="border-bottom:1px solid #f0e5c1;">\n`;
|
|
bodyContent += ` <td style="padding:10px;vertical-align:top;color:#856404;">{{ expense.title }}</td>\n`;
|
|
bodyContent += ` <td style="padding:10px;text-align:center;vertical-align:top;color:#856404;">{{ expense.expense_date }}</td>\n`;
|
|
bodyContent += ` <td style="padding:10px;vertical-align:top;color:#856404;">{{ expense.category }}</td>\n`;
|
|
bodyContent += ` <td style="padding:10px;text-align:right;vertical-align:top;font-weight:bold;color:#856404;">{{ format_money(expense.total_amount) }}</td>\n`;
|
|
bodyContent += ` </tr>\n`;
|
|
bodyContent += ` {% endfor %}\n`;
|
|
bodyContent += ` {% else %}\n`;
|
|
bodyContent += ` <tr>\n`;
|
|
bodyContent += ` <td colspan="4" style="padding:10px;text-align:center;color:#999;">No expenses</td>\n`;
|
|
bodyContent += ` </tr>\n`;
|
|
bodyContent += ` {% endif %}\n`;
|
|
{% endraw %}
|
|
bodyContent += ` </tbody>\n`;
|
|
bodyContent += ` </table>\n`;
|
|
bodyContent += ` </div>\n`;
|
|
bodyContent += ` <!-- Expenses Table End -->\n`;
|
|
} else if (attrs.name && attrs.name.includes('decorative-image')) {
|
|
// Decorative image element (handles name variations like "decorative-image element-overlap")
|
|
// This should not be reached if the earlier check worked, but keeping as fallback
|
|
// Get imageUrl from attrs - try multiple methods to ensure we get it
|
|
let imageUrl = '';
|
|
try {
|
|
imageUrl = child.getAttr('imageUrl') || '';
|
|
} catch(e) {
|
|
console.warn('Could not get imageUrl via getAttr:', e);
|
|
}
|
|
if (!imageUrl) {
|
|
imageUrl = attrs.imageUrl || '';
|
|
}
|
|
// Also check if stored in the node's attrs directly
|
|
if (!imageUrl && child.attrs) {
|
|
imageUrl = child.attrs.imageUrl || '';
|
|
}
|
|
|
|
console.log('Generating code for decorative image (fallback), URL:', imageUrl, 'attrs:', attrs);
|
|
|
|
// Find the actual image in the group if it exists
|
|
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
|
const imageElement = children.find(c => c.className === 'Image');
|
|
|
|
let actualWidth = 100;
|
|
let actualHeight = 100;
|
|
|
|
if (imageElement) {
|
|
actualWidth = Math.round(imageElement.width() || imageElement.attrs.width || 100);
|
|
actualHeight = Math.round(imageElement.height() || imageElement.attrs.height || 100);
|
|
} else {
|
|
// Use group dimensions if available
|
|
const groupBox = child.getClientRect();
|
|
if (groupBox && groupBox.width > 0 && groupBox.height > 0) {
|
|
actualWidth = Math.round(groupBox.width);
|
|
actualHeight = Math.round(groupBox.height);
|
|
}
|
|
}
|
|
|
|
console.log('Decorative image dimensions (fallback):', actualWidth, 'x', actualHeight, 'URL:', imageUrl);
|
|
|
|
// Add to ReportLab template JSON
|
|
templateJson.elements.push({
|
|
type: 'image',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(actualWidth),
|
|
height: pxToPt(actualHeight),
|
|
source: imageUrl || '', // Store the image URL
|
|
opacity: opacity,
|
|
decorative: true // Mark as decorative image
|
|
});
|
|
|
|
// Legacy HTML for preview - only generate image tag, not rectangle
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
bodyContent += ` <img src="${imageUrl}" style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;opacity:${opacity}" alt="Decorative image" class="image-element">\n`;
|
|
} else {
|
|
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;background:#f0f0f0;border:2px dashed #999;opacity:${opacity};display:flex;align-items:center;justify-content:center;color:#666;font-size:12px;">Decorative Image</div>\n`;
|
|
}
|
|
} else {
|
|
// Regular group (not a table)
|
|
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity}">\n`;
|
|
child.children.forEach(c => {
|
|
if (c.className === 'Text') {
|
|
const text = (c.attrs.text || '').replace(/</g, '<').replace(/>/g, '>');
|
|
// Preserve text alignment for text in groups
|
|
const textAlign = c.attrs.align || 'left';
|
|
bodyContent += ` <div style="font-size:${c.attrs.fontSize || 12}px;font-weight:${c.attrs.fontStyle === 'bold' ? 'bold' : 'normal'};text-align:${textAlign}">${text}</div>\n`;
|
|
} else if (c.className === 'Line') {
|
|
bodyContent += ` <hr style="border-top:${c.attrs.strokeWidth || 1}px solid ${c.attrs.stroke || 'black'};margin:${c.attrs.y || 0}px 0">\n`;
|
|
}
|
|
});
|
|
bodyContent += ` </div>\n`;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Legacy HTML/CSS generation for preview (backward compatibility)
|
|
const widthPx = dimensions.width;
|
|
const heightPx = dimensions.height;
|
|
|
|
const html = `<div class="invoice-wrapper">
|
|
${bodyContent}</div>`;
|
|
|
|
const css = `@page {
|
|
size: ${currentSize};
|
|
margin: 0;
|
|
}
|
|
html, body {
|
|
margin: 0 !important;
|
|
padding: 0 !important;
|
|
width: ${widthPx}px !important;
|
|
height: ${heightPx}px !important;
|
|
font-family: Arial, sans-serif;
|
|
overflow: hidden !important;
|
|
box-sizing: border-box !important;
|
|
}
|
|
.invoice-wrapper {
|
|
position: relative;
|
|
width: ${widthPx}px !important;
|
|
height: ${heightPx}px !important;
|
|
max-width: ${widthPx}px !important;
|
|
max-height: ${heightPx}px !important;
|
|
min-width: ${widthPx}px !important;
|
|
min-height: ${heightPx}px !important;
|
|
background: white;
|
|
padding: 0 !important;
|
|
box-sizing: border-box !important;
|
|
margin: 0 !important;
|
|
overflow: hidden !important;
|
|
clip-path: inset(0) !important;
|
|
contain: layout style paint;
|
|
isolation: isolate;
|
|
}
|
|
.element, .text-element {
|
|
white-space: pre-wrap;
|
|
box-sizing: border-box;
|
|
max-width: 100%;
|
|
}
|
|
.rectangle-element, .circle-element {
|
|
box-sizing: border-box;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
}
|
|
.line-element {
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
max-width: 100%;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 10px 0;
|
|
}
|
|
table th {
|
|
background-color: #f8f9fa;
|
|
font-weight: bold;
|
|
text-align: left;
|
|
padding: 10px;
|
|
border-bottom: 2px solid #333;
|
|
}
|
|
table td {
|
|
padding: 10px;
|
|
border-bottom: 1px solid #ddd;
|
|
}
|
|
table tr:last-child td {
|
|
border-bottom: 2px solid #333;
|
|
}`;
|
|
|
|
// Return both JSON (new format) and HTML/CSS (legacy for preview)
|
|
return {
|
|
html, // Legacy HTML for preview
|
|
css, // Legacy CSS for preview
|
|
json: JSON.stringify(templateJson, null, 2) // ReportLab template JSON
|
|
};
|
|
} catch (error) {
|
|
console.error('Error in generateCode():', error);
|
|
console.error('Stack trace:', error.stack);
|
|
// Return minimal valid structure on error
|
|
const currentSize = (pageSizeSelector && pageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
const widthPx = dimensions.width;
|
|
const heightPx = dimensions.height;
|
|
return {
|
|
html: `<div class="invoice-wrapper"><p style="color: red; padding: 20px;">Error generating template: ${error.message}</p></div>`,
|
|
css: `@page { size: ${currentSize}; margin: 0; } html, body { margin: 0; padding: 0; width: ${widthPx}px; height: ${heightPx}px; } .invoice-wrapper { width: ${widthPx}px; height: ${heightPx}px; background: white; padding: 0; }`,
|
|
json: JSON.stringify({ page: { size: currentSize, margin: { top: 20, right: 20, bottom: 20, left: 20 } }, elements: [], styles: {} }, null, 2)
|
|
};
|
|
}
|
|
}
|
|
|
|
// View code
|
|
document.getElementById('btn-code').addEventListener('click', function() {
|
|
const { html, css } = generateCode();
|
|
|
|
// Log for debugging
|
|
console.log('=== GENERATED HTML ===');
|
|
console.log(html);
|
|
console.log('=== END HTML ===');
|
|
|
|
// Check if items table is present
|
|
const hasItemsTable = html.includes('<!-- Items Table Start -->');
|
|
const hasForLoop = html.includes('{' + '% for item in invoice.items');
|
|
console.log('Has Items Table marker:', hasItemsTable);
|
|
console.log('Has Jinja2 loop:', hasForLoop);
|
|
|
|
document.getElementById('code-html').value = html;
|
|
document.getElementById('code-css').value = css;
|
|
document.getElementById('code-modal').classList.remove('hidden');
|
|
});
|
|
|
|
document.getElementById('close-modal').addEventListener('click', function() {
|
|
document.getElementById('code-modal').classList.add('hidden');
|
|
});
|
|
|
|
// Page size selector handler
|
|
// Reuse the pageSizeSelector declared at the top level
|
|
if (pageSizeSelector) {
|
|
// Set the current page size in the dropdown
|
|
if (CURRENT_PAGE_SIZE) {
|
|
pageSizeSelector.value = CURRENT_PAGE_SIZE;
|
|
}
|
|
|
|
pageSizeSelector.addEventListener('change', async function() {
|
|
const newSize = this.value;
|
|
const confirmed = await showConfirm(
|
|
'Switching page size will reload the template. Any unsaved changes will be lost. Continue?',
|
|
{
|
|
title: 'Switch Page Size',
|
|
confirmText: 'Continue',
|
|
cancelText: 'Cancel',
|
|
variant: 'warning'
|
|
}
|
|
);
|
|
if (confirmed) {
|
|
window.location.href = '{{ url_for("admin.pdf_layout") }}?size=' + encodeURIComponent(newSize);
|
|
} else {
|
|
// Reset to current size
|
|
this.value = CURRENT_PAGE_SIZE || 'A4';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Cleanup function to remove unwanted elements before saving
|
|
function cleanupUnwantedElements() {
|
|
if (!layer) return;
|
|
|
|
const pageWidth = dimensions.width;
|
|
const pageHeight = dimensions.height;
|
|
let removedCount = 0;
|
|
|
|
// Get all children (create a copy since we'll be modifying the array)
|
|
const children = layer.children.slice();
|
|
|
|
children.forEach((child) => {
|
|
if (!child || child === background || child.className === 'Transformer') return;
|
|
|
|
// Remove invisible elements (opacity 0 or not visible)
|
|
if (child.attrs && (child.attrs.opacity === 0 || child.attrs.visible === false)) {
|
|
console.log('Removing invisible element:', child.className, child.attrs.name || 'unnamed');
|
|
child.destroy();
|
|
removedCount++;
|
|
return;
|
|
}
|
|
|
|
// Remove zero-sized elements
|
|
if (child.attrs) {
|
|
const width = child.attrs.width || 0;
|
|
const height = child.attrs.height || 0;
|
|
if (width === 0 && height === 0 && child.className !== 'Line' && child.className !== 'Circle') {
|
|
console.log('Removing zero-sized element:', child.className, child.attrs.name || 'unnamed');
|
|
child.destroy();
|
|
removedCount++;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Remove unwanted rectangles
|
|
if (child.className === 'Rect') {
|
|
const rectName = child.attrs.name || '';
|
|
const rectX = child.attrs.x || 0;
|
|
const rectY = child.attrs.y || 0;
|
|
const rectWidth = child.attrs.width || 0;
|
|
const rectHeight = child.attrs.height || 0;
|
|
const rectFill = child.attrs.fill || '';
|
|
const rectStroke = child.attrs.stroke || '';
|
|
|
|
// Remove if name is "background" or "page-border" (duplicates)
|
|
if (rectName === 'background' || rectName === 'page-border') {
|
|
console.log('Removing duplicate rectangle:', rectName);
|
|
child.destroy();
|
|
removedCount++;
|
|
return;
|
|
}
|
|
|
|
// Remove full-page rectangles at 0,0 with white/transparent fill and black stroke
|
|
const isFullPage = Math.abs(rectX) < 5 && Math.abs(rectY) < 5 &&
|
|
Math.abs(rectWidth - pageWidth) < 5 &&
|
|
Math.abs(rectHeight - pageHeight) < 5;
|
|
|
|
if (isFullPage && (rectFill === 'white' || rectFill === '#ffffff' || rectFill === 'transparent' || rectFill === '') &&
|
|
(rectStroke === 'black' || rectStroke === '#000000')) {
|
|
console.log('Removing unwanted full-page rectangle border before save');
|
|
child.destroy();
|
|
removedCount++;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Remove unwanted lines (border lines and separator lines)
|
|
if (child.className === 'Line') {
|
|
const lineName = child.attrs.name || '';
|
|
const lineX = child.attrs.x || 0;
|
|
const lineY = child.attrs.y || 0;
|
|
const lineWidth = child.attrs.width || 0;
|
|
const points = child.attrs.points || [];
|
|
|
|
// Skip grid lines
|
|
if (lineName === 'grid-line') return;
|
|
|
|
// Remove border lines at origin (0,0) with full page width
|
|
const isAtOrigin = Math.abs(lineX) < 5 && Math.abs(lineY) < 5;
|
|
const isFullWidthBorder = lineWidth > pageWidth * 0.9 ||
|
|
(points.length >= 4 && Math.abs(points[2] - points[0]) > pageWidth * 0.9);
|
|
|
|
if (isAtOrigin && isFullWidthBorder) {
|
|
console.log('Removing unwanted border line at origin before save');
|
|
child.destroy();
|
|
removedCount++;
|
|
return;
|
|
}
|
|
|
|
// Remove unwanted gray separator lines
|
|
if (points.length >= 4) {
|
|
const x1 = points[0];
|
|
const y1 = points[1];
|
|
const x2 = points[2];
|
|
const y2 = points[3];
|
|
const calculatedWidth = Math.abs(x2 - x1);
|
|
const stroke = child.attrs.stroke || '';
|
|
|
|
// Remove full-width gray/blue lines near top or anywhere
|
|
const isFullWidth = calculatedWidth > pageWidth * 0.9;
|
|
const isNearTop = Math.min(y1, y2) < 50;
|
|
const isGrayOrBlue = stroke === '#e0e0e0' || stroke === '#dee2e6' || stroke === 'gray' || stroke === 'grey' ||
|
|
stroke === '#cccccc' || stroke === '#999999' || stroke === '#667eea';
|
|
|
|
if (isFullWidth && (isNearTop || isAtOrigin) && isGrayOrBlue && lineName !== 'items-table-separator' && lineName !== 'expenses-table-separator') {
|
|
console.log('Removing unwanted separator line before save');
|
|
child.destroy();
|
|
removedCount++;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (removedCount > 0) {
|
|
console.log(`Cleaned up ${removedCount} unwanted elements before saving`);
|
|
layer.draw();
|
|
}
|
|
}
|
|
|
|
// Save
|
|
document.getElementById('btn-save').addEventListener('click', function() {
|
|
// Clean up unwanted elements before generating code
|
|
cleanupUnwantedElements();
|
|
|
|
const { html, css, json } = generateCode();
|
|
|
|
// Log what we're saving for debugging
|
|
console.log('=== SAVING TO DATABASE ===');
|
|
console.log('HTML length:', html.length);
|
|
console.log('CSS length:', css.length);
|
|
console.log('JSON template length:', json ? json.length : 0);
|
|
console.log('Has Items Table:', html.includes('<!-- Items Table Start -->'));
|
|
console.log('Has Jinja2 loop:', html.includes('{' + '% for item in invoice.items'));
|
|
console.log('Number of elements:', layer.children.length);
|
|
console.log('Page size:', CURRENT_PAGE_SIZE);
|
|
|
|
// Log all Groups to see if items-table is there
|
|
layer.children.forEach((child, idx) => {
|
|
if (child.className === 'Group') {
|
|
console.log(`Group ${idx}: name="${child.attrs.name || 'unnamed'}"`, child.attrs);
|
|
// Check for decorative images
|
|
if (child.attrs.name === 'decorative-image') {
|
|
const imageUrl = child.getAttr('imageUrl') || child.attrs.imageUrl || '';
|
|
console.log(` Decorative image ${idx}: imageUrl="${imageUrl}"`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Ensure imageUrl is in attrs for all decorative images before serialization
|
|
layer.find('[name="decorative-image"]').forEach((decorativeImageGroup, idx) => {
|
|
try {
|
|
// Try multiple methods to get imageUrl
|
|
let imageUrl = decorativeImageGroup.getAttr('imageUrl');
|
|
console.log(`[INVOICE] [SAVE] Decorative image ${idx}: getAttr('imageUrl') =`, imageUrl);
|
|
if (!imageUrl) {
|
|
imageUrl = decorativeImageGroup.attrs.imageUrl;
|
|
console.log(`[INVOICE] [SAVE] Decorative image ${idx}: attrs.imageUrl =`, imageUrl);
|
|
}
|
|
if (!imageUrl) {
|
|
// Check if there's an Image node with the URL
|
|
const imageNode = decorativeImageGroup.findOne('Image');
|
|
if (imageNode) {
|
|
// Try to get URL from image node's source if available
|
|
const imgAttrs = imageNode.attrs || {};
|
|
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
|
|
console.log(`[INVOICE] [SAVE] Decorative image ${idx}: Found imageUrl in Image node:`, imageUrl);
|
|
}
|
|
}
|
|
|
|
if (imageUrl) {
|
|
// Use setAttr to ensure Konva properly serializes it
|
|
decorativeImageGroup.setAttr('imageUrl', imageUrl);
|
|
// Also set in attrs for redundancy
|
|
decorativeImageGroup.attrs.imageUrl = imageUrl;
|
|
console.log(`[INVOICE] [SAVE] ✅ Ensured imageUrl in attrs for decorative image ${idx}:`, imageUrl);
|
|
} else {
|
|
console.warn(`[INVOICE] [SAVE] ⚠️ No imageUrl found for decorative image group ${idx}`);
|
|
}
|
|
} catch(e) {
|
|
console.warn(`[INVOICE] [SAVE] Could not get/set imageUrl for decorative image ${idx}:`, e);
|
|
}
|
|
});
|
|
|
|
// Save both legacy HTML/CSS and new JSON template
|
|
// Ensure JSON is always present and valid
|
|
if (!json || !json.trim()) {
|
|
console.error('No JSON generated from template!');
|
|
alert('Error: Could not generate template JSON. Please try again.');
|
|
return;
|
|
}
|
|
|
|
// Validate JSON before saving
|
|
try {
|
|
JSON.parse(json);
|
|
} catch (e) {
|
|
console.error('Invalid JSON generated:', e);
|
|
alert('Error: Generated template JSON is invalid. Please try again.');
|
|
return;
|
|
}
|
|
|
|
// Final check: ensure all decorative images have imageUrl properly set before serialization
|
|
// Also ensure base name "decorative-image" is preserved (even if "element-overlap" is added)
|
|
let allDecorativeGroups = layer.find('[name="decorative-image"]');
|
|
const allGroupsForCheck = layer.find('Group');
|
|
allGroupsForCheck.forEach(group => {
|
|
const name = group.name() || group.getAttr('name') || '';
|
|
if (name.includes('decorative-image') && !allDecorativeGroups.includes(group)) {
|
|
allDecorativeGroups = allDecorativeGroups.concat([group]);
|
|
}
|
|
});
|
|
|
|
allDecorativeGroups.forEach((decorativeImageGroup, idx) => {
|
|
try {
|
|
// Ensure base name is set (preserve "decorative-image" even if "element-overlap" exists)
|
|
const currentName = decorativeImageGroup.name() || decorativeImageGroup.getAttr('name') || '';
|
|
if (!currentName.includes('decorative-image')) {
|
|
decorativeImageGroup.setAttr('name', 'decorative-image');
|
|
decorativeImageGroup.name('decorative-image');
|
|
if (decorativeImageGroup.attrs) {
|
|
decorativeImageGroup.attrs.name = 'decorative-image';
|
|
}
|
|
console.log(`[INVOICE] [SAVE] Fixed name for decorative image ${idx}: was "${currentName}", now "decorative-image"`);
|
|
}
|
|
|
|
// Double-check that imageUrl is set via both methods
|
|
const imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl;
|
|
if (imageUrl) {
|
|
decorativeImageGroup.setAttr('imageUrl', imageUrl);
|
|
decorativeImageGroup.attrs.imageUrl = imageUrl;
|
|
console.log(`[INVOICE] [SAVE] Final check: imageUrl set for decorative image ${idx}:`, imageUrl);
|
|
} else {
|
|
console.warn(`[INVOICE] [SAVE] ⚠️ Final check: No imageUrl found for decorative image ${idx}`);
|
|
}
|
|
} catch(e) {
|
|
console.warn(`[INVOICE] [SAVE] Final check: Could not set imageUrl for decorative image ${idx}:`, e);
|
|
}
|
|
});
|
|
|
|
// CRITICAL FIX: Get imageUrl from actual nodes before serialization and store in a map
|
|
// This ensures we can inject imageUrl into JSON after serialization
|
|
const decorativeImageUrlMap = new Map();
|
|
// Find all decorative-image groups, including those with modified names like "decorative-image element-overlap"
|
|
let decorativeImageGroups = layer.find('[name="decorative-image"]');
|
|
const allGroups = layer.find('Group');
|
|
allGroups.forEach(group => {
|
|
const name = group.name() || group.getAttr('name') || '';
|
|
if (name.includes('decorative-image') && !decorativeImageGroups.includes(group)) {
|
|
decorativeImageGroups = decorativeImageGroups.concat([group]);
|
|
}
|
|
});
|
|
console.log('[INVOICE] [SAVE] Found', decorativeImageGroups.length, 'decorative image group(s) before serialization (including modified names)');
|
|
decorativeImageGroups.forEach((decorativeImageGroup, index) => {
|
|
// CRITICAL: Ensure primary name is "decorative-image" before serialization
|
|
// Konva's toJSON() uses the primary name() method, not additional names from addName()
|
|
const currentPrimaryName = decorativeImageGroup.name();
|
|
if (currentPrimaryName !== 'decorative-image') {
|
|
decorativeImageGroup.name('decorative-image');
|
|
decorativeImageGroup.setAttr('name', 'decorative-image');
|
|
if (decorativeImageGroup.attrs) {
|
|
decorativeImageGroup.attrs.name = 'decorative-image';
|
|
}
|
|
console.log(`[INVOICE] [SAVE] Fixed primary name for decorative image ${index}: was "${currentPrimaryName}", now "decorative-image"`);
|
|
}
|
|
|
|
// Try multiple methods to get imageUrl
|
|
let imageUrl = decorativeImageGroup.getAttr('imageUrl') || '';
|
|
if (!imageUrl) {
|
|
imageUrl = decorativeImageGroup.attrs.imageUrl || '';
|
|
}
|
|
// Also check if there's an Image node with the URL stored in it
|
|
if (!imageUrl) {
|
|
const imageNode = decorativeImageGroup.findOne('Image');
|
|
if (imageNode) {
|
|
// Try to get URL from image node's source if available
|
|
const imgAttrs = imageNode.attrs || {};
|
|
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
|
|
}
|
|
}
|
|
|
|
const x = decorativeImageGroup.x() || 0;
|
|
const y = decorativeImageGroup.y() || 0;
|
|
// Use position as key to match nodes after serialization
|
|
const key = `${x}_${y}_${index}`;
|
|
decorativeImageUrlMap.set(key, imageUrl);
|
|
// Also store by index as fallback
|
|
decorativeImageUrlMap.set(`index_${index}`, imageUrl);
|
|
console.log(`[INVOICE] [SAVE] Stored imageUrl for decorative image ${index} at (${x}, ${y}):`, imageUrl, 'key:', key);
|
|
|
|
if (!imageUrl) {
|
|
console.warn(`[INVOICE] [SAVE] ⚠️ No imageUrl found for decorative image ${index} at (${x}, ${y})`);
|
|
}
|
|
});
|
|
|
|
// Serialize the stage
|
|
const stageJson = stage.toJSON();
|
|
|
|
// CRITICAL FIX: Manually inject imageUrl into serialized JSON for decorative images
|
|
// Konva's toJSON() might not include custom attributes, so we need to add them manually
|
|
let decorativeImageIndex = 0;
|
|
function ensureImageUrlInJson(node, parentKey = '') {
|
|
// CRITICAL FIX: Check if name includes 'decorative-image' (handles "decorative-image element-overlap")
|
|
const nodeName = node.attrs && node.attrs.name ? node.attrs.name : '';
|
|
if (nodeName && nodeName.includes('decorative-image')) {
|
|
// Try to match by position
|
|
const x = node.attrs.x || 0;
|
|
const y = node.attrs.y || 0;
|
|
const positionKey = `${x}_${y}_${decorativeImageIndex}`;
|
|
const indexKey = `index_${decorativeImageIndex}`;
|
|
|
|
// Get imageUrl from map
|
|
let imageUrl = decorativeImageUrlMap.get(positionKey) ||
|
|
decorativeImageUrlMap.get(indexKey) || '';
|
|
|
|
// If still not found, try to find by matching all keys
|
|
if (!imageUrl) {
|
|
for (const [key, url] of decorativeImageUrlMap.entries()) {
|
|
if (url) {
|
|
imageUrl = url;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (imageUrl) {
|
|
// Ensure imageUrl is in the JSON attrs
|
|
if (!node.attrs) {
|
|
node.attrs = {};
|
|
}
|
|
node.attrs.imageUrl = imageUrl;
|
|
console.log(`[INVOICE] [SAVE] ✅ Injected imageUrl into JSON for decorative image ${decorativeImageIndex} at (${x}, ${y}):`, imageUrl);
|
|
} else {
|
|
console.warn(`[INVOICE] [SAVE] ⚠️ No imageUrl found for decorative image ${decorativeImageIndex} at position (${x}, ${y})`);
|
|
// Debug: show what's in the map
|
|
console.log(`[INVOICE] [SAVE] Map contents:`, Array.from(decorativeImageUrlMap.entries()));
|
|
}
|
|
|
|
decorativeImageIndex++;
|
|
}
|
|
if (node.children) {
|
|
node.children.forEach((child, idx) => ensureImageUrlInJson(child, `${parentKey}.children[${idx}]`));
|
|
}
|
|
}
|
|
|
|
// Ensure imageUrl is in JSON for all decorative images
|
|
if (stageJson.children) {
|
|
stageJson.children.forEach((child, idx) => ensureImageUrlInJson(child, `children[${idx}]`));
|
|
}
|
|
|
|
// Debug: Log decorative image groups in the JSON
|
|
function logDecorativeImagesInJson(node, depth = 0) {
|
|
const nodeName = node.attrs && node.attrs.name ? node.attrs.name : '';
|
|
if (nodeName && nodeName.includes('decorative-image')) {
|
|
const imageUrl = node.attrs.imageUrl || 'NOT FOUND';
|
|
console.log('[INVOICE] [SAVE] ' + ' '.repeat(depth) + 'Found decorative-image in JSON at (' + (node.attrs.x || 0) + ',' + (node.attrs.y || 0) + '), imageUrl:', imageUrl);
|
|
}
|
|
if (node.children) {
|
|
node.children.forEach(child => logDecorativeImagesInJson(child, depth + 1));
|
|
}
|
|
}
|
|
console.log('=== Checking decorative images in serialized JSON ===');
|
|
logDecorativeImagesInJson(stageJson);
|
|
|
|
document.getElementById('save-html').value = html;
|
|
document.getElementById('save-css').value = css;
|
|
document.getElementById('save-design-json').value = JSON.stringify(stageJson);
|
|
document.getElementById('save-template-json').value = json;
|
|
// Use page size from selector if available, otherwise use CURRENT_PAGE_SIZE
|
|
const pageSizeForSave = (pageSizeSelector && pageSizeSelector.value) || CURRENT_PAGE_SIZE || 'A4';
|
|
document.getElementById('save-page-size').value = pageSizeForSave;
|
|
// Save date format
|
|
const dateFormatInput = document.getElementById('template-date-format');
|
|
if (dateFormatInput) {
|
|
document.getElementById('save-date-format').value = dateFormatInput.value;
|
|
}
|
|
document.getElementById('form-save').submit();
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', function(e) {
|
|
// Check if user is editing text in an input field or textarea
|
|
const activeElement = document.activeElement;
|
|
const isEditingText = activeElement && (
|
|
activeElement.tagName === 'INPUT' ||
|
|
activeElement.tagName === 'TEXTAREA' ||
|
|
activeElement.isContentEditable
|
|
);
|
|
|
|
// Delete selected element with Delete or Backspace (only if not editing text)
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedElement && !isEditingText) {
|
|
e.preventDefault();
|
|
selectedElement.destroy();
|
|
layer.find('Transformer').forEach(t => t.destroy());
|
|
selectedElement = null;
|
|
layer.draw();
|
|
document.getElementById('properties-content').innerHTML = '<p class="text-sm text-gray-500 italic">Select an element to edit its properties</p>';
|
|
}
|
|
|
|
// Copy with Ctrl+C
|
|
if (e.ctrlKey && e.key === 'c' && selectedElement) {
|
|
e.preventDefault();
|
|
window.copiedElement = selectedElement.toJSON();
|
|
}
|
|
|
|
// Paste with Ctrl+V
|
|
if (e.ctrlKey && e.key === 'v' && window.copiedElement) {
|
|
e.preventDefault();
|
|
const json = window.copiedElement;
|
|
const node = Konva.Node.create(json);
|
|
node.x(node.x() + 20);
|
|
node.y(node.y() + 20);
|
|
layer.add(node);
|
|
setupSelection(node);
|
|
layer.draw();
|
|
}
|
|
|
|
// Duplicate with Ctrl+D
|
|
if (e.ctrlKey && e.key === 'd' && selectedElement) {
|
|
e.preventDefault();
|
|
const json = selectedElement.toJSON();
|
|
const node = Konva.Node.create(json);
|
|
node.x(node.x() + 20);
|
|
node.y(node.y() + 20);
|
|
layer.add(node);
|
|
setupSelection(node);
|
|
layer.draw();
|
|
}
|
|
|
|
// Arrow keys to move element (only if not editing text)
|
|
if (selectedElement && ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && !isEditingText) {
|
|
e.preventDefault();
|
|
const step = e.shiftKey ? 10 : 1;
|
|
|
|
switch(e.key) {
|
|
case 'ArrowUp':
|
|
selectedElement.y(selectedElement.y() - step);
|
|
break;
|
|
case 'ArrowDown':
|
|
selectedElement.y(selectedElement.y() + step);
|
|
break;
|
|
case 'ArrowLeft':
|
|
selectedElement.x(selectedElement.x() - step);
|
|
break;
|
|
case 'ArrowRight':
|
|
selectedElement.x(selectedElement.x() + step);
|
|
break;
|
|
}
|
|
|
|
layer.draw();
|
|
|
|
// Update properties panel if visible
|
|
const propX = document.getElementById('prop-x');
|
|
const propY = document.getElementById('prop-y');
|
|
if (propX) propX.value = Math.round(selectedElement.x());
|
|
if (propY) propY.value = Math.round(selectedElement.y());
|
|
}
|
|
});
|
|
|
|
// Click on background to deselect
|
|
background.on('click', function() {
|
|
layer.find('Transformer').forEach(t => t.destroy());
|
|
selectedElement = null;
|
|
layer.draw();
|
|
document.getElementById('properties-content').innerHTML = '<p class="text-sm text-gray-500 italic">Select an element to edit its properties</p>';
|
|
});
|
|
|
|
// Alignment tools
|
|
window.alignElements = function(direction) {
|
|
if (!selectedElement) return;
|
|
|
|
const stageWidth = stage.width();
|
|
const stageHeight = stage.height();
|
|
const box = selectedElement.getClientRect();
|
|
|
|
switch(direction) {
|
|
case 'left':
|
|
selectedElement.x(0);
|
|
break;
|
|
case 'center-h':
|
|
selectedElement.x((stageWidth - box.width) / 2);
|
|
break;
|
|
case 'right':
|
|
selectedElement.x(stageWidth - box.width);
|
|
break;
|
|
case 'top':
|
|
selectedElement.y(0);
|
|
break;
|
|
case 'center-v':
|
|
selectedElement.y((stageHeight - box.height) / 2);
|
|
break;
|
|
case 'bottom':
|
|
selectedElement.y(stageHeight - box.height);
|
|
break;
|
|
}
|
|
|
|
layer.draw();
|
|
|
|
// Update properties panel
|
|
const propX = document.getElementById('prop-x');
|
|
const propY = document.getElementById('prop-y');
|
|
if (propX) propX.value = Math.round(selectedElement.x());
|
|
if (propY) propY.value = Math.round(selectedElement.y());
|
|
};
|
|
|
|
// Save design state
|
|
window.saveDesignState = function() {
|
|
return {
|
|
json: stage.toJSON(),
|
|
html: generateCode().html,
|
|
css: generateCode().css
|
|
};
|
|
};
|
|
|
|
// Load design state
|
|
window.loadDesignState = function(designJson) {
|
|
try {
|
|
const json = JSON.parse(designJson);
|
|
stage = Konva.Node.create(json, 'canvas-container');
|
|
layer = stage.children[0];
|
|
|
|
// Re-setup selections for all elements
|
|
layer.children.forEach(child => {
|
|
if (child !== background && child.className !== 'Transformer') {
|
|
setupSelection(child);
|
|
// If it's a table Group, also setup selection on children
|
|
if (child.className === 'Group' && (child.attrs.name === 'items-table' || child.attrs.name === 'expenses-table')) {
|
|
child.children.forEach(grandChild => {
|
|
setupSelection(grandChild);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.draw();
|
|
} catch(e) {
|
|
console.error('Failed to load design state:', e);
|
|
}
|
|
};
|
|
|
|
// Add alignment buttons to canvas toolbar
|
|
const canvasToolbar = document.querySelector('.canvas-toolbar');
|
|
if (canvasToolbar) {
|
|
const alignmentButtons = `
|
|
<button type="button" onclick="alignElements('left')" title="Align Left">
|
|
<i class="fas fa-align-left"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('center-h')" title="Center Horizontally">
|
|
<i class="fas fa-align-center"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('right')" title="Align Right">
|
|
<i class="fas fa-align-right"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('top')" title="Align Top">
|
|
<i class="fas fa-arrow-up"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('center-v')" title="Center Vertically">
|
|
<i class="fas fa-arrows-alt-v"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('bottom')" title="Align Bottom">
|
|
<i class="fas fa-arrow-down"></i>
|
|
</button>
|
|
`;
|
|
canvasToolbar.insertAdjacentHTML('beforeend', alignmentButtons);
|
|
}
|
|
|
|
// Load saved design or default layout
|
|
setTimeout(() => {
|
|
if (SAVED_DESIGN_JSON && SAVED_DESIGN_JSON.trim() !== '') {
|
|
console.log('Loading saved design from database...');
|
|
try {
|
|
const savedJson = JSON.parse(SAVED_DESIGN_JSON);
|
|
|
|
// Get current page size dimensions
|
|
const currentSize = CURRENT_PAGE_SIZE || 'A4';
|
|
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
|
|
// Update saved JSON dimensions to match current page size
|
|
if (savedJson.attrs) {
|
|
savedJson.attrs.width = dimensions.width;
|
|
savedJson.attrs.height = dimensions.height;
|
|
}
|
|
|
|
// Clear current layer
|
|
layer.destroyChildren();
|
|
|
|
// Recreate stage from saved JSON
|
|
const restoredStage = Konva.Node.create(savedJson, 'canvas-container');
|
|
|
|
// Ensure stage has correct dimensions
|
|
restoredStage.width(dimensions.width);
|
|
restoredStage.height(dimensions.height);
|
|
|
|
// Replace current stage
|
|
stage.destroy();
|
|
stage = restoredStage;
|
|
layer = stage.children[0];
|
|
|
|
// Debug: Log the saved JSON to check if imageUrl is there
|
|
const savedJsonString = JSON.stringify(savedJson);
|
|
console.log('[INVOICE] [LOAD] Saved JSON structure (first 2000 chars):', savedJsonString.substring(0, 2000));
|
|
|
|
// Check if imageUrl is in the saved JSON
|
|
const decorativeImageCountInSaved = (savedJsonString.match(/"name":"decorative-image"/g) || []).length;
|
|
const imageUrlCountInSaved = (savedJsonString.match(/"imageUrl":"[^"]+"/g) || []).length;
|
|
console.log('[INVOICE] [LOAD] Saved JSON check - Decorative images:', decorativeImageCountInSaved, 'imageUrl attributes:', imageUrlCountInSaved);
|
|
|
|
if (decorativeImageCountInSaved > 0 && imageUrlCountInSaved === 0) {
|
|
console.error('[INVOICE] [LOAD] ⚠️ WARNING: Found decorative images in saved JSON but NO imageUrl attributes!');
|
|
}
|
|
|
|
// Find or create background by name and resize it
|
|
background = layer.findOne('[name="background"]');
|
|
if (background) {
|
|
if (background.className === 'Rect') {
|
|
background.width(dimensions.width);
|
|
background.height(dimensions.height);
|
|
}
|
|
} else {
|
|
// Create background if it doesn't exist
|
|
background = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
fill: 'white',
|
|
name: 'background'
|
|
});
|
|
layer.add(background);
|
|
background.moveToBottom();
|
|
}
|
|
|
|
// Update page border to match new dimensions
|
|
const pageBorder = layer.findOne('[name="page-border"]');
|
|
if (pageBorder) {
|
|
pageBorder.width(dimensions.width);
|
|
pageBorder.height(dimensions.height);
|
|
} else {
|
|
// Create page border if it doesn't exist
|
|
const newPageBorder = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
stroke: '#667eea',
|
|
strokeWidth: 2,
|
|
fill: 'transparent',
|
|
name: 'page-border',
|
|
listening: false,
|
|
perfectDrawEnabled: false
|
|
});
|
|
layer.add(newPageBorder);
|
|
newPageBorder.moveToBottom();
|
|
}
|
|
|
|
// Clean up unwanted elements that might have been saved
|
|
// Remove unwanted full-page rectangle borders
|
|
layer.children.forEach((child) => {
|
|
if (child.className === 'Rect' && child !== background && child !== pageBorder) {
|
|
const rectName = child.attrs.name || '';
|
|
const rectX = child.attrs.x || 0;
|
|
const rectY = child.attrs.y || 0;
|
|
const rectWidth = child.attrs.width || 0;
|
|
const rectHeight = child.attrs.height || 0;
|
|
const rectFill = child.attrs.fill || '';
|
|
const rectStroke = child.attrs.stroke || '';
|
|
|
|
// Remove if name is "background" (duplicate)
|
|
if (rectName === 'background') {
|
|
console.log('Removing duplicate background rectangle');
|
|
child.destroy();
|
|
return;
|
|
}
|
|
|
|
// Remove full-page rectangles at 0,0 with white fill and black stroke (unwanted borders)
|
|
const isFullPage = Math.abs(rectX) < 5 && Math.abs(rectY) < 5 &&
|
|
Math.abs(rectWidth - dimensions.width) < 5 &&
|
|
Math.abs(rectHeight - dimensions.height) < 5;
|
|
|
|
if (isFullPage && (rectFill === 'white' || rectFill === '#ffffff' || rectFill === 'transparent') &&
|
|
(rectStroke === 'black' || rectStroke === '#000000')) {
|
|
console.log('Removing unwanted full-page rectangle border on load');
|
|
child.destroy();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Remove unwanted lines (border lines and separator lines)
|
|
if (child.className === 'Line') {
|
|
const lineX = child.attrs.x || 0;
|
|
const lineY = child.attrs.y || 0;
|
|
const points = child.attrs.points || [];
|
|
const stroke = child.attrs.stroke || '';
|
|
const lineName = child.attrs.name || '';
|
|
|
|
// Skip grid lines
|
|
if (lineName === 'grid-line') {
|
|
return;
|
|
}
|
|
|
|
// Calculate actual line position and width from points
|
|
let actualX = lineX;
|
|
let actualY = lineY;
|
|
let lineWidth = 0;
|
|
|
|
if (points.length >= 4) {
|
|
const x1 = points[0];
|
|
const y1 = points[1];
|
|
const x2 = points[2];
|
|
const y2 = points[3];
|
|
lineWidth = Math.abs(x2 - x1);
|
|
actualX = lineX + Math.min(x1, x2);
|
|
actualY = lineY + Math.min(y1, y2);
|
|
} else {
|
|
// If no points, check width attribute
|
|
lineWidth = child.attrs.width || 0;
|
|
actualX = lineX;
|
|
actualY = lineY;
|
|
}
|
|
|
|
// Remove border lines at origin (0,0) with full page width
|
|
const isAtOrigin = Math.abs(actualX) < 5 && Math.abs(actualY) < 5;
|
|
const isFullWidth = lineWidth > dimensions.width * 0.9;
|
|
const isGrayOrBlue = stroke === '#e0e0e0' || stroke === '#dee2e6' || stroke === 'gray' || stroke === 'grey' ||
|
|
stroke === '#cccccc' || stroke === '#999999' || stroke === '#667eea';
|
|
|
|
// Remove any line at origin with full width (border lines)
|
|
if (isAtOrigin && isFullWidth) {
|
|
console.log('Removing unwanted border line at origin on load:', {actualX, actualY, lineWidth, stroke, name: lineName});
|
|
child.destroy();
|
|
return;
|
|
}
|
|
|
|
// Remove unwanted gray/blue separator lines near top
|
|
if (points.length >= 4) {
|
|
const y1 = points[1];
|
|
const y2 = points[3];
|
|
const isNearTop = Math.min(y1, y2) < 50 || actualY < 50;
|
|
|
|
if (isFullWidth && isNearTop && isGrayOrBlue && lineName !== 'items-table-separator' && lineName !== 'expenses-table-separator') {
|
|
console.log('Removing unwanted separator line on load:', {actualX, actualY, lineWidth, stroke, name: lineName});
|
|
child.destroy();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.draw();
|
|
|
|
// Update width and height variables for fit function
|
|
width = dimensions.width;
|
|
height = dimensions.height;
|
|
|
|
// Redraw grid for new size
|
|
drawGrid();
|
|
|
|
// Fix logo images: Konva Image nodes need to reload the image from URL
|
|
// When Konva Image nodes are serialized/deserialized, the image data is lost
|
|
// We need to reload the image from the URL and restore all attributes
|
|
if (LOGO_URL) {
|
|
// Find logo images by name attribute first
|
|
let logoImages = layer.find('[name="logo"]');
|
|
|
|
// Also check all Image nodes in case name attribute is lost during deserialization
|
|
// An Image node with name="logo" or any Image node that should be a logo
|
|
const allImages = layer.find('Image');
|
|
allImages.forEach(function(imgNode) {
|
|
// If it's already in logoImages, skip
|
|
if (logoImages.indexOf(imgNode) === -1) {
|
|
// Check if it has name="logo" or if it's likely a logo (has width/height but no image data)
|
|
const nodeName = imgNode.name ? imgNode.name() : (imgNode.attrs ? imgNode.attrs.name : null);
|
|
if (nodeName === 'logo' || (!imgNode.image() && imgNode.width() && imgNode.height())) {
|
|
logoImages = logoImages.concat([imgNode]);
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log('Found', logoImages.length, 'logo image(s) to restore');
|
|
|
|
logoImages.forEach(function(logoNode) {
|
|
// Check if it's an Image node - use multiple methods for robustness
|
|
const isImage = logoNode.className === 'Image' ||
|
|
(logoNode.constructor && logoNode.constructor.name === 'Image') ||
|
|
(logoNode instanceof Konva.Image) ||
|
|
(logoNode.getClassName && logoNode.getClassName() === 'Image');
|
|
|
|
if (isImage) {
|
|
// Save all attributes before reloading
|
|
const savedAttrs = {
|
|
x: logoNode.x(),
|
|
y: logoNode.y(),
|
|
width: logoNode.width(),
|
|
height: logoNode.height(),
|
|
scaleX: logoNode.scaleX(),
|
|
scaleY: logoNode.scaleY(),
|
|
rotation: logoNode.rotation(),
|
|
opacity: logoNode.opacity(),
|
|
draggable: logoNode.draggable(),
|
|
visible: logoNode.visible()
|
|
};
|
|
|
|
// Store reference to update elements array
|
|
const oldNode = logoNode;
|
|
const parent = logoNode.getParent();
|
|
const index = logoNode.getZIndex();
|
|
|
|
console.log('Restoring logo image at', savedAttrs.x, savedAttrs.y);
|
|
|
|
// Reload the image from URL
|
|
Konva.Image.fromURL(LOGO_URL, function(newImage) {
|
|
// Calculate actual size considering scale
|
|
// If scaleX/scaleY are not 1, the actual size is width*scaleX and height*scaleY
|
|
let finalWidth = savedAttrs.width;
|
|
let finalHeight = savedAttrs.height;
|
|
|
|
// If scale was applied, we need to account for it
|
|
// Reset scale to 1 and apply the scaled dimensions as the new width/height
|
|
if (savedAttrs.scaleX !== 1 || savedAttrs.scaleY !== 1) {
|
|
finalWidth = savedAttrs.width * savedAttrs.scaleX;
|
|
finalHeight = savedAttrs.height * savedAttrs.scaleY;
|
|
}
|
|
|
|
// Restore all attributes, but reset scale to 1 and use calculated dimensions
|
|
newImage.setAttrs({
|
|
x: savedAttrs.x,
|
|
y: savedAttrs.y,
|
|
width: finalWidth,
|
|
height: finalHeight,
|
|
scaleX: 1,
|
|
scaleY: 1,
|
|
rotation: savedAttrs.rotation,
|
|
opacity: savedAttrs.opacity,
|
|
draggable: savedAttrs.draggable,
|
|
visible: savedAttrs.visible
|
|
});
|
|
newImage.name('logo');
|
|
|
|
// Replace the old node with the new one
|
|
oldNode.destroy();
|
|
parent.add(newImage);
|
|
newImage.zIndex(index);
|
|
|
|
// Update elements array
|
|
const elementIndex = elements.findIndex(e => e.node === oldNode);
|
|
if (elementIndex !== -1) {
|
|
elements[elementIndex].node = newImage;
|
|
} else {
|
|
// Add to elements array if not found
|
|
elements.push({ type: 'logo', node: newImage });
|
|
}
|
|
|
|
// Setup selection for the new image
|
|
setupSelection(newImage);
|
|
|
|
layer.draw();
|
|
console.log('Logo image restored successfully');
|
|
}, function(error) {
|
|
console.error('Failed to load logo image:', error);
|
|
// Keep the old node if image fails to load, but try to make it visible
|
|
// by setting a placeholder
|
|
if (oldNode && oldNode.parent) {
|
|
oldNode.visible(true);
|
|
layer.draw();
|
|
}
|
|
});
|
|
} else {
|
|
console.warn('Found node with name="logo" but it is not an Image:', logoNode.className, logoNode.constructor ? logoNode.constructor.name : 'unknown');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Restore decorative images - must happen AFTER stage is created and fully deserialized
|
|
// Find decorative image groups and restore their images
|
|
console.log('🔍 Starting decorative image restoration...');
|
|
console.log('Layer children count:', layer.children.length);
|
|
console.log('Searching for decorative-image groups...');
|
|
console.log('Saved JSON available:', !!savedJson);
|
|
if (savedJson && savedJson.children) {
|
|
console.log('Saved JSON has', savedJson.children.length, 'top-level children');
|
|
}
|
|
|
|
// CRITICAL: Ensure decorative image restoration runs even if there's a delay
|
|
// Use a longer timeout to ensure stage is fully ready
|
|
setTimeout(() => {
|
|
console.log('🔍 [INVOICE] Decorative image restoration setTimeout callback executing...');
|
|
// CRITICAL FIX: Search for decorative-image groups, handling names with additional suffixes like "element-overlap"
|
|
let decorativeImageGroups = layer.find('[name="decorative-image"]');
|
|
// Also find groups whose name includes "decorative-image" (handles "decorative-image element-overlap")
|
|
const allGroups = layer.find('Group');
|
|
allGroups.forEach(group => {
|
|
const name = group.name() || group.getAttr('name') || '';
|
|
if (name.includes('decorative-image') && !decorativeImageGroups.includes(group)) {
|
|
decorativeImageGroups = decorativeImageGroups.concat([group]);
|
|
console.log('[INVOICE] Found decorative-image group with modified name:', name);
|
|
}
|
|
});
|
|
console.log('[INVOICE] Found', decorativeImageGroups.length, 'decorative image group(s) via layer.find (including modified names)');
|
|
|
|
// Also try finding by className and checking name - check ALL children, not just Groups
|
|
console.log('Checking all', layer.children.length, 'layer children...');
|
|
const potentialGroups = [];
|
|
layer.children.forEach((child, idx) => {
|
|
// Check if it's a Group by className or getType
|
|
const isGroup = child.className === 'Group' ||
|
|
(child.getType && child.getType() === 'Group') ||
|
|
(child.constructor && child.constructor.name === 'Group');
|
|
|
|
if (isGroup) {
|
|
const nameViaGetAttr = child.getAttr ? child.getAttr('name') : null;
|
|
const nameViaName = child.name ? child.name() : null;
|
|
const nameViaAttrs = child.attrs ? child.attrs.name : null;
|
|
|
|
// CRITICAL FIX: Check if name includes 'decorative-image' (handles "decorative-image element-overlap")
|
|
if ((nameViaGetAttr && nameViaGetAttr.includes('decorative-image')) ||
|
|
(nameViaName && nameViaName.includes('decorative-image')) ||
|
|
(nameViaAttrs && nameViaAttrs.includes('decorative-image'))) {
|
|
console.log(`[INVOICE] ✅ Found decorative-image group at index ${idx} with name: "${nameViaGetAttr || nameViaName || nameViaAttrs}"`);
|
|
potentialGroups.push(child);
|
|
} else {
|
|
// Check if it has Image or Rect children (might be decorative image without name)
|
|
const hasImage = child.findOne ? child.findOne('Image') : null;
|
|
const hasRect = child.findOne ? child.findOne('Rect') : null;
|
|
if (hasImage || hasRect) {
|
|
console.log(` ⚠️ Group ${idx} has Image/Rect but name="${nameViaGetAttr || nameViaName || nameViaAttrs || 'unnamed'}"`);
|
|
}
|
|
}
|
|
} else {
|
|
// Check if it's an Image node that might be part of a decorative image
|
|
if (child.className === 'Image' || (child.getType && child.getType() === 'Image')) {
|
|
const parent = child.getParent ? child.getParent() : null;
|
|
if (parent && (parent.className === 'Group' || (parent.getType && parent.getType() === 'Group'))) {
|
|
const parentName = parent.getAttr ? parent.getAttr('name') : (parent.attrs ? parent.attrs.name : null);
|
|
if (parentName === 'decorative-image') {
|
|
console.log(` ✅ Found decorative-image group via Image node's parent at index ${idx}`);
|
|
if (!potentialGroups.includes(parent)) {
|
|
potentialGroups.push(parent);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log('Total potential decorative-image groups found:', potentialGroups.length);
|
|
|
|
// Also search the saved JSON directly to find decorative-image groups
|
|
const jsonGroups = [];
|
|
if (savedJson && savedJson.children) {
|
|
function findDecorativeImageGroupsInJson(node, path = '') {
|
|
const nodeName = node.attrs && node.attrs.name ? node.attrs.name : '';
|
|
// Check if name includes 'decorative-image' (handles "decorative-image element-overlap")
|
|
if (nodeName && nodeName.includes('decorative-image')) {
|
|
console.log(`[INVOICE] ✅ Found decorative-image in JSON at path: ${path}, name: "${nodeName}", imageUrl:`, node.attrs.imageUrl || 'NOT FOUND');
|
|
jsonGroups.push({ node: node, path: path, imageUrl: node.attrs.imageUrl || '' });
|
|
}
|
|
if (node.children) {
|
|
node.children.forEach((child, idx) => {
|
|
findDecorativeImageGroupsInJson(child, path ? `${path}.children[${idx}]` : `children[${idx}]`);
|
|
});
|
|
}
|
|
}
|
|
console.log('Searching saved JSON for decorative-image groups...');
|
|
savedJson.children.forEach((child, idx) => {
|
|
findDecorativeImageGroupsInJson(child, `children[${idx}]`);
|
|
});
|
|
console.log('Found', jsonGroups.length, 'decorative-image group(s) in saved JSON');
|
|
}
|
|
|
|
// Combine found groups with potential groups
|
|
let groupsToProcess = decorativeImageGroups;
|
|
|
|
// Add any potential groups that weren't found by layer.find
|
|
potentialGroups.forEach(group => {
|
|
if (!groupsToProcess.includes(group)) {
|
|
console.log('Adding potential group to process list');
|
|
groupsToProcess = groupsToProcess.concat([group]);
|
|
}
|
|
});
|
|
|
|
// If we found groups in JSON but not in the layer, try to find them by matching position/attributes
|
|
if (jsonGroups.length > 0 && groupsToProcess.length === 0) {
|
|
console.log('⚠️ Found groups in JSON but not in layer - trying to match by attributes...');
|
|
jsonGroups.forEach(jsonGroup => {
|
|
// Try to find matching group in layer by checking all children
|
|
layer.children.forEach(child => {
|
|
const isGroup = child.className === 'Group' ||
|
|
(child.getType && child.getType() === 'Group');
|
|
if (isGroup) {
|
|
// Check if attributes match (x, y, width, height)
|
|
const jsonAttrs = jsonGroup.node.attrs || {};
|
|
const childAttrs = child.attrs || {};
|
|
if (Math.abs((jsonAttrs.x || 0) - (childAttrs.x || 0)) < 1 &&
|
|
Math.abs((jsonAttrs.y || 0) - (childAttrs.y || 0)) < 1) {
|
|
console.log(' ✅ Matched group by position, setting name and imageUrl');
|
|
child.setAttr('name', 'decorative-image');
|
|
child.name('decorative-image');
|
|
if (child.attrs) {
|
|
child.attrs.name = 'decorative-image';
|
|
}
|
|
if (jsonGroup.imageUrl) {
|
|
child.setAttr('imageUrl', jsonGroup.imageUrl);
|
|
child.attrs.imageUrl = jsonGroup.imageUrl;
|
|
}
|
|
if (!groupsToProcess.includes(child)) {
|
|
groupsToProcess = groupsToProcess.concat([child]);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
if (groupsToProcess.length === 0) {
|
|
console.warn('⚠️ No decorative image groups found! Checking all groups for Image/Rect children...');
|
|
layer.children.forEach((child, idx) => {
|
|
// Check if it's a Group
|
|
const isGroup = child.className === 'Group' ||
|
|
(child.getType && child.getType() === 'Group') ||
|
|
(child.constructor && child.constructor.name === 'Group');
|
|
|
|
if (isGroup) {
|
|
const nameViaGetAttr = child.getAttr ? child.getAttr('name') : null;
|
|
const nameViaName = child.name ? child.name() : null;
|
|
const nameViaAttrs = child.attrs ? child.attrs.name : null;
|
|
console.log(`Group ${idx}: name via getAttr="${nameViaGetAttr}", via name()="${nameViaName}", via attrs="${nameViaAttrs}"`);
|
|
|
|
// Check if this might be a decorative image group by checking children
|
|
const hasImage = child.findOne ? child.findOne('Image') : null;
|
|
const hasPlaceholderRect = child.findOne ? child.findOne('Rect') : null;
|
|
if (hasImage || hasPlaceholderRect) {
|
|
console.log(` ⚠️ Group ${idx} has Image or Rect - might be decorative image but name is missing!`);
|
|
// Try to fix it
|
|
if (!nameViaGetAttr && !nameViaName && !nameViaAttrs) {
|
|
console.log(` 🔧 Attempting to fix: setting name to 'decorative-image'`);
|
|
child.setAttr('name', 'decorative-image');
|
|
child.name('decorative-image');
|
|
if (child.attrs) {
|
|
child.attrs.name = 'decorative-image';
|
|
}
|
|
// Add to groups to process
|
|
if (!groupsToProcess.includes(child)) {
|
|
groupsToProcess = groupsToProcess.concat([child]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Try searching again after potential fixes
|
|
const decorativeImageGroupsAfterFix = layer.find('[name="decorative-image"]');
|
|
if (decorativeImageGroupsAfterFix.length > 0) {
|
|
console.log('✅ Found', decorativeImageGroupsAfterFix.length, 'decorative image group(s) after fixing names');
|
|
// Merge with existing groupsToProcess
|
|
decorativeImageGroupsAfterFix.forEach(group => {
|
|
if (!groupsToProcess.includes(group)) {
|
|
groupsToProcess = groupsToProcess.concat([group]);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log('Final groupsToProcess count:', groupsToProcess.length);
|
|
|
|
groupsToProcess.forEach(decorativeImageGroup => {
|
|
console.log('Found decorative image group:', decorativeImageGroup);
|
|
console.log('Group attrs:', decorativeImageGroup.attrs);
|
|
|
|
// Check if there's an Image node already (from deserialization)
|
|
const existingImage = decorativeImageGroup.findOne('Image');
|
|
console.log('Existing image node:', existingImage);
|
|
if (existingImage) {
|
|
console.log('Existing image attrs:', existingImage.attrs);
|
|
// Check if it has valid image data
|
|
try {
|
|
const hasImageData = existingImage.image();
|
|
console.log('Existing image has data:', !!hasImageData);
|
|
if (!hasImageData) {
|
|
// Image node exists but has no data - remove it
|
|
console.log('Removing image node without data');
|
|
existingImage.destroy();
|
|
}
|
|
} catch(e) {
|
|
console.log('Could not check image data, removing node:', e);
|
|
existingImage.destroy();
|
|
}
|
|
}
|
|
|
|
// Try multiple methods to get imageUrl
|
|
let imageUrl = '';
|
|
try {
|
|
imageUrl = decorativeImageGroup.getAttr('imageUrl') || '';
|
|
console.log('[INVOICE] Got imageUrl via getAttr:', imageUrl);
|
|
} catch(e) {
|
|
console.warn('[INVOICE] Could not get imageUrl via getAttr:', e);
|
|
}
|
|
if (!imageUrl) {
|
|
imageUrl = decorativeImageGroup.attrs.imageUrl || '';
|
|
console.log('[INVOICE] Got imageUrl from attrs.imageUrl:', imageUrl);
|
|
}
|
|
if (!imageUrl && decorativeImageGroup.attrs) {
|
|
imageUrl = decorativeImageGroup.attrs.imageUrl || '';
|
|
}
|
|
|
|
// Also check the saved JSON directly if we still don't have it
|
|
if (!imageUrl && savedJson && savedJson.children) {
|
|
try {
|
|
// Search through the JSON structure for decorative-image groups
|
|
// Match by position to get the correct imageUrl for this specific group
|
|
const groupX = decorativeImageGroup.x() || 0;
|
|
const groupY = decorativeImageGroup.y() || 0;
|
|
|
|
function findImageUrlInJsonByPosition(node, targetName, targetX, targetY) {
|
|
const nodeName = node.attrs && node.attrs.name ? node.attrs.name : '';
|
|
// Check if name includes targetName (handles "decorative-image element-overlap")
|
|
if (nodeName && nodeName.includes(targetName)) {
|
|
// Match by position (within 5px tolerance)
|
|
const nodeX = node.attrs.x || 0;
|
|
const nodeY = node.attrs.y || 0;
|
|
if (Math.abs(nodeX - targetX) < 5 && Math.abs(nodeY - targetY) < 5) {
|
|
const url = node.attrs.imageUrl || '';
|
|
console.log('[INVOICE] Found matching decorative-image in JSON at position (', nodeX, ',', nodeY, '), imageUrl:', url);
|
|
return url;
|
|
}
|
|
}
|
|
if (node.children) {
|
|
for (let child of node.children) {
|
|
const url = findImageUrlInJsonByPosition(child, targetName, targetX, targetY);
|
|
if (url) return url;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// First try to find by position
|
|
let jsonImageUrl = findImageUrlInJsonByPosition(savedJson, 'decorative-image', groupX, groupY);
|
|
|
|
// If not found by position, try to find any decorative-image (fallback)
|
|
if (!jsonImageUrl) {
|
|
console.log('[INVOICE] No match by position, trying to find any decorative-image in JSON...');
|
|
function findImageUrlInJson(node, targetName) {
|
|
if (node.attrs && node.attrs.name === targetName) {
|
|
const url = node.attrs.imageUrl || '';
|
|
console.log('[INVOICE] Found decorative-image in JSON (no position match), imageUrl:', url);
|
|
return url;
|
|
}
|
|
if (node.children) {
|
|
for (let child of node.children) {
|
|
const url = findImageUrlInJson(child, targetName);
|
|
if (url) return url;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
jsonImageUrl = findImageUrlInJson(savedJson, 'decorative-image');
|
|
}
|
|
|
|
if (jsonImageUrl) {
|
|
imageUrl = jsonImageUrl;
|
|
// Also set it in the group for future use
|
|
decorativeImageGroup.setAttr('imageUrl', imageUrl);
|
|
decorativeImageGroup.attrs.imageUrl = imageUrl;
|
|
console.log('[INVOICE] ✅ Found imageUrl in saved JSON and set it:', imageUrl);
|
|
} else {
|
|
console.warn('[INVOICE] ⚠️ No imageUrl found in saved JSON for decorative image at position (', groupX, ',', groupY, ')');
|
|
// Debug: log all decorative-image groups in JSON
|
|
function logAllDecorativeImages(node, depth = 0) {
|
|
const nodeName = node.attrs && node.attrs.name ? node.attrs.name : '';
|
|
if (nodeName && nodeName.includes('decorative-image')) {
|
|
console.log('[INVOICE] '.repeat(depth) + 'Found decorative-image in JSON: x=' + (node.attrs.x || 0) + ', y=' + (node.attrs.y || 0) + ', imageUrl=' + (node.attrs.imageUrl || 'NOT FOUND'));
|
|
}
|
|
if (node.children) {
|
|
node.children.forEach(child => logAllDecorativeImages(child, depth + 1));
|
|
}
|
|
}
|
|
console.log('[INVOICE] All decorative-image groups in saved JSON:');
|
|
logAllDecorativeImages(savedJson);
|
|
}
|
|
} catch(e) {
|
|
console.warn('[INVOICE] Could not search JSON for imageUrl:', e);
|
|
}
|
|
}
|
|
|
|
console.log('[INVOICE] Restoring decorative image, final URL:', imageUrl);
|
|
|
|
// CRITICAL FIX: Ensure decorative image group is always visible, even without imageUrl
|
|
decorativeImageGroup.visible(true);
|
|
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
// Remove any existing image node (it won't have valid image data after deserialization)
|
|
if (existingImage) {
|
|
existingImage.destroy();
|
|
}
|
|
|
|
// Remove placeholder elements
|
|
const rect = decorativeImageGroup.findOne('Rect');
|
|
const allTexts = decorativeImageGroup.find('Text');
|
|
allTexts.forEach(textNode => {
|
|
const text = textNode.text();
|
|
if (text.includes('Decorative') || text.includes('🖼️')) {
|
|
textNode.destroy();
|
|
}
|
|
});
|
|
|
|
if (rect) rect.destroy();
|
|
|
|
// Create a temporary image to verify it loads
|
|
const tempImg = new Image();
|
|
tempImg.crossOrigin = 'anonymous';
|
|
|
|
tempImg.onload = function() {
|
|
console.log('[INVOICE] Decorative image verified, loading into Konva. Dimensions:', tempImg.width, 'x', tempImg.height);
|
|
|
|
// Load the actual image
|
|
Konva.Image.fromURL(imageUrl, function(konvaImage) {
|
|
const maxSize = 200;
|
|
let width = konvaImage.width() || tempImg.width || 100;
|
|
let height = konvaImage.height() || tempImg.height || 100;
|
|
const aspectRatio = width / height;
|
|
|
|
if (width > maxSize || height > maxSize) {
|
|
if (width > height) {
|
|
width = maxSize;
|
|
height = maxSize / aspectRatio;
|
|
} else {
|
|
height = maxSize;
|
|
width = maxSize * aspectRatio;
|
|
}
|
|
}
|
|
|
|
// Preserve transparency - don't set fill or opacity that would override image alpha
|
|
konvaImage.setAttrs({
|
|
x: 0,
|
|
y: 0,
|
|
width: width,
|
|
height: height,
|
|
// Ensure transparency is preserved - Konva.Image preserves alpha by default
|
|
opacity: 1.0, // Don't override image's built-in alpha channel
|
|
visible: true, // Explicitly make it visible
|
|
listening: true
|
|
});
|
|
|
|
decorativeImageGroup.width(width);
|
|
decorativeImageGroup.height(height);
|
|
decorativeImageGroup.visible(true); // Ensure group is visible
|
|
decorativeImageGroup.add(konvaImage);
|
|
|
|
// Force a redraw and ensure the image is on top
|
|
layer.draw();
|
|
|
|
// Also try drawing the stage to ensure everything is rendered
|
|
stage.draw();
|
|
|
|
// Verify the image is actually in the group and visible
|
|
const imageInGroup = decorativeImageGroup.findOne('Image');
|
|
if (imageInGroup && imageInGroup === konvaImage) {
|
|
console.log('[INVOICE] ✅ Decorative image restored successfully, dimensions:', width, 'x', height);
|
|
console.log('[INVOICE] ✅ Image node is in group and visible');
|
|
} else {
|
|
console.error('❌ Image node not found in group after adding!');
|
|
console.log('Expected:', konvaImage);
|
|
console.log('Found in group:', imageInGroup);
|
|
console.log('Group children:', decorativeImageGroup.children.map(c => c.className));
|
|
// Try adding again
|
|
if (!decorativeImageGroup.children.includes(konvaImage)) {
|
|
decorativeImageGroup.add(konvaImage);
|
|
layer.draw();
|
|
stage.draw();
|
|
}
|
|
}
|
|
|
|
console.log('Image visible:', konvaImage.visible());
|
|
console.log('Group visible:', decorativeImageGroup.visible());
|
|
console.log('Image in group:', decorativeImageGroup.children.includes(konvaImage));
|
|
|
|
// Force another draw after a short delay to ensure rendering
|
|
setTimeout(() => {
|
|
layer.draw();
|
|
stage.draw();
|
|
}, 100);
|
|
}, function(error) {
|
|
console.error('Failed to load decorative image into Konva:', error);
|
|
// Restore placeholder if image fails to load
|
|
const placeholderRect = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
fill: '#f0f0f0',
|
|
stroke: '#999',
|
|
strokeWidth: 2,
|
|
dash: [5, 5]
|
|
});
|
|
decorativeImageGroup.add(placeholderRect);
|
|
layer.draw();
|
|
});
|
|
};
|
|
|
|
tempImg.onerror = function() {
|
|
console.error('Failed to verify decorative image:', imageUrl);
|
|
// Restore placeholder on error
|
|
const placeholderRect = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
fill: '#f0f0f0',
|
|
stroke: '#999',
|
|
strokeWidth: 2,
|
|
dash: [5, 5]
|
|
});
|
|
decorativeImageGroup.add(placeholderRect);
|
|
layer.draw();
|
|
};
|
|
|
|
// Set src after all handlers are attached
|
|
tempImg.src = imageUrl;
|
|
|
|
// Also try loading directly if tempImg fails silently
|
|
setTimeout(() => {
|
|
if (!tempImg.complete || tempImg.naturalWidth === 0) {
|
|
console.warn('Temp image did not load, trying direct Konva load');
|
|
// Try loading directly with Konva
|
|
Konva.Image.fromURL(imageUrl, function(directImage) {
|
|
if (directImage && directImage.image()) {
|
|
const maxSize = 200;
|
|
let width = directImage.width() || 100;
|
|
let height = directImage.height() || 100;
|
|
const aspectRatio = width / height;
|
|
|
|
if (width > maxSize || height > maxSize) {
|
|
if (width > height) {
|
|
width = maxSize;
|
|
height = maxSize / aspectRatio;
|
|
} else {
|
|
height = maxSize;
|
|
width = maxSize * aspectRatio;
|
|
}
|
|
}
|
|
|
|
directImage.setAttrs({
|
|
x: 0,
|
|
y: 0,
|
|
width: width,
|
|
height: height,
|
|
opacity: 1.0,
|
|
visible: true,
|
|
listening: true
|
|
});
|
|
|
|
decorativeImageGroup.width(width);
|
|
decorativeImageGroup.height(height);
|
|
decorativeImageGroup.visible(true);
|
|
decorativeImageGroup.add(directImage);
|
|
layer.draw();
|
|
stage.draw();
|
|
console.log('Decorative image loaded directly via Konva');
|
|
}
|
|
}, function(error) {
|
|
console.error('Direct Konva load also failed:', error);
|
|
});
|
|
}
|
|
}, 2000); // Wait 2 seconds before fallback
|
|
} else {
|
|
console.log('No imageUrl found for decorative image, ensuring placeholder is visible');
|
|
// Even if no imageUrl, ensure the group is visible and has placeholder elements
|
|
decorativeImageGroup.visible(true);
|
|
|
|
// Check if placeholder elements exist, if not create them
|
|
const hasRect = decorativeImageGroup.findOne('Rect');
|
|
const hasPlaceholderText = decorativeImageGroup.find('Text').some(textNode => {
|
|
const text = textNode.text();
|
|
return text.includes('Decorative') || text.includes('🖼️');
|
|
});
|
|
|
|
if (!hasRect || !hasPlaceholderText) {
|
|
console.log('Creating placeholder elements for decorative image without imageUrl');
|
|
|
|
// Remove any existing image that might be there
|
|
const existingImage = decorativeImageGroup.findOne('Image');
|
|
if (existingImage) {
|
|
existingImage.destroy();
|
|
}
|
|
|
|
// Create placeholder rect if missing
|
|
if (!hasRect) {
|
|
const placeholderRect = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
fill: '#f0f0f0',
|
|
stroke: '#999',
|
|
strokeWidth: 2,
|
|
dash: [5, 5]
|
|
});
|
|
decorativeImageGroup.add(placeholderRect);
|
|
}
|
|
|
|
// Create placeholder text/icon if missing
|
|
if (!hasPlaceholderText) {
|
|
const placeholderText = new Konva.Text({
|
|
x: 10,
|
|
y: 40,
|
|
text: 'Decorative\nImage',
|
|
fontSize: 12,
|
|
fontFamily: 'Arial',
|
|
fill: '#666',
|
|
align: 'center',
|
|
width: 80
|
|
});
|
|
decorativeImageGroup.add(placeholderText);
|
|
|
|
const placeholderIcon = new Konva.Text({
|
|
x: 35,
|
|
y: 15,
|
|
text: '🖼️',
|
|
fontSize: 24,
|
|
width: 30,
|
|
align: 'center'
|
|
});
|
|
decorativeImageGroup.add(placeholderIcon);
|
|
}
|
|
|
|
decorativeImageGroup.width(100);
|
|
decorativeImageGroup.height(100);
|
|
}
|
|
|
|
layer.draw();
|
|
stage.draw();
|
|
}
|
|
});
|
|
}, 300); // Increased delay to ensure stage is fully loaded and all elements are ready
|
|
|
|
// Re-setup selections for all elements (skip background, grid lines, and page border)
|
|
layer.children.forEach(child => {
|
|
if (child !== background &&
|
|
child.className !== 'Transformer' &&
|
|
!(child.className === 'Line' && child.attrs.name === 'grid-line') &&
|
|
!(child.className === 'Rect' && child.attrs.name === 'page-border')) {
|
|
// Skip logo images as they're handled above
|
|
if (child.className === 'Image' && child.attrs.name === 'logo') {
|
|
return;
|
|
}
|
|
setupSelection(child);
|
|
// If it's a table Group, also setup selection on children
|
|
if (child.className === 'Group' && (child.attrs.name === 'items-table' || child.attrs.name === 'expenses-table')) {
|
|
child.children.forEach(grandChild => {
|
|
setupSelection(grandChild);
|
|
});
|
|
}
|
|
// If it's a decorative-image Group, also setup selection on children
|
|
if (child.className === 'Group' && child.attrs.name === 'decorative-image') {
|
|
child.children.forEach(grandChild => {
|
|
setupSelection(grandChild);
|
|
});
|
|
}
|
|
elements.push({ type: child.attrs.name || child.className, node: child });
|
|
}
|
|
});
|
|
|
|
layer.draw();
|
|
|
|
// After setupSelection, try to restore decorative images again if they weren't found earlier
|
|
// This is a fallback in case the first attempt ran too early
|
|
setTimeout(() => {
|
|
const decorativeImageGroups = layer.find('[name="decorative-image"]');
|
|
if (decorativeImageGroups.length > 0) {
|
|
console.log('🔄 Fallback: Found', decorativeImageGroups.length, 'decorative image group(s) after setupSelection');
|
|
decorativeImageGroups.forEach(decorativeImageGroup => {
|
|
const existingImage = decorativeImageGroup.findOne('Image');
|
|
|
|
// Try multiple methods to get imageUrl
|
|
let imageUrl = '';
|
|
try {
|
|
imageUrl = decorativeImageGroup.getAttr('imageUrl') || '';
|
|
} catch(e) {
|
|
console.warn('Fallback: Could not get imageUrl via getAttr:', e);
|
|
}
|
|
if (!imageUrl) {
|
|
imageUrl = decorativeImageGroup.attrs.imageUrl || '';
|
|
}
|
|
|
|
// Also check the saved JSON directly if we still don't have it
|
|
if (!imageUrl && savedJson && savedJson.children) {
|
|
try {
|
|
function findImageUrlInJson(node, targetName) {
|
|
if (node.attrs && node.attrs.name === targetName) {
|
|
return node.attrs.imageUrl || '';
|
|
}
|
|
if (node.children) {
|
|
for (let child of node.children) {
|
|
const url = findImageUrlInJson(child, targetName);
|
|
if (url) return url;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
const jsonImageUrl = findImageUrlInJson(savedJson, 'decorative-image');
|
|
if (jsonImageUrl) {
|
|
imageUrl = jsonImageUrl;
|
|
decorativeImageGroup.setAttr('imageUrl', imageUrl);
|
|
decorativeImageGroup.attrs.imageUrl = imageUrl;
|
|
console.log('🔄 Fallback: Found imageUrl in saved JSON:', imageUrl);
|
|
}
|
|
} catch(e) {
|
|
console.warn('Fallback: Could not search JSON for imageUrl:', e);
|
|
}
|
|
}
|
|
|
|
// Only restore if we have an imageUrl but no visible image
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
const hasValidImage = existingImage && existingImage.image();
|
|
if (!hasValidImage) {
|
|
console.log('🔄 Fallback: Restoring image for group with URL:', imageUrl);
|
|
|
|
// Remove existing image node if it doesn't have valid data
|
|
if (existingImage && !existingImage.image()) {
|
|
existingImage.destroy();
|
|
}
|
|
|
|
// Remove placeholder elements
|
|
const rect = decorativeImageGroup.findOne('Rect');
|
|
const allTexts = decorativeImageGroup.find('Text');
|
|
allTexts.forEach(textNode => {
|
|
const text = textNode.text();
|
|
if (text.includes('Decorative') || text.includes('🖼️')) {
|
|
textNode.destroy();
|
|
}
|
|
});
|
|
if (rect) rect.destroy();
|
|
|
|
Konva.Image.fromURL(imageUrl, function(konvaImage) {
|
|
const maxSize = 200;
|
|
let width = konvaImage.width() || 100;
|
|
let height = konvaImage.height() || 100;
|
|
const aspectRatio = width / height;
|
|
|
|
if (width > maxSize || height > maxSize) {
|
|
if (width > height) {
|
|
width = maxSize;
|
|
height = maxSize / aspectRatio;
|
|
} else {
|
|
height = maxSize;
|
|
width = maxSize * aspectRatio;
|
|
}
|
|
}
|
|
|
|
konvaImage.setAttrs({
|
|
x: 0,
|
|
y: 0,
|
|
width: width,
|
|
height: height,
|
|
opacity: 1.0,
|
|
visible: true,
|
|
listening: true
|
|
});
|
|
|
|
decorativeImageGroup.width(width);
|
|
decorativeImageGroup.height(height);
|
|
decorativeImageGroup.visible(true);
|
|
decorativeImageGroup.add(konvaImage);
|
|
layer.draw();
|
|
stage.draw();
|
|
console.log('✅ Fallback: Decorative image restored successfully');
|
|
}, function(error) {
|
|
console.error('❌ Fallback: Failed to load image:', error);
|
|
// Restore placeholder on error
|
|
const placeholderRect = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: 100,
|
|
height: 100,
|
|
fill: '#f0f0f0',
|
|
stroke: '#999',
|
|
strokeWidth: 2,
|
|
dash: [5, 5]
|
|
});
|
|
decorativeImageGroup.add(placeholderRect);
|
|
decorativeImageGroup.visible(true);
|
|
layer.draw();
|
|
stage.draw();
|
|
});
|
|
} else {
|
|
console.log('🔄 Fallback: Image already has valid image data, skipping restoration');
|
|
}
|
|
} else {
|
|
console.log('🔄 Fallback: No imageUrl found for decorative image group');
|
|
}
|
|
});
|
|
}
|
|
}, 500);
|
|
|
|
// Refit canvas after loading saved design
|
|
setTimeout(() => {
|
|
fitCanvasToContainer();
|
|
}, 150);
|
|
|
|
console.log('✅ Saved design loaded successfully for size:', currentSize);
|
|
} catch (error) {
|
|
console.error('Failed to load saved design:', error);
|
|
console.log('Loading default layout instead...');
|
|
loadDefaultLayout();
|
|
}
|
|
} else {
|
|
console.log('No saved design found, loading default layout...');
|
|
loadDefaultLayout();
|
|
}
|
|
}, 100);
|
|
|
|
function loadDefaultLayout() {
|
|
console.log('Loading comprehensive default layout...');
|
|
|
|
// ========== HEADER SECTION ==========
|
|
// Logo (top left)
|
|
if (LOGO_URL) {
|
|
addElement('logo', 40, 30);
|
|
}
|
|
|
|
// Company Name and Address (top left)
|
|
addElement('company-name', 40, 95);
|
|
addElement('company-address', 40, 125);
|
|
addElement('company-phone', 40, 155);
|
|
addElement('company-email', 40, 175);
|
|
|
|
// INVOICE Heading (center-right)
|
|
addElement('heading', 320, 35);
|
|
|
|
// Invoice Details Box (top right)
|
|
addElement('rectangle', 380, 85, null); // Background box for invoice details
|
|
const rect = layer.children[layer.children.length - 1];
|
|
rect.width(175);
|
|
rect.height(110);
|
|
rect.fill('#f8f9fa');
|
|
rect.stroke('#dee2e6');
|
|
rect.strokeWidth(1);
|
|
|
|
addElement('invoice-number', 395, 95);
|
|
addElement('invoice-date', 395, 120);
|
|
addElement('due-date', 395, 145);
|
|
addElement('invoice-status', 395, 170);
|
|
|
|
// ========== CLIENT & PROJECT SECTION ==========
|
|
// Bill To Section (left)
|
|
const billToLabel = new Konva.Text({
|
|
x: 40,
|
|
y: 230,
|
|
text: 'BILL TO:',
|
|
fontSize: 11,
|
|
fontStyle: 'bold',
|
|
fill: '#667eea',
|
|
fontFamily: 'Arial',
|
|
draggable: true,
|
|
name: 'section-label'
|
|
});
|
|
layer.add(billToLabel);
|
|
setupSelection(billToLabel);
|
|
elements.push({ type: 'label', node: billToLabel });
|
|
|
|
addElement('client-name', 40, 250);
|
|
addElement('client-address', 40, 275);
|
|
addElement('client-email', 40, 305);
|
|
|
|
// Project Info (right)
|
|
const projectLabel = new Konva.Text({
|
|
x: 320,
|
|
y: 230,
|
|
text: 'PROJECT:',
|
|
fontSize: 11,
|
|
fontStyle: 'bold',
|
|
fill: '#667eea',
|
|
fontFamily: 'Arial',
|
|
draggable: true,
|
|
name: 'section-label'
|
|
});
|
|
layer.add(projectLabel);
|
|
setupSelection(projectLabel);
|
|
elements.push({ type: 'label', node: projectLabel });
|
|
|
|
addElement('project-name', 320, 250);
|
|
addElement('currency', 320, 275);
|
|
|
|
// ========== ITEMS TABLE ==========
|
|
addElement('items-table', 40, 350);
|
|
|
|
// ========== TOTALS SECTION ==========
|
|
// Totals Box (right side) - moved down to give table more space
|
|
addElement('rectangle', 380, 500, null);
|
|
const totalsBox = layer.children[layer.children.length - 1];
|
|
totalsBox.width(175);
|
|
totalsBox.height(90);
|
|
totalsBox.fill('#f8f9fa');
|
|
totalsBox.stroke('#dee2e6');
|
|
totalsBox.strokeWidth(1);
|
|
|
|
addElement('subtotal', 395, 512);
|
|
addElement('tax', 395, 540);
|
|
|
|
// Total with highlight
|
|
addElement('rectangle', 385, 565, null);
|
|
const totalHighlight = layer.children[layer.children.length - 1];
|
|
totalHighlight.width(165);
|
|
totalHighlight.height(27);
|
|
totalHighlight.fill('#667eea');
|
|
totalHighlight.stroke('#667eea');
|
|
totalHighlight.strokeWidth(1);
|
|
|
|
addElement('totals', 395, 570);
|
|
// Find the totals text element by name
|
|
const totalText = layer.findOne('[name="totals"]');
|
|
if (totalText && totalText.className === 'Text') {
|
|
totalText.fill('white');
|
|
totalText.fontStyle('bold');
|
|
}
|
|
|
|
// ========== PAYMENT SECTION ==========
|
|
const paymentLabel = new Konva.Text({
|
|
x: 40,
|
|
y: 610,
|
|
text: 'PAYMENT INFORMATION:',
|
|
fontSize: 11,
|
|
fontStyle: 'bold',
|
|
fill: '#667eea',
|
|
fontFamily: 'Arial',
|
|
draggable: true,
|
|
name: 'section-label'
|
|
});
|
|
layer.add(paymentLabel);
|
|
setupSelection(paymentLabel);
|
|
elements.push({ type: 'label', node: paymentLabel });
|
|
|
|
addElement('payment-status', 40, 635);
|
|
addElement('amount-paid', 40, 660);
|
|
addElement('outstanding-amount', 40, 685);
|
|
|
|
// Bank Info (right side)
|
|
addElement('bank-info', 320, 635);
|
|
|
|
// ========== NOTES & TERMS ==========
|
|
addElement('notes', 40, 738);
|
|
addElement('terms', 40, 768);
|
|
|
|
// ========== FOOTER ==========
|
|
// Footer text (centered)
|
|
const footerText = new Konva.Text({
|
|
x: 40,
|
|
y: 820,
|
|
text: 'Thank you for your business!',
|
|
fontSize: 10,
|
|
fill: '#6c757d',
|
|
fontFamily: 'Arial',
|
|
width: 515,
|
|
align: 'center',
|
|
draggable: true,
|
|
name: 'footer-text'
|
|
});
|
|
layer.add(footerText);
|
|
setupSelection(footerText);
|
|
elements.push({ type: 'footer', node: footerText });
|
|
|
|
layer.draw();
|
|
console.log('✅ Default layout loaded with ' + layer.children.length + ' elements');
|
|
}
|
|
}
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initializePDFEditor);
|
|
} else {
|
|
initializePDFEditor();
|
|
}
|
|
|
|
// Confirm reset function
|
|
async function confirmResetPdfLayout() {
|
|
const confirmed = await showConfirm(
|
|
'{{ _("Reset to defaults?") }}',
|
|
{
|
|
title: '{{ _("Reset PDF Layout") }}',
|
|
confirmText: '{{ _("Reset") }}',
|
|
cancelText: '{{ _("Cancel") }}',
|
|
variant: 'danger'
|
|
}
|
|
);
|
|
if (confirmed) {
|
|
document.getElementById('form-reset').submit();
|
|
}
|
|
}
|
|
|
|
// Import JSON handler
|
|
document.getElementById('import-json-file')?.addEventListener('change', function(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('json_file', file);
|
|
formData.append('page_size', '{{ page_size }}');
|
|
formData.append('csrf_token', '{{ csrf_token() }}');
|
|
|
|
fetch('{{ url_for("admin.pdf_layout_import_json") }}', {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
.then(response => {
|
|
if (response.ok) {
|
|
window.location.reload();
|
|
} else {
|
|
return response.text().then(text => {
|
|
throw new Error(text);
|
|
});
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Import error:', error);
|
|
alert('{{ _("Error importing template") }}: ' + error.message);
|
|
})
|
|
.finally(() => {
|
|
// Reset file input
|
|
e.target.value = '';
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %}
|