264 lines
12 KiB
JavaScript
264 lines
12 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
};
|
||
})();
|