class HtmlEditor extends EicComponent { constructor(el, context, options = {}) { super(); this.el = el Object.assign(this, app.helpers.activeAttributes) Object.assign(this, app.helpers.basicDialogs) this.components = []; this.context = context; this.externalHandlers = options.externalHandlers || {}; this.images = options.images || []; this.tokens = options.tokens || []; this.tplStatus = options.templateStatus || []; this.buttonMap = this.buttonMap || { subject: ['heUndo','heToken'], header: ['heUndo', 'heLink', 'heTextAlignment', 'heToogleStyle', 'heChangeFontSize', 'heColorText', 'heImage', 'heToken'], main: ['heUndo', 'heTable', 'heAddTable', 'heLink', 'heList', 'heTextAlignment','heToogleStyle', 'heChangeFontSize', 'heColorText', 'heImage', 'heToken'], footer: ['heUndo', 'heLink', 'heTextAlignment', 'heToogleStyle', 'heChangeFontSize', 'heColorText', 'heImage', 'heToken'], altText: ['heUndo', 'heToken'] }; this.initialize(); this.undoStack = []; this.redoStack = []; this.isUndoRedo = false; this.boldTextButton = null; this.lastModifiedSpan = null; this.linkUrlInput = null; this.spaceBefore = document.createTextNode('\u00A0'); this.spaceAfter = document.createTextNode('\u00A0'); if (HtmlEditor.instance) { return HtmlEditor.instance; } HtmlEditor.instance = this; this.injectStyles(); } initialize() { const loadingMessage = this.el.querySelector('.tpl-loadingTools'); const toolButtonsContainer = this.el.querySelector('.tpl-toolButtons'); // Afficher "Loading..." et cacher le menu loadingMessage.style.display = 'block'; toolButtonsContainer.style.display = 'none'; this.buildToolButtons('subject'); setTimeout(() => { this.setCursorToField('subject'); }, 0); // Attendre le chargement des boutons puis afficher le menu et masquer "Loading..." setTimeout(() => { loadingMessage.style.display = 'none'; toolButtonsContainer.style.display = 'block'; }, 500); // Ajuste le délai si nécessaire this.components = Array.from(toolButtonsContainer.querySelectorAll('.tool-button')).map(button => ({ el: button })); this.initEditorEvents(); //*sanitize copy/paste ['.template-field.subject','.template-field.header', '.template-field.main', '.template-field.footer', '.template-field.altText'].forEach(selector => { const editor = this.el.querySelector(selector); editor.contentEditable = 'true'; editor.addEventListener('paste', (event) => this.handlePaste(event)); editor.addEventListener('input', (event) => this.saveState(event)); }); } buildToolButtons(fieldType) { const availableButtons = this.buttonMap[fieldType] || [] const toolButtonsContainer = this.el.querySelector('.tpl-toolButtons') this.setCursorToField(fieldType); toolButtonsContainer.innerHTML = ''; // Init buttons //Manage tools buttons if Status != Submitted const isSubmitted = this.tplStatus === 'submitted' ? 'disabled' : ''; const buttonTemplates = { // Add buttons by field permissions heUndo: ` `,heTable: `
`, heLink: ` `, heList: ` `, heTextAlignment: ` `, heToogleStyle: ` `, heChangeFontSize:` `, heColorText: ` `,heImage: ` `, heToken: ` ` }; // Gen. HTML buttons toolButtonsContainer.innerHTML = availableButtons.map(btn => buttonTemplates[btn] || '').join('') this.attachButtonTriggers(toolButtonsContainer) this.toggleButtonsByFieldType(fieldType) } setCursorToField(fieldType) { const field = this.el.querySelector(`.template-field.${fieldType}`); if (field && field.contentEditable !== "false") { field.focus(); } } /* LISTENER */ initEditorEvents() { const triggers = this.el.querySelectorAll('[data-trigger]'); triggers.forEach(element => { const triggerMethods = element.dataset.trigger.split(',').map(method => method.trim()); element.addEventListener('click', () => { const changeValue = element.dataset.value || null; const numericChange = (changeValue !== null && !isNaN(changeValue)) ? parseFloat(changeValue) : changeValue; triggerMethods.forEach(triggerMethod => { let method = this[triggerMethod]; // 👇 Fallback vers externalHandlers si la méthode n'existe pas dans la classe if (typeof method !== 'function' && this.externalHandlers) { method = this.externalHandlers[triggerMethod]; } if (typeof method === 'function') { method.call(this, numericChange); // on garde `this` comme contexte } else { console.error(`Method ${triggerMethod} doesn't exist`); } }); }); }); } attachButtonTriggers(container) { const triggers = container.querySelectorAll('[data-trigger]'); triggers.forEach(element => { const triggerMethods = element.dataset.trigger.split(',').map(method => method.trim()); element.addEventListener('click', () => { const changeValue = element.dataset.value || null; let numericChange = isNaN(changeValue) ? changeValue : parseFloat(changeValue); triggerMethods.forEach(triggerMethod => { if (typeof this[triggerMethod] === 'function') { this[triggerMethod](numericChange); } else { console.error(`Method ${triggerMethod} does not exist`); } }); }); }); } toggleButtonsByFieldType(fieldType) { const availableButtons = this.buttonMap[fieldType] || []; availableButtons.forEach(button => { const btnElement = this.el.querySelector(`.tpl-toolButtons .${button}`); if (btnElement) { btnElement.classList.remove('hidden'); } }); } isInsertionAllowed(range) { if (!range || !range.startContainer) return false let parentElement = null if (range.startContainer.nodeType === Node.ELEMENT_NODE) { parentElement = range.startContainer.closest('.template-field') } else if (range.startContainer.nodeType === Node.TEXT_NODE) { parentElement = range.startContainer.parentNode?.closest('.template-field') } return parentElement !== null } saveSelection() { const selection = window.getSelection() if (selection.rangeCount > 0) { this.savedRange = selection.getRangeAt(0).cloneRange() this.selectedText = selection.toString() } } restoreSelection() { if (this.savedRange) { const selection = window.getSelection() selection.removeAllRanges() selection.addRange(this.savedRange) } } saveState() { if (this.isUndoRedo) return const state = { subject: this.el.querySelector('.template-field.subject').innerHTML, header: this.el.querySelector('.template-field.header').innerHTML, main: this.el.querySelector('.template-field.main').innerHTML, footer: this.el.querySelector('.template-field.footer').innerHTML, altText: this.el.querySelector('.template-field.altText').innerHTML }; this.undoStack.push(state) this.redoStack = [] } restoreState(state) { this.el.querySelector('.template-field.subject').innerHTML = state.subject this.el.querySelector('.template-field.header').innerHTML = state.header this.el.querySelector('.template-field.main').innerHTML = state.main this.el.querySelector('.template-field.footer').innerHTML = state.footer this.el.querySelector('.template-field.altText').innerText = state.altText } undo() { if (this.undoStack.length > 1) { this.isUndoRedo = true const currentState = this.undoStack.pop() this.redoStack.push(currentState) const lastState = this.undoStack[this.undoStack.length - 1] this.restoreState(lastState) this.isUndoRedo = false } } redo() { if (this.redoStack.length > 0) { this.isUndoRedo = true const nextState = this.redoStack.pop() this.undoStack.push(nextState) this.restoreState(nextState) this.isUndoRedo = false } } //clear copy/paste content handlePaste(event) { event.preventDefault(); const text = event.clipboardData.getData('text/plain'); const range = window.getSelection().getRangeAt(0); range.deleteContents(); range.insertNode(document.createTextNode(text)); } /* Ensure not alternateTextEditor */ isSelectionInsideAlternateTextEditor() { const alternateTextEditor = this.el.querySelector('.template-field.altText'); const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); const selectedElement = range.commonAncestorContainer; // Check if selected content not in alternateTextEditor return alternateTextEditor && alternateTextEditor.contains(selectedElement); } return false; } /* MANAGE STYLES Bold Underline Italic */ toggleStyle(styleType) { if (this.isSelectionInsideAlternateTextEditor()) return; const selection = window.getSelection(); if (selection.rangeCount > 0) { const range = selection.getRangeAt(0); const selectedText = range.toString(); let isStyled = false; let parentStyledNode = null; let currentNode = range.startContainer; let tagName, styleProperty, styleValue; if (styleType === 'bold') { tagName = 'SPAN'; styleProperty = 'fontWeight'; styleValue = 'bold'; } else if (styleType === 'italic') { tagName = 'EM'; styleProperty = 'fontStyle'; styleValue = 'italic'; } else { tagName = 'U'; styleProperty = 'textDecoration'; styleValue = 'underline'; } if (currentNode.nodeType === Node.TEXT_NODE) { currentNode = currentNode.parentNode; } while (currentNode && currentNode.nodeType !== Node.DOCUMENT_NODE) { if (currentNode.nodeType === Node.ELEMENT_NODE && currentNode.nodeName === tagName) { if (currentNode.style[styleProperty] === styleValue) { isStyled = true; parentStyledNode = currentNode; break; } } currentNode = currentNode.parentNode; } if (isStyled) { const textNode = document.createTextNode(selectedText); if (parentStyledNode) { parentStyledNode.parentNode.replaceChild(textNode, parentStyledNode); selection.removeAllRanges(); selection.addRange(range); } } else { const element = document.createElement(tagName); element.style[styleProperty] = styleValue; range.deleteContents(); range.insertNode(this.spaceAfter); range.insertNode(element); range.insertNode(this.spaceBefore); element.appendChild(document.createTextNode(selectedText)); selection.removeAllRanges(); const newRange = document.createRange(); newRange.selectNodeContents(element); selection.addRange(newRange); this.saveState(); } } } applyTextAlignment(alignment) { if (this.isSelectionInsideAlternateTextEditor()) return; const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0); // Check if the cursor is inside a text node let blockElement = this.findBlockElement(range.startContainer); if (!blockElement) return; const isAligned = blockElement.style.textAlign === alignment; if (isAligned) { this.removeAlignment(blockElement); } else { this.applyAlignment(blockElement, alignment); } this.saveState(); } applyAlignment(blockElement, alignment) { blockElement.style.textAlign = alignment; } removeAlignment(blockElement) { blockElement.style.textAlign = ''; } findBlockElement(node) { while (node && node.nodeType !== Node.DOCUMENT_NODE) { if (node.nodeType === Node.ELEMENT_NODE && (node.nodeName === 'P' || node.nodeName === 'DIV')) { return node; } node = node.parentNode; } return null; } changeFontSize(change) { if (this.isSelectionInsideAlternateTextEditor()) return; const selection = window.getSelection(); let targetSpan = null; if (selection.rangeCount) { const range = selection.getRangeAt(0); const selectedText = range.toString().trim(); if (selectedText.length > 0) { let span = range.startContainer.parentNode; if (span.tagName === 'SPAN' && span.id.startsWith('highlighted-text-')) { targetSpan = span; } else { const uniqueId = `highlighted-text-${Date.now()}`; span = document.createElement('span'); span.id = uniqueId; span.classList.add('highlight'); range.surroundContents(span); targetSpan = span; } this.lastModifiedSpan = targetSpan; } } if (this.lastModifiedSpan) { const currentFontSize = window.getComputedStyle(this.lastModifiedSpan).getPropertyValue('font-size'); const newFontSize = (parseFloat(currentFontSize) + change) + 'px'; this.lastModifiedSpan.style.fontSize = newFontSize; } selection.removeAllRanges(); this.saveState(); } //COLOR TEXT changeColor(color) { const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0); // Ensure alternateTextEditor is a valid node const alternateTextEditor = this.el.querySelector('.template-field.altText'); if (!alternateTextEditor) return; // If the editor element doesn't exist, exit // Check if a node is within the alternate text editor const isNodeWithinEditor = (node) => { while (node) { if (node === alternateTextEditor) return true; node = node.parentNode; } return false; }; if (isNodeWithinEditor(range.commonAncestorContainer)) { return; } if (range.collapsed) return; // Create a span to apply the color const span = document.createElement('span'); span.style.color = color; // Wrap the selected range with the span range.surroundContents(span); this.saveState(); } /*MANAGE TABLE */ showTableInput() { this.saveSelection(); this.tableContainer = this.el.querySelector('.he-tableContainer'); this.buttonRect = null; this.rowsInput = this.tableContainer.querySelector('.he-tableRows'); this.colsInput = this.tableContainer.querySelector('.he-tableCols'); this.borderCheckbox = this.tableContainer.querySelector('.he-addTableBorders'); this.centerCheckbox = this.tableContainer.querySelector('.he-addTableCenter'); const addTableButton = this.el.querySelector('.he-addTable'); const isTableContainerVisible = window.getComputedStyle(this.tableContainer).display !== "none"; // Reset the input values when opening this.rowsInput.value = ''; this.colsInput.value = ''; if (!isTableContainerVisible) { if (addTableButton) { this.buttonRect = addTableButton.getBoundingClientRect(); } // Display the table input under the button this.tableContainer.style.display = 'block'; this.tableContainer.style.position = 'fixed'; this.tableContainer.style.border = '1px solid #ccc'; this.tableContainer.style.padding = '10px'; this.tableContainer.style.background = '#fff'; this.tableContainer.style.zIndex = '999'; const containerWidth = this.tableContainer.offsetWidth; const buttonRect = addTableButton.getBoundingClientRect(); const topPosition = buttonRect.bottom + window.scrollY; const leftPosition = buttonRect.left + window.scrollX - containerWidth; this.tableContainer.style.top = `${topPosition}px`; this.tableContainer.style.left = `${leftPosition}px`; // Update button to "cancel" mode addTableButton.classList.remove("icon-table"); addTableButton.classList.add("icon-cancel"); addTableButton.setAttribute("title", "close"); } else { this.closeTableInput(); } } closeTableInput() { // Hide the table input this.tableContainer.style.display = "none"; // Reset the button back to "add table" mode const addTableButton = this.el.querySelector('.he-addTable'); addTableButton.classList.remove("icon-cancel"); addTableButton.classList.add("icon-table"); addTableButton.setAttribute("title", "add table"); // Reset the button rect this.buttonRect = null; return } createTable() { this.restoreSelection(); const numRows = parseInt(this.rowsInput.value) || 1; const numCols = parseInt(this.colsInput.value) || 1; const hasBorders = this.borderCheckbox.checked; const tabCenter = this.centerCheckbox.checked; // Validate inputs if (numRows < 1 || numCols < 1) { ui.growl.append('Please enter valid numbers for rows and columns.'); return; } // Insert the table this.insertTable(numRows, numCols, hasBorders, tabCenter); this.closeTableInput(); } insertTable(rows, cols, hasBorders, tabCenter) { const selection = window.getSelection(); if (selection.rangeCount === 0) return; const range = selection.getRangeAt(0); if (!this.isInsertionAllowed(range)) { ui.growl.append('select a valid location', 'error'); return; } // Créer l'élément table const tplTable = document.createElement('table'); tplTable.classList.add('tpl-table'); if (hasBorders) { tplTable.classList.add('tpl-table-bordered'); tplTable.style.border = "1px solid #999"; tplTable.style.borderCollapse = "collapse"; } if (tabCenter) { tplTable.style.margin = "auto"; } // Add rows and cols for (let i = 0; i < rows; i++) { const row = document.createElement('tr'); for (let j = 0; j < cols; j++) { const cell = document.createElement('td'); cell.innerHTML = ' '; if (hasBorders) { cell.style.border = "1px solid #999"; tplTable.style.borderCollapse = "collapse"; } row.appendChild(cell); } tplTable.appendChild(row); } // Insérer le tableau à la position du curseur range.deleteContents(); range.insertNode(tplTable); } /*MANAGE COLORS */ showColorPalette() { this.saveSelection(); this.colorPalette = this.el.querySelector('.he-colorPalette'); this.buttonRect = null; const colorTextButton = this.el.querySelector('.he-colorText'); const isPaletteVisible = window.getComputedStyle(this.colorPalette).display !== "none"; if (!isPaletteVisible) { this.buttonRect = colorTextButton.getBoundingClientRect(); this.colorPalette.style.display = "flex"; this.colorPalette.style.gridTemplateColumns = "repeat(auto-fill, minmax(50px, 1fr))"; this.colorPalette.style.position = 'fixed'; this.colorPalette.style.zIndex = '999'; this.colorPalette.style.top = `${this.buttonRect.bottom + window.scrollY}px`; // Vérifie si le sélecteur dépasse la largeur de l'écran const paletteWidth = this.colorPalette.offsetWidth; const windowWidth = window.innerWidth; const defaultLeft = this.buttonRect.left + window.scrollX; if (defaultLeft + paletteWidth > windowWidth) { // Ouvrir vers la gauche this.colorPalette.style.left = `${defaultLeft - paletteWidth}px`; } else { // Ouvrir vers la droite (comportement par défaut) this.colorPalette.style.left = `${defaultLeft}px`; } colorTextButton.classList.remove("icon-format-color"); colorTextButton.classList.add("icon-cancel"); colorTextButton.setAttribute("title", "close"); } else { this.closeColorPalette(); } } closeColorPalette() { this.colorPalette.style.display = "none"; const colorTextButton = this.el.querySelector('.he-colorText'); colorTextButton.classList.remove("icon-cancel"); colorTextButton.classList.add("icon-format-color"); colorTextButton.setAttribute("title", "color text"); this.buttonRect = null; return } applyColorToText(color) { this.restoreSelection(); if (!this.savedRange) { console.error('No selection saved to apply color'); return; } const selection = window.getSelection(); if (!selection.rangeCount) { console.error('No range found in selection'); return; } const range = selection.getRangeAt(0); const selectedTextContent = range.extractContents(); const spanColor = document.createElement('span'); spanColor.style.color = color; spanColor.appendChild(selectedTextContent); if (range) { range.deleteContents(); range.insertNode(this.spaceBefore); range.insertNode(spanColor); range.insertNode(this.spaceAfter); } window.getSelection().removeAllRanges(); this.closeColorPalette(); } /*MANAGE IMAGES SELECTOR*/ async showImageSelector() { this.saveSelection() this.imgSelectContainer = this.el.querySelector('.he-imgSelectContainer') this.selectedImageUrl = ""; this.linkInput = this.el.querySelector('.he-imgUrl') const addImageButton = this.el.querySelector('.he-addImg') const isImgContainerVisible = window.getComputedStyle(this.imgSelectContainer).display !== "none" if (!isImgContainerVisible) { this.selectedImageUrl = "" // Always reload images before showing the selector if (typeof this.context.templates?.getImages === 'function') { this.showLoading(addImageButton) this.images = await this.context.templates.getImages(this.context.templateId) } await this.generateImageSelector() this.imgSelectContainer.style.display = "block" this.imgSelectContainer.style.position = 'fixed' this.imgSelectContainer.style.border = '1px solid #ccc' this.imgSelectContainer.style.width = '20%' this.imgSelectContainer.style.padding = '10px' this.imgSelectContainer.style.background = '#fff' this.imgSelectContainer.style.zIndex = '999' this.imgSelectContainer.style.visibility = 'hidden' const containerWidth = this.imgSelectContainer.offsetWidth const buttonRect = addImageButton.getBoundingClientRect() const topPosition = buttonRect.bottom + window.scrollY const leftPosition = buttonRect.left + window.scrollX - containerWidth this.imgSelectContainer.style.top = `${topPosition}px` this.imgSelectContainer.style.left = `${leftPosition}px` this.imgSelectContainer.style.visibility = 'visible' addImageButton.classList.remove("icon-image") addImageButton.classList.add("icon-cancel") addImageButton.setAttribute("title", "close image selector") this.hideLoading(addImageButton, '') } else { this.closeImageSelector(); } } closeImageSelector() { this.imgSelectContainer.style.display = "none"; const addImgButton = this.el.querySelector('.he-addImg'); if (addImgButton) { addImgButton.classList.remove("icon-cancel"); addImgButton.classList.add("icon-image"); addImgButton.setAttribute("title", "Add a picture"); } } handleImageClick() { this.restoreSelection(); if (!this.selectedImageUrl) { document.innerHTML = `You are about to permanently delete the image "${imgTitle}".
Are you sure?
`, okLabel: 'Delete', severity: 'danger', okPromise: async () => { await this.context.templates.deleteImage(imgId, deleteUrl); } }); if(result) { ui.growl.append("Image deleted !", 'success') // Hide the image item in the selector const imgItem = this.el.querySelector(`.he-imgItem[data-id="${imgId}"]`) if (imgItem) imgItem.style.display = 'none' } else { ui.growl.append("Error deleting Image!", 'error') } } highlightSelectedImage(selectedOption) { const imgItems = this.imgSelectContainer.querySelectorAll('.he-imgItem'); imgItems.forEach(item => { item.style.backgroundColor = ""; }); selectedOption.style.backgroundColor = "#e0e0e0"; } addUrlInputAndButton() { const imgUrlLabel = document.createElement('h5'); imgUrlLabel.setAttribute('for', 'he-imgUrl'); imgUrlLabel.setAttribute('xsmall', ''); imgUrlLabel.setAttribute('primary', ''); imgUrlLabel.textContent = "Optional - Add a link to the image:"; const imgUrlInput = document.createElement('input'); imgUrlInput.setAttribute('eicinput',''); imgUrlInput.setAttribute('small', ''); imgUrlInput.id = 'he-imgUrl'; imgUrlInput.name = 'he-imgUrl'; imgUrlInput.placeholder = 'Enter url or em@il (optional)'; this.linkInput = imgUrlInput; const addButton = document.createElement('button'); addButton.setAttribute('eicbutton', ''); addButton.setAttribute('xsmall', ''); addButton.setAttribute('primary', ''); addButton.className = 'tool-button he-confirmAddImg'; addButton.textContent = 'Add image'; addButton.addEventListener('click', () => this.handleImageClick()); this.imgSelectContainer.appendChild(imgUrlLabel); this.imgSelectContainer.appendChild(imgUrlInput); this.imgSelectContainer.appendChild(addButton); } /*MANAGE LINKS */ showLinkInput() { this.saveSelection(); this.linkContainer = this.el.querySelector('.he-linkContainer'); this.linkUrlInput = this.el.querySelector('.he-linkUrl'); this.linkTextInput = this.el.querySelector('.he-linkText'); this.selectedText = ''; this.selectedRange = null; this.addLinkButton = this.el.querySelector('.he-addLink'); const selection = window.getSelection(); const isLinkContainerVisible = window.getComputedStyle(this.linkContainer).display !== "none"; if (!isLinkContainerVisible) { if (selection.rangeCount > 0 && !selection.isCollapsed) { // Text is selected this.selectedRange = selection.getRangeAt(0); this.selectedText = this.selectedRange.toString(); this.linkTextInput.style.display = 'none'; // Hide the text input since text is already selected } else { // No text selected, show input for link text this.selectedText = ''; this.linkTextInput.style.display = 'block'; // Show the input to enter the link text } const buttonRect = this.addLinkButton.getBoundingClientRect(); this.linkContainer.style.display = 'block'; this.linkContainer.style.position = 'fixed'; this.linkContainer.style.border = '1px solid #ccc'; this.linkContainer.style.padding = '10px'; this.linkContainer.style.background = '#fff'; this.linkContainer.style.zIndex = 1000; // Div on left side of button const containerWidth = this.linkContainer.offsetWidth; this.linkContainer.style.top = `${buttonRect.bottom + window.scrollY}px`; this.linkContainer.style.left = `${buttonRect.left + window.scrollX - containerWidth}px`; this.linkUrlInput.value = ''; this.linkTextInput.value = ''; // Clear the text input in case it was used before this.linkUrlInput.focus(); this.addLinkButton.classList.remove("icon-link"); this.addLinkButton.classList.add("icon-cancel"); this.addLinkButton.setAttribute("title", "close add link"); window.addEventListener('scroll', this.updateLinkContainerPosition.bind(this)); } else { this.closeLinkInput(); } } updateLinkContainerPosition() { const buttonRect = this.addLinkButton.getBoundingClientRect(); const containerWidth = this.linkContainer.offsetWidth; this.linkContainer.style.top = `${buttonRect.bottom + window.scrollY}px`; this.linkContainer.style.left = `${buttonRect.left + window.scrollX - containerWidth}px`; } validateURL(url) { const pattern = new RegExp('^(https?:\\/\\/)?' + '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + '((\\d{1,3}\\.){3}\\d{1,3}))' + '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + '(\\?[;&a-z\\d%_.~+=-]*)?' + '(\\#[-a-z\\d_]*)?$', 'i'); return !!pattern.test(url); } createLink() { this.restoreSelection(); this.linkUrlInput = this.el.querySelector('.he-linkUrl'); let url = this.linkUrlInput.value.trim(); const selection = window.getSelection(); if (selection.rangeCount === 0) return; const range = selection.getRangeAt(0); const text = this.selectedText || this.linkTextInput.value.trim(); // Use selected text if (!url || !text) { ui.growl.append('Both URL and link text are required to create a link', 'error'); return; } // Check if mail adress (mailto:) const isEmail = url.startsWith('mailto:') || /^[\w.-]+@[\w.-]+\.\w+$/.test(url); // Add "mailto:" if e-mail without mailto if (!url.startsWith('http://') && !url.startsWith('https://') && !isEmail) { url = 'http://' + url; } else if (/^[\w.-]+@[\w.-]+\.\w+$/.test(url)) { url = 'mailto:' + url; } // Check URL if (!this.validateURL(url) && !isEmail) { ui.growl.append('Invalid URL or email address', 'error'); return; } if (!this.isInsertionAllowed(range)) { ui.growl.append('Select a valid location', 'error'); return; } const linkElement = document.createElement('a'); linkElement.href = url; linkElement.target = isEmail ? '_self' : '_blank'; // mailto: open in same page linkElement.title = url; linkElement.alt = url; linkElement.textContent = text; if (this.savedRange) { this.savedRange.deleteContents(); this.savedRange.insertNode(linkElement); } else { // Insérer à la position du curseur const range = window.getSelection().getRangeAt(0); range.insertNode(this.spaceBefore); range.insertNode(linkElement); range.insertNode(this.spaceAfter); } window.getSelection().removeAllRanges(); this.closeLinkInput(); } closeLinkInput() { this.linkContainer.style.display = 'none'; this.addLinkButton.classList.remove("icon-cancel"); this.addLinkButton.classList.add("icon-link"); this.addLinkButton.setAttribute("title", "add link"); } /*MANAGE LIST */ showListSelector() { this.saveSelection(); this.listContainer = this.el.querySelector('.he-addListContainer'); this.listItemsInput = this.el.querySelector('.he-listItems'); const addListButton = this.el.querySelector('.he-addList'); const isListContainerVisible = window.getComputedStyle(this.listContainer).display !== "none"; // Reset the input value when opening this.listItemsInput.value = ''; if (!isListContainerVisible) { const buttonRect = addListButton.getBoundingClientRect(); // Display the list selector under the button this.listContainer.style.display = "block"; this.listContainer.style.position = 'fixed'; this.listContainer.style.border = '1px solid #ccc'; this.listContainer.style.padding = '10px'; this.listContainer.style.background = '#fff'; const containerWidth = this.listContainer.offsetWidth; this.listContainer.style.top = `${buttonRect.bottom + window.scrollY}px`; this.listContainer.style.left = `${buttonRect.left + window.scrollX - containerWidth}px`; // Update button to "cancel" mode addListButton.classList.remove("icon-list-ul"); addListButton.classList.add("icon-cancel"); addListButton.setAttribute("title", "close"); } else { this.closeListSelector(); } } closeListSelector() { const listSelectContainer = this.listContainer; const addListButton = this.el.querySelector('.he-addList'); // Hide the list selector listSelectContainer.style.display = "none"; // Reset the button back to "add list" mode addListButton.classList.remove("icon-cancel"); addListButton.classList.add("icon-list-ul"); addListButton.setAttribute("title", "add list items"); // Reset the button rect this.buttonRect = null; } confirmList() { this.restoreSelection(); const listItemsTextarea = this.el.querySelector('.he-listItems'); const listItems = listItemsTextarea.value.trim().split('\n'); if (listItems.length === 0 || (listItems.length === 1 && listItems[0] === '')) { alert("Please enter at least one item for the list."); return; } let listType; if (this.el.querySelector('.he-listTypeBullet').checked) { listType = 'bullet'; } else if (this.el.querySelector('.he-listTypeDash').checked) { listType = 'dash'; } else if (this.el.querySelector('.he-listTypeNumbered').checked) { listType = 'numbered'; } this.insertList(listItems, listType); listItemsTextarea.value = ''; this.closeListSelector(); } getListType() { const bulletType = this.listContainer.querySelector('.he-listTypeBullet').checked; const dashType = this.listContainer.querySelector('.he-listTypeDash').checked; const numberedType = this.listContainer.querySelector('.he-listTypeNumbered').checked; if (bulletType) return 'bullet'; if (dashType) return 'dash'; if (numberedType) return 'numbered'; return 'bullet'; } insertList(items, type) { const selection = window.getSelection(); if (selection.rangeCount === 0) return; const range = selection.getRangeAt(0); if (!this.isInsertionAllowed(range)) { ui.growl.append('select a valid location', 'error'); return; } // Check if insert in authorized fields let editor = range.commonAncestorContainer; while (editor && editor.nodeType !== Node.ELEMENT_NODE) { editor = editor.parentNode; } if (!editor || (!editor.classList.contains('template-field.main'))) { editor = this.el.querySelector('.template-field.main'); } // Create list elements let heList; if (type === 'bullet' || type === 'dash') { heList = document.createElement('ul'); if (type === 'dash') { heList.style.listStyleType = 'none'; } } else if (type === 'numbered') { heList = document.createElement('ol'); } heList.style.marginLeft = '1em'; heList.style.paddingLeft = '1em'; // Add items items.forEach(item => { const li = document.createElement('li'); if (type === 'dash') { li.textContent = `- ${item}`; } else { li.textContent = item; } heList.appendChild(li); }); // Insert list at cursor position if (range) { range.deleteContents(); range.insertNode(heList); } else { editor.appendChild(heList); } } /*MANAGE TOKENS */ showTokenInput() { this.saveSelection(); this.tokenTextInput = this.el.querySelector('.he-tokenText'); this.tokenSelect = this.el.querySelector('.he-tokenSelect'); this.tokenContainer = this.el.querySelector('.he-tokenContainer'); this.addTokenButton = this.el.querySelector('.he-addToken'); this.selectedText = ''; this.savedRange = null; const selection = window.getSelection(); const isTokenContainerVisible = window.getComputedStyle(this.tokenContainer).display !== "none"; if (!isTokenContainerVisible) { if (selection.rangeCount > 0 && !selection.isCollapsed) { // Selected text this.savedRange = selection.getRangeAt(0); this.selectedText = this.savedRange.toString().trim(); this.tokenTextInput.style.display = 'none'; this.tokenSelect.style.display = 'none'; } else { this.populateTokenSelect(); this.selectedText = ''; this.savedRange = selection.getRangeAt(0); this.tokenTextInput.style.display = 'block'; this.tokenSelect.style.display = 'block'; } const buttonRect = this.addTokenButton.getBoundingClientRect(); this.tokenContainer.style.display = 'block'; this.tokenContainer.style.position = 'fixed'; this.tokenContainer.style.border = '1px solid #ccc'; this.tokenContainer.style.padding = '10px'; this.tokenContainer.style.background = '#fff'; this.tokenContainer.style.zIndex = 1000; const containerWidth = this.tokenContainer.offsetWidth; this.tokenContainer.style.top = `${buttonRect.bottom + window.scrollY}px`; this.tokenContainer.style.left = `${buttonRect.left + window.scrollX - containerWidth}px`; this.tokenTextInput.value = ''; this.tokenTextInput.focus(); this.addTokenButton.classList.remove("icon-loop"); this.addTokenButton.classList.add("icon-cancel"); this.addTokenButton.setAttribute("title", "close Add variable"); } else { this.closeTokenInput(); } } async populateTokenSelect() { this.tokenSelect.innerHTML = ""; const defaultOption = document.createElement('option'); defaultOption.value = ''; defaultOption.text = 'Choose a replacement variable'; this.tokenSelect.appendChild(defaultOption); // Add tokens list const tokens = await this.tokens; tokens.forEach(token => { const option = document.createElement('option'); option.value = token.name; option.text = token.name; this.tokenSelect.appendChild(option); }); } createToken() { this.restoreSelection() document.querySelectorAll('.caret-holder').forEach(el => el.remove()) let tokenText = this.selectedText || this.tokenTextInput.value.trim() const selection = window.getSelection() if (selection.rangeCount === 0) return const range = selection.getRangeAt(0) const selectedToken = this.el.querySelector('.he-tokenSelect').value if (!tokenText && selectedToken) { tokenText = selectedToken } if (!tokenText) { ui.growl.append('Required text for variables', 'error') return } if (!this.isInsertionAllowed(range)) { ui.growl.append('select a valid location', 'error') return } let parentTemplateField = null if (range.startContainer.nodeType === Node.ELEMENT_NODE) { parentTemplateField = range.startContainer.closest('.template-field') } else if (range.startContainer.nodeType === Node.TEXT_NODE) { parentTemplateField = range.startContainer.parentNode?.closest('.template-field') } if (!parentTemplateField) { ui.growl.append('Select a valid location !', 'error') return } const tokenElement = document.createElement('span') tokenElement.classList.add('tpl-token') tokenElement.setAttribute('eicchip', '') tokenElement.setAttribute('success', '') tokenElement.setAttribute('data-original', tokenText) tokenElement.textContent = tokenText const caretHolder = document.createElement('span') caretHolder.className = 'caret-holder' caretHolder.contentEditable = 'true' caretHolder.innerHTML = '' const spaceBefore = document.createTextNode('\u00A0') // const spaceAfter = document.createTextNode('\u00A0') //  . if (range) { const fragment = document.createDocumentFragment() fragment.appendChild(spaceBefore) fragment.appendChild(tokenElement) fragment.appendChild(spaceAfter) fragment.appendChild(caretHolder) range.deleteContents() range.insertNode(fragment) } this.tokenInserted = true this.tokenSelect.value = '' this.closeTokenInput() } closeTokenInput() { this.tokenContainer.style.display = 'none' this.addTokenButton.classList.remove("icon-cancel") this.addTokenButton.classList.add("icon-loop") this.addTokenButton.setAttribute("title", "Add content variable") if (this.tokenInserted) { const caretHolder = document.querySelector('.caret-holder') if (caretHolder) { const range = document.createRange() range.setStart(caretHolder, 1) range.collapse(true) const sel = window.getSelection() sel.removeAllRanges() sel.addRange(range) } } else { this.restoreSelection() } this.tokenInserted = false; } // Add style into document head injectStyles() { const style = document.createElement('style'); style.innerHTML = ` .resize-container { position: relative; display: inline-block; } .tpl-resizer { resize: both; overflow: hidden; display: inline-block; max-width: 100%; } .he-imgItem.selected { border: 2px solid blue; background-color: lightgray; } .he-imgItem { transition: background-color 0.3s ease; } .he-imgItem:hover { background-color: #f0f0f0; } .he-color-palette { display: grid; grid-template-columns: repeat(3, 1.5em); gap: 0.5em; position: fixed; border: 1px solid #ccc; padding: 2px; background-color: #fff; z-index: 1000; } .he-colorOption { width: 1.5em; height: 1.5em; cursor: pointer; pointer-events: auto; } .he-colorOption:hover { border: 2px solid #000; } .tpl-table { border-collapse: collapse; } tpl-table td { border: 1px solid #ccc; padding: 10px; position: relative; } .he-listItems{ min-height: 100px; } `; document.head.appendChild(style); } }