UNPKG

@etsoo/editor

Version:

ETSOO Free WYSIWYG HTML Editor

1,809 lines (1,565 loc) 95.2 kB
import { IEOEditor, IEOEditorClickedButton } from "./IEOEditor"; import { EOEditorCommandKey, EOEditorCommands, EOEditorCommandsParse, EOEditorSeparator, EOEditorSVGs, IEOEditorCommand, IEOEditorIconCommand } from "./classes/EOEditorCommand"; import { EOEditorGetLabels, EOEditorLabelLanguage } from "./classes/EOEditorLabels"; import { EOPopup } from "./components/EOPopup"; import { EOButton } from "./components/EOButton"; import { EOEditorCharacters, EOEditorCharacterType } from "./classes/EOEditorCharacters"; import { EOImageEditor } from "./components/EOImageEditor"; import { DomUtils, EColor, ExtendUtils, Utils } from "@etsoo/shared"; import { VirtualTable } from "./classes/VirtualTable"; import { EOPalette } from "./components/EOPalette"; import styles from "./EOEditor.css"; const lockClass = "eo-lock"; const template = document.createElement("template"); template.innerHTML = ` <style>${styles}</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/ */ export class EOEditor extends HTMLElement implements IEOEditor { /** * Observed attributes */ static get observedAttributes() { return ["name", "commands", "width", "height", "color", "activeColor"]; } /** * Caret keys */ static caretKeys: EOEditorCommandKey[] = [ "bold", "italic", "underline", "strikeThrough", "foreColor", "backColor", "removeFormat", "subscript", "superscript", "link", "unlink", "lock" ]; /** * Backup key */ static readonly BackupKey = "EOEditor-Backup"; /** * Lastest characters key */ static readonly LatestCharactersKey = "EOEditor-Latest-Characters"; private _backupInitialized: boolean = false; /** * Backup initialized */ get backupInitialized() { return this._backupInitialized; } /** * Buttons */ readonly buttons: Record<string, HTMLButtonElement | undefined> = {}; /** * Image editor */ readonly imageEditor: EOImageEditor; /** * Popup */ readonly popup: EOPopup; /** * Editor container */ readonly editorContainer: HTMLDivElement; /** * Editor iframe */ readonly editorFrame: HTMLIFrameElement; private _editorWindow!: Window; /** * Editor iframe window */ get editorWindow() { return this._editorWindow; } /** * Editor source code textarea */ readonly editorSourceArea: HTMLTextAreaElement; /** * Editor toolbar */ readonly editorToolbar: HTMLDivElement; private _labels?: EOEditorLabelLanguage; /** * Editor labels */ get labels() { return this._labels; } // Color palette private palette: EOPalette; // Backup cancel private backupCancel?: () => void; // Selection change cancel private selectionChangeCancel?: () => void; // Form element private form?: HTMLFormElement | null; private formInput?: HTMLInputElement; private currentCell: HTMLTableCellElement | null = null; private lastHighlights?: HTMLTableCellElement[]; // Categories with custom order // Same order with label specialCharacterCategories private characterCategories: [EOEditorCharacterType, string?][] = [ ["symbols"], ["punctuation"], ["arrows"], ["currency"], ["math"], ["numbers"] ]; private _lastClickedButton?: IEOEditorClickedButton; /** * Last clicked button */ get lastClickedButton() { return this._lastClickedButton; } /** * Name */ get name() { return this.getAttribute("name"); } set name(value: string | null | undefined) { if (value) this.setAttribute("name", value); else this.removeAttribute("name"); } /** * Clone styles to editor */ get cloneStyles() { return this.getAttribute("cloneStyles"); } set cloneStyles(value: string | boolean | null | undefined) { 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: string | null | undefined) { if (value) this.setAttribute("commands", value); else this.removeAttribute("commands"); } _content: string | null | undefined; /** * 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: string | null | undefined) { 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: string | null | undefined) { this.content = value; } /** * Main color */ get color() { return this.getAttribute("color"); } set color(value: string | null | undefined) { if (value) this.setAttribute("color", value); else this.removeAttribute("color"); } /** * Active color */ get activeColor() { return this.getAttribute("activeColor"); } set activeColor(value: string | null | undefined) { if (value) this.setAttribute("activeColor", value); else this.removeAttribute("activeColor"); } /** * Width */ get width(): string | null { return this.getAttribute("width"); } set width(value: string | number | null | undefined) { if (value) this.setAttribute( "width", typeof value === "number" ? `${value}px` : value ); else this.removeAttribute("width"); } /** * Height */ get height(): string | null { return this.getAttribute("height"); } set height(value: string | number | null | undefined) { 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: string | boolean | null | undefined) { if (value) this.setAttribute("styleWithCSS", value.toString()); else this.removeAttribute("styleWithCSS"); } /** * Language */ get language() { return this.getAttribute("language"); } set language(value: string | null | undefined) { if (value) this.setAttribute("language", value); else this.removeAttribute("language"); } /** * Backup distinguish key */ get backupKey() { return this.getAttribute("backupKey"); } set backupKey(value: string | null | undefined) { 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" )!; } private getBackupName() { return `${EOEditor.BackupKey}-${this.name}-${this.backupKey}`; } /** * Backup editor content * @param miliseconds Miliseconds to wait */ backup(miliseconds: number = 1000) { this.clearBackupSeed(); if (miliseconds < 0) { this.backupAction(); } else { this.backupCancel = ExtendUtils.waitFor( () => this.backupAction(), miliseconds ); } } private 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()); } private setCommands() { const commands = EOEditorCommandsParse(this.commands); const language = this.language ?? window.navigator.language; 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); }); } private setupButtons(container: HTMLElement) { container .querySelectorAll<HTMLButtonElement>("button") .forEach((button) => { // Button/command name const name = button.name as EOEditorCommandKey; // 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: HTMLImageElement, callback?: (data: string) => void) { this.imageEditor.open(image, callback); } private getAllHighlights(): HTMLTableCellElement[]; private getAllHighlights(table: HTMLTableElement): HTMLTableCellElement[]; private getAllHighlights(range: Range): HTMLTableCellElement[]; private getAllHighlights( range: HTMLTableElement | Range ): HTMLTableCellElement[]; private getAllHighlights(container?: HTMLTableElement | Range) { if (container == null || "querySelectorAll" in container) return Array.from( (container ?? this.editorWindow.document).querySelectorAll( `td.${hhClass}, th.${hhClass}` ) ); const items: HTMLTableCellElement[] = []; const startTd = ( container.startContainer.nodeType === Node.ELEMENT_NODE ? (container.startContainer as HTMLElement) : container.startContainer.parentElement )?.closest<HTMLTableCellElement>("td, th"); const endTd = ( container.endContainer.nodeType === Node.ELEMENT_NODE ? (container.endContainer as HTMLElement) : container.endContainer.parentElement )?.closest<HTMLTableCellElement>("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 as HTMLTableCellElement); } if (nextTd == endTd) break; nextTd = nextTd.nextElementSibling; } } else { items.push(startTd, endTd); } } return items; } /** * Clear highlights */ private clearHighlights() { this.getAllHighlights().forEach((td) => td.classList.remove(hhClass)); } /** * Restore focus to the editor iframe */ restoreFocus() { this.editorWindow.document.body.focus(); } private buttonClick(button: HTMLButtonElement, name: EOEditorCommandKey) { // Hold the button's states const subs = button.dataset["subs"] ?.split(",") .map((s) => s.trim() as EOEditorCommandKey); this.updateClickedButton(button, subs); // Hide the popup this.popup.hide(); // Command const command = 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(); } private setWidth() { const width = this.width; if (width) { this.style.setProperty("--width", width); } } private setHeight() { const height = this.height; if (height) { this.style.setProperty("--height", height); } } private setColor() { const color = this.color; if (color) this.style.setProperty("--color", color, "important"); } private setContent(value?: string | null) { this.editorWindow.document.body.innerHTML = value ?? ""; } private setActiveColor() { const activeColor = 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()); } } private closePopups() { this.popup.hide(); this.palette.hide(); } private initContent(win: Window | null) { 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 (Utils.hasHtmlEntity(html) && !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 import("./EOEditorArea.css").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")) { // Custom object resizing } if (!doc.execCommand("enableInlineTableEditing")) { // Custom table editing } 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 as HTMLElement; const td = e.closest<HTMLTableCellElement>("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.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 as HTMLImageElement; this.adjustPopup(event, image); this.popupIcons([ { name: "edit", label: labels.edit, icon: EOEditorSVGs.edit, action: () => { this.editImage(image, (data) => (image.src = data)); } }, { name: "link", label: labels.link, icon: EOEditorCommands.link.icon, action: () => { this.link(); } }, EOEditorSeparator, { name: "delete", label: labels.delete, icon: EOEditorCommands.delete.icon, action: () => { this.delete(); } } ]); } else if (nodeName === "IFRAME") { const iframe = target as HTMLIFrameElement; this.adjustPopup(event, iframe); this.popupIcons([ { name: "edit", label: labels.edit, icon: EOEditorSVGs.edit, action: () => { this.iframe(iframe); } }, EOEditorSeparator, { name: "delete", label: labels.delete, icon: EOEditorCommands.delete.icon, action: () => { this.delete(); } } ]); } else { const element = target as HTMLElement; const div = element.closest("div"); if (div) { if (this.adjustTargetPopup(div)) { this.popupIcons([ { name: "edit", label: labels.edit, icon: EOEditorSVGs.edit, action: () => { this.popupTextbox(div); } }, EOEditorSeparator, { name: "delete", label: labels.delete, icon: EOEditorCommands.delete.icon, action: () => { div.remove(); } } ]); } else { this.popup.reshow(); } } else { const cell = element.closest<HTMLTableCellElement>("td, th"); if (cell) { this.currentCell = cell; const table = cell.closest("table"); if (table) { if (this.adjustTargetPopup(table)) { // Virtual table const vt = new VirtualTable(table); this.popupIcons( [ { name: "tableProperties", label: labels.tableProperties, icon: EOEditorSVGs.tableEdit, action: () => { this.tableProperties(table); } }, { name: "tableRemove", label: `${labels.delete}(${labels.table})`, icon: EOEditorSVGs.tableRemove, action: () => { vt.removeTable(); } }, EOEditorSeparator, { name: "tableSplitCell", label: `${labels.tableSplitCell}`, icon: EOEditorSVGs.tableSplitCell, action: () => { this.tableSplitCell((isRow, qty) => { vt.splitCell(this.currentCell!, isRow, qty); }); } }, { name: "tableMergeCells", label: `${labels.tableMergeCells}`, icon: EOEditorSVGs.tableMergeCells, action: () => { let cells = this.lastHighlights ?? this.getAllHighlights(table); vt.mergeCells(cells); } }, EOEditorSeparator, { name: "tableColumnAddBefore", label: `${labels.tableColumnAddBefore}`, icon: EOEditorSVGs.tableColumnAddBefore, action: () => { vt.addColumnBefore(this.currentCell!); } }, { name: "tableColumnAddAfter", label: `${labels.tableColumnAddAfter}`, icon: EOEditorSVGs.tableColumnAddAfter, action: () => { vt.addColumnAfter(this.currentCell!); } }, { name: "tableColumnRemove", label: `${labels.tableColumnRemove}`, icon: EOEditorSVGs.tableColumnRemove, action: () => { vt.removeColumn(this.currentCell!); } }, EOEditorSeparator, { name: "tableRowAddBefore", label: `${labels.tableRowAddBefore}`, icon: EOEditorSVGs.tableRowAddBefore, action: () => { vt.addRowBefore(this.currentCell!); } }, { name: "tableRowAddAfter", label: `${labels.tableRowAddAfter}`, icon: EOEditorSVGs.tableRowAddAfter, action: () => { vt.addRowAfter(this.currentCell!); } }, { name: "tableRowRemove", label: `${labels.tableRowRemove}`, icon: 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(); } } private testMergeButton(table: HTMLTableElement | Range) { if (!this.popup.isVisible()) return; const mergeButton = this.popup.querySelector<HTMLButtonElement>( 'button[name="tableMergeCells"]' ); if (mergeButton) { this.lastHighlights = this.getAllHighlights(table); mergeButton.disabled = this.lastHighlights.length <= 1; } } private selectPopupElement(target: HTMLElement) { if (target.nodeName == "IMG" || target.nodeName == "IFRAME") { this.selectElement(target); } } private selectElement( target: Node, selection: Selection | null = null, isContent: boolean = 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; } } private adjustPopup(event: MouseEvent, target: HTMLElement) { this.selectPopupElement(target); // Pos this._lastClickedButton = { name: "object", rect: new DOMRect( event.clientX + this.editorFrame.offsetLeft, event.clientY + this.editorFrame.offsetTop, 6, 6 ) }; } private adjustTargetPopup(target: HTMLElement) { 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: string, oldVal: string | null, newVal: string | null ) { // 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; } } private createButton(name: EOEditorCommandKey, command: IEOEditorCommand) { return this.createButtonSimple( name, command.label ?? this.labels![name], command.icon ); } private createButtonSimple(name: string, label: string, icon: string) { 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>`; } private createIconButton(name: EOEditorCommandKey) { if (name === "s") return '<div class="separator"></div>'; const command = EOEditorCommands[name]; const label = command.label ?? this.labels![name]; return `<button class="icon-button" name="${name}">${this.createSVG( command.icon )}<span>${label}</span></button>`; } private createSVG(path: string) { return `<svg width="24" height="24" viewBox="0 0 24 24">${path}</svg>`; } /** * Create element * @param tagName Tag name * @returns Element */ createElement<K extends keyof HTMLElementTagNameMap>(tagName: K) { 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: 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: Node): HTMLElement | null { let element: HTMLElement | null = null; container.childNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { if (element == null) element = node as HTMLElement; else return null; } }); return element; } /** * Get current element * @param tester Tester function or class name * @returns Element */ getCurrentElement( tester: string | ((input: HTMLElement) => boolean) ): HTMLElement | null { 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 selection Selection */ getFirstElement(selection?: Selection | null): HTMLElement | null; /** * Get first element * @param range Range */ getFirstElement(range: Range | null): HTMLElement | null; /** * Get first element * @param input Input selection or range * @returns Element */ getFirstElement(input: Selection | Range | null | undefined) { // 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: Node | null = 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 as HTMLElement) : node.parentElement; } /** * Get first link * @returns Link */ getFirstLink(): HTMLAnchorElement | null { const element = this.getFirstElement(this.getSelection()); if (element) { if (element instanceof HTMLAnchorElement) return element; return element.closest("a"); } return null; } private onFormSubmit() { this.clearHighlights(); if (this.formInput) this.formInput.value = this.innerHTML; // this.backup(0) will submit first then trigger backup event this.backup(-1); } private clearBackupSeed() { if (this.backupCancel) { this.backupCancel(); this.backupCancel = undefined; } } private clearSelectionChangeSeed() { if (this.selectionChangeCancel) { this.selectionChangeCancel(); this.selectionChangeCancel = undefined; } } private getClasses(element: HTMLElement) { const selector = new RegExp(`^${element.tagName}\\.([a-z0-9\\-_]+)$`, "i"); const sheets = this.editorWindow.document.styleSheets; const classes: string[] = []; 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 as CSSStyleRule; 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; }, [] as string[]); classes.push(...parts); } } catch {} } return classes; } private onSelectionChange() { this.clearSelectionChangeSeed(); this.selectionChangeCancel = ExtendUtils.waitFor( () => this.onSelectionChangeDirect(), 50 ); } private setFillColor(key: EOEditorCommandKey, color: string) { const button = this.buttons[key]?.querySelector<SVGElement>(".color-indicator"); if (button) button.style.fill = color; } private getFillColor(key: EOEditorCommandKey) { const button = this.buttons[key]?.querySelector<SVGElement>(".color-indicator"); return button?.style.fill; } private 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 as EOEditorCommandKey; const button = this.buttons[key]; if (button == null || button.classList.contains("active")) continue; const command = EOEditorCommands[key]; if (command.detectStyle == null && command.detectTag == null) { let textSubs: string | undefined; 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 as EOEditorCommandKey; return { key, command: 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; } } private detectElement(element: HTMLElement, command: IEOEditorCommand) { 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; } private delectPopupSelection(subs: EOEditorCommandKey[]) { const selection = this.getSelection(); const isCaret = this.isCaretSelection(selection); subs.forEach((sub) => { const button = this.popup.querySelector<HTMLButtonElement>( `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: 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 = 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: HTMLElement | null = 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: HTMLElement[] = [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<HTMLButtonElement>("div.parents button") .forEach((button, key) => { if (button.disabled) return; button.addEventListener("click", () => this.popupStyle(parents[key])); }); const classNameSelect = this.popup.querySelector<HTMLSelectElement>("#className")!; const codeArea = this.popup.querySelector<HTMLTextAreaElement>( '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) element!.classList.add(option.value); else element!.classList.remove(option.value); } element!.style.cssText = codeArea.value; this.restoreFocus(); range?.collapse(); }); } private createAligns(id: string, tooltip: string) { const sides = this.labels!.sides.split("|"); const options = ["top", "right", "bottom", "left"] .map((o, key) => `<option value="${o}">${sides[key]}</option>`) .join(""); return `<select title="${tooltip}" id="${id}">${options}</select>`; } private createInputs(name: string, div?: HTMLDivElement) { const labels = this.labels!; const sides = labels.sides.split("|"); const getValue = (pName: string) => { if (div?.style == null) return ""; return Reflect.get(div.style, pName); }; const nameTop = name.replace("?", "Top"); const nameRight = name.replace("?", "Right"); const nameBottom = name.replace("?", "Bottom"); const nameLeft = name.replace("?", "Left"); return ` <div class="span3 narrow"> <input id="${nameTop}" placeholder="${sides[0]}" value="${getValue( nameTop )}"/> <button title="${labels.sameValue}"> <svg width="16" height="16" viewBox="0 0 24 24" class="inline">${ EOEditorSVGs.arrayRight }</svg> </button> <input id="${nameRight}" placeholder="${ sides[1] }" value="${getValue(nameRight)}"/> <input id="${nameBottom}" placeholder="${ sides[2] }" value="${getValue(nameBottom)}"/> <input id="${nameLeft}" placeholder="${sides[3]}" value="${getValue( nameLeft )}"/> </div> `; } private createRadios( name: string, values: string[], labels: string | string[], defaultValue?: string | null ) { if (typeof labels === "string") labels = labels.split("|"); return values .map( (v, k) => `<label><input type="radio"${ k === 0 ? ` id=${name}` : "" } name="${name}" value="${v}"${ v === defaultValue ? " checked" : "" }/>${labels[k]}</label>` ) .join(""); } private createSelect(id: string, options: string[], value?: string) { return `<select id="${id}">${options.map((o) => { const v = o.toLocaleLowerCase(); return `<option value="${v}"${ v === value ? " selected" : "" }>${o}</option>`; })}</select>`; } private createMSelect(id: string, options: string[], value?: DOMTokenList) { return `<select style="width: 100%; height: 60px;" multiple id="${id}">${options.map( (o) => { const v = o.toLocaleLowerCase(); return `<option value="${v}"${ value?.contains(v) ? " selected" : "" }>${o}</option>`; } )}</select>`; } private setColorInput(id: string) { const input = this.popup.querySelector<HTMLInputElement>(`input#${id}`)!; this.palette.setupInput(input); } private popupTextbox(div?: HTMLDivElement) { const labels = this.labels!; const html = ` <div class="grid"> <div class="grid-title">${labels.textbox}</div> <label for="width">${labels.width}</label> <input type="text" id="width" value="${ div?.style.width ?? "100%" }"/> <label for="height">${labels.height}</label> <input type="text" id="height" value="${ div?.style.height ?? "" }"/> <label for="color">${labels.color}</label> <input type="text" id="color" value="${ div?.style.color ?? "" }"/> <label for="backgroundColor">${labels.bgColor}</label> <input type="text" id="backgroundColor" value="${ div?.style.backgroundColor ?? "" }"/> <label for="float">${labels.float}</label> <div class="span3"> ${this.createRadios( "float", ["none", "left", "right"], [labels.none, labels.justifyLeft, labels.justifyRight], div?.style.float )} </div> <label for="marginTop">${labels.margin}</label> ${this.createInputs("margin?", div)} <label for="paddingTop">${labels.padding}</label> ${this.createInputs("padding?", div)} <div class="grid-title">${labels.border}</div> <label for="borderLeftWidth">${labels.width}</label> ${this.createInputs("border?Width", div)} <label for="borderRadius">${labels.borderStyle}</label> ${this.createSelect( "borderStyle", borderStyles, Utils.replaceNullOrEmpty(div?.style.borderStyle, "solid")