diff --git a/.gitignore b/.gitignore --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ result !.gitkeep !appview/pages/static/topbar-search.js +!appview/pages/static/markdown-editor.js out/ node_modules/ patches diff --git a/appview/pages/static/markdown-editor.js b/appview/pages/static/markdown-editor.js new file mode 100644 --- /dev/null +++ b/appview/pages/static/markdown-editor.js @@ -0,0 +1,173 @@ +export default class TangledMarkdownEditor extends HTMLElement { + static tag = "markdown-editor"; + + static define(tag = this.tag) { + this.tag = tag; + + const name = customElements.getName(this); + if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); + + const ce = customElements.get(tag); + if (ce && ce !== this) return console.warn(`${tag} already defined as ${ce.name}!`); + + customElements.define(tag, this); + } + + static { + const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; + if (tag != "none") this.define(tag); + } + + #dragHoverClass = "drag-hover"; + + constructor() { + super(); + this.textarea = this.querySelector("textarea"); + if (!this.textarea) { + console.error("textarea is missing in markdown-editor"); + return; + } + + this.querySelectorAll('[data-md-mode]').forEach(btn => { + btn.addEventListener("click", () => { + const mode = btn.dataset.mdMode; + this.querySelectorAll('[data-md-panel]').forEach(p => { + p.classList.toggle('hidden', p.dataset.mdPanel !== mode); + }); + this.querySelectorAll('[data-md-mode]').forEach(b => { + b.classList.toggle('active', b === btn); + }); + }); + }); + + // this.textarea.addEventListener("paste", (ev) => this.#onPaste(ev)); + // this.textarea.addEventListener("dragover", (ev) => this.#onDragOver(ev)); + // this.textarea.addEventListener("dragleave", (ev) => this.#onDragLeave(ev)); + // this.textarea.addEventListener("drop", (ev) => this.#onDrop(ev)); + } + + async insertFile() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.multiple = true; + input.style.display = "none"; + input.addEventListener("change", () => { + if (!input.files) return; + for (const file of input.files) { + this.#handleFile(file); + } + }); + this.appendChild(input); + input.click(); + this.removeChild(input); + } + + /** @param {ClipboardEvent} ev */ + async #onPaste(ev) { + const dt = ev.clipboardData; + if (!dt || !dt.files || dt.files.length === 0) return; + + ev.preventDefault(); + + for (const file of dt.files) { + if (!file.type.startsWith("image/")) continue; + + await this.#handleFile(file); + } + } + + /** @param {DragEvent} ev */ + async #onDragOver(ev) { + ev.preventDefault(); + this.classList.add(this.#dragHoverClass); + } + + /** @param {DragEvent} ev */ + async #onDragLeave(ev) { + ev.preventDefault(); + this.classList.remove(this.#dragHoverClass); + } + + /** @param {DragEvent} ev */ + async #onDrop(ev) { + this.classList.remove(this.#dragHoverClass); + + const dt = ev.dataTransfer; + if (!dt || !dt.files || dt.files.length === 0) return; + + ev.preventDefault(); + + for (const file of dt.files) { + if (!file.type.startsWith("image/")) continue; + + await this.#handleFile(file); + } + } + + /** @param {File} file */ + async #handleFile(file) { + const textarea = this.textarea; + if (!textarea) return; + + const placeholder = ``; + + this.#insertTextAtCursor(placeholder); + + let blob; + try { + blob = await this.#upload(file); + } catch (e) { + console.error("failed to upload blob", e) + textarea.value = textarea.value.replace(placeholder, ``); + return + } + + // TODO: insert blob itself to form + + const cid = blob.ref["$link"] + textarea.value = textarea.value.replace(placeholder, `![Image](blob://${cid})`); + } + + /** @param {string} text */ + #insertTextAtCursor(text) { + const textarea = this.textarea; + if (!textarea) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + const before = textarea.value.slice(0, start); + const after = textarea.value.slice(end); + + // add surrounding newlines if it's mid-line + if (before && !before.endsWith("\n")) text = "\n\n" + text; + if (after && !after.startsWith("\n")) text = text + "\n\n"; + + textarea.value = before + text + after; + + const newPos = start + text.length; + textarea.selectionStart = textarea.selectionEnd = newPos; + + textarea.dispatchEvent( + new InputEvent("input", { bubbles: true, inputType: "insertText", data: text }) + ); + // textarea.dispatchEvent(new Event("input", { bubbles: true })); + textarea.dispatchEvent(new Event("change", { bubbles: true })); + } + + /** @param {File} file */ + async #upload(file) { + await new Promise(r => setTimeout(r, 500)); + + const host = this.getAttribute("host") ?? ""; + const res = await fetch(host + "/xrpc/com.atproto.repo.uploadBlob", { + method: "POST", + body: file, + headers: { + "Content-Type": file.type, + }, + }); + const output = await res.json(); + return output.blob; + } +} diff --git a/appview/pages/templates/fragments/markdownEditor.html b/appview/pages/templates/fragments/markdownEditor.html --- a/appview/pages/templates/fragments/markdownEditor.html +++ b/appview/pages/templates/fragments/markdownEditor.html @@ -7,7 +7,10 @@ {{ $required := .Required }} {{ $autofocus := .AutoFocus }} {{ $placeholder := .Placeholder }} -
+
- - + {{ end }} diff --git a/appview/pages/templates/layouts/base.html b/appview/pages/templates/layouts/base.html --- a/appview/pages/templates/layouts/base.html +++ b/appview/pages/templates/layouts/base.html @@ -23,6 +23,7 @@ + diff --git a/appview/pages/templates/repo/issues/fragments/putIssue.html b/appview/pages/templates/repo/issues/fragments/putIssue.html --- a/appview/pages/templates/repo/issues/fragments/putIssue.html +++ b/appview/pages/templates/repo/issues/fragments/putIssue.html @@ -16,13 +16,12 @@
- + {{ template "fragments/markdownEditor" + (dict "Name" "body" + "Value" (and .Issue .Issue.Body) + "BlobName" "blob" + "Rows" 15 + "Placeholder" "Describe your issue. Markdown is supported.") }}