diff --git a/src/main.tsx b/src/main.tsx index c87d616..00921e4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -195,7 +195,7 @@ */ // @ts-ignore -import React, { useState, useEffect, useRef, useMemo, useCallback, ChangeEvent } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback, ChangeEvent } from 'react'; import { createRoot } from 'react-dom/client'; // ============================================================================ @@ -249,12 +249,15 @@ const NUMERIC_FILTER_TYPES: Set = new Set(['number', 'percent', 'cur interface AppSettings { theme: 'light' | 'dark'; firstRowIsHeader: boolean; - firstColIsHeader: boolean; mergeFiles: boolean; stickyHeaders: boolean; rememberData: boolean; // Persist imported datasets to localStorage across reloads columnCustomNames: Record; columnTypeOverrides: Record; + /** Columns anchored by the user: they stay in place and stick to the left edge on horizontal scroll. + * Merge view shares one set (its blocks share columns), tab view keeps one set per file name */ + pinnedColumns: number[]; + pinnedColumnsByFile: Record; /** Persisted column filter expressions, keyed by column index */ columnFilters: Record; /** Persisted global sort state */ @@ -309,12 +312,13 @@ const LOCALSTORAGE_MAX_DATA_MB = 4; const DEFAULT_SETTINGS: AppSettings = { theme: 'light', firstRowIsHeader: false, - firstColIsHeader: true, mergeFiles: false, stickyHeaders: true, rememberData: true, columnCustomNames: {}, columnTypeOverrides: {}, + pinnedColumns: [], + pinnedColumnsByFile: {}, columnFilters: {}, sortState: { columnIndex: null, direction: 'asc' }, }; @@ -323,7 +327,9 @@ const DEFAULT_LOADING_STATE: LoadingState = { active: false, phase: 'idle', curr const DEFAULT_SORT_STATE: SortState = { columnIndex: null, direction: 'asc' }; const EXPENSIVE_TOGGLE_ROW_THRESHOLD = 500; const TYPE_DETECTION_SAMPLE_SIZE = 20; -const LAYOUT_RESTRUCTURING_KEYS: Set = new Set(['mergeFiles', 'firstRowIsHeader', 'firstColIsHeader']); +const LAYOUT_RESTRUCTURING_KEYS: Set = new Set(['mergeFiles', 'firstRowIsHeader']); +/** Merge view renders every column at a fixed width (Tailwind w-[180px]), so pinned offsets are multiples of it */ +const MERGE_COLUMN_WIDTH = 180; const naturalCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); /** @@ -697,10 +703,14 @@ function makeExportFileName(originalName: string): string { */ const isPlainObject = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); +const columnIndexes = (v: unknown): number[] => (Array.isArray(v) + ? [...new Set(v.filter((x): x is number => Number.isInteger(x) && x >= 0))].sort((a, b) => a - b) + : []); + /** Settings from localStorage or an imported file: every field is checked, a bad one falls back to the default. */ function sanitizeSettings(raw: unknown): AppSettings { const s = isPlainObject(raw) ? raw : {}; - const bool = (key: 'firstRowIsHeader' | 'firstColIsHeader' | 'mergeFiles' | 'stickyHeaders' | 'rememberData') => + const bool = (key: 'firstRowIsHeader' | 'mergeFiles' | 'stickyHeaders' | 'rememberData') => (typeof s[key] === 'boolean' ? s[key] : DEFAULT_SETTINGS[key]) as boolean; const strings = (v: unknown) => (isPlainObject(v) ? Object.fromEntries(Object.entries(v).filter(([k, x]) => /^\d+$/.test(k) && typeof x === 'string')) @@ -712,12 +722,15 @@ function sanitizeSettings(raw: unknown): AppSettings { return { theme: s.theme === 'dark' ? 'dark' : 'light', firstRowIsHeader: bool('firstRowIsHeader'), - firstColIsHeader: bool('firstColIsHeader'), mergeFiles: bool('mergeFiles'), stickyHeaders: bool('stickyHeaders'), rememberData: bool('rememberData'), columnCustomNames: strings(s.columnCustomNames), columnTypeOverrides: types as Record, + pinnedColumns: columnIndexes(s.pinnedColumns), + pinnedColumnsByFile: isPlainObject(s.pinnedColumnsByFile) + ? Object.fromEntries(Object.entries(s.pinnedColumnsByFile).map(([k, v]) => [k, columnIndexes(v)]).filter(([, v]) => v.length > 0)) + : {}, columnFilters: strings(s.columnFilters), sortState: { columnIndex: typeof sort.columnIndex === 'number' && Number.isInteger(sort.columnIndex) && sort.columnIndex >= 0 ? sort.columnIndex : null, @@ -959,11 +972,13 @@ const ColumnHeader: React.FC<{ onSort: (col: number, dir: 'asc' | 'desc') => void; onTypeChange: (col: number, t: ColumnType) => void; onFilterChange: (col: number, value: string) => void; + isPinned: boolean; + onTogglePin: (col: number) => void; isMergeView?: boolean; }> = ({ header, colIndex, sortState, colType, isEditing, editValue, filterValue, onEditChange, onEditStart, onEditSave, onEditCancel, - onSort, onTypeChange, onFilterChange, isMergeView = false, + onSort, onTypeChange, onFilterChange, isPinned, onTogglePin, isMergeView = false, }) => { const isActiveSort = sortState.columnIndex === colIndex; const isAsc = isActiveSort && sortState.direction === 'asc'; @@ -982,6 +997,11 @@ const ColumnHeader: React.FC<{ + {header}