623 lines
19 KiB
JavaScript
623 lines
19 KiB
JavaScript
/**
|
||
* Preview Manager - Verwaltet Preview-Generation mit Batch-Loading, Navigation und Rate-Limiting
|
||
*/
|
||
|
||
import { preparePlaceholdersForIndex } from './configurator-utils.js';
|
||
|
||
class PreviewManager {
|
||
constructor(api) {
|
||
this.api = api;
|
||
this.currentBatchIndex = 0;
|
||
this.currentDocIndex = 0;
|
||
this.previewCount = 0;
|
||
this.batchSize = 25;
|
||
this.currentBatchPreviews = [];
|
||
this.requestsRemaining = 10;
|
||
this.maxRequests = 10;
|
||
// Für Änderungserkennung und Validierungs-Caching
|
||
this.lastValidatedTextHash = null;
|
||
this.lastValidationResult = null;
|
||
this.lastOverflowFiles = null; // null = keine Validierung durchgeführt
|
||
}
|
||
|
||
/**
|
||
* Erzeugt einen einfachen Hash für Text-Vergleich
|
||
*/
|
||
hashText(text, quantity, format, font) {
|
||
const str = `${text || ''}|${quantity || 1}|${format || 'a4'}|${font || 'tilda'}`;
|
||
let hash = 0;
|
||
for (let i = 0; i < str.length; i++) {
|
||
const char = str.charCodeAt(i);
|
||
hash = ((hash << 5) - hash) + char;
|
||
hash = hash & hash;
|
||
}
|
||
return hash.toString(36);
|
||
}
|
||
|
||
/**
|
||
* Prüft ob sich der Text seit der letzten Validierung geändert hat
|
||
*/
|
||
hasTextChanged(state) {
|
||
const currentHash = this.hashText(
|
||
state.answers?.letterText,
|
||
state.answers?.quantity,
|
||
state.answers?.format,
|
||
state.answers?.font
|
||
);
|
||
return currentHash !== this.lastValidatedTextHash;
|
||
}
|
||
|
||
/**
|
||
* Speichert den aktuellen Text-Hash nach Validierung
|
||
*/
|
||
saveTextHash(state) {
|
||
this.lastValidatedTextHash = this.hashText(
|
||
state.answers?.letterText,
|
||
state.answers?.quantity,
|
||
state.answers?.format,
|
||
state.answers?.font
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Bereitet Platzhalter für ein bestimmtes Dokument vor
|
||
* Verwendet jetzt die gemeinsame Utility-Funktion
|
||
*/
|
||
preparePlaceholders(state, index) {
|
||
return preparePlaceholdersForIndex(state, index);
|
||
}
|
||
|
||
/**
|
||
* Generiert ein Letter-Objekt für ein bestimmtes Dokument
|
||
*/
|
||
prepareLetter(state, index, isEnvelope = false) {
|
||
const placeholders = this.preparePlaceholders(state, index);
|
||
|
||
if (isEnvelope) {
|
||
const isRecipientMode = state.answers?.envelopeMode === 'recipientData';
|
||
const isCustomMode = state.answers?.envelopeMode === 'customText';
|
||
const addressMode = state.addressMode || 'classic';
|
||
|
||
let envelopeText = '';
|
||
let envelopeType = 'recipient';
|
||
|
||
if (isRecipientMode) {
|
||
if (addressMode === 'free' && Array.isArray(state.freeAddressRows) && state.freeAddressRows.length > index) {
|
||
// Freie Adresse: Bis zu 5 Zeilen
|
||
const freeAddr = state.freeAddressRows[index];
|
||
const lines = [];
|
||
if (freeAddr.line1) lines.push(freeAddr.line1);
|
||
if (freeAddr.line2) lines.push(freeAddr.line2);
|
||
if (freeAddr.line3) lines.push(freeAddr.line3);
|
||
if (freeAddr.line4) lines.push(freeAddr.line4);
|
||
if (freeAddr.line5) lines.push(freeAddr.line5);
|
||
envelopeText = lines.join('\n');
|
||
envelopeType = 'free';
|
||
} else if (addressMode === 'classic' && Array.isArray(state.recipientRows) && state.recipientRows.length > index) {
|
||
// Klassische Adresse
|
||
const recipient = state.recipientRows[index];
|
||
const lines = [];
|
||
|
||
const fullName = `${recipient.firstName || ''} ${recipient.lastName || ''}`.trim();
|
||
if (fullName) lines.push(fullName);
|
||
|
||
const streetLine = `${recipient.street || ''} ${recipient.houseNumber || ''}`.trim();
|
||
if (streetLine) lines.push(streetLine);
|
||
|
||
const location = `${recipient.zip || ''} ${recipient.city || ''}`.trim();
|
||
if (location) lines.push(location);
|
||
|
||
if (recipient.country && recipient.country !== 'Deutschland') {
|
||
lines.push(recipient.country);
|
||
}
|
||
|
||
envelopeText = lines.join('\n');
|
||
envelopeType = 'recipient';
|
||
}
|
||
} else if (isCustomMode) {
|
||
envelopeText = state.answers?.envelopeCustomText || '';
|
||
envelopeType = 'custom';
|
||
}
|
||
|
||
return {
|
||
index: index,
|
||
text: envelopeText,
|
||
font: state.answers?.envelopeFont || 'tilda',
|
||
format: state.answers?.format === 'a4' ? 'DIN_LANG' : 'C6',
|
||
placeholders: placeholders,
|
||
type: 'envelope',
|
||
envelopeType: envelopeType,
|
||
envelope: {
|
||
type: envelopeType
|
||
}
|
||
};
|
||
} else {
|
||
return {
|
||
index: index,
|
||
text: state.answers?.letterText || state.answers?.text || state.answers?.briefText || '',
|
||
font: state.answers?.font || 'tilda',
|
||
format: state.answers?.format || 'a4',
|
||
placeholders: placeholders,
|
||
type: 'letter'
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Lädt ALLE Previews auf einmal
|
||
* @param {boolean} skipLimitCheck - Wenn true, wird das Request-Limit ignoriert (für Validierung)
|
||
*/
|
||
async loadAllPreviews(state, isEnvelope = false, skipLimitCheck = false) {
|
||
// Request-Limit nur prüfen wenn nicht übersprungen (normale Preview-Generierung)
|
||
if (!skipLimitCheck) {
|
||
if (this.requestsRemaining <= 0) {
|
||
throw new Error(`Maximale Anzahl von ${this.maxRequests} Vorschau-Anfragen erreicht.`);
|
||
}
|
||
this.requestsRemaining--;
|
||
}
|
||
|
||
const letters = [];
|
||
for (let i = 0; i < this.previewCount; i++) {
|
||
letters.push(this.prepareLetter(state, i, isEnvelope));
|
||
}
|
||
|
||
try {
|
||
const result = await this.api.generatePreviewBatch(letters);
|
||
|
||
if (!result.success) {
|
||
throw new Error(result.error || 'Preview generation failed');
|
||
}
|
||
|
||
this.currentBatchPreviews = result.previews;
|
||
this.lastValidationResult = result; // Speichere für Overflow-Check
|
||
return true;
|
||
} catch (error) {
|
||
console.error('[PreviewManager] Load error:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validiert Textlänge durch Preview-Generierung
|
||
* Wenn Text nicht geändert wurde und Preview bereits existiert, wird gecachtes Ergebnis verwendet
|
||
* @returns {Object} { valid: boolean, overflowFiles: Array, fromCache: boolean }
|
||
*/
|
||
async validateTextLength(state, forceRevalidate = false) {
|
||
// Prüfe ob wir gecachtes Ergebnis verwenden können
|
||
if (!forceRevalidate && !this.hasTextChanged(state) && this.lastOverflowFiles !== null) {
|
||
console.log('[PreviewManager] Using cached validation result');
|
||
return {
|
||
valid: this.lastOverflowFiles.length === 0,
|
||
overflowFiles: this.lastOverflowFiles,
|
||
fromCache: true
|
||
};
|
||
}
|
||
|
||
this.previewCount = parseInt(state.answers?.quantity) || 1;
|
||
|
||
try {
|
||
// skipLimitCheck = true: Validierung soll immer möglich sein, auch ohne verbleibende Anfragen
|
||
await this.loadAllPreviews(state, false, true);
|
||
|
||
const result = this.lastValidationResult;
|
||
if (!result) {
|
||
this.lastOverflowFiles = [];
|
||
this.saveTextHash(state);
|
||
return { valid: true, overflowFiles: [], fromCache: false };
|
||
}
|
||
|
||
const hasOverflow = result.hasOverflow || false;
|
||
const overflowFiles = (result.overflowFiles || []).map(f => ({
|
||
index: f.index,
|
||
lineCount: f.lineCount,
|
||
lineLimit: f.lineLimit
|
||
}));
|
||
|
||
// Cache das Ergebnis
|
||
this.lastOverflowFiles = overflowFiles;
|
||
this.saveTextHash(state);
|
||
|
||
return {
|
||
valid: !hasOverflow,
|
||
overflowFiles: overflowFiles,
|
||
fromCache: false
|
||
};
|
||
} catch (error) {
|
||
console.error('[PreviewManager] Validation error:', error);
|
||
|
||
// Bei Fehlern: Nicht durchlassen - Nutzer muss es erneut versuchen
|
||
return {
|
||
valid: false,
|
||
overflowFiles: [],
|
||
error: error.message,
|
||
fromCache: false
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gibt gecachte Overflow-Infos zurück (ohne neue Anfrage)
|
||
*/
|
||
getCachedOverflowFiles() {
|
||
return this.lastOverflowFiles || [];
|
||
}
|
||
|
||
/**
|
||
* Prüft ob Validierung bereits erfolgt ist (für UI-Anzeige)
|
||
*/
|
||
hasValidationResult() {
|
||
return this.lastOverflowFiles !== null;
|
||
}
|
||
|
||
/**
|
||
* Generiert Previews und zeigt Overflow-Warnung wenn nötig
|
||
* @returns {Object} { success: boolean, hasOverflow: boolean, overflowFiles: Array }
|
||
*/
|
||
async generatePreviews(state, dom, isEnvelope = false) {
|
||
const btn = dom.querySelector('.sk-preview-generate-btn');
|
||
const statusEl = dom.querySelector('.sk-preview-status');
|
||
const requestCounterEl = dom.querySelector('.sk-preview-request-counter');
|
||
|
||
if (!btn) return { success: false, hasOverflow: false, overflowFiles: [] };
|
||
|
||
try {
|
||
btn.disabled = true;
|
||
btn.textContent = 'Generiere Vorschau...';
|
||
|
||
this.previewCount = parseInt(state.answers?.quantity) || 1;
|
||
|
||
if (statusEl) {
|
||
statusEl.textContent = `Lade alle ${this.previewCount} Dokumente...`;
|
||
}
|
||
|
||
await this.loadAllPreviews(state, isEnvelope);
|
||
|
||
// Text-Hash speichern für Änderungserkennung
|
||
if (!isEnvelope) {
|
||
this.saveTextHash(state);
|
||
}
|
||
|
||
this.currentDocIndex = 0;
|
||
this.showPreview(0, dom);
|
||
this.showNavigationControls(dom, state, isEnvelope);
|
||
|
||
if (statusEl) {
|
||
statusEl.textContent = `Dokument 1 von ${this.previewCount}`;
|
||
}
|
||
|
||
if (requestCounterEl) {
|
||
requestCounterEl.textContent = `Verbleibende Vorschau-Anfragen: ${this.requestsRemaining}/${this.maxRequests}`;
|
||
}
|
||
|
||
btn.disabled = false;
|
||
btn.textContent = btn.textContent.includes('Umschlag') ? 'Umschlag Vorschau generieren' : 'Vorschau Schriftstück generieren';
|
||
|
||
// Overflow-Prüfung für Briefe (nicht Umschläge)
|
||
if (!isEnvelope && this.lastValidationResult) {
|
||
const hasOverflow = this.lastValidationResult.hasOverflow || false;
|
||
const overflowFiles = (this.lastValidationResult.overflowFiles || []).map(f => ({
|
||
index: f.index,
|
||
lineCount: f.lineCount,
|
||
lineLimit: f.lineLimit
|
||
}));
|
||
|
||
// Cache speichern
|
||
this.lastOverflowFiles = overflowFiles;
|
||
|
||
return { success: true, hasOverflow, overflowFiles };
|
||
}
|
||
|
||
return { success: true, hasOverflow: false, overflowFiles: [] };
|
||
|
||
} catch (error) {
|
||
console.error('[PreviewManager] Error:', error);
|
||
btn.disabled = false;
|
||
btn.textContent = isEnvelope ? 'Umschlag Vorschau generieren' : 'Vorschau generieren';
|
||
|
||
// Fehlermeldung im Preview-Bereich anzeigen
|
||
const previewBox = dom.querySelector('.sk-preview-box');
|
||
if (previewBox) {
|
||
previewBox.innerHTML = '';
|
||
const notice = document.createElement('div');
|
||
notice.style.cssText = 'padding: 20px; text-align: center; color: #666;';
|
||
notice.innerHTML = `
|
||
<p style="margin-bottom: 15px;">Die Vorschau konnte nicht generiert werden.</p>
|
||
<p style="color: #999; font-size: 13px;">Bitte versuchen Sie es erneut oder kontaktieren Sie uns bei anhaltenden Problemen.</p>
|
||
`;
|
||
previewBox.appendChild(notice);
|
||
}
|
||
|
||
return { success: false, hasOverflow: false, overflowFiles: [], error: error.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Zeigt eine Preview an einem bestimmten Index
|
||
*/
|
||
showPreview(index, dom) {
|
||
const preview = this.currentBatchPreviews[index];
|
||
if (!preview) {
|
||
console.warn('[PreviewManager] Preview not loaded:', index);
|
||
return;
|
||
}
|
||
|
||
const previewBox = dom.querySelector('.sk-preview-box');
|
||
const statusEl = dom.querySelector('.sk-preview-status');
|
||
|
||
if (!previewBox) {
|
||
console.warn('[PreviewManager] Preview box not found');
|
||
return;
|
||
}
|
||
|
||
const imgContainer = document.createElement('div');
|
||
imgContainer.style.cssText = 'position: relative; overflow: hidden; margin-top: 15px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer;';
|
||
|
||
const img = document.createElement('img');
|
||
img.src = `${this.api.baseURL}${preview.url}?t=${Date.now()}`;
|
||
img.style.cssText = 'width: 100%; display: block;';
|
||
|
||
imgContainer.addEventListener('click', () => {
|
||
this.showFullscreenPreview(img.src);
|
||
});
|
||
|
||
imgContainer.addEventListener('mouseenter', () => {
|
||
imgContainer.style.opacity = '0.9';
|
||
});
|
||
|
||
imgContainer.addEventListener('mouseleave', () => {
|
||
imgContainer.style.opacity = '1';
|
||
});
|
||
|
||
imgContainer.appendChild(img);
|
||
previewBox.innerHTML = '';
|
||
previewBox.appendChild(imgContainer);
|
||
|
||
if (statusEl) {
|
||
statusEl.textContent = `Dokument ${index + 1} von ${this.previewCount}`;
|
||
}
|
||
|
||
this.currentDocIndex = index;
|
||
}
|
||
|
||
/**
|
||
* Navigiert zur nächsten/vorherigen Preview
|
||
*/
|
||
navigateWithinBatch(direction, dom) {
|
||
const newIndex = this.currentDocIndex + direction;
|
||
|
||
if (newIndex < 0 || newIndex >= this.currentBatchPreviews.length) {
|
||
return;
|
||
}
|
||
|
||
this.showPreview(newIndex, dom);
|
||
this.updateNavigationButtons();
|
||
}
|
||
|
||
// navigateToBatch wurde entfernt - Batch-Loading wird nicht mehr verwendet
|
||
// Alle Previews werden jetzt auf einmal geladen (loadAllPreviews)
|
||
|
||
/**
|
||
* Ermittelt den aktuellen lokalen Index
|
||
*/
|
||
getCurrentLocalIndex(dom) {
|
||
const statusEl = dom.querySelector('.sk-preview-status');
|
||
if (!statusEl) return 0;
|
||
|
||
const match = statusEl.textContent.match(/Dokument (\d+) von/);
|
||
if (match) {
|
||
return parseInt(match[1]) - 1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* Aktualisiert den Request Counter im DOM
|
||
*/
|
||
updateRequestCounter(dom) {
|
||
const requestCounterEl = dom.querySelector('.sk-preview-request-counter');
|
||
if (requestCounterEl) {
|
||
requestCounterEl.textContent = `Verbleibende Vorschau-Anfragen: ${this.requestsRemaining}/${this.maxRequests}`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Aktualisiert Button-States der Navigation
|
||
*/
|
||
updateNavigationButtons() {
|
||
const navWrapper = document.querySelector('.sk-preview-navigation-container');
|
||
if (!navWrapper) return;
|
||
|
||
const navContainer = navWrapper.querySelector('.sk-preview-navigation');
|
||
if (!navContainer) return;
|
||
|
||
const prevDocBtn = navContainer.querySelector('.sk-preview-prev-doc');
|
||
const nextDocBtn = navContainer.querySelector('.sk-preview-next-doc');
|
||
|
||
if (prevDocBtn) {
|
||
const canPrev = this.currentDocIndex > 0;
|
||
prevDocBtn.disabled = !canPrev;
|
||
prevDocBtn.style.opacity = canPrev ? '1' : '0.5';
|
||
prevDocBtn.style.cursor = canPrev ? 'pointer' : 'not-allowed';
|
||
}
|
||
|
||
if (nextDocBtn) {
|
||
const canNext = this.currentDocIndex < this.currentBatchPreviews.length - 1;
|
||
nextDocBtn.disabled = !canNext;
|
||
nextDocBtn.style.opacity = canNext ? '1' : '0.5';
|
||
nextDocBtn.style.cursor = canNext ? 'pointer' : 'not-allowed';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Zeigt Navigation Controls
|
||
*/
|
||
showNavigationControls(dom, state, isEnvelope) {
|
||
const navWrapper = dom.querySelector('.sk-preview-navigation-container');
|
||
if (!navWrapper) return;
|
||
|
||
let navContainer = navWrapper.querySelector('.sk-preview-navigation');
|
||
if (navContainer) {
|
||
navContainer.remove();
|
||
}
|
||
|
||
navContainer = document.createElement('div');
|
||
navContainer.className = 'sk-preview-navigation';
|
||
navContainer.style.display = 'flex';
|
||
navContainer.style.gap = '10px';
|
||
navContainer.style.alignItems = 'center';
|
||
|
||
const buttonStyle = {
|
||
padding: '8px 12px',
|
||
fontSize: '20px',
|
||
lineHeight: '1',
|
||
minWidth: '40px',
|
||
backgroundColor: '#fff',
|
||
border: '1px solid #ddd',
|
||
borderRadius: '4px',
|
||
cursor: 'pointer'
|
||
};
|
||
|
||
const prevDocBtn = document.createElement('button');
|
||
prevDocBtn.type = 'button';
|
||
prevDocBtn.className = 'sk-btn sk-btn-secondary sk-preview-prev-doc';
|
||
prevDocBtn.textContent = '‹';
|
||
prevDocBtn.title = 'Vorheriges Dokument';
|
||
Object.assign(prevDocBtn.style, buttonStyle);
|
||
prevDocBtn.onclick = () => {
|
||
this.navigateWithinBatch(-1, dom);
|
||
};
|
||
|
||
const nextDocBtn = document.createElement('button');
|
||
nextDocBtn.type = 'button';
|
||
nextDocBtn.className = 'sk-btn sk-btn-secondary sk-preview-next-doc';
|
||
nextDocBtn.textContent = '›';
|
||
nextDocBtn.title = 'Nächstes Dokument';
|
||
Object.assign(nextDocBtn.style, buttonStyle);
|
||
nextDocBtn.onclick = () => {
|
||
this.navigateWithinBatch(1, dom);
|
||
};
|
||
|
||
navContainer.appendChild(prevDocBtn);
|
||
navContainer.appendChild(nextDocBtn);
|
||
|
||
navWrapper.appendChild(navContainer);
|
||
this.updateNavigationButtons();
|
||
}
|
||
|
||
/**
|
||
* Keyboard Event Handler
|
||
*/
|
||
handleKeyboardNavigation(event, state, dom, isEnvelope = false) {
|
||
if (this.currentBatchPreviews.length === 0) return;
|
||
|
||
const currentLocalIndex = this.getCurrentLocalIndex(dom);
|
||
|
||
if (event.key === 'ArrowLeft' && currentLocalIndex > 0) {
|
||
event.preventDefault();
|
||
this.navigateWithinBatch(-1, dom);
|
||
} else if (event.key === 'ArrowRight' && currentLocalIndex < this.currentBatchPreviews.length - 1) {
|
||
event.preventDefault();
|
||
this.navigateWithinBatch(1, dom);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cleanup
|
||
*/
|
||
destroy() {
|
||
this.currentBatchPreviews = [];
|
||
this.currentBatchIndex = 0;
|
||
this.previewCount = 0;
|
||
}
|
||
|
||
/**
|
||
* Reset für neue Session
|
||
*/
|
||
reset() {
|
||
this.currentBatchPreviews = [];
|
||
this.currentBatchIndex = 0;
|
||
this.previewCount = 0;
|
||
this.requestsRemaining = this.maxRequests;
|
||
}
|
||
|
||
/**
|
||
* Zeigt Fullscreen-Vorschau als Modal
|
||
*/
|
||
showFullscreenPreview(imgSrc) {
|
||
const existingModal = document.getElementById('sk-preview-fullscreen-modal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
const modal = document.createElement('div');
|
||
modal.id = 'sk-preview-fullscreen-modal';
|
||
modal.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100vw;
|
||
height: 100vh;
|
||
background: rgba(0, 0, 0, 0.9);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
cursor: pointer;
|
||
`;
|
||
|
||
const modalImg = document.createElement('img');
|
||
modalImg.src = imgSrc;
|
||
modalImg.style.cssText = `
|
||
max-width: 90vw;
|
||
max-height: 90vh;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||
`;
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.innerHTML = '×';
|
||
closeBtn.style.cssText = `
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 30px;
|
||
font-size: 40px;
|
||
color: white;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
`;
|
||
|
||
const hint = document.createElement('div');
|
||
hint.textContent = 'Klicken zum Schließen';
|
||
hint.style.cssText = `
|
||
position: absolute;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 14px;
|
||
`;
|
||
|
||
modal.appendChild(modalImg);
|
||
modal.appendChild(closeBtn);
|
||
modal.appendChild(hint);
|
||
|
||
const closeModal = () => modal.remove();
|
||
modal.addEventListener('click', closeModal);
|
||
closeBtn.addEventListener('click', closeModal);
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') closeModal();
|
||
}, { once: true });
|
||
|
||
document.body.appendChild(modal);
|
||
}
|
||
}
|
||
|
||
// Globale Instanzen
|
||
window.envelopePreviewManager = null;
|
||
window.contentPreviewManager = null;
|
||
|
||
export default PreviewManager;
|