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