This repository has no description
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, ``);
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}