Files
TimeTracker/templates/admin/pdf_layout.html
T
Dries Peeters d9e7e82ab2 Add manual trigger for scheduled reports
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.
2026-01-22 14:31:38 +01:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').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, '&lt;').replace(/>/g, '&gt;');
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, '&lt;').replace(/>/g, '&gt;')}</th>\n`;
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;">${(headerParts[1] || 'Qty').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</th>\n`;
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[2] || 'Unit Price').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</th>\n`;
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[3] || 'Total').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</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, '&lt;').replace(/>/g, '&gt;')}</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, '&lt;').replace(/>/g, '&gt;')}</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, '&lt;').replace(/>/g, '&gt;')}</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, '&lt;').replace(/>/g, '&gt;')}</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, '&lt;').replace(/>/g, '&gt;');
// 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 %}