Andrey Epifancev e5f4b3b965 perf(table): ColumnResizer сообщает MRT начало и конец перетаскивания ширины
isResizingColumn выставляется на mousedown и снимается по mouseup, blur окна
и размонтированию: пока тянут, MRT замораживает тело таблицы (design.md D3).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 21:18:44 +04:00

381 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useMemo } from 'react';
import ColumnResizer from './ColumnResizer';
import { formatHeaderYear } from '../utils/headerYearUtils';
import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils';
import { endColumnResize, startColumnResize } from '../utils/columnResizeInfo';
const getColumnPinnedStyles = (column) => {
const pinningPosition = column.getIsPinned();
return {
isPinned: Boolean(pinningPosition),
left: pinningPosition === 'left' ? column.getStart('left') : undefined,
right: pinningPosition === 'right' ? column.getAfter('right') : undefined,
};
};
const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => {
const sortedColumns = getOrderedVisibleColumns(table);
const tableState = table.getState();
const filteredColumns = sortedColumns.filter(
(col) => !(col.id in tableState.columnVisibility) || (col.id in tableState.columnVisibility && tableState.columnVisibility[col.id]),
);
return (
<div
style={{
display: 'flex',
position: 'sticky',
top: 0,
zIndex: (theme) => theme.zIndex.table + 1,
backgroundColor: 'white',
}}>
{filteredColumns.map((column) => (
<ColumnNumberCell
key={`col-number-${column.id}`}
column={column}
table={table}
selectedColumnId={selectedColumnId}
onColumnSelect={onColumnSelect}
/>
))}
</div>
);
};
const ColumnNumberCell = ({ column, table, selectedColumnId, onColumnSelect }) => {
const { isPinned, left, right } = getColumnPinnedStyles(column);
const allLeafColumns = table.getAllLeafColumns();
const originalIndex = allLeafColumns.findIndex((col) => col.id === column.id);
const selected = selectedColumnId === column.id;
return (
<div
key={`col-number-${column.id}`}
data-col-select-id={column.id}
onClick={(e) => {
e.stopPropagation();
onColumnSelect?.(column.id);
}}
onMouseDown={(e) => e.stopPropagation()}
style={{
width: column.getSize(),
minWidth: column.getSize(),
maxWidth: column.getSize(),
padding: '2px 6px',
textAlign: 'center',
backgroundColor: selected ? '#589A2F' : '#FBFBFB',
color: selected ? '#ffffff' : '#5E5E5E',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '1.5rem',
border: '.063rem solid rgba(209, 213, 220, 1)',
borderBottom: 'none',
position: isPinned ? 'sticky' : 'relative',
left,
right,
zIndex: selected ? 16 : isPinned ? 20 : 'auto',
cursor: 'pointer',
userSelect: 'none',
}}>
{originalIndex}
</div>
);
};
const getColorBrightness = (hexColor) => {
if (!hexColor) return 255;
const color = hexColor.replace('#', '');
let r, g, b;
if (color.length === 3) {
r = Number.parseInt(color[0] + color[0], 16);
g = Number.parseInt(color[1] + color[1], 16);
b = Number.parseInt(color[2] + color[2], 16);
} else if (color.length === 6) {
r = Number.parseInt(color.substring(0, 2), 16);
g = Number.parseInt(color.substring(2, 4), 16);
b = Number.parseInt(color.substring(4, 6), 16);
} else {
return 255;
}
return 0.299 * r + 0.587 * g + 0.114 * b;
};
const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear }) => {
const column = header.column;
const headerSize = header.getSize();
const isPinned = Boolean(pinningPosition);
const leftOffset = pinningPosition === 'left' ? header.getStart() : undefined;
const rightOffset = pinningPosition === 'right' ? table.getRightTotalSize() - header.getStart() - headerSize : undefined;
const stickyTextLeft = isPinned ? undefined : table.getLeftTotalSize();
const stickyTextRight = isPinned ? undefined : table.getRightTotalSize();
const customBgColor = column.columnDef?.muiTableHeadCellProps?.sx?.backgroundColor;
const backgroundColor = customBgColor ? customBgColor : 'rgba(243, 244, 246, 1)';
const color = useMemo(() => {
const brightness = getColorBrightness(backgroundColor);
return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor]);
const handleChangeWidth = (deltaWidth) => {
const size = header.getSize();
onChangeWidth(header, size + deltaWidth);
};
return (
<div
style={{
position: isPinned ? 'sticky' : 'relative',
left: leftOffset,
right: rightOffset,
zIndex: isPinned ? 21 : 'auto',
width: `${headerSize}px`,
minWidth: `${headerSize}px`,
maxWidth: `${headerSize}px`,
flex: '0 0 auto',
}}>
<div
onClick={() => onClick(header)}
style={{
width: '100%',
minWidth: '100%',
maxWidth: '100%',
position: 'relative',
padding: '4px 6px',
textAlign: 'center',
fontWeight: 600,
fontSize: '12px',
lineHeight: 1,
backgroundColor: backgroundColor,
color: '#424242',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '3rem',
border: '.063rem solid rgba(209, 213, 220, 1)',
}}>
<div
style={{
textAlign: 'center',
position: isPinned ? 'relative' : 'sticky',
left: stickyTextLeft,
right: stickyTextRight,
zIndex: 1,
paddingRight: '0.5rem',
paddingLeft: '0.5rem',
backgroundColor: backgroundColor,
color: color,
maxWidth: 'calc(100% - 1rem)',
boxSizing: 'border-box',
}}>
{header.isPlaceholder ? null : formatHeaderYear(column.columnDef.header, budgetYear)}
</div>
<ColumnResizer
columnId={column.id}
reportDragDeltaX={handleChangeWidth}
onDragStart={() => table.setColumnSizingInfo(startColumnResize(column.id))}
onDragEnd={() => table.setColumnSizingInfo(endColumnResize)}
/>
</div>
</div>
);
};
const FilterRow = ({ table }) => {
const sortedColumns = getOrderedVisibleColumns(table);
const tableState = table.getState();
const filteredColumns = sortedColumns.filter(
(col) => !(col.id in tableState.columnVisibility) || (col.id in tableState.columnVisibility && tableState.columnVisibility[col.id]),
);
return (
<div
style={{
display: 'flex',
position: 'sticky',
top: '1.5rem',
zIndex: 20,
backgroundColor: 'white',
}}>
{filteredColumns.map((column) => (
<FilterCell key={`filter-${column.id}`} column={column} />
))}
</div>
);
};
// Компонент ячейки фильтра
const FilterCell = ({ column }) => {
const { isPinned, left, right } = getColumnPinnedStyles(column);
// Определяем тип фильтра на основе колонки
const filterVariant = column.columnDef.filterVariant || 'text';
// Для select фильтра
const selectOptions = column.columnDef.filterSelectOptions || [];
const handleFilterChange = (value) => {
column.setFilterValue(value);
};
// Рендер разных типов фильтров
const renderFilterInput = () => {
switch (filterVariant) {
case 'select':
return (
<select
value={column.getFilterValue() ?? ''}
onChange={(e) => handleFilterChange(e.target.value)}
style={{
width: '100%',
padding: '0.25rem 0.375rem',
fontSize: '0.6875rem',
border: '1px solid #ddd',
borderRadius: '0.25rem',
backgroundColor: 'white',
}}>
<option value=''>Все</option>
{selectOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
);
case 'range': {
const [min, max] = column.getFilterValue() || ['', ''];
return (
<div style={{ display: 'flex', gap: '0.25rem' }}>
{' '}
{/* 4px */}
<input
type='number'
placeholder='От'
value={min}
onChange={(e) => column.setFilterValue([e.target.value, max])}
style={{
width: '50%',
padding: '0.25rem',
fontSize: '0.6875rem',
}}
/>
<input
type='number'
placeholder='До'
value={max}
onChange={(e) => column.setFilterValue([min, e.target.value])}
style={{
width: '50%',
padding: '0.25rem',
fontSize: '0.6875rem',
}}
/>
</div>
);
}
default: // 'text'
return (
<input
type='text'
value={column.getFilterValue() ?? ''}
onChange={(e) => handleFilterChange(e.target.value)}
placeholder='Поиск...'
style={{
width: '100%',
padding: '0.25rem 0.375rem',
fontSize: '0.6875rem',
border: '1px solid #ddd',
borderRadius: '0.25rem',
boxSizing: 'border-box',
}}
/>
);
}
};
return (
<div
style={{
width: column.getSize(),
minWidth: column.getSize(),
maxWidth: column.getSize(),
padding: '4px 6px',
backgroundColor: isPinned ? '#f5f5f5' : 'white',
boxSizing: 'border-box',
height: '2.5rem',
border: '.063rem solid rgba(209, 213, 220, 1)',
borderTop: 'none',
position: isPinned ? 'sticky' : 'relative',
left,
right,
zIndex: isPinned ? 19 : 'auto',
}}>
{renderFilterInput()}
</div>
);
};
const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData, budgetYear }) => {
const handleHeaderClick = (header) => {
if (!header.subHeaders?.length && header.column?.id) {
onColumnSelect?.(header.column.id);
}
};
if (!isLoadingData) {
return <div></div>;
}
const headerSections = [
{ groups: table.getLeftHeaderGroups(), pinningPosition: 'left' },
{ groups: table.getCenterHeaderGroups(), pinningPosition: undefined },
{ groups: table.getRightHeaderGroups(), pinningPosition: 'right' },
];
const headerRowsCount = Math.max(...headerSections.map(({ groups }) => groups.length));
return (
<>
<div
style={{
zIndex: (theme) => theme.zIndex.table,
position: 'relative',
width: 'max-content',
}}>
{/* Строка с номерами колонок */}
<ColumnNumbersRow table={table} selectedColumnId={selectedColumnId} onColumnSelect={onColumnSelect} />
{table.getState().showColumnFilters && <FilterRow table={table} />}
{/* Pinned и обычные заголовки строятся отдельными группами. */}
{Array.from({ length: headerRowsCount }, (_, rowIndex) => (
<div key={`header-row-${rowIndex}`} style={{ display: 'flex' }}>
{headerSections.flatMap(({ groups, pinningPosition }) =>
(groups[rowIndex]?.headers || []).map((header) => (
<HeaderCell
budgetYear={budgetYear}
key={`${pinningPosition || 'center'}-${header.id}`}
header={header}
table={table}
pinningPosition={pinningPosition}
onClick={handleHeaderClick}
onChangeWidth={onChangeWidth}
/>
)),
)}
</div>
))}
</div>
</>
);
};
export const TableHead = React.memo(TableHeadComponent);