Files
awo-hamburg-intranet/module/shared_components/quill/quill_resize.js
Moritz Weinmann 54d9194d22 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>
2026-06-23 10:00:25 +02:00

364 lines
15 KiB
JavaScript

/**
* 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') {
console.error('[quill_resize] Quill ist nicht geladen.');
return;
}
// 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;
}
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]);
}
});
}
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);
}
}
};
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 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';
};
ImageResize.prototype.onReposition = function () {
this.reposition();
};
ImageResize.prototype.onHandleDown = function (e, corner) {
if (!this.activeImg) return;
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
};
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);
};
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', ImageResize);
global.QuillImageResize = ImageResize;
})(window);