Files
Skrift-Kofnigurator/skrift-configurator/assets/js/configurator-preview-manager.js
2026-02-07 13:04:04 +01:00

623 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;