const KEY_STORAGE_TNX = "tnx"; const PATH_THEME_FOLDER = "/themes/"; const THEME_NAME_DEFAULT = "default_dark_modern"; const MODAL_OFFSET_TOP_PX = 25; const CLASSNAME = Object.freeze({ ACTIVE : "active", EMPTY : "empty", HIGHLIGHT_SPANS : "highlight-spans" }); const SELECTOR = Object.freeze({ SPAN_MKT : ".span-mkt", SPAN_JOIN : "button.join", SPAN_SPLIT : "button.split", MODAL_ELEMENT : "edit-span-modal", EDITOR_ELEMENT : "main", EXPORT_ELEMENT : "[data-export]", TOGGLE_HIGHLIGHT : "input[name='highlight-spans']" }); class Editor { static #editingAbort; static #editingSpanElement; /** * Stop editing */ static stopEditing() { this.#editingAbort?.abort(); this.#editingSpanElement?.removeAttribute("contenteditable"); this.#editingSpanElement = null; Editor.#modalElement.classList.remove(CLASSNAME.ACTIVE); } /** * Returns all elements that loads a theme stylesheet * * @returns {NodeList} */ static get #themeElements() { return document.head.querySelectorAll(`[href^='${PATH_THEME_FOLDER}']`); } /** * Returns the span editor modal element * * @returns {HTMLElement} */ static get #modalElement() { return document.body.querySelector(SELECTOR.MODAL_ELEMENT); } constructor() { this.#setContent(); this.theme = THEME_NAME_DEFAULT; this.#bindExport(); this.#bindSplitSpan(); this.#bindModifySpan(); this.#bindSetSpanMkt(); this.#bindJoinSpanLeft(); this.#bindHighlightSpans(); this.#bindAdjustFontSizePreview(); } /** * Return the editor wrapper element * @returns {HTMLElement} */ get editor() { return document.body.querySelector(SELECTOR.EDITOR_ELEMENT); } /** * Get the name of the current theme * * @returns {string} */ get theme() { return Editor.#themeElements() ? Editor.#themeElements()[0].dataset.themeName : null; } /** * Update current theme * * @param {string} themeName */ set theme(themeName = THEME_NAME_DEFAULT) { if (typeof themeName !== "string") { throw Error("Theme name expects a string"); } // Remove existing themes Editor.#themeElements.forEach(element => element.remove()); // Create link element to load stylesheet from themes directory const element = document.createElement("link"); element.rel = "stylesheet"; element.href = PATH_THEME_FOLDER + THEME_NAME_DEFAULT + ".css"; element.dataset.themeName = themeName; document.head.appendChild(element); } /** * Set the current font size * @param {number} size */ set fontSize(size) { Editor.stopEditing(); this.editor.style.setProperty("--font-size", `${size}px`); // TODO: Adjust span top and height } /** * Save the current editor HTML to session storage */ save() { chrome.storage.session.set({ [KEY_STORAGE_TNX]: this.editor.innerHTML }); } /** * Start editing a span element * * @param {HTMLSpanElement} spanElement */ editSpan(spanElement) { if (!(spanElement instanceof HTMLSpanElement)) { throw Error("Target element must be of type HTMLSpanElement"); } // Stop edting any existing spans Editor.stopEditing(); Editor.#editingAbort = new AbortController(); Editor.#editingSpanElement = spanElement; // Create event listener to filter characters when editing this span content document.addEventListener("keyup", (event) => { // Stop editing on enter key is pressed if (["Enter", "NumpadEnter"].includes(event.code)) { event.preventDefault(); return Editor.stopEditing(); } this.#setSpanText(spanElement.innerText); }, { signal: Editor.#editingAbort.signal }); // Stop editing if main is clicked around this span this.editor.addEventListener("click", (event) => { if (event.target !== spanElement) { Editor.stopEditing(); } }, { signal: Editor.#editingAbort.signal }); spanElement.setAttribute("contenteditable", true); spanElement.focus(); this.#editSpanModal(spanElement); } /** * Open editor modal on a span element * * @param {HTMLSpanElement} spanElement */ #editSpanModal(spanElement) { const x = spanElement.offsetLeft; const y = spanElement.offsetTop + MODAL_OFFSET_TOP_PX; // Set text preview this.#setSpanText(spanElement.innerText); // Mark the current mkt on the span as selected in the modal editor Editor.#modalElement.querySelector(`.${spanElement.classList.toString()}`)?.classList.add(CLASSNAME.ACTIVE); // Scroll lists to the top Editor.#modalElement.querySelectorAll("ul, ol").forEach(element => element.scrollTop = 0); Editor.#modalElement.style.top = `${y}px`; Editor.#modalElement.style.left = `${x}px`; //Editor.#modalElement.style.setProperty("transform", `translate(${x}px, ${y}px)`); Editor.#modalElement.classList.add(CLASSNAME.ACTIVE); } /** * Set the preview texts in the span editor modal * * @param {string} text */ #setSpanText(text) { Editor.#editingSpanElement.classList.remove(CLASSNAME.EMPTY); // Span has no text if (text.length < 1) { text = " "; Editor.#editingSpanElement.classList.add(CLASSNAME.EMPTY); } // Update the preview text in the span editor modal Editor.#modalElement.querySelectorAll(SELECTOR.SPAN_MKT).forEach(element => element.querySelector("p").innerText = text); } /** * Sets the editor content with HTML from session storage */ async #setContent() { const content = await chrome.storage.session.get(KEY_STORAGE_TNX); this.editor.innerHTML = content[KEY_STORAGE_TNX]; } /** * Bind adjust font size input elements */ #bindAdjustFontSizePreview() { const inputElements = document.body.querySelector("#text-size").querySelectorAll("input"); inputElements.forEach(inputElement => { // Update all input element values when any input element changes inputElement.addEventListener("input", () => { inputElements.forEach(element => element.value = inputElement.value); this.fontSize = inputElement.value; }) }); } /** * Bind listener for editing spans */ #bindModifySpan() { // We're going to bind all clicks on main since we might have a lot of span elements this.editor.addEventListener("click", (event) => { // Bail out if the target element is not a span if (!(event.target instanceof HTMLSpanElement)) { return; } this.editSpan(event.target); }); } /** * Bind set span mtk by clicking a text preview in the span modal list */ #bindSetSpanMkt() { Editor.#modalElement.querySelectorAll(SELECTOR.SPAN_MKT).forEach(element => { element.addEventListener("click", (event) => { // Bail out if we're not editing anything if (!Editor.#editingSpanElement) { return; } // Make the span in the editor the same mkt as the one in the modal Editor.#editingSpanElement.classList = event.target.querySelector("p").classList.toString(); // Mark all mkts in the modal as inactive Editor.#modalElement.querySelectorAll(SELECTOR.SPAN_MKT).forEach(element => element.classList.remove(CLASSNAME.ACTIVE)); // Make the current mkt active event.target.classList.add(CLASSNAME.ACTIVE); }); }); } /** * Bind button for joining the span being edited with the one to the left */ #bindJoinSpanLeft() { document.body.querySelector(SELECTOR.SPAN_JOIN).addEventListener("click", () => { // Bail out if we're not editing anything or element doesn't have a previous element if (!Editor.#editingSpanElement || !Editor.#editingSpanElement.previousElementSibling) { return; } // Concatinate editing span element text with previous element Editor.#editingSpanElement.previousElementSibling.innerText += Editor.#editingSpanElement.innerText; // Shift editing to the previous element this.editSpan(Editor.#editingSpanElement.previousElementSibling); // Delete the original span Editor.#editingSpanElement.nextElementSibling.remove(); }); } /** * Bind button for splitting the span being edited into two spans at the cursor location */ #bindSplitSpan() { document.body.querySelector(SELECTOR.SPAN_SPLIT).addEventListener("click", () => { const selection = window.getSelection(); // Bail out if we're at the start or end of the span text if (selection.baseOffset < 1 || selection.baseOffset === Editor.#editingSpanElement.innerText.length) { return; } const newSpan = Editor.#editingSpanElement.cloneNode(); // Set text of cloned span to everything ahead of the cursor newSpan.innerText = Editor.#editingSpanElement.innerText.slice(selection.baseOffset); // Truncate the text of the original span up to the cursor Editor.#editingSpanElement.innerText = Editor.#editingSpanElement.innerText.slice(0, selection.baseOffset); // Append new span and shift editing towards it Editor.#editingSpanElement.insertAdjacentElement("afterend", newSpan); this.editSpan(newSpan); }); } /** * Bind button for toggling highlighting of individual span elements */ #bindHighlightSpans() { document.body.querySelector(SELECTOR.TOGGLE_HIGHLIGHT).addEventListener("click", (event) => { event.target.checked ? this.editor.classList.add(CLASSNAME.HIGHLIGHT_SPANS) : this.editor.classList.remove(CLASSNAME.HIGHLIGHT_SPANS); }); } /** * Bind export HTML buttons */ #bindExport() { document.querySelectorAll(SELECTOR.EXPORT_ELEMENT).forEach(element => { element.addEventListener("click", () => { const url = new URL(window.location); url.pathname = element.dataset.export; this.save(); window.open(url, "_blank"); }); }) } } globalThis._Editor = new Editor();