From 201a040a8a0fc64f47529df21af9a44226a69c99 Mon Sep 17 00:00:00 2001 From: JayBridge <12310903@mail.sustech.edu.cn> Date: Mon, 30 Jun 2025 22:08:58 +0800 Subject: [PATCH] feat: implement sticky rows and columns functionality with pin/unpin buttons for enhanced table navigation #12 --- docs/STICKY_HEADERS.md | 91 +++++++++++++++++++++++++++++++ src/view.ts | 75 ++++++++++++++++++++++++- src/view/table-render.ts | 37 +++++++++++++ styles.css | 68 ++++++++++++++++++++++- test/sticky-functionality.test.ts | 90 ++++++++++++++++++++++++++++++ 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 docs/STICKY_HEADERS.md create mode 100644 test/sticky-functionality.test.ts diff --git a/docs/STICKY_HEADERS.md b/docs/STICKY_HEADERS.md new file mode 100644 index 0000000..08d1b6a --- /dev/null +++ b/docs/STICKY_HEADERS.md @@ -0,0 +1,91 @@ +# 固定行列功能 (Sticky Headers & Columns) + +## 概述 +新增的固定行列功能允许用户通过简单的Pin/Unpin按钮来固定表格的特定行和列,在滚动大型CSV文件时保持重要的标题行和关键列始终可见。 + +## 使用方法 + +### 固定列 +1. 将鼠标悬停在列标题(A, B, C...)上 +2. 点击出现的📍图标来固定该列 +3. 固定后图标变为📌,列将在水平滚动时保持可见 +4. 再次点击📌图标可取消固定 + +### 固定行 +1. 将鼠标悬停在行号(0, 1, 2...)上 +2. 点击出现的📍图标来固定该行 +3. 固定后图标变为📌,行将在垂直滚动时保持可见 +4. 再次点击📌图标可取消固定 + +## 技术实现 + +### 核心技术 +- **CSS Position Sticky**: 使用原生CSS sticky定位实现流畅的固定效果 +- **动态类管理**: 通过JavaScript动态添加/移除`.csv-sticky-row`和`.csv-sticky-col`类 +- **事件驱动**: 基于用户点击事件的响应式Pin/Unpin机制 + +### 架构特点 +- **保持原有结构**: 不破坏现有的单一表格结构,完全兼容现有功能 +- **性能优化**: 使用CSS sticky而非JavaScript滚动监听,性能更佳 +- **灵活配置**: 支持任意数量的行列同时固定 + +### 样式层次 +```css +.csv-sticky-row { + position: sticky !important; + top: 0; + z-index: 20; +} + +.csv-sticky-col { + position: sticky !important; + left: 0; + z-index: 15; +} + +.csv-sticky-row.csv-sticky-col { + z-index: 25; /* 交叉区域最高优先级 */ +} +``` + +## 优势对比 + +### vs 四象限方案 +- ✅ **兼容性好**: 保持原有表格结构,所有现有功能无缝兼容 +- ✅ **实现简单**: 无需复杂的四象限布局和滚动同步 +- ✅ **性能更好**: 利用浏览器原生sticky性能优化 +- ✅ **维护容易**: 代码结构清晰,易于调试和扩展 + +### vs 复杂UI面板 +- ✅ **用户体验**: 直观的Pin/Unpin按钮,所见即所得 +- ✅ **学习成本低**: 无需额外的配置界面,点击即用 +- ✅ **空间节约**: 不占用额外的UI空间 + +## 文件变更 + +### 新增功能 +- `src/view.ts`: 添加sticky状态管理和切换逻辑 +- `src/view/table-render.ts`: 在表头添加Pin/Unpin按钮 +- `styles.css`: 添加sticky样式和按钮样式 + +### 测试覆盖 +- `test/sticky-functionality.test.ts`: 核心逻辑单元测试 + +## 使用场景 + +### 数据分析 +- 固定表头行查看列含义 +- 固定ID列追踪特定记录 +- 固定日期列对比时间序列 + +### 大表格导航 +- 1000+行数据滚动时保持标题可见 +- 多列对比时固定关键基准列 +- 分组数据查看时固定分组标识列 + +## 兼容性 +- ✅ 所有现有CSV编辑功能 +- ✅ 搜索和筛选功能 +- ✅ 拖拽排序功能 +- ✅ 列宽调整功能 +- ✅ 右键菜单功能 \ No newline at end of file diff --git a/src/view.ts b/src/view.ts index 5db0b64..0a388b4 100644 --- a/src/view.ts +++ b/src/view.ts @@ -69,6 +69,10 @@ export class CSVView extends TextFileView { // 新增:header context menu 解绑函数 private headerContextMenuCleanup: (() => void) | null = null; + // 新增:固定行列功能 + private stickyRows: Set = new Set(); + private stickyColumns: Set = new Set(); + constructor(leaf: any) { super(leaf); this.historyManager = new TableHistoryManager( @@ -204,7 +208,7 @@ export class CSVView extends TextFileView { this.tableData = [[""]]; } - // 迁移:表格渲染全部交由 table-render.ts 处理 + // 恢复原有表格渲染方式 // 传递renderEditBar给renderTable,实现双向同步 const renderEditBarBridge = (row: number, col: number, cellEl: HTMLInputElement) => { renderEditBar({ @@ -298,8 +302,16 @@ export class CSVView extends TextFileView { this.refresh(); this.requestSave(); }, + // 新增:固定行列相关回调 + stickyRows: this.stickyRows, + stickyColumns: this.stickyColumns, + toggleRowSticky: (rowIndex: number) => this.toggleRowSticky(rowIndex), + toggleColumnSticky: (colIndex: number) => this.toggleColumnSticky(colIndex), }); + // 应用sticky样式 + this.applyStickyStyles(); + // 在完成表格渲染后,更新滚动条容器的宽度 // 现在topScrollContainer在operationEl下方 const topScroll = this.operationEl?.querySelector?.('.top-scroll'); @@ -646,6 +658,8 @@ export class CSVView extends TextFileView { this.refresh(); }); + + // 编辑栏(sticky工具栏内,按钮和搜索栏之后) this.editBarEl = this.operationEl.createEl("div", { cls: "csv-edit-bar", @@ -909,6 +923,65 @@ export class CSVView extends TextFileView { this.refresh(); this.requestSave(); } + + // 新增:切换行固定状态 + private toggleRowSticky(rowIndex: number) { + if (this.stickyRows.has(rowIndex)) { + this.stickyRows.delete(rowIndex); + } else { + this.stickyRows.add(rowIndex); + } + this.applyStickyStyles(); + } + + // 新增:切换列固定状态 + private toggleColumnSticky(colIndex: number) { + if (this.stickyColumns.has(colIndex)) { + this.stickyColumns.delete(colIndex); + } else { + this.stickyColumns.add(colIndex); + } + this.applyStickyStyles(); + } + + // 新增:应用sticky样式 + private applyStickyStyles() { + if (!this.tableEl) return; + + // 移除所有现有的sticky类 + this.tableEl.querySelectorAll('.csv-sticky-row, .csv-sticky-col').forEach(el => { + el.classList.remove('csv-sticky-row', 'csv-sticky-col'); + }); + + // 应用固定行样式 + this.stickyRows.forEach(rowIndex => { + // 表头行(列标题行) + if (rowIndex === -1) { + const headerCells = this.tableEl.querySelectorAll('thead tr th'); + headerCells.forEach(cell => cell.classList.add('csv-sticky-row')); + } else { + // 数据行(在tbody中,从第1个tr开始) + const rowCells = this.tableEl.querySelectorAll(`tbody tr:nth-child(${rowIndex + 1}) td`); + rowCells.forEach(cell => cell.classList.add('csv-sticky-row')); + } + }); + + // 应用固定列样式 + this.stickyColumns.forEach(colIndex => { + // 列头(在thead中) + const headerCell = this.tableEl.querySelector(`thead tr th:nth-child(${colIndex + 2})`); + if (headerCell) headerCell.classList.add('csv-sticky-col'); + + // 数据列(在tbody的所有行中) + const dataCells = this.tableEl.querySelectorAll(`tbody tr td:nth-child(${colIndex + 2})`); + dataCells.forEach(cell => cell.classList.add('csv-sticky-col')); + }); + + console.log('Applied sticky styles:', { + stickyRows: Array.from(this.stickyRows), + stickyColumns: Array.from(this.stickyColumns) + }); + } moveCol(fromIndex: number, toIndex: number) { if (fromIndex < 0 || toIndex < 0 || fromIndex >= this.tableData[0].length || toIndex >= this.tableData[0].length) return; this.saveSnapshot(); diff --git a/src/view/table-render.ts b/src/view/table-render.ts index d3ff878..7915071 100644 --- a/src/view/table-render.ts +++ b/src/view/table-render.ts @@ -29,6 +29,11 @@ export interface TableRenderOptions { // 拖拽排序回调 onColumnReorder?: (from: number, to: number) => void; onRowReorder?: (from: number, to: number) => void; + // 新增:固定行列相关 + stickyRows?: Set; + stickyColumns?: Set; + toggleRowSticky?: (rowIndex: number) => void; + toggleColumnSticky?: (colIndex: number) => void; } export function renderTable(options: TableRenderOptions) { @@ -57,6 +62,10 @@ export function renderTable(options: TableRenderOptions) { renderEditBar, onColumnReorder, onRowReorder, + stickyRows, + stickyColumns, + toggleRowSticky, + toggleColumnSticky, } = options; tableEl.empty(); @@ -101,6 +110,20 @@ export function renderTable(options: TableRenderOptions) { e.stopPropagation(); selectColumn(index); }; + + // 添加pin/unpin按钮 + if (toggleColumnSticky) { + const isSticky = stickyColumns?.has(index) || false; + const pinBtn = th.createEl("button", { + cls: `csv-pin-btn csv-pin-col ${isSticky ? 'pinned' : ''}`, + attr: { title: isSticky ? "Unpin column" : "Pin column" } + }); + pinBtn.innerHTML = isSticky ? "📌" : "📍"; + pinBtn.onclick = (e) => { + e.stopPropagation(); + toggleColumnSticky(index); + }; + } // 拖拽排序事件 th.ondragstart = (e) => { e.dataTransfer?.setData("text/col-index", String(index)); @@ -173,6 +196,20 @@ export function renderTable(options: TableRenderOptions) { e.stopPropagation(); selectRow(i); }; + + // 添加pin/unpin按钮 + if (toggleRowSticky) { + const isSticky = stickyRows?.has(i) || false; + const pinBtn = rowNumberCell.createEl("button", { + cls: `csv-pin-btn csv-pin-row ${isSticky ? 'pinned' : ''}`, + attr: { title: isSticky ? "Unpin row" : "Pin row" } + }); + pinBtn.innerHTML = isSticky ? "📌" : "📍"; + pinBtn.onclick = (e) => { + e.stopPropagation(); + toggleRowSticky(i); + }; + } // 拖拽排序事件 rowNumberCell.ondragstart = (e) => { e.dataTransfer?.setData("text/row-index", String(i)); diff --git a/styles.css b/styles.css index a085b2d..0921a2d 100644 --- a/styles.css +++ b/styles.css @@ -99,6 +99,71 @@ If your plugin does not need CSS, delete this file. display: flex; align-items: center; gap: 8px; + flex-wrap: wrap; +} + +/* Pin按钮样式 */ +.csv-pin-btn { + position: absolute; + top: 2px; + right: 2px; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + font-size: 10px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; +} + +.csv-row-number:hover .csv-pin-btn, +.csv-col-number:hover .csv-pin-btn { + opacity: 1; +} + +.csv-pin-btn.pinned { + opacity: 1; +} + +.csv-pin-btn:hover { + background: var(--background-modifier-hover); + border-radius: 2px; +} + +/* Sticky行列样式 */ +.csv-sticky-row { + position: sticky !important; + top: 0 !important; + background: var(--background-secondary) !important; + z-index: 20 !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important; +} + +.csv-sticky-col { + position: sticky !important; + left: 0 !important; + background: var(--background-secondary) !important; + z-index: 15 !important; + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1) !important; +} + +/* 同时是sticky行和列的单元格 */ +.csv-sticky-row.csv-sticky-col { + z-index: 25 !important; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.15) !important; +} + +/* 确保表格容器支持sticky定位 */ +.table-container { + position: relative !important; + overflow: auto !important; +} + +.csv-lite-table { + position: relative !important; } /* 搜索栏样式 */ @@ -152,6 +217,8 @@ td input:focus { z-index: 5; /* 设置表格的层级 */ } + + /* 表格包装器,包含滚动条和主表格 */ .table-wrapper { display: flex; @@ -310,7 +377,6 @@ td.csv-row-number{ left: 0; z-index: 10; overflow: visible; /* 确保按钮不会被遮挡 */ - position: relative; /* 确保定位的按钮能够正确显示 */ } .csv-col-number { diff --git a/test/sticky-functionality.test.ts b/test/sticky-functionality.test.ts new file mode 100644 index 0000000..964b46f --- /dev/null +++ b/test/sticky-functionality.test.ts @@ -0,0 +1,90 @@ +// 简单的功能测试,不依赖Obsidian的复杂继承 +export {}; + +describe('Sticky Functionality', () => { + + test('should handle Set operations for sticky rows and columns', () => { + const stickyRows = new Set(); + const stickyColumns = new Set(); + + // Test row toggle functionality + const toggleRowSticky = (rowIndex: number) => { + if (stickyRows.has(rowIndex)) { + stickyRows.delete(rowIndex); + } else { + stickyRows.add(rowIndex); + } + }; + + // Test column toggle functionality + const toggleColumnSticky = (colIndex: number) => { + if (stickyColumns.has(colIndex)) { + stickyColumns.delete(colIndex); + } else { + stickyColumns.add(colIndex); + } + }; + + // Initially empty + expect(stickyRows.size).toBe(0); + expect(stickyColumns.size).toBe(0); + + // Add rows + toggleRowSticky(0); + expect(stickyRows.has(0)).toBe(true); + expect(stickyRows.size).toBe(1); + + toggleRowSticky(1); + expect(stickyRows.has(1)).toBe(true); + expect(stickyRows.size).toBe(2); + + // Add columns + toggleColumnSticky(0); + expect(stickyColumns.has(0)).toBe(true); + expect(stickyColumns.size).toBe(1); + + // Remove row + toggleRowSticky(0); + expect(stickyRows.has(0)).toBe(false); + expect(stickyRows.size).toBe(1); + + // Remove column + toggleColumnSticky(0); + expect(stickyColumns.has(0)).toBe(false); + expect(stickyColumns.size).toBe(0); + }); + + test('should generate correct CSS selectors for sticky elements', () => { + const getStickyRowSelector = (rowIndex: number) => + `tr:nth-child(${rowIndex + 2}) td, tr:nth-child(${rowIndex + 2}) th`; + + const getStickyColSelector = (colIndex: number) => + `td:nth-child(${colIndex + 2}), th:nth-child(${colIndex + 2})`; + + // Test row selectors (adding 2 because: 1 for 1-indexed, 1 for header row) + expect(getStickyRowSelector(0)).toBe('tr:nth-child(2) td, tr:nth-child(2) th'); + expect(getStickyRowSelector(1)).toBe('tr:nth-child(3) td, tr:nth-child(3) th'); + + // Test column selectors (adding 2 because: 1 for 1-indexed, 1 for row number column) + expect(getStickyColSelector(0)).toBe('td:nth-child(2), th:nth-child(2)'); + expect(getStickyColSelector(1)).toBe('td:nth-child(3), th:nth-child(3)'); + }); + + test('should determine pin button state correctly', () => { + const stickyRows = new Set([0, 2]); + const stickyColumns = new Set([1]); + + const getRowPinState = (rowIndex: number) => stickyRows.has(rowIndex); + const getColPinState = (colIndex: number) => stickyColumns.has(colIndex); + + // Test row pin states + expect(getRowPinState(0)).toBe(true); // pinned + expect(getRowPinState(1)).toBe(false); // not pinned + expect(getRowPinState(2)).toBe(true); // pinned + + // Test column pin states + expect(getColPinState(0)).toBe(false); // not pinned + expect(getColPinState(1)).toBe(true); // pinned + expect(getColPinState(2)).toBe(false); // not pinned + }); +}); \ No newline at end of file