mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-16 04:05:15 +00:00
feat(web-shell): add markdown table column controls
Support resizing, reordering, and freezing table columns while preserving visible-order copy behavior and selection stability.
This commit is contained in:
parent
9a63c03224
commit
6de3f4c19c
4 changed files with 509 additions and 36 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -255,6 +255,71 @@ function dispatchCopy(target: Element) {
|
|||
return { event, setData };
|
||||
}
|
||||
|
||||
function dragColumn(
|
||||
container: HTMLElement,
|
||||
fromLabel: string,
|
||||
toLabel: string,
|
||||
): 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) ?? ''),
|
||||
};
|
||||
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<Pick<Touch, 'clientX' | 'clientY'>>,
|
||||
|
|
@ -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<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 +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('隐藏列');
|
||||
|
|
|
|||
|
|
@ -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<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 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<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
|
||||
const [columnOrder, setColumnOrder] = useState<number[]>(() =>
|
||||
initialColumnOrder(table.columnCount),
|
||||
);
|
||||
const [freezeFirstColumn, setFreezeFirstColumn] = useState(false);
|
||||
const [resizingColumn, setResizingColumn] =
|
||||
useState<ColumnResizeState | null>(null);
|
||||
const [draggingColumn, setDraggingColumn] = useState<number | null>(null);
|
||||
const [detailRowKey, setDetailRowKey] = useState<string | null>(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<HTMLButtonElement>,
|
||||
columnIndex: number,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setResizingColumn({
|
||||
columnIndex,
|
||||
startX: event.clientX,
|
||||
startWidth: columnWidths[columnIndex] ?? DEFAULT_COLUMN_WIDTH,
|
||||
});
|
||||
};
|
||||
|
||||
const startColumnDrag = (
|
||||
event: ReactDragEvent<HTMLButtonElement>,
|
||||
columnIndex: number,
|
||||
) => {
|
||||
event.stopPropagation();
|
||||
setDraggingColumn(columnIndex);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(COLUMN_DRAG_MIME, String(columnIndex));
|
||||
};
|
||||
|
||||
const dragOverColumn = (event: ReactDragEvent<HTMLButtonElement>) => {
|
||||
if (draggingColumn === null && !hasColumnDragData(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const dropColumn = (
|
||||
event: ReactDragEvent<HTMLButtonElement>,
|
||||
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<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 +1796,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 +1844,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 +1882,15 @@ export function EnhancedTable({
|
|||
const headerAlignStyle = header.textAlign
|
||||
? { textAlign: header.textAlign }
|
||||
: undefined;
|
||||
const isFrozenColumn = frozenColumnIndex === columnIndex;
|
||||
return (
|
||||
<th
|
||||
key={header.key}
|
||||
className={styles.headerCell}
|
||||
className={`${styles.headerCell} ${
|
||||
isFrozenColumn ? styles.frozenHeaderCell : ''
|
||||
}`}
|
||||
aria-sort={ariaSort}
|
||||
style={headerAlignStyle}
|
||||
style={columnStyle(columnIndex, headerAlignStyle)}
|
||||
>
|
||||
<div className={styles.headerControls}>
|
||||
<button
|
||||
|
|
@ -1729,6 +1910,22 @@ export function EnhancedTable({
|
|||
{sortLabel}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={styles.reorderHandle}
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={(event) =>
|
||||
startColumnDrag(event, columnIndex)
|
||||
}
|
||||
onDragOver={dragOverColumn}
|
||||
onDrop={(event) => dropColumn(event, columnIndex)}
|
||||
onDragEnd={() => setDraggingColumn(null)}
|
||||
aria-label={t('markdownTable.moveColumn', {
|
||||
column: columnName,
|
||||
})}
|
||||
>
|
||||
↔
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.filterTrigger} ${
|
||||
isFiltered ? styles.filterTriggerActive : ''
|
||||
|
|
@ -1746,6 +1943,16 @@ export function EnhancedTable({
|
|||
▾
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className={styles.resizeHandle}
|
||||
type="button"
|
||||
onMouseDown={(event) =>
|
||||
startColumnResize(event, columnIndex)
|
||||
}
|
||||
aria-label={t('markdownTable.resizeColumn', {
|
||||
column: columnName,
|
||||
})}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1760,7 +1967,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 +1988,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 +2002,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 +2028,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 +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}
|
||||
|
|
|
|||
|
|
@ -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 ?? ''} 行详情`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue