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

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

Feature: rich editor on the edit forms too (klonkt-demo-c7f, last slice)

Editing a sent reply (Messages + the interact page's manage list) now opens the
same shared editor as new replies, instead of a bare textarea. Prutter stays
plain on purpose (Robin: not part of this).

  • reply-editor partial: initialHtml/initialText prefill for edit mode, and a noAttach flag that omits the media UI (an edit never touches attachments). JS grew canAttach guards so the component runs without the attach elements; pasted files are still swallowed there rather than becoming base64 blobs.
  • deliverOutboxUpdate(site, id, text, {html, language}): rich path with the same sanitize + enrichment + mention-first-paragraph logic as deliverReply; language updated via COALESCE (bogus codes keep the old one); attachments survive untouched. Plain path unchanged.
  • /fediverse/:id/edit passes content + language; listOutbox and the Messages sent-projection carry language so the select preselects correctly.
  • The interact page's edit-toggle focuses the rich editor when present.

2 new tests (93 green). Browser-verified on /messages: prefilled editor
(mention-stripped HTML + plain fallback + language preselected, no paperclip),
edit saved -> content replaced with markup, mention re-attached inline,
language nl->en, no console errors.

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

  • Property mode set to 100644
File size: 7.4 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 if (fileInput) fileInput.addEventListener('change', function () {
110 uploadFiles(fileInput.files);
111 fileInput.value = '';
112 });
113
114 // Paste: files become attachments; text pastes as plain text (rich paste
115 // becomes messy HTML; formatting is what the toolbar is for).
116 ed.addEventListener('paste', function (e) {
117 var cd = e.clipboardData || window.clipboardData;
118 if (cd.files && cd.files.length) {
119 e.preventDefault(); // never let the browser inline-paste a file as base64
120 uploadFiles(cd.files); // no-op without the attach UI (edit mode)
121 return;
122 }
123 var txt = cd.getData('text/plain');
124 if (!txt) return;
125 e.preventDefault();
126 document.execCommand('insertText', false, txt);
127 });
128
129 // Drag/drop media onto the editor.
130 if (canAttach) {
131 ed.addEventListener('dragover', function (e) {
132 if (e.dataTransfer && Array.prototype.some.call(e.dataTransfer.types || [], function (t) { return t === 'Files'; })) {
133 e.preventDefault();
134 ed.classList.add('re-drop');
135 }
136 });
137 ed.addEventListener('dragleave', function () { ed.classList.remove('re-drop'); });
138 ed.addEventListener('drop', function (e) {
139 ed.classList.remove('re-drop');
140 if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
141 e.preventDefault();
142 uploadFiles(e.dataTransfer.files);
143 }
144 });
145 }
146
147 // Full-screen compose on mobile: enter on focus, leave via ×.
148 function setFull(on) {
149 form.classList.toggle('re-full', on);
150 head.hidden = !on;
151 document.documentElement.classList.toggle('re-lock', on);
152 if (on) ed.focus();
153 }
154 ed.addEventListener('focus', function () {
155 if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
156 });
157 var cancel = form.querySelector('.re-cancel');
158 if (cancel) cancel.addEventListener('click', function () { setFull(false); });
159
160 // Serialize on submit; block truly empty replies (media-only is fine).
161 form.addEventListener('submit', function (e) {
162 var html = ed.innerHTML.trim();
163 var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
164 if (!plain && !attachments.length) { e.preventDefault(); ed.focus(); return; }
165 hidden.value = plain ? html : '';
166 ta.value = plain;
167 document.documentElement.classList.remove('re-lock');
168 });
169
170 // Open <details> parents (fedi-node) keep working: nothing special needed.
171 void foot;
172 }
173
174 function initAll() {
175 document.querySelectorAll('form[data-re]').forEach(init);
176 }
177 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll);
178 else initAll();
179})();
Note: See TracBrowser for help on using the repository browser.