export default class TangledMarkdownEditor extends HTMLElement { static tag = "markdown-editor"; static define(tag = this.tag) { this.tag = tag; const name = customElements.getName(this); if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); const ce = customElements.get(tag); if (ce && ce !== this) return console.warn(`${tag} already defined as ${ce.name}!`); customElements.define(tag, this); } static { const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; if (tag != "none") this.define(tag); } #dragHoverClass = "drag-hover"; constructor() { super(); this.textarea = this.querySelector("textarea"); if (!this.textarea) { console.error("textarea is missing in markdown-editor"); return; } this.querySelectorAll('[data-md-mode]').forEach(btn => { btn.addEventListener("click", () => { const mode = btn.dataset.mdMode; this.querySelectorAll('[data-md-panel]').forEach(p => { p.classList.toggle('hidden', p.dataset.mdPanel !== mode); }); this.querySelectorAll('[data-md-mode]').forEach(b => { b.classList.toggle('active', b === btn); }); }); }); // TODO: blob upload support // this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); // this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); // this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); // this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); } async insertFile() { const input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.multiple = true; input.style.display = "none"; input.addEventListener("change", () => { if (!input.files) return; for (const file of input.files) { this.#handleFile(file); } }); this.appendChild(input); input.click(); this.removeChild(input); } /** @param {ClipboardEvent} ev */ async #onPaste(ev) { const dt = ev.clipboardData; if (!dt || !dt.files || dt.files.length === 0) return; ev.preventDefault(); for (const file of dt.files) { if (!file.type.startsWith("image/")) continue; await this.#handleFile(file); } } /** @param {DragEvent} ev */ async #onDragOver(ev) { ev.preventDefault(); this.classList.add(this.#dragHoverClass); } /** @param {DragEvent} ev */ async #onDragLeave(ev) { ev.preventDefault(); this.classList.remove(this.#dragHoverClass); } /** @param {DragEvent} ev */ async #onDrop(ev) { this.classList.remove(this.#dragHoverClass); const dt = ev.dataTransfer; if (!dt || !dt.files || dt.files.length === 0) return; ev.preventDefault(); for (const file of dt.files) { if (!file.type.startsWith("image/")) continue; await this.#handleFile(file); } } /** @param {File} file */ async #handleFile(file) { const textarea = this.textarea; if (!textarea) return; const placeholder = ``; this.#insertTextAtCursor(placeholder); let blob; try { blob = await this.#upload(file); } catch (e) { console.error("failed to upload blob", e) textarea.value = textarea.value.replace(placeholder, ``); return } // TODO: insert blob itself to form const cid = blob.ref["$link"] textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); } /** @param {string} text */ #insertTextAtCursor(text) { const textarea = this.textarea; if (!textarea) return; const start = textarea.selectionStart; const end = textarea.selectionEnd; const before = textarea.value.slice(0, start); const after = textarea.value.slice(end); // add surrounding newlines if it's mid-line if (before && !before.endsWith("\n")) text = "\n\n" + text; if (after && !after.startsWith("\n")) text = text + "\n\n"; textarea.value = before + text + after; const newPos = start + text.length; textarea.selectionStart = textarea.selectionEnd = newPos; textarea.dispatchEvent( new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }) ); // textarea.dispatchEvent(new Event("input", { bubbles: true })); textarea.dispatchEvent(new Event("change", { bubbles: true })); } /** @param {File} file */ async #upload(file) { await new Promise(r => setTimeout(r, 500)); const host = this.getAttribute("host") ?? ""; const res = await fetch(host + "/xrpc/com.atproto.repo.uploadBlob", { method: "POST", body: file, headers: { "Content-Type": file.type, }, }); const output = await res.json(); return output.blob; } }