import { getPagePath, isContentEditHost, loadEdits, saveEdits } from "./content-edit-storage.js"; import { buildPageTarget, buildRefTarget, buildTarget, findClickableTarget, findItemBySelector, findPageEditItem, getItemKey, getMainRoot, isPageScopeItem, resolveElement, } from "./content-edit-target.js"; import { createDock } from "./content-edit-dock.js"; const OUTLINE_CLASS = "content-edit-outline"; const HOVER_OUTLINE_CLASS = "content-edit-outline-hover"; const REF_HOVER_OUTLINE_CLASS = "content-edit-outline-ref-hover"; const HOVER_THROTTLE_MS = 50; function hasQueuedInstruction(text) { return Boolean(text && text.trim()); } function padRefToken(token, insertContext) { const before = insertContext.text.slice(0, insertContext.caret.start); const after = insertContext.text.slice(insertContext.caret.end); let padded = token; if (before.length > 0 && !/\s$/.test(before)) { padded = ` ${padded}`; } if (after.length === 0 || (after.length > 0 && !/^\s/.test(after))) { padded = `${padded} `; } return padded; } export function initContentEditMode() { if (!isContentEditHost()) return; const main = getMainRoot(); if (!main) return; const modeBtn = document.querySelector("[data-content-edit-mode-toggle]"); const badgeEl = modeBtn?.querySelector(".content-edit-fab__badge"); if (!modeBtn) return; modeBtn.hidden = false; const page = getPagePath(); let modeActive = false; let items = []; let outlinedEl = null; let hoverEl = null; let hoverTimer = null; let shiftHeld = false; let skipNextClick = false; let persistQueue = Promise.resolve(); const dock = createDock({ onSave: handleDockSave, onSelect: (item) => openItem(item), onDraft: handleDockDraft, onDelete: (item) => deleteItem(item), }); document.body.appendChild(dock.el); function setOutline(el) { clearOutline(); if (!el) return; outlinedEl = el; el.classList.add(OUTLINE_CLASS); clearHoverOutline(); } function clearOutline() { if (outlinedEl) { outlinedEl.classList.remove(OUTLINE_CLASS); outlinedEl = null; } } function setHoverOutline(el) { if (!el || el === outlinedEl) { clearHoverOutline(); return; } if (hoverEl === el) return; clearHoverOutline(); hoverEl = el; el.classList.add(shiftHeld && dock.getCurrentItem() ? REF_HOVER_OUTLINE_CLASS : HOVER_OUTLINE_CLASS); } function clearHoverOutline() { if (hoverEl) { hoverEl.classList.remove(HOVER_OUTLINE_CLASS, REF_HOVER_OUTLINE_CLASS); hoverEl = null; } } function queuedCount() { return items.filter((i) => hasQueuedInstruction(i.instruction)).length; } function updateFabBadge() { const count = queuedCount(); if (badgeEl) { badgeEl.textContent = String(count); badgeEl.hidden = count === 0; } const label = count > 0 ? `Режим правок (${count} в очереди)` : "Режим правок контента"; modeBtn.setAttribute("aria-label", label); modeBtn.title = count > 0 ? `Режим правок (${count})` : "Режим правок"; } function syncDockItems() { dock.setItems(items); updateFabBadge(); } function renumberItems() { items = items.map((it, i) => ({ ...it, n: i + 1 })); } function upsertItemFromDraft(item, instruction) { const key = getItemKey(item); const idx = items.findIndex((i) => getItemKey(i) === key); const merged = { ...item, instruction }; if (hasQueuedInstruction(instruction)) { if (idx >= 0) { items[idx] = { ...items[idx], ...merged }; } else { items.push({ ...merged, n: items.length + 1 }); renumberItems(); } } else if (idx >= 0) { items = items.filter((_, i) => i !== idx); renumberItems(); } dock.setItems(items); updateFabBadge(); } function handleDockDraft({ item, instruction }) { upsertItemFromDraft(item, instruction); } function persist() { persistQueue = persistQueue .then(async () => { const payload = { version: 2, page, items: items .filter((i) => hasQueuedInstruction(i.instruction)) .map((i) => ({ ...i, page })), }; const saved = await saveEdits(payload); items = (Array.isArray(saved.items) ? saved.items : []).map((it, i) => ({ ...it, n: i + 1 })); syncDockItems(); }) .catch(() => {}); return persistQueue; } async function load() { try { const data = await loadEdits(page); items = Array.isArray(data.items) ? data.items : []; } catch { items = []; } syncDockItems(); } function openItem(item) { dock.flushSave(); if (isPageScopeItem(item)) { clearOutline(); clearHoverOutline(); dock.selectItem(item); return; } const el = resolveElement(item.target, main); if (!el) { clearOutline(); clearHoverOutline(); dock.selectItem(item); return; } setOutline(el); el.scrollIntoView({ behavior: "smooth", block: "center" }); dock.selectItem(item); } function openPageEdit({ skipFlush = false } = {}) { if (!skipFlush) dock.flushSave(); const target = buildPageTarget(main); let item = findPageEditItem(items); if (!item) { item = { instruction: "", target, refs: [] }; } clearOutline(); clearHoverOutline(); dock.setItems(items); dock.selectItem(item); } function deleteItem(item) { const key = getItemKey(item); if (!key) return; const wasSelected = getItemKey(dock.getCurrentItem()) === key; if (!wasSelected) { dock.flushSave(); } items = items.filter((i) => getItemKey(i) !== key); renumberItems(); if (wasSelected) { openPageEdit({ skipFlush: true }); } syncDockItems(); persist(); } function insertBlockRef(el, insertContext) { const current = dock.getCurrentItem(); if (!current || dock.textarea.disabled) return; const ref = buildRefTarget(el, main); const token = padRefToken(`«${ref.label}»`, insertContext); dock.insertAtCaret(token, insertContext); const refs = Array.isArray(current.refs) ? [...current.refs] : []; if (!refs.some((r) => r.target?.selector === ref.target.selector)) { refs.push({ label: ref.label, target: ref.target }); } const instruction = dock.textarea.value; const updated = { ...current, refs, instruction, }; dock.patchItem({ refs, instruction }); const idx = items.findIndex((i) => getItemKey(i) === getItemKey(current)); if (idx >= 0) { items[idx] = { ...items[idx], ...updated }; } else { items.push({ ...updated, n: items.length + 1 }); renumberItems(); } dock.setItems(items); updateFabBadge(); if (hasQueuedInstruction(instruction)) { persist(); } } function openForElement(el) { dock.flushSave(); const target = buildTarget(el, main); let item = findItemBySelector(items, target.selector); if (!item) { item = { instruction: "", target, refs: [] }; } setOutline(el); el.scrollIntoView({ behavior: "smooth", block: "center" }); dock.setItems(items); dock.selectItem(item); } function handleDockSave({ item, instruction }) { upsertItemFromDraft(item, instruction); persist(); } function setMode(active) { modeActive = active; modeBtn.setAttribute("aria-pressed", active ? "true" : "false"); modeBtn.classList.toggle("is-active", active); document.body.classList.toggle("content-edit-mode-active", active); if (active) { dock.open(); dock.setItems(items); if (!dock.getCurrentItem()) { openPageEdit(); } } else { dock.flushSave(); dock.close(); clearOutline(); clearHoverOutline(); shiftHeld = false; document.body.classList.remove("content-edit-shift-active"); } updateFabBadge(); } modeBtn.addEventListener("click", () => { setMode(!modeActive); }); main.addEventListener( "pointerdown", (event) => { if (!modeActive) return; if (event.target.closest("[data-content-edit-ui]")) return; const target = findClickableTarget(event, main); if (!target) return; if (!event.shiftKey || !dock.getCurrentItem() || dock.textarea.disabled) return; const insertContext = dock.getInsertContext(); if (!insertContext) return; event.preventDefault(); event.stopPropagation(); skipNextClick = true; insertBlockRef(target, insertContext); }, true ); main.addEventListener( "click", (event) => { if (!modeActive) return; if (event.target.closest("[data-content-edit-ui]")) return; if (skipNextClick) { skipNextClick = false; event.preventDefault(); event.stopPropagation(); return; } const target = findClickableTarget(event, main); if (!target) { dismissBlockSelection(); return; } event.preventDefault(); event.stopPropagation(); openForElement(target); }, true ); document.addEventListener( "click", (event) => { if (!modeActive) return; if (event.target.closest("[data-content-edit-ui]")) return; if (main.contains(event.target)) return; dismissBlockSelection(); }, true ); main.addEventListener("pointermove", (event) => { if (!modeActive) return; const { clientX, clientY } = event; clearTimeout(hoverTimer); hoverTimer = setTimeout(() => { const under = document.elementFromPoint(clientX, clientY); if (!under?.closest?.("main") || under.closest("[data-content-edit-ui]")) { clearHoverOutline(); return; } const target = findClickableTarget({ target: under }, main); setHoverOutline(target); }, HOVER_THROTTLE_MS); }); main.addEventListener("pointerleave", () => { clearTimeout(hoverTimer); clearHoverOutline(); }); function dismissBlockSelection() { dock.flushSave(); clearOutline(); clearHoverOutline(); openPageEdit(); } document.addEventListener("keydown", (event) => { if (event.key === "Shift" && !shiftHeld) { shiftHeld = true; document.body.classList.add("content-edit-shift-active"); if (hoverEl) { hoverEl.classList.remove(HOVER_OUTLINE_CLASS, REF_HOVER_OUTLINE_CLASS); hoverEl.classList.add( dock.getCurrentItem() ? REF_HOVER_OUTLINE_CLASS : HOVER_OUTLINE_CLASS ); } } if (event.key !== "Escape" || !modeActive) return; dismissBlockSelection(); }); document.addEventListener("keyup", (event) => { if (event.key !== "Shift") return; shiftHeld = false; document.body.classList.remove("content-edit-shift-active"); if (hoverEl) { hoverEl.classList.remove(HOVER_OUTLINE_CLASS, REF_HOVER_OUTLINE_CLASS); hoverEl.classList.add(HOVER_OUTLINE_CLASS); } }); window.addEventListener("blur", () => { shiftHeld = false; document.body.classList.remove("content-edit-shift-active"); }); load(); }