163 lines
4.9 KiB
TypeScript
163 lines
4.9 KiB
TypeScript
export const CAROUSEL_LIKE_SEMANTIC_PATTERN_SOURCE =
|
|
"carousel|slider|slides?|swiper|slick|splide|keen|flickity|owl|gallery|galleries|rail|marquee|track|strip|media|videos?|images?|photos?|pictures?|poster|thumbnail|карусел|слайдер|слайды?|галере|медиа|видео|изображ|картин|фото|лента|витрин";
|
|
|
|
export const CAROUSEL_LIKE_SEMANTIC_PATTERN = new RegExp(CAROUSEL_LIKE_SEMANTIC_PATTERN_SOURCE, "iu");
|
|
|
|
export type SemanticBlockBoxLike = {
|
|
anchors?: SemanticBlockAnchorLike[];
|
|
capturedEntities?: unknown[];
|
|
geometry?: {
|
|
documentHeight?: number;
|
|
documentWidth?: number;
|
|
height: number;
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
};
|
|
height: number;
|
|
id: string;
|
|
left: number;
|
|
targetBound?: boolean;
|
|
targetIds: string[];
|
|
title: string;
|
|
top: number;
|
|
width: number;
|
|
};
|
|
|
|
export type SemanticBlockAnchorLike = {
|
|
entityType: string;
|
|
fingerprint?: {
|
|
mediaSrc?: string;
|
|
tagName?: string;
|
|
text?: string;
|
|
targetId?: string;
|
|
};
|
|
id: string;
|
|
pageId?: string;
|
|
scopeId?: string;
|
|
selector?: string;
|
|
selectorIndex?: number;
|
|
source: "captured_dom" | "manual_scope" | "workspace_target";
|
|
targetId?: string;
|
|
text?: string;
|
|
};
|
|
|
|
export function normalizeSemanticBlockIdFragment(value: string) {
|
|
return value
|
|
.normalize("NFKC")
|
|
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
export function isCarouselLikeSemanticValue(values: Array<string | null | undefined>) {
|
|
return CAROUSEL_LIKE_SEMANTIC_PATTERN.test(values.filter(Boolean).join(" ").toLocaleLowerCase("ru-RU"));
|
|
}
|
|
|
|
export function getCollapsedCarouselOverrideTitle(value: string) {
|
|
return value.replace(/\s+\d+$/g, "").trim() || value;
|
|
}
|
|
|
|
export function getCarouselManualBoxBase(box: Pick<SemanticBlockBoxLike, "id" | "title">) {
|
|
const visualIdMatch = /^(.*):visual-\d+$/i.exec(box.id || "");
|
|
const numberedTitleMatch = /^(.*?)(?:\s+|[-_#])\d+$/i.exec(box.title || "");
|
|
const baseTitle = (numberedTitleMatch?.[1] || box.title || "").trim();
|
|
const searchableValue = [
|
|
visualIdMatch?.[1],
|
|
baseTitle,
|
|
box.id,
|
|
box.title
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLocaleLowerCase("ru-RU");
|
|
|
|
// Geometry and numbered labels are presentation hints only. A stable
|
|
// :visual-N identity is the sole authority for collapsing projections.
|
|
if (!visualIdMatch || !CAROUSEL_LIKE_SEMANTIC_PATTERN.test(searchableValue)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: visualIdMatch[1],
|
|
title: baseTitle || box.title || "Слайдер"
|
|
};
|
|
}
|
|
|
|
function mergeGeometry<T extends SemanticBlockBoxLike>(current: T, next: T) {
|
|
const left = Math.min(current.left, next.left);
|
|
const top = Math.min(current.top, next.top);
|
|
const right = Math.max(current.left + current.width, next.left + next.width);
|
|
const bottom = Math.max(current.top + current.height, next.top + next.height);
|
|
|
|
current.left = left;
|
|
current.top = top;
|
|
current.width = Math.max(80, right - left);
|
|
current.height = Math.max(56, bottom - top);
|
|
current.geometry = {
|
|
...(current.geometry || {}),
|
|
height: current.height,
|
|
left: current.left,
|
|
top: current.top,
|
|
width: current.width
|
|
};
|
|
}
|
|
|
|
function getAnchorKey(anchor: SemanticBlockAnchorLike) {
|
|
return [
|
|
anchor.source,
|
|
anchor.targetId ?? "",
|
|
anchor.selector ?? "",
|
|
anchor.selectorIndex ?? 0,
|
|
anchor.scopeId ?? "",
|
|
anchor.pageId ?? "",
|
|
anchor.entityType
|
|
].join("::");
|
|
}
|
|
|
|
function mergeAnchors(current: SemanticBlockAnchorLike[] | undefined, next: SemanticBlockAnchorLike[] | undefined) {
|
|
const anchorsByKey = new Map<string, SemanticBlockAnchorLike>();
|
|
|
|
for (const anchor of [...(current ?? []), ...(next ?? [])]) {
|
|
anchorsByKey.set(getAnchorKey(anchor), anchor);
|
|
}
|
|
|
|
return Array.from(anchorsByKey.values()).slice(0, 120);
|
|
}
|
|
|
|
export function collapseCarouselManualSemanticBlockBoxes<T extends SemanticBlockBoxLike>(boxes: T[]) {
|
|
const mergedByKey = new Map<string, T>();
|
|
const result: T[] = [];
|
|
|
|
for (const box of boxes) {
|
|
const base = getCarouselManualBoxBase(box);
|
|
|
|
if (!base) {
|
|
result.push(box);
|
|
continue;
|
|
}
|
|
|
|
const key = base.id;
|
|
const existing = mergedByKey.get(key);
|
|
|
|
if (!existing) {
|
|
const collapsed = {
|
|
...box,
|
|
id: base.id,
|
|
targetBound: (box.targetIds || []).length > 0 || (box.capturedEntities || []).length > 0 || (box.anchors || []).length > 0,
|
|
title: base.title
|
|
};
|
|
mergedByKey.set(key, collapsed);
|
|
result.push(collapsed);
|
|
continue;
|
|
}
|
|
|
|
existing.targetIds = Array.from(new Set([...(existing.targetIds || []), ...(box.targetIds || [])]));
|
|
existing.capturedEntities = [...(existing.capturedEntities || []), ...(box.capturedEntities || [])];
|
|
existing.anchors = mergeAnchors(existing.anchors, box.anchors);
|
|
existing.targetBound = existing.targetIds.length > 0 || existing.capturedEntities.length > 0 || (existing.anchors || []).length > 0;
|
|
mergeGeometry(existing, box);
|
|
}
|
|
|
|
return result;
|
|
}
|