← 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;
    }
  }
})();