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

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