Monorepo for Tangled tangled.org
5

Configure Feed

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

appview/pages: markdown-editor web component

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

author
Seongmin Lee
date (Jun 21, 2026, 8:28 PM +0900) commit 6b7b5868 parent 8fad3408 change-id rprspvts
+187 -29
+1
.gitignore
··· 7 7 result 8 8 !.gitkeep 9 9 !appview/pages/static/topbar-search.js 10 + !appview/pages/static/markdown-editor.js 10 11 out/ 11 12 node_modules/ 12 13 patches
+173
appview/pages/static/markdown-editor.js
··· 1 + export 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 + // this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); 44 + // this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); 45 + // this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); 46 + // this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); 47 + } 48 + 49 + async insertFile() { 50 + const input = document.createElement("input"); 51 + input.type = "file"; 52 + input.accept = "image/*"; 53 + input.multiple = true; 54 + input.style.display = "none"; 55 + input.addEventListener("change", () => { 56 + if (!input.files) return; 57 + for (const file of input.files) { 58 + this.#handleFile(file); 59 + } 60 + }); 61 + this.appendChild(input); 62 + input.click(); 63 + this.removeChild(input); 64 + } 65 + 66 + /** @param {ClipboardEvent} ev */ 67 + async #onPaste(ev) { 68 + const dt = ev.clipboardData; 69 + if (!dt || !dt.files || dt.files.length === 0) return; 70 + 71 + ev.preventDefault(); 72 + 73 + for (const file of dt.files) { 74 + if (!file.type.startsWith("image/")) continue; 75 + 76 + await this.#handleFile(file); 77 + } 78 + } 79 + 80 + /** @param {DragEvent} ev */ 81 + async #onDragOver(ev) { 82 + ev.preventDefault(); 83 + this.classList.add(this.#dragHoverClass); 84 + } 85 + 86 + /** @param {DragEvent} ev */ 87 + async #onDragLeave(ev) { 88 + ev.preventDefault(); 89 + this.classList.remove(this.#dragHoverClass); 90 + } 91 + 92 + /** @param {DragEvent} ev */ 93 + async #onDrop(ev) { 94 + this.classList.remove(this.#dragHoverClass); 95 + 96 + const dt = ev.dataTransfer; 97 + if (!dt || !dt.files || dt.files.length === 0) return; 98 + 99 + ev.preventDefault(); 100 + 101 + for (const file of dt.files) { 102 + if (!file.type.startsWith("image/")) continue; 103 + 104 + await this.#handleFile(file); 105 + } 106 + } 107 + 108 + /** @param {File} file */ 109 + async #handleFile(file) { 110 + const textarea = this.textarea; 111 + if (!textarea) return; 112 + 113 + const placeholder = `<!-- Uploading "${file.name}"... -->`; 114 + 115 + this.#insertTextAtCursor(placeholder); 116 + 117 + let blob; 118 + try { 119 + blob = await this.#upload(file); 120 + } catch (e) { 121 + console.error("failed to upload blob", e) 122 + textarea.value = textarea.value.replace(placeholder, `<!-- Failed to upload "${file.name}". -->`); 123 + return 124 + } 125 + 126 + // TODO: insert blob itself to form 127 + 128 + const cid = blob.ref["$link"] 129 + textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); 130 + } 131 + 132 + /** @param {string} text */ 133 + #insertTextAtCursor(text) { 134 + const textarea = this.textarea; 135 + if (!textarea) return; 136 + const start = textarea.selectionStart; 137 + const end = textarea.selectionEnd; 138 + 139 + const before = textarea.value.slice(0, start); 140 + const after = textarea.value.slice(end); 141 + 142 + // add surrounding newlines if it's mid-line 143 + if (before && !before.endsWith("\n")) text = "\n\n" + text; 144 + if (after && !after.startsWith("\n")) text = text + "\n\n"; 145 + 146 + textarea.value = before + text + after; 147 + 148 + const newPos = start + text.length; 149 + textarea.selectionStart = textarea.selectionEnd = newPos; 150 + 151 + textarea.dispatchEvent( 152 + new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }) 153 + ); 154 + // textarea.dispatchEvent(new Event("input", { bubbles: true })); 155 + textarea.dispatchEvent(new Event("change", { bubbles: true })); 156 + } 157 + 158 + /** @param {File} file */ 159 + async #upload(file) { 160 + await new Promise(r => setTimeout(r, 500)); 161 + 162 + const host = this.getAttribute("host") ?? ""; 163 + const res = await fetch(host + "/xrpc/com.atproto.repo.uploadBlob", { 164 + method: "POST", 165 + body: file, 166 + headers: { 167 + "Content-Type": file.type, 168 + }, 169 + }); 170 + const output = await res.json(); 171 + return output.blob; 172 + } 173 + }
+6 -22
appview/pages/templates/fragments/markdownEditor.html
··· 7 7 {{ $required := .Required }} 8 8 {{ $autofocus := .AutoFocus }} 9 9 {{ $placeholder := .Placeholder }} 10 - <div class="flex flex-col gap-2" data-md-editor> 10 + <markdown-editor 11 + class="flex flex-col gap-2" 12 + hx-disinherit="*" 13 + > 11 14 <div class="btn-group self-start text-gray-600 dark:text-gray-300"> 12 15 <button type="button" data-md-mode="write" 13 16 class="btn-group-item active group"> ··· 16 19 </button> 17 20 <button type="button" data-md-mode="preview" 18 21 hx-post="/markup/preview" 19 - hx-vals='js:{body: this.closest("[data-md-editor]").querySelector("textarea").value}' 22 + hx-vals='js:{body: this.closest("markdown-editor").querySelector("textarea").value}' 20 23 hx-params="body" 21 24 hx-target="next [data-md-preview]" 22 25 hx-swap="innerHTML" ··· 43 46 <span class="text-gray-400 dark:text-gray-500 italic">Loading preview...</span> 44 47 </div> 45 48 </div> 46 - </div> 47 - <script> 48 - (() => { 49 - if (window.__mdEditorWired) return; 50 - window.__mdEditorWired = true; 51 - document.body.addEventListener('click', (e) => { 52 - const btn = e.target.closest('[data-md-mode]'); 53 - if (!btn) return; 54 - const editor = btn.closest('[data-md-editor]'); 55 - if (!editor) return; 56 - const mode = btn.dataset.mdMode; 57 - editor.querySelectorAll('[data-md-panel]').forEach(p => { 58 - p.classList.toggle('hidden', p.dataset.mdPanel !== mode); 59 - }); 60 - editor.querySelectorAll('[data-md-mode]').forEach(b => { 61 - b.classList.toggle('active', b === btn); 62 - }); 63 - }); 64 - })(); 65 - </script> 49 + </markdown-editor> 66 50 {{ end }}
+1
appview/pages/templates/layouts/base.html
··· 23 23 <script defer src="/static/htmx.min.js"></script> 24 24 <script defer src="/static/htmx-ext-ws.min.js"></script> 25 25 <script defer src="/static/actor-typeahead.js" type="module"></script> 26 + <script defer src="/static/markdown-editor.js" type="module"></script> 26 27 <script defer src="/static/topbar-search.js"></script> 27 28 28 29 <link rel="icon" href="/static/logos/dolly.ico" sizes="48x48"/>
+6 -7
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 16 16 </div> 17 17 <div> 18 18 <label for="body">Body</label> 19 - <textarea 20 - name="body" 21 - id="body" 22 - rows="15" 23 - class="w-full resize-y" 24 - placeholder="Describe your issue. Markdown is supported." 25 - >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea> 19 + {{ template "fragments/markdownEditor" 20 + (dict "Name" "body" 21 + "Value" (and .Issue .Issue.Body) 22 + "BlobName" "blob" 23 + "Rows" 15 24 + "Placeholder" "Describe your issue. Markdown is supported.") }} 26 25 </div> 27 26 <div class="flex justify-between"> 28 27 <div id="issues" class="error"></div>