This repository has no description
0

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
committer
Tangled
date (Jul 22, 2026, 4:28 PM +0300) commit 70bf264e parent 23aa5efc change-id rprspvts
+184 -29
+1
.gitignore
··· 9 9 result 10 10 !.gitkeep 11 11 !appview/pages/static/topbar-search.js 12 + !appview/pages/static/markdown-editor.js 12 13 out/ 13 14 node_modules/ 14 15 patches
+174
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 + // 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, `![Image](blob://${cid})`); 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 + }
+2 -22
appview/pages/templates/fragments/markdownEditor.html
··· 7 7 {{ $required := .Required }} 8 8 {{ $autofocus := .AutoFocus }} 9 9 {{ $placeholder := .Placeholder }} 10 - <div 10 + <markdown-editor 11 11 class="flex flex-col gap-2" 12 - data-md-editor 13 12 hx-disinherit="*" 14 13 hx-include="this" 15 14 > ··· 46 45 <span class="text-gray-400 dark:text-gray-500 italic">Loading preview...</span> 47 46 </div> 48 47 </div> 49 - </div> 50 - <script> 51 - (() => { 52 - if (window.__mdEditorWired) return; 53 - window.__mdEditorWired = true; 54 - document.body.addEventListener('click', (e) => { 55 - const btn = e.target.closest('[data-md-mode]'); 56 - if (!btn) return; 57 - const editor = btn.closest('[data-md-editor]'); 58 - if (!editor) return; 59 - const mode = btn.dataset.mdMode; 60 - editor.querySelectorAll('[data-md-panel]').forEach(p => { 61 - p.classList.toggle('hidden', p.dataset.mdPanel !== mode); 62 - }); 63 - editor.querySelectorAll('[data-md-mode]').forEach(b => { 64 - b.classList.toggle('active', b === btn); 65 - }); 66 - }); 67 - })(); 68 - </script> 48 + </markdown-editor> 69 49 {{ 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>