From 22c639c9e684553df5c54e9a0e147e5da6b5b207 Mon Sep 17 00:00:00 2001 From: Maksim Kostromin Date: Tue, 13 Oct 2026 15:14:03 -0400 Subject: [PATCH] feat: pin any columns with an anchor and keep merged block label in view Each column header has an anchor button. Pinned columns stay in place and stick to the left edge on horizontal scroll, several can be pinned and they stack in column order. Tab view keeps one pin set per file name, merge view shares one set because its blocks share columns. Pins persist with the other settings. The 1st Col = Sticky toggle is removed: nothing is pinned by default, column 0 is pinned with its anchor like any other. The Merged Block label sticks to the right edge of the viewport instead of sitting at the far end of the row. --- src/main.tsx | 97 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 78 insertions(+), 19 deletions(-) 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}