source: Klonkt/src/assets/js/mod/post-edit.js@ fb9a8ad

main
Last change on this file since fb9a8ad was fb9a8ad, checked in by roboburr <roboburr@…>, 5 weeks ago

De posteditor, de laatste (shaer-bqr, stap 3g)

Zeven blokken, ruim 1000 regels, 34 vertaalsleutels en een tijdzone. Daarmee is
er in pages en partials geen inline script meer over, op embed-player na -- die
staat los (res.render, eigen CSP, altijd een volledige laadbeurt).

ESCAPEN. Deze pagina bouwt op plekken HTML met stringplakwerk, en daar gingen de
vertalingen doorheen als escapende EJS-tag. Een module heeft die niet, dus er is
een esc() bij gekomen in lib.js en alle 34 gaan daardoorheen. Dat is
gedragsgelijk aan wat er stond, en het houdt een apostrof in een vertaling uit
het attribuut waar hij in staat -- het soort fout dat pas in een andere taal
opvalt.

EEN FOUT DIE DE STEEKPROEF VING: ik liet de vervanging kiezen tussen een enkele
en een dubbele aanhalingsvorm op grond van het teken ernaast. Maar bij
aria-label="..." is dat aanhalingsteken HTML en niet de grens van de JS-string;
die is overal enkel. Resultaat was aria-label="" + esc(T.title) + "". Teruggezet
en uniform de enkele vorm gebruikt.

post-edit neemt de playlist-editor op, dus de route vraagt om beide modules.

Templates compileren, 23 modules schoon en syntactisch geldig, suite 565/565.

  • Property mode set to 100644
File size: 48.5 KB
RevLine 
[fb9a8ad]1// De posteditor -- verplaatst uit inline script, shaer-bqr.
2//
3// Zeven blokken, ruim 1000 regels. Alle servertekst komt uit pageData() en gaat
4// door esc(): de EJS-tag ontsnapte die vroeger ook, dus dit is gedragsgelijk en
5// het houdt een apostrof in een vertaling uit de HTML die hier geplakt wordt.
6
7import { pageData, esc } from './lib.js';
8
9const T = pageData();
10
11 (function () {
12 var cb = document.getElementById('pe-fedi-audio'), w = document.getElementById('pe-fedi-audio-warn');
13 if (cb && w && !cb.__wired) { cb.__wired = true; cb.addEventListener('change', function () { w.hidden = !cb.checked; }); }
14 })();
15
16
17// ── volgend blok ──
18
19 (function () {
20 var cw = document.getElementById('pe-cw'), nsfw = document.getElementById('pe-nsfw');
21 // Typing a warning text implies the post is sensitive → auto-tick NSFW.
22 if (cw && nsfw && !cw.__nsfwWired) { cw.__nsfwWired = true;
23 cw.addEventListener('input', function () { if (cw.value.trim()) nsfw.checked = true; });
24 }
25 })();
26
27
28// ── volgend blok ──
29
30 (function () {
31 var box = document.getElementById('pe-poll-fields');
32 var tog = document.getElementById('pe-poll-toggle');
33 var opts = document.getElementById('pe-poll-opts');
34 var add = document.getElementById('pe-poll-add');
35 if (!box || !opts) return;
36 if (tog && !tog.__wired) { tog.__wired = true; tog.addEventListener('change', function () { box.style.display = tog.checked ? '' : 'none'; }); }
37 var PH = opts.getAttribute('data-ph') || '', DEL = opts.getAttribute('data-del') || '';
38 function rows() { return opts.querySelectorAll('.pe-poll-row'); }
39 // A poll needs at least 2 options: hide the ✕ at the minimum, and cap adding at 8.
40 function refresh() {
41 var n = rows().length;
42 opts.querySelectorAll('.pe-poll-del').forEach(function (b) { b.hidden = n <= 2; });
43 if (add) add.disabled = n >= 8;
44 }
45 function makeRow() {
46 var row = document.createElement('div'); row.className = 'pe-poll-row';
47 var i = document.createElement('input'); i.type = 'text'; i.name = 'poll_option'; i.className = 'pe-poll-opt'; i.maxLength = 100; i.placeholder = PH;
48 var d = document.createElement('button'); d.type = 'button'; d.className = 'pe-poll-del'; d.setAttribute('aria-label', DEL); d.title = DEL; d.innerHTML = '&times;';
49 row.appendChild(i); row.appendChild(d); return row;
50 }
51 if (add && !add.__wired) { add.__wired = true; add.addEventListener('click', function () { if (rows().length >= 8) return; opts.appendChild(makeRow()); refresh(); }); }
52 if (!opts.__wired) { opts.__wired = true; opts.addEventListener('click', function (e) { var d = e.target.closest('.pe-poll-del'); if (!d || rows().length <= 2) return; d.closest('.pe-poll-row').remove(); refresh(); }); }
53 refresh();
54 })();
55
56
57// ── volgend blok ──
58
59 (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
60 if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
61
62
63// ── volgend blok ──
64
65 (function () {
66 var cb = document.getElementById('pe-sched-toggle');
67 var box = document.getElementById('pe-sched-fields');
68 if (!cb || !box) return;
69 var inp = document.getElementById('pe-publish-at');
70 var SITE_TZ = '' + (T._timezone || '') + ''; // configured site timezone; empty = browser local
71 var pad = function (n) { return String(n).padStart(2, '0'); };
72 // Offset (ms) between a timezone and UTC at a given moment.
73 function tzOffset(date, tz) {
74 var f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
75 var p = {}; f.formatToParts(date).forEach(function (x) { p[x.type] = x.value; });
76 return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - date.getTime();
77 }
78 // datetime-local "wall time" (in the site zone) → UTC Date.
79 function wallToUtc(wall) {
80 if (!SITE_TZ) return new Date(wall);
81 var guess = new Date(wall + ':00Z').getTime();
82 return new Date(guess - tzOffset(new Date(guess), SITE_TZ));
83 }
84 // UTC-ISO → "YYYY-MM-DDTHH:MM" wall time in the site zone.
85 function utcToWall(iso) {
86 var d = new Date(iso); if (isNaN(d)) return '';
87 if (!SITE_TZ) return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
88 var f = new Intl.DateTimeFormat('en-CA', { timeZone: SITE_TZ, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
89 var p = {}; f.formatToParts(d).forEach(function (x) { p[x.type] = x.value; });
90 return p.year + '-' + p.month + '-' + p.day + 'T' + p.hour + ':' + p.minute;
91 }
92 // Prefill: stored UTC → wall time in the site zone.
93 if (inp && inp.dataset.iso) inp.value = utcToWall(inp.dataset.iso);
94 // "Scheduled for" in human-readable time in the site zone.
95 var when = document.getElementById('pe-sched-when');
96 if (when && when.dataset.iso) {
97 var dw = new Date(when.dataset.iso);
98 if (!isNaN(dw)) when.textContent = '⏳ ' + when.dataset.label + ' ' + dw.toLocaleString(undefined, SITE_TZ ? { timeZone: SITE_TZ } : undefined);
99 }
100 function sync() { box.style.display = cb.checked ? '' : 'none'; if (inp) inp.disabled = !cb.checked; }
101 cb.addEventListener('change', sync); sync();
102 // On save: wall time in the site zone → UTC-ISO via a hidden field.
103 var form = cb.closest('form');
104 if (form) {
105 form.addEventListener('submit', function () {
106 if (inp) inp.removeAttribute('name');
107 var old = form.querySelector('input[data-pa-utc]');
108 if (old) old.remove();
109 if (cb.checked && inp && inp.value) {
110 var d2 = wallToUtc(inp.value);
111 if (!isNaN(d2)) {
112 var h = document.createElement('input');
113 h.type = 'hidden'; h.name = 'publish_at'; h.setAttribute('data-pa-utc', '');
114 h.value = d2.toISOString();
115 form.appendChild(h);
116 }
117 }
118 });
119 }
120 })();
121
122
123// ── volgend blok ──
124
125(function() {
126
127 // ── Cover upload ────────────────────────────────────────────────
128 const coverField = document.getElementById('cover-upload-field');
129 const coverTrigger = document.getElementById('cover-upload-trigger');
130 const coverUrl = document.getElementById('cover-url-field');
131 const coverVideo = document.getElementById('cover-video-field');
132 const coverStatus = document.getElementById('cover-upload-status');
133 const coverWrap = document.getElementById('cover-preview-wrap');
134 const coverImg = document.getElementById('cover-preview-img');
135
136 async function uploadImage(file) {
137 const fd = new FormData();
138 fd.append('image', file);
139 const res = await fetch('/posts/upload-image', { method: 'POST', body: fd });
140 if (!res.ok) {
141 const j = await res.json().catch(() => ({}));
142 throw new Error(j.error || ('Upload failed (' + res.status + ')'));
143 }
144 return await res.json(); // {url, size, mime}
145 }
146
147 // ── Image editor (rotate / crop / mirror) ──────────
148 // Lazy-load Cropper.js (locally vendored) on first use.
149 let _cropperReady = null;
150 function ensureCropper() {
151 if (window.Cropper) return Promise.resolve();
152 if (_cropperReady) return _cropperReady;
153 _cropperReady = new Promise((resolve, reject) => {
154 if (!document.querySelector('link[data-cropper-css]')) {
155 const l = document.createElement('link');
156 l.rel = 'stylesheet'; l.href = '/assets/vendor/cropper.min.css'; l.setAttribute('data-cropper-css', '');
157 document.head.appendChild(l);
158 }
159 const s = document.createElement('script');
160 s.src = '/assets/vendor/cropper.min.js';
161 s.onload = () => resolve();
162 s.onerror = () => reject(new Error('cropper load failed'));
163 document.head.appendChild(s);
164 });
165 return _cropperReady;
166 }
167
168 // True for an animated WebP (VP8X chunk with the animation flag set) — like a GIF it must skip
169 // the canvas editor, otherwise it'd be flattened to a single static frame.
170 async function isAnimatedWebpFile(file) {
171 if (!file || file.type !== 'image/webp') return false;
172 try {
173 const b = new Uint8Array(await file.slice(0, 40).arrayBuffer());
174 return b.length >= 21 && String.fromCharCode(b[12], b[13], b[14], b[15]) === 'VP8X' && (b[20] & 0x02) !== 0;
175 } catch (_) { return false; }
176 }
177
178 // Opens the editor for a chosen file; resolves with an edited File,
179 // or null if the user cancels. Animated images (GIF / animated WebP) are NOT sent through the
180 // canvas editor (they would become static) — those upload directly.
181 async function openImageEditor(file) {
182 if (!file || !file.type || !file.type.startsWith('image/')) return file;
183 if (file.type === 'image/gif') return file; // preserve animation
184 if (await isAnimatedWebpFile(file)) return file; // animated WebP → preserve animation
185 try { await ensureCropper(); } catch (_) { return file; } // editor unavailable → upload directly
186
187 return new Promise((resolve) => {
188 const back = document.createElement('div');
189 back.className = 'imed-backdrop';
190 back.innerHTML =
191 '<div class="imed-modal" role="dialog" aria-modal="true" aria-label="' + esc(T.title) + '">' +
192 '<div class="imed-stage"><img alt=""></div>' +
193 '<div class="imed-tools">' +
194 '<button type="button" data-act="rl" title="' + esc(T.rotate_left) + '">⟲</button>' +
195 '<button type="button" data-act="rr" title="' + esc(T.rotate_right) + '">⟳</button>' +
196 '<button type="button" data-act="fh" title="' + esc(T.flip_h) + '">⇆</button>' +
197 '<button type="button" data-act="fv" title="' + esc(T.flip_v) + '">⇅</button>' +
198 '<button type="button" data-act="zi" title="' + esc(T.zoom_in) + '">+</button>' +
199 '<button type="button" data-act="zo" title="' + esc(T.zoom_out) + '">-</button>' +
200 '<button type="button" data-act="reset" title="' + esc(T.reset) + '">↺</button>' +
201 '</div>' +
202 '<div class="imed-actions">' +
203 '<button type="button" data-act="cancel" class="pe-btn pe-btn-secondary">' + esc(T.cancel) + '</button>' +
204 '<button type="button" data-act="apply" class="pe-btn pe-btn-primary">' + esc(T.apply) + '</button>' +
205 '</div>' +
206 '</div>';
207 document.body.appendChild(back);
208 const img = back.querySelector('img');
209 const url = URL.createObjectURL(file);
210 let cropper = null, sx = 1, sy = 1;
211
212 function cleanup() {
213 try { if (cropper) cropper.destroy(); } catch (_) {}
214 URL.revokeObjectURL(url);
215 back.remove();
216 document.removeEventListener('keydown', onKey);
217 }
218 function onKey(e) { if (e.key === 'Escape') { cleanup(); resolve(null); } }
219 document.addEventListener('keydown', onKey);
220
221 img.onload = () => {
222 cropper = new Cropper(img, { viewMode: 1, autoCropArea: 1, background: false, responsive: true });
223 };
224 img.onerror = () => { cleanup(); resolve(file); }; // could not load → upload the original
225 img.src = url;
226
227 back.addEventListener('click', (e) => {
228 const btn = e.target.closest('[data-act]');
229 if (e.target === back) { cleanup(); resolve(null); return; }
230 if (!btn || !cropper) return;
231 const a = btn.getAttribute('data-act');
232 if (a === 'rl') cropper.rotate(-90);
233 else if (a === 'rr') cropper.rotate(90);
234 else if (a === 'fh') { sx = -sx; cropper.scaleX(sx); }
235 else if (a === 'fv') { sy = -sy; cropper.scaleY(sy); }
236 else if (a === 'zi') cropper.zoom(0.1);
237 else if (a === 'zo') cropper.zoom(-0.1);
238 else if (a === 'reset') { sx = 1; sy = 1; cropper.reset(); }
239 else if (a === 'cancel') { cleanup(); resolve(null); }
240 else if (a === 'apply') {
241 const canvas = cropper.getCroppedCanvas({ maxWidth: 3000, maxHeight: 3000, imageSmoothingEnabled: true, imageSmoothingQuality: 'high' });
242 const png = (file.type === 'image/png' || file.type === 'image/webp');
243 const mime = png ? 'image/png' : 'image/jpeg';
244 const ext = png ? '.png' : '.jpg';
245 canvas.toBlob((blob) => {
246 cleanup();
247 if (!blob) { resolve(file); return; }
248 const base = (file.name || 'afbeelding').replace(/\.[^.]+$/, '');
249 resolve(new File([blob], base + ext, { type: mime }));
250 }, mime, 0.92);
251 }
252 });
253 });
254 }
255
256 function showCoverPreview(url) {
257 if (!coverWrap || !coverImg) return;
258 if (url) {
259 coverImg.src = url;
260 coverImg.hidden = false;
261 coverWrap.removeAttribute('data-empty');
262 const emptyIcon = coverWrap.querySelector('.pe-cover-empty');
263 if (emptyIcon) emptyIcon.remove();
264 } else {
265 coverImg.hidden = true;
266 coverImg.src = '';
267 coverWrap.setAttribute('data-empty', '');
268 if (!coverWrap.querySelector('.pe-cover-empty')) {
269 const span = document.createElement('span');
270 span.className = 'pe-cover-empty';
271 span.textContent = '🖼';
272 coverWrap.appendChild(span);
273 }
274 }
275 }
276
277 if (coverTrigger && coverField) {
278 coverTrigger.addEventListener('click', () => coverField.click());
279 }
280 if (coverField) {
281 coverField.addEventListener('change', async () => {
282 if (!coverField.files[0]) return;
283 const edited = await openImageEditor(coverField.files[0]);
284 coverField.value = '';
285 if (!edited) return; // cancelled
286 coverStatus.classList.remove('is-error');
287 coverStatus.textContent = '' + esc(T.js_uploading) + '';
288 try {
289 const j = await uploadImage(edited);
290 coverUrl.value = j.url;
291 if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
292 showCoverPreview(j.url);
293 coverStatus.textContent = (j.video ? '🎬 ' : '') + '' + esc(T.js_uploaded) + ' ✓';
294 setTimeout(() => { coverStatus.textContent = ''; }, 2000);
295 } catch (e) {
296 coverStatus.classList.add('is-error');
297 coverStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
298 }
299 });
300 }
301 // Live-update preview when user pastes a URL manually
302 if (coverUrl) {
303 coverUrl.addEventListener('input', () => {
304 const v = coverUrl.value.trim();
305 if (v) showCoverPreview(v); else showCoverPreview('');
306 });
307 }
308
309 // ── WYSIWYG editor (P58) ────────────────────────────────────────
310 // Architecture:
311 // - Visible <div contenteditable> (`#content-editor`) is what the user
312 // types in; it shows real HTML (formatted, not raw markup).
313 // - Hidden <input name="content"> (`#content-hidden`) is what submits.
314 // On submit we serialize the editor's HTML into it, with shortcode
315 // chips reduced back to their [[track:UUID]]/[[album:Name]]/[[playlist:slug]] text.
316 // - Initial content comes from a <script type="application/json"> tag
317 // to avoid HTML-escape-into-DOM issues; we set innerHTML once on load
318 // and walk text nodes to render shortcode tokens as chips.
319 const contentField = document.getElementById('content-upload-field');
320 const contentBtn = document.getElementById('insert-image-btn');
321 const contentStatus = document.getElementById('content-upload-status');
322 const editor = document.getElementById('content-editor');
323 const hiddenField = document.getElementById('content-hidden');
324 const charCountEl = document.getElementById('char-count');
325 const initialEl = document.getElementById('initial-content');
326 const toolbar = document.getElementById('pe-toolbar');
327 const form = editor && editor.closest('form');
328
329 if (!editor) return;
330
331 // Auto-focus the title only on desktop (mouse/trackpad). On touch this would
332 // immediately open the keyboard when the editor opens — not desired.
333 try {
334 const titleInput = form && form.querySelector('input[name="title"]');
335 if (titleInput && window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
336 titleInput.focus({ preventScroll: true });
337 }
338 } catch (_) {}
339
340 // ── Shortcode chip rendering / serialization ────────────────────
341 // Pattern matches [[track:UUID]] / [[album:any text]] / [[playlist:slug]]
342 // — but we DON'T want to chipify text the user is mid-typing inside an
343 // HTML attribute; since chipify only walks text nodes (never attribute
344 // values) that's already safe.
345 const SC_RE = /\[\[(track|album|playlist|embed):([^\]]+)\]\]/g;
346
347 const SC_ICONS = {
348 track: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4"/></svg>',
349 album: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>',
350 playlist: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="15" y2="18"/><polygon points="3 5 3 13 9 9"/></svg>',
351 embed: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polygon points="10 9 15.5 12 10 15"/></svg>',
352 };
353
354 function chipLabel(kind, value) {
355 if (kind === 'track') {
356 // UUIDs are noisy — show a 6-char prefix for visual hint
357 const v = String(value || '');
358 return '' + esc(T.chip_track) + ' ' + (v.length > 8 ? v.slice(0, 6) + '…' : v);
359 }
360 if (kind === 'album') return '' + esc(T.chip_album) + ' ' + value;
361 if (kind === 'playlist') return '' + esc(T.chip_playlist) + ' ' + value;
362 if (kind === 'embed') {
363 const clean = String(value || '').replace(/^https?:\/\/(www\.)?/, '');
364 return '▶ ' + (clean.length > 36 ? clean.slice(0, 34) + '…' : clean);
365 }
366 return value;
367 }
368
369 function makeChip(kind, value) {
370 const span = document.createElement('span');
371 span.className = 'sc-chip';
372 span.contentEditable = 'false';
373 span.setAttribute('data-sc', kind + ':' + value);
374 span.innerHTML =
375 '<span class="sc-chip-icon" aria-hidden="true">' + (SC_ICONS[kind] || '') + '</span>' +
376 '<span class="sc-chip-label"></span>';
377 span.querySelector('.sc-chip-label').textContent = chipLabel(kind, value);
378 return span;
379 }
380
381 // Walk text nodes inside `root` and replace [[type:value]] tokens with chips.
382 function chipifyShortcodes(root) {
383 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
384 const targets = [];
385 while (walker.nextNode()) {
386 const n = walker.currentNode;
387 // Skip text inside existing chips (their .sc-chip-label is set via .textContent so the [[...]] text never appears)
388 if (n.parentElement && n.parentElement.closest('.sc-chip')) continue;
389 if (SC_RE.test(n.nodeValue)) targets.push(n);
390 SC_RE.lastIndex = 0;
391 }
392 for (const node of targets) {
393 const txt = node.nodeValue;
394 const frag = document.createDocumentFragment();
395 let last = 0;
396 let m;
397 SC_RE.lastIndex = 0;
398 while ((m = SC_RE.exec(txt)) !== null) {
399 if (m.index > last) frag.appendChild(document.createTextNode(txt.slice(last, m.index)));
400 frag.appendChild(makeChip(m[1], m[2].trim()));
401 last = m.index + m[0].length;
402 }
403 if (last < txt.length) frag.appendChild(document.createTextNode(txt.slice(last)));
404 node.parentNode.replaceChild(frag, node);
405 }
406 }
407
408 // Inverse of chipify: clone the editor, replace every chip with its text.
409 function serializeChips(rootClone) {
410 const chips = rootClone.querySelectorAll('.sc-chip[data-sc]');
411 for (const c of chips) {
412 const txt = '[[' + c.getAttribute('data-sc') + ']]';
413 c.replaceWith(document.createTextNode(txt));
414 }
415 }
416
417 // ── Boot: load initial content as HTML, then render shortcodes as chips
418 try {
419 const initial = JSON.parse(initialEl.textContent || '""');
420 editor.innerHTML = initial || '';
421 chipifyShortcodes(editor);
422 } catch (e) {
423 console.error('[editor] could not parse initial content', e);
424 editor.innerHTML = '';
425 }
426
427 // ── Char counter
428 function updateCharCount() {
429 const text = (editor.innerText || '').replace(/\s+/g, ' ').trim();
430 if (charCountEl) charCountEl.textContent = String(text.length);
431 }
432 updateCharCount();
433 editor.addEventListener('input', updateCharCount);
434
435 // ── Toolbar wiring
436 // Lock the scroll position around an edit command. execCommand/insert scrolls
437 // the caret into view by default → the view "jumps" when clicking a formatting
438 // button. We lock ALL scrollable ancestors (editor, frame, #pcms-main, …)
439 // + the page and restore them — sync and over a few frames, because Chrome
440 // sometimes scrolls a frame later. The user scrolls themselves.
441 function scrollableAncestors(el) {
442 const list = [];
443 let node = el;
444 while (node && node !== document.body && node !== document.documentElement) {
445 const oy = getComputedStyle(node).overflowY;
446 if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') list.push(node);
447 node = node.parentElement;
448 }
449 return list;
450 }
451 function keepScroll(fn) {
452 // In fullscreen the page is locked (body overflow:hidden) and the field may
453 // scroll to the caret freely — no page jump possible, so nothing to fix.
454 const frame = document.querySelector('.pe-editor-frame');
455 if (frame && frame.classList.contains('pe-fs')) { fn(); return; }
456 const wx = window.scrollX, wy = window.scrollY;
457 const anc = scrollableAncestors(editor).map(function (n) { return [n, n.scrollTop, n.scrollLeft]; });
458 const restore = function () {
459 window.scrollTo(wx, wy);
460 anc.forEach(function (e) { e[0].scrollTop = e[1]; e[0].scrollLeft = e[2]; });
461 };
462 fn();
463 restore();
464 requestAnimationFrame(restore);
465 }
466 function execCmd(cmd, arg) {
467 keepScroll(function () {
468 editor.focus({ preventScroll: true });
469 document.execCommand(cmd, false, arg);
470 });
471 updateToolbarState();
472 updateCharCount();
473 }
474 function wrapCode() {
475 const sel = window.getSelection();
476 if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
477 keepScroll(function () {
478 const range = sel.getRangeAt(0);
479 const code = document.createElement('code');
480 code.textContent = sel.toString();
481 range.deleteContents();
482 range.insertNode(code);
483 // Move caret after the new node
484 range.setStartAfter(code);
485 range.collapse(true);
486 sel.removeAllRanges();
487 sel.addRange(range);
488 editor.focus({ preventScroll: true });
489 });
490 }
491 function linkPrompt() {
492 const url = window.prompt('' + esc(T.js_link_prompt) + '');
493 if (!url) return;
494 execCmd('createLink', url);
495 }
496 // Is the current selection inside a <blockquote> within the editor? Return it.
497 function blockquoteAncestor() {
498 const sel = window.getSelection();
499 if (!sel || sel.rangeCount === 0) return null;
500 let node = sel.anchorNode;
501 while (node && node !== editor) {
502 if (node.nodeType === 1 && node.tagName === 'BLOCKQUOTE') return node;
503 node = node.parentNode;
504 }
505 return null;
506 }
507 // Real toggle: execCommand('formatBlock','blockquote') does turn it ON but
508 // can never turn it OFF (browser quirk). If the caret is already in a quote →
509 // unwrap it; otherwise apply blockquote.
510 function toggleBlockquote() {
511 keepScroll(function () {
512 editor.focus({ preventScroll: true });
513 const bq = blockquoteAncestor();
514 if (bq) {
515 const parent = bq.parentNode;
516 // Extract content from the quote in place, then remove the empty wrapper.
517 const ref = bq;
518 let firstMoved = null;
519 while (bq.firstChild) {
520 const child = bq.firstChild;
521 if (!firstMoved) firstMoved = child;
522 parent.insertBefore(child, ref);
523 }
524 parent.removeChild(bq);
525 // Restore the caret inside the unwrapped content.
526 if (firstMoved) {
527 const sel = window.getSelection();
528 const range = document.createRange();
529 range.selectNodeContents(firstMoved.nodeType === 1 ? firstMoved : parent);
530 range.collapse(false);
531 sel.removeAllRanges();
532 sel.addRange(range);
533 }
534 } else {
535 document.execCommand('formatBlock', false, 'blockquote');
536 }
537 });
538 updateToolbarState();
539 updateCharCount();
540 }
541
542 if (toolbar) {
543 // CRUCIAL (mobile + desktop): prevent a toolbar button from stealing focus/selection
544 // from the editor field. Without this the selection is lost on tap
545 // → execCommand operates on an empty selection (bold can no longer be toggled OFF)
546 // and the browser scrolls the caret back into view (the "jump down"). preventDefault
547 // on mousedown keeps focus in the editor; the click still fires normally.
548 toolbar.addEventListener('mousedown', (e) => {
549 if (e.target.closest('button')) e.preventDefault();
550 });
551 toolbar.addEventListener('click', (e) => {
552 const btn = e.target.closest('button[data-cmd]');
553 if (!btn) return;
554 e.preventDefault();
555 const cmd = btn.dataset.cmd;
556 const arg = btn.dataset.arg || null;
557 if (cmd === 'link-prompt') linkPrompt();
558 else if (cmd === 'code-wrap') wrapCode();
559 else if (cmd === 'formatBlock' && arg === 'blockquote') toggleBlockquote();
560 else execCmd(cmd, arg);
561 });
562 }
563
564 // ── Full-screen writing mode: the writing field fills the whole page.
565 const fsBtn = document.getElementById('pe-fullscreen-btn');
566 const editorFrame = document.querySelector('.pe-editor-frame');
567 const isTouch = !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
568
569 // On mobile the keyboard pushes the visible (visual) viewport up while
570 // a position:fixed frame stays pinned to the LAYOUT viewport → the toolbar
571 // slides out of view. Keep the fullscreen frame aligned to the visual
572 // viewport (top + height) so the toolbar stays visible at the top.
573 function syncFsViewport() {
574 if (!editorFrame || !editorFrame.classList.contains('pe-fs')) return;
575 const vv = window.visualViewport;
576 if (!vv) return;
577 editorFrame.style.top = vv.offsetTop + 'px';
578 editorFrame.style.height = vv.height + 'px';
579 }
580 function clearFsViewport() {
581 if (!editorFrame) return;
582 editorFrame.style.top = '';
583 editorFrame.style.height = '';
584 }
585 function isFs() { return !!(editorFrame && editorFrame.classList.contains('pe-fs')); }
586 function applyFs(on) {
587 if (!editorFrame) return;
588 editorFrame.classList.toggle('pe-fs', on);
589 document.body.classList.toggle('pe-fs-open', on);
590 document.documentElement.classList.toggle('pe-fs-open', on);
591 if (fsBtn) {
592 fsBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
593 fsBtn.title = on ? '' + esc(T.tb_done) + '' : '' + esc(T.tb_fullscreen) + '';
594 }
595 if (window.visualViewport) {
596 if (on) {
597 window.visualViewport.addEventListener('resize', syncFsViewport);
598 window.visualViewport.addEventListener('scroll', syncFsViewport);
599 syncFsViewport();
600 } else {
601 window.visualViewport.removeEventListener('resize', syncFsViewport);
602 window.visualViewport.removeEventListener('scroll', syncFsViewport);
603 clearFsViewport();
604 }
605 }
606 // On touch the field is NOT editable inline; only in fullscreen.
607 if (isTouch) editor.setAttribute('contenteditable', on ? 'true' : 'false');
608 if (on) {
609 editor.focus({ preventScroll: true });
610 } else {
611 if (isTouch) editor.blur();
612 // On close: scroll to the TOP of the content instead of staying
613 // somewhere at the bottom (footer).
614 requestAnimationFrame(function () {
615 try { editorFrame.scrollIntoView({ block: 'start' }); } catch (_) {}
616 });
617 }
618 }
619 // The fullscreen writing "page": opening pushes a history state so the browser
620 // back button (and the Done button) closes it and returns you to the form — feels
621 // like a separate page, but all form fields remain intact (same DOM).
622 function openFs() {
623 if (isFs()) return;
624 try { history.pushState({ peFs: true }, ''); } catch (_) {}
625 applyFs(true);
626 }
627 function closeFs() {
628 if (!isFs()) return;
629 if (history.state && history.state.peFs) history.back(); // → popstate closes it
630 else applyFs(false);
631 }
632 function toggleFullscreen() { if (isFs()) closeFs(); else openFs(); }
633 window.addEventListener('popstate', function () { if (isFs()) applyFs(false); });
634 if (fsBtn) fsBtn.addEventListener('click', toggleFullscreen);
635 var fsDoneBtn = document.getElementById('pe-fs-done');
636 if (fsDoneBtn) fsDoneBtn.addEventListener('click', closeFs);
637 document.addEventListener('keydown', (e) => {
638 if (e.key === 'Escape' && isFs()) { e.preventDefault(); closeFs(); }
639 });
640
641 // On mobile/tablet (touch): the content field is NOT editable inline — it is
642 // not a text field there. One tap → fullscreen, where it becomes editable
643 // (toggleFullscreen toggles contenteditable). This prevents inline typing.
644 if (isTouch) {
645 editor.setAttribute('contenteditable', 'false');
646 editor.classList.add('pe-tap-to-edit');
647 editor.addEventListener('click', function () {
648 if (!isFs()) openFs();
649 });
650 }
651
652 // Reflect bold/italic/list state on the toolbar buttons
653 function updateToolbarState() {
654 if (!toolbar) return;
655 const cmds = ['bold', 'italic', 'underline', 'insertUnorderedList', 'insertOrderedList'];
656 for (const cmd of cmds) {
657 const btn = toolbar.querySelector('button[data-cmd="' + cmd + '"]');
658 if (!btn) continue;
659 try { btn.classList.toggle('is-active', document.queryCommandState(cmd)); } catch(_) {}
660 }
661 // Quote button: active when the caret is inside a <blockquote> (toggle feedback).
662 const bqBtn = toolbar.querySelector('button[data-cmd="formatBlock"][data-arg="blockquote"]');
663 if (bqBtn) bqBtn.classList.toggle('is-active', !!blockquoteAncestor());
664 }
665 document.addEventListener('selectionchange', () => {
666 if (document.activeElement === editor) updateToolbarState();
667 });
668
669 // Keyboard shortcuts: Ctrl/Cmd + B/I/U/K
670 editor.addEventListener('keydown', (e) => {
671 const mod = e.ctrlKey || e.metaKey;
672 if (!mod) return;
673 const k = e.key.toLowerCase();
674 if (k === 'b') { e.preventDefault(); execCmd('bold'); }
675 else if (k === 'i') { e.preventDefault(); execCmd('italic'); }
676 else if (k === 'u') { e.preventDefault(); execCmd('underline'); }
677 else if (k === 'k') { e.preventDefault(); linkPrompt(); }
678 });
679
680 // Paste: keep it simple — strip formatting unless user wants it. Default
681 // execCommand 'paste' includes Word/Google-Docs garbage. We accept inline
682 // styles from clipboard only when shift is held — otherwise plain text.
683 editor.addEventListener('paste', (e) => {
684 if (e.shiftKey) return; // user wants formatted paste
685 const text = (e.clipboardData || window.clipboardData).getData('text/plain');
686 if (text == null) return;
687 e.preventDefault();
688 document.execCommand('insertText', false, text);
689 });
690
691 // ── Image upload (button + drag-drop into the editor)
692 async function uploadAndInsertImage(file) {
693 const edited = await openImageEditor(file);
694 if (!edited) return; // cancelled
695 contentStatus.classList.remove('is-error');
696 contentStatus.textContent = '' + esc(T.js_uploading) + '';
697 try {
698 const j = await uploadImage(edited);
699 const img = '<img src="' + j.url + '" alt="">';
700 editor.focus({ preventScroll: true });
701 document.execCommand('insertHTML', false, img);
702 contentStatus.textContent = '' + esc(T.js_inserted) + ' ✓';
703 setTimeout(() => { contentStatus.textContent = ''; }, 2000);
704 updateCharCount();
705 } catch (e) {
706 contentStatus.classList.add('is-error');
707 contentStatus.textContent = '' + esc(T.js_failed) + ': ' + e.message;
708 }
709 }
710
711 if (contentBtn && contentField) {
712 contentBtn.addEventListener('click', () => contentField.click());
713 contentField.addEventListener('change', () => {
714 if (contentField.files[0]) uploadAndInsertImage(contentField.files[0]);
715 contentField.value = '';
716 });
717
718 editor.addEventListener('dragover', (e) => {
719 if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
720 e.preventDefault();
721 editor.classList.add('is-dragover');
722 }
723 });
724 editor.addEventListener('dragleave', () => editor.classList.remove('is-dragover'));
725 editor.addEventListener('drop', async (e) => {
726 editor.classList.remove('is-dragover');
727 const files = e.dataTransfer && e.dataTransfer.files;
728 if (!files || !files.length) return;
729 e.preventDefault();
730 for (const f of files) {
731 if (f.type.startsWith('image/')) await uploadAndInsertImage(f);
732 }
733 });
734 }
735
736 // ── Insert chip helpers (track / playlist)
737 function insertChip(kind, value) {
738 editor.focus({ preventScroll: true });
739 const chip = makeChip(kind, value);
740 // Insert at caret using the Selection API (execCommand insertNode)
741 const sel = window.getSelection();
742 if (sel && sel.rangeCount > 0) {
743 const range = sel.getRangeAt(0);
744 range.deleteContents();
745 range.insertNode(chip);
746 // Insert a trailing space so the user can keep typing after the chip
747 const space = document.createTextNode('\u00A0');
748 chip.after(space);
749 range.setStartAfter(space);
750 range.collapse(true);
751 sel.removeAllRanges();
752 sel.addRange(range);
753 } else {
754 editor.appendChild(chip);
755 editor.appendChild(document.createTextNode('\u00A0'));
756 }
757 updateCharCount();
758 }
759
760 // ── Embed insert: paste a platform URL -> [[embed:url]]-chip that becomes
761 // an iframe server-side (YouTube/Spotify/SoundCloud/Vimeo/Apple Music/Bandcamp).
762 const embedBtn = document.getElementById('insert-embed-btn');
763 if (embedBtn) {
764 embedBtn.addEventListener('click', () => {
765 const raw = window.prompt('' + esc(T.js_embed_prompt) + '');
766 if (!raw) return;
767 const url = raw.trim();
768 if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
769 insertChip('embed', url);
770 });
771 }
772
773 // ── Track insert: opens the track-picker modal (P59)
774 const trackBtn = document.getElementById('insert-track-btn');
775 const trackPicker = document.getElementById('track-picker');
776 if (trackBtn && trackPicker) {
777 const tpList = document.getElementById('tp-list');
778 const tpEmpty = document.getElementById('tp-empty');
779 const tpSearch = document.getElementById('tp-search');
780 let tpCache = null; // cached track list (fetched once per page load)
781 let tpLastFocus = null; // element to restore focus to on close
782
783 const SVG_NOTE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 17V5l12-2v12"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="15" r="3"/></svg>';
784
785 function fmtDur(sec) {
786 sec = Math.max(0, Math.floor(sec || 0));
787 const m = Math.floor(sec / 60), s = sec % 60;
788 return m + ':' + String(s).padStart(2, '0');
789 }
790 function escAttr(s) {
791 return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
792 '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
793 }[c]));
794 }
795
796 function renderList(filter) {
797 if (!Array.isArray(tpCache)) return;
798 const q = (filter || '').trim().toLowerCase();
799 const filtered = q
800 ? tpCache.filter(t =>
801 (t.title || '').toLowerCase().includes(q) ||
802 (t.artist || '').toLowerCase().includes(q))
803 : tpCache;
804
805 if (!filtered.length) {
806 tpList.innerHTML = '';
807 tpEmpty.textContent = q ? '' + esc(T.js_no_tracks_found) + ' ' + q : '' + esc(T.js_no_tracks_yet) + '';
808 tpList.appendChild(tpEmpty);
809 return;
810 }
811
812 tpList.innerHTML = filtered.map(t => {
813 const cov = t.cover
814 ? '<span class="tp-cover" style="background-image:url(\'' + escAttr(t.cover) + '\')"></span>'
815 : '<span class="tp-cover tp-cover-empty">' + SVG_NOTE + '</span>';
816 const dis = t.playable ? '' : ' aria-disabled="true"';
817 const sub = t.artist ? '<span class="tp-row-artist">' + escAttr(t.artist) + '</span>' : '';
818 return (
819 '<button type="button" class="tp-row" role="option" data-track-id="' + escAttr(t.id) + '"' + dis + '>' +
820 cov +
821 '<span class="tp-meta">' +
822 '<span class="tp-row-title">' + escAttr(t.title) + '</span>' +
823 sub +
824 '</span>' +
825 '<span class="tp-duration">' + fmtDur(t.duration) + '</span>' +
826 '</button>'
827 );
828 }).join('');
829 }
830
831 async function loadTracks() {
832 if (Array.isArray(tpCache)) return tpCache;
833 tpEmpty.textContent = '' + esc(T.js_tracks_loading) + '';
834 try {
835 const r = await fetch('/admin/playlists/api/tracks', { credentials: 'same-origin' });
836 const j = await r.json();
837 tpCache = (j && j.ok && Array.isArray(j.tracks)) ? j.tracks : [];
838 } catch (e) {
839 tpCache = [];
840 tpEmpty.textContent = '' + esc(T.js_tracks_load_fail) + ': ' + e.message;
841 }
842 return tpCache;
843 }
844
845 function openPicker() {
846 tpLastFocus = document.activeElement;
847 trackPicker.hidden = false;
848 trackPicker.setAttribute('aria-hidden', 'false');
849 document.body.classList.add('tp-locked');
850 tpSearch.value = '';
851 renderList('');
852 // Defer focus so the open animation doesn't get jumped
853 setTimeout(() => tpSearch.focus(), 30);
854 }
855 function closePicker() {
856 trackPicker.hidden = true;
857 trackPicker.setAttribute('aria-hidden', 'true');
858 document.body.classList.remove('tp-locked');
859 if (tpLastFocus && typeof tpLastFocus.focus === 'function') {
860 try { tpLastFocus.focus(); } catch(_) {}
861 }
862 }
863
864 trackBtn.addEventListener('click', async () => {
865 openPicker();
866 await loadTracks();
867 renderList(tpSearch.value);
868 });
869
870 // Close: backdrop click, [data-tp-close], or Escape
871 trackPicker.addEventListener('click', (e) => {
872 if (e.target.closest('[data-tp-close]')) {
873 closePicker();
874 return;
875 }
876 const row = e.target.closest('.tp-row[data-track-id]');
877 if (row) {
878 if (row.getAttribute('aria-disabled') === 'true') return;
879 const id = row.dataset.trackId;
880 if (id) {
881 insertChip('track', id);
882 closePicker();
883 }
884 }
885 });
886 document.addEventListener('keydown', (e) => {
887 if (!trackPicker.hidden && e.key === 'Escape') {
888 e.preventDefault();
889 closePicker();
890 }
891 });
892
893 // Live filter
894 tpSearch.addEventListener('input', () => renderList(tpSearch.value));
895 }
896
897 // ── Playlist insert (open existing or create new via modal)
898 const playlistBtn = document.getElementById('insert-playlist-btn');
899 if (playlistBtn) {
900 playlistBtn.addEventListener('click', async () => {
901 if (typeof window.openPlaylistEditor !== 'function') {
902 alert('' + esc(T.js_playlist_editor_missing) + '');
903 return;
904 }
905 try {
906 const r = await fetch('/admin/playlists/api/list', { credentials: 'same-origin' });
907 const j = await r.json();
908 if (j.ok && Array.isArray(j.playlists) && j.playlists.length > 0) {
909 const choice = prompt(
910 '' + esc(T.js_playlist_existing) + '\n\n' +
911 j.playlists.map((p, i) => `${i + 1}. ${p.title} (${p.track_count} tracks)`).join('\n') +
912 '\n\n' + esc(T.js_playlist_choose) + ''
913 );
914 if (choice && /^\d+$/.test(choice.trim())) {
915 const idx = parseInt(choice.trim(), 10) - 1;
916 if (idx >= 0 && idx < j.playlists.length) {
917 insertChip('playlist', j.playlists[idx].id);
918 return;
919 }
920 }
921 if (choice === null) return;
922 }
923 } catch (_) { /* fall through to create */ }
924
925 window.openPlaylistEditor({
926 mode: 'create',
927 onSaved: ({ id }) => insertChip('playlist', id),
928 });
929 });
930 }
931
932 // ── Post type: segmented control + type-aware panels ──────────
933 (function () {
934 const typeInput = document.getElementById('pe-type-input');
935 const card = document.querySelector('.pe-type-card');
936 if (!typeInput || !card) return;
937 const seg = card.querySelector('.pe-typeseg');
938 const panels = card.querySelectorAll('.pe-type-panel');
939
940 function applyType(tt) {
941 typeInput.value = tt;
942 seg.querySelectorAll('.pe-typeseg-btn').forEach(b => {
943 const on = b.dataset.type === tt;
944 b.classList.toggle('is-active', on);
945 b.setAttribute('aria-checked', on ? 'true' : 'false');
946 });
947 panels.forEach(p => { p.hidden = (p.dataset.panel !== tt); });
948 }
949 seg.addEventListener('click', (e) => {
950 const btn = e.target.closest('.pe-typeseg-btn');
951 if (btn) applyType(btn.dataset.type);
952 });
953 applyType(typeInput.value || 'post');
954
955 // Video URL → [[embed:url]] chip
956 const vBtn = document.getElementById('pe-video-insert');
957 const vUrl = document.getElementById('pe-video-url');
958 if (vBtn && vUrl) {
959 const doInsert = () => {
960 const url = (vUrl.value || '').trim();
961 if (!/^https?:\/\//i.test(url)) { alert('' + esc(T.js_embed_invalid) + ''); return; }
962 insertChip('embed', url);
963 vUrl.value = '';
964 };
965 vBtn.addEventListener('click', doInsert);
966 vUrl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doInsert(); } });
967 }
968
969 // Audio: inline upload → transcodes server-side → [[track:id]] chip
970 const drop = document.getElementById('pe-audio-drop');
971 const fileInput = document.getElementById('pe-audio-file');
972 const list = document.getElementById('pe-audio-list');
973 if (drop && fileInput && list) {
974 const pick = () => fileInput.click();
975 drop.addEventListener('click', pick);
976 drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
977 ['dragenter', 'dragover'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('is-drag'); }));
978 ['dragleave', 'drop'].forEach(ev => drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('is-drag'); }));
979 drop.addEventListener('drop', (e) => { if (e.dataTransfer && e.dataTransfer.files) handleFiles(e.dataTransfer.files); });
980 fileInput.addEventListener('change', () => { handleFiles(fileInput.files); fileInput.value = ''; });
981
982 function clientDuration(f) {
983 return new Promise((resolve) => {
984 try {
985 const u = URL.createObjectURL(f);
986 const a = document.createElement('audio');
987 a.preload = 'metadata';
988 a.onloadedmetadata = () => { URL.revokeObjectURL(u); resolve(Number.isFinite(a.duration) ? Math.round(a.duration) : null); };
989 a.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
990 a.src = u;
991 } catch (_) { resolve(null); }
992 });
993 }
994 async function handleFiles(files) {
995 for (const f of Array.from(files || [])) await uploadOne(f);
996 }
997 async function uploadOne(f) {
998 const li = document.createElement('li');
999 li.className = 'pe-audio-item';
1000 const nameEl = document.createElement('span');
1001 nameEl.className = 'pe-audio-item-name';
1002 nameEl.textContent = f.name;
1003 const stateEl = document.createElement('span');
1004 stateEl.className = 'pe-audio-item-state';
1005 stateEl.textContent = '⏳ ' + esc(T.audio_up_busy) + '';
1006 li.appendChild(nameEl); li.appendChild(stateEl);
1007 list.appendChild(li);
1008 try {
1009 const dur = await clientDuration(f);
1010 const fd = new FormData();
1011 fd.append('audio', f);
1012 if (dur) fd.append('duration', String(dur));
1013 const res = await fetch('/admin/audio/upload', {
1014 method: 'POST', body: fd,
1015 headers: { 'Accept': 'application/json' },
1016 credentials: 'same-origin',
1017 });
1018 const j = await res.json().catch(() => ({}));
1019 if (!res.ok || !j.ok || !j.id) throw new Error(j.error || ('HTTP ' + res.status));
1020 insertChip('track', j.id);
1021 stateEl.textContent = '✓ ' + esc(T.audio_up_done) + '';
1022 li.classList.add('is-done');
1023 } catch (err) {
1024 stateEl.textContent = '✕ ' + esc(T.audio_up_fail) + ': ' + err.message;
1025 li.classList.add('is-fail');
1026 }
1027 }
1028 }
1029 })();
1030
1031 // ── Submit: serialize editor contents into the hidden field
1032 if (form && hiddenField) {
1033 form.addEventListener('submit', () => {
1034 const clone = editor.cloneNode(true);
1035 serializeChips(clone);
1036 hiddenField.value = clone.innerHTML;
1037 });
1038 }
1039})();
1040
1041// ── volgend blok ──
1042
1043(function () {
1044 // Pin: checkbox toggles the hidden rank field (0 = not pinned),
1045 // ▲▼ shifts the position, with a readable description instead of a raw number.
1046 var toggle = document.getElementById('pin-toggle');
1047 var rank = document.getElementById('pin-rank');
1048 var pos = document.getElementById('pin-pos');
1049 var label = document.getElementById('pin-label');
1050 var up = document.getElementById('pin-up'); // higher = lower number (towards 1/top)
1051 var down = document.getElementById('pin-down');
1052 if (!toggle || !rank || !pos) return;
1053
1054 function descr(n) {
1055 n = Number(n) || 0;
1056 if (n <= 1) return '' + esc(T.pin_top) + '';
1057 return n + '' + esc(T.pin_nth_suffix) + '';
1058 }
1059 function render() {
1060 var on = toggle.checked;
1061 pos.hidden = !on;
1062 if (on && Number(rank.value) < 1) rank.value = 1;
1063 if (!on) rank.value = 0;
1064 if (label) label.textContent = on ? descr(rank.value) : '';
1065 if (up) up.disabled = Number(rank.value) <= 1;
1066 }
1067 toggle.addEventListener('change', render);
1068 if (up) up.addEventListener('click', function () { rank.value = Math.max(1, (Number(rank.value) || 1) - 1); render(); });
1069 if (down) down.addEventListener('click', function () { rank.value = (Number(rank.value) || 0) + 1; render(); });
1070 render();
1071})();
1072
1073(function () {
1074 // Keep the Save/Cancel bar (position: sticky; bottom:0) just above two possible
1075 // obstacles by setting a dynamic bottom offset = the greater of:
1076 // 1) the height of the keyboard area NOT covered by the layout viewport
1077 // (on iOS the visual viewport shifts; on Android the layout viewport shrinks
1078 // due to interactive-widget=resizes-content → offset ≈ 0);
1079 // 2) the height of the playing audio player (fixed, z-index 1000).
1080 // We stick with sticky (no fixed/top tricks → no bar floating in the middle).
1081 var bar = document.querySelector('.pe-actions');
1082 if (!bar) return;
1083 var vv = window.visualViewport;
1084 function position() {
1085 var ap = document.querySelector('.audio-player');
1086 var playing = document.body.classList.contains('has-audio-player') &&
1087 ap && getComputedStyle(ap).display !== 'none';
1088 var audioOffset = playing ? Math.round(ap.getBoundingClientRect().height) : 0;
1089 var kbCovered = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
1090 var offset = Math.max(audioOffset, kbCovered);
1091 bar.style.bottom = offset ? offset + 'px' : '';
1092 }
1093 position();
1094 window.addEventListener('resize', position);
1095 if (vv) { vv.addEventListener('resize', position); vv.addEventListener('scroll', position); }
1096 // has-audio-player is toggled via a body class → observe it.
1097 try { new MutationObserver(position).observe(document.body, { attributes: true, attributeFilter: ['class'] }); } catch (_) {}
1098})();
Note: See TracBrowser for help on using the repository browser.