mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-23 23:00:15 -05:00
32aeb7368d
Match live layer children to JSON by index and explicitly inject name and imageUrl for decorative-image nodes, since Konva toJSON() may not include custom attributes. Co-authored-by: Cursor <cursoragent@cursor.com>
7121 lines
302 KiB
HTML
7121 lines
302 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>
|
|
|
|
<!-- 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 }
|
|
};
|
|
|
|
// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/\\n/g, '\n');
|
|
// Use more rows for table items which might be longer
|
|
const isTableText = node.parent && node.parent.attrs && (node.parent.attrs.name === 'items-table' || node.parent.attrs.name === 'expenses-table');
|
|
const textareaRows = isTableText ? 8 : 3;
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Text Content</div>';
|
|
html += `<textarea id="prop-text" class="property-input" rows="${textareaRows}">${textContent}</textarea>`;
|
|
if (isTableText) {
|
|
html += '<p class="text-xs text-gray-500 mt-1">Double-click table text to edit, or edit here in the properties panel.</p>';
|
|
}
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Font Size</div>';
|
|
html += `<input type="number" id="prop-fontSize" value="${attrs.fontSize || 14}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Font Family</div>';
|
|
html += `<select id="prop-fontFamily" class="property-input">`;
|
|
const fonts = ['Arial', 'Times New Roman', 'Courier New', 'Georgia', 'Verdana', 'Helvetica'];
|
|
fonts.forEach(font => {
|
|
const selected = (attrs.fontFamily === font) ? 'selected' : '';
|
|
html += `<option value="${font}" ${selected}>${font}</option>`;
|
|
});
|
|
html += '</select>';
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Font Style</div>';
|
|
html += `<select id="prop-fontStyle" class="property-input">`;
|
|
html += `<option value="normal" ${attrs.fontStyle === 'normal' ? 'selected' : ''}>Normal</option>`;
|
|
html += `<option value="bold" ${attrs.fontStyle === 'bold' ? 'selected' : ''}>Bold</option>`;
|
|
html += `<option value="italic" ${attrs.fontStyle === 'italic' ? 'selected' : ''}>Italic</option>`;
|
|
html += '</select>';
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Text Color</div>';
|
|
html += `<input type="color" id="prop-fill" value="${rgbToHex(attrs.fill || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Width</div>';
|
|
html += `<input type="number" id="prop-width" value="${attrs.width || 400}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Opacity</div>';
|
|
html += `<input type="range" id="prop-opacity" min="0" max="1" step="0.1" value="${attrs.opacity !== undefined ? attrs.opacity : 1}" class="property-input">`;
|
|
html += `<span id="opacity-value">${(attrs.opacity !== undefined ? attrs.opacity : 1) * 100}%</span>`;
|
|
html += '</div>';
|
|
}
|
|
|
|
// Shape-specific properties
|
|
if (className === 'Rect' || className === 'Circle') {
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Fill Color</div>';
|
|
html += `<input type="color" id="prop-fill" value="${rgbToHex(attrs.fill || '#ffffff')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Stroke Color</div>';
|
|
html += `<input type="color" id="prop-stroke" value="${rgbToHex(attrs.stroke || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Stroke Width</div>';
|
|
html += `<input type="number" id="prop-strokeWidth" value="${attrs.strokeWidth || 1}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
if (className === 'Rect') {
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Width</div>';
|
|
html += `<input type="number" id="prop-width" value="${attrs.width || 100}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Height</div>';
|
|
html += `<input type="number" id="prop-height" value="${attrs.height || 100}" class="property-input">`;
|
|
html += '</div>';
|
|
}
|
|
|
|
if (className === 'Circle') {
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Radius</div>';
|
|
html += `<input type="number" id="prop-radius" value="${attrs.radius || 50}" class="property-input">`;
|
|
html += '</div>';
|
|
}
|
|
}
|
|
|
|
// Line-specific properties
|
|
if (className === 'Line') {
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Stroke Color</div>';
|
|
html += `<input type="color" id="prop-stroke" value="${rgbToHex(attrs.stroke || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Stroke Width</div>';
|
|
html += `<input type="number" id="prop-strokeWidth" value="${attrs.strokeWidth || 1}" class="property-input">`;
|
|
html += '</div>';
|
|
}
|
|
|
|
// Decorative image properties
|
|
// Use includes() to handle cases where name might be modified (e.g., 'decorative-image element-overlap')
|
|
if (attrs.name && attrs.name.includes('decorative-image')) {
|
|
// Use getAttr to ensure we get the imageUrl
|
|
const imageUrl = node.getAttr('imageUrl') || attrs.imageUrl || '';
|
|
console.log('Properties panel - decorative image URL:', imageUrl);
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Decorative Image</div>';
|
|
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
html += '<div style="margin-bottom: 1rem;">';
|
|
html += `<img src="${imageUrl}" alt="Decorative image" style="max-width: 100%; max-height: 150px; border: 1px solid #ddd; border-radius: 4px;">`;
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<label for="decorative-image-upload" class="property-label">Upload Image</label>';
|
|
html += '<input type="file" id="decorative-image-upload" accept="image/png,image/jpeg,image/jpg,image/gif,image/webp" style="display: none;">';
|
|
html += `<button type="button" id="btn-upload-decorative-image" class="property-input" style="background: #667eea; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; width: 100%;">`;
|
|
html += '<i class="fas fa-upload mr-2"></i>';
|
|
html += imageUrl ? '{{ _("Change Image") }}' : '{{ _("Upload Image") }}';
|
|
html += '</button>';
|
|
html += '</div>';
|
|
|
|
if (imageUrl) {
|
|
html += '<div class="property-group">';
|
|
html += '<button type="button" id="btn-remove-decorative-image" class="property-input" style="background: #ef4444; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; width: 100%;">';
|
|
html += '<i class="fas fa-trash mr-2"></i>{{ _("Remove Image") }}';
|
|
html += '</button>';
|
|
html += '</div>';
|
|
}
|
|
|
|
html += '<input type="hidden" id="prop-image-url" value="' + (imageUrl || '') + '">';
|
|
html += '</div>';
|
|
}
|
|
|
|
// Group-specific properties (for items-table and expenses-table)
|
|
// Check both className and constructor name for Groups
|
|
const isGroup = className === 'Group' || (node.constructor && node.constructor.name === 'Group');
|
|
const isTableGroup = isGroup && (attrs.name === 'items-table' || attrs.name === 'expenses-table');
|
|
console.log('Group check:', {
|
|
className: className,
|
|
constructorName: node.constructor ? node.constructor.name : 'unknown',
|
|
isGroup: isGroup,
|
|
name: attrs.name,
|
|
isTableGroup: isTableGroup
|
|
});
|
|
|
|
if (isTableGroup) {
|
|
try {
|
|
// Find child elements - use getChildren() to get direct children
|
|
const children = node.getChildren();
|
|
const textElements = children.filter(child => child.className === 'Text');
|
|
const lineElements = children.filter(child => child.className === 'Line');
|
|
|
|
const headerText = textElements[0]; // First text is header
|
|
const line = lineElements[0];
|
|
const itemsText = textElements[1]; // Second text is items
|
|
|
|
const headerAttrs = headerText ? headerText.attrs : {};
|
|
const lineAttrs = line ? line.attrs : {};
|
|
const itemsAttrs = itemsText ? itemsText.attrs : {};
|
|
|
|
// Debug logging
|
|
console.log('Table Group detected:', attrs.name);
|
|
console.log('Children count:', children.length);
|
|
console.log('Text elements:', textElements.length);
|
|
console.log('Line elements:', lineElements.length);
|
|
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Table Header</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Header Text</div>';
|
|
html += `<textarea id="prop-table-header-text" class="property-input" rows="2">${(headerAttrs.text || '').replace(/\\n/g, '\n')}</textarea>`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Header Font Size</div>';
|
|
html += `<input type="number" id="prop-table-header-fontSize" value="${headerAttrs.fontSize || 12}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Header Font Style</div>';
|
|
html += `<select id="prop-table-header-fontStyle" class="property-input">`;
|
|
html += `<option value="normal" ${headerAttrs.fontStyle === 'normal' ? 'selected' : ''}>Normal</option>`;
|
|
html += `<option value="bold" ${headerAttrs.fontStyle === 'bold' ? 'selected' : ''}>Bold</option>`;
|
|
html += `<option value="italic" ${headerAttrs.fontStyle === 'italic' ? 'selected' : ''}>Italic</option>`;
|
|
html += '</select>';
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Header Color</div>';
|
|
html += `<input type="color" id="prop-table-header-fill" value="${rgbToHex(headerAttrs.fill || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Table Items</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Items Template</div>';
|
|
html += `<textarea id="prop-table-items-text" class="property-input" rows="4">${(itemsAttrs.text || '').replace(/\\n/g, '\n')}</textarea>`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Items Font Size</div>';
|
|
html += `<input type="number" id="prop-table-items-fontSize" value="${itemsAttrs.fontSize || 11}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Items Color</div>';
|
|
html += `<input type="color" id="prop-table-items-fill" value="${rgbToHex(itemsAttrs.fill || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Table Separator Line</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Line Color</div>';
|
|
html += `<input type="color" id="prop-table-line-stroke" value="${rgbToHex(lineAttrs.stroke || '#000000')}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Line Width</div>';
|
|
html += `<input type="number" id="prop-table-line-strokeWidth" value="${lineAttrs.strokeWidth || 1}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Table Dimensions</div>';
|
|
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Table Width</div>';
|
|
const tableWidth = headerAttrs.width || itemsAttrs.width || 500;
|
|
html += `<input type="number" id="prop-table-width" value="${tableWidth}" class="property-input">`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
} catch (error) {
|
|
console.error('Error processing table group:', error);
|
|
html += '<div class="property-group"><p class="text-sm text-red-500">Error loading table properties: ' + error.message + '</p></div>';
|
|
}
|
|
} else if (isGroup) {
|
|
// Fallback for Groups that aren't recognized as tables
|
|
// Show at least basic info
|
|
html += '<div class="property-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label" style="font-weight: bold; color: #667eea;">Group Information</div>';
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Group Name</div>';
|
|
html += `<input type="text" value="${attrs.name || 'Unnamed Group'}" disabled class="property-input bg-gray-100">`;
|
|
html += '</div>';
|
|
html += '<div class="property-group">';
|
|
html += '<div class="property-label">Children Count</div>';
|
|
try {
|
|
const childrenCount = node.getChildren ? node.getChildren().length : (node.children ? node.children.length : 0);
|
|
html += `<input type="text" value="${childrenCount} children" disabled class="property-input bg-gray-100">`;
|
|
} catch (e) {
|
|
html += `<input type="text" value="Unknown" disabled class="property-input bg-gray-100">`;
|
|
}
|
|
html += '</div>';
|
|
html += '<p class="text-sm text-gray-500 italic mt-2">This is a Group element. If this is a table, make sure it has the name "items-table" or "expenses-table".</p>';
|
|
html += '</div>';
|
|
}
|
|
|
|
// Layer controls
|
|
html += '<div class="property-group" style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 2px solid #e5e7eb;">';
|
|
html += '<div class="property-label">Layer Order</div>';
|
|
html += '<div style="display: flex; gap: 0.5rem;">';
|
|
html += '<button onclick="moveLayer(\'up\')" class="btn btn-sm btn-secondary flex-1"><i class="fas fa-arrow-up"></i></button>';
|
|
html += '<button onclick="moveLayer(\'down\')" class="btn btn-sm btn-secondary flex-1"><i class="fas fa-arrow-down"></i></button>';
|
|
html += '<button onclick="moveLayer(\'top\')" class="btn btn-sm btn-secondary flex-1"><i class="fas fa-angle-double-up"></i></button>';
|
|
html += '<button onclick="moveLayer(\'bottom\')" class="btn btn-sm btn-secondary flex-1"><i class="fas fa-angle-double-down"></i></button>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
|
|
console.log('Setting properties HTML, length:', html.length);
|
|
console.log('HTML preview (first 500 chars):', html.substring(0, 500));
|
|
propsContent.innerHTML = html;
|
|
console.log('Properties HTML set successfully');
|
|
console.log('Properties content element exists:', !!propsContent);
|
|
console.log('Properties content innerHTML length after setting:', propsContent.innerHTML.length);
|
|
|
|
// Attach event listeners
|
|
try {
|
|
attachPropertyListeners();
|
|
console.log('Property listeners attached successfully');
|
|
} catch (error) {
|
|
console.error('Error attaching property listeners:', error);
|
|
}
|
|
}
|
|
|
|
function rgbToHex(color) {
|
|
if (color.startsWith('#')) return color;
|
|
if (color === 'transparent') return '#ffffff';
|
|
if (color === 'black') return '#000000';
|
|
if (color === 'white') return '#ffffff';
|
|
return color;
|
|
}
|
|
|
|
function attachPropertyListeners() {
|
|
if (!selectedElement) return;
|
|
|
|
const propX = document.getElementById('prop-x');
|
|
const propY = document.getElementById('prop-y');
|
|
const propText = document.getElementById('prop-text');
|
|
const propFontSize = document.getElementById('prop-fontSize');
|
|
const propFontFamily = document.getElementById('prop-fontFamily');
|
|
const propFontStyle = document.getElementById('prop-fontStyle');
|
|
const propFill = document.getElementById('prop-fill');
|
|
const propStroke = document.getElementById('prop-stroke');
|
|
const propStrokeWidth = document.getElementById('prop-strokeWidth');
|
|
const propWidth = document.getElementById('prop-width');
|
|
const propHeight = document.getElementById('prop-height');
|
|
const propRadius = document.getElementById('prop-radius');
|
|
const propOpacity = document.getElementById('prop-opacity');
|
|
|
|
if (propX) propX.addEventListener('input', () => { selectedElement.x(parseFloat(propX.value)); layer.draw(); });
|
|
if (propY) propY.addEventListener('input', () => { selectedElement.y(parseFloat(propY.value)); layer.draw(); });
|
|
if (propText) {
|
|
// Add both input and change listeners to ensure changes are captured
|
|
propText.addEventListener('input', function() {
|
|
if (selectedElement && selectedElement.text) {
|
|
selectedElement.text(this.value);
|
|
layer.draw();
|
|
}
|
|
});
|
|
propText.addEventListener('change', function() {
|
|
if (selectedElement && selectedElement.text) {
|
|
selectedElement.text(this.value);
|
|
layer.draw();
|
|
}
|
|
});
|
|
// Also handle paste events
|
|
propText.addEventListener('paste', function() {
|
|
setTimeout(() => {
|
|
if (selectedElement && selectedElement.text) {
|
|
selectedElement.text(this.value);
|
|
layer.draw();
|
|
}
|
|
}, 10);
|
|
});
|
|
}
|
|
if (propFontSize) propFontSize.addEventListener('input', () => { selectedElement.fontSize(parseFloat(propFontSize.value)); layer.draw(); });
|
|
if (propFontFamily) propFontFamily.addEventListener('change', () => { selectedElement.fontFamily(propFontFamily.value); layer.draw(); });
|
|
if (propFontStyle) propFontStyle.addEventListener('change', () => { selectedElement.fontStyle(propFontStyle.value); layer.draw(); });
|
|
if (propFill) propFill.addEventListener('input', () => { selectedElement.fill(propFill.value); layer.draw(); });
|
|
if (propStroke) propStroke.addEventListener('input', () => { selectedElement.stroke(propStroke.value); layer.draw(); });
|
|
if (propStrokeWidth) propStrokeWidth.addEventListener('input', () => { selectedElement.strokeWidth(parseFloat(propStrokeWidth.value)); layer.draw(); });
|
|
if (propWidth) propWidth.addEventListener('input', () => { selectedElement.width(parseFloat(propWidth.value)); layer.draw(); });
|
|
if (propHeight) propHeight.addEventListener('input', () => { selectedElement.height(parseFloat(propHeight.value)); layer.draw(); });
|
|
if (propRadius) propRadius.addEventListener('input', () => { selectedElement.radius(parseFloat(propRadius.value)); layer.draw(); });
|
|
if (propOpacity) {
|
|
propOpacity.addEventListener('input', () => {
|
|
selectedElement.opacity(parseFloat(propOpacity.value));
|
|
document.getElementById('opacity-value').textContent = (propOpacity.value * 100) + '%';
|
|
layer.draw();
|
|
});
|
|
}
|
|
|
|
// Function to update decorative image element with uploaded image (moved outside to be accessible)
|
|
window.updateDecorativeImageElement = function(element, imageUrl) {
|
|
const elementName = element && element.attrs ? element.attrs.name : '';
|
|
if (!element || !elementName || !elementName.includes('decorative-image')) {
|
|
// Silently return if element is not a decorative image (this can happen when buttons exist but wrong element is selected)
|
|
return;
|
|
}
|
|
|
|
console.log('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, '<').replace(/>/g, '>');
|
|
bodyContent += ` <div class="element text-element" style="position:absolute;left:${x}px;top:${y}px;font-size:${fontSize}px;font-family:'${fontFamily}';font-weight:${fontWeight};font-style:${fontStyleCss};color:${color};opacity:${opacity};width:${attrs.width || 400}px;text-align:${textAlign}">${escapedText}</div>\n`;
|
|
} else if (child.className === 'Image') {
|
|
const w = Math.round(attrs.width || 100);
|
|
const h = Math.round(attrs.height || 50);
|
|
|
|
// Add to ReportLab template JSON - image source needs Jinja2 template syntax
|
|
{% raw %}
|
|
const imageSource = '{{ get_logo_base64(settings.get_logo_path()) if settings.has_logo() and settings.get_logo_path() else "" }}';
|
|
{% endraw %}
|
|
templateJson.elements.push({
|
|
type: 'image',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(w),
|
|
height: pxToPt(h),
|
|
source: imageSource,
|
|
opacity: opacity
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
{% raw %}
|
|
bodyContent += ` <img src="{{ get_logo_base64(settings.get_logo_path()) if settings.has_logo() and settings.get_logo_path() else '' }}" style="position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;opacity:${opacity}" alt="Logo">\n`;
|
|
{% endraw %}
|
|
} else if (child.className === 'Rect') {
|
|
const w = Math.round(attrs.width || 100);
|
|
const h = Math.round(attrs.height || 100);
|
|
const fill = attrs.fill || 'transparent';
|
|
const stroke = attrs.stroke || 'black';
|
|
const strokeWidth = attrs.strokeWidth || 1;
|
|
|
|
// Add to ReportLab template JSON
|
|
templateJson.elements.push({
|
|
type: 'rectangle',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(w),
|
|
height: pxToPt(h),
|
|
style: {
|
|
fill: fill !== 'transparent' ? fill : null,
|
|
stroke: stroke,
|
|
strokeWidth: strokeWidth,
|
|
opacity: opacity
|
|
}
|
|
});
|
|
|
|
// Legacy HTML for preview
|
|
bodyContent += ` <div class="rectangle-element" style="position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;background:${fill};border:${strokeWidth}px solid ${stroke};opacity:${opacity}"></div>\n`;
|
|
} else if (child.className === 'Circle') {
|
|
const radius = Math.round(attrs.radius || 50);
|
|
const fill = attrs.fill || 'transparent';
|
|
const stroke = attrs.stroke || 'black';
|
|
const strokeWidth = attrs.strokeWidth || 1;
|
|
const adjustedX = x - radius;
|
|
const adjustedY = y - radius;
|
|
|
|
// Add to ReportLab template JSON
|
|
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
|
|
let isItemsTable = child.attrs.name === 'items-table';
|
|
let isExpensesTable = child.attrs.name === 'expenses-table';
|
|
|
|
// CRITICAL FIX: Check if this is a decorative-image group (handle name variations)
|
|
const groupName = child.attrs.name || '';
|
|
const isDecorativeImage = groupName && groupName.includes('decorative-image');
|
|
|
|
// Fallback: Check if group has multiple children (header, line, items) - likely a table
|
|
if (!isItemsTable && !isExpensesTable && !isDecorativeImage && child.children && child.children.length >= 3) {
|
|
const hasTextChildren = child.children.filter(c => c.className === 'Text').length >= 2;
|
|
const hasLine = child.children.some(c => c.className === 'Line');
|
|
if (hasTextChildren && hasLine) {
|
|
console.log('⚠ Table detected by structure (missing name attribute) - consider re-saving layout');
|
|
isItemsTable = true;
|
|
}
|
|
}
|
|
|
|
// Process decorative-image groups first (before tables)
|
|
if (isDecorativeImage) {
|
|
// Decorative image element
|
|
// Get imageUrl from attrs - try multiple methods to ensure we get it
|
|
let imageUrl = '';
|
|
try {
|
|
imageUrl = child.getAttr('imageUrl') || '';
|
|
} catch(e) {
|
|
console.warn('Could not get imageUrl via getAttr:', e);
|
|
}
|
|
if (!imageUrl) {
|
|
imageUrl = attrs.imageUrl || '';
|
|
}
|
|
// Also check if stored in the node's attrs directly
|
|
if (!imageUrl && child.attrs) {
|
|
imageUrl = child.attrs.imageUrl || '';
|
|
}
|
|
|
|
console.log('Generating code for decorative image, URL:', imageUrl, 'attrs:', attrs);
|
|
|
|
// Find the actual image in the group if it exists
|
|
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
|
const imageElement = children.find(c => c.className === 'Image');
|
|
|
|
let actualWidth = 100;
|
|
let actualHeight = 100;
|
|
|
|
if (imageElement) {
|
|
actualWidth = Math.round(imageElement.width() || imageElement.attrs.width || 100);
|
|
actualHeight = Math.round(imageElement.height() || imageElement.attrs.height || 100);
|
|
} else {
|
|
// Use group dimensions if available
|
|
const groupBox = child.getClientRect();
|
|
if (groupBox && groupBox.width > 0 && groupBox.height > 0) {
|
|
actualWidth = Math.round(groupBox.width);
|
|
actualHeight = Math.round(groupBox.height);
|
|
}
|
|
}
|
|
|
|
console.log('Decorative image dimensions:', actualWidth, 'x', actualHeight, 'URL:', imageUrl);
|
|
|
|
// Add to ReportLab template JSON
|
|
templateJson.elements.push({
|
|
type: 'image',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(actualWidth),
|
|
height: pxToPt(actualHeight),
|
|
source: imageUrl || '', // Store the image URL
|
|
opacity: opacity,
|
|
decorative: true // Mark as decorative image
|
|
});
|
|
|
|
// Legacy HTML for preview - only generate image tag, not rectangle
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
bodyContent += ` <img src="${imageUrl}" style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;opacity:${opacity}" alt="Decorative image" class="image-element">\n`;
|
|
} else {
|
|
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;background:#f0f0f0;border:2px dashed #999;opacity:${opacity};display:flex;align-items:center;justify-content:center;color:#666;font-size:12px;">Decorative Image</div>\n`;
|
|
}
|
|
// Skip processing children of decorative-image groups - they are just placeholders
|
|
return; // Use return instead of continue in forEach
|
|
}
|
|
|
|
if (isItemsTable) {
|
|
// Extract actual header text from the table group's first Text child
|
|
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
|
const textElements = children.filter(c => c.className === 'Text');
|
|
const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
|
|
|
|
// Parse header text
|
|
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, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;">${(headerParts[1] || 'Qty').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[2] || 'Unit Price').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[3] || 'Total').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` </tr>\n`;
|
|
bodyContent += ` </thead>\n`;
|
|
bodyContent += ` <tbody>\n`;
|
|
{% raw %}
|
|
bodyContent += ` {% if 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, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:100px;color:#856404;">${(headerParts[1] || 'Date').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">${(headerParts[2] || 'Category').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">${(headerParts[3] || 'Amount').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
|
bodyContent += ` </tr>\n`;
|
|
bodyContent += ` </thead>\n`;
|
|
bodyContent += ` <tbody>\n`;
|
|
{% raw %}
|
|
bodyContent += ` {% if 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);
|
|
|
|
// Add to ReportLab template JSON
|
|
templateJson.elements.push({
|
|
type: 'image',
|
|
x: pxToPt(x),
|
|
y: pxToPt(y),
|
|
width: pxToPt(actualWidth),
|
|
height: pxToPt(actualHeight),
|
|
source: imageUrl || '', // Store the image URL
|
|
opacity: opacity,
|
|
decorative: true // Mark as decorative image
|
|
});
|
|
|
|
// Legacy HTML for preview - only generate image tag, not rectangle
|
|
if (imageUrl && imageUrl.trim() !== '') {
|
|
bodyContent += ` <img src="${imageUrl}" style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;opacity:${opacity}" alt="Decorative image" class="image-element">\n`;
|
|
} else {
|
|
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;width:${actualWidth}px;height:${actualHeight}px;background:#f0f0f0;border:2px dashed #999;opacity:${opacity};display:flex;align-items:center;justify-content:center;color:#666;font-size:12px;">Decorative Image</div>\n`;
|
|
}
|
|
} else {
|
|
// Regular group (not a table)
|
|
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity}">\n`;
|
|
child.children.forEach(c => {
|
|
if (c.className === 'Text') {
|
|
const text = (c.attrs.text || '').replace(/</g, '<').replace(/>/g, '>');
|
|
// Preserve text alignment for text in groups
|
|
const textAlign = c.attrs.align || 'left';
|
|
bodyContent += ` <div style="font-size:${c.attrs.fontSize || 12}px;font-weight:${c.attrs.fontStyle === 'bold' ? 'bold' : 'normal'};text-align:${textAlign}">${text}</div>\n`;
|
|
} else if (c.className === 'Line') {
|
|
bodyContent += ` <hr style="border-top:${c.attrs.strokeWidth || 1}px solid ${c.attrs.stroke || 'black'};margin:${c.attrs.y || 0}px 0">\n`;
|
|
}
|
|
});
|
|
bodyContent += ` </div>\n`;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Legacy HTML/CSS generation for preview (backward compatibility)
|
|
const widthPx = dimensions.width;
|
|
const heightPx = dimensions.height;
|
|
|
|
const 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();
|
|
|
|
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);
|
|
|
|
// Log all Groups to see if items-table is there
|
|
layer.children.forEach((child, idx) => {
|
|
if (child.className === 'Group') {
|
|
console.log(`Group ${idx}: name="${child.attrs.name || 'unnamed'}"`, child.attrs);
|
|
// Check for decorative images
|
|
if (child.attrs.name === 'decorative-image') {
|
|
const imageUrl = child.getAttr('imageUrl') || child.attrs.imageUrl || '';
|
|
console.log(` Decorative image ${idx}: imageUrl="${imageUrl}"`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Ensure imageUrl is in attrs for all decorative images before serialization
|
|
layer.find('[name="decorative-image"]').forEach(decorativeImageGroup => {
|
|
try {
|
|
// Try multiple methods to get imageUrl
|
|
let imageUrl = decorativeImageGroup.getAttr('imageUrl');
|
|
if (!imageUrl) {
|
|
imageUrl = decorativeImageGroup.attrs.imageUrl;
|
|
}
|
|
if (!imageUrl) {
|
|
// Check if there's an Image node with the URL
|
|
const imageNode = decorativeImageGroup.findOne('Image');
|
|
if (imageNode) {
|
|
// Try to get URL from image node's source if available
|
|
const imgAttrs = imageNode.attrs || {};
|
|
imageUrl = imgAttrs.src || imgAttrs.url || imgAttrs.imageUrl || '';
|
|
}
|
|
}
|
|
|
|
if (imageUrl) {
|
|
// Use setAttr to ensure Konva properly serializes it
|
|
decorativeImageGroup.setAttr('imageUrl', imageUrl);
|
|
// Also set in attrs for redundancy
|
|
decorativeImageGroup.attrs.imageUrl = imageUrl;
|
|
console.log('Ensured imageUrl in attrs for decorative image:', imageUrl);
|
|
} else {
|
|
console.warn('No imageUrl found for decorative image group');
|
|
}
|
|
} catch(e) {
|
|
console.warn('Could not get/set imageUrl for decorative image:', e);
|
|
}
|
|
});
|
|
|
|
// Final check: ensure all decorative images have imageUrl properly set before serialization
|
|
layer.find('[name="decorative-image"]').forEach(decorativeImageGroup => {
|
|
try {
|
|
// Double-check that imageUrl is set via both methods
|
|
const imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl;
|
|
if (imageUrl) {
|
|
decorativeImageGroup.setAttr('imageUrl', imageUrl);
|
|
decorativeImageGroup.attrs.imageUrl = imageUrl;
|
|
console.log('Final check: imageUrl set for decorative image:', imageUrl);
|
|
}
|
|
} catch(e) {
|
|
console.warn('Final check: Could not set imageUrl:', e);
|
|
}
|
|
});
|
|
|
|
// CRITICAL FIX: Get imageUrl from actual nodes before serialization and store in a map
|
|
// This ensures we can inject imageUrl into JSON after serialization
|
|
const decorativeImageUrlMap = new Map();
|
|
layer.find('[name="decorative-image"]').forEach((decorativeImageGroup, index) => {
|
|
let imageUrl = decorativeImageGroup.getAttr('imageUrl') || decorativeImageGroup.attrs.imageUrl || '';
|
|
// Use position as key to match nodes after serialization
|
|
const key = `${decorativeImageGroup.x()}_${decorativeImageGroup.y()}_${index}`;
|
|
decorativeImageUrlMap.set(key, imageUrl);
|
|
// Also store by index as fallback
|
|
decorativeImageUrlMap.set(`index_${index}`, imageUrl);
|
|
console.log(`Stored imageUrl for decorative image ${index}:`, imageUrl, 'key:', key);
|
|
});
|
|
|
|
// Serialize the stage
|
|
const stageJson = stage.toJSON();
|
|
|
|
// CRITICAL FIX: Explicitly inject name and imageUrl into serialized JSON for decorative images
|
|
// by matching live layer children to JSON by index (Konva may not serialize custom attrs)
|
|
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)) || '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Manually inject imageUrl into serialized JSON for decorative images (backup pass)
|
|
// Konva's toJSON() might not include custom attributes, so we need to add them manually
|
|
let decorativeImageIndex = 0;
|
|
function ensureImageUrlInJson(node, parentKey = '') {
|
|
if (node.attrs && node.attrs.name === 'decorative-image') {
|
|
// Try to match by position
|
|
const x = node.attrs.x || 0;
|
|
const y = node.attrs.y || 0;
|
|
const positionKey = `${x}_${y}_${decorativeImageIndex}`;
|
|
const indexKey = `index_${decorativeImageIndex}`;
|
|
|
|
// Get imageUrl from map
|
|
let imageUrl = decorativeImageUrlMap.get(positionKey) ||
|
|
decorativeImageUrlMap.get(indexKey) || '';
|
|
|
|
// If still not found, try to find by matching all keys
|
|
if (!imageUrl) {
|
|
for (const [key, url] of decorativeImageUrlMap.entries()) {
|
|
if (url) {
|
|
imageUrl = url;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (imageUrl) {
|
|
// Ensure imageUrl is in the JSON attrs
|
|
if (!node.attrs) {
|
|
node.attrs = {};
|
|
}
|
|
node.attrs.imageUrl = imageUrl;
|
|
console.log(`Injected imageUrl into JSON for decorative image ${decorativeImageIndex}:`, imageUrl);
|
|
} else {
|
|
console.warn(`No imageUrl found for decorative image ${decorativeImageIndex} at position (${x}, ${y})`);
|
|
}
|
|
|
|
decorativeImageIndex++;
|
|
}
|
|
if (node.children) {
|
|
node.children.forEach((child, idx) => ensureImageUrlInJson(child, `${parentKey}.children[${idx}]`));
|
|
}
|
|
}
|
|
|
|
// Ensure imageUrl is in JSON for all decorative images
|
|
if (stageJson.children) {
|
|
stageJson.children.forEach((child, idx) => ensureImageUrlInJson(child, `children[${idx}]`));
|
|
}
|
|
|
|
// Debug: Log decorative image groups in the JSON
|
|
function logDecorativeImagesInJson(node, depth = 0) {
|
|
if (node.attrs && node.attrs.name === 'decorative-image') {
|
|
console.log(' '.repeat(depth) + 'Found decorative-image in JSON, imageUrl:', node.attrs.imageUrl || 'NOT FOUND');
|
|
}
|
|
if (node.children) {
|
|
node.children.forEach(child => logDecorativeImagesInJson(child, depth + 1));
|
|
}
|
|
}
|
|
console.log('=== Checking decorative images in serialized JSON ===');
|
|
logDecorativeImagesInJson(stageJson);
|
|
|
|
document.getElementById('save-html').value = html;
|
|
document.getElementById('save-css').value = css;
|
|
document.getElementById('save-design-json').value = JSON.stringify(stageJson);
|
|
// Ensure JSON is always present and valid
|
|
if (!json || !json.trim()) {
|
|
console.error('No JSON generated from template!');
|
|
alert('Error: Could not generate template JSON. Please try again.');
|
|
return;
|
|
}
|
|
|
|
// Validate JSON before saving
|
|
try {
|
|
JSON.parse(json);
|
|
} catch (e) {
|
|
console.error('Invalid JSON generated:', e);
|
|
alert('Error: Generated template JSON is invalid. Please try again.');
|
|
return;
|
|
}
|
|
|
|
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.children.forEach(grandChild => {
|
|
setupSelection(grandChild);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.draw();
|
|
} catch(e) {
|
|
console.error('Failed to load design state:', e);
|
|
}
|
|
};
|
|
|
|
// Add alignment buttons to canvas toolbar
|
|
const canvasToolbar = document.querySelector('.canvas-toolbar');
|
|
if (canvasToolbar) {
|
|
const alignmentButtons = `
|
|
<button type="button" onclick="alignElements('left')" title="{{ _('Align Left') }}">
|
|
<i class="fas fa-align-left"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('center-h')" title="{{ _('Center Horizontally') }}">
|
|
<i class="fas fa-align-center"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('right')" title="{{ _('Align Right') }}">
|
|
<i class="fas fa-align-right"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('top')" title="{{ _('Align Top') }}">
|
|
<i class="fas fa-arrow-up"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('center-v')" title="{{ _('Center Vertically') }}">
|
|
<i class="fas fa-arrows-alt-v"></i>
|
|
</button>
|
|
<button type="button" onclick="alignElements('bottom')" title="{{ _('Align Bottom') }}">
|
|
<i class="fas fa-arrow-down"></i>
|
|
</button>
|
|
`;
|
|
canvasToolbar.insertAdjacentHTML('beforeend', alignmentButtons);
|
|
}
|
|
|
|
// Load saved design or default layout
|
|
setTimeout(() => {
|
|
if (SAVED_DESIGN_JSON && SAVED_DESIGN_JSON.trim() !== '') {
|
|
console.log('Loading saved design from database...');
|
|
try {
|
|
const savedJson = JSON.parse(SAVED_DESIGN_JSON);
|
|
|
|
// Get current page size dimensions
|
|
const currentSize = CURRENT_PAGE_SIZE || 'A4';
|
|
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
|
|
|
// Update saved JSON dimensions to match current page size
|
|
if (savedJson.attrs) {
|
|
savedJson.attrs.width = dimensions.width;
|
|
savedJson.attrs.height = dimensions.height;
|
|
}
|
|
|
|
// Clear current layer
|
|
layer.destroyChildren();
|
|
|
|
// Recreate stage from saved JSON
|
|
const restoredStage = Konva.Node.create(savedJson, 'canvas-container');
|
|
|
|
// Ensure stage has correct dimensions
|
|
restoredStage.width(dimensions.width);
|
|
restoredStage.height(dimensions.height);
|
|
|
|
// Replace current stage
|
|
stage.destroy();
|
|
stage = restoredStage;
|
|
layer = stage.children[0];
|
|
|
|
// Debug: Log the saved JSON to check if imageUrl is there
|
|
console.log('Saved JSON structure (first 2000 chars):', JSON.stringify(savedJson).substring(0, 2000));
|
|
|
|
// Find or create background by name and resize it
|
|
background = layer.findOne('[name="background"]');
|
|
if (background) {
|
|
if (background.className === 'Rect') {
|
|
background.width(dimensions.width);
|
|
background.height(dimensions.height);
|
|
}
|
|
} else {
|
|
// Create background if it doesn't exist
|
|
background = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
fill: 'white',
|
|
name: 'background'
|
|
});
|
|
layer.add(background);
|
|
background.moveToBottom();
|
|
}
|
|
|
|
// Update page border to match new dimensions
|
|
const pageBorder = layer.findOne('[name="page-border"]');
|
|
if (pageBorder) {
|
|
pageBorder.width(dimensions.width);
|
|
pageBorder.height(dimensions.height);
|
|
} else {
|
|
// Create page border if it doesn't exist
|
|
const newPageBorder = new Konva.Rect({
|
|
x: 0,
|
|
y: 0,
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
stroke: '#667eea',
|
|
strokeWidth: 2,
|
|
fill: 'transparent',
|
|
name: 'page-border',
|
|
listening: false,
|
|
perfectDrawEnabled: false
|
|
});
|
|
layer.add(newPageBorder);
|
|
newPageBorder.moveToBottom();
|
|
}
|
|
|
|
// Clean up unwanted elements that might have been saved
|
|
// Remove unwanted full-page rectangle borders
|
|
layer.children.forEach((child) => {
|
|
if (child.className === 'Rect' && child !== background && child !== pageBorder) {
|
|
const rectName = child.attrs.name || '';
|
|
const rectX = child.attrs.x || 0;
|
|
const rectY = child.attrs.y || 0;
|
|
const rectWidth = child.attrs.width || 0;
|
|
const rectHeight = child.attrs.height || 0;
|
|
const rectFill = child.attrs.fill || '';
|
|
const rectStroke = child.attrs.stroke || '';
|
|
|
|
// Remove if name is "background" (duplicate)
|
|
if (rectName === 'background') {
|
|
console.log('Removing duplicate background rectangle');
|
|
child.destroy();
|
|
return;
|
|
}
|
|
|
|
// Remove full-page rectangles at 0,0 with white fill and black stroke (unwanted borders)
|
|
const isFullPage = Math.abs(rectX) < 5 && Math.abs(rectY) < 5 &&
|
|
Math.abs(rectWidth - dimensions.width) < 5 &&
|
|
Math.abs(rectHeight - dimensions.height) < 5;
|
|
|
|
if (isFullPage && (rectFill === 'white' || rectFill === '#ffffff' || rectFill === 'transparent') &&
|
|
(rectStroke === 'black' || rectStroke === '#000000')) {
|
|
console.log('Removing unwanted full-page rectangle border on load');
|
|
child.destroy();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Remove unwanted lines (border lines and separator lines)
|
|
if (child.className === 'Line') {
|
|
const lineX = child.attrs.x || 0;
|
|
const lineY = child.attrs.y || 0;
|
|
const points = child.attrs.points || [];
|
|
const stroke = child.attrs.stroke || '';
|
|
const lineName = child.attrs.name || '';
|
|
|
|
// Skip grid lines
|
|
if (lineName === 'grid-line') {
|
|
return;
|
|
}
|
|
|
|
// Calculate actual line position and width from points
|
|
let actualX = lineX;
|
|
let actualY = lineY;
|
|
let lineWidth = 0;
|
|
|
|
if (points.length >= 4) {
|
|
const x1 = points[0];
|
|
const y1 = points[1];
|
|
const x2 = points[2];
|
|
const y2 = points[3];
|
|
lineWidth = Math.abs(x2 - x1);
|
|
actualX = lineX + Math.min(x1, x2);
|
|
actualY = lineY + Math.min(y1, y2);
|
|
} else {
|
|
// If no points, check width attribute
|
|
lineWidth = child.attrs.width || 0;
|
|
actualX = lineX;
|
|
actualY = lineY;
|
|
}
|
|
|
|
// Remove border lines at origin (0,0) with full page width
|
|
const isAtOrigin = Math.abs(actualX) < 5 && Math.abs(actualY) < 5;
|
|
const isFullWidth = lineWidth > dimensions.width * 0.9;
|
|
const isGrayOrBlue = stroke === '#e0e0e0' || stroke === '#dee2e6' || stroke === 'gray' || stroke === 'grey' ||
|
|
stroke === '#cccccc' || stroke === '#999999' || stroke === '#667eea';
|
|
|
|
// Remove any line at origin with full width (border lines)
|
|
if (isAtOrigin && isFullWidth) {
|
|
console.log('Removing unwanted border line at origin on load:', {actualX, actualY, lineWidth, stroke, name: lineName});
|
|
child.destroy();
|
|
return;
|
|
}
|
|
|
|
// Remove unwanted gray/blue separator lines near top
|
|
if (points.length >= 4) {
|
|
const y1 = points[1];
|
|
const y2 = points[3];
|
|
const isNearTop = Math.min(y1, y2) < 50 || actualY < 50;
|
|
|
|
if (isFullWidth && isNearTop && isGrayOrBlue && lineName !== 'items-table-separator' && lineName !== 'expenses-table-separator') {
|
|
console.log('Removing unwanted separator line on load:', {actualX, actualY, lineWidth, stroke, name: lineName});
|
|
child.destroy();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
layer.draw();
|
|
|
|
// Update width and height variables for fit function
|
|
width = dimensions.width;
|
|
height = dimensions.height;
|
|
|
|
// Redraw grid for new size
|
|
drawGrid();
|
|
|
|
// 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.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 %}
|