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

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

Feature: rich replies phase 1 — shared editor, mobile full-screen, language (klonkt-demo-c7f)

Replying to fediverse comments used bare textareas in four places. This adds
ONE shared, progressively-enhanced editor component and mounts it on the two
new-reply spots (inline thread reply in fedi-node, and authorize_interaction);
the edit forms and prutter follow with the media phase.

  • partials/reply-editor.ejs + assets/js/reply-editor.js + css: renders a plain textarea that works without JS; the JS upgrades it to a contenteditable with a small toolbar (bold/italic/link/list/quote) and a language select. Assets load once per render even when the partial repeats per comment.
  • Mobile (max-width 700px): focusing the editor opens a FULL-SCREEN compose overlay (top bar with cancel and send, scroll lock), the right pattern on phones. Two real-world fixes came out of browser verification: site CSS gives thread forms display:contents, which collapses the form box and breaks both flex and position:fixed (now overridden with !important); and the overlay sits at z-index 1200, above the bottom tab bar (1050) and sheets (1100).
  • Server: fedi-reply and authorize_interaction accept content (editor HTML) + language next to text. deliverReply sanitizes the HTML (HtmlSanitizerService), runs the same mention/hashtag/URL enrichment as the plain path, and places the parent mention inline in the first paragraph (own paragraph before block content, merged paragraph around bare inline text). Plain-text path unchanged (no-JS fallback).
  • ap_outbox.language (additive) -> contentMap on the outgoing Note.

5 new tests (sanitize, mention placement, plain path unchanged, empty-html
reject, bogus language dropped); 88 green. Live-verified in the browser:
desktop upgrade, mobile full-screen (enter/cancel/scroll-lock), and a real
submit landing in ap_outbox with markup + language intact.

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

  • Property mode set to 100644
File size: 3.6 KB
RevLine 
[33e1dbd]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 // Toolbar commands (execCommand is deprecated-but-universal; same approach
35 // as the post editor).
36 bar.addEventListener('click', function (e) {
37 var btn = e.target.closest('button[data-cmd]');
38 if (!btn) return;
39 e.preventDefault();
40 ed.focus();
41 var cmd = btn.getAttribute('data-cmd');
42 if (cmd === 'bold') document.execCommand('bold');
43 else if (cmd === 'italic') document.execCommand('italic');
44 else if (cmd === 'list') document.execCommand('insertUnorderedList');
45 else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
46 else if (cmd === 'link') {
47 var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
48 if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
49 }
50 });
51
52 // Paste as plain text (rich paste becomes messy HTML; formatting is what
53 // the toolbar is for). Media paste/drop lands in the media phase.
54 ed.addEventListener('paste', function (e) {
55 var txt = (e.clipboardData || window.clipboardData).getData('text/plain');
56 if (!txt) return;
57 e.preventDefault();
58 document.execCommand('insertText', false, txt);
59 });
60
61 // Full-screen compose on mobile: enter on focus, leave via ×.
62 function setFull(on) {
63 form.classList.toggle('re-full', on);
64 head.hidden = !on;
65 document.documentElement.classList.toggle('re-lock', on);
66 if (on) ed.focus();
67 }
68 ed.addEventListener('focus', function () {
69 if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
70 });
71 var cancel = form.querySelector('.re-cancel');
72 if (cancel) cancel.addEventListener('click', function () { setFull(false); });
73
74 // Serialize on submit; block truly empty replies.
75 form.addEventListener('submit', function (e) {
76 var html = ed.innerHTML.trim();
77 var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
78 if (!plain) { e.preventDefault(); ed.focus(); return; }
79 hidden.value = html;
80 ta.value = plain;
81 document.documentElement.classList.remove('re-lock');
82 });
83
84 // Open <details> parents (fedi-node) keep working: nothing special needed.
85 void foot;
86 }
87
88 function initAll() {
89 document.querySelectorAll('form[data-re]').forEach(init);
90 }
91 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll);
92 else initAll();
93})();
Note: See TracBrowser for help on using the repository browser.