From 971d4ba27e1a4bace129a2350f8f08201b4df001 Mon Sep 17 00:00:00 2001 From: jifeng Date: Tue, 7 Jul 2026 22:18:07 +0800 Subject: [PATCH] feat(web-shell): add column reorder, resize, and freeze controls to markdown table (#6444) * feat(web-shell): add markdown table column controls Support resizing, reordering, and freezing table columns while preserving visible-order copy behavior and selection stability. * fix(web-shell): address markdown table review feedback * fix(web-shell): refine markdown table column reordering * fix(web-shell): address markdown table review feedback --------- Co-authored-by: Shaojin Wen --- .../messages/EnhancedMarkdownTable.module.css | 132 +++++- .../messages/EnhancedMarkdownTable.test.tsx | 420 ++++++++++++++++++ .../messages/EnhancedMarkdownTable.tsx | 408 +++++++++++++++-- packages/web-shell/client/i18n.tsx | 8 + 4 files changed, 927 insertions(+), 41 deletions(-) diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css index 2d14f2a025..ab6b2e9f73 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css @@ -81,6 +81,8 @@ } .table { + --action-column-width: 72px; + width: max-content; min-width: 100%; border-collapse: separate; @@ -112,6 +114,11 @@ text-align: left; } +.frozenHeaderCell { + left: var(--action-column-width); + z-index: 5; +} + .headerControls { display: flex; align-items: stretch; @@ -162,6 +169,72 @@ cursor: pointer; } +.activeHeaderCell { + background: color-mix(in srgb, var(--agent-blue-500) 8%, var(--card)); +} + +.reorderHandle { + flex: 0 0 0; + width: 0; + min-width: 0; + padding: 0; + overflow: hidden; + border: none; + border-left: 0 solid transparent; + background: transparent; + color: var(--muted-foreground); + font: inherit; + font-size: 12px; + cursor: grab; + opacity: 0; + pointer-events: none; + transition: + flex-basis 120ms ease, + width 120ms ease, + opacity 120ms ease, + border-color 120ms ease, + background 120ms ease; +} + +.reorderHandleVisible, +.reorderHandle:focus-visible, +.reorderHandle:active { + flex-basis: 24px; + width: 24px; + border-left-width: 1px; + border-left-color: var(--border); + opacity: 1; + pointer-events: auto; +} + +.reorderHandle:active { + cursor: grabbing; +} + +.reorderHandle:hover, +.reorderHandle:focus-visible { + background: var(--subtle-bg-strong); +} + +.resizeHandle { + position: absolute; + top: 0; + right: -3px; + z-index: 7; + width: 6px; + height: 100%; + padding: 0; + border: none; + background: transparent; + cursor: col-resize; +} + +.resizeHandle:hover, +.resizeHandle:focus-visible { + background: color-mix(in srgb, var(--agent-blue-500) 45%, transparent); + outline: none; +} + .filterTriggerActive { background: color-mix(in srgb, var(--agent-blue-500) 18%, transparent); color: var(--agent-blue-500); @@ -342,18 +415,71 @@ tr:hover .selectedCell { .actionHeaderCell, .actionCell { - width: 72px; - min-width: 72px; - max-width: 72px; + width: var(--action-column-width); + min-width: var(--action-column-width); + max-width: var(--action-column-width); text-align: center; } +.stickyActionHeaderCell, +.stickyActionCell { + position: sticky; + left: 0; + z-index: 6; +} + +.stickyActionHeaderCell { + z-index: 8; +} + .actionCell { padding: 4px 6px; cursor: default; user-select: none; } +.frozenCell { + position: sticky; + left: var(--action-column-width); + z-index: 4; +} + +.stickyActionHeaderCell, +.frozenHeaderCell, +.stickyActionCell, +.frozenCell { + background: var(--card); +} + +.evenRow .stickyActionCell, +.evenRow .frozenCell { + background: + linear-gradient(var(--subtle-bg), var(--subtle-bg)), + var(--card); +} + +tr:hover .stickyActionCell, +tr:hover .frozenCell { + background: + linear-gradient(var(--subtle-bg-strong), var(--subtle-bg-strong)), + var(--card); +} + +.stickyActionCell.selectedCell, +.frozenCell.selectedCell, +.evenRow .stickyActionCell.selectedCell, +.evenRow .frozenCell.selectedCell, +tr:hover .stickyActionCell.selectedCell, +tr:hover .frozenCell.selectedCell { + background: + linear-gradient( + color-mix(in srgb, var(--agent-blue-500) 20%, transparent), + color-mix(in srgb, var(--agent-blue-500) 20%, transparent) + ), + var(--card); + box-shadow: inset 0 0 0 1px var(--agent-blue-500); +} + .rowDetailButton { padding: 3px 7px; border: 1px solid var(--border); diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx index c95f5f336d..d0bedaa7e7 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx @@ -11,6 +11,7 @@ import { EnhancedMarkdownTable } from './EnhancedMarkdownTable'; const mounted: Array<{ root: Root; container: HTMLElement }> = []; const originalElementFromPoint = document.elementFromPoint; +const COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column'; afterEach(() => { for (const { root, container } of mounted.splice(0)) { @@ -255,6 +256,100 @@ function dispatchCopy(target: Element) { return { event, setData }; } +function dragColumnElements(from: Element, to: Element): void { + const data = new Map(); + const dataTransfer = { + dropEffect: '', + effectAllowed: '', + get types() { + return [...data.keys()]; + }, + setData: vi.fn((type: string, value: string) => data.set(type, value)), + getData: vi.fn((type: string) => data.get(type) ?? ''), + }; + act(() => { + from.dispatchEvent( + Object.assign(new Event('dragstart', { bubbles: true }), { + dataTransfer, + }), + ); + to.dispatchEvent( + Object.assign( + new Event('dragover', { bubbles: true, cancelable: true }), + { + dataTransfer, + }, + ), + ); + to.dispatchEvent( + Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { + dataTransfer, + }), + ); + from.dispatchEvent(new Event('dragend', { bubbles: true })); + }); +} + +function dragColumn( + container: HTMLElement, + fromLabel: string, + toLabel: string, +): void { + dragColumnElements(button(container, fromLabel), button(container, toLabel)); +} + +function dropExternalColumn(container: HTMLElement, toLabel: string): void { + const dataTransfer = { + dropEffect: '', + effectAllowed: '', + types: ['text/plain'], + setData: vi.fn(), + getData: vi.fn(() => ''), + }; + const to = button(container, toLabel); + act(() => { + to.dispatchEvent( + Object.assign( + new Event('dragover', { bubbles: true, cancelable: true }), + { + dataTransfer, + }, + ), + ); + to.dispatchEvent( + Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { + dataTransfer, + }), + ); + }); +} + +function dropForgedColumn(container: HTMLElement, toLabel: string): void { + const dataTransfer = { + dropEffect: '', + effectAllowed: '', + types: [COLUMN_DRAG_MIME], + setData: vi.fn(), + getData: vi.fn(() => '2'), + }; + const to = button(container, toLabel); + act(() => { + to.dispatchEvent( + Object.assign( + new Event('dragover', { bubbles: true, cancelable: true }), + { + dataTransfer, + }, + ), + ); + to.dispatchEvent( + Object.assign(new Event('drop', { bubbles: true, cancelable: true }), { + dataTransfer, + }), + ); + }); +} + function touchEvent( type: string, touches: Array>, @@ -666,6 +761,330 @@ describe('EnhancedMarkdownTable', () => { ); }); + it('resizes a column from its header handle', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 100, + }), + ); + }); + act(() => { + window.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 160 }), + ); + window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '220px', + ); + expect(dataCell(container, 0, 0).style.width).toBe('220px'); + }); + + it('clamps resized columns to the minimum width', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 100, + }), + ); + }); + act(() => { + window.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 0 }), + ); + window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '80px', + ); + expect(dataCell(container, 0, 0).style.width).toBe('80px'); + }); + + it('clamps resized columns to the maximum width', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 100, + }), + ); + }); + act(() => { + window.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 900 }), + ); + window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '640px', + ); + expect(dataCell(container, 0, 0).style.width).toBe('640px'); + }); + + it('stops resizing a column when the window blurs', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 100, + }), + ); + }); + act(() => { + window.dispatchEvent(new Event('blur')); + }); + act(() => { + window.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 220 }), + ); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '160px', + ); + }); + + it('stops resizing a column when page visibility changes', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 100, + }), + ); + }); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + act(() => { + window.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 220 }), + ); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '160px', + ); + }); + + it('resizes a column with keyboard arrows', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'ArrowRight', + }), + ); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '176px', + ); + + act(() => { + resize.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'ArrowLeft', + }), + ); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '160px', + ); + }); + + it('shows column move handles only for the active column', () => { + const container = renderWideTable(); + const teamHandle = button(container, 'Move Team'); + const scoreHandle = button(container, 'Move Score'); + + expect(teamHandle.className).not.toContain('reorderHandleVisible'); + expect(teamHandle.tabIndex).toBe(-1); + expect(scoreHandle.className).not.toContain('reorderHandleVisible'); + expect(scoreHandle.tabIndex).toBe(-1); + + click(button(container, 'Sort by Team')); + + expect(teamHandle.className).toContain('reorderHandleVisible'); + expect(teamHandle.tabIndex).toBe(0); + expect(scoreHandle.className).not.toContain('reorderHandleVisible'); + expect(scoreHandle.tabIndex).toBe(-1); + expect( + button(container, 'Sort by Team, ascending').closest('th')?.className, + ).toContain('activeHeaderCell'); + }); + + it('reorders columns and quick copies in the visible order', () => { + const writeText = mockClipboard(); + const container = renderWideTable(); + + dragColumn(container, 'Move Score', 'Move Team'); + + expect(rowTexts(container)).toEqual(['10|Alpha|US', '2|Beta|EMEA']); + click(textButton(container, 'Quick copy')); + expect(writeText).toHaveBeenCalledWith( + ['Score\tTeam\tRegion', '10\tAlpha\tUS', '2\tBeta\tEMEA'].join('\n'), + ); + }); + + it('ignores external drops on column move handles', () => { + const container = renderWideTable(); + + dropExternalColumn(container, 'Move Score'); + + expect(rowTexts(container)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']); + }); + + it('ignores forged column drag payloads', () => { + const container = renderWideTable(); + + dropForgedColumn(container, 'Move Team'); + + expect(rowTexts(container)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']); + }); + + it('ignores column drags from another table', () => { + const source = renderWideTable(); + const target = renderWideTable(); + + dragColumnElements(button(source, 'Move Score'), button(target, 'Move Team')); + + expect(rowTexts(source)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']); + expect(rowTexts(target)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']); + }); + + it('drops reordered columns on the target header cell', () => { + const container = renderWideTable(); + + dragColumnElements( + button(container, 'Move Score'), + button(container, 'Sort by Team'), + ); + + expect(rowTexts(container)).toEqual(['10|Alpha|US', '2|Beta|EMEA']); + }); + + it('preserves hidden column slots when reordering visible columns', () => { + const container = renderWideTable(); + + click(button(container, 'Filter Region')); + click(textButton(container, 'Hide column')); + dragColumn(container, 'Move Score', 'Move Team'); + click(textButton(container, 'Show 1 hidden column')); + + expect(rowTexts(container)).toEqual(['10|US|Alpha', '2|EMEA|Beta']); + }); + + it('keeps hidden columns out of reordered selections', () => { + const writeText = mockClipboard(); + const container = renderWideTable(); + + dragColumn(container, 'Move Score', 'Move Team'); + click(button(container, 'Filter Region')); + click(textButton(container, 'Hide column')); + dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1)); + click(textButton(container, 'Copy TSV')); + + expect(writeText).toHaveBeenCalledWith(['10\tAlpha', '2\tBeta'].join('\n')); + }); + + it('toggles sticky classes for the action column and first visible column', () => { + const container = renderWideTable(); + + click(textButton(container, 'Freeze first column')); + + expect(textButton(container, 'Unfreeze first column')).toBeDefined(); + expect(container.querySelector('thead th')?.className).toContain( + 'stickyActionHeaderCell', + ); + expect( + button(container, 'Sort by Team').closest('th')?.className, + ).toContain('frozenHeaderCell'); + expect(dataCell(container, 0, 0).className).toContain('frozenCell'); + + dragColumn(container, 'Move Score', 'Move Team'); + expect( + button(container, 'Sort by Score').closest('th')?.className, + ).toContain('frozenHeaderCell'); + + click(textButton(container, 'Unfreeze first column')); + expect(container.textContent).toContain('Freeze first column'); + expect( + button(container, 'Sort by Score').closest('th')?.className, + ).not.toContain('frozenHeaderCell'); + }); + + it('keeps selected cell classes visible on a frozen column', () => { + const container = renderWideTable(); + + click(textButton(container, 'Freeze first column')); + dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1)); + + expect(container.textContent).toContain('4 cells selected'); + expect(dataCell(container, 0, 0).className).toContain('frozenCell'); + expect(dataCell(container, 0, 0).className).toContain('selectedCell'); + expect(dataCell(container, 0, 1).className).toContain('selectedCell'); + }); + + it('copies reordered selections from the keyboard copy event', () => { + const container = renderWideTable(); + + dragColumn(container, 'Move Score', 'Move Team'); + dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1)); + const scroller = container.querySelector('div[tabindex="0"]'); + expect(scroller).not.toBeNull(); + const { event, setData } = dispatchCopy(scroller!); + + expect(event.defaultPrevented).toBe(true); + expect(setData).toHaveBeenCalledWith( + 'text/plain', + ['10\tAlpha', '2\tBeta'].join('\n'), + ); + }); + + it('copies reordered selections after freezing the first column', () => { + const writeText = mockClipboard(); + const container = renderWideTable(); + + dragColumn(container, 'Move Score', 'Move Team'); + click(textButton(container, 'Freeze first column')); + dragCells(dataCell(container, 0, 0), dataCell(container, 1, 1)); + click(textButton(container, 'Copy TSV')); + + expect(writeText).toHaveBeenCalledWith(['10\tAlpha', '2\tBeta'].join('\n')); + }); + it('shows checkmark feedback after quick copy', async () => { vi.useFakeTimers(); mockClipboard(); @@ -1381,6 +1800,7 @@ describe('EnhancedMarkdownTable', () => { const container = renderTable('zh-CN'); expect(container.textContent).toContain('快捷复制'); + expect(container.textContent).toContain('冻结首列'); expect(container.textContent).toContain('详情'); click(button(container, '筛选 Team')); expect(container.textContent).toContain('隐藏列'); diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx index 0639b98b96..84beb673aa 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx @@ -10,6 +10,8 @@ import { useState, type CSSProperties, type ClipboardEvent, + type DragEvent as ReactDragEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, @@ -81,6 +83,12 @@ interface OpenFilterMenu { top: number; } +interface ColumnResizeState { + columnIndex: number; + startX: number; + startWidth: number; +} + interface FilterOption { value: string; label: string; @@ -105,6 +113,21 @@ const NUMBER_FILTER_LABEL_KEYS: Record = { export const MAX_ENHANCED_TABLE_ROWS = 500; export const MAX_ENHANCED_TABLE_COLUMNS = 50; +const DEFAULT_COLUMN_WIDTH = 160; +const MIN_COLUMN_WIDTH = 80; +const MAX_COLUMN_WIDTH = 640; +const KEYBOARD_COLUMN_RESIZE_STEP = 16; +const COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column'; +const DEFAULT_COLUMN_STYLE: CSSProperties = { + width: DEFAULT_COLUMN_WIDTH, + minWidth: DEFAULT_COLUMN_WIDTH, + maxWidth: DEFAULT_COLUMN_WIDTH, +}; + +function clampColumnWidth(width: number): number { + return Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, width)); +} + const FOCUSABLE_FILTER_MENU_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -278,15 +301,26 @@ function compareCellText(a: string, b: string): number { }); } -function getSelectionBounds(range: SelectionRange) { +function getSelectionRowBounds(range: SelectionRange) { return { minRow: Math.min(range.anchorRow, range.focusRow), maxRow: Math.max(range.anchorRow, range.focusRow), - minCol: Math.min(range.anchorCol, range.focusCol), - maxCol: Math.max(range.anchorCol, range.focusCol), }; } +function getSelectedColumnIndexes( + range: SelectionRange | null, + visibleColumnIndexes: number[], +): number[] { + if (!range) return []; + const anchorIndex = visibleColumnIndexes.indexOf(range.anchorCol); + const focusIndex = visibleColumnIndexes.indexOf(range.focusCol); + if (anchorIndex === -1 || focusIndex === -1) return []; + const minIndex = Math.min(anchorIndex, focusIndex); + const maxIndex = Math.max(anchorIndex, focusIndex); + return visibleColumnIndexes.slice(minIndex, maxIndex + 1); +} + function sanitizeForClipboard(value: string): string { const inspectedValue = value.replace( /[\u200B-\u200D\u2060\u00AD\uFEFF]/g, @@ -301,10 +335,8 @@ function getSelectionText( visibleColumnIndexes: number[], ): string { if (!range) return ''; - const { minRow, maxRow, minCol, maxCol } = getSelectionBounds(range); - const selectedColumns = visibleColumnIndexes.filter( - (columnIndex) => columnIndex >= minCol && columnIndex <= maxCol, - ); + const { minRow, maxRow } = getSelectionRowBounds(range); + const selectedColumns = getSelectedColumnIndexes(range, visibleColumnIndexes); if (selectedColumns.length === 0) return ''; const lines: string[] = []; @@ -350,13 +382,59 @@ function selectionSize( visibleColumnIndexes: number[], ): number { if (!range) return 0; - const { minRow, maxRow, minCol, maxCol } = getSelectionBounds(range); - const selectedColumnCount = visibleColumnIndexes.filter( - (columnIndex) => columnIndex >= minCol && columnIndex <= maxCol, + const { minRow, maxRow } = getSelectionRowBounds(range); + const selectedColumnCount = getSelectedColumnIndexes( + range, + visibleColumnIndexes, ).length; return (maxRow - minRow + 1) * selectedColumnCount; } +function moveColumn(order: number[], fromColumn: number, toColumn: number) { + if (fromColumn === toColumn) return order; + const fromIndex = order.indexOf(fromColumn); + const toIndex = order.indexOf(toColumn); + if (fromIndex === -1 || toIndex === -1) return order; + const next = [...order]; + const [moved] = next.splice(fromIndex, 1); + if (moved === undefined) return order; + next.splice(toIndex, 0, moved); + return next; +} + +function moveVisibleColumn( + order: number[], + fromColumn: number, + toColumn: number, + hiddenColumns: Set, +) { + if (hiddenColumns.size === 0) return moveColumn(order, fromColumn, toColumn); + const visibleColumns = order.filter( + (columnIndex) => !hiddenColumns.has(columnIndex), + ); + const nextVisibleColumns = moveColumn( + visibleColumns, + fromColumn, + toColumn, + ); + if (nextVisibleColumns === visibleColumns) return order; + let visibleIndex = 0; + return order.map((columnIndex) => { + if (hiddenColumns.has(columnIndex)) return columnIndex; + const nextColumn = nextVisibleColumns[visibleIndex]; + visibleIndex += 1; + return nextColumn ?? columnIndex; + }); +} + +function initialColumnOrder(columnCount: number): number[] { + return Array.from({ length: columnCount }, (_, index) => index); +} + +function hasColumnDragData(dataTransfer: DataTransfer): boolean { + return Array.from(dataTransfer.types).includes(COLUMN_DRAG_MIME); +} + function isFilterActive(filter: ColumnFilter | undefined): boolean { if (!filter) return false; if (filter.selectedValues !== undefined) return true; @@ -1071,6 +1149,14 @@ export function EnhancedTable({ const [hiddenColumns, setHiddenColumns] = useState>( () => new Set(), ); + const [columnWidths, setColumnWidths] = useState>({}); + const [columnOrder, setColumnOrder] = useState(() => + initialColumnOrder(table.columnCount), + ); + const [activeColumn, setActiveColumn] = useState(null); + const [freezeFirstColumn, setFreezeFirstColumn] = useState(false); + const [resizingColumn, setResizingColumn] = + useState(null); const [detailRowKey, setDetailRowKey] = useState(null); const [isDragging, setIsDragging] = useState(false); const [copiedVisible, setCopiedVisible] = useState(false); @@ -1095,6 +1181,7 @@ export function EnhancedTable({ columnIndex: number; } | null>(null); const selectionFrameRef = useRef(0); + const draggingColumnRef = useRef(null); const tableStructureKey = useMemo( () => `${table.columnCount}\0${table.headers.map((header) => header.text).join('\0')}`, @@ -1189,12 +1276,23 @@ export function EnhancedTable({ setSelection(null); setOpenFilterMenu(null); setHiddenColumns(new Set()); + setColumnWidths({}); + setColumnOrder(initialColumnOrder(table.columnCount)); + setActiveColumn(null); + setFreezeFirstColumn(false); + setResizingColumn(null); setDetailRowKey(null); resetCopiedVisible(); resetCopiedSelection(); draggingRef.current = false; + draggingColumnRef.current = null; setIsDragging(false); - }, [resetCopiedSelection, resetCopiedVisible, tableStructureKey]); + }, [ + resetCopiedSelection, + resetCopiedVisible, + table.columnCount, + tableStructureKey, + ]); useEffect(() => { resetCopiedSelection(); @@ -1303,17 +1401,54 @@ export function EnhancedTable({ ), [table.headers, table.rows], ); - const visibleColumnIndexes = useMemo( + const orderedVisibleColumnIndexes = useMemo( () => - table.headers - .map((_, index) => index) + columnOrder + .filter((index) => index >= 0 && index < table.columnCount) .filter((index) => !hiddenColumns.has(index)), - [hiddenColumns, table.headers], + [columnOrder, hiddenColumns, table.columnCount], ); + const frozenColumnIndex = freezeFirstColumn + ? orderedVisibleColumnIndexes[0] + : undefined; useEffect(() => { resetCopiedVisible(); - }, [resetCopiedVisible, visibleColumnIndexes, visibleRows]); + }, [resetCopiedVisible, orderedVisibleColumnIndexes, visibleRows]); + + useEffect(() => { + if (!resizingColumn) return; + const resizeColumn = (event: MouseEvent) => { + const nextWidth = clampColumnWidth( + resizingColumn.startWidth + event.clientX - resizingColumn.startX, + ); + setColumnWidths((current) => { + const currentWidth = + current[resizingColumn.columnIndex] ?? DEFAULT_COLUMN_WIDTH; + if (currentWidth === nextWidth) return current; + if (nextWidth === DEFAULT_COLUMN_WIDTH) { + const next = { ...current }; + delete next[resizingColumn.columnIndex]; + return next; + } + return { + ...current, + [resizingColumn.columnIndex]: nextWidth, + }; + }); + }; + const stopResize = () => setResizingColumn(null); + window.addEventListener('mousemove', resizeColumn); + window.addEventListener('mouseup', stopResize); + window.addEventListener('blur', stopResize); + document.addEventListener('visibilitychange', stopResize); + return () => { + window.removeEventListener('mousemove', resizeColumn); + window.removeEventListener('mouseup', stopResize); + window.removeEventListener('blur', stopResize); + document.removeEventListener('visibilitychange', stopResize); + }; + }, [resizingColumn]); useEffect(() => { if (detailRowKey && !visibleRows.some((row) => row.key === detailRowKey)) { @@ -1343,11 +1478,13 @@ export function EnhancedTable({ direction: SortState['direction'] | null, ) => { setSelection(null); + setActiveColumn(columnIndex); setSort(direction ? { columnIndex, direction } : null); }; const toggleSort = (columnIndex: number) => { setSelection(null); + setActiveColumn(columnIndex); setSort((current) => { if (current?.columnIndex !== columnIndex) { return { columnIndex, direction: 'asc' }; @@ -1364,6 +1501,7 @@ export function EnhancedTable({ columnIndex: number, ) => { setSelection(null); + setActiveColumn(columnIndex); filterTriggerRef.current = event.currentTarget; const buttonRect = event.currentTarget.getBoundingClientRect(); const menuWidth = 300; @@ -1389,8 +1527,9 @@ export function EnhancedTable({ }; const hideColumn = (columnIndex: number) => { - if (visibleColumnIndexes.length <= 1) return; + if (orderedVisibleColumnIndexes.length <= 1) return; setSelection(null); + setActiveColumn((current) => (current === columnIndex ? null : current)); closeFilterMenu(); setFilters((current) => { const next = { ...current }; @@ -1412,24 +1551,146 @@ export function EnhancedTable({ setHiddenColumns(new Set()); }; + const toggleFreezeFirstColumn = () => { + setFreezeFirstColumn((current) => !current); + }; + const toggleRowDetail = (rowKey: string) => { setSelection(null); setDetailRowKey((current) => (current === rowKey ? null : rowKey)); }; - const selectionBounds = useMemo( - () => (selection ? getSelectionBounds(selection) : null), + const selectionRowBounds = useMemo( + () => (selection ? getSelectionRowBounds(selection) : null), [selection], ); + const selectedColumnIndexSet = useMemo( + () => + new Set(getSelectedColumnIndexes(selection, orderedVisibleColumnIndexes)), + [selection, orderedVisibleColumnIndexes], + ); const isCellSelected = (rowIndex: number, columnIndex: number): boolean => { - if (!selectionBounds) return false; - const { minRow, maxRow, minCol, maxCol } = selectionBounds; + if (!selectionRowBounds) return false; + const { minRow, maxRow } = selectionRowBounds; return ( rowIndex >= minRow && rowIndex <= maxRow && - columnIndex >= minCol && - columnIndex <= maxCol + selectedColumnIndexSet.has(columnIndex) + ); + }; + + const columnStyle = ( + columnIndex: number, + extra?: CSSProperties, + ): CSSProperties => { + const width = columnWidths[columnIndex]; + if (width === undefined) { + return extra + ? { ...DEFAULT_COLUMN_STYLE, ...extra } + : DEFAULT_COLUMN_STYLE; + } + return { + width, + minWidth: width, + maxWidth: width, + ...extra, + }; + }; + + const startColumnResize = ( + event: ReactMouseEvent, + columnIndex: number, + ) => { + event.preventDefault(); + event.stopPropagation(); + setResizingColumn({ + columnIndex, + startX: event.clientX, + startWidth: columnWidths[columnIndex] ?? DEFAULT_COLUMN_WIDTH, + }); + }; + + const resizeColumnWithKeyboard = ( + event: ReactKeyboardEvent, + columnIndex: number, + ) => { + let delta = 0; + if (event.key === 'ArrowRight') { + delta = KEYBOARD_COLUMN_RESIZE_STEP; + } else if (event.key === 'ArrowLeft') { + delta = -KEYBOARD_COLUMN_RESIZE_STEP; + } + + if (delta === 0) return; + + event.preventDefault(); + event.stopPropagation(); + setColumnWidths((current) => { + const width = current[columnIndex] ?? DEFAULT_COLUMN_WIDTH; + const nextWidth = clampColumnWidth(width + delta); + if (width === nextWidth) return current; + if (nextWidth === DEFAULT_COLUMN_WIDTH) { + const next = { ...current }; + delete next[columnIndex]; + return next; + } + return { + ...current, + [columnIndex]: nextWidth, + }; + }); + }; + + const startColumnDrag = ( + event: ReactDragEvent, + columnIndex: number, + ) => { + event.stopPropagation(); + setActiveColumn(columnIndex); + draggingColumnRef.current = columnIndex; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(COLUMN_DRAG_MIME, String(columnIndex)); + }; + + const stopColumnDrag = () => { + draggingColumnRef.current = null; + }; + + const dragOverColumn = (event: ReactDragEvent) => { + if ( + draggingColumnRef.current === null || + !hasColumnDragData(event.dataTransfer) + ) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }; + + const dropColumn = ( + event: ReactDragEvent, + targetColumnIndex: number, + ) => { + const sourceColumnIndex = draggingColumnRef.current; + stopColumnDrag(); + if ( + sourceColumnIndex === null || + !hasColumnDragData(event.dataTransfer) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + setSelection(null); + setActiveColumn(sourceColumnIndex); + setColumnOrder((current) => + moveVisibleColumn( + current, + sourceColumnIndex, + targetColumnIndex, + hiddenColumns, + ), ); }; @@ -1523,7 +1784,11 @@ export function EnhancedTable({ }; const copySelection = () => { - const text = getSelectionText(selection, visibleRows, visibleColumnIndexes); + const text = getSelectionText( + selection, + visibleRows, + orderedVisibleColumnIndexes, + ); if (!text || !navigator.clipboard) return; const copyGeneration = copiedSelectionGenRef.current; void navigator.clipboard @@ -1549,7 +1814,7 @@ export function EnhancedTable({ const text = getVisibleTableText( table.headers, visibleRows, - visibleColumnIndexes, + orderedVisibleColumnIndexes, ); if (!text || !navigator.clipboard) return; const copyGeneration = copiedVisibleGenRef.current; @@ -1574,13 +1839,17 @@ export function EnhancedTable({ const handleCopy = (event: ClipboardEvent) => { if (hasNativeSelection()) return; - const text = getSelectionText(selection, visibleRows, visibleColumnIndexes); + const text = getSelectionText( + selection, + visibleRows, + orderedVisibleColumnIndexes, + ); if (!text) return; event.preventDefault(); event.clipboardData.setData('text/plain', text); }; - const selectedCount = selectionSize(selection, visibleColumnIndexes); + const selectedCount = selectionSize(selection, orderedVisibleColumnIndexes); const activeFilterCount = Object.values(filters).filter(isFilterActive).length; const rowSummary = @@ -1633,6 +1902,17 @@ export function EnhancedTable({ })} )} + {orderedVisibleColumnIndexes.length > 0 && ( + + )} {activeFilterCount > 0 && ( {t('markdownTable.filtersActive', { count: activeFilterCount })} @@ -1670,10 +1950,14 @@ export function EnhancedTable({ - - {visibleColumnIndexes.map((columnIndex) => { + {orderedVisibleColumnIndexes.map((columnIndex) => { const header = table.headers[columnIndex]; if (!header) return null; const isSorted = sort?.columnIndex === columnIndex; @@ -1704,12 +1988,18 @@ export function EnhancedTable({ const headerAlignStyle = header.textAlign ? { textAlign: header.textAlign } : undefined; + const isFrozenColumn = frozenColumnIndex === columnIndex; + const isActiveColumn = activeColumn === columnIndex; return ( ); })} @@ -1760,7 +2087,11 @@ export function EnhancedTable({ - - {visibleColumnIndexes.map((columnIndex) => { + {orderedVisibleColumnIndexes.map((columnIndex) => { const cell = row.cells[columnIndex]; if (!cell) return null; const cellAlignStyle = cell.textAlign ? { textAlign: cell.textAlign } : undefined; + const isFrozenColumn = frozenColumnIndex === columnIndex; return (
+ {t('markdownTable.actions')} dropColumn(event, columnIndex)} + style={columnStyle(columnIndex, headerAlignStyle)} >
+
+
+ @@ -1816,13 +2148,13 @@ export function EnhancedTable({
{t('markdownTable.detailsHeader')}
- {visibleColumnIndexes.map((columnIndex) => { + {orderedVisibleColumnIndexes.map((columnIndex) => { const header = table.headers[columnIndex]; const cell = row.cells[columnIndex]; if (!header || !cell) return null; @@ -1881,7 +2213,7 @@ export function EnhancedTable({ sort={sort} style={{ left: openFilterMenu.left, top: openFilterMenu.top }} menuRef={filterMenuRef} - canHideColumn={visibleColumnIndexes.length > 1} + canHideColumn={orderedVisibleColumnIndexes.length > 1} onApply={setColumnFilter} onClose={closeFilterMenu} onHideColumn={hideColumn} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 8defe5be78..06716cb6b6 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -265,11 +265,15 @@ const EN: Messages = { }, 'markdownTable.copyTsv': 'Copy TSV', 'markdownTable.copyVisible': 'Quick copy', + 'markdownTable.freezeFirstColumn': 'Freeze first column', + 'markdownTable.unfreezeFirstColumn': 'Unfreeze first column', 'markdownTable.hideColumn': 'Hide column', 'markdownTable.showHiddenColumns': (v) => { const count = Number(v?.count ?? 0); return `Show ${count} hidden column${count === 1 ? '' : 's'}`; }, + 'markdownTable.moveColumn': (v) => `Move ${v?.column ?? ''}`, + 'markdownTable.resizeColumn': (v) => `Resize ${v?.column ?? ''}`, 'markdownTable.rowDetails': 'Details', 'markdownTable.rowDetailsAria': (v) => `View details for row ${v?.index ?? ''}`, @@ -1910,8 +1914,12 @@ const ZH: Messages = { 'markdownTable.cellsSelected': (v) => `${v?.count ?? 0} 个单元格已选中`, 'markdownTable.copyTsv': '复制 TSV', 'markdownTable.copyVisible': '快捷复制', + 'markdownTable.freezeFirstColumn': '冻结首列', + 'markdownTable.unfreezeFirstColumn': '取消冻结首列', 'markdownTable.hideColumn': '隐藏列', 'markdownTable.showHiddenColumns': (v) => `显示 ${v?.count ?? 0} 个隐藏列`, + 'markdownTable.moveColumn': (v) => `移动 ${v?.column ?? ''}`, + 'markdownTable.resizeColumn': (v) => `调整 ${v?.column ?? ''} 列宽`, 'markdownTable.rowDetails': '详情', 'markdownTable.rowDetailsAria': (v) => `查看第 ${v?.index ?? ''} 行详情`, 'markdownTable.closeRowDetailsAria': (v) => `收起第 ${v?.index ?? ''} 行详情`,