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