markdown editor web component for tangled

initial commit

Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me eca7ee66

+229
+35
index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <script type="module" src="./tangled-md-editor.js"></script> 5 + <style> 6 + /* dark mode for my eyes */ 7 + body, textarea { 8 + color: #ddd; 9 + background-color: #222; 10 + } 11 + tangled-md-editor textarea { 12 + padding: 1em; 13 + } 14 + tangled-md-editor.drag-hover textarea { 15 + outline: dashed 2px #ccc; 16 + outline-offset: -0.5em; 17 + } 18 + tangled-md-editor:not(.drag-hover) button > .condensed, 19 + tangled-md-editor.drag-hover button > .spacious { 20 + display: none; 21 + } 22 + </style> 23 + </head> 24 + <body> 25 + <div style="width: min-content"> 26 + <tangled-md-editor> 27 + <textarea rows="20" cols="80"></textarea> 28 + <button onclick="this.parentElement.insertFile()"> 29 + <span class="condensed">Add Files</span> 30 + <span class="spacious">Paste, drop, or click to add files</span> 31 + </button> 32 + </tangled-md-editor> 33 + </div> 34 + </body> 35 + </html>
+14
jsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "esnext", 4 + "module": "esnext", 5 + "checkJs": true, 6 + "strict": true, 7 + "verbatimModuleSyntax": true, 8 + "isolatedModules": true, 9 + "noUncheckedSideEffectImports": true, 10 + "moduleDetection": "force", 11 + "skipLibCheck": true 12 + }, 13 + "include": ["./*.js"] 14 + }
+180
tangled-md-editor.js
··· 1 + const template = document.createElement("template"); 2 + template.innerHTML = ` 3 + <slot></slot> 4 + 5 + <style> 6 + * { 7 + margin: 0; 8 + padding: 0; 9 + box-sizing: border-box; 10 + } 11 + :host { 12 + display: block; 13 + position: relative; 14 + } 15 + </style> 16 + `; 17 + 18 + /** 19 + * @template {HTMLElement} T 20 + * @param {T} tmpl 21 + */ 22 + function clone(tmpl) { 23 + return /** @type {T} */ (tmpl.cloneNode(true)); 24 + } 25 + 26 + export default class TangledMarkdownEditor extends HTMLElement { 27 + static tag = "tangled-md-editor"; 28 + 29 + static define(tag = this.tag) { 30 + this.tag = tag; 31 + 32 + const name = customElements.getName(this); 33 + if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); 34 + 35 + const ce = customElements.get(tag); 36 + if (ce && ce !== this) return console.warn(`${tag} already defined as ${ce.name}!`); 37 + 38 + customElements.define(tag, this); 39 + } 40 + 41 + static { 42 + const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; 43 + if (tag != "none") this.define(tag); 44 + } 45 + 46 + #shadow = this.attachShadow({ mode: "closed" }); 47 + #dragHoverClass = "drag-hover"; 48 + 49 + constructor() { 50 + super(); 51 + 52 + this.#shadow.append(clone(template).content); 53 + this.addEventListener("paste", (ev) => this.#onPaste(ev)); 54 + this.addEventListener("dragover", (ev) => this.#onDragOver(ev)); 55 + this.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); 56 + this.addEventListener("drop", (ev) => this.#onDrop(ev)); 57 + } 58 + 59 + async insertFile() { 60 + const input = document.createElement("input"); 61 + input.type = "file"; 62 + input.accept = "image/*"; 63 + input.multiple = true; 64 + input.style.display = "none"; 65 + input.addEventListener("change", () => { 66 + if (!input.files) return; 67 + for (const file of input.files) { 68 + this.#handleFile(file); 69 + } 70 + }); 71 + this.appendChild(input); 72 + input.click(); 73 + this.removeChild(input); 74 + } 75 + 76 + /** @param {ClipboardEvent} ev */ 77 + async #onPaste(ev) { 78 + const dt = ev.clipboardData; 79 + if (!dt || !dt.files || dt.files.length === 0) return; 80 + 81 + ev.preventDefault(); 82 + 83 + for (const file of dt.files) { 84 + if (!file.type.startsWith("image/")) continue; 85 + 86 + await this.#handleFile(file); 87 + } 88 + } 89 + 90 + /** @param {DragEvent} ev */ 91 + async #onDragOver(ev) { 92 + ev.preventDefault(); 93 + this.classList.add(this.#dragHoverClass); 94 + } 95 + 96 + /** @param {DragEvent} ev */ 97 + async #onDragLeave(ev) { 98 + ev.preventDefault(); 99 + this.classList.remove(this.#dragHoverClass); 100 + } 101 + 102 + /** @param {DragEvent} ev */ 103 + async #onDrop(ev) { 104 + this.classList.remove(this.#dragHoverClass); 105 + 106 + const dt = ev.dataTransfer; 107 + if (!dt || !dt.files || dt.files.length === 0) return; 108 + 109 + ev.preventDefault(); 110 + 111 + for (const file of dt.files) { 112 + if (!file.type.startsWith("image/")) continue; 113 + 114 + await this.#handleFile(file); 115 + } 116 + } 117 + 118 + /** @param {File} file */ 119 + async #handleFile(file) { 120 + const textarea = this.querySelector("textarea"); 121 + if (!textarea) return; 122 + 123 + const placeholder = `<!-- Uploading "${file.name}"... -->`; 124 + 125 + this.#insertTextAtCursor(placeholder); 126 + 127 + const url = await this.#upload(file); 128 + 129 + const finalTag = `![Image](blob://${url})`; 130 + 131 + textarea.value = textarea.value.replace(placeholder, finalTag); 132 + } 133 + 134 + /** @param {string} text */ 135 + #insertTextAtCursor(text) { 136 + const textarea = this.querySelector("textarea"); 137 + if (!textarea) return; 138 + const start = textarea.selectionStart; 139 + const end = textarea.selectionEnd; 140 + 141 + const before = textarea.value.slice(0, start); 142 + const after = textarea.value.slice(end); 143 + 144 + // add surrounding newlines if it's mid-line 145 + if (before && !before.endsWith("\n")) text = "\n\n" + text; 146 + if (after && !after.startsWith("\n")) text = text + "\n\n"; 147 + 148 + textarea.value = before + text + after; 149 + 150 + const newPos = start + text.length; 151 + textarea.selectionStart = textarea.selectionEnd = newPos; 152 + 153 + textarea.dispatchEvent( 154 + new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }) 155 + ); 156 + // textarea.dispatchEvent(new Event("input", { bubbles: true })); 157 + textarea.dispatchEvent(new Event("change", { bubbles: true })); 158 + } 159 + 160 + /** @param {File} file */ 161 + async #upload(file) { 162 + await new Promise(r => setTimeout(r, 500)); 163 + 164 + const host = this.getAttribute("host") ?? ""; 165 + try { 166 + const res = await fetch(host + "/xrpc/com.atproto.repo.uploadBlob", { 167 + method: "POST", 168 + body: file, 169 + headers: { 170 + "Content-Type": file.type, 171 + }, 172 + }); 173 + const output = await res.json(); 174 + return output.blob.ref["$link"] 175 + } catch (e) { 176 + console.error("failed to upload blob", e) 177 + return "failed" 178 + } 179 + } 180 + }