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 <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
jifeng 2026-07-07 22:18:07 +08:00 committed by GitHub
parent 55b2886909
commit 971d4ba27e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 927 additions and 41 deletions

View file

@ -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);

View file

@ -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<string, string>();
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<Pick<Touch, 'clientX' | 'clientY'>>,
@ -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<HTMLElement>('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('隐藏列');

View file

@ -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<NumberFilterOperator, string> = {
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<number>,
) {
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<Set<number>>(
() => new Set(),
);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
const [columnOrder, setColumnOrder] = useState<number[]>(() =>
initialColumnOrder(table.columnCount),
);
const [activeColumn, setActiveColumn] = useState<number | null>(null);
const [freezeFirstColumn, setFreezeFirstColumn] = useState(false);
const [resizingColumn, setResizingColumn] =
useState<ColumnResizeState | null>(null);
const [detailRowKey, setDetailRowKey] = useState<string | null>(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<number | null>(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<HTMLButtonElement>,
columnIndex: number,
) => {
event.preventDefault();
event.stopPropagation();
setResizingColumn({
columnIndex,
startX: event.clientX,
startWidth: columnWidths[columnIndex] ?? DEFAULT_COLUMN_WIDTH,
});
};
const resizeColumnWithKeyboard = (
event: ReactKeyboardEvent<HTMLButtonElement>,
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<HTMLButtonElement>,
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<HTMLElement>) => {
if (
draggingColumnRef.current === null ||
!hasColumnDragData(event.dataTransfer)
) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
};
const dropColumn = (
event: ReactDragEvent<HTMLElement>,
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<HTMLDivElement>) => {
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({
})}
</button>
)}
{orderedVisibleColumnIndexes.length > 0 && (
<button
className={styles.copyButton}
type="button"
onClick={toggleFreezeFirstColumn}
>
{freezeFirstColumn
? t('markdownTable.unfreezeFirstColumn')
: t('markdownTable.freezeFirstColumn')}
</button>
)}
{activeFilterCount > 0 && (
<span className={styles.selection}>
{t('markdownTable.filtersActive', { count: activeFilterCount })}
@ -1670,10 +1950,14 @@ export function EnhancedTable({
<table className={styles.table}>
<thead>
<tr>
<th className={`${styles.headerCell} ${styles.actionHeaderCell}`}>
<th
className={`${styles.headerCell} ${styles.actionHeaderCell} ${
freezeFirstColumn ? styles.stickyActionHeaderCell : ''
}`}
>
{t('markdownTable.actions')}
</th>
{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 (
<th
key={header.key}
className={styles.headerCell}
className={`${styles.headerCell} ${
isFrozenColumn ? styles.frozenHeaderCell : ''
} ${isActiveColumn ? styles.activeHeaderCell : ''}`}
aria-sort={ariaSort}
style={headerAlignStyle}
onDragOver={dragOverColumn}
onDrop={(event) => dropColumn(event, columnIndex)}
style={columnStyle(columnIndex, headerAlignStyle)}
>
<div className={styles.headerControls}>
<button
@ -1729,6 +2019,23 @@ export function EnhancedTable({
{sortLabel}
</span>
</button>
<button
className={`${styles.reorderHandle} ${
isActiveColumn ? styles.reorderHandleVisible : ''
}`}
type="button"
draggable
tabIndex={isActiveColumn ? 0 : -1}
onDragStart={(event) =>
startColumnDrag(event, columnIndex)
}
onDragEnd={stopColumnDrag}
aria-label={t('markdownTable.moveColumn', {
column: columnName,
})}
>
</button>
<button
className={`${styles.filterTrigger} ${
isFiltered ? styles.filterTriggerActive : ''
@ -1746,6 +2053,26 @@ export function EnhancedTable({
</button>
</div>
<button
className={styles.resizeHandle}
type="button"
onMouseDown={(event) =>
startColumnResize(event, columnIndex)
}
onKeyDown={(event) =>
resizeColumnWithKeyboard(event, columnIndex)
}
role="separator"
aria-label={t('markdownTable.resizeColumn', {
column: columnName,
})}
aria-orientation="vertical"
aria-valuemin={MIN_COLUMN_WIDTH}
aria-valuemax={MAX_COLUMN_WIDTH}
aria-valuenow={
columnWidths[columnIndex] ?? DEFAULT_COLUMN_WIDTH
}
/>
</th>
);
})}
@ -1760,7 +2087,11 @@ export function EnhancedTable({
<tr
className={rowIndex % 2 === 1 ? styles.evenRow : undefined}
>
<td className={`${styles.cell} ${styles.actionCell}`}>
<td
className={`${styles.cell} ${styles.actionCell} ${
freezeFirstColumn ? styles.stickyActionCell : ''
}`}
>
<button
className={styles.rowDetailButton}
type="button"
@ -1777,12 +2108,13 @@ export function EnhancedTable({
{t('markdownTable.rowDetails')}
</button>
</td>
{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 (
<td
key={cell.key}
@ -1790,8 +2122,8 @@ export function EnhancedTable({
isCellSelected(rowIndex, columnIndex)
? styles.selectedCell
: ''
}`}
style={cellAlignStyle}
} ${isFrozenColumn ? styles.frozenCell : ''}`}
style={columnStyle(columnIndex, cellAlignStyle)}
data-row-index={rowIndex}
data-column-index={columnIndex}
onMouseDown={(event) =>
@ -1816,13 +2148,13 @@ export function EnhancedTable({
<tr id={detailId} className={styles.detailRow}>
<td
className={styles.detailCell}
colSpan={visibleColumnIndexes.length + 1}
colSpan={orderedVisibleColumnIndexes.length + 1}
>
<div className={styles.detailPanel}>
<div className={styles.detailTitle}>
{t('markdownTable.detailsHeader')}
</div>
{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}

View file

@ -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 ?? ''} 行详情`,