Files
deskhub/DeskHub-Webseite/assets/js/richtext.js
T
2026-11-04 07:56:07 +00:00

264 lines
12 KiB
JavaScript
Raw 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.
/**
* Verwandelt ein <textarea data-richtext> in einen einfachen Rich-Text-Editor
* (Fett/Kursiv/Unterstrichen/Listen/Link) ohne externe Bibliotheken. Der HTML-Inhalt
* wird laufend in das ursprüngliche (versteckte) Textfeld zurückgeschrieben, damit
* normale Formular-Submits unverändert funktionieren. Serverseitig wird der Wert
* zusätzlich über sanitize_staff_html() bereinigt – dieses Skript ist nur für Komfort,
* keine Sicherheitsgrenze.
*/
(function () {
/**
* Bilder im Editor skalieren: Klick auf ein Bild zeigt einen Rahmen mit Anfasser (ziehen mit Maus oder Finger) und Schnellwahl
* 25 / 50 / 75 % und Original. Chrome und Edge bieten das bei contenteditable nicht von selbst, Firefox hat eigene Anfasser,
* die hier abgeschaltet werden, damit es nur eine Bedienung gibt. Die Breite wird als Attribut und als Stil gesetzt
* (E-Mail-Programme wie Outlook beachten das Attribut), die Höhe folgt automatisch.
*/
window.attachImageResize = function (editable) {
if (!editable || editable.dataset.imgResize) return;
editable.dataset.imgResize = '1';
try { document.execCommand('enableObjectResizing', false, false); } catch (e) { /* nicht überall verfügbar */ }
let current = null;
const box = document.createElement('div');
box.style.cssText = 'position:absolute;display:none;z-index:9999;border:2px solid #0f6f6a;box-sizing:border-box;pointer-events:none';
const bar = document.createElement('div');
bar.style.cssText = 'position:absolute;left:-2px;top:-40px;display:flex;gap:4px;align-items:center;pointer-events:auto;background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:3px;box-shadow:0 2px 8px rgba(0,0,0,.18);white-space:nowrap';
const info = document.createElement('span');
info.style.cssText = 'font:12px system-ui,sans-serif;color:#475569;padding:0 6px';
const handle = document.createElement('div');
handle.title = 'Ziehen zum Skalieren';
handle.style.cssText = 'position:absolute;right:-9px;bottom:-9px;width:16px;height:16px;background:#0f6f6a;border:2px solid #fff;border-radius:50%;cursor:nwse-resize;pointer-events:auto;touch-action:none;box-shadow:0 1px 4px rgba(0,0,0,.3)';
box.appendChild(bar);
box.appendChild(handle);
document.body.appendChild(box);
function changed() { editable.dispatchEvent(new Event('input', { bubbles: true })); }
function maxWidth() { return Math.max(40, editable.clientWidth - 24); }
function place() {
if (!current || !document.body.contains(current)) { hide(); return; }
const r = current.getBoundingClientRect();
box.style.display = 'block';
box.style.left = (r.left + window.scrollX) + 'px';
box.style.top = (r.top + window.scrollY) + 'px';
box.style.width = r.width + 'px';
box.style.height = r.height + 'px';
bar.style.top = r.top + window.scrollY > 44 ? '-40px' : (r.height + 8) + 'px'; // am Seitenanfang unter dem Bild statt darüber
info.textContent = Math.round(r.width) + ' px';
}
function hide() { current = null; box.style.display = 'none'; }
function setWidth(w) {
if (!current) return;
w = Math.max(24, Math.min(Math.round(w), maxWidth()));
current.style.width = w + 'px';
current.style.height = 'auto';
current.style.maxWidth = '100%';
current.setAttribute('width', String(w));
current.removeAttribute('height');
place();
changed();
}
[['25 %', 0.25], ['50 %', 0.5], ['75 %', 0.75], ['Original', 1]].forEach(function (p) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = p[0];
b.style.cssText = 'font:12px system-ui,sans-serif;padding:3px 8px;border:1px solid #cbd5e1;border-radius:4px;background:#f8fafc;cursor:pointer';
b.addEventListener('mousedown', function (e) { e.preventDefault(); });
b.addEventListener('click', function () { if (current) setWidth((current.naturalWidth || current.width) * p[1]); });
bar.appendChild(b);
});
bar.appendChild(info);
handle.addEventListener('pointerdown', function (e) {
if (!current) return;
e.preventDefault();
handle.setPointerCapture(e.pointerId);
const startX = e.clientX, startW = current.getBoundingClientRect().width;
function move(ev) { setWidth(startW + (ev.clientX - startX)); }
function up() { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); handle.removeEventListener('pointercancel', up); }
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
});
editable.addEventListener('click', function (e) {
if (e.target && e.target.tagName === 'IMG') { current = e.target; place(); } else { hide(); }
});
document.addEventListener('mousedown', function (e) {
if (current && e.target !== current && !box.contains(e.target)) hide();
});
editable.addEventListener('keyup', function () { if (current) place(); });
editable.addEventListener('scroll', place);
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
};
})();
(function () {
function initRichText(textarea) {
if (!textarea || textarea.dataset.richtextReady) return;
textarea.dataset.richtextReady = '1';
const wrap = document.createElement('div');
wrap.className = 'richtext-wrap';
const toolbar = document.createElement('div');
toolbar.className = 'richtext-toolbar';
const editable = document.createElement('div');
editable.className = 'richtext-editable';
editable.contentEditable = 'true';
editable.innerHTML = textarea.value;
const buttons = [
['Fett', 'bold', 'B'],
['Kursiv', 'italic', 'I'],
['Unterstrichen', 'underline', 'U'],
['Aufzählung', 'insertUnorderedList', '•'],
['Nummerierte Liste', 'insertOrderedList', '1.'],
];
buttons.forEach(([title, cmd, label]) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.title = title;
btn.textContent = label;
btn.addEventListener('click', () => {
editable.focus();
document.execCommand(cmd, false, null);
textarea.value = editable.innerHTML;
});
toolbar.appendChild(btn);
});
const linkBtn = document.createElement('button');
linkBtn.type = 'button';
linkBtn.title = 'Link einfügen';
linkBtn.textContent = '🔗';
linkBtn.addEventListener('click', () => {
const url = window.prompt('Link-Adresse (https://...)');
if (url) {
editable.focus();
document.execCommand('createLink', false, url);
textarea.value = editable.innerHTML;
}
});
toolbar.appendChild(linkBtn);
// Bild einfügen: nur sichtbar, wenn ein Upload-Endpunkt angegeben ist
// (per data-image-upload-url am textarea, aktuell für Signaturen genutzt).
const uploadUrl = textarea.dataset.imageUploadUrl;
if (uploadUrl) {
const imgBtn = document.createElement('button');
imgBtn.type = 'button';
imgBtn.title = 'Bild einfügen';
imgBtn.textContent = '🖼️';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/png,image/jpeg,image/gif';
fileInput.style.display = 'none';
fileInput.addEventListener('change', () => {
const file = fileInput.files && fileInput.files[0];
fileInput.value = '';
if (!file) return;
const metaToken = document.querySelector('meta[name="csrf"]');
const csrfToken = metaToken ? metaToken.content : '';
const formData = new FormData();
formData.append('image', file);
formData.append('csrf_token', csrfToken);
imgBtn.disabled = true;
imgBtn.textContent = '⏳';
fetch(uploadUrl, { method: 'POST', body: formData })
.then((r) => r.json())
.then((data) => {
if (data.error) {
window.alert(data.error);
return;
}
// Rotierten CSRF-Token überall auf der Seite aktualisieren, sonst schlägt
// das nachfolgende Absenden des umgebenden Formulars fehl.
if (data.csrfToken) {
document.querySelectorAll('input[name="csrf_token"]').forEach((el) => { el.value = data.csrfToken; });
if (metaToken) metaToken.content = data.csrfToken;
}
editable.focus();
document.execCommand('insertHTML', false, '<img src="' + data.url + '" alt="" style="max-width:100%">');
textarea.value = editable.innerHTML;
})
.catch(() => window.alert('Der Bild-Upload ist fehlgeschlagen.'))
.finally(() => {
imgBtn.disabled = false;
imgBtn.textContent = '🖼️';
});
});
imgBtn.addEventListener('click', () => fileInput.click());
toolbar.appendChild(imgBtn);
toolbar.appendChild(fileInput);
// Bild aus der Zwischenablage (Strg + V, zum Beispiel ein Screenshot) oder per Ziehen in das Feld: derselbe Weg wie der Knopf.
// Enthält die Zwischenablage auch Text (Kopie aus Word, Webseite), bleibt das normale Einfügen unverändert.
const imageFrom = (list) => Array.from(list || []).find((f) => /^image\/(png|jpe?g|gif)$/i.test(f.type));
const uploadPicked = (file) => {
const ext = file.type.toLowerCase() === 'image/jpeg' ? 'jpg' : file.type.split('/')[1].toLowerCase();
const named = /\.(png|jpe?g|gif)$/i.test(file.name || '') ? file : new File([file], 'screenshot-' + Date.now() + '.' + ext, { type: file.type });
const dt = new DataTransfer();
dt.items.add(named);
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event('change'));
};
editable.addEventListener('paste', (e) => {
const cd = e.clipboardData;
if (!cd || Array.from(cd.types || []).some((t) => t === 'text/plain' || t === 'text/html')) return;
const img = imageFrom(cd.files);
if (!img) return;
e.preventDefault();
uploadPicked(img);
});
editable.addEventListener('dragover', (e) => { if (Array.from((e.dataTransfer && e.dataTransfer.types) || []).includes('Files')) e.preventDefault(); });
editable.addEventListener('drop', (e) => {
const img = imageFrom(e.dataTransfer && e.dataTransfer.files);
if (!img) return;
e.preventDefault();
uploadPicked(img);
});
}
editable.addEventListener('input', () => { textarea.value = editable.innerHTML; });
editable.addEventListener('blur', () => { textarea.value = editable.innerHTML; });
const form = textarea.closest('form');
if (form) {
form.addEventListener('submit', () => { textarea.value = editable.innerHTML; });
}
textarea.style.display = 'none';
textarea.parentNode.insertBefore(wrap, textarea);
wrap.appendChild(toolbar);
wrap.appendChild(editable);
wrap.appendChild(textarea);
if (window.attachImageResize) window.attachImageResize(editable);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('textarea[data-richtext]').forEach(initRichText);
});
// Hilfsfunktion für andere Skripte (z.B. Textbaustein-Auswahl), um Inhalt in ein
// ggf. durch initRichText() ersetztes Feld einzufügen – funktioniert mit und ohne Rich-Text-Editor.
window.insertIntoRichField = function (textareaId, html) {
const textarea = document.getElementById(textareaId);
if (!textarea) return;
const editable = textarea.previousElementSibling;
if (editable && editable.classList && editable.classList.contains('richtext-editable')) {
editable.innerHTML += (editable.innerHTML && editable.innerHTML !== '<br>' ? '<br><br>' : '') + html;
textarea.value = editable.innerHTML;
} else {
textarea.value += (textarea.value ? '\n\n' : '') + html;
}
};
})();