mirror of
https://codeberg.org/vlw/code-export-syntax.git
synced 2025-11-05 04:22:43 +01:00
345 lines
No EOL
9.4 KiB
JavaScript
345 lines
No EOL
9.4 KiB
JavaScript
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(); |