Implement a minimal, dependency-free publishing tool to manage news and articles. The app includes: - A Markdown-based block editor with rich-text capabilities. - Image upload and resizing using GD. - Support for drafts, scheduled publishing, and a trash system. - Integrated Basic Auth protection via PHP and .htpasswd. - A global plugin for automatic execution of scheduled posts. The tool reuses core site logic in custom/plugins/publiser-lib.php to ensure consistency between the editor and the live site.
382 lines
13 KiB
JavaScript
382 lines
13 KiB
JavaScript
/* /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 ``;
|
|
}
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
})();
|