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>
364 lines
15 KiB
JavaScript
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);
|