/* /publiser block editor: rich-text toolbar for .md blocks, plain code * editing for .html/.php blocks, image upload (cover + inline), and a * small Markdown serializer for the bounded tag set the toolbar produces. * No external dependencies - contenteditable + document.execCommand for * the handful of inline/list commands (still functional everywhere, * despite being spec-deprecated), manual DOM ops for block-level toggles. */ (function () { 'use strict'; document.execCommand('defaultParagraphSeparator', false, 'p'); // --- Markdown serialization (HTML -> MD, mirrors index.php's MD -> HTML) -- function serializeChildren(node) { return Array.from(node.childNodes).map(serializeNode).join(''); } function serializeNode(node) { if (node.nodeType === Node.TEXT_NODE) { // Not escaping markdown-significant characters (*, [, ] ...) in plain // text: round-tripping literal asterisks/brackets through the parser // is a rare, low-severity edge case not worth the added complexity. return node.textContent; } if (node.nodeType !== Node.ELEMENT_NODE) return ''; if (node.hasAttribute('data-raw-html')) { // Non-editable raw-HTML island (see index.php's publiserBlockMdToHtml) // - pass the original source through unchanged rather than trying to // reconstruct it from the rendered DOM. return `\n\n${node.getAttribute('data-raw-html')}\n\n`; } const tag = node.tagName.toLowerCase(); switch (tag) { case 'strong': case 'b': { const inner = serializeChildren(node).trim(); return inner ? `**${inner}**` : ''; } case 'em': case 'i': { const inner = serializeChildren(node).trim(); return inner ? `*${inner}*` : ''; } case 'a': { const href = node.getAttribute('href') || ''; const inner = serializeChildren(node).trim(); return href ? `[${inner}](${href})` : inner; } case 'img': { const file = node.getAttribute('data-filename') || node.getAttribute('src') || ''; const alt = node.getAttribute('alt') || ''; return `![${alt}](${file})`; } case 'br': return ' '; case 'h2': return `\n\n## ${serializeChildren(node).trim()}\n\n`; case 'h3': return `\n\n### ${serializeChildren(node).trim()}\n\n`; case 'blockquote': { const text = serializeChildren(node).trim().replace(/\s+/g, ' '); return text ? `\n\n> ${text}\n\n` : ''; } case 'ul': { const items = Array.from(node.children) .filter((c) => c.tagName === 'LI') .map((li) => `- ${serializeChildren(li).trim()}`) .join('\n'); return items ? `\n\n${items}\n\n` : ''; } case 'ol': { const items = Array.from(node.children) .filter((c) => c.tagName === 'LI') .map((li, i) => `${i + 1}. ${serializeChildren(li).trim()}`) .join('\n'); return items ? `\n\n${items}\n\n` : ''; } case 'li': return serializeChildren(node); case 'p': case 'div': { const inner = serializeChildren(node).trim(); return inner ? `\n\n${inner}\n\n` : ''; } default: return serializeChildren(node); } } function serializeRichText(root) { let out = Array.from(root.childNodes).map(serializeNode).join(''); out = out.replace(/[ \t]+\n/g, '\n'); out = out.replace(/\n{3,}/g, '\n\n'); return out.trim(); } // --- dirty tracking --------------------------------------------------- function markDirty(richDiv) { const block = richDiv.closest('.p-block'); if (!block) return; const dirtyFlag = block.querySelector('.p-dirty-flag'); const hidden = block.querySelector('.p-block-hidden'); if (dirtyFlag) dirtyFlag.value = '1'; if (hidden) hidden.value = serializeRichText(richDiv); } // --- selection helpers -------------------------------------------------- function saveSelection(richDiv) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return null; const range = sel.getRangeAt(0); if (!richDiv.contains(range.startContainer)) return null; return range.cloneRange(); } function restoreSelection(range) { if (!range) return; const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } function placeCaretAtEnd(el) { el.focus(); const range = document.createRange(); range.selectNodeContents(el); range.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } function getCurrentBlock(richDiv) { const sel = window.getSelection(); if (!sel.rangeCount) return null; let node = sel.getRangeAt(0).startContainer; if (node.nodeType === Node.TEXT_NODE) node = node.parentElement; while (node && node !== richDiv && !['P', 'DIV', 'H2', 'H3', 'BLOCKQUOTE', 'LI'].includes(node.tagName)) { node = node.parentElement; } return node === richDiv ? null : node; } function toggleBlockTag(richDiv, tagName) { const block = getCurrentBlock(richDiv); if (!block) return; const targetTag = block.tagName === tagName ? 'P' : tagName; const replacement = document.createElement(targetTag); replacement.innerHTML = block.innerHTML || '
'; block.replaceWith(replacement); placeCaretAtEnd(replacement); } // --- image upload ------------------------------------------------------- async function uploadImage(file, form, target) { const fd = new FormData(); fd.append('image', file); fd.append('csrf', form.dataset.csrf); fd.append('location', form.dataset.location); fd.append('section', form.dataset.section); fd.append('folder', form.dataset.folder); fd.append('target', target); try { const res = await fetch(form.dataset.uploadUrl, { method: 'POST', body: fd }); const data = await res.json(); if (!data.ok) { alert('Opplasting feilet: ' + (data.error || 'ukjent feil')); return null; } return data; } catch (err) { alert('Opplasting feilet: ' + err.message); return null; } } function insertImageAtRange(richDiv, range, imgData) { const el = document.createElement('img'); el.src = imgData.url; el.alt = ''; el.setAttribute('data-filename', imgData.filename); restoreSelection(range); const sel = window.getSelection(); if (sel.rangeCount && richDiv.contains(sel.getRangeAt(0).startContainer)) { const r = sel.getRangeAt(0); r.deleteContents(); r.insertNode(el); r.setStartAfter(el); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); } else { richDiv.appendChild(el); } markDirty(richDiv); } async function uploadAndInsertImage(richDiv, file, range) { const form = richDiv.closest('form'); const data = await uploadImage(file, form, 'inline'); if (!data) return; insertImageAtRange(richDiv, range, data); } function triggerImageInsert(richDiv) { const savedRange = saveSelection(richDiv); const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.addEventListener('change', () => { if (input.files[0]) uploadAndInsertImage(richDiv, input.files[0], savedRange); }); input.click(); } // --- toolbar commands ----------------------------------------------- function execToolbarCommand(cmd, richDiv) { richDiv.focus(); switch (cmd) { case 'bold': document.execCommand('bold'); break; case 'italic': document.execCommand('italic'); break; case 'ul': document.execCommand('insertUnorderedList'); break; case 'ol': document.execCommand('insertOrderedList'); break; case 'link': { const url = prompt('Lenke (URL):', 'https://'); if (url) document.execCommand('createLink', false, url); break; } case 'h2': toggleBlockTag(richDiv, 'H2'); break; case 'h3': toggleBlockTag(richDiv, 'H3'); break; case 'quote': toggleBlockTag(richDiv, 'BLOCKQUOTE'); break; case 'image': triggerImageInsert(richDiv); return; // markDirty happens after upload completes default: return; } markDirty(richDiv); } // --- wire up rich text blocks ------------------------------------------- document.querySelectorAll('.p-block').forEach((block) => { const richDiv = block.querySelector('.p-richtext'); if (!richDiv) return; richDiv.addEventListener('input', () => markDirty(richDiv)); richDiv.addEventListener('paste', (e) => { const cd = e.clipboardData || window.clipboardData; const imageFile = Array.from(cd.files || []).find((f) => f.type.startsWith('image/')); e.preventDefault(); if (imageFile) { const sel = window.getSelection(); const range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; uploadAndInsertImage(richDiv, imageFile, range); return; } const text = cd.getData('text/plain'); document.execCommand('insertText', false, text); markDirty(richDiv); }); richDiv.addEventListener('dragover', (e) => { e.preventDefault(); richDiv.classList.add('dragover'); }); richDiv.addEventListener('dragleave', () => richDiv.classList.remove('dragover')); richDiv.addEventListener('drop', (e) => { e.preventDefault(); richDiv.classList.remove('dragover'); const file = e.dataTransfer.files && e.dataTransfer.files[0]; if (!file || !file.type.startsWith('image/')) return; let range = null; if (document.caretRangeFromPoint) { range = document.caretRangeFromPoint(e.clientX, e.clientY); } else if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(e.clientX, e.clientY); if (pos) { range = document.createRange(); range.setStart(pos.offsetNode, pos.offset); } } uploadAndInsertImage(richDiv, file, range); }); block.querySelectorAll('.p-block-toolbar [data-cmd]').forEach((btn) => { btn.addEventListener('click', () => execToolbarCommand(btn.dataset.cmd, richDiv)); }); }); // --- cover image dropzone ------------------------------------------------ function wireCoverDrop(drop) { const form = drop.closest('form'); let input = drop.querySelector('input[type=file]'); async function handleFile(file) { if (!file || !file.type.startsWith('image/')) return; const previousContent = drop.innerHTML; drop.textContent = 'Laster opp...'; const data = await uploadImage(file, form, 'cover'); if (!data) { drop.innerHTML = previousContent; return; } drop.innerHTML = ''; const img = document.createElement('img'); img.src = data.url; drop.appendChild(img); const newInput = document.createElement('input'); newInput.type = 'file'; newInput.accept = 'image/*'; newInput.hidden = true; drop.appendChild(newInput); input = newInput; newInput.addEventListener('change', () => handleFile(newInput.files[0])); } drop.addEventListener('click', () => input && input.click()); if (input) input.addEventListener('change', () => handleFile(input.files[0])); drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('dragover'); }); drop.addEventListener('dragleave', () => drop.classList.remove('dragover')); drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('dragover'); handleFile(e.dataTransfer.files && e.dataTransfer.files[0]); }); } document.querySelectorAll('.p-cover-drop').forEach(wireCoverDrop); // --- confirmations for destructive submit buttons ----------------------- document.querySelectorAll('[data-confirm]').forEach((btn) => { btn.addEventListener('click', (e) => { if (!confirm(btn.dataset.confirm)) e.preventDefault(); }); }); // --- safety net: resync any dirty block right before submit ------------- const form = document.getElementById('editor-form'); if (form) { form.addEventListener('submit', () => { form.querySelectorAll('.p-block[data-type="richtext"]').forEach((block) => { const richDiv = block.querySelector('.p-richtext'); const dirtyFlag = block.querySelector('.p-dirty-flag'); if (richDiv && dirtyFlag && dirtyFlag.value === '1') { block.querySelector('.p-block-hidden').value = serializeRichText(richDiv); } }); }); } })();