296 lines
9.3 KiB
JavaScript
296 lines
9.3 KiB
JavaScript
const CONTENT_LIMIT = 2000;
|
|
const SNIPPET_LEN = 80;
|
|
const LABEL_LEN = 50;
|
|
const REF_CONTENT_LIMIT = 200;
|
|
|
|
const CLICKABLE_SELECTOR =
|
|
"p, h1, h2, h3, h4, li, figure, figcaption, blockquote, .case-callout, .case-media, .case-media-gallery__item, .case-block, section";
|
|
|
|
const MEDIA_ANCESTOR_SELECTOR = "figure, .case-media, .case-media-gallery__item";
|
|
|
|
const IGNORE_SELECTOR =
|
|
"header, footer, nav, .fab-stack, .content-edit-fab-stack, .content-edit-dock, button, video, input, textarea, select, a, [data-hero-rotator], [data-content-edit-ui]";
|
|
|
|
export function getMainRoot() {
|
|
return document.querySelector("main#case-main") || document.querySelector("main#main") || document.querySelector("main");
|
|
}
|
|
|
|
function normalizeText(text) {
|
|
return (text || "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function escapeCssIdent(value) {
|
|
if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(value);
|
|
return value.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
|
}
|
|
|
|
function segmentForElement(el) {
|
|
if (el.id) return `#${escapeCssIdent(el.id)}`;
|
|
|
|
const tag = el.tagName.toLowerCase();
|
|
const parent = el.parentElement;
|
|
if (!parent) return tag;
|
|
|
|
const preferred = Array.from(el.classList).filter(
|
|
(c) => c.startsWith("case-") || c.startsWith("section__") || c.startsWith("hero__")
|
|
);
|
|
let seg = tag;
|
|
if (preferred.length) {
|
|
seg += preferred.slice(0, 2).map((c) => `.${escapeCssIdent(c)}`).join("");
|
|
}
|
|
|
|
const sameTag = Array.from(parent.children).filter((c) => c.tagName === el.tagName);
|
|
if (sameTag.length > 1) {
|
|
const index = sameTag.indexOf(el) + 1;
|
|
seg += `:nth-of-type(${index})`;
|
|
}
|
|
return seg;
|
|
}
|
|
|
|
export function buildSelector(el, main) {
|
|
const segments = [];
|
|
let current = el;
|
|
|
|
while (current && current !== main) {
|
|
segments.unshift(segmentForElement(current));
|
|
current = current.parentElement;
|
|
}
|
|
|
|
const mainSeg = main.id ? `main#${escapeCssIdent(main.id)}` : "main";
|
|
let selector = `${mainSeg} ${segments.join(" > ")}`;
|
|
|
|
let matches = main.querySelectorAll(selector);
|
|
if (matches.length === 1) return selector;
|
|
|
|
segments.length = 0;
|
|
current = el;
|
|
while (current && current !== main) {
|
|
segments.unshift(segmentForElement(current));
|
|
current = current.parentElement;
|
|
const trySel = `${mainSeg} ${segments.join(" > ")}`;
|
|
matches = main.querySelectorAll(trySel);
|
|
if (matches.length === 1) return trySel;
|
|
}
|
|
|
|
return selector;
|
|
}
|
|
|
|
export function getAnchorId(el, main) {
|
|
let node = el;
|
|
while (node && node !== main && node !== document.body) {
|
|
if (node.id) return node.id;
|
|
node = node.parentElement;
|
|
}
|
|
return main.id || "main";
|
|
}
|
|
|
|
function mediaFileName(el) {
|
|
const img = el.tagName === "IMG" ? el : el.querySelector("img");
|
|
if (!img) return "";
|
|
const src = img.getAttribute("src") || img.currentSrc || "";
|
|
const file = src.split("/").pop()?.split("?")[0] || "";
|
|
return file;
|
|
}
|
|
|
|
function mediaLabel(el) {
|
|
const tag = el.tagName.toLowerCase();
|
|
const file = mediaFileName(el);
|
|
if (file) return `Изображение: ${file}`;
|
|
if (tag === "figure" || tag === "img" || el.classList.contains("case-media")) return "Изображение";
|
|
if (el.classList.contains("case-media-gallery__item")) return "Элемент галереи";
|
|
return "";
|
|
}
|
|
|
|
export function extractContent(el) {
|
|
const tag = el.tagName.toLowerCase();
|
|
|
|
if (tag === "figure" || tag === "img" || el.classList.contains("case-media-gallery__item")) {
|
|
const cap = el.querySelector("figcaption");
|
|
const img = el.tagName === "IMG" ? el : el.querySelector("img");
|
|
const parts = [];
|
|
if (cap) parts.push(normalizeText(cap.textContent));
|
|
if (img?.getAttribute("alt")) parts.push(normalizeText(img.getAttribute("alt")));
|
|
const file = mediaFileName(el);
|
|
if (file) parts.push(file);
|
|
const joined = parts.filter(Boolean).join(" — ");
|
|
if (joined) return joined.slice(0, CONTENT_LIMIT);
|
|
return mediaLabel(el).slice(0, CONTENT_LIMIT);
|
|
}
|
|
|
|
const text = normalizeText(el.textContent);
|
|
if (text) return text.slice(0, CONTENT_LIMIT);
|
|
|
|
const childText = normalizeText(
|
|
Array.from(el.querySelectorAll("p, figcaption, h1, h2, h3, li"))
|
|
.map((n) => n.textContent)
|
|
.join(" ")
|
|
);
|
|
return childText.slice(0, CONTENT_LIMIT);
|
|
}
|
|
|
|
export function makeSnippet(content) {
|
|
const s = normalizeText(content);
|
|
if (s.length <= SNIPPET_LEN) return s;
|
|
return `${s.slice(0, SNIPPET_LEN)}…`;
|
|
}
|
|
|
|
function truncateWithEllipsis(text, maxLen) {
|
|
const s = normalizeText(text);
|
|
if (s.length <= maxLen) return s;
|
|
return `${s.slice(0, maxLen)}…`;
|
|
}
|
|
|
|
/** Короткая метка для вставки в instruction (~50 символов). */
|
|
export function buildShortLabel(el) {
|
|
const tag = el.tagName.toLowerCase();
|
|
|
|
if (tag === "figcaption") {
|
|
return truncateWithEllipsis(el.textContent, LABEL_LEN);
|
|
}
|
|
|
|
const cap = el.querySelector?.("figcaption");
|
|
if (cap && (tag === "figure" || el.classList.contains("case-media"))) {
|
|
const capText = normalizeText(cap.textContent);
|
|
if (capText) return truncateWithEllipsis(capText, LABEL_LEN);
|
|
}
|
|
|
|
if (tag === "figure" || tag === "img" || el.classList.contains("case-media-gallery__item")) {
|
|
const file = mediaFileName(el);
|
|
if (file) return truncateWithEllipsis(`Изображение: ${file}`, LABEL_LEN);
|
|
const img = el.tagName === "IMG" ? el : el.querySelector("img");
|
|
const alt = normalizeText(img?.getAttribute("alt") || "");
|
|
if (alt) return truncateWithEllipsis(alt, LABEL_LEN);
|
|
return truncateWithEllipsis(mediaLabel(el), LABEL_LEN);
|
|
}
|
|
|
|
const text = normalizeText(el.textContent);
|
|
if (text) return truncateWithEllipsis(text, LABEL_LEN);
|
|
|
|
return truncateWithEllipsis(mediaLabel(el) || "Блок", LABEL_LEN);
|
|
}
|
|
|
|
/** Ref для Shift+клика: короткий content (~200), не полный extractContent. */
|
|
export function buildRefTarget(el, main) {
|
|
const fullContent = extractContent(el);
|
|
const content = truncateWithEllipsis(fullContent, REF_CONTENT_LIMIT);
|
|
const mediaRef = mediaFileName(el);
|
|
const label = buildShortLabel(el);
|
|
|
|
return {
|
|
label,
|
|
target: {
|
|
selector: buildSelector(el, main),
|
|
anchorId: getAnchorId(el, main),
|
|
tag: el.tagName.toLowerCase(),
|
|
content,
|
|
snippet: makeSnippet(fullContent) || mediaLabel(el),
|
|
mediaRef: mediaRef || undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function buildTarget(el, main) {
|
|
const selector = buildSelector(el, main);
|
|
const content = extractContent(el);
|
|
const mediaRef = mediaFileName(el);
|
|
return {
|
|
selector,
|
|
anchorId: getAnchorId(el, main),
|
|
tag: el.tagName.toLowerCase(),
|
|
content,
|
|
snippet: makeSnippet(content) || mediaLabel(el),
|
|
mediaRef: mediaRef || undefined,
|
|
};
|
|
}
|
|
|
|
export function normalizeForMatch(text) {
|
|
return normalizeText(text).toLowerCase();
|
|
}
|
|
|
|
export function findClickableTarget(event, main) {
|
|
if (!main) return null;
|
|
|
|
let el = event.target;
|
|
if (!(el instanceof Element)) return null;
|
|
if (el.closest(IGNORE_SELECTOR)) return null;
|
|
if (!main.contains(el)) return null;
|
|
|
|
let hit = el.closest(CLICKABLE_SELECTOR);
|
|
|
|
if (!hit && el.closest("img")) {
|
|
hit = el.closest(MEDIA_ANCESTOR_SELECTOR);
|
|
}
|
|
|
|
if (!hit || !main.contains(hit)) return null;
|
|
if (hit.closest(IGNORE_SELECTOR)) return null;
|
|
|
|
if (hit.matches("a") && !hit.closest(".case-article, .case-callout__content, main")) return null;
|
|
|
|
return hit;
|
|
}
|
|
|
|
export function resolveElement(target, main) {
|
|
if (!target?.selector) return null;
|
|
try {
|
|
const el = main.querySelector(target.selector);
|
|
if (el) return el;
|
|
} catch {
|
|
/* invalid selector */
|
|
}
|
|
|
|
if (target.mediaRef) {
|
|
const imgs = main.querySelectorAll("img");
|
|
for (const img of imgs) {
|
|
const file = mediaFileName(img);
|
|
if (file && file === target.mediaRef) {
|
|
return img.closest(MEDIA_ANCESTOR_SELECTOR) || img;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!target.content) return null;
|
|
const needle = normalizeForMatch(target.content);
|
|
if (!needle) return null;
|
|
|
|
const candidates = main.querySelectorAll(CLICKABLE_SELECTOR);
|
|
for (const el of candidates) {
|
|
if (normalizeForMatch(extractContent(el)) === needle) return el;
|
|
if (normalizeForMatch(extractContent(el)).includes(needle.slice(0, 40))) return el;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function findItemBySelector(items, selector) {
|
|
return items.find((item) => item.target?.selector === selector) ?? null;
|
|
}
|
|
|
|
export const PAGE_EDIT_SCOPE = "page";
|
|
|
|
export function isPageScopeItem(item) {
|
|
if (item?.target?.scope === PAGE_EDIT_SCOPE) return true;
|
|
const t = item?.target;
|
|
return t?.tag === "main" && t?.snippet === "Общая правка страницы";
|
|
}
|
|
|
|
export function getItemKey(item) {
|
|
if (!item?.target) return "";
|
|
if (isPageScopeItem(item)) return PAGE_EDIT_SCOPE;
|
|
return item.target.selector || "";
|
|
}
|
|
|
|
export function buildPageTarget(main) {
|
|
const mainId = main.id || "main";
|
|
return {
|
|
scope: PAGE_EDIT_SCOPE,
|
|
selector: main.id ? `main#${main.id}` : "main",
|
|
anchorId: mainId,
|
|
tag: "main",
|
|
content: "Общая правка страницы",
|
|
snippet: "Общая правка страницы",
|
|
};
|
|
}
|
|
|
|
export function findPageEditItem(items) {
|
|
return items.find((item) => isPageScopeItem(item)) ?? null;
|
|
}
|