Files
TimeTracker/app/templates/admin/quote_pdf_layout.html
T
Dries Peeters d39c5a2f37 fix(pdf-layout): decorative image persistence and PDF preview (Issue #432)
Decorative images now survive save/load and no longer cause a black PDF preview:

- Sync imageUrl onto groups before generateCode() so template_json has
  correct source (invoice and quote layout editors).
- Inject name/imageUrl into design_json with position-based matching so
  reordering does not swap or drop URLs.
- Restore name and imageUrl from saved JSON onto canvas on load
  (synchronous) so Konva custom attrs are not required.
- Omit decorative image elements with empty source from template_json;
  placeholders stay visible in the editor but are not sent to ReportLab.
- ReportLab: explicitly skip decorative images with empty source; validate
  base64 data URI payload and decode in try/except to avoid bad PDF output.

Documentation: PDF_LAYOUT_CUSTOMIZATION.md and PDF_EDITOR_ENHANCED_FEATURES.md
updated with decorative image description, state persistence details, and
troubleshooting. CHANGELOG.md updated under [Unreleased] Fixed.
2026-02-16 07:37:59 +01:00

7173 lines
306 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('PDF Quote 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: #374151;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
/* 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;
}
.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;
}
/* 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 Quote Designer') }}
</h1>
<p class="text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Drag and drop elements to design your quote 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>
<!-- Help Section -->
<details class="mb-4 border border-gray-200 dark:border-gray-600 rounded-lg" style="font-size: 0.875rem;">
<summary class="px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-t-lg flex items-center gap-2">
<i class="fas fa-question-circle text-gray-500"></i>
<span class="font-medium">{{ _('Help: Quote Items Table & Saving') }}</span>
</summary>
<div class="px-4 py-3 pt-0 text-gray-600 dark:text-gray-400 space-y-2">
<p><strong>{{ _('Quote Items Table:') }}</strong> {{ _('Displays quote line items. Add from Invoice Data in the sidebar.') }}</p>
<p><strong>{{ _('Saving:') }}</strong> {{ _('Click "Save Design" to persist. If the table disappears after save, try Reset to restore defaults, then re-add the Items Table.') }}</p>
<p><strong>{{ _('Preview:') }}</strong> {{ _('Use "Generate Preview" to see how the PDF will look with real quote data.') }}</p>
<p>{{ _('See') }} <code>docs/PDF_LAYOUT_CUSTOMIZATION.md</code> {{ _('for full documentation.') }}</p>
</div>
</details>
<!-- 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.quote_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.quote_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="QUOTE">
<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">{{ _('Quote Data') }}</div>
<div class="element-item" data-type="invoice-number">
<i class="fas fa-hashtag"></i>
<span>{{ _('Quote Number') }}</span>
</div>
<div class="element-item" data-type="invoice-date">
<i class="fas fa-calendar"></i>
<span>{{ _('Quote 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="quote-items-table">
<i class="fas fa-table"></i>
<span>{{ _('Quote Items 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">{{ _('Quote Fields') }}</div>
<div class="variable-item" data-variable="invoice.invoice_number">
<div class="variable-name">{{ '{{' }} invoice.invoice_number {{ '}}' }}</div>
<div class="variable-desc">{{ _('Quote number') }}</div>
</div>
<div class="variable-item" data-variable="invoice.status">
<div class="variable-name">{{ '{{' }} invoice.status {{ '}}' }}</div>
<div class="variable-desc">{{ _('Quote 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">{{ _('Quote 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="quote.status">
<div class="variable-name">{{ '{{' }} quote.status {{ '}}' }}</div>
<div class="variable-desc">{{ _('Quote status') }}</div>
</div>
<div class="variable-item" data-variable="format_date(quote.accepted_at)">
<div class="variable-name">{{ '{{' }} format_date(quote.accepted_at) {{ '}}' }}</div>
<div class="variable-desc">{{ _('Quote accepted date') }}</div>
</div>
<div class="variable-item" data-variable="quote.visible_to_client">
<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">{{ _('Quote 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">{{ _('Quote last update date') }}</div>
</div>
</div>
<div class="element-group">
<div class="element-group-title">{{ _('Quote 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>
<!-- 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" action="{{ url_for('admin.quote_pdf_layout') }}" 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="quote_pdf_template_html"></textarea>
<textarea id="save-css" name="quote_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.quote_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 }
};
// Issue #504: Infer quote items table from group structure (supports localized headers)
function inferQuoteTableNameFromGroup(child, excludeNode) {
if (!child || child === excludeNode || (child.className !== 'Group' && (!child.constructor || child.constructor.name !== 'Group'))) return null;
const children = child.getChildren ? child.getChildren() : (child.children || []);
if (children.length < 3) return null;
const textElements = children.filter(function(c) { return c.className === 'Text'; });
const hasLine = children.some(function(c) { return c.className === 'Line'; });
if (textElements.length < 2 || !hasLine) return null;
const headerText = (textElements[0].attrs && textElements[0].attrs.text) ? textElements[0].attrs.text : '';
const h = headerText.toLowerCase();
const hasPipeColumns = headerText.indexOf('|') !== -1 && headerText.split('|').length >= 3;
const itemsKeywords = ['description', 'qty', 'quantity', 'unit price', 'total', 'beschreibung', 'menge', 'preis', 'betrag', 'gesamt', 'quantité', 'prix', 'descrizione', 'quantità', 'prezzo', 'totale', 'omschrijving', 'aantal', 'eenheidsprijs', 'totaal'];
const hasItemsKw = itemsKeywords.some(function(kw) { return h.indexOf(kw) !== -1; });
if (hasItemsKw || (hasPipeColumns && (h.indexOf('price') !== -1 || h.indexOf('preis') !== -1 || h.indexOf('prix') !== -1 || h.indexOf('amount') !== -1 || h.indexOf('betrag') !== -1 || h.indexOf('total') !== -1 || h.indexOf('gesamt') !== -1))) {
return 'quote-items-table';
}
if (hasPipeColumns && textElements.length >= 2 && hasLine) return 'quote-items-table';
return null;
}
function ensureQuoteTableGroupNames(layerRef, bgRef) {
if (!layerRef || !layerRef.children) return;
const bg = bgRef || (typeof background !== 'undefined' ? background : null);
layerRef.children.forEach(function(child) {
if (!child || child === bg || child.className !== 'Group') return;
const currentName = child.getAttr ? child.getAttr('name') : (child.attrs && child.attrs.name);
if (currentName === 'quote-items-table') return;
const inferred = inferQuoteTableNameFromGroup(child, bg);
if (inferred) {
if (child.setAttr) child.setAttr('name', inferred);
if (child.attrs) child.attrs.name = inferred;
if (child.name) child.name(inferred);
console.log('[QUOTE_PDF_EDITOR] Ensured quote table group name before save:', inferred);
}
});
}
// Overflow and overlap detection functions (same as invoice template)
function getElementBoundingBox(element) {
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;
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) {
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) {
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;
const intersects = !(
elementBox.right < otherBox.x ||
elementBox.x > otherBox.right ||
elementBox.bottom < otherBox.y ||
elementBox.y > otherBox.bottom
);
if (intersects) {
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
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: 'QUOTE', 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 },
// Quote data
'quote-number': { text: 'Quote #: {{ quote.quote_number }}', fontSize: 14, fontStyle: 'bold' },
'quote-date': { text: 'Date: {{ format_date(quote.created_at) }}', fontSize: 12 },
'valid-until': { text: 'Valid Until: {{ format_date(quote.valid_until) }}', fontSize: 12 },
'quote-status': { text: 'Status: {{ quote.status|upper }}', fontSize: 12 },
'client-info': { text: 'Quote For:\\n{{ quote.client.name }}\\n{{ quote.client.address }}', fontSize: 12 },
'client-name': { text: '{{ quote.client.name }}', fontSize: 14, fontStyle: 'bold' },
'client-address': { text: '{{ quote.client.address }}', fontSize: 12 },
'subtotal': { text: 'Subtotal: {{ format_money(quote.subtotal) }}', fontSize: 14 },
'tax': { text: 'Tax ({{ quote.tax_rate }}%): {{ format_money(quote.tax_amount) }}', fontSize: 14 },
'totals': { text: 'Total: {{ format_money(quote.total_amount) }}', fontSize: 16, fontStyle: 'bold' },
'notes': { text: 'Notes: {{ quote.notes }}', fontSize: 11 },
'terms': { text: 'Terms: {{ quote.terms }}', fontSize: 10 },
// Quote specific
'quote-title': { text: 'Title: {{ quote.title }}', fontSize: 12 },
'quote-description': { text: 'Description: {{ quote.description }}', fontSize: 11 },
'accepted-date': { text: 'Accepted Date: {{ format_date(quote.accepted_at) if quote.accepted_at else "N/A" }}', fontSize: 12 },
'sent-date': { text: 'Sent Date: {{ format_date(quote.sent_at) if quote.sent_at else "N/A" }}', fontSize: 12 },
'client-email': { text: 'Email: {{ quote.client.email or "" }}', fontSize: 12 },
'client-phone': { text: 'Phone: {{ quote.client.phone or "" }}', fontSize: 12 },
// Advanced
'qr-code': { text: '[QR Code: {{ quote.quote_number }}]', fontSize: 10 },
'barcode': { text: '{{ quote.quote_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: {{ quote.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 quote.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 === '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;
}
if (type === 'quote-items-table') {
addTable(x, y);
return;
}
// Removed expenses-table for quotes
if (false && type === 'expenses-table') {
addExpensesTable(x, y);
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);
layer.draw();
}
function addTable(x, y) {
const tableGroup = new Konva.Group({
x: x,
y: y,
draggable: true,
name: 'quote-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 quote.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: 'quote-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);
layer.draw();
}
function addExpensesTable(x, y) {
const tableGroup = new Konva.Group({
x: x,
y: y,
draggable: true,
name: 'quote-items-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);
// Removed expenses table for quotes
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 === 'quote-items-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
});
}
});
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());
});
}
// 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,
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') {
transformer.on('transformend', function() {
const image = node;
const scaleX = image.scaleX();
const scaleY = image.scaleY();
// Update width and height based on scale
const newWidth = image.width() * scaleX;
const newHeight = image.height() * scaleY;
// Reset scale to 1 and apply the new dimensions
image.width(newWidth);
image.height(newHeight);
image.scaleX(1);
image.scaleY(1);
// Update properties panel if visible
if (selectedElement === image) {
updatePropertiesPanel(image);
}
layer.draw();
});
}
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);
}
// Remove any warning indicator dots that might exist
layer.find('.warning-indicator').forEach(indicator => {
indicator.destroy();
});
layer.draw();
// Update properties panel
updatePropertiesPanel(node);
}
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' || node.parent.attrs.name === 'quote-items-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' || attrs.name === 'quote-items-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 "quote-items-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('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 || '';
// 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
opacity: 1.0, // Don't override image's built-in alpha channel
listening: true
});
// Update group size
element.width(width);
element.height(height);
// Add image
element.add(konvaImage);
layer.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);
layer.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();
const elementName = selectedElement && selectedElement.attrs ? selectedElement.attrs.name : '';
// 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 || !elementName || !elementName.includes('decorative-image')) {
// 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');
if (currentInput) {
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;
// Only proceed if selected element is a decorative image
const elementName = selectedElement && selectedElement.attrs ? selectedElement.attrs.name : '';
// Check if name includes 'decorative-image' (it might be 'decorative-image element-overlap' or similar)
if (!selectedElement || !elementName || !elementName.includes('decorative-image')) {
// 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() }}');
btnUploadDecorativeImage.disabled = true;
btnUploadDecorativeImage.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(() => {
btnUploadDecorativeImage.disabled = false;
btnUploadDecorativeImage.innerHTML = '<i class="fas fa-upload mr-2"></i>{{ _("Change Image") }}';
decorativeImageUpload.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) {
// If iframe not loaded yet, set mode and wait
previewState.fitMode = 'width';
updateFitButtons();
return;
}
const wrapper = previewFrameWrapper;
if (!wrapper.parentElement) return;
// Get available width accounting for padding (1.5rem = 24px on each side = 48px total)
const parentPadding = 48; // 1.5rem * 2 = 48px
const availableWidth = wrapper.parentElement.clientWidth - parentPadding;
const frameDoc = previewFrame.contentDocument;
const container = frameDoc.querySelector('.preview-container');
if (container) {
// Get the actual content width from the container (before any zoom)
// We need to get the base width, not the scaled width
const containerWidth = container.offsetWidth || container.scrollWidth || container.clientWidth;
// If container has a transform scale, we need to account for it
const currentTransform = container.style.transform;
const currentZoom = previewState.zoomLevel / 100;
const baseWidth = containerWidth / (currentZoom || 1);
if (baseWidth > 0 && availableWidth > 0) {
// Calculate zoom to fit width with a small margin for better appearance
const margin = 20; // Small margin
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);
// Fallback
setZoom(100);
previewState.fitMode = 'width';
updateFitButtons();
}
}
function fitToHeight() {
try {
if (!previewFrame || !previewFrameWrapper) {
previewState.fitMode = 'height';
updateFitButtons();
return;
}
if (!previewFrame.contentDocument) {
// If iframe not loaded yet, set mode and wait
previewState.fitMode = 'height';
updateFitButtons();
return;
}
const wrapper = previewFrameWrapper;
if (!wrapper.parentElement) return;
// Get available height accounting for padding
const parentPadding = 48; // 1.5rem * 2 = 48px
const availableHeight = wrapper.parentElement.clientHeight - parentPadding;
const frameDoc = previewFrame.contentDocument;
const container = frameDoc.querySelector('.preview-container');
if (container) {
// Get base height before any zoom
const containerHeight = container.offsetHeight || container.scrollHeight || container.clientHeight;
const currentZoom = previewState.zoomLevel / 100;
const baseHeight = containerHeight / (currentZoom || 1);
if (baseHeight > 0 && availableHeight > 0) {
// Calculate zoom to fit height with a small margin
const margin = 20; // Small margin
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);
// Fallback
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 => {
btn.classList.remove(activeClass);
});
if (previewState.fitMode === 'width') {
previewBtnFitWidth.classList.add(activeClass);
} else if (previewState.fitMode === 'height') {
previewBtnFitHeight.classList.add(activeClass);
} else if (previewState.fitMode === 'actual') {
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';
// Simulate progress
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';
}
// Reset state
setZoom(100);
updatePageSizeBadge(previewState.pageSize);
// Update preview page size selector to match
if (previewPageSizeSelect) {
previewPageSizeSelect.value = previewState.pageSize;
}
// Focus management for accessibility
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;
}
// Reset state
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 {
// Fallback: try on modal itself
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;
// Close on Escape
if (e.key === 'Escape') {
if (previewState.isFullscreen) {
toggleFullscreen();
} else {
closePreviewModal();
}
return;
}
// Zoom shortcuts (Ctrl/Cmd + Plus/Minus/0)
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();
}
}
// Fullscreen (F11)
if (e.key === 'F11') {
e.preventDefault();
toggleFullscreen();
}
});
// Zoom controls (with safety checks)
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 with debounce
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 - syncs with main selector and loads correct template
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?") }}')) {
// Update main page size selector to match (for consistency)
const mainPageSizeSelector = document.getElementById('page-size-selector');
if (mainPageSizeSelector) {
mainPageSizeSelector.value = newSize;
}
// Load preview with new size (this will load the template for that size)
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);
// Fallback: simple preview functionality
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)
function pxToPt(px) {
return Math.round(px || 0);
}
// Build ReportLab template JSON structure
const templateJson = {
page: {
size: currentSize,
margin: {
top: 20,
right: 20,
bottom: 20,
left: 20
}
},
elements: [],
styles: {
default: {
font: "Helvetica",
size: 10,
color: "#000000"
}
}
};
// Legacy HTML/CSS generation for preview (backward compatibility)
let bodyContent = '';
if (!layer || !layer.children) {
console.error('Layer or children not found');
return { html: '<div class="quote-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,
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
templateJson.elements.push({
type: 'circle',
x: pxToPt(x),
y: pxToPt(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 (quotes use quote-items-table)
let isItemsTable = child.attrs.name === 'items-table' || child.attrs.name === 'quote-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 table-like structure but missing name (Issue #504 - i18n-aware)
if (!isItemsTable && !isExpensesTable && !isDecorativeImage && child.children && child.children.length >= 3) {
const inferred = inferQuoteTableNameFromGroup(child, null);
if (inferred === 'quote-items-table') {
isItemsTable = true;
console.log('⚠ Quote items table detected by structure (missing name) - using inference');
}
}
// 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);
// Issue #432: Only add to template JSON when source is non-empty
if (imageUrl && imageUrl.trim() !== '') {
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl,
opacity: opacity,
decorative: true
});
}
// Legacy HTML for preview
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`;
}
return;
}
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
let headerParts = ['Description', 'Qty', 'Unit Price', 'Total'];
if (headerText && headerText.includes('|')) {
headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
while (headerParts.length < 4) {
headerParts.push(['Description', 'Qty', 'Unit Price', 'Total'][headerParts.length]);
}
}
// Add to ReportLab template JSON - quote items table
{% raw %}
const itemsData = '{{ quote.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 quote.items %}\n`;
bodyContent += ` {% for item in quote.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 (for quotes, this might not be used)
{% raw %}
const expensesDataQuote = '{{ quote.expenses }}';
const expensesRowTemplateQuote = {
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: expensesDataQuote,
row_template: expensesRowTemplateQuote,
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 quote.expenses %}\n`;
bodyContent += ` {% for expense in quote.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);
// Issue #432: Only add to template JSON when source is non-empty
if (imageUrl && imageUrl.trim() !== '') {
templateJson.elements.push({
type: 'image',
x: pxToPt(x),
y: pxToPt(y),
width: pxToPt(actualWidth),
height: pxToPt(actualHeight),
source: imageUrl,
opacity: opacity,
decorative: true
});
}
// Legacy HTML for preview
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 generatedHtml = `<div class="quote-wrapper">
${bodyContent}</div>`;
const generatedCss = `@page {
size: ${currentSize};
margin: 0;
}
html, body {
margin: 0;
padding: 0;
width: ${widthPx}px;
height: ${heightPx}px;
font-family: Arial, sans-serif;
overflow: hidden;
}
.invoice-wrapper, .quote-wrapper {
position: relative;
width: ${widthPx}px !important;
height: ${heightPx}px !important;
max-width: ${widthPx}px !important;
max-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: generatedHtml, // Legacy HTML for preview
css: generatedCss, // 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="quote-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; } .quote-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 quote.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.quote_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();
// Issue #504: Ensure quote table groups have correct name before generateCode
if (typeof ensureQuoteTableGroupNames === 'function' && layer) {
ensureQuoteTableGroupNames(layer, background);
layer.draw();
}
// Issue #432: Sync imageUrl onto decorative-image groups BEFORE generateCode() so template_json has correct source
layer.find('[name="decorative-image"]').forEach((decorativeImageGroup, idx) => {
try {
let imageUrl = decorativeImageGroup.getAttr('imageUrl');
if (!imageUrl) imageUrl = decorativeImageGroup.attrs.imageUrl;
if (!imageUrl) {
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
}
}
if (imageUrl) {
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
}
} catch(e) {
console.warn('Could not get/set imageUrl for decorative image:', e);
}
});
let allDecorativeGroups = layer.find('[name="decorative-image"]');
layer.find('Group').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 {
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';
}
const imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl;
if (imageUrl) {
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
}
} catch(e) {}
});
const decorativeImageUrlMap = new Map();
let decorativeImageGroups = layer.find('[name="decorative-image"]');
layer.find('Group').forEach(group => {
const name = group.name() || group.getAttr('name') || '';
if (name.includes('decorative-image') && !decorativeImageGroups.includes(group)) {
decorativeImageGroups = decorativeImageGroups.concat([group]);
}
});
decorativeImageGroups.forEach((decorativeImageGroup, index) => {
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';
}
let imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl || '';
if (!imageUrl) {
const imageNode = decorativeImageGroup.findOne('Image');
if (imageNode) {
const imgAttrs = imageNode.attrs || {};
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
}
}
const x = decorativeImageGroup.x() || 0;
const y = decorativeImageGroup.y() || 0;
decorativeImageUrlMap.set(`${x}_${y}`, imageUrl || '');
decorativeImageUrlMap.set(`${x}_${y}_${index}`, imageUrl || '');
decorativeImageUrlMap.set(`index_${index}`, imageUrl || '');
});
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('Has Items Table:', html.includes('<!-- Items Table Start -->'));
console.log('Has Jinja2 loop:', html.includes('{' + '% for item in quote.items'));
console.log('Number of elements:', layer.children.length);
console.log('Page size:', CURRENT_PAGE_SIZE);
// Serialize the stage
const stageJson = stage.toJSON();
// Explicitly inject name and imageUrl into serialized JSON (Issue #432)
const layerJson = stageJson.children && stageJson.children[0];
if (layer && layerJson && layerJson.children && layer.children) {
for (let i = 0; i < layer.children.length; i++) {
const layerChild = layer.children[i];
const jsonChild = layerJson.children[i];
if (!jsonChild) continue;
const name = layerChild.getAttr ? layerChild.getAttr('name') : (layerChild.attrs && layerChild.attrs.name);
if (name && (name === 'decorative-image' || (typeof name === 'string' && name.includes('decorative-image')))) {
if (!jsonChild.attrs) jsonChild.attrs = {};
jsonChild.attrs.name = 'decorative-image';
jsonChild.attrs.imageUrl = (layerChild.getAttr ? layerChild.getAttr('imageUrl') : (layerChild.attrs && layerChild.attrs.imageUrl)) || '';
} else if (name === 'quote-items-table') {
if (!jsonChild.attrs) jsonChild.attrs = {};
jsonChild.attrs.name = 'quote-items-table';
}
}
}
let decorativeImageIndex = 0;
function ensureImageUrlInJson(node, parentKey = '') {
const nodeName = node.attrs && node.attrs.name ? node.attrs.name : '';
if (nodeName && nodeName.includes('decorative-image')) {
const x = node.attrs.x || 0;
const y = node.attrs.y || 0;
let imageUrl = decorativeImageUrlMap.get(`${x}_${y}`) ||
decorativeImageUrlMap.get(`${x}_${y}_${decorativeImageIndex}`) ||
decorativeImageUrlMap.get(`index_${decorativeImageIndex}`) || '';
if (!node.attrs) node.attrs = {};
node.attrs.name = 'decorative-image';
node.attrs.imageUrl = (typeof imageUrl === 'string' ? imageUrl : '');
decorativeImageIndex++;
}
if (node.children) {
node.children.forEach((child, idx) => ensureImageUrlInJson(child, `${parentKey}.children[${idx}]`));
}
}
if (stageJson.children) {
stageJson.children.forEach((child, idx) => ensureImageUrlInJson(child, `children[${idx}]`));
}
document.getElementById('save-html').value = html;
document.getElementById('save-css').value = css;
document.getElementById('save-design-json').value = JSON.stringify(stageJson);
if (!json || !json.trim()) {
console.error('No JSON generated from template!');
alert('Error: Could not generate template JSON. Please try again.');
return;
}
try {
JSON.parse(json);
} catch (e) {
console.error('Invalid JSON generated:', e);
alert('Error: Generated template JSON is invalid. Please try again.');
return;
}
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];
// Remove any warning indicator dots that might have been loaded
layer.find('.warning-indicator').forEach(indicator => {
indicator.destroy();
});
// 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.attrs.name === 'quote-items-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];
// Issue #432: Synchronously restore name and imageUrl from saved JSON onto live nodes
const layerJson = savedJson.children && savedJson.children[0];
if (layerJson && layerJson.children && layer.children) {
for (let i = 0; i < layer.children.length; i++) {
const liveChild = layer.children[i];
const jsonChild = layerJson.children[i];
if (!liveChild || !jsonChild || liveChild.className !== 'Group') continue;
const savedName = (jsonChild.attrs && jsonChild.attrs.name) ? jsonChild.attrs.name : '';
if (savedName && savedName.includes('decorative-image')) {
const savedImageUrl = (jsonChild.attrs && jsonChild.attrs.imageUrl) ? jsonChild.attrs.imageUrl : '';
if (liveChild.setAttr) {
liveChild.setAttr('name', 'decorative-image');
liveChild.setAttr('imageUrl', savedImageUrl || '');
}
if (liveChild.attrs) {
liveChild.attrs.name = 'decorative-image';
liveChild.attrs.imageUrl = savedImageUrl || '';
}
if (liveChild.name) liveChild.name('decorative-image');
}
}
}
// 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();
}
// Defensive fix (Issue #504): Restore quote-items-table group name from structure when missing (i18n-aware)
layer.children.forEach((child) => {
if (!child || child === background || child.className !== 'Group') return;
const currentName = child.getAttr ? child.getAttr('name') : (child.attrs && child.attrs.name);
if (currentName === 'quote-items-table') return;
const inferredName = inferQuoteTableNameFromGroup(child, background);
if (inferredName) {
if (child.setAttr) child.setAttr('name', inferredName);
if (child.attrs) child.attrs.name = inferredName;
if (child.name) child.name(inferredName);
console.log('[QUOTE] [LOAD] Restored quote table group name from structure:', inferredName);
}
});
// 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();
// Remove any warning indicator dots that might have been loaded
layer.find('.warning-indicator').forEach(indicator => {
indicator.destroy();
});
// 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');
}
setTimeout(() => {
let decorativeImageGroups = layer.find('[name="decorative-image"]');
console.log('Found', decorativeImageGroups.length, 'decorative image group(s) via layer.find');
// 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;
if (nameViaGetAttr === 'decorative-image' ||
nameViaName === 'decorative-image' ||
nameViaAttrs === 'decorative-image') {
console.log(` ✅ Found decorative-image group at index ${idx}`);
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 = '') {
if (node.attrs && node.attrs.name === 'decorative-image') {
console.log(` ✅ Found decorative-image in JSON at path: ${path}, 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('Got imageUrl via getAttr:', imageUrl);
} catch(e) {
console.warn('Could not get imageUrl via getAttr:', e);
}
if (!imageUrl) {
imageUrl = decorativeImageGroup.attrs.imageUrl || '';
console.log('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
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;
// Also set it in the group for future use
decorativeImageGroup.setAttr('imageUrl', imageUrl);
decorativeImageGroup.attrs.imageUrl = imageUrl;
console.log('Found imageUrl in saved JSON:', imageUrl);
}
} catch(e) {
console.warn('Could not search JSON for imageUrl:', e);
}
}
console.log('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('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('✅ Decorative image restored successfully, dimensions:', width, 'x', height);
console.log('✅ 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);
decorativeImageGroup.visible(true);
layer.draw();
stage.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 and grid lines)
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.attrs.name === 'quote-items-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);
// QUOTE Heading (center-right)
addElement('heading', 320, 35);
// Quote 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);
// ========== QUOTE ITEMS TABLE ==========
addElement('quote-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');
}
// ========== QUOTE INFORMATION SECTION ==========
const quoteInfoLabel = new Konva.Text({
x: 40,
y: 610,
text: 'QUOTE INFORMATION:',
fontSize: 11,
fontStyle: 'bold',
fill: '#667eea',
fontFamily: 'Arial',
draggable: true,
name: 'section-label'
});
layer.add(quoteInfoLabel);
setupSelection(quoteInfoLabel);
elements.push({ type: 'label', node: quoteInfoLabel });
addElement('quote-status', 40, 635);
addElement('valid-until', 40, 660);
addElement('quote-title', 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.quote_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 %}