310 lines
9.3 KiB
JavaScript
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);
|
|
},
|
|
};
|
|
}
|