mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
55b2886909
commit
971d4ba27e
4 changed files with 927 additions and 41 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,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);
|
||||
|
|
|
|||
|
|
@ -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('隐藏列');
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ?? ''} 行详情`,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue