@etsoo/editor
Version:
ETSOO Free WYSIWYG HTML Editor
1,393 lines (1,390 loc) • 100 kB
JavaScript
import { EOEditorCommandsParse, EOEditorCommands, EOEditorSVGs, EOEditorSeparator } from './classes/EOEditorCommand.js';
import { EOEditorGetLabels } from './classes/EOEditorLabels.js';
import { EOButton } from './components/EOButton.js';
import { EOEditorCharacters } from './classes/EOEditorCharacters.js';
import { EOImageEditor } from './components/EOImageEditor.js';
import { ExtendUtils, EColor, Utils, DomUtils } from '@etsoo/shared';
import { VirtualTable } from './classes/VirtualTable.js';
import css_248z from './EOEditor.css.js';
const lockClass = "eo-lock";
const template = document.createElement("template");
template.innerHTML = `
<style>${css_248z}</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 = 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 = 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);
});
}
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 = 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 = 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 (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.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.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: 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;
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;
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("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();
}
}
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 = 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 = 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 = 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: 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: 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 = 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)
element.classList.add(option.value);
else
element.classList.remove(option.value);
}
element.style.cssText = codeArea.value;
this.restoreFocus();
range?.collapse();
});
}
createAligns(id, tooltip) {
const sides = this.labels.sides.split("|");
const options = ["top", "right", "bottom", "left"]
.map((o, key) => `<option value="${o}">${sides[key]}</option>`)
.join("");
retu