/* global Node, HTMLElement */ const TOOLBAR_ACTIONS = [ { command: 'bold', icon: 'B', title: 'Gras' }, { command: 'italic', icon: 'I', title: 'Italique' }, { command: 'underline', icon: 'S', title: 'Souligne' }, { separator: true }, { command: 'formatBlock', value: 'P', icon: 'P', title: 'Paragraphe' }, { separator: true }, { command: 'insertUnorderedList', icon: '•', title: 'Liste a puces' }, { separator: true }, { command: 'removeFormat', icon: '❌', title: 'Supprimer le formatage' }, ] const ALLOWED_TAGS = new Set([ 'p', 'br', 'b', 'strong', 'i', 'em', 'u', 'ul', 'li', ]) const BLOCKED_TAGS = new Set([ 'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'select', 'textarea', 'link', 'meta', 'noscript', 'template', 'svg', 'math', ]) export function sanitizeHtml(html) { const container = document.createElement('div') container.innerHTML = html const fragment = sanitizeNode(container) const wrapper = document.createElement('div') wrapper.appendChild(fragment) return wrapper.innerHTML.trim() } function sanitizeNode(node) { const fragment = document.createDocumentFragment() for (const child of Array.from(node.childNodes)) { if (child.nodeType === Node.TEXT_NODE) { fragment.appendChild(document.createTextNode(child.textContent)) } else if (child.nodeType === Node.ELEMENT_NODE) { const tagName = child.tagName.toLowerCase() if (BLOCKED_TAGS.has(tagName)) { continue } if (ALLOWED_TAGS.has(tagName)) { // createElement produces a bare element — no attributes from source are copied, // which strips onclick, style, class, id, onerror, etc. by design. const el = document.createElement(tagName) el.appendChild(sanitizeNode(child)) fragment.appendChild(el) } else { fragment.appendChild(sanitizeNode(child)) } } } return fragment } export class ETicketEditor extends HTMLElement { connectedCallback() { const textarea = this.querySelector('textarea') if (!textarea) { return } this._textarea = textarea textarea.style.display = 'none' this._buildToolbar() this._buildContentArea() if (textarea.value) { this._content.innerHTML = textarea.value } this._content.addEventListener('input', () => this._sync()) this._content.addEventListener('keydown', (e) => this._onKeydown(e)) } _buildToolbar() { const toolbar = document.createElement('div') toolbar.classList.add('ete-toolbar') for (const action of TOOLBAR_ACTIONS) { if (action.separator) { const sep = document.createElement('span') sep.classList.add('ete-separator') toolbar.appendChild(sep) continue } const btn = document.createElement('button') btn.type = 'button' btn.classList.add('ete-btn') btn.innerHTML = action.icon btn.title = action.title btn.setAttribute('aria-label', action.title) btn.tabIndex = 0 btn.addEventListener('mousedown', (e) => { e.preventDefault() this._exec(action) }) toolbar.appendChild(btn) } this.insertBefore(toolbar, this._textarea) } _buildContentArea() { const content = document.createElement('div') content.classList.add('ete-content') content.setAttribute('contenteditable', 'true') content.setAttribute('role', 'textbox') content.setAttribute('aria-multiline', 'true') const placeholder = this._textarea.getAttribute('placeholder') if (placeholder) { content.dataset.placeholder = placeholder } this.insertBefore(content, this._textarea) this._content = content } _exec(action) { if (action.value) { document.execCommand(action.command, false, action.value) } else { document.execCommand(action.command, false, null) } this._sync() this._content.focus() } _onKeydown(e) { if (e.key === 'Tab') { e.preventDefault() } } _sync() { const html = sanitizeHtml(this._content.innerHTML) this._textarea.value = html } getHtml() { return this._textarea.value } } export function registerEditor() { if (!globalThis.customElements.get('e-ticket-editor')) { globalThis.customElements.define('e-ticket-editor', ETicketEditor) } }