Files
site-portfolio/public/js/modules/ui/content-edit/content-edit-mode.js
T

442 lines
11 KiB
JavaScript

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