9 changed files with 293 additions and 53 deletions
@ -1,39 +1,166 @@
@@ -1,39 +1,166 @@
|
||||
import {Controller} from '@hotwired/stimulus'; |
||||
import { Controller } from '@hotwired/stimulus'; |
||||
import Quill from 'quill'; |
||||
|
||||
import('quill/dist/quill.core.css'); |
||||
import('quill/dist/quill.snow.css'); |
||||
|
||||
|
||||
export default class extends Controller { |
||||
connect() { |
||||
// --- 1) Custom IMG blot that supports alt ---
|
||||
const BlockEmbed = Quill.import('blots/block/embed'); |
||||
class ImageAltBlot extends BlockEmbed { |
||||
static blotName = 'imageAlt'; // if you want to replace default, rename to 'image'
|
||||
static tagName = 'IMG'; |
||||
|
||||
static create(value) { |
||||
const node = super.create(); |
||||
if (typeof value === 'string') { |
||||
node.setAttribute('src', value); |
||||
} else if (value && value.src) { |
||||
node.setAttribute('src', value.src); |
||||
if (value.alt) node.setAttribute('alt', value.alt); |
||||
} |
||||
node.setAttribute('draggable', 'false'); |
||||
return node; |
||||
} |
||||
|
||||
static value(node) { |
||||
return { |
||||
src: node.getAttribute('src') || '', |
||||
alt: node.getAttribute('alt') || '', |
||||
}; |
||||
} |
||||
} |
||||
Quill.register(ImageAltBlot); |
||||
|
||||
// --- 2) Tooltip UI (modeled on Quill's link tooltip) ---
|
||||
const Tooltip = Quill.import('ui/tooltip'); |
||||
class ImageTooltip extends Tooltip { |
||||
constructor(quill, boundsContainer) { |
||||
super(quill, boundsContainer); |
||||
this.root.classList.add('ql-image-tooltip'); |
||||
this.root.innerHTML = [ |
||||
'<span class="ql-tooltip-arrow"></span>', |
||||
'<div class="ql-tooltip-editor">', |
||||
'<input class="ql-image-src" type="text" placeholder="Image URL" />', |
||||
'<input class="ql-image-alt" type="text" placeholder="Alt text" />', |
||||
'<a class="ql-action"></a>', |
||||
'<a class="ql-cancel"></a>', |
||||
'</div>', |
||||
].join(''); |
||||
|
||||
this.srcInput = this.root.querySelector('.ql-image-src'); |
||||
this.altInput = this.root.querySelector('.ql-image-alt'); |
||||
this.action = this.root.querySelector('.ql-action'); |
||||
this.cancel = this.root.querySelector('.ql-cancel'); |
||||
|
||||
this.action.addEventListener('click', () => this.save()); |
||||
this.cancel.addEventListener('click', () => this.hide()); |
||||
|
||||
const keyHandler = (e) => { |
||||
if (e.key === 'Enter') { e.preventDefault(); this.save(); } |
||||
if (e.key === 'Escape') { e.preventDefault(); this.hide(); } |
||||
}; |
||||
this.srcInput.addEventListener('keydown', keyHandler); |
||||
this.altInput.addEventListener('keydown', keyHandler); |
||||
} |
||||
|
||||
edit(prefill = null) { |
||||
const range = this.quill.getSelection(true); |
||||
if (!range) return; |
||||
|
||||
const bounds = this.quill.getBounds(range); |
||||
this.show(); |
||||
this.position(bounds); |
||||
|
||||
connect() { |
||||
const toolbarOptions = [ |
||||
['bold', 'italic', 'strike'], |
||||
['link', 'blockquote', 'code-block', 'image'], |
||||
[{ 'header': 1 }, { 'header': 2 }, { 'header': 3 }], |
||||
[{ list: 'ordered' }, { list: 'bullet' }], |
||||
]; |
||||
|
||||
const options = { |
||||
theme: 'snow', |
||||
modules: { |
||||
toolbar: toolbarOptions, |
||||
} |
||||
this.root.classList.add('ql-editing'); |
||||
this.srcInput.value = prefill?.src || ''; |
||||
this.altInput.value = prefill?.alt || ''; |
||||
this.srcInput.focus(); |
||||
this.srcInput.select(); |
||||
} |
||||
|
||||
hide() { |
||||
this.root.classList.remove('ql-editing'); |
||||
super.hide(); |
||||
} |
||||
|
||||
save() { |
||||
const src = (this.srcInput.value || '').trim(); |
||||
const alt = (this.altInput.value || '').trim(); |
||||
|
||||
// basic safety: allow http(s) or data:image/*
|
||||
if (!src || !/^https?:|^data:image\//i.test(src)) { |
||||
this.srcInput.focus(); |
||||
return; |
||||
} |
||||
|
||||
let quill = new Quill('#editor', options); |
||||
let target = document.querySelector('#editor_content'); |
||||
const range = this.quill.getSelection(true); |
||||
if (!range) return; |
||||
|
||||
// If selection is on existing ImageAlt blot, replace it; otherwise insert new
|
||||
const [blot, blotOffset] = this.quill.getLeaf(range.index); |
||||
const isImageBlot = blot && blot.domNode && blot.domNode.tagName === 'IMG'; |
||||
|
||||
quill.on('text-change', function(delta, oldDelta, source) { |
||||
console.log('Text change!'); |
||||
console.log(delta); |
||||
console.log(oldDelta); |
||||
console.log(source); |
||||
// save as html
|
||||
target.value = quill.root.innerHTML; |
||||
}); |
||||
if (isImageBlot) { |
||||
// delete current, insert new one
|
||||
const idx = range.index - blotOffset; |
||||
this.quill.deleteText(idx, 1, 'user'); |
||||
this.quill.insertEmbed(idx, 'imageAlt', { src, alt }, 'user'); |
||||
this.quill.setSelection(idx + 1, 0, 'user'); |
||||
} else { |
||||
this.quill.insertEmbed(range.index, 'imageAlt', { src, alt }, 'user'); |
||||
this.quill.setSelection(range.index + 1, 0, 'user'); |
||||
} |
||||
this.hide(); |
||||
} |
||||
} |
||||
|
||||
// --- 3) Quill init ---
|
||||
const toolbarOptions = [ |
||||
['bold', 'italic', 'strike'], |
||||
['link', 'blockquote', 'code-block', 'image'], |
||||
[{ header: 1 }, { header: 2 }, { header: 3 }], |
||||
[{ list: 'ordered' }, { list: 'bullet' }], |
||||
]; |
||||
|
||||
const options = { |
||||
theme: 'snow', |
||||
modules: { |
||||
toolbar: toolbarOptions, |
||||
}, |
||||
}; |
||||
|
||||
// Use the element in this controller's scope
|
||||
const editorEl = this.element.querySelector('#editor') || document.querySelector('#editor'); |
||||
const target = this.element.querySelector('#editor_content') || document.querySelector('#editor_content'); |
||||
|
||||
const quill = new Quill(editorEl, options); |
||||
|
||||
// One tooltip instance per editor
|
||||
const imageTooltip = new ImageTooltip(quill, quill.root.parentNode); |
||||
|
||||
// Intercept toolbar 'image' to open our tooltip
|
||||
quill.getModule('toolbar').addHandler('image', () => { |
||||
// If caret is on an IMG, prefill from it
|
||||
const range = quill.getSelection(true); |
||||
let prefill = null; |
||||
if (range) { |
||||
const [blot] = quill.getLeaf(range.index); |
||||
if (blot?.domNode?.tagName === 'IMG') { |
||||
prefill = { |
||||
src: blot.domNode.getAttribute('src') || '', |
||||
alt: blot.domNode.getAttribute('alt') || '', |
||||
}; |
||||
} |
||||
} |
||||
imageTooltip.edit(prefill); |
||||
}); |
||||
|
||||
// Keep your hidden field synced as HTML
|
||||
const sync = () => { if (target) target.value = quill.root.innerHTML; }; |
||||
quill.on('text-change', sync); |
||||
// initialize once
|
||||
sync(); |
||||
} |
||||
} |
||||
|
||||
Loading…
Reference in new issue