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 = ``;
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"
}
}
}