OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

wysiwyg.js

26 KB
/**
 * IT-Dokumentation - WYSIWYG Editor
 * Sicherer, funktionsreicher Editor
 */

'use strict';

window.OpenNIT = window.OpenNIT || {};

/**
 * WYSIWYG Editor Klasse
 */
OpenNIT.WysiwygEditor = function(container, options) {
    this.container = typeof container === 'string' ?
        document.querySelector(container) : container;

    if (!this.container) {
        throw new Error('Editor container not found');
    }

    this.options = Object.assign({
        placeholder: 'Inhalt eingeben...',
        uploadUrl: '/files/upload',
        csrfToken: '',
        onChange: null,
        onImageUpload: null,
        aiImproveEnabled: (typeof window.AI_EDITOR_ENABLED !== 'undefined') ? window.AI_EDITOR_ENABLED : false,
    }, options);

    this.toolbar = null;
    this.editor = null;
    this.hiddenInput = null;

    this.init();
};

OpenNIT.WysiwygEditor.prototype = {
    /**
     * Editor initialisieren
     */
    init: function() {
        this.createToolbar();
        this.createEditor();
        this.bindEvents();
        if (this.options.aiImproveEnabled) {
            this.initAiButton();
        }
    },

    /**
     * Toolbar erstellen
     */
    createToolbar: function() {
        const toolbarHtml = `
            <div class="wysiwyg-toolbar">
                <div class="toolbar-group">
                    <select class="toolbar-select" data-command="formatBlock" title="${window.onitText('Format')}">
                        <option value="p">${window.onitText('Absatz')}</option>
                        <option value="h2">${window.onitText('Überschrift 2')}</option>
                        <option value="h3">${window.onitText('Überschrift 3')}</option>
                        <option value="h4">${window.onitText('Überschrift 4')}</option>
                    </select>
                </div>
                ${this.options.fontSize ? `
                <div class="toolbar-group">
                    <select class="toolbar-select" data-command="fontSize" title="${window.onitText('Schriftgröße')}">
                        <option value="1">${window.onitText('Sehr klein')}</option>
                        <option value="2">${window.onitText('Klein')}</option>
                        <option value="3" selected>${window.onitText('Normal')}</option>
                        <option value="4">${window.onitText('Groß')}</option>
                        <option value="5">${window.onitText('Größer')}</option>
                        <option value="6">${window.onitText('Sehr groß')}</option>
                        <option value="7">${window.onitText('Riesig')}</option>
                    </select>
                </div>` : ''}
                <div class="toolbar-group">
                    <button type="button" class="toolbar-btn" data-command="bold" title="${window.onitText('Fett (Ctrl+B)')}"><b>B</b></button>
                    <button type="button" class="toolbar-btn" data-command="italic" title="${window.onitText('Kursiv (Ctrl+I)')}"><i>I</i></button>
                    <button type="button" class="toolbar-btn" data-command="underline" title="${window.onitText('Unterstrichen (Ctrl+U)')}"><u>U</u></button>
                    <button type="button" class="toolbar-btn" data-command="strikeThrough" title="${window.onitText('Durchgestrichen')}"><s>S</s></button>
                </div>
                <div class="toolbar-group">
                    <button type="button" class="toolbar-btn" data-command="insertUnorderedList" title="${window.onitText('Aufzählung')}">&#8226;</button>
                    <button type="button" class="toolbar-btn" data-command="insertOrderedList" title="${window.onitText('Nummerierung')}">1.</button>
                </div>
                <div class="toolbar-group">
                    <button type="button" class="toolbar-btn" data-command="createLink" title="${window.onitText('Link einfügen')}">&#128279;</button>
                    <button type="button" class="toolbar-btn" data-command="insertImage" title="${window.onitText('Bild einfügen')}">&#128247;</button>
                    <button type="button" class="toolbar-btn" data-command="insertTable" title="${window.onitText('Tabelle einfügen')}">&#9638;</button>
                </div>
                <div class="toolbar-group">
                    <button type="button" class="toolbar-btn" data-command="formatCode" title="${window.onitText('Code')}">&#60;/&#62;</button>
                    <button type="button" class="toolbar-btn" data-command="insertCodeBlock" title="${window.onitText('Codeblock')}">&#9783;</button>
                    <button type="button" class="toolbar-btn" data-command="formatQuote" title="${window.onitText('Zitat')}">&#8220;</button>
                </div>
                <div class="toolbar-group">
                    <button type="button" class="toolbar-btn" data-command="insertCallout" data-type="info" title="${window.onitText('Info-Box')}">&#8505;</button>
                    <button type="button" class="toolbar-btn" data-command="insertCallout" data-type="warning" title="${window.onitText('Warnung')}">&#9888;</button>
                </div>
                <div class="toolbar-group">
                    <button type="button" class="toolbar-btn" data-command="removeFormat" title="${window.onitText('Formatierung entfernen')}">&#10006;</button>
                </div>
            </div>
        `;

        const wrapper = document.createElement('div');
        wrapper.innerHTML = toolbarHtml;
        this.toolbar = wrapper.firstElementChild;
        this.container.appendChild(this.toolbar);
    },

    /**
     * Editor-Bereich erstellen
     */
    createEditor: function() {
        this.editor = document.createElement('div');
        this.editor.className = 'wysiwyg-editor';
        this.editor.contentEditable = 'true';
        this.editor.dataset.placeholder = this.options.placeholder;

        // Vorhandenen Inhalt übernehmen
        const existingContent = this.container.querySelector('textarea, input[type="hidden"]');
        if (existingContent) {
            this.hiddenInput = existingContent;
            this.editor.innerHTML = existingContent.value;
            existingContent.style.display = 'none';
        }

        this.container.appendChild(this.editor);
    },

    /**
     * Event-Handler binden
     */
    bindEvents: function() {
        const self = this;

        // Toolbar-Buttons
        this.toolbar.querySelectorAll('.toolbar-btn').forEach(function(btn) {
            btn.addEventListener('click', function(e) {
                e.preventDefault();
                const command = this.dataset.command;
                self.execCommand(command, this.dataset);
                self.editor.focus();
            });
        });

        // Format-Select
        this.toolbar.querySelectorAll('.toolbar-select').forEach(function(select) {
            select.addEventListener('change', function() {
                const command = this.dataset.command;
                document.execCommand(command, false, this.value);
                self.editor.focus();
                self.updateContent();
            });
        });

        // Inhalt synchronisieren
        this.editor.addEventListener('input', function() {
            self.updateContent();
        });

        // Paste-Handler für Bilder
        this.editor.addEventListener('paste', function(e) {
            self.handlePaste(e);
        });

        // Drop-Handler für Bilder
        this.editor.addEventListener('drop', function(e) {
            self.handleDrop(e);
        });

        // Drag-Over
        this.editor.addEventListener('dragover', function(e) {
            e.preventDefault();
        });

        // Keyboard-Shortcuts
        this.editor.addEventListener('keydown', function(e) {
            self.handleKeydown(e);
        });
    },

    /**
     * Befehl ausführen
     */
    execCommand: function(command, data) {
        switch (command) {
            case 'createLink':
                this.insertLink();
                break;
            case 'insertImage':
                this.showImageDialog();
                break;
            case 'insertTable':
                this.showTableDialog();
                break;
            case 'formatCode':
                this.wrapSelection('code');
                break;
            case 'insertCodeBlock':
                this.insertCodeBlock();
                break;
            case 'formatQuote':
                document.execCommand('formatBlock', false, 'blockquote');
                break;
            case 'insertCallout':
                this.insertCallout(data.type || 'info');
                break;
            default:
                document.execCommand(command, false, null);
        }
        this.updateContent();
    },

    /**
     * Link einfügen
     */
    insertLink: function() {
        const selection = window.getSelection();
        const selectedText = selection.toString();

        const url = prompt('URL eingeben:', 'https://');
        if (url && url !== 'https://') {
            // URL validieren
            if (!this.isValidUrl(url)) {
                alert('Ungültige URL');
                return;
            }
            document.execCommand('createLink', false, url);
        }
    },

    /**
     * URL validieren
     */
    isValidUrl: function(string) {
        try {
            const url = new URL(string);
            return url.protocol === 'http:' || url.protocol === 'https:';
        } catch (_) {
            return false;
        }
    },

    /**
     * Bild-Dialog anzeigen
     */
    showImageDialog: function() {
        const self = this;
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = 'image/*';

        input.addEventListener('change', function() {
            if (this.files && this.files[0]) {
                self.uploadImage(this.files[0]);
            }
        });

        input.click();
    },

    /**
     * Bild hochladen
     */
    uploadImage: function(file) {
        const self = this;

        if (!file.type.startsWith('image/')) {
            alert('Bitte nur Bilder hochladen');
            return;
        }

        // Max 10MB
        if (file.size > 10 * 1024 * 1024) {
            alert(window.onitText('Datei zu groß (max. 10MB)'));
            return;
        }

        const formData = new FormData();
        formData.append('file', file);
        formData.append('_csrf', this.options.csrfToken);

        // Placeholder anzeigen
        const placeholder = document.createElement('span');
        placeholder.className = 'image-uploading';
        placeholder.textContent = 'Bild wird hochgeladen...';
        this.insertNodeAtCursor(placeholder);

        fetch(this.options.uploadUrl, {
            method: 'POST',
            body: formData
        })
        .then(function(response) {
            return response.json();
        })
        .then(function(data) {
            if (data.success && data.url) {
                const img = document.createElement('img');
                img.src = data.url;
                img.alt = file.name;
                placeholder.replaceWith(img);
                self.updateContent();
            } else {
                placeholder.remove();
                alert(data.error || 'Upload fehlgeschlagen');
            }
        })
        .catch(function() {
            placeholder.remove();
            alert('Upload fehlgeschlagen');
        });
    },

    /**
     * Paste-Handler
     */
    handlePaste: function(e) {
        const self = this;
        const items = e.clipboardData?.items;

        if (items) {
            for (let i = 0; i < items.length; i++) {
                if (items[i].type.indexOf('image') !== -1) {
                    e.preventDefault();
                    const file = items[i].getAsFile();
                    this.uploadImage(file);
                    return;
                }
            }
        }

        // HTML-Paste bereinigen
        if (e.clipboardData?.types.includes('text/html')) {
            e.preventDefault();
            const html = e.clipboardData.getData('text/html');
            const cleanHtml = this.sanitizeHtml(html);
            document.execCommand('insertHTML', false, cleanHtml);
        }
    },

    /**
     * Drop-Handler
     */
    handleDrop: function(e) {
        e.preventDefault();

        const files = e.dataTransfer?.files;
        if (files && files.length > 0) {
            for (let i = 0; i < files.length; i++) {
                if (files[i].type.startsWith('image/')) {
                    this.uploadImage(files[i]);
                }
            }
        }
    },

    /**
     * Keyboard-Handler
     */
    handleKeydown: function(e) {
        // Tab in Code-Block
        if (e.key === 'Tab') {
            const selection = window.getSelection();
            const node = selection.anchorNode?.parentElement;
            if (node && (node.tagName === 'CODE' || node.closest('pre'))) {
                e.preventDefault();
                document.execCommand('insertText', false, '    ');
            }
        }

        // Ctrl+K für Link
        if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
            e.preventDefault();
            this.insertLink();
        }
    },

    /**
     * Tabellen-Dialog
     */
    showTableDialog: function() {
        const rows = prompt('Anzahl Zeilen:', '3');
        const cols = prompt('Anzahl Spalten:', '3');

        if (rows && cols) {
            this.insertTable(parseInt(rows), parseInt(cols));
        }
    },

    /**
     * Tabelle einfügen
     */
    insertTable: function(rows, cols) {
        let html = '<table><thead><tr>';

        for (let c = 0; c < cols; c++) {
            html += '<th>Spalte ' + (c + 1) + '</th>';
        }
        html += '</tr></thead><tbody>';

        for (let r = 0; r < rows; r++) {
            html += '<tr>';
            for (let c = 0; c < cols; c++) {
                html += '<td>&nbsp;</td>';
            }
            html += '</tr>';
        }

        html += '</tbody></table><p>&nbsp;</p>';

        document.execCommand('insertHTML', false, html);
        this.updateContent();
    },

    /**
     * Code-Block einfügen
     */
    insertCodeBlock: function() {
        const language = prompt('Programmiersprache (optional):', '');
        const html = '<pre><code class="language-' + (language || 'plaintext') + '">// Code hier eingeben</code></pre><p>&nbsp;</p>';
        document.execCommand('insertHTML', false, html);
        this.updateContent();
    },

    /**
     * Callout-Box einfügen
     */
    insertCallout: function(type) {
        const titles = {
            info: 'Hinweis',
            warning: 'Warnung',
            danger: 'Achtung',
            success: 'Erfolg'
        };

        const html = '<div class="callout callout-' + type + '">' +
            '<div class="callout-title">' + titles[type] + '</div>' +
            '<p>Text hier eingeben...</p>' +
            '</div><p>&nbsp;</p>';

        document.execCommand('insertHTML', false, html);
        this.updateContent();
    },

    /**
     * Selection in Element wrappen
     */
    wrapSelection: function(tagName) {
        const selection = window.getSelection();
        if (selection.rangeCount > 0) {
            const range = selection.getRangeAt(0);
            const element = document.createElement(tagName);
            range.surroundContents(element);
            this.updateContent();
        }
    },

    /**
     * Node an Cursor einfügen
     */
    insertNodeAtCursor: function(node) {
        const selection = window.getSelection();
        if (selection.rangeCount > 0) {
            const range = selection.getRangeAt(0);
            range.deleteContents();
            range.insertNode(node);
            range.setStartAfter(node);
            range.collapse(true);
            selection.removeAllRanges();
            selection.addRange(range);
        }
    },

    /**
     * HTML bereinigen
     */
    sanitizeHtml: function(html) {
        // Einfache Bereinigung - Server macht die eigentliche Arbeit
        const div = document.createElement('div');
        div.innerHTML = html;

        // Script-Tags entfernen
        div.querySelectorAll('script, style, link, meta').forEach(function(el) {
            el.remove();
        });

        // Event-Handler entfernen
        div.querySelectorAll('*').forEach(function(el) {
            Array.from(el.attributes).forEach(function(attr) {
                if (attr.name.startsWith('on') || attr.name === 'style') {
                    el.removeAttribute(attr.name);
                }
            });
        });

        return div.innerHTML;
    },

    /**
     * Inhalt aktualisieren
     */
    updateContent: function() {
        if (this.hiddenInput) {
            this.hiddenInput.value = this.editor.innerHTML;
        }

        if (typeof this.options.onChange === 'function') {
            this.options.onChange(this.getContent());
        }
    },

    /**
     * Inhalt abrufen
     */
    getContent: function() {
        return this.editor.innerHTML;
    },

    /**
     * Inhalt setzen
     */
    setContent: function(html) {
        this.editor.innerHTML = html;
        this.updateContent();
    },

    /**
     * Text abrufen
     */
    getText: function() {
        return this.editor.textContent;
    },

    /**
     * Fokus setzen
     */
    focus: function() {
        this.editor.focus();
    },

    // ── KI-Textverbesserung ─────────────────────────────────────────────────

    /**
     * KI-Button und Popup zur Toolbar hinzufügen
     */
    initAiButton: function() {
        var self = this;

        // Toolbar-Gruppe mit Button
        var group = document.createElement('div');
        group.className = 'toolbar-group';
        var btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'toolbar-btn toolbar-btn-ai';
        btn.title = 'Text mit KI verbessern';
        btn.innerHTML = '&#10024; KI';
        group.appendChild(btn);
        this.toolbar.appendChild(group);

        // Popup erstellen und an body hängen (fixed, damit overflow:hidden nicht stört)
        this.aiPopup = this._createAiPopup();
        document.body.appendChild(this.aiPopup);
        this.savedRange = null;
        this.savedIsFullContent = false;

        btn.addEventListener('click', function(e) {
            e.stopPropagation();
            var sel = window.getSelection();
            if (sel && sel.rangeCount > 0 && sel.toString().trim() !== '') {
                self.savedRange = sel.getRangeAt(0).cloneRange();
                self.savedIsFullContent = false;
            } else {
                self.savedRange = null;
                self.savedIsFullContent = true;
            }
            var visible = self.aiPopup.style.display !== 'none' && self.aiPopup.style.display !== '';
            if (visible) {
                self.aiPopup.style.display = 'none';
            } else {
                // Position berechnen: rechts neben / unterhalb des Buttons, immer sichtbar
                var rect = btn.getBoundingClientRect();
                var popupW = 330;
                var margin = 8;
                var left = rect.right - popupW;
                if (left < margin) left = margin;
                var top = rect.bottom + margin;
                // Popup-Höhe schätzen (~340px); wenn zu weit unten → über Button öffnen
                if (top + 340 > window.innerHeight - margin) {
                    top = Math.max(margin, rect.top - 340 - margin);
                }
                self.aiPopup.style.left = left + 'px';
                self.aiPopup.style.top  = top  + 'px';
                self.aiPopup.style.display = 'block';
                self.aiPopup.querySelector('.ai-instruction-input').focus();
            }
        });

        // Klick außerhalb schließt Popup
        document.addEventListener('click', function(e) {
            if (self.aiPopup && !self.aiPopup.contains(e.target) && e.target !== btn) {
                self.aiPopup.style.display = 'none';
            }
        });
    },

    /**
     * Popup-Element aufbauen
     */
    _createAiPopup: function() {
        var self = this;
        var presets = [
            { label: window.onitText('Freundlicher'),      value: 'Schreibe den Text freundlicher und zugänglicher um' },
            { label: window.onitText('Professioneller'),   value: 'Schreibe den Text professioneller und formeller um' },
            { label: window.onitText('Kürzer'),            value: 'Kürze den Text auf das Wesentliche, ohne Informationen zu verlieren' },
            { label: window.onitText('Ausführlicher'),     value: 'Ergänze den Text sinnvoll mit hilfreichen Details' },
            { label: window.onitText('Fehler korrigieren'), value: 'Korrigiere alle Rechtschreib- und Grammatikfehler' },
        ];

        var presetHtml = presets.map(function(p) {
            return '<button type="button" class="ai-preset-btn" data-value="' + p.value + '">' + p.label + '</button>';
        }).join('');

        var popup = document.createElement('div');
        popup.className = 'wysiwyg-ai-popup';
        popup.style.display = 'none';
        popup.innerHTML =
            '<div class="wysiwyg-ai-popup-header">' +
                '<span>&#10024; KI-Textverbesserung</span>' +
                '<button type="button" class="ai-popup-close" title="Schlie&szlig;en">&#10005;</button>' +
            '</div>' +
            '<p class="wysiwyg-ai-hint">Markierten Text oder gesamten Inhalt verbessern:</p>' +
            '<div class="wysiwyg-ai-presets">' + presetHtml + '</div>' +
            '<div class="wysiwyg-ai-input-row">' +
                '<input type="text" class="ai-instruction-input" placeholder="Eigene Anweisung eingeben\u2026" maxlength="300">' +
                '<button type="button" class="ai-apply-btn">Anwenden</button>' +
            '</div>' +
            '<div class="wysiwyg-ai-status" style="display:none"></div>';

        popup.querySelector('.ai-popup-close').addEventListener('click', function() {
            popup.style.display = 'none';
        });

        popup.querySelectorAll('.ai-preset-btn').forEach(function(btn) {
            btn.addEventListener('click', function() {
                popup.querySelector('.ai-instruction-input').value = this.dataset.value;
                self._applyAiImprovement();
            });
        });

        popup.querySelector('.ai-apply-btn').addEventListener('click', function() {
            self._applyAiImprovement();
        });

        popup.querySelector('.ai-instruction-input').addEventListener('keydown', function(e) {
            if (e.key === 'Enter') { e.preventDefault(); self._applyAiImprovement(); }
        });

        // Klick im Popup nicht an document weiterleiten
        popup.addEventListener('click', function(e) { e.stopPropagation(); });

        return popup;
    },

    /**
     * KI-Verbesserung anwenden
     */
    _applyAiImprovement: function() {
        var self = this;
        var instruction = this.aiPopup.querySelector('.ai-instruction-input').value.trim();
        var applyBtn    = this.aiPopup.querySelector('.ai-apply-btn');

        if (!instruction) {
            this._showAiStatus('Bitte eine Anweisung eingeben.', 'error');
            return;
        }

        // Text ermitteln: Selektion oder voller Inhalt
        var text = '';
        if (!this.savedIsFullContent && this.savedRange) {
            text = this.savedRange.toString().trim();
        }
        if (!text) {
            text = this.getText().trim();
            this.savedIsFullContent = true;
        }
        if (!text) {
            this._showAiStatus('Kein Text zum Verbessern vorhanden.', 'error');
            return;
        }

        var csrf = this.options.csrfToken ||
                   ((document.querySelector('input[name="_csrf"]') || {}).value || '');

        applyBtn.disabled = true;
        this._showAiStatus('KI verarbeitet Text\u2026', 'loading');

        var body = 'text=' + encodeURIComponent(text) +
                   '&instruction=' + encodeURIComponent(instruction) +
                   '&_csrf=' + encodeURIComponent(csrf);

        fetch('/api/ai-chat/improve-text', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'X-Requested-With': 'XMLHttpRequest',
            },
            body: body,
        })
        .then(function(r) { return r.json(); })
        .then(function(data) {
            if (data.error) {
                self._showAiStatus(data.error, 'error');
                return;
            }
            var improved = (data.improved || '').trim();
            if (!improved) {
                self._showAiStatus('Keine Antwort erhalten.', 'error');
                return;
            }
            // Ergebnis einfügen
            self.editor.focus();
            if (!self.savedIsFullContent && self.savedRange) {
                var sel = window.getSelection();
                sel.removeAllRanges();
                sel.addRange(self.savedRange);
                document.execCommand('insertText', false, improved);
            } else {
                var html = '<p>' + improved
                    .replace(/&/g, '&amp;')
                    .replace(/</g, '&lt;')
                    .replace(/>/g, '&gt;')
                    .replace(/\n\n+/g, '</p><p>')
                    .replace(/\n/g, '<br>') + '</p>';
                self.setContent(html);
            }
            self._showAiStatus('&#10003; Text erfolgreich verbessert', 'success');
            setTimeout(function() { self.aiPopup.style.display = 'none'; }, 1600);
        })
        .catch(function() {
            self._showAiStatus('Netzwerkfehler \u2013 bitte erneut versuchen.', 'error');
        })
        .finally(function() {
            applyBtn.disabled = false;
        });
    },

    /**
     * Status-Meldung im Popup anzeigen
     */
    _showAiStatus: function(msg, type) {
        var el = this.aiPopup.querySelector('.wysiwyg-ai-status');
        el.innerHTML = msg;
        el.className = 'wysiwyg-ai-status wysiwyg-ai-status--' + (type || 'info');
        el.style.display = 'block';
    }
};

/**
 * Editor initialisieren (Helper)
 */
OpenNIT.initWysiwyg = function(selector, options) {
    const containers = document.querySelectorAll(selector);
    const editors = [];

    containers.forEach(function(container) {
        editors.push(new OpenNIT.WysiwygEditor(container, options));
    });

    return editors.length === 1 ? editors[0] : editors;
};