← back to Inline Editor
public/editor.js
322 lines
/* @dw/inline-editor — client.
* Only loaded by the server when `dw_auth` cookie has role=admin.
*
* UX:
* - Floating "Edit" toggle button (bottom-right) and Ctrl/Cmd+E shortcut.
* - When edit mode is ON:
* • text leaf nodes get hover outline + contenteditable on click
* • <img> elements get hover outline + click → file picker / URL prompt
* • shift+click an <img> → edit alt text
* • right-click an <a> → edit href (preventDefault on context menu)
* • dblclick a [data-edit-region] → free-form HTML editor (textarea modal)
* - Auto-save on blur (text/html) or on selection (image/href). Debounced 700ms.
* - Selector strategy: prefer #id, then [data-edit-id="..."], else a
* stable structural selector built from nth-of-type chain.
*/
(function () {
if (window.__DW_INLINE_LOADED__) return; window.__DW_INLINE_LOADED__ = true;
const ctx = window.__DW_INLINE__ || {};
if (!ctx.editor) { console.warn('inline-editor: not authed'); return; }
// ---------- UI scaffolding ----------
const ui = document.createElement('div');
ui.id = 'dw-inline-ui';
ui.innerHTML = `
<button id="dw-inline-toggle" type="button">Edit</button>
<div id="dw-inline-toast"></div>
<div id="dw-inline-modal" hidden>
<div class="m-back"></div>
<div class="m-card">
<h3 id="dw-inline-modal-title">Edit</h3>
<textarea id="dw-inline-modal-ta"></textarea>
<div class="m-actions">
<button id="dw-inline-modal-cancel" type="button">Cancel</button>
<button id="dw-inline-modal-save" type="button">Save</button>
</div>
</div>
</div>
`;
document.body.appendChild(ui);
const $ = (s) => document.querySelector(s);
const toggleBtn = $('#dw-inline-toggle');
const toast = $('#dw-inline-toast');
const modal = $('#dw-inline-modal');
const modalTitle = $('#dw-inline-modal-title');
const modalTa = $('#dw-inline-modal-ta');
const modalCancel = $('#dw-inline-modal-cancel');
const modalSave = $('#dw-inline-modal-save');
let editMode = false;
let pending = new Map();
let saveTimer = null;
function setEditMode(on) {
editMode = !!on;
document.documentElement.classList.toggle('dw-edit-on', editMode);
toggleBtn.classList.toggle('on', editMode);
toggleBtn.textContent = editMode ? 'Editing — click Edit again to stop' : 'Edit';
showToast(editMode ? 'Edit mode ON' : 'Edit mode OFF');
if (!editMode) {
document.querySelectorAll('[contenteditable]').forEach(e => e.removeAttribute('contenteditable'));
}
}
toggleBtn.addEventListener('click', () => setEditMode(!editMode));
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') {
e.preventDefault(); setEditMode(!editMode);
}
});
function showToast(msg, kind) {
toast.textContent = msg;
toast.dataset.kind = kind || '';
toast.classList.add('show');
clearTimeout(toast._t);
toast._t = setTimeout(() => toast.classList.remove('show'), 2200);
}
// ---------- selector strategy ----------
function makeSelector(el) {
if (!el || el === document.body) return 'body';
if (el.id) return '#' + cssEscape(el.id);
if (el.dataset && el.dataset.editId) return `[data-edit-id="${cssEscape(el.dataset.editId)}"]`;
const parts = [];
let cur = el;
while (cur && cur.nodeType === 1 && cur !== document.body) {
const tag = cur.tagName.toLowerCase();
const sib = Array.from(cur.parentNode.children).filter(c => c.tagName === cur.tagName);
const idx = sib.indexOf(cur) + 1;
parts.unshift(`${tag}:nth-of-type(${idx})`);
if (cur.parentNode && cur.parentNode.id) {
parts.unshift('#' + cssEscape(cur.parentNode.id));
break;
}
cur = cur.parentNode;
}
return parts.join(' > ');
}
function cssEscape(s) {
return String(s).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
}
// ---------- core: handler delegation ----------
document.addEventListener('mouseover', onHover, true);
document.addEventListener('mouseout', onHoverOut, true);
document.addEventListener('click', onClick, true);
document.addEventListener('contextmenu', onContext, true);
document.addEventListener('dblclick', onDblClick, true);
document.addEventListener('blur', onBlur, true);
function isInsideEditorUi(el) { return !!el.closest('#dw-inline-ui, #dw-inline-modal'); }
function isEditableLeaf(el) {
if (!el || isInsideEditorUi(el)) return false;
const tag = el.tagName;
if (!tag) return false;
if (/^(HTML|HEAD|BODY|SCRIPT|STYLE|META|LINK|NOSCRIPT|IFRAME|SVG|PATH|CANVAS|VIDEO|AUDIO)$/.test(tag)) return false;
if (el.children.length === 0 && (el.textContent || '').trim().length > 0) return true;
// also allow elements explicitly marked editable
if (el.dataset && (el.dataset.editText === 'true' || el.dataset.editRegion === 'true')) return true;
return false;
}
function isImage(el) { return el && el.tagName === 'IMG' && !isInsideEditorUi(el); }
function isAnchor(el) { return el && el.tagName === 'A' && !isInsideEditorUi(el); }
function onHover(e) {
if (!editMode) return;
const t = e.target;
if (isInsideEditorUi(t)) return;
if (isImage(t) || isAnchor(t) || isEditableLeaf(t)) t.classList.add('dw-edit-hover');
}
function onHoverOut(e) { e.target && e.target.classList && e.target.classList.remove('dw-edit-hover'); }
function onClick(e) {
if (!editMode) return;
const t = e.target;
if (isInsideEditorUi(t)) return;
// Image edit
if (isImage(t)) {
e.preventDefault(); e.stopPropagation();
if (e.shiftKey) {
promptEditImageAlt(t);
} else {
promptEditImage(t);
}
return;
}
// Text edit
if (isEditableLeaf(t)) {
e.preventDefault(); e.stopPropagation();
enableContentEditable(t);
}
}
function onContext(e) {
if (!editMode) return;
const t = e.target;
if (isAnchor(t)) {
e.preventDefault();
promptEditHref(t);
}
}
function onDblClick(e) {
if (!editMode) return;
const t = e.target;
const region = t.closest && t.closest('[data-edit-region]');
if (region && !isInsideEditorUi(region)) {
e.preventDefault(); e.stopPropagation();
openHtmlEditorFor(region);
}
}
function onBlur(e) {
const t = e.target;
if (t && t.hasAttribute && t.hasAttribute('contenteditable')) {
flushTextEdit(t);
}
}
function enableContentEditable(el) {
el.setAttribute('contenteditable', 'true');
el.dataset._dwOriginal = el.textContent;
el.focus();
}
function flushTextEdit(el) {
const newText = el.textContent;
if (newText === el.dataset._dwOriginal) {
el.removeAttribute('contenteditable');
return;
}
el.removeAttribute('contenteditable');
const kind = (el.dataset && el.dataset.editRegion === 'true') ? 'html' : 'text';
const value = kind === 'html' ? el.innerHTML : newText;
save({ selector: makeSelector(el), kind, value });
}
function promptEditImage(img) {
openModal({
title: `Replace image — ${img.alt || img.src.slice(-40)}`,
ta: img.src,
help: 'Paste a new URL, OR pick a file below. (svg/jpg/png/webp/gif ≤ 5MB)',
file: true,
onSave: async (val, file) => {
if (file) {
const url = await uploadFile(file);
if (!url) return;
img.src = url;
save({ selector: makeSelector(img), kind: 'image', value: url });
} else if (val && val !== img.src) {
img.src = val;
save({ selector: makeSelector(img), kind: 'image', value: val });
}
}
});
}
function promptEditImageAlt(img) {
openModal({
title: 'Edit alt text',
ta: img.alt || '',
help: 'Describe what the image shows. Helps screen readers + SEO.',
onSave: (val) => {
if (val !== img.alt) {
img.alt = val;
save({ selector: makeSelector(img), kind: 'image_alt', value: val });
}
}
});
}
function promptEditHref(a) {
openModal({
title: 'Edit link URL',
ta: a.getAttribute('href') || '',
help: 'http(s)://, mailto:, tel:, or a /relative path',
onSave: (val) => {
if (val !== a.getAttribute('href')) {
a.setAttribute('href', val);
save({ selector: makeSelector(a), kind: 'href', value: val });
}
}
});
}
function openHtmlEditorFor(region) {
openModal({
title: 'Edit HTML block',
ta: region.innerHTML,
help: 'Inline HTML — script tags and on* handlers get stripped server-side.',
onSave: (val) => {
region.innerHTML = val;
save({ selector: makeSelector(region), kind: 'html', value: val });
}
});
}
// ---------- modal ----------
function openModal({ title, ta, help, onSave, file }) {
modalTitle.textContent = title;
modalTa.value = ta || '';
let existingFile = $('#dw-inline-modal-file');
if (existingFile) existingFile.remove();
if (file) {
const inp = document.createElement('input');
inp.type = 'file'; inp.id = 'dw-inline-modal-file'; inp.accept = 'image/*';
modalTa.after(inp);
}
const helpEl = $('#dw-inline-modal-help');
if (!helpEl) {
const h = document.createElement('div');
h.id = 'dw-inline-modal-help'; h.style.fontSize = '11px'; h.style.color = '#888'; h.style.margin = '6px 0';
modalTa.before(h);
}
$('#dw-inline-modal-help').textContent = help || '';
modal.hidden = false;
modalTa.focus(); modalTa.select();
modalSave.onclick = async () => {
const fileInput = $('#dw-inline-modal-file');
const chosen = fileInput && fileInput.files && fileInput.files[0];
modal.hidden = true;
await onSave(modalTa.value, chosen);
};
modalCancel.onclick = () => { modal.hidden = true; };
}
// ---------- save / upload ----------
function save(payload) {
pending.set(payload.selector, payload);
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(flushPending, 700);
}
async function flushPending() {
const batch = Array.from(pending.values());
pending.clear();
for (const item of batch) {
try {
const res = await fetch('/api/_inline/save', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: ctx.path, ...item })
});
const j = await res.json();
if (!res.ok) {
showToast(`Save failed: ${j.error || res.status}`, 'err');
} else {
showToast(`Saved: ${item.kind} · ${item.selector.slice(0, 40)}`);
}
} catch (e) {
showToast(`Save error: ${e.message}`, 'err');
}
}
}
async function uploadFile(file) {
const fd = new FormData();
fd.append('image', file);
try {
const res = await fetch('/api/_inline/upload', { method: 'POST', credentials: 'include', body: fd });
const j = await res.json();
if (!res.ok) { showToast(`Upload failed: ${j.error || res.status}`, 'err'); return null; }
showToast(`Uploaded: ${(j.bytes/1024).toFixed(0)} KB`);
return j.url;
} catch (e) {
showToast(`Upload error: ${e.message}`, 'err');
return null;
}
}
})();