This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / pages / static / markdown-editor.js
8.1 kB 248 lines
1export default class TangledMarkdownEditor extends HTMLElement { 2 static tag = "markdown-editor"; 3 4 static define(tag = this.tag) { 5 this.tag = tag; 6 7 const name = customElements.getName(this); 8 if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); 9 10 const ce = customElements.get(tag); 11 if (ce && ce !== this) return console.warn(`${tag} already defined as ${ce.name}!`); 12 13 customElements.define(tag, this); 14 } 15 16 static { 17 const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; 18 if (tag != "none") this.define(tag); 19 } 20 21 #dragHoverClass = "drag-hover"; 22 #uploadCounter = 0; 23 // cid -> object URL, to preview an image before its blob is committed 24 #objectUrls = new Map(); 25 26 constructor() { 27 super(); 28 this.textarea = this.querySelector("textarea"); 29 if (!this.textarea) { 30 console.error("textarea is missing in markdown-editor"); 31 return; 32 } 33 34 this.querySelectorAll('[data-md-mode]').forEach(btn => { 35 btn.addEventListener("click", () => { 36 const mode = btn.dataset.mdMode; 37 this.querySelectorAll('[data-md-panel]').forEach(p => { 38 p.classList.toggle('hidden', p.dataset.mdPanel !== mode); 39 }); 40 this.querySelectorAll('[data-md-mode]').forEach(b => { 41 b.classList.toggle('active', b === btn); 42 }); 43 }); 44 }); 45 46 this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); 47 this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); 48 this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); 49 this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); 50 51 // swap local object URLs into rendered previews (getBlob can't serve uncommitted blobs) 52 this.addEventListener("htmx:afterSwap", () => this.#hydratePreview()); 53 } 54 55 disconnectedCallback() { 56 for (const url of this.#objectUrls.values()) URL.revokeObjectURL(url); 57 this.#objectUrls.clear(); 58 } 59 60 #hydratePreview() { 61 this.querySelectorAll("[data-md-preview] img[data-blob-cid]").forEach(img => { 62 const url = this.#objectUrls.get(img.dataset.blobCid); 63 if (url) img.src = url; 64 }); 65 } 66 67 // name of the hidden input carrying blob refs back to the form 68 get #blobName() { 69 return this.getAttribute("blob-name") || "blobs"; 70 } 71 72 async insertFile() { 73 const input = document.createElement("input"); 74 input.type = "file"; 75 input.accept = "image/*"; 76 input.multiple = true; 77 input.addEventListener("change", () => { 78 if (!input.files) return; 79 for (const file of input.files) { 80 this.#handleFile(file); 81 } 82 }); 83 input.click(); 84 } 85 86 /** @param {ClipboardEvent} ev */ 87 async #onPaste(ev) { 88 const dt = ev.clipboardData; 89 if (!dt || !dt.files || dt.files.length === 0) return; 90 91 ev.preventDefault(); 92 93 for (const file of dt.files) { 94 if (!file.type.startsWith("image/")) continue; 95 96 await this.#handleFile(file); 97 } 98 } 99 100 /** @param {DragEvent} ev */ 101 async #onDragOver(ev) { 102 ev.preventDefault(); 103 this.classList.add(this.#dragHoverClass); 104 } 105 106 /** @param {DragEvent} ev */ 107 async #onDragLeave(ev) { 108 ev.preventDefault(); 109 this.classList.remove(this.#dragHoverClass); 110 } 111 112 /** @param {DragEvent} ev */ 113 async #onDrop(ev) { 114 this.classList.remove(this.#dragHoverClass); 115 116 const dt = ev.dataTransfer; 117 if (!dt || !dt.files || dt.files.length === 0) return; 118 119 ev.preventDefault(); 120 121 for (const file of dt.files) { 122 if (!file.type.startsWith("image/")) continue; 123 124 await this.#handleFile(file); 125 } 126 } 127 128 /** @param {File} file */ 129 async #handleFile(file) { 130 const textarea = this.textarea; 131 if (!textarea) return; 132 133 if (!file || !file.type.startsWith("image/")) { 134 console.warn("skipping non-image file", file && file.name); 135 return; 136 } 137 138 let bytes; 139 try { 140 bytes = await file.arrayBuffer(); 141 } catch (e) { 142 console.error("failed to read file", e); 143 return; 144 } 145 if (bytes.byteLength === 0) { 146 console.error("skipping empty file", file.name); 147 return; 148 } 149 150 const token = ++this.#uploadCounter; 151 const placeholder = `<!-- Uploading "${file.name}" (#${token})... -->`; 152 153 this.#insertTextAtCursor(placeholder); 154 155 let result; 156 try { 157 result = await this.#upload(bytes, file.type); 158 } catch (e) { 159 console.error("failed to upload blob", e); 160 this.#replaceInTextarea(placeholder, `<!-- Failed to upload "${file.name}": ${e.message} -->`); 161 return; 162 } 163 164 this.#replaceInTextarea(placeholder, `![Image](${result.uri})`); 165 this.#addBlobInput(result.blob); 166 167 // stash a local object URL keyed by cid (echoed back as data-blob-cid) for Preview 168 const cid = result.uri.split("/").pop(); 169 if (cid) { 170 const prev = this.#objectUrls.get(cid); 171 if (prev) URL.revokeObjectURL(prev); 172 this.#objectUrls.set(cid, URL.createObjectURL(new Blob([bytes], { type: file.type }))); 173 } 174 } 175 176 /** @param {string} text */ 177 #insertTextAtCursor(text) { 178 const textarea = this.textarea; 179 if (!textarea) return; 180 const start = textarea.selectionStart; 181 const end = textarea.selectionEnd; 182 183 const before = textarea.value.slice(0, start); 184 const after = textarea.value.slice(end); 185 186 // add surrounding newlines if it's mid-line 187 if (before && !before.endsWith("\n")) text = "\n\n" + text; 188 if (after && !after.startsWith("\n")) text = text + "\n\n"; 189 190 textarea.value = before + text + after; 191 192 const newPos = start + text.length; 193 textarea.selectionStart = textarea.selectionEnd = newPos; 194 195 this.#fireInput(text); 196 } 197 198 /** @param {string} needle @param {string} replacement */ 199 #replaceInTextarea(needle, replacement) { 200 const textarea = this.textarea; 201 if (!textarea) return; 202 // function replacer so `$` in the filename isn't read as a substitution pattern 203 textarea.value = textarea.value.replace(needle, () => replacement); 204 this.#fireInput(replacement); 205 } 206 207 #fireInput(data = "") { 208 const textarea = this.textarea; 209 if (!textarea) return; 210 textarea.dispatchEvent( 211 new InputEvent("input", { bubbles: true, inputType: "insertText", data }) 212 ); 213 textarea.dispatchEvent(new Event("change", { bubbles: true })); 214 } 215 216 /** @param {object} blob */ 217 #addBlobInput(blob) { 218 const input = document.createElement("input"); 219 input.type = "hidden"; 220 input.name = this.#blobName; 221 input.value = JSON.stringify(blob); 222 this.appendChild(input); 223 } 224 225 /** @param {ArrayBuffer} bytes @param {string} contentType */ 226 async #upload(bytes, contentType) { 227 const host = this.getAttribute("host") ?? ""; 228 const res = await fetch(host + "/markup/upload", { 229 method: "POST", 230 body: bytes, 231 headers: { 232 "Content-Type": contentType, 233 }, 234 }); 235 if (!res.ok) { 236 let msg = `upload failed (${res.status})`; 237 try { 238 const err = await res.json(); 239 if (err && err.error) msg = err.error; 240 } catch { 241 // non-JSON error body; keep the status-based message 242 } 243 throw new Error(msg); 244 } 245 // { blob, did, uri } 246 return await res.json(); 247 } 248}