@etsoo/editor
Version:
ETSOO Free WYSIWYG HTML Editor
1,809 lines (1,565 loc) • 95.2 kB
text/typescript
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")