From 6de3f4c19c081a641ee7eaf4338e85f39a51306c Mon Sep 17 00:00:00 2001 From: "jifeng.zjd" Date: Tue, 7 Jul 2026 17:06:42 +0800 Subject: [PATCH] feat(web-shell): add markdown table column controls Support resizing, reordering, and freezing table columns while preserving visible-order copy behavior and selection stability. --- .../messages/EnhancedMarkdownTable.module.css | 67 ++++- .../messages/EnhancedMarkdownTable.test.tsx | 192 ++++++++++++ .../messages/EnhancedMarkdownTable.tsx | 278 +++++++++++++++--- packages/web-shell/client/i18n.tsx | 8 + 4 files changed, 509 insertions(+), 36 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..779b0a6026 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,43 @@ cursor: pointer; } +.reorderHandle { + flex: 0 0 28px; + border: none; + border-left: 1px solid var(--border); + background: transparent; + color: var(--muted-foreground); + font: inherit; + cursor: grab; +} + +.reorderHandle:active { + cursor: grabbing; +} + +.reorderHandle:hover { + 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 +386,35 @@ 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; +} + .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..ffa5a2d9bc 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx @@ -255,6 +255,71 @@ function dispatchCopy(target: Element) { return { event, setData }; } +function dragColumn( + container: HTMLElement, + fromLabel: string, + toLabel: string, +): 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) ?? ''), + }; + const from = button(container, fromLabel); + const to = button(container, toLabel); + 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, + }), + ); + }); +} + +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 touchEvent( type: string, touches: Array>, @@ -666,6 +731,132 @@ 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('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('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 +1572,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..20c6857cea 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx @@ -10,6 +10,7 @@ import { useState, type CSSProperties, type ClipboardEvent, + type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, @@ -81,6 +82,12 @@ interface OpenFilterMenu { top: number; } +interface ColumnResizeState { + columnIndex: number; + startX: number; + startWidth: number; +} + interface FilterOption { value: string; label: string; @@ -105,6 +112,10 @@ 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 COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column'; const FOCUSABLE_FILTER_MENU_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; @@ -282,11 +293,22 @@ function getSelectionBounds(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 +323,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 } = getSelectionBounds(range); + const selectedColumns = getSelectedColumnIndexes(range, visibleColumnIndexes); if (selectedColumns.length === 0) return ''; const lines: string[] = []; @@ -350,13 +370,41 @@ 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 } = getSelectionBounds(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 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 getDraggedColumnIndex(dataTransfer: DataTransfer): number | null { + const rawColumnIndex = dataTransfer.getData(COLUMN_DRAG_MIME); + if (rawColumnIndex === '') return null; + const columnIndex = Number(rawColumnIndex); + return Number.isInteger(columnIndex) ? columnIndex : null; +} + function isFilterActive(filter: ColumnFilter | undefined): boolean { if (!filter) return false; if (filter.selectedValues !== undefined) return true; @@ -1071,6 +1119,14 @@ export function EnhancedTable({ const [hiddenColumns, setHiddenColumns] = useState>( () => new Set(), ); + const [columnWidths, setColumnWidths] = useState>({}); + const [columnOrder, setColumnOrder] = useState(() => + initialColumnOrder(table.columnCount), + ); + const [freezeFirstColumn, setFreezeFirstColumn] = useState(false); + const [resizingColumn, setResizingColumn] = + useState(null); + const [draggingColumn, setDraggingColumn] = useState(null); const [detailRowKey, setDetailRowKey] = useState(null); const [isDragging, setIsDragging] = useState(false); const [copiedVisible, setCopiedVisible] = useState(false); @@ -1189,6 +1245,11 @@ export function EnhancedTable({ setSelection(null); setOpenFilterMenu(null); setHiddenColumns(new Set()); + setColumnWidths({}); + setColumnOrder(initialColumnOrder(table.columnCount)); + setFreezeFirstColumn(false); + setResizingColumn(null); + setDraggingColumn(null); setDetailRowKey(null); resetCopiedVisible(); resetCopiedSelection(); @@ -1303,17 +1364,44 @@ 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 = Math.min( + MAX_COLUMN_WIDTH, + Math.max( + MIN_COLUMN_WIDTH, + resizingColumn.startWidth + event.clientX - resizingColumn.startX, + ), + ); + setColumnWidths((current) => ({ + ...current, + [resizingColumn.columnIndex]: nextWidth, + })); + }; + const stopResize = () => setResizingColumn(null); + window.addEventListener('mousemove', resizeColumn); + window.addEventListener('mouseup', stopResize); + return () => { + window.removeEventListener('mousemove', resizeColumn); + window.removeEventListener('mouseup', stopResize); + }; + }, [resizingColumn]); useEffect(() => { if (detailRowKey && !visibleRows.some((row) => row.key === detailRowKey)) { @@ -1389,7 +1477,7 @@ export function EnhancedTable({ }; const hideColumn = (columnIndex: number) => { - if (visibleColumnIndexes.length <= 1) return; + if (orderedVisibleColumnIndexes.length <= 1) return; setSelection(null); closeFilterMenu(); setFilters((current) => { @@ -1412,6 +1500,10 @@ export function EnhancedTable({ setHiddenColumns(new Set()); }; + const toggleFreezeFirstColumn = () => { + setFreezeFirstColumn((current) => !current); + }; + const toggleRowDetail = (rowKey: string) => { setSelection(null); setDetailRowKey((current) => (current === rowKey ? null : rowKey)); @@ -1424,12 +1516,75 @@ export function EnhancedTable({ const isCellSelected = (rowIndex: number, columnIndex: number): boolean => { if (!selectionBounds) return false; - const { minRow, maxRow, minCol, maxCol } = selectionBounds; + const { minRow, maxRow } = selectionBounds; + const selectedColumnIndexes = getSelectedColumnIndexes( + selection, + orderedVisibleColumnIndexes, + ); return ( rowIndex >= minRow && rowIndex <= maxRow && - columnIndex >= minCol && - columnIndex <= maxCol + selectedColumnIndexes.includes(columnIndex) + ); + }; + + const columnStyle = ( + columnIndex: number, + extra?: CSSProperties, + ): CSSProperties => { + const width = columnWidths[columnIndex] ?? DEFAULT_COLUMN_WIDTH; + 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 startColumnDrag = ( + event: ReactDragEvent, + columnIndex: number, + ) => { + event.stopPropagation(); + setDraggingColumn(columnIndex); + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData(COLUMN_DRAG_MIME, String(columnIndex)); + }; + + const dragOverColumn = (event: ReactDragEvent) => { + if (draggingColumn === null && !hasColumnDragData(event.dataTransfer)) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }; + + const dropColumn = ( + event: ReactDragEvent, + targetColumnIndex: number, + ) => { + const sourceColumnIndex = + draggingColumn ?? getDraggedColumnIndex(event.dataTransfer); + setDraggingColumn(null); + if (sourceColumnIndex === null) return; + event.preventDefault(); + event.stopPropagation(); + setSelection(null); + setColumnOrder((current) => + moveColumn(current, sourceColumnIndex, targetColumnIndex), ); }; @@ -1523,7 +1678,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 +1708,7 @@ export function EnhancedTable({ const text = getVisibleTableText( table.headers, visibleRows, - visibleColumnIndexes, + orderedVisibleColumnIndexes, ); if (!text || !navigator.clipboard) return; const copyGeneration = copiedVisibleGenRef.current; @@ -1574,13 +1733,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 +1796,17 @@ export function EnhancedTable({ })} )} + {orderedVisibleColumnIndexes.length > 0 && ( + + )} {activeFilterCount > 0 && ( {t('markdownTable.filtersActive', { count: activeFilterCount })} @@ -1670,10 +1844,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 +1882,15 @@ export function EnhancedTable({ const headerAlignStyle = header.textAlign ? { textAlign: header.textAlign } : undefined; + const isFrozenColumn = frozenColumnIndex === columnIndex; return ( ); })} @@ -1760,7 +1967,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')}
+
+
+ @@ -1816,13 +2028,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 +2093,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 ad03d44051..33f4a8ac68 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 ?? ''}`, @@ -1793,8 +1797,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 ?? ''} 行详情`,