const template = document.createElement("template"); template.innerHTML = ` `; /** * @template {HTMLElement} T * @param {T} tmpl */ function clone(tmpl) { return /** @type {T} */ (tmpl.cloneNode(true)); } export default class TangledMarkdownEditor extends HTMLElement { static tag = "tangled-md-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); } #shadow = this.attachShadow({ mode: "closed" }); #dragHoverClass = "drag-hover"; constructor() { super(); this.#shadow.append(clone(template).content); this.addEventListener("paste", (ev) => this.#onPaste(ev)); this.addEventListener("dragover", (ev) => this.#onDragOver(ev)); this.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); this.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.querySelector("textarea"); if (!textarea) return; const placeholder = ``; this.#insertTextAtCursor(placeholder); const url = await this.#upload(file); const finalTag = `![Image](blob://${url})`; textarea.value = textarea.value.replace(placeholder, finalTag); } /** @param {string} text */ #insertTextAtCursor(text) { const textarea = this.querySelector("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") ?? ""; try { 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.ref["$link"] } catch (e) { console.error("failed to upload blob", e) return "failed" } } }