source: Klonkt/src/assets/js/reply-editor.js@ ff3b8ce

main
Last change on this file since ff3b8ce was ff3b8ce, checked in by Robin <roboburr@…>, 7 weeks ago

Polish: reply editor styled as a sibling of the post editor (klonkt-demo-3dp)

The rich reply editor worked but looked ad-hoc. This is a visual pass, no
behavior change:

  • Toolbar: text glyphs (B/I/link/list/quote/paperclip) replaced with the post editor's Feather SVG icons; 32px buttons, transparent border, 6px radius, hover only on hover-capable devices, and an is-active accent-filled state reflecting the caret's formatting (bold/italic/list) via queryCommandState.
  • Editor box: framed with a rule border and an accent focus ring, tidy paragraph/list/blockquote/link spacing.
  • Footer, language select, mentions chips and attachment chips restyled to the same rule/paper/accent tokens; the mobile full-screen top bar gets a divider.

Reuses the theme's --rule/--paper/--paper-2/--accent tokens so it tracks light
and dark. 97 tests green; browser-verified: 32px SVG buttons + separator, framed
editor (10px radius), bold shows is-active on selection, mobile full-screen top
bar order and z-index intact.

Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 8.9 KB
Line 
1// Rich reply editor — upgrades every form[data-re] (partials/reply-editor.ejs)
2// from a plain textarea to a contenteditable with a small toolbar and, on
3// narrow screens (≤700px), a full-screen compose overlay (the right pattern on
4// mobile). Progressive enhancement: without this file the textarea submits as
5// before. On submit: `content` = editor HTML (server sanitizes), `text` =
6// plain-text fallback.
7(function () {
8 'use strict';
9 if (window.__replyEditorInit) return;
10 window.__replyEditorInit = true;
11
12 var MOBILE = '(max-width: 700px)';
13
14 function init(form) {
15 if (form.__re) return;
16 form.__re = true;
17
18 var ta = form.querySelector('textarea[name="text"]');
19 var ed = form.querySelector('.re-editor');
20 var bar = form.querySelector('.re-toolbar');
21 var head = form.querySelector('.re-head');
22 var foot = form.querySelector('.re-foot');
23 var lang = form.querySelector('.re-lang');
24 var hidden = form.querySelector('input[name="content"]');
25 if (!ta || !ed || !hidden) return;
26
27 // Upgrade: swap the textarea for the editor.
28 ta.hidden = true;
29 ta.required = false;
30 ed.hidden = false;
31 bar.hidden = false;
32 if (lang) lang.hidden = false;
33
34 // ── Media attachments: picker (📎), drag/drop and paste ──────────────
35 var attWrap = form.querySelector('.re-attachments');
36 var attField = form.querySelector('input[name="attachments"]');
37 var fileInput = form.querySelector('.re-file');
38 var canAttach = !!(attWrap && attField && fileInput); // edit mode renders without media
39 var attachments = [];
40
41 function syncAtt() {
42 if (!canAttach) return;
43 attField.value = attachments.length ? JSON.stringify(attachments) : '';
44 attWrap.hidden = attachments.length === 0;
45 }
46 function addChip(a) {
47 var chip = document.createElement('span');
48 chip.className = 're-att';
49 if (a.mediaType.indexOf('image/') === 0) {
50 var img = document.createElement('img');
51 img.src = a.url; img.alt = a.name || '';
52 chip.appendChild(img);
53 } else {
54 chip.appendChild(document.createTextNode((a.mediaType.indexOf('audio/') === 0 ? '🎵 ' : '🎬 ') + (a.name || a.mediaType)));
55 }
56 var del = document.createElement('button');
57 del.type = 'button'; del.className = 're-att-del'; del.textContent = '×';
58 del.addEventListener('click', function () {
59 attachments = attachments.filter(function (x) { return x !== a; });
60 chip.remove(); syncAtt();
61 });
62 chip.appendChild(del);
63 attWrap.appendChild(chip);
64 }
65 function uploadFiles(files) {
66 if (!canAttach) return;
67 Array.prototype.forEach.call(files, function (file) {
68 if (!/^(image|audio|video)\//.test(file.type) || attachments.length >= 4) return;
69 var chip = document.createElement('span');
70 chip.className = 're-att re-att-busy';
71 chip.textContent = '⏳ ' + file.name;
72 attWrap.hidden = false;
73 attWrap.appendChild(chip);
74 var fd = new FormData();
75 fd.append('media', file);
76 fetch(form.getAttribute('data-upload'), { method: 'POST', body: fd })
77 .then(function (r) { return r.json().then(function (j) { return r.ok ? j : Promise.reject(j); }); })
78 .then(function (j) {
79 chip.remove();
80 var a = { url: j.url, mediaType: j.mediaType, name: j.name || file.name };
81 attachments.push(a); addChip(a); syncAtt();
82 })
83 .catch(function (err) {
84 chip.className = 're-att re-att-err';
85 chip.textContent = (form.getAttribute('data-upload-err') || 'Upload failed') + (err && err.error ? ': ' + err.error : '');
86 setTimeout(function () { chip.remove(); syncAtt(); }, 5000);
87 });
88 });
89 }
90
91 // Toolbar commands (execCommand is deprecated-but-universal; same approach
92 // as the post editor).
93 bar.addEventListener('click', function (e) {
94 var btn = e.target.closest('button[data-cmd]');
95 if (!btn) return;
96 e.preventDefault();
97 var cmd = btn.getAttribute('data-cmd');
98 if (cmd === 'attach') { if (fileInput) fileInput.click(); return; } // no editor focus: keeps the picker usable on mobile
99 ed.focus();
100 if (cmd === 'bold') document.execCommand('bold');
101 else if (cmd === 'italic') document.execCommand('italic');
102 else if (cmd === 'list') document.execCommand('insertUnorderedList');
103 else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
104 else if (cmd === 'link') {
105 var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
106 if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
107 }
108 });
109
110 // Reflect the current formatting on the toolbar (bold/italic/list active),
111 // like the post editor, while the caret is inside this editor.
112 function syncToolbar() {
113 if (document.activeElement !== ed) return;
114 var map = { bold: 'bold', italic: 'italic', list: 'insertUnorderedList' };
115 bar.querySelectorAll('button[data-cmd]').forEach(function (b) {
116 var c = map[b.getAttribute('data-cmd')];
117 if (!c) return;
118 var on = false; try { on = document.queryCommandState(c); } catch (e) { on = false; }
119 b.classList.toggle('is-active', on);
120 });
121 }
122 document.addEventListener('selectionchange', syncToolbar);
123 ed.addEventListener('keyup', syncToolbar);
124 ed.addEventListener('mouseup', syncToolbar);
125
126 if (fileInput) fileInput.addEventListener('change', function () {
127 uploadFiles(fileInput.files);
128 fileInput.value = '';
129 });
130
131 // Paste: files become attachments; text pastes as plain text (rich paste
132 // becomes messy HTML; formatting is what the toolbar is for).
133 ed.addEventListener('paste', function (e) {
134 var cd = e.clipboardData || window.clipboardData;
135 if (cd.files && cd.files.length) {
136 e.preventDefault(); // never let the browser inline-paste a file as base64
137 uploadFiles(cd.files); // no-op without the attach UI (edit mode)
138 return;
139 }
140 var txt = cd.getData('text/plain');
141 if (!txt) return;
142 e.preventDefault();
143 document.execCommand('insertText', false, txt);
144 });
145
146 // Drag/drop media onto the editor.
147 if (canAttach) {
148 ed.addEventListener('dragover', function (e) {
149 if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
150 e.preventDefault();
151 ed.classList.add('re-drop');
152 }
153 });
154 ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
155 ed.addEventListener('drop', function (e) {
156 ed.classList.remove('re-drop');
157 if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
158 e.preventDefault();
159 uploadFiles(e.dataTransfer.files);
160 }
161 });
162 }
163
164 // Mentions bar (u02): removing a chip stops addressing that partner. The
165 // hidden field always mirrors the remaining chips.
166 var mField = form.querySelector('input[name="mentions"]');
167 if (mField) {
168 var parts = [];
169 try { parts = JSON.parse(mField.value || '[]'); } catch (e) { parts = []; }
170 form.querySelectorAll('.re-mention-del').forEach(function (btn) {
171 btn.addEventListener('click', function () {
172 var chip = btn.closest('.re-mention');
173 var uri = chip.getAttribute('data-uri');
174 parts = parts.filter(function (p) { return p.uri !== uri; });
175 mField.value = JSON.stringify(parts);
176 chip.remove();
177 });
178 });
179 }
180
181 // Full-screen compose on mobile: enter on focus, leave via ×.
182 function setFull(on) {
183 form.classList.toggle('re-full', on);
184 head.hidden = !on;
185 document.documentElement.classList.toggle('re-lock', on);
186 if (on) ed.focus();
187 }
188 ed.addEventListener('focus', function () {
189 if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
190 });
191 var cancel = form.querySelector('.re-cancel');
192 if (cancel) cancel.addEventListener('click', function () { setFull(false); });
193
194 // Serialize on submit; block truly empty replies (media-only is fine).
195 form.addEventListener('submit', function (e) {
196 var html = ed.innerHTML.trim();
197 var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
198 if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
199 hidden.value = plain ? html : '';
200 ta.value = plain;
201 document.documentElement.classList.remove('re-lock');
202 });
203
204 // Open <details> parents (fedi-node) keep working: nothing special needed.
205 void foot;
206 }
207
208 function initAll() {
209 document.querySelectorAll('form[data-re]').forEach(init);
210 }
211 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll);
212 else initAll();
213})();
Note: See TracBrowser for help on using the repository browser.