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
23 constructor() {
24 super();
25 this.textarea = this.querySelector("textarea");
26 if (!this.textarea) {
27 console.error("textarea is missing in markdown-editor");
28 return;
29 }
30
31 this.querySelectorAll('[data-md-mode]').forEach(btn => {
32 btn.addEventListener("click", () => {
33 const mode = btn.dataset.mdMode;
34 this.querySelectorAll('[data-md-panel]').forEach(p => {
35 p.classList.toggle('hidden', p.dataset.mdPanel !== mode);
36 });
37 this.querySelectorAll('[data-md-mode]').forEach(b => {
38 b.classList.toggle('active', b === btn);
39 });
40 });
41 });
42
43 // TODO: blob upload support
44 // this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev));
45 // this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev));
46 // this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev));
47 // this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev));
48 }
49
50 async insertFile() {
51 const input = document.createElement("input");
52 input.type = "file";
53 input.accept = "image/*";
54 input.multiple = true;
55 input.style.display = "none";
56 input.addEventListener("change", () => {
57 if (!input.files) return;
58 for (const file of input.files) {
59 this.#handleFile(file);
60 }
61 });
62 this.appendChild(input);
63 input.click();
64 this.removeChild(input);
65 }
66
67 /** @param {ClipboardEvent} ev */
68 async #onPaste(ev) {
69 const dt = ev.clipboardData;
70 if (!dt || !dt.files || dt.files.length === 0) return;
71
72 ev.preventDefault();
73
74 for (const file of dt.files) {
75 if (!file.type.startsWith("image/")) continue;
76
77 await this.#handleFile(file);
78 }
79 }
80
81 /** @param {DragEvent} ev */
82 async #onDragOver(ev) {
83 ev.preventDefault();
84 this.classList.add(this.#dragHoverClass);
85 }
86
87 /** @param {DragEvent} ev */
88 async #onDragLeave(ev) {
89 ev.preventDefault();
90 this.classList.remove(this.#dragHoverClass);
91 }
92
93 /** @param {DragEvent} ev */
94 async #onDrop(ev) {
95 this.classList.remove(this.#dragHoverClass);
96
97 const dt = ev.dataTransfer;
98 if (!dt || !dt.files || dt.files.length === 0) return;
99
100 ev.preventDefault();
101
102 for (const file of dt.files) {
103 if (!file.type.startsWith("image/")) continue;
104
105 await this.#handleFile(file);
106 }
107 }
108
109 /** @param {File} file */
110 async #handleFile(file) {
111 const textarea = this.textarea;
112 if (!textarea) return;
113
114 const placeholder = `<!-- Uploading "${file.name}"... -->`;
115
116 this.#insertTextAtCursor(placeholder);
117
118 let blob;
119 try {
120 blob = await this.#upload(file);
121 } catch (e) {
122 console.error("failed to upload blob", e)
123 textarea.value = textarea.value.replace(placeholder, `<!-- Failed to upload "${file.name}". -->`);
124 return
125 }
126
127 // TODO: insert blob itself to form
128
129 const cid = blob.ref["$link"]
130 textarea.value = textarea.value.replace(placeholder, ``);
131 }
132
133 /** @param {string} text */
134 #insertTextAtCursor(text) {
135 const textarea = this.textarea;
136 if (!textarea) return;
137 const start = textarea.selectionStart;
138 const end = textarea.selectionEnd;
139
140 const before = textarea.value.slice(0, start);
141 const after = textarea.value.slice(end);
142
143 // add surrounding newlines if it's mid-line
144 if (before && !before.endsWith("\n")) text = "\n\n" + text;
145 if (after && !after.startsWith("\n")) text = text + "\n\n";
146
147 textarea.value = before + text + after;
148
149 const newPos = start + text.length;
150 textarea.selectionStart = textarea.selectionEnd = newPos;
151
152 textarea.dispatchEvent(
153 new InputEvent("input", { bubbles: true, inputType: "insertText", data: text })
154 );
155 // textarea.dispatchEvent(new Event("input", { bubbles: true }));
156 textarea.dispatchEvent(new Event("change", { bubbles: true }));
157 }
158
159 /** @param {File} file */
160 async #upload(file) {
161 await new Promise(r => setTimeout(r, 500));
162
163 const host = this.getAttribute("host") ?? "";
164 const res = await fetch(host + "/xrpc/com.atproto.repo.uploadBlob", {
165 method: "POST",
166 body: file,
167 headers: {
168 "Content-Type": file.type,
169 },
170 });
171 const output = await res.json();
172 return output.blob;
173 }
174}