// Rich reply editor — upgrades every form[data-re] (partials/reply-editor.ejs) // from a plain textarea to a contenteditable with a small toolbar and, on // narrow screens (≤700px), a full-screen compose overlay (the right pattern on // mobile). Progressive enhancement: without this file the textarea submits as // before. On submit: `content` = editor HTML (server sanitizes), `text` = // plain-text fallback. (function () { 'use strict'; if (window.__replyEditorInit) return; window.__replyEditorInit = true; var MOBILE = '(max-width: 700px)'; function init(form) { if (form.__re) return; form.__re = true; var ta = form.querySelector('textarea[name="text"]'); var ed = form.querySelector('.re-editor'); var bar = form.querySelector('.re-toolbar'); var head = form.querySelector('.re-head'); var foot = form.querySelector('.re-foot'); var lang = form.querySelector('.re-lang'); var hidden = form.querySelector('input[name="content"]'); if (!ta || !ed || !hidden) return; // Upgrade: swap the textarea for the editor. ta.hidden = true; ta.required = false; ed.hidden = false; bar.hidden = false; if (lang) lang.hidden = false; // ── Media attachments: picker (📎), drag/drop and paste ────────────── var attWrap = form.querySelector('.re-attachments'); var attField = form.querySelector('input[name="attachments"]'); var fileInput = form.querySelector('.re-file'); var canAttach = !!(attWrap && attField && fileInput); // edit mode renders without media var attachments = []; function syncAtt() { if (!canAttach) return; attField.value = attachments.length ? JSON.stringify(attachments) : ''; attWrap.hidden = attachments.length === 0; } function addChip(a) { var chip = document.createElement('span'); chip.className = 're-att'; if (a.mediaType.indexOf('image/') === 0) { var img = document.createElement('img'); img.src = a.url; img.alt = a.name || ''; chip.appendChild(img); } else { chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType))); } var del = document.createElement('button'); del.type = 'button'; del.className = 're-att-del'; del.textContent = '×'; del.addEventListener('click', function () { attachments = attachments.filter(function (x) { return x !== a; }); chip.remove(); syncAtt(); }); chip.appendChild(del); attWrap.appendChild(chip); } function uploadFiles(files) { if (!canAttach) return; Array.prototype.forEach.call(files, function (file) { if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return; var chip = document.createElement('span'); chip.className = 're-att re-att-busy'; chip.textContent = '⏳ ' + file.name; attWrap.hidden = false; attWrap.appendChild(chip); var fd = new FormData(); fd.append('media', file); fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd }) .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); }) .then(function (j) { chip.remove(); var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name }; attachments.push(a); addChip(a); syncAtt(); }) .catch(function (err) { chip.className = 're-att re-att-err'; chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : ''); setTimeout(function () { chip.remove(); syncAtt(); }, 5000); }); }); } // Toolbar commands (execCommand is deprecated-but-universal; same approach // as the post editor). bar.addEventListener('click', function (e) { var btn = e.target.closest('button[data-cmd]'); if (!btn) return; e.preventDefault(); var cmd = btn.getAttribute('data-cmd'); if (cmd === 'attach') { if (fileInput) fileInput.click(); return; } // no editor focus: keeps the picker usable on mobile ed.focus(); if (cmd === 'bold') document.execCommand('bold'); else if (cmd === 'italic') document.execCommand('italic'); else if (cmd === 'list') document.execCommand('insertUnorderedList'); else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote'); else if (cmd === 'link') { var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL'); if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim()); } }); // Reflect the current formatting on the toolbar (bold/italic/list active), // like the post editor, while the caret is inside this editor. function syncToolbar() { if (document.activeElement !== ed) return; var map = { bold: 'bold', italic: 'italic', list: 'insertUnorderedList' }; bar.querySelectorAll('button[data-cmd]').forEach(function (b) { var c = map[b.getAttribute('data-cmd')]; if (!c) return; var on = false; try { on = document.queryCommandState(c); } catch (e) { on = false; } b.classList.toggle('is-active', on); }); } document.addEventListener('selectionchange', syncToolbar); ed.addEventListener('keyup', syncToolbar); ed.addEventListener('mouseup', syncToolbar); if (fileInput) fileInput.addEventListener('change', function () { uploadFiles(fileInput.files); fileInput.value = ''; }); // Paste: files become attachments; text pastes as plain text (rich paste // becomes messy HTML; formatting is what the toolbar is for). ed.addEventListener('paste', function (e) { var cd = e.clipboardData || window.clipboardData; if (cd.files && cd.files.length) { e.preventDefault(); // never let the browser inline-paste a file as base64 uploadFiles(cd.files); // no-op without the attach UI (edit mode) return; } var txt = cd.getData('text/plain'); if (!txt) return; e.preventDefault(); document.execCommand('insertText', false, txt); }); // Drag/drop media onto the editor. if (canAttach) { ed.addEventListener('dragover', function (e) { if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) { e.preventDefault(); ed.classList.add('re-drop'); } }); ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); }); ed.addEventListener('drop', function (e) { ed.classList.remove('re-drop'); if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) { e.preventDefault(); uploadFiles(e.dataTransfer.files); } }); } // Mentions bar (u02): removing a chip stops addressing that partner. The // hidden field always mirrors the remaining chips. var mField = form.querySelector('input[name="mentions"]'); if (mField) { var parts = []; try { parts = JSON.parse(mField.value || '[]'); } catch (e) { parts = []; } form.querySelectorAll('.re-mention-del').forEach(function (btn) { btn.addEventListener('click', function () { var chip = btn.closest('.re-mention'); var uri = chip.getAttribute('data-uri'); parts = parts.filter(function (p) { return p.uri !== uri; }); mField.value = JSON.stringify(parts); chip.remove(); }); }); } // Full-screen compose on mobile: enter on focus, leave via ×. function setFull(on) { form.classList.toggle('re-full', on); head.hidden = !on; document.documentElement.classList.toggle('re-lock', on); if (on) ed.focus(); } ed.addEventListener('focus', function () { if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true); }); var cancel = form.querySelector('.re-cancel'); if (cancel) cancel.addEventListener('click', function () { setFull(false); }); // Serialize on submit; block truly empty replies (media-only is fine). form.addEventListener('submit', function (e) { var html = ed.innerHTML.trim(); var plain = (ed.innerText || '').replace(/ /g, ' ').trim(); if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; } hidden.value = plain ? html : ''; ta.value = plain; document.documentElement.classList.remove('re-lock'); }); // Open
parents (fedi-node) keep working: nothing special needed. void foot; } function initAll() { document.querySelectorAll('form[data-re]').forEach(init); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll); else initAll(); })();