refactor(quill): Toolbar-Presets als Factory-Funktionen, Resize-Plugin überarbeitet

Toolbar-Presets liefern jetzt frische Arrays (Factory-Pattern), damit
mehrere Editor-Instanzen auf einer Seite sich nicht gegenseitig
beeinflussen. Resize-Modul und CSS angepasst.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 10:00:25 +02:00
parent 8ff44a407e
commit 54d9194d22
3 changed files with 491 additions and 123 deletions

View File

@@ -1,20 +1,59 @@
.ql-img-resize-overlay {
position: absolute;
border: 1px dashed #3C7DD9;
/* Drag-Handle Overlay für Quill Image Resize */
.ql-image-overlay {
box-sizing: border-box;
border: 1px dashed #2684ff;
pointer-events: none;
z-index: 100;
}
.ql-img-resize-handle {
.ql-image-handle {
position: absolute;
bottom: -5px;
right: -5px;
width: 10px;
height: 10px;
background: #3C7DD9;
border: 1px solid #fff;
width: 12px;
height: 12px;
background: #ffffff;
border: 2px solid #2684ff;
border-radius: 2px;
cursor: nwse-resize;
pointer-events: all;
pointer-events: auto;
z-index: 10001;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
}
.ql-image-handle:hover {
background: #2684ff;
}
.ql-image-handle-nw {
left: -6px;
top: -6px;
cursor: nwse-resize;
}
.ql-image-handle-ne {
right: -6px;
top: -6px;
cursor: nesw-resize;
}
.ql-image-handle-sw {
left: -6px;
bottom: -6px;
cursor: nesw-resize;
}
.ql-image-handle-se {
right: -6px;
bottom: -6px;
cursor: nwse-resize;
}
/* Während des Drags Text-Selection global unterdrücken */
.ql-image-overlay.is-dragging,
.ql-image-overlay.is-dragging * {
user-select: none;
-webkit-user-select: none;
}
/* Im Editor selbst soll das Bild visuell ein leichtes Outline bekommen,
wenn es aktiv ist — aber CSS kann nicht aus dem Overlay zurückgreifen,
daher: ein dünner Schatten ist genug; der gestrichelte Rand am Overlay
markiert das aktive Bild klar genug. */

View File

@@ -1,96 +1,363 @@
(function () {
/**
* Quill-Modul: Drag-Resize für Bilder im Editor.
*
* Aktivierung pro Editor-Instanz via:
* modules: { imageResize: true }
*
* Verhalten:
* - Click auf <img> im Editor → 4 Ecken-Handles als position:fixed-Overlay
* - mousedown/touchstart auf einem Handle startet den Drag
* - Drag aktualisiert width-Attribut + Inline-Style mit !important am <img>
* (Inline-Style ist nötig, weil die globale `img { max-width:100% }`-Regel
* im Intranet das HTML-width-Attribut überstimmt — Spezifität 0 verliert
* gegen jede Author-CSS-Regel auf <img>)
* - Aspect Ratio über CSS `height: auto` gehalten
*
* Persistenz-Pipeline:
* 1) ResizableImage-Blot (unten registriert) whitelisted alt/width/height in
* Quills formats/image-Override, damit die Attribute den Quill-Save-Pfad
* (`quill.update`) überleben.
* 2) Pro Quill-Instanz: Clipboard-Matcher für IMG + Monkey-Patch um
* `dangerouslyPasteHTML`. Die reparieren den Lade-Pfad, weil Quill
* 2.0.0-dev.3 beim HTML→Delta-Parse die Attribute trotz Blot droppt.
* Der Monkey-Patch reappliziert width-Attribut UND Inline-!important-Style
* am Editor-DOM (gleiches Pattern wie Live-Drag).
*
* Bewusst klein gehalten. Keine Abhängigkeit zu third-party Resize-Modulen,
* die für Quill 1.3.x geschrieben wurden und in 2.0.0-dev.3 unstet sind.
*/
(function (global) {
'use strict';
if (typeof Quill === 'undefined') return;
function ImageResizeModule(quill, options) {
this.quill = quill;
this.overlay = null;
this.activeImg = null;
this._startX = 0;
this._startW = 0;
this._onImgClick = this._onImgClick.bind(this);
this._onDocClick = this._onDocClick.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onMouseUp = this._onMouseUp.bind(this);
quill.root.addEventListener('click', this._onImgClick);
document.addEventListener('click', this._onDocClick);
if (typeof Quill === 'undefined') {
console.error('[quill_resize] Quill ist nicht geladen.');
return;
}
ImageResizeModule.prototype._onImgClick = function (e) {
if (e.target && e.target.tagName === 'IMG') {
e.stopPropagation();
this._select(e.target);
// ResizableImage-Blot: Quill 2.0.0-dev.3' nativer formats/image strippt
// beim Re-Parse alle Attribute außer src. Dieser Blot whitelisted alt,
// width, height, damit die per Drag gesetzte Größe den Roundtrip überlebt.
//
// Quill 2.x' Blots sind ES6-Klassen — Vererbung MUSS via `class ... extends`
// erfolgen, nicht via Prototype-Chain (Image.apply würde TypeError werfen,
// weil ES6-Classes nicht ohne `new` aufgerufen werden können).
//
// try/catch: Falls Quill in einer Version vorliegt, die diese Override-API
// nicht unterstützt, kippt der Editor nicht — wir loggen und fallen
// stillschweigend auf den nativen Image-Blot zurück.
if (!global.__ResizableImageRegistered) {
try {
var Image = Quill.import('formats/image');
var ATTRS = ['alt', 'width', 'height'];
var ResizableImage = class extends Image {
static create(value) {
var src = value;
var extras = null;
if (value && typeof value === 'object') {
src = value.src || value.url || '';
extras = value;
}
};
ImageResizeModule.prototype._onDocClick = function (e) {
if (this.overlay && e.target !== this.activeImg && !this.overlay.contains(e.target)) {
this._deselect();
var node = super.create(src);
if (extras) {
ATTRS.forEach(function (attr) {
if (extras[attr] !== undefined && extras[attr] !== null && extras[attr] !== '') {
node.setAttribute(attr, extras[attr]);
}
};
ImageResizeModule.prototype._select = function (img) {
this._deselect();
this.activeImg = img;
var overlay = document.createElement('div');
overlay.className = 'ql-img-resize-overlay';
var handle = document.createElement('div');
handle.className = 'ql-img-resize-handle';
overlay.appendChild(handle);
this.quill.root.style.position = 'relative';
this.quill.root.appendChild(overlay);
this.overlay = overlay;
this._reposition();
var self = this;
handle.addEventListener('mousedown', function (e) {
e.preventDefault();
self._startX = e.clientX;
self._startW = self.activeImg.offsetWidth || self.activeImg.getBoundingClientRect().width;
document.addEventListener('mousemove', self._onMouseMove);
document.addEventListener('mouseup', self._onMouseUp);
});
}
return node;
}
static formats(domNode) {
var base = (typeof super.formats === 'function') ? super.formats(domNode) : {};
ATTRS.forEach(function (attr) {
if (domNode.hasAttribute(attr)) {
base[attr] = domNode.getAttribute(attr);
}
});
return base;
}
format(name, value) {
if (ATTRS.indexOf(name) > -1) {
if (value) {
this.domNode.setAttribute(name, value);
} else {
this.domNode.removeAttribute(name);
}
} else {
super.format(name, value);
}
}
};
ImageResizeModule.prototype._reposition = function () {
if (!this.overlay || !this.activeImg) return;
ResizableImage.blotName = 'image';
ResizableImage.tagName = 'IMG';
Quill.register(ResizableImage, true);
global.__ResizableImageRegistered = true;
global.ResizableImage = ResizableImage;
} catch (err) {
console.error('[quill_resize] ResizableImage-Blot konnte nicht registriert werden — fallback auf nativen Image-Blot. Resize-Größe geht beim Re-Edit verloren.', err);
}
}
var MIN_WIDTH = 50;
function ImageResize(quill, options) {
this.quill = quill;
this.editor = quill.root;
this.options = options || {};
this.activeImg = null;
this.overlay = null;
this.dragState = null;
// Methoden binden, damit removeEventListener funktioniert
this.onEditorClick = this.onEditorClick.bind(this);
this.onDocPointerDown = this.onDocPointerDown.bind(this);
this.onReposition = this.onReposition.bind(this);
this.onMove = this.onMove.bind(this);
this.onUp = this.onUp.bind(this);
this.editor.addEventListener('click', this.onEditorClick);
document.addEventListener('mousedown', this.onDocPointerDown, true);
document.addEventListener('touchstart', this.onDocPointerDown, true);
window.addEventListener('resize', this.onReposition);
window.addEventListener('scroll', this.onReposition, true);
// Bei Text-Changes Position anpassen (z.B. wenn Bild verschoben wird)
quill.on('text-change', this.onReposition);
// Quill 2.0.0-dev.3 strippt beim dangerouslyPasteHTML alle <img>-Attribute
// außer src. Reader rendert das gespeicherte HTML direkt — also korrekt;
// aber im Editor ist die Größe nach Re-Open weg. Zwei Schutzschichten:
//
// (a) Clipboard-Matcher: Beim HTML→Delta-Parse die width/height/alt
// als delta.attributes mitführen. Bei korrekt greifendem
// ResizableImage-Blot überlebt das den Render zurück ans DOM.
//
// (b) Monkey-Patch um dangerouslyPasteHTML: Falls (a) in dev.3 nicht
// durchgereicht wird (Embed-format-Hook ignoriert), reapplizieren
// wir die Attribute nach dem Paste direkt am Editor-DOM. Idempotent
// pro Quill-Instanz via Marker-Property.
try {
quill.clipboard.addMatcher('IMG', function (node, delta) {
if (!node || !delta || !delta.ops || !delta.ops.length) return delta;
var op = delta.ops[0];
if (!op || !op.insert) return delta;
var attrs = op.attributes || {};
['alt', 'width', 'height'].forEach(function (a) {
if (node.hasAttribute(a) && attrs[a] == null) {
attrs[a] = node.getAttribute(a);
}
});
if (Object.keys(attrs).length) op.attributes = attrs;
return delta;
});
} catch (e) {
console.warn('[quill_resize] Clipboard-Matcher Setup fehlgeschlagen', e);
}
if (!quill.__pasteHtmlPatched && quill.clipboard
&& typeof quill.clipboard.dangerouslyPasteHTML === 'function') {
var origPaste = quill.clipboard.dangerouslyPasteHTML;
var PASTE_ATTRS = ['alt', 'width', 'height'];
quill.clipboard.dangerouslyPasteHTML = function () {
var args = Array.prototype.slice.call(arguments);
var htmlArg = (typeof args[0] === 'string') ? args[0] : args[1];
var seq = [];
if (typeof htmlArg === 'string' && htmlArg.indexOf('<img') !== -1) {
var tmp = document.createElement('div');
tmp.innerHTML = htmlArg;
Array.prototype.forEach.call(tmp.querySelectorAll('img'), function (img) {
var entry = { src: img.getAttribute('src') || '', attrs: {} };
PASTE_ATTRS.forEach(function (a) {
if (img.hasAttribute(a)) entry.attrs[a] = img.getAttribute(a);
});
seq.push(entry);
});
}
var result = origPaste.apply(this, args);
if (seq.length) {
var domImgs = quill.root.querySelectorAll('img');
var ix = 0;
for (var di = 0; di < domImgs.length && ix < seq.length; di++) {
var dimg = domImgs[di];
var dsrc = dimg.getAttribute('src') || '';
while (ix < seq.length && seq[ix].src !== dsrc) ix++;
if (ix < seq.length && seq[ix].src === dsrc) {
var entryAttrs = seq[ix].attrs;
Object.keys(entryAttrs).forEach(function (k) {
if (!dimg.hasAttribute(k)) {
dimg.setAttribute(k, entryAttrs[k]);
}
});
// Inline-Style mit !important: das HTML-width-Attribut
// hat Spezifität 0 und wird im Intranet von der globalen
// mysyde.css-img-Regel überstimmt (oder von Quills internem
// Embed-Wrapping). Inline-!important gewinnt garantiert.
// Spiegelt das Verhalten von onMove (live-drag) wider.
if (entryAttrs.width) {
dimg.style.setProperty('width', entryAttrs.width + 'px', 'important');
dimg.style.setProperty('height', 'auto', 'important');
}
ix++;
}
}
}
return result;
};
quill.__pasteHtmlPatched = true;
}
}
ImageResize.prototype.onEditorClick = function (e) {
if (e.target && e.target.tagName === 'IMG' && this.editor.contains(e.target)) {
this.attachTo(e.target);
} else {
this.detach();
}
};
ImageResize.prototype.attachTo = function (img) {
if (this.activeImg === img && this.overlay) return;
this.detach();
this.activeImg = img;
this.overlay = this.buildOverlay();
document.body.appendChild(this.overlay);
this.reposition();
};
ImageResize.prototype.buildOverlay = function () {
var self = this;
var box = document.createElement('div');
box.className = 'ql-image-overlay';
['nw', 'ne', 'sw', 'se'].forEach(function (corner) {
var h = document.createElement('div');
h.className = 'ql-image-handle ql-image-handle-' + corner;
h.dataset.corner = corner;
// Corner explizit über Closure binden, nicht über e.currentTarget
// (currentTarget kann nach Listener-Return null sein, je nach Browser).
var startDrag = function (e) { self.onHandleDown(e, corner); };
h.addEventListener('mousedown', startDrag);
h.addEventListener('touchstart', startDrag, { passive: false });
box.appendChild(h);
});
return box;
};
ImageResize.prototype.reposition = function () {
if (!this.activeImg || !this.overlay) return;
var r = this.activeImg.getBoundingClientRect();
var er = this.quill.root.getBoundingClientRect();
this.overlay.style.left = (r.left - er.left + this.quill.root.scrollLeft) + 'px';
this.overlay.style.top = (r.top - er.top + this.quill.root.scrollTop) + 'px';
this.overlay.style.width = r.width + 'px';
this.overlay.style.height = r.height + 'px';
var s = this.overlay.style;
s.position = 'fixed';
s.left = r.left + 'px';
s.top = r.top + 'px';
s.width = r.width + 'px';
s.height = r.height + 'px';
// KEIN pointer-events:none hier — die Handles brauchen Klick-Empfang.
// Der gestrichelte Rand des Overlays liegt im CSS auf
// `.ql-image-overlay` mit pointer-events:none direkt im CSS.
s.zIndex = '10000';
};
ImageResizeModule.prototype._onMouseMove = function (e) {
ImageResize.prototype.onReposition = function () {
this.reposition();
};
ImageResize.prototype.onHandleDown = function (e, corner) {
if (!this.activeImg) return;
var dx = e.clientX - this._startX;
var newW = Math.max(40, this._startW + dx);
this.activeImg.style.width = newW + 'px';
this.activeImg.setAttribute('width', newW);
this._reposition();
e.preventDefault();
e.stopPropagation();
var p = (e.touches && e.touches[0]) ? e.touches[0] : e;
var r = this.activeImg.getBoundingClientRect();
this.dragState = {
startX: p.clientX,
startY: p.clientY,
startW: r.width,
startH: r.height,
corner: corner
};
ImageResizeModule.prototype._onMouseUp = function () {
document.removeEventListener('mousemove', this._onMouseMove);
document.removeEventListener('mouseup', this._onMouseUp);
if (this.quill) this.quill.update();
document.addEventListener('mousemove', this.onMove);
document.addEventListener('mouseup', this.onUp);
document.addEventListener('touchmove', this.onMove, { passive: false });
document.addEventListener('touchend', this.onUp);
document.addEventListener('touchcancel', this.onUp);
};
ImageResizeModule.prototype._deselect = function () {
ImageResize.prototype.onMove = function (e) {
if (!this.dragState || !this.activeImg) return;
if (e.cancelable) e.preventDefault();
var p = (e.touches && e.touches[0]) ? e.touches[0] : e;
var dx = p.clientX - this.dragState.startX;
// Links liegende Handles invertieren das Vorzeichen:
// rechts ziehen → dx positiv → image schrumpft (left handle)
if (this.dragState.corner === 'nw' || this.dragState.corner === 'sw') {
dx = -dx;
}
var editorR = this.editor.getBoundingClientRect();
var maxW = Math.max(MIN_WIDTH, editorR.width - 8);
var newW = Math.max(MIN_WIDTH, Math.min(maxW, this.dragState.startW + dx));
newW = Math.round(newW);
// width sowohl als HTML-Attribut als auch als Inline-Style setzen.
// Inline-Style ist nötig, weil im Intranet eine CSS-Regel mit höherer
// Spezifität als das Attribut existiert (an dieser Stelle nicht zu
// finden ohne Live-DevTools). Inline-Spezifität (1000) gewinnt gegen
// alle nicht-!important CSS-Regeln. setProperty mit 'important' als
// Fallback, falls eine !important-Regel im Spiel ist.
var widthPx = newW + 'px';
this.activeImg.setAttribute('width', String(newW));
this.activeImg.style.setProperty('width', widthPx, 'important');
this.activeImg.style.setProperty('height', 'auto', 'important');
this.activeImg.removeAttribute('height');
this.reposition();
};
ImageResize.prototype.onUp = function () {
document.removeEventListener('mousemove', this.onMove);
document.removeEventListener('mouseup', this.onUp);
document.removeEventListener('touchmove', this.onMove);
document.removeEventListener('touchend', this.onUp);
document.removeEventListener('touchcancel', this.onUp);
if (this.dragState) {
this.dragState = null;
try { this.quill.update('user'); } catch (e) {}
}
};
ImageResize.prototype.onDocPointerDown = function (e) {
if (!this.activeImg) return;
if (this.overlay && this.overlay.contains(e.target)) return;
if (e.target === this.activeImg) return;
this.detach();
};
ImageResize.prototype.detach = function () {
if (this.overlay && this.overlay.parentNode) {
this.overlay.parentNode.removeChild(this.overlay);
}
this.overlay = null;
this.activeImg = null;
this.dragState = null;
};
Quill.register('modules/imageResize', ImageResizeModule);
})();
Quill.register('modules/imageResize', ImageResize);
global.QuillImageResize = ImageResize;
})(window);

View File

@@ -1,46 +1,108 @@
window.QuillToolbarPresets = {
/**
* Geteilte Toolbar-Presets für Quill-Editoren (Wiki, Forum).
*
* Jeder Preset ist eine Factory-Funktion, die ein frisches Array
* liefert. Grund: Quill kann das Modules-Objekt während der Init
* mutieren — bei mehreren Editor-Instanzen auf einer Seite (z.B.
* Wiki mit Sprach-Tabs) würden sie sich sonst gegenseitig
* verändern.
*
* Verwendung pro Editor:
*
* container: window.QuillToolbarPresets.full()
*
* Bewusst nicht enthalten: `code` (inline) — nur `code-block`,
* weil Multi-Line-Code die häufige Nutzung ist.
*/
(function (global) {
'use strict';
global.QuillToolbarPresets = {
// Vollständige Toolbar — Goldstandard für Wiki und Forum
full: function () {
return [
[{ header: [1, 2, 3, 4, 5, 6, false] }],
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ script: 'sub' }, { script: 'super' }],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
[{ align: [] }],
['blockquote', 'code-block'],
['link', 'image'],
['table'],
[
{ list: 'ordered' }, { list: 'bullet' },
{ indent: '-1' }, { indent: '+1' }
],
['link', 'image', 'video'],
['code-block', 'table'],
['clean']
];
},
// Compact-Toolbar — wie full(), aber OHNE Image und Video.
// Genutzt in Modulen, die kein Image-Backend haben (Tasks, Products).
compact: function () {
return [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['table'],
['bold', 'italic', 'underline', 'strike'],
[{ script: 'sub' }, { script: 'super' }],
[{ color: [] }, { background: [] }],
[{ align: [] }],
[
{ list: 'ordered' }, { list: 'bullet' },
{ indent: '-1' }, { indent: '+1' }
],
['link'],
['code-block', 'table'],
['clean']
];
},
// Better-Table-Modul-Konfiguration. Identisch über alle Editoren —
// deutsche Operations-Menu-Labels und der Hintergrundfarben-Picker
// für Tabellenzellen.
betterTableConfig: function () {
return {
operationMenu: {
items: {
insertColumnRight: { text: 'Spalte rechts' },
insertColumnLeft: { text: 'Spalte links' },
insertRowUp: { text: 'Zeile oben' },
insertRowDown: { text: 'Zeile unten' },
mergeCells: { text: 'Zellen verbinden' },
unmergeCells: { text: 'Zellen trennen' },
deleteColumn: { text: 'Spalte löschen' },
insertRowUp: { text: 'Zeile oberhalb einfügen' },
insertRowDown: { text: 'Zeile unterhalb einfügen' },
insertColumnLeft: { text: 'Spalte links einfügen' },
insertColumnRight: { text: 'Spalte rechts einfügen' },
mergeCells: { text: 'Zellen zusammenführen' },
unmergeCells: { text: 'Zellen teilen' },
deleteRow: { text: 'Zeile löschen' },
deleteColumn: { text: 'Spalte löschen' },
deleteTable: { text: 'Tabelle löschen' }
},
color: {
colors: [
'#f8f9fa', '#e9ecef', '#dee2e6', '#ced4da',
'#adb5bd', '#6c757d',
'green', 'red', 'yellow', 'blue',
'white', 'black', 'orange'
],
text: 'Hintergrundfarbe'
}
}
};
}
};
// Vorlage für künftige Erweiterungen — derzeit nicht aktiv:
//
// compact: function () {
// // wie full, aber ohne 'table' — z.B. für Comment-Felder
// var t = this.full();
// var idx = t.findIndex(function (row) { return row.indexOf('table') > -1; });
// if (idx > -1) t[idx] = ['code-block'];
// return t;
// },
//
// minimal: function () {
// return [
// [{ header: [2, 3, false] }],
// ['bold', 'italic', 'underline'],
// [{ list: 'ordered' }, { list: 'bullet' }],
// ['link'],
// ['clean']
// ];
// }
};
})(window);