442 lines
11 KiB
JavaScript
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();
|
|
}
|