import { layout, measureLineStats, measureNaturalWidth, prepareWithSegments, } from "@chenglou/pretext"; import { getElWrapper } from "@web3r/flowerkit/dom"; import { getDocument } from "ssr-window"; import type { TStaticElementCfg } from "../../utils/getCfg"; import { getCfg } from "../../utils/getCfg"; import { Dispatcher } from "../dispatcher"; export interface IAdaptiveTableCfg { isFormatText: boolean; minWidth: number; maxWidth: number; thresholdColsNumber: number; horizontalPadding: number; heightWeight: number; lineWeight: number; } type TAdaptiveTableWidthCandidate = { width: number; lineCount: number; score: number; }; /** * Singleton. Плагин для создания адаптивных таблиц. * Обеспечивает таблицы горизонтальной прокруткой, делая их адаптивными на мобильных устройствах путём оборачивания исходного элемента в новые блоки с возможностью скроллинга. Имеет несколько статических методов. * В зависимости от крайнего положения скролла у элемента `div.adaptiveTable` динамически изменяются классы `isEdgeLeft` и `isEdgeRight`, служащие для появления и скрытия декоративных теней. * @module AdaptiveTable * @example * *
*
* * *
*
*/ export class AdaptiveTable { static #instance: AdaptiveTable | null = null; /** * Селекторы, используемые для поиска обёртки адаптивной таблицы и исключений. * @type {{instance: string, customAdaptiveTable: string}} */ static selectors = { instance: "[data-js-adaptiveTable]", customAdaptiveTable: "[data-js-adaptiveTableExclude]", }; /** * Конфигурация по умолчанию для адаптивных таблиц. */ static defaultCfg: IAdaptiveTableCfg = { /** Включает автоматический подбор минимальной ширины ячеек по тексту. */ isFormatText: true, /** Минимально допустимая ширина ячейки. */ minWidth: 120, /** Верхняя граница ширины ячейки, чтобы таблица не расползалась. */ maxWidth: 400, /** Минимальное количество колонок, начиная с которого включается оптимизация. */ thresholdColsNumber: 7, /** Базовый запас по горизонтали под внутренние отступы ячейки. */ horizontalPadding: 16, /** Вес штрафа за избыточную высоту текста в ячейке. */ heightWeight: 2, /** Вес штрафа за дополнительные переносы строк. */ lineWeight: 4, }; static #classStates = { leftEdge: "isEdgeLeft", rightEdge: "isEdgeRight", formatText: "adaptiveTable--isFormatted", }; static #layouts = { wrap: `
`, }; static #measurementCache = new Map(); static #scrollEdgeThreshold = 1; constructor() { if (AdaptiveTable.#instance) { return AdaptiveTable.#instance; } AdaptiveTable.init(); AdaptiveTable.#bindEvents(); AdaptiveTable.#instance = this; } static #bindEvents() { Dispatcher.initiator = { selector: AdaptiveTable.selectors.instance, initiator: (context: Document | HTMLElement) => AdaptiveTable.init(context), getCollection: () => [], }; } /** * Проверяет, является ли таблица адаптивной * @param table{HTMLTableElement} таблица * @returns {boolean} */ static isAdaptive(table: HTMLTableElement): boolean { return !table.closest(AdaptiveTable.selectors.instance) && !table.matches(AdaptiveTable.selectors.customAdaptiveTable) && !table.closest(AdaptiveTable.selectors.customAdaptiveTable); } /** * Инициализатор плагина * @param context{Document|HTMLElement} контекст поиска элементов */ static init(context: Document | HTMLElement = getDocument()) { context.querySelectorAll("table").forEach((table: HTMLTableElement) => { if (AdaptiveTable.isAdaptive(table)) { const wrapped = getElWrapper(table, AdaptiveTable.#layouts.wrap) as HTMLElement; wrapped.scrollLeft = 0; AdaptiveTable.#bindScrollEvent(wrapped); const container = AdaptiveTable.#getContainer(wrapped); if (container instanceof HTMLElement) { AdaptiveTable.formatText(container); AdaptiveTable.setEdges(container); } } }); } /** * Запускает дополнительное форматирование текста таблицы, если оно включено в конфиге. * @param container{HTMLElement} адаптивный контейнер таблицы */ static formatText(container: HTMLElement) { const cfg = AdaptiveTable.#getCfg(container); if (!cfg.isFormatText) { return; } const table = container.querySelector("table"); if (!table) { return; } AdaptiveTable.#optimiseTable(container, table, cfg); } /** * Добавляет класс для оптимизированной таблицы * @param container * @private */ static #addFormatClass(container: HTMLElement) { container.classList.add(AdaptiveTable.#classStates.formatText); } /** * Получает конфигурацию адаптивного контейнера. * @param container{HTMLElement} адаптивный контейнер таблицы * @returns {TStaticElementCfg} */ static #getCfg(container: HTMLElement): TStaticElementCfg { return getCfg(container, AdaptiveTable.selectors.instance, AdaptiveTable.defaultCfg); } /** * Проходит по всем ячейкам таблицы и выставляет для каждой рассчитанный `min-width`. * Оптимизация запускается только для таблиц, у которых количество колонок превышает заданный порог. * После оптимизации добавляется класс для плавных переходов * @param container{HTMLElement} адаптивный контейнер таблицы * @param table{HTMLTableElement} таблица * @param cfg{TStaticElementCfg} конфигурация */ static #optimiseTable(container: HTMLElement, table: HTMLTableElement, cfg: TStaticElementCfg) { const rows = [ ...table.rows ]; if (!rows.length) { return; } const colsCount = rows[0].cells.length; if (colsCount < cfg.thresholdColsNumber || !AdaptiveTable.#isMeasurementSupported()) { return; } rows.forEach((row) => { [ ...row.cells ].forEach((cell) => { cell.style.minWidth = `${AdaptiveTable.#measureCell(cell, cfg)}px`; }); }); AdaptiveTable.#addFormatClass(container); } /** * Проверяет доступность canvas-измерений, необходимых для pretext. * @returns {boolean} */ static #isMeasurementSupported() { try { if (typeof OffscreenCanvas !== "undefined") { return !!new OffscreenCanvas(1, 1).getContext("2d"); } if (typeof document === "undefined") { return false; } return !!document.createElement("canvas").getContext("2d"); } catch { return false; } } /** * Вычисляет оптимальную минимальную ширину ячейки на основе её текста и CSS-стилей. * @param cell{HTMLTableCellElement} ячейка, для которой нужно подобрать ширину. * @param cfg{TStaticElementCfg} конфигурация * @returns итоговая минимальная ширина ячейки в пикселях. */ static #measureCell(cell: HTMLTableCellElement, cfg: TStaticElementCfg): number { const text = cell.textContent ?.replace(/\s+/g, " ") .trim(); if (!text) { return cfg.minWidth; } const styles = window.getComputedStyle(cell); const font = AdaptiveTable.#getCanvasFont(styles); const lineHeight = AdaptiveTable.#getLineHeight(styles); const letterSpacing = AdaptiveTable.#getLetterSpacing(styles); const horizontalPadding = AdaptiveTable.#resolveHorizontalPadding(styles, cfg); const whiteSpace = styles.whiteSpace.includes("pre") ? "pre-wrap" : "normal"; const wordBreak = styles.wordBreak === "keep-all" ? "keep-all" : "normal"; const cacheKey = [ text, font, lineHeight, letterSpacing, horizontalPadding, whiteSpace, wordBreak, ].join("|"); const cachedWidth = AdaptiveTable.#measurementCache.get(cacheKey); if (cachedWidth !== undefined) { return cachedWidth; } const prepared = prepareWithSegments(text, font, { whiteSpace, wordBreak, ...(letterSpacing !== 0 ? { letterSpacing } : {}), }); const minContentWidth = Math.max(1, cfg.minWidth - horizontalPadding); const maxContentWidth = Math.max(minContentWidth, cfg.maxWidth - horizontalPadding); const naturalWidth = Math.ceil(measureNaturalWidth(prepared)); if (naturalWidth <= minContentWidth) { AdaptiveTable.#measurementCache.set(cacheKey, cfg.minWidth); return cfg.minWidth; } const optimalContentWidth = AdaptiveTable.#findOptimalWidth(prepared, minContentWidth, Math.min(maxContentWidth, naturalWidth), lineHeight, cfg); const measuredWidth = Math.min(cfg.maxWidth, Math.max(cfg.minWidth, Math.ceil(optimalContentWidth + horizontalPadding))); AdaptiveTable.#measurementCache.set(cacheKey, measuredWidth); return measuredWidth; } /** * Перебирает допустимый диапазон ширин и выбирает вариант с наилучшим интегральным score. * @param prepared{ReturnType} подготовленный pretext-объект для текста ячейки * @param minWidth{number} нижняя граница перебора для контентной ширины * @param maxWidth{number} верхняя граница перебора для контентной ширины * @param lineHeight{number} текущая высота строки текста в ячейке * @param cfg{TStaticElementCfg} конфигурация * @returns {number} лучшая найденная контентная ширина в пикселях */ static #findOptimalWidth( prepared: ReturnType, minWidth: number, maxWidth: number, lineHeight: number, cfg: TStaticElementCfg ): number { if (maxWidth <= minWidth) { return minWidth; } const step = Math.max(4, Math.round((maxWidth - minWidth) / 24)); let bestWidth = maxWidth; let bestScore = Number.POSITIVE_INFINITY; let bestLineCount = Number.POSITIVE_INFINITY; for (let width = minWidth; width <= maxWidth; width += step) { const candidate = AdaptiveTable.#evaluateWidth(prepared, width, lineHeight, cfg); if (AdaptiveTable.#shouldUseCandidate(candidate, bestScore, bestLineCount, bestWidth)) { bestScore = candidate.score; bestLineCount = candidate.lineCount; bestWidth = candidate.width; } } if ((maxWidth - minWidth) % step !== 0) { const candidate = AdaptiveTable.#evaluateWidth(prepared, maxWidth, lineHeight, cfg); if (AdaptiveTable.#shouldUseCandidate(candidate, bestScore, bestLineCount, bestWidth)) { return candidate.width; } } return bestWidth; } /** * Считает метрики текста для конкретной ширины и формирует score кандидата. * @param prepared{ReturnType} подготовленный pretext-объект для текста ячейки * @param width{number} проверяемая контентная ширина * @param lineHeight{number} текущая высота строки текста в ячейке * @param cfg{TStaticElementCfg} конфигурация * @returns {TAdaptiveTableWidthCandidate} метрики кандидата для сравнения */ static #evaluateWidth( prepared: ReturnType, width: number, lineHeight: number, cfg: TStaticElementCfg ): TAdaptiveTableWidthCandidate { const { maxLineWidth, lineCount } = measureLineStats(prepared, width); const { height } = layout(prepared, width, lineHeight); return { width: Math.ceil(maxLineWidth), lineCount, score: maxLineWidth + (height * cfg.heightWeight) + (lineCount * cfg.lineWeight), }; } /** * Определяет, лучше ли новый кандидат текущего лучшего варианта. * @param candidate{TAdaptiveTableWidthCandidate} новый кандидат на оптимальную ширину * @param bestScore{number} лучший найденный score * @param bestLineCount{number} число строк у лучшего найденного кандидата * @param bestWidth{number} ширина у лучшего найденного кандидата * @returns {boolean} `true`, если нового кандидата нужно принять как лучшего */ static #shouldUseCandidate( candidate: TAdaptiveTableWidthCandidate, bestScore: number, bestLineCount: number, bestWidth: number ) { return ( candidate.score < bestScore || (candidate.score === bestScore && candidate.lineCount < bestLineCount) || ( candidate.score === bestScore && candidate.lineCount === bestLineCount && candidate.width < bestWidth ) ); } /** * Возвращает CSS font shorthand в формате, пригодном для canvas/pretext измерений. * @param styles{CSSStyleDeclaration} вычисленные CSS-стили ячейки * @returns {string} строка шрифта для передачи в pretext */ static #getCanvasFont(styles: CSSStyleDeclaration): string { const font = styles.font.trim(); if (font) { return font.replace(/\/[^\s]+/, ""); } return [ styles.fontStyle, styles.fontVariant, styles.fontWeight, styles.fontSize, styles.fontFamily, ] .filter(Boolean) .join(" "); } /** * Возвращает числовое значение `line-height` для расчёта высоты текста. * @param styles{CSSStyleDeclaration} вычисленные CSS-стили ячейки * @returns {number} высота строки в пикселях */ static #getLineHeight(styles: CSSStyleDeclaration): number { const lineHeight = Number.parseFloat(styles.lineHeight); if (Number.isFinite(lineHeight)) { return lineHeight; } return Math.ceil(Number.parseFloat(styles.fontSize) * 1.2); } /** * Нормализует `letter-spacing` в числовое значение для pretext. * @param styles{CSSStyleDeclaration} вычисленные CSS-стили ячейки * @returns {number} межбуквенный интервал в пикселях */ static #getLetterSpacing(styles: CSSStyleDeclaration): number { const letterSpacing = Number.parseFloat(styles.letterSpacing); return Number.isFinite(letterSpacing) ? letterSpacing : 0; } /** * Определяет горизонтальные внутренние отступы ячейки. * @param styles{CSSStyleDeclaration} вычисленные CSS-стили ячейки * @param cfg{TStaticElementCfg} конфигурация * @returns {number} суммарный горизонтальный отступ в пикселях */ static #resolveHorizontalPadding(styles: CSSStyleDeclaration, cfg: TStaticElementCfg): number { const paddingLeft = Number.parseFloat(styles.paddingLeft) || 0; const paddingRight = Number.parseFloat(styles.paddingRight) || 0; return Math.max(cfg.horizontalPadding, Math.ceil(paddingLeft + paddingRight)); } /** * Получает родительский адаптивный контейнер таблицы * @param el{HTMLElement|ChildNode} элемент * @returns {*} */ static #getContainer(el: Element | null) { return el?.closest(AdaptiveTable.selectors.instance) ?? null; } /** * Устанавливает дополнительные классы, если скроллинг внутри таблицы достиг конца * @param el{HTMLElement} элемент */ static setEdges(el: HTMLElement) { const container = AdaptiveTable.#getContainer(el); if (!container) { return; } const { scrollLeft, scrollWidth, offsetWidth } = el; const maxScrollLeft = Math.max(0, scrollWidth - offsetWidth); container.classList.toggle(AdaptiveTable.#classStates.leftEdge, scrollLeft <= AdaptiveTable.#scrollEdgeThreshold); container.classList.toggle( AdaptiveTable.#classStates.rightEdge, scrollLeft >= maxScrollLeft - AdaptiveTable.#scrollEdgeThreshold ); } static #onTableScroll(e: Event) { const target = e.target; if (target instanceof HTMLElement) { AdaptiveTable.setEdges(target); } } /** * Устанавливает на элемент слушатель скроллинга * @param el{HTMLElement|ChildNode} элемент */ static #bindScrollEvent(el: HTMLElement) { el.addEventListener("scroll", AdaptiveTable.#onTableScroll); } }