innhold/content/publiser/publiser.js

383 lines
13 KiB
JavaScript
Raw Normal View History

/* /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 || '<br>';
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);
}
});
});
}
})();