UNPKG

@etsoo/editor

Version:

ETSOO Free WYSIWYG HTML Editor

1,381 lines (1,376 loc) 101 kB
'use strict'; var EOEditorCommand = require('./classes/EOEditorCommand.js'); var EOEditorLabels = require('./classes/EOEditorLabels.js'); var EOButton = require('./components/EOButton.js'); var EOEditorCharacters = require('./classes/EOEditorCharacters.js'); var EOImageEditor = require('./components/EOImageEditor.js'); var shared = require('@etsoo/shared'); var VirtualTable = require('./classes/VirtualTable.js'); var EOEditor$1 = require('./EOEditor.css.js'); const lockClass = "eo-lock"; const template = document.createElement("template"); template.innerHTML = ` <style>${EOEditor$1.default}</style> <eo-tooltip></eo-tooltip> <eo-palette></eo-palette> <eo-popup></eo-popup> <eo-image-editor></eo-image-editor> <div class="container"> <div class="toolbar"></div> <div class="edit-area"><iframe></iframe><textarea></textarea></div> </div> `; const textBoxNextTags = [ "BODY", "P", "TD", "TH", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL" ]; const borderStyles = [ "none", "hidden", "dotted", "dashed", "solid", "double", "groove", "ridge", "inset", "outset" ]; const hhClass = "eo-highlight"; /** * EOEditor * Attributes (strings that are set declaratively on the tag itself or set imperatively using setAttribute) vs Properties * https://lamplightdev.com/blog/2020/04/30/whats-the-difference-between-web-component-attributes-and-properties/ */ class EOEditor extends HTMLElement { /** * Observed attributes */ static get observedAttributes() { return ["name", "commands", "width", "height", "color", "activeColor"]; } /** * Caret keys */ static caretKeys = [ "bold", "italic", "underline", "strikeThrough", "foreColor", "backColor", "removeFormat", "subscript", "superscript", "link", "unlink", "lock" ]; /** * Backup key */ static BackupKey = "EOEditor-Backup"; /** * Lastest characters key */ static LatestCharactersKey = "EOEditor-Latest-Characters"; _backupInitialized = false; /** * Backup initialized */ get backupInitialized() { return this._backupInitialized; } /** * Buttons */ buttons = {}; /** * Image editor */ imageEditor; /** * Popup */ popup; /** * Editor container */ editorContainer; /** * Editor iframe */ editorFrame; _editorWindow; /** * Editor iframe window */ get editorWindow() { return this._editorWindow; } /** * Editor source code textarea */ editorSourceArea; /** * Editor toolbar */ editorToolbar; _labels; /** * Editor labels */ get labels() { return this._labels; } // Color palette palette; // Backup cancel backupCancel; // Selection change cancel selectionChangeCancel; // Form element form; formInput; currentCell = null; lastHighlights; // Categories with custom order // Same order with label specialCharacterCategories characterCategories = [ ["symbols"], ["punctuation"], ["arrows"], ["currency"], ["math"], ["numbers"] ]; _lastClickedButton; /** * Last clicked button */ get lastClickedButton() { return this._lastClickedButton; } /** * Name */ get name() { return this.getAttribute("name"); } set name(value) { if (value) this.setAttribute("name", value); else this.removeAttribute("name"); } /** * Clone styles to editor */ get cloneStyles() { return this.getAttribute("cloneStyles"); } set cloneStyles(value) { if (value) this.setAttribute("cloneStyles", value.toString()); else this.removeAttribute("cloneStyles"); } /** * Commands, a supported kind or commands array */ get commands() { return this.getAttribute("commands"); } set commands(value) { if (value) this.setAttribute("commands", value); else this.removeAttribute("commands"); } _content; /** * Get or set editor's content */ get content() { if (this.hidden) return this._content; let content = this.editorWindow.document.body.innerHTML.trim(); if (content === "") return undefined; // Remove empty style property inside tags content = content.replace(/(<[^<>]+)\s+style\s*=\s*(['"])\2/g, "$1"); // Remove all "<p><br></p>" content = content.replace(/<p><br\/?><\/p>/g, ""); // Remove empty <p> tags content = content.replace(/<p><\/p>/g, "").trim(); // Return empty string if no content if (content === "") return undefined; // Suplement "<p>" for the first one const first = content.search(/<(p|div|h[1-6]|table|section|header|footer|article|nav|main|form|ul|ol|fieldset|blockquote|pre)[^>]*>/); if (first == -1) { content = `<p>${content}</p>`; } else if (first > 0) { const prev = content.substring(0, first); const next = content.substring(first); content = `<p>${prev}</p>${next}`; } // Return return content; } set content(value) { if (this.hidden) { this._content = value; } else { this.setContent(value); } } /** * Get or set editor's value, alias of content */ get value() { return this.content; } set value(value) { this.content = value; } /** * Main color */ get color() { return this.getAttribute("color"); } set color(value) { if (value) this.setAttribute("color", value); else this.removeAttribute("color"); } /** * Active color */ get activeColor() { return this.getAttribute("activeColor"); } set activeColor(value) { if (value) this.setAttribute("activeColor", value); else this.removeAttribute("activeColor"); } /** * Width */ get width() { return this.getAttribute("width"); } set width(value) { if (value) this.setAttribute("width", typeof value === "number" ? `${value}px` : value); else this.removeAttribute("width"); } /** * Height */ get height() { return this.getAttribute("height"); } set height(value) { if (value) this.setAttribute("height", typeof value === "number" ? `${value}px` : value); else this.removeAttribute("height"); } /** * Style with CSS */ get styleWithCSS() { return this.getAttribute("styleWithCSS"); } set styleWithCSS(value) { if (value) this.setAttribute("styleWithCSS", value.toString()); else this.removeAttribute("styleWithCSS"); } /** * Language */ get language() { return this.getAttribute("language"); } set language(value) { if (value) this.setAttribute("language", value); else this.removeAttribute("language"); } /** * Backup distinguish key */ get backupKey() { return this.getAttribute("backupKey"); } set backupKey(value) { if (value) this.setAttribute("backupKey", value); else this.removeAttribute("backupKey"); } /** * Constructor */ constructor() { // always call super() first in the constructor super(); // Attach a shadow root to the element. const shadowRoot = this.attachShadow({ mode: "open" }); shadowRoot.appendChild(template.content.cloneNode(true)); // Nodes this.palette = shadowRoot.querySelector("eo-palette"); this.popup = shadowRoot.querySelector("eo-popup"); this.imageEditor = shadowRoot.querySelector("eo-image-editor"); this.editorContainer = shadowRoot.querySelector(".container"); this.editorToolbar = this.editorContainer.querySelector(".toolbar"); this.editorFrame = this.editorContainer.querySelector("iframe"); this.editorSourceArea = this.editorContainer.querySelector(".edit-area textarea"); } getBackupName() { return `${EOEditor.BackupKey}-${this.name}-${this.backupKey}`; } /** * Backup editor content * @param miliseconds Miliseconds to wait */ backup(miliseconds = 1000) { this.clearBackupSeed(); if (miliseconds < 0) { this.backupAction(); } else { this.backupCancel = shared.ExtendUtils.waitFor(() => this.backupAction(), miliseconds); } } backupAction() { const content = this.content; if (content) { window.localStorage.setItem(this.getBackupName(), content); this.dispatchEvent(new CustomEvent("backup", { detail: content })); } } /** * Clear backup */ clearBackup() { window.localStorage.removeItem(this.getBackupName()); } /** * Get backup */ getBackup() { return window.localStorage.getItem(this.getBackupName()); } setCommands() { const commands = EOEditorCommand.EOEditorCommandsParse(this.commands); const language = this.language ?? window.navigator.language; EOEditorLabels.EOEditorGetLabels(language).then((labels) => { this._labels = labels; this.imageEditor.language = language; this.palette.applyLabel = labels.apply; const buttons = commands .map((c) => { const more = c.subs && c.subs.length > 0; if (!more) return this.createButton(c.name, c.command); const label = c.command.label ?? labels[c.name]; const icon = c.command.icon; return `<button is="eo-button" class="${icon === "" ? "more text" : c.name === "more" ? "" : "more"}" name="${c.name}" tooltip="${label}" data-subs="${c.subs?.join(",")}">${icon === "" ? `<span class="text">${label}</span>` : this.createSVG(c.command.icon)}${c.name === "more" ? "" : '<svg width="16" height="16" viewBox="0 0 24 24" class="more-icon"><path d="M7,10L12,15L17,10H7Z" /></svg>'}</button>`; }) .join(""); this.editorToolbar.innerHTML = buttons; this.setupButtons(this.editorContainer); this.toggleButtons(true); }); } setupButtons(container) { container .querySelectorAll("button") .forEach((button) => { // Button/command name const name = button.name; // Hold button reference this.buttons[name] = button; // Click button.addEventListener("click", (event) => { // Prevent event.preventDefault(); event.stopPropagation(); // Process click this.buttonClick(button, name); }); }); } /** * Delete selection */ delete() { this.editorWindow.document.execCommand("delete"); } /** * Edit image * @param image Image to edit * @param callback Callback when doen */ editImage(image, callback) { this.imageEditor.open(image, callback); } getAllHighlights(container) { if (container == null || "querySelectorAll" in container) return Array.from((container ?? this.editorWindow.document).querySelectorAll(`td.${hhClass}, th.${hhClass}`)); const items = []; const startTd = (container.startContainer.nodeType === Node.ELEMENT_NODE ? container.startContainer : container.startContainer.parentElement)?.closest("td, th"); const endTd = (container.endContainer.nodeType === Node.ELEMENT_NODE ? container.endContainer : container.endContainer.parentElement)?.closest("td, th"); if (startTd && endTd) { if (container.commonAncestorContainer.nodeName === "TR") { items.push(startTd); let nextTd = startTd.nextElementSibling; while (nextTd) { if (nextTd.nodeName === "TD" || nextTd.nodeName === "TH") { items.push(nextTd); } if (nextTd == endTd) break; nextTd = nextTd.nextElementSibling; } } else { items.push(startTd, endTd); } } return items; } /** * Clear highlights */ clearHighlights() { this.getAllHighlights().forEach((td) => td.classList.remove(hhClass)); } /** * Restore focus to the editor iframe */ restoreFocus() { this.editorWindow.document.body.focus(); } buttonClick(button, name) { // Hold the button's states const subs = button.dataset["subs"] ?.split(",") .map((s) => s.trim()); this.updateClickedButton(button, subs); // Hide the popup this.popup.hide(); // Command const command = EOEditorCommand.EOEditorCommands[name]; // Set focus to iframe this.restoreFocus(); // Execute the command const result = command.action ? command.action(this) : this.executeCommand(name); if (result) this.onSelectionChange(); // Later update the backup content this.backup(); } setWidth() { const width = this.width; if (width) { this.style.setProperty("--width", width); } } setHeight() { const height = this.height; if (height) { this.style.setProperty("--height", height); } } setColor() { const color = this.color; if (color) this.style.setProperty("--color", color, "important"); } setContent(value) { this.editorWindow.document.body.innerHTML = value ?? ""; } setActiveColor() { const activeColor = shared.EColor.parse(this.activeColor); if (activeColor) { this.style.setProperty("--color-active", activeColor.toRGBColor(), "important"); this.style.setProperty("--color-hover-bg", activeColor.toRGBColor(0.05), "important"); this.imageEditor.panelColor = activeColor.toRGBColor(0.2); this.style.setProperty("--color-active-bg", activeColor.toRGBColor(0.2), "important"); } } /** * Called every time the element is inserted into the DOM. * Useful for running setup code */ connectedCallback() { // Flag for edit // this.contentEditable = 'true'; // Hide the border when focus // this.style.outline = '0px solid transparent'; this.hidden = true; // Update attributes this.setWidth(); this.setHeight(); this.setColor(); this.setActiveColor(); this.setCommands(); // Fill the form, easier for submit this.form = this.closest("form"); if (this.form) { const input = document.createElement("input"); input.type = "hidden"; input.name = this.name ?? "content"; this.formInput = this.form.appendChild(input); this.form.addEventListener("submit", this.onFormSubmit.bind(this), true); } // Check document readyState const init = () => { if (document.readyState !== "complete") return false; this.initContent(this.editorFrame.contentWindow); return true; }; if (!init()) { document.addEventListener("readystatechange", () => init()); } } closePopups() { this.popup.hide(); this.palette.hide(); } initContent(win) { if (win == null) return; this._editorWindow = win; const doc = win.document; // Cache first let html = this.getBackup(); if (html) { this.content = html; this._backupInitialized = true; } else { html = this.innerHTML.trim(); if (html) { if (shared.Utils.hasHtmlEntity(html) && !shared.Utils.hasHtmlTag(html)) { this.content = this.textContent; } else { this.content = html; } } } this.innerHTML = ""; // Clear the textContent to avoid duplication doc.body.innerHTML = this.content ?? ""; this.content = undefined; // Clear the content if (doc.body.contentEditable !== "true") { // Default styles // :is(td, th) released on 2021, replaced with a secure way // https://developer.mozilla.org/en-US/docs/Web/CSS/:is Promise.resolve().then(function () { return require('./EOEditorArea.css.js'); }).then((areaStyles) => { doc.head.insertAdjacentHTML("beforeend", `<style>${areaStyles.default}</style>`); }); // Clone styles if (this.cloneStyles !== "false") { for (let i = 0; i < document.styleSheets.length; i++) { const style = document.styleSheets.item(i); if (style == null || style.ownerNode == null) continue; doc.head.appendChild(style.ownerNode.cloneNode(true)); } } // Editable doc.body.contentEditable = "true"; // Keep the reference this.palette.refDocument = doc; // Press enter for <p>, otherwise is <br/> // this.style.display = 'inline-block'; doc.execCommand("defaultParagraphSeparator", false, "p"); if (!doc.execCommand("enableObjectResizing")) ; if (!doc.execCommand("enableInlineTableEditing")) ; const styleWithCSS = this.styleWithCSS; if (styleWithCSS) { doc.execCommand("styleWithCSS", undefined, styleWithCSS.toString()); } // Listen to focus event doc.addEventListener("mousedown", (event) => { this.closePopups(); const target = event.target; if (target == null || !("nodeName" in target)) { return; } if (event.ctrlKey) { const selection = this.getSelection(); if (selection) { const e = target; const td = e.closest("td, th"); if (td) { // Table const table = td.closest("table"); if (table) { // First one if (this.getAllHighlights(table).length === 0) { td.classList.add(hhClass); } else { const vt = VirtualTable.VirtualTable.tables.find((item) => item.HTMLTable == table); if (vt) { // Next to the current items if (vt .getNearCells(td) .some((c) => c.classList.contains(hhClass))) { td.classList.add(hhClass); } } } } } } event.preventDefault(); } else { this.clearHighlights(); } const nodeName = target["nodeName"]; const labels = this.labels; if (nodeName === "IMG") { const image = target; this.adjustPopup(event, image); this.popupIcons([ { name: "edit", label: labels.edit, icon: EOEditorCommand.EOEditorSVGs.edit, action: () => { this.editImage(image, (data) => (image.src = data)); } }, { name: "link", label: labels.link, icon: EOEditorCommand.EOEditorCommands.link.icon, action: () => { this.link(); } }, EOEditorCommand.EOEditorSeparator, { name: "delete", label: labels.delete, icon: EOEditorCommand.EOEditorCommands.delete.icon, action: () => { this.delete(); } } ]); } else if (nodeName === "IFRAME") { const iframe = target; this.adjustPopup(event, iframe); this.popupIcons([ { name: "edit", label: labels.edit, icon: EOEditorCommand.EOEditorSVGs.edit, action: () => { this.iframe(iframe); } }, EOEditorCommand.EOEditorSeparator, { name: "delete", label: labels.delete, icon: EOEditorCommand.EOEditorCommands.delete.icon, action: () => { this.delete(); } } ]); } else { const element = target; const div = element.closest("div"); if (div) { if (this.adjustTargetPopup(div)) { this.popupIcons([ { name: "edit", label: labels.edit, icon: EOEditorCommand.EOEditorSVGs.edit, action: () => { this.popupTextbox(div); } }, EOEditorCommand.EOEditorSeparator, { name: "delete", label: labels.delete, icon: EOEditorCommand.EOEditorCommands.delete.icon, action: () => { div.remove(); } } ]); } else { this.popup.reshow(); } } else { const cell = element.closest("td, th"); if (cell) { this.currentCell = cell; const table = cell.closest("table"); if (table) { if (this.adjustTargetPopup(table)) { // Virtual table const vt = new VirtualTable.VirtualTable(table); this.popupIcons([ { name: "tableProperties", label: labels.tableProperties, icon: EOEditorCommand.EOEditorSVGs.tableEdit, action: () => { this.tableProperties(table); } }, { name: "tableRemove", label: `${labels.delete}(${labels.table})`, icon: EOEditorCommand.EOEditorSVGs.tableRemove, action: () => { vt.removeTable(); } }, EOEditorCommand.EOEditorSeparator, { name: "tableSplitCell", label: `${labels.tableSplitCell}`, icon: EOEditorCommand.EOEditorSVGs.tableSplitCell, action: () => { this.tableSplitCell((isRow, qty) => { vt.splitCell(this.currentCell, isRow, qty); }); } }, { name: "tableMergeCells", label: `${labels.tableMergeCells}`, icon: EOEditorCommand.EOEditorSVGs.tableMergeCells, action: () => { let cells = this.lastHighlights ?? this.getAllHighlights(table); vt.mergeCells(cells); } }, EOEditorCommand.EOEditorSeparator, { name: "tableColumnAddBefore", label: `${labels.tableColumnAddBefore}`, icon: EOEditorCommand.EOEditorSVGs.tableColumnAddBefore, action: () => { vt.addColumnBefore(this.currentCell); } }, { name: "tableColumnAddAfter", label: `${labels.tableColumnAddAfter}`, icon: EOEditorCommand.EOEditorSVGs.tableColumnAddAfter, action: () => { vt.addColumnAfter(this.currentCell); } }, { name: "tableColumnRemove", label: `${labels.tableColumnRemove}`, icon: EOEditorCommand.EOEditorSVGs.tableColumnRemove, action: () => { vt.removeColumn(this.currentCell); } }, EOEditorCommand.EOEditorSeparator, { name: "tableRowAddBefore", label: `${labels.tableRowAddBefore}`, icon: EOEditorCommand.EOEditorSVGs.tableRowAddBefore, action: () => { vt.addRowBefore(this.currentCell); } }, { name: "tableRowAddAfter", label: `${labels.tableRowAddAfter}`, icon: EOEditorCommand.EOEditorSVGs.tableRowAddAfter, action: () => { vt.addRowAfter(this.currentCell); } }, { name: "tableRowRemove", label: `${labels.tableRowRemove}`, icon: EOEditorCommand.EOEditorSVGs.tableRowRemove, action: () => { vt.removeRow(this.currentCell); } } ], () => { this.testMergeButton(table); }); } else { this.popup.reshow(); this.testMergeButton(table); } } } } } }); doc.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; const range = this.getFirstRange(); if (range == null) return; const element = this.getFirstElement(range); if (element?.tagName !== "DIV") return; event.preventDefault(); event.stopImmediatePropagation(); event.stopPropagation(); if (event.ctrlKey) { if (element.previousSibling) { this.selectElement(element.previousSibling, null, true)?.collapse(); } else { const br = doc.createElement("br"); element.parentElement?.prepend(br); this.selectElement(br, null, true)?.collapse(); } } else { const p = doc.createElement("P"); p.innerHTML = "<br/>"; range.insertNode(p); range.selectNode(p); range.collapse(); } }); // Listen to selection change doc.addEventListener("selectionchange", () => this.onSelectionChange()); // Backup content when window blurs win.addEventListener("blur", () => { this.backup(-1); }); // Display this.hidden = false; this.restoreFocus(); } } testMergeButton(table) { if (!this.popup.isVisible()) return; const mergeButton = this.popup.querySelector('button[name="tableMergeCells"]'); if (mergeButton) { this.lastHighlights = this.getAllHighlights(table); mergeButton.disabled = this.lastHighlights.length <= 1; } } selectPopupElement(target) { if (target.nodeName == "IMG" || target.nodeName == "IFRAME") { this.selectElement(target); } } selectElement(target, selection = null, isContent = false) { selection ??= this.getSelection(); if (selection) { selection.removeAllRanges(); const range = this.editorWindow.document.createRange(); if (isContent) range.selectNodeContents(target); else range.selectNode(target); selection.addRange(range); return range; } } adjustPopup(event, target) { this.selectPopupElement(target); // Pos this._lastClickedButton = { name: "object", rect: new DOMRect(event.clientX + this.editorFrame.offsetLeft, event.clientY + this.editorFrame.offsetTop, 6, 6) }; } adjustTargetPopup(target) { this.selectPopupElement(target); const t = target.getBoundingClientRect(); const rect = new DOMRect(this.editorFrame.offsetLeft + t.left, this.editorFrame.offsetTop + t.top - 40, 6, 6); const b = this._lastClickedButton; if ("object" === b?.name && rect.x === b?.rect.x && rect.y === b?.rect.y) return false; // Pos this._lastClickedButton = { name: "object", rect }; return true; } disconnectedCallback() { this.form?.removeEventListener("submit", this.onFormSubmit.bind(this)); this.clearBackupSeed(); this.clearSelectionChangeSeed(); } // Only called for the disabled and open attributes due to observedAttributes attributeChangedCallback(name, oldVal, newVal) { // No necessary to update before being connected if (!this.isConnected || newVal == null) return; switch (name) { case "name": if (this.formInput) this.formInput.name = newVal; break; case "commands": this.setCommands(); break; case "width": this.setWidth(); break; case "height": this.setHeight(); break; case "color": this.setColor(); break; case "activeColor": this.setActiveColor(); break; } } createButton(name, command) { return this.createButtonSimple(name, command.label ?? this.labels[name], command.icon); } createButtonSimple(name, label, icon) { if (name === "s") return '<div class="separator"></div>'; return `<button is="eo-button" name="${name}" tooltip="${label}">${this.createSVG(icon)}${name === "foreColor" || name === "backColor" ? '<svg width="18" height="4" viewBox="0 0 18 4" class="color-indicator"><rect x="0" y="0" width="18" height="4" /></svg>' : ""}</button>`; } createIconButton(name) { if (name === "s") return '<div class="separator"></div>'; const command = EOEditorCommand.EOEditorCommands[name]; const label = command.label ?? this.labels[name]; return `<button class="icon-button" name="${name}">${this.createSVG(command.icon)}<span>${label}</span></button>`; } createSVG(path) { return `<svg width="24" height="24" viewBox="0 0 24 24">${path}</svg>`; } /** * Create element * @param tagName Tag name * @returns Element */ createElement(tagName) { return this.editorWindow.document.createElement(tagName); } /** * Get selection * @returns Selection */ getSelection() { return this.editorWindow.getSelection(); } /** * Get first range * @returns Range */ getFirstRange() { const selection = this.getSelection(); if (selection == null || selection.rangeCount === 0) return null; return selection.getRangeAt(0); } /** * Get deepest node * @param node Node * @returns Deepest node */ getDeepestNode(node) { while (node.childNodes.length === 1) { node = node.childNodes[0]; } return node; } /** * Get the only child element * @param container Container node * @returns Only element */ getOnlyElement(container) { let element = null; container.childNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { if (element == null) element = node; else return null; } }); return element; } /** * Get current element * @param tester Tester function or class name * @returns Element */ getCurrentElement(tester) { let input = this.getFirstElement(); while (input != null) { const test = typeof tester === "string" ? input.classList.contains(tester) : tester(input); if (test) return input; input = input.parentElement; } return null; } /** * Get first element * @param input Input selection or range * @returns Element */ getFirstElement(input) { // Null case if (input == null) input = this.getSelection(); if (input == null) return; const range = "rangeCount" in input ? input.rangeCount > 0 ? input.getRangeAt(0) : null : input; if (range == null) return null; // Firefox range.commonAncestorContainer is the parent element // range.startContainer is the text node or the previous text node // Chrome range.commonAncestorContainer is the text node // range.startContainer is the same text node let node = null; const container = range.commonAncestorContainer; const nodeCount = container.childNodes.length; const onlyElement = this.getOnlyElement(container); if (onlyElement) return onlyElement; if (nodeCount === 0) node = container; else if (nodeCount === 1) node = this.getDeepestNode(container.childNodes[0]); else { for (let c = 0; c < nodeCount; c++) { const childNode = container.childNodes[c]; if (childNode === range.startContainer && c + 2 < nodeCount && container.childNodes[c + 2] === range.endContainer) { node = this.getDeepestNode(container.childNodes[c + 1]); break; } } // Default if (node == null) node = range.endOffset === 0 ? range.startContainer : range.endContainer; } return node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; } /** * Get first link * @returns Link */ getFirstLink() { const element = this.getFirstElement(this.getSelection()); if (element) { if (element instanceof HTMLAnchorElement) return element; return element.closest("a"); } return null; } onFormSubmit() { this.clearHighlights(); if (this.formInput) this.formInput.value = this.innerHTML; // this.backup(0) will submit first then trigger backup event this.backup(-1); } clearBackupSeed() { if (this.backupCancel) { this.backupCancel(); this.backupCancel = undefined; } } clearSelectionChangeSeed() { if (this.selectionChangeCancel) { this.selectionChangeCancel(); this.selectionChangeCancel = undefined; } } getClasses(element) { const selector = new RegExp(`^${element.tagName}\\.([a-z0-9\\-_]+)$`, "i"); const sheets = this.editorWindow.document.styleSheets; const classes = []; for (let c = 0; c < sheets.length; c++) { const sheet = sheets.item(c); if (sheet == null) continue; try { // CORS security rules are applicable for style-sheets // https://stackoverflow.com/questions/49993633/uncaught-domexception-failed-to-read-the-cssrules-property for (const rule of sheet.cssRules) { const styleRule = rule; if (!("style" in styleRule)) continue; const parts = styleRule.selectorText .split(/\s*,\s*/) .reduce((prev, curr) => { curr.split(/\s+/).forEach((item) => { const match = item.match(selector); if (match && match.length > 1) prev.push(match[1]); }); return prev; }, []); classes.push(...parts); } } catch { } } return classes; } onSelectionChange() { this.clearSelectionChangeSeed(); this.selectionChangeCancel = shared.ExtendUtils.waitFor(() => this.onSelectionChangeDirect(), 50); } setFillColor(key, color) { const button = this.buttons[key]?.querySelector(".color-indicator"); if (button) button.style.fill = color; } getFillColor(key) { const button = this.buttons[key]?.querySelector(".color-indicator"); return button?.style.fill; } onSelectionChangeDirect() { // Selection const selection = this.getSelection(); if (selection == null || selection.type === "None") { return; } const range = this.getFirstRange(); if (this.isCaretSelection(selection) || range?.toString() === "") { this.toggleButtonsCaret(); } else { this.toggleButtons(false); if (range) this.testMergeButton(range); } // Element let element = this.getFirstElement(range); if (element) { // Fore color and back color detection const style = this.editorWindow.getComputedStyle(element); this.setFillColor("foreColor", style.color); this.setFillColor("backColor", style.backgroundColor); } // Status indicating while (element) { // Query all for (const b in this.buttons) { const key = b; const button = this.buttons[key]; if (button == null || button.classList.contains("active")) continue; const command = EOEditorCommand.EOEditorCommands[key]; if (command.detectStyle == null && command.detectTag == null) { let textSubs; if (command.icon === "" && (textSubs = button.dataset["subs"])) { // Dropdown text options const subs = textSubs.split(","); // Find the command const item = subs .map((s) => { const key = s; return { key, command: EOEditorCommand.EOEditorCommands[key] }; }) .find((c) => { return this.detectElement(element, c.command); }); if (item) { const span = button.querySelector("span.text"); if (span) { span.innerHTML = item.command.label ?? this.labels[item.key]; } break; } } continue; } if (this.detectElement(element, command)) { button.classList.add("active"); break; } } // Parent element = element.parentElement; if (element?.tagName === "BODY") break; } } detectElement(element, command) { const { detectTag, detectStyle } = command; if (detectTag) { if (detectTag.toUpperCase() === element.tagName) return true; } if (detectStyle) { const v = Reflect.get(element.style, detectStyle[0]); if (v === detectStyle[1]) return true; } return false; } delectPopupSelection(subs) { const selection = this.getSelection(); const isCaret = this.isCaretSelection(selection); subs.forEach((sub) => { const button = this.popup.querySelector(`button[name="${sub}"]`); if (button) button.disabled = isCaret && this.isCaretKey(sub); }); let element = this.getFirstElement(selection); while (element) { // Find the command const item = subs .map((key) => ({ key, command: EOEditorCommand.EOEditorCommands[key] })) .find((c) => this.detectElement(element, c.command)); if (item) { const button = this.popup.querySelector(`button[name="${item.key}"]`); button?.classList.add("active"); break; } // Parent element = element.parentElement; if (element?.tagName === "BODY") break; } } /** * Popup blocks */ popupBlocks() { const button = this._lastClickedButton; if (button == null || button.subs == null) return; const html = button.subs .map((s) => { const command = EOEditorCommand.EOEditorCommands[s]; const label = command.label ?? this.labels[s]; return `<button is="eo-button" class="line" name="${s}"><${s}>${label}</${s}></button>`; }) .join(""); this.popupContent(`<div class="icons" style="flex-direction: column">${html}</div>`); this.setupButtons(this.popup); this.delectPopupSelection(button.subs); } /** * Popup styles */ popupStyle(element = null) { const selection = this.getSelection(); if (selection == null) return; element ??= this.getFirstElement(selection); if (element == null) return; const range = this.selectElement(element, selection, true); const parents = [element]; let p = element.parentElement; while (p) { if (p?.nodeName === "BODY") break; parents.push(p); if (parents.length > 5) break; p = p.parentElement; } const labels = this.labels; const html = `<div class="grid"> <div class="grid-title">${labels.style}</div> <div class="full-width parents"> ${parents .map((p, k) => `<button${k === 0 ? " disabled" : ""}>${p.nodeName}</button>`) .join("")} </div> <label>${labels.className}</label> <div class="span3">${this.createMSelect("className", this.getClasses(element), element.classList)}</div> <textarea rows="8" name="code" class="full-width" style="width: 250px;"></textarea> <button class="full-width" name="apply">${labels.apply}</button> </div>`; this.popupContent(html); this.popup .querySelectorAll("div.parents button") .forEach((button, key) => { if (button.disabled) return; button.addEventListener("click", () => this.popupStyle(parents[key])); }); const classNameSelect = this.popup.querySelector("#className"); const codeArea = this.popup.querySelector('textarea[name="code"]'); codeArea.value = element.style.cssText; this.popup .querySelector('button[name="apply"]') ?.addEventListener("click", () => { this.popup.hide(); for (const option of classNameSelect.options) { if (option.selected)