source: Klonkt/src/assets/js/mod/reply-editor.js@ 1d76e0e

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

De reply-editor wordt een module (shaer-nh2)

Stond nog als <script src> onderaan partials/reply-editor.ejs -- het patroon dat
shaer-bqr overal weghaalde, maar dit bestand ging niet mee omdat die migratie
over INLINE scripts ging en dit er een met src is.

Bij een volledige laadbeurt ging het goed: injectCspNonce zet er een nonce op.
Bij een htmx-navigatie draagt de partial de nonce van DAT verzoek, terwijl het
document die van zijn eigen laadbeurt heeft. De CSP is strict-dynamic, dus
alleen die laatste telt en het script draaide niet. Gevolg: geen toolbar, geen
media-drop, geen .re-full -- en op mobiel dus geen fullscreen-compose.

De omzetting was mechanisch, want de opzet was al goed: init(form) had al een
__re-vlag PER FORMULIER. Na een htmx-wissel zijn de formulieren nieuw, dus
geen vlag, dus opnieuw opgewaardeerd. Alleen de IIFE, de window-vlag en de
DOMContentLoaded eromheen konden weg; init(form) heet nu initForm om plaats te
maken voor de geexporteerde init().

pageJs erbij op de drie routes die de editor tonen: Berichten, een post met
reacties, en authorize-interaction.

EN DIT IS DE VOORWAARDE VOOR HET SAMENVOEGEN. Robin wil een herbruikbaar
editor-component; een los script kan niet importeren uit mod/lib.js, een module
wel. Dit is dus stap nul van die samenvoeging en niet alleen een bugfix.

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

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