From 21aded26cc664ad676ac741923aafa02e52f95ce Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:14:38 +0400 Subject: [PATCH] =?UTF-8?q?perf(table):=20=D1=81=D1=82=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=8F=D1=87=D0=B5=D0=B5=D0=BA=20=D0=B2=20CSS=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B0,=20overscan=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=202=20=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../RealtimeTable/RealtimeTable.jsx | 39 +++----------- .../RealtimeTable/constants/tableConfig.js | 39 +++++++------- .../RealtimeTable/utils/activeCellCss.js | 51 +++++++++++++++++++ .../RealtimeTable/utils/activeCellCss.test.js | 31 +++++++++++ .../RealtimeTable/utils/columnLayoutUtils.js | 2 +- .../utils/columnLayoutUtils.test.js | 4 ++ 6 files changed, 113 insertions(+), 53 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/activeCellCss.js create mode 100644 web/src/components/RealtimeTable/utils/activeCellCss.test.js diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 7aded64..bbde0fc 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -16,8 +16,9 @@ import { CreateProgramModal } from './Modals/CreateProgramProjectModal'; import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal'; import { SelectVspModal } from './Modals/SelectVspModal'; import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig'; -import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig'; +import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig'; import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions'; +import { getActiveCellSx } from './utils/activeCellCss'; import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext'; import { useColumnSettings } from './hooks/useColumnSettings'; import { useHeaderPortal } from './hooks/useHeaderPortal'; @@ -37,7 +38,7 @@ const CONFIG_CACHE = new Map(); const ROW_VIRTUALIZER_OPTIONS = createFixedRowVirtualizerOptions({ rowHeight: getTableRowHeightPx, - overscan: 5, + overscan: 2, extra: { scrollPaddingStart: 0, scrollPaddingEnd: 0 }, }); @@ -362,16 +363,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { onClick: () => handleClickRowCell(row.id), hover: false, style: { height: TABLE_ROW_HEIGHT }, - sx: { - '& > td::after': { - content: row.getIsSelected() ? '""' : 'none', - position: 'absolute', - inset: 0, - backgroundColor: 'rgba(0, 0, 0, 0.08)', - pointerEvents: 'none', - zIndex: 11, - }, - }, + 'data-row-id': row.id, }), onColumnSizingChange: setColumnSizing, onColumnPinningChange: setColumnPinning, @@ -394,21 +386,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { muiTableHeadCellProps: { sx: { boxSizing: 'border-box' }, }, - muiTableBodyCellProps: (props) => { - const cellProps = getTableBodyCellProps(props); - const isActiveMatch = activeSearchMatch?.rowId === props.row.id && activeSearchMatch?.columnId === props.column.id; - - return { - ...cellProps, - sx: { - ...cellProps.sx, - ...(isActiveMatch && { - zIndex: 7, - boxShadow: 'inset 0 0 0 3px #1976d2', - }), - }, - }; - }, + muiTableBodyCellProps: ({ column }) => ({ 'data-col-id': column.id }), muiTableHeadProps: { sx: { display: 'table-header-group', @@ -427,12 +405,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { muiTablePaperProps: getTablePaperStyles(), muiTableContainerProps: { ref: containerRef, - sx: { - position: 'relative', - contain: 'layout', - minHeight: '100%', - '& .MuiTable-root': { position: 'relative' }, - }, + sx: { ...TABLE_CONTAINER_SX, ...getActiveCellSx(activeSearchMatch) }, }, muiTableHeadCellFilterTextFieldProps: { placeholder: 'Поиск...', diff --git a/web/src/components/RealtimeTable/constants/tableConfig.js b/web/src/components/RealtimeTable/constants/tableConfig.js index 0c0aa17..0a4520e 100644 --- a/web/src/components/RealtimeTable/constants/tableConfig.js +++ b/web/src/components/RealtimeTable/constants/tableConfig.js @@ -40,32 +40,33 @@ export const BASE_TABLE_CONFIG = { }, }; -export const getTableHeadCellStyles = () => ({ - sx: { - whiteSpace: 'auto', +// Статичное оформление ячеек одним правилом на контейнере вместо sx на каждой ячейке. +// Высота берётся из TABLE_ROW_HEIGHT (rem), чтобы совпадать с виртуализатором строк. +export const TABLE_CONTAINER_SX = { + position: 'relative', + contain: 'layout', + minHeight: '100%', + '& .MuiTable-root': { position: 'relative' }, + '& tbody td': { wordBreak: 'normal', boxSizing: 'border-box', minHeight: TABLE_ROW_HEIGHT, maxHeight: TABLE_ROW_HEIGHT, + height: TABLE_ROW_HEIGHT, border: '1px solid #E6E6E6', - fontSize: '11px', + fontSize: '1rem', fontFamily: 'Calibri, Arial, sans-serif', + overflow: 'hidden', + }, + '& tbody td[data-pinned="true"]': { zIndex: 5 }, + '& tbody tr[data-selected="true"] > td::after': { + content: '""', + position: 'absolute', + inset: 0, + backgroundColor: 'rgba(0, 0, 0, 0.08)', + pointerEvents: 'none', + zIndex: 11, }, -}); - -export const getTableBodyCellProps = ({ column, table }) => { - const isPinned = (table.getState().columnPinning?.left || []).includes(column.id); - - return { - 'data-col-id': column.id, - sx: { - ...getTableHeadCellStyles().sx, - fontSize: '1rem', - height: TABLE_ROW_HEIGHT, - overflow: 'hidden', - ...(isPinned && { zIndex: 5 }), - }, - }; }; export const getTablePaperStyles = () => ({ diff --git a/web/src/components/RealtimeTable/utils/activeCellCss.js b/web/src/components/RealtimeTable/utils/activeCellCss.js new file mode 100644 index 0000000..3b8ca64 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/activeCellCss.js @@ -0,0 +1,51 @@ +// Экранирование идентификатора по алгоритму CSS.escape (CSSOM). В браузере используется +// нативный CSS.escape, этот вариант нужен там, где его нет (node-тесты). +const escapeCssIdentFallback = (value) => { + const string = String(value); + let result = ''; + for (let index = 0; index < string.length; index++) { + const char = string[index]; + const code = string.charCodeAt(index); + if (code === 0) { + result += '�'; + } else if ( + (code >= 0x1 && code <= 0x1f) || + code === 0x7f || + (index === 0 && code >= 0x30 && code <= 0x39) || + (index === 1 && code >= 0x30 && code <= 0x39 && string.charCodeAt(0) === 0x2d) + ) { + result += `\\${code.toString(16)} `; + } else if (index === 0 && string.length === 1 && code === 0x2d) { + result += `\\${char}`; + } else if ( + code >= 0x80 || + code === 0x2d || + code === 0x5f || + (code >= 0x30 && code <= 0x39) || + (code >= 0x41 && code <= 0x5a) || + (code >= 0x61 && code <= 0x7a) + ) { + result += char; + } else { + result += `\\${char}`; + } + } + return result; +}; + +const defaultEscape = (value) => + typeof globalThis.CSS?.escape === 'function' ? globalThis.CSS.escape(value) : escapeCssIdentFallback(value); + +export const ACTIVE_CELL_STYLE = { zIndex: 7, boxShadow: 'inset 0 0 0 3px #1976d2' }; + +// Правило sx для контейнера таблицы: подсветка текущего совпадения поиска. +// Вместо sx на каждой ячейке перерисовывается один селектор. null/неполное совпадение -> {}. +export const getActiveCellSx = (activeSearchMatch, escape = defaultEscape) => { + const rowId = activeSearchMatch?.rowId; + const columnId = activeSearchMatch?.columnId; + if (rowId == null || columnId == null) return {}; + + return { + [`& tbody tr[data-row-id="${escape(rowId)}"] > td[data-col-id="${escape(columnId)}"]`]: ACTIVE_CELL_STYLE, + }; +}; diff --git a/web/src/components/RealtimeTable/utils/activeCellCss.test.js b/web/src/components/RealtimeTable/utils/activeCellCss.test.js new file mode 100644 index 0000000..af48fb6 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/activeCellCss.test.js @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ACTIVE_CELL_STYLE, getActiveCellSx } from './activeCellCss.js'; + +test('null and incomplete matches produce no rule', () => { + assert.deepEqual(getActiveCellSx(null), {}); + assert.deepEqual(getActiveCellSx(undefined), {}); + assert.deepEqual(getActiveCellSx({ rowId: '1' }), {}); + assert.deepEqual(getActiveCellSx({ columnId: 'a' }), {}); +}); + +test('row ids like 0.3.2 are escaped', () => { + const sx = getActiveCellSx({ rowId: '0.3.2', columnId: 'amount' }); + assert.deepEqual(Object.keys(sx), ['& tbody tr[data-row-id="\\30 \\.3\\.2"] > td[data-col-id="amount"]']); + assert.deepEqual(Object.values(sx), [ACTIVE_CELL_STYLE]); +}); + +test('row ids like ITEM:123 are escaped', () => { + const sx = getActiveCellSx({ rowId: 'ITEM:123', columnId: 'col.1' }); + assert.deepEqual(Object.keys(sx), ['& tbody tr[data-row-id="ITEM\\:123"] > td[data-col-id="col\\.1"]']); +}); + +test('uses injected escape', () => { + const sx = getActiveCellSx({ rowId: 'r', columnId: 'c' }, (value) => `<${value}>`); + assert.deepEqual(Object.keys(sx), ['& tbody tr[data-row-id=""] > td[data-col-id=""]']); +}); + +test('highlight keeps 3px frame and z-index 7', () => { + assert.equal(ACTIVE_CELL_STYLE.zIndex, 7); + assert.equal(ACTIVE_CELL_STYLE.boxShadow, 'inset 0 0 0 3px #1976d2'); +}); diff --git a/web/src/components/RealtimeTable/utils/columnLayoutUtils.js b/web/src/components/RealtimeTable/utils/columnLayoutUtils.js index 3a541ce..55f2fc3 100644 --- a/web/src/components/RealtimeTable/utils/columnLayoutUtils.js +++ b/web/src/components/RealtimeTable/utils/columnLayoutUtils.js @@ -11,7 +11,7 @@ export const getColumnVirtualizerOptions = ({ table }) => { const getColumnSize = (index) => columns[index]?.getSize() ?? 150; return { - overscan: 10, + overscan: 3, scrollPaddingStart: leftColumns.reduce((width, column) => width + column.getSize(), 0), scrollPaddingEnd: rightColumns.reduce((width, column) => width + column.getSize(), 0), estimateSize: getColumnSize, diff --git a/web/src/components/RealtimeTable/utils/columnLayoutUtils.test.js b/web/src/components/RealtimeTable/utils/columnLayoutUtils.test.js index c1ddf81..df6cc7c 100644 --- a/web/src/components/RealtimeTable/utils/columnLayoutUtils.test.js +++ b/web/src/components/RealtimeTable/utils/columnLayoutUtils.test.js @@ -60,3 +60,7 @@ test('scrolling to the far edge keeps both pinned sides rendered', () => { assert.deepEqual(virtualizer.rangeExtractor({ startIndex: 2, endIndex: 2, overscan: 0, count: 4 }), [0, 2, 3]); assert.deepEqual(virtualizer.rangeExtractor({ startIndex: 0, endIndex: 0, overscan: 0, count: 4 }), [0, 3]); }); + +test('column overscan is 3', () => { + assert.equal(getColumnVirtualizerOptions({ table: createFixture() }).overscan, 3); +});