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

310 lines
9.3 KiB
JavaScript

import { getItemKey, isPageScopeItem } from "./content-edit-target.js";
const DEBOUNCE_MS = 400;
function itemLabel(item) {
const instruction = item.instruction || "";
const text = instruction.length > 80 ? `${instruction.slice(0, 80)}…` : instruction;
if (isPageScopeItem(item)) {
return text ? `Страница: ${text}` : "Общая правка страницы";
}
return text;
}
export function createDock({ onSave, onSelect, onDraft, onDelete }) {
const root = document.createElement("div");
root.className = "content-edit-dock glass-neutral";
root.setAttribute("data-content-edit-ui", "");
root.hidden = true;
root.innerHTML = `
<div class="content-edit-dock__list" role="listbox" aria-label="Список правок">
<h2 class="content-edit-dock__heading">Список правок</h2>
<p class="content-edit-dock__empty" hidden>Кликните блок на странице или пишите общую правку справа.</p>
<ul class="content-edit-dock__items"></ul>
</div>
<div class="content-edit-dock__editor">
<h2 class="content-edit-dock__heading" id="content-edit-dock-editor-heading">Инструкция агенту</h2>
<div class="content-edit-dock__editor-body">
<textarea
class="content-edit-dock__input"
rows="5"
placeholder="Что изменить, добавить после, куда перенести…"
aria-labelledby="content-edit-dock-editor-heading"
disabled
></textarea>
<p class="content-edit-dock__hint">Shift+клик по блоку — вставить ссылку в текст</p>
</div>
</div>
`;
const listEl = root.querySelector(".content-edit-dock__items");
const emptyEl = root.querySelector(".content-edit-dock__empty");
const textarea = root.querySelector("textarea");
let debounceTimer = null;
let currentItem = null;
let suppressInput = false;
let items = [];
let selectedKey = null;
let resizeObserver = null;
let savedCaret = null;
function captureCaret() {
if (textarea.disabled) return;
savedCaret = {
start: textarea.selectionStart,
end: textarea.selectionEnd,
};
}
function syncDockHeight() {
const h = root.getBoundingClientRect().height;
document.documentElement.style.setProperty("--content-edit-dock-height", `${Math.ceil(h)}px`);
}
function observeHeight() {
if (resizeObserver) return;
resizeObserver = new ResizeObserver(syncDockHeight);
resizeObserver.observe(root);
}
function unobserveHeight() {
resizeObserver?.disconnect();
resizeObserver = null;
document.documentElement.style.removeProperty("--content-edit-dock-height");
}
function scrollSelectedIntoList() {
const btn = listEl.querySelector(".content-edit-dock__item.is-selected");
btn?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function scheduleSave() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (!currentItem) return;
onSave({ item: currentItem, instruction: textarea.value });
}, DEBOUNCE_MS);
}
function patchCurrentInstruction(raw, { draft = true } = {}) {
if (!currentItem) return;
currentItem = { ...currentItem, instruction: raw };
if (draft) onDraft?.({ item: currentItem, instruction: raw });
}
function patchCurrentItem(fields) {
if (!currentItem) return;
currentItem = { ...currentItem, ...fields };
const instruction = fields.instruction ?? currentItem.instruction ?? textarea.value;
currentItem = { ...currentItem, instruction };
onDraft?.({ item: currentItem, instruction });
}
textarea.addEventListener("input", () => {
if (suppressInput) return;
patchCurrentInstruction(textarea.value);
captureCaret();
scheduleSave();
});
textarea.addEventListener("keyup", captureCaret);
textarea.addEventListener("click", captureCaret);
textarea.addEventListener("select", captureCaret);
function renderList() {
listEl.replaceChildren();
if (!items.length) {
emptyEl.hidden = false;
return;
}
emptyEl.hidden = true;
for (const item of items) {
const li = document.createElement("li");
li.className = "content-edit-dock__row";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "content-edit-dock__item";
btn.setAttribute("role", "option");
btn.dataset.itemKey = getItemKey(item);
btn.textContent = `${item.n}. ${itemLabel(item)}`;
const itemKey = btn.dataset.itemKey;
if (itemKey && itemKey === selectedKey) {
btn.classList.add("is-selected");
btn.setAttribute("aria-selected", "true");
} else {
btn.setAttribute("aria-selected", "false");
}
btn.addEventListener("click", () => onSelect?.(item));
const deleteBtn = document.createElement("button");
deleteBtn.type = "button";
deleteBtn.className = "content-edit-dock__delete";
deleteBtn.title = "Удалить";
deleteBtn.setAttribute("aria-label", `Удалить правку ${item.n}`);
deleteBtn.innerHTML = '<span class="content-edit-dock__delete-icon" aria-hidden="true"></span>';
deleteBtn.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onDelete?.(item);
});
li.appendChild(btn);
li.appendChild(deleteBtn);
listEl.appendChild(li);
}
if (selectedKey && items.some((i) => getItemKey(i) === selectedKey)) {
requestAnimationFrame(scrollSelectedIntoList);
}
}
function itemKeyOf(item) {
return getItemKey(item) || null;
}
function loadEditor(item) {
currentItem = item;
selectedKey = itemKeyOf(item);
suppressInput = true;
textarea.value = item?.instruction ?? "";
textarea.disabled = !item;
textarea.placeholder = isPageScopeItem(item)
? "Общая правка страницы: что изменить на странице…"
: "Что изменить, добавить после, куда перенести…";
suppressInput = false;
renderList();
}
return {
el: root,
textarea,
open() {
root.hidden = false;
observeHeight();
requestAnimationFrame(syncDockHeight);
},
close() {
root.hidden = true;
unobserveHeight();
currentItem = null;
selectedKey = null;
clearTimeout(debounceTimer);
},
isOpen() {
return !root.hidden;
},
setItems(nextItems) {
items = nextItems;
if (currentItem) {
const key = itemKeyOf(currentItem);
const fresh = items.find((i) => itemKeyOf(i) === key);
if (fresh) {
currentItem = {
...fresh,
instruction: textarea.disabled ? fresh.instruction : textarea.value,
};
}
}
renderList();
},
selectItem(item, { focusEditor = true } = {}) {
loadEditor(item);
captureCaret();
if (focusEditor && item) textarea.focus();
},
clearSelection() {
currentItem = null;
selectedKey = null;
suppressInput = true;
textarea.value = "";
textarea.disabled = true;
suppressInput = false;
renderList();
},
getCurrentItem() {
return currentItem;
},
flushSave() {
clearTimeout(debounceTimer);
if (!currentItem) return;
onSave({ item: currentItem, instruction: textarea.value });
},
insertAtCaret(text, insertContext = null) {
if (!currentItem || textarea.disabled || !text) return false;
const baseText = insertContext?.text ?? textarea.value;
let start;
let end;
if (insertContext?.caret) {
({ start, end } = insertContext.caret);
} else if (document.activeElement === textarea) {
start = textarea.selectionStart;
end = textarea.selectionEnd;
} else if (savedCaret) {
({ start, end } = savedCaret);
} else {
start = baseText.length;
end = baseText.length;
}
const before = baseText.slice(0, start);
const after = baseText.slice(end);
const next = before + text + after;
suppressInput = true;
textarea.value = next;
const pos = start + text.length;
textarea.setSelectionRange(pos, pos);
suppressInput = false;
savedCaret = { start: pos, end: pos };
patchCurrentInstruction(next, { draft: false });
scheduleSave();
textarea.focus();
return true;
},
getInsertContext() {
if (!currentItem || textarea.disabled) return null;
const text = textarea.value;
let caret;
if (document.activeElement === textarea) {
caret = {
start: textarea.selectionStart,
end: textarea.selectionEnd,
};
} else if (savedCaret) {
caret = { ...savedCaret };
} else {
caret = { start: text.length, end: text.length };
}
return { text, caret };
},
saveCaret: captureCaret,
patchItem(fields) {
patchCurrentItem(fields);
},
};
}