mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(web-shell): refine markdown table interactions (#6500)
* fix(web-shell): refine markdown table interactions * fix(web-shell): preserve active column when closing filters
This commit is contained in:
parent
727c2d580c
commit
5b2d1369b5
3 changed files with 246 additions and 42 deletions
|
|
@ -451,11 +451,13 @@ tr:hover .selectedCell {
|
|||
background: var(--card);
|
||||
}
|
||||
|
||||
.activeHeaderCell.frozenHeaderCell {
|
||||
background: color-mix(in srgb, var(--agent-blue-500) 8%, var(--card));
|
||||
}
|
||||
|
||||
.evenRow .stickyActionCell,
|
||||
.evenRow .frozenCell {
|
||||
background:
|
||||
linear-gradient(var(--subtle-bg), var(--subtle-bg)),
|
||||
var(--card);
|
||||
background: linear-gradient(var(--subtle-bg), var(--subtle-bg)), var(--card);
|
||||
}
|
||||
|
||||
tr:hover .stickyActionCell,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import { EnhancedMarkdownTable } from './EnhancedMarkdownTable';
|
|||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
|
||||
const originalDocumentHidden = Object.getOwnPropertyDescriptor(
|
||||
document,
|
||||
'hidden',
|
||||
);
|
||||
const originalElementFromPoint = document.elementFromPoint;
|
||||
const COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column';
|
||||
|
||||
|
|
@ -21,6 +25,11 @@ afterEach(() => {
|
|||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
document.getSelection()?.removeAllRanges();
|
||||
if (originalDocumentHidden) {
|
||||
Object.defineProperty(document, 'hidden', originalDocumentHidden);
|
||||
} else {
|
||||
Reflect.deleteProperty(document, 'hidden');
|
||||
}
|
||||
if (originalElementFromPoint) {
|
||||
Object.defineProperty(document, 'elementFromPoint', {
|
||||
configurable: true,
|
||||
|
|
@ -866,7 +875,7 @@ describe('EnhancedMarkdownTable', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('stops resizing a column when page visibility changes', () => {
|
||||
it('flushes pending resize width when the window blurs', () => {
|
||||
const container = renderTable();
|
||||
const resize = button(container, 'Resize Team');
|
||||
|
||||
|
|
@ -879,6 +888,35 @@ describe('EnhancedMarkdownTable', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 200 }),
|
||||
);
|
||||
window.dispatchEvent(new Event('blur'));
|
||||
});
|
||||
|
||||
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
|
||||
'260px',
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
|
@ -893,6 +931,38 @@ describe('EnhancedMarkdownTable', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps resizing when page visibility changes while visible', () => {
|
||||
const container = renderTable();
|
||||
const resize = button(container, 'Resize Team');
|
||||
|
||||
act(() => {
|
||||
resize.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
}),
|
||||
);
|
||||
});
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('mousemove', { bubbles: true, clientX: 220 }),
|
||||
);
|
||||
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe(
|
||||
'280px',
|
||||
);
|
||||
});
|
||||
|
||||
it('resizes a column with keyboard arrows', () => {
|
||||
const container = renderTable();
|
||||
const resize = button(container, 'Resize Team');
|
||||
|
|
@ -924,6 +994,25 @@ describe('EnhancedMarkdownTable', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('ignores keyboard resize arrows with modifiers', () => {
|
||||
const container = renderTable();
|
||||
const resize = button(container, 'Resize Team');
|
||||
|
||||
act(() => {
|
||||
resize.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
ctrlKey: true,
|
||||
key: 'ArrowRight',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
|
|
@ -945,6 +1034,66 @@ describe('EnhancedMarkdownTable', () => {
|
|||
).toContain('activeHeaderCell');
|
||||
});
|
||||
|
||||
it('clears the active column move handle on outside click, cell selection, and Escape', () => {
|
||||
const container = renderWideTable();
|
||||
const teamHandle = button(container, 'Move Team');
|
||||
|
||||
click(button(container, 'Sort by Team'));
|
||||
act(() => {
|
||||
document.body.dispatchEvent(
|
||||
new MouseEvent('mousedown', { bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(teamHandle.className).not.toContain('reorderHandleVisible');
|
||||
expect(teamHandle.tabIndex).toBe(-1);
|
||||
|
||||
click(button(container, 'Sort by Team, ascending'));
|
||||
act(() => {
|
||||
dataCell(container, 0, 0).dispatchEvent(
|
||||
new MouseEvent('mousedown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
expect(teamHandle.className).not.toContain('reorderHandleVisible');
|
||||
expect(teamHandle.tabIndex).toBe(-1);
|
||||
|
||||
click(button(container, 'Sort by Team, descending'));
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
|
||||
);
|
||||
});
|
||||
expect(teamHandle.className).not.toContain('reorderHandleVisible');
|
||||
expect(teamHandle.tabIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('keeps the active column when Escape closes an open filter menu first', () => {
|
||||
const container = renderWideTable();
|
||||
const teamHandle = button(container, 'Move Team');
|
||||
|
||||
click(button(container, 'Sort by Team'));
|
||||
click(button(container, 'Filter Team'));
|
||||
expect(container.textContent).toContain('Custom filter');
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain('Custom filter');
|
||||
expect(teamHandle.className).toContain('reorderHandleVisible');
|
||||
expect(teamHandle.tabIndex).toBe(0);
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(teamHandle.className).not.toContain('reorderHandleVisible');
|
||||
expect(teamHandle.tabIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it('reorders columns and quick copies in the visible order', () => {
|
||||
const writeText = mockClipboard();
|
||||
const container = renderWideTable();
|
||||
|
|
@ -978,7 +1127,10 @@ describe('EnhancedMarkdownTable', () => {
|
|||
const source = renderWideTable();
|
||||
const target = renderWideTable();
|
||||
|
||||
dragColumnElements(button(source, 'Move Score'), button(target, 'Move Team'));
|
||||
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']);
|
||||
|
|
@ -1033,6 +1185,14 @@ describe('EnhancedMarkdownTable', () => {
|
|||
).toContain('frozenHeaderCell');
|
||||
expect(dataCell(container, 0, 0).className).toContain('frozenCell');
|
||||
|
||||
click(button(container, 'Sort by Team'));
|
||||
expect(
|
||||
button(container, 'Sort by Team, ascending').closest('th')?.className,
|
||||
).toContain('activeHeaderCell');
|
||||
expect(
|
||||
button(container, 'Sort by Team, ascending').closest('th')?.className,
|
||||
).toContain('frozenHeaderCell');
|
||||
|
||||
dragColumn(container, 'Move Score', 'Move Team');
|
||||
expect(
|
||||
button(container, 'Sort by Score').closest('th')?.className,
|
||||
|
|
|
|||
|
|
@ -329,6 +329,24 @@ function sanitizeForClipboard(value: string): string {
|
|||
return /^[=+\-@]/.test(inspectedValue) ? `'${value}` : value;
|
||||
}
|
||||
|
||||
function applyColumnWidth(
|
||||
current: Record<number, number>,
|
||||
columnIndex: number,
|
||||
nextWidth: number,
|
||||
): Record<number, number> {
|
||||
const currentWidth = current[columnIndex] ?? DEFAULT_COLUMN_WIDTH;
|
||||
if (currentWidth === nextWidth) return current;
|
||||
if (nextWidth === DEFAULT_COLUMN_WIDTH) {
|
||||
const next = { ...current };
|
||||
delete next[columnIndex];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[columnIndex]: nextWidth,
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectionText(
|
||||
range: SelectionRange | null,
|
||||
rows: EnhancedTableRow[],
|
||||
|
|
@ -412,11 +430,7 @@ function moveVisibleColumn(
|
|||
const visibleColumns = order.filter(
|
||||
(columnIndex) => !hiddenColumns.has(columnIndex),
|
||||
);
|
||||
const nextVisibleColumns = moveColumn(
|
||||
visibleColumns,
|
||||
fromColumn,
|
||||
toColumn,
|
||||
);
|
||||
const nextVisibleColumns = moveColumn(visibleColumns, fromColumn, toColumn);
|
||||
if (nextVisibleColumns === visibleColumns) return order;
|
||||
let visibleIndex = 0;
|
||||
return order.map((columnIndex) => {
|
||||
|
|
@ -1181,6 +1195,8 @@ export function EnhancedTable({
|
|||
columnIndex: number;
|
||||
} | null>(null);
|
||||
const selectionFrameRef = useRef(0);
|
||||
const resizeFrameRef = useRef(0);
|
||||
const pendingResizeWidthRef = useRef<number | null>(null);
|
||||
const draggingColumnRef = useRef<number | null>(null);
|
||||
const tableStructureKey = useMemo(
|
||||
() =>
|
||||
|
|
@ -1328,6 +1344,7 @@ export function EnhancedTable({
|
|||
};
|
||||
const handleMenuKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeFilterMenu();
|
||||
return;
|
||||
}
|
||||
|
|
@ -1377,6 +1394,28 @@ export function EnhancedTable({
|
|||
};
|
||||
}, [closeFilterMenu, openFilterMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const clearActiveColumnOnOutsideMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && !shellRef.current?.contains(target)) {
|
||||
setActiveColumn(null);
|
||||
}
|
||||
};
|
||||
const clearActiveColumnOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || openFilterMenu) return;
|
||||
if (event.key === 'Escape') setActiveColumn(null);
|
||||
};
|
||||
document.addEventListener('mousedown', clearActiveColumnOnOutsideMouseDown);
|
||||
document.addEventListener('keydown', clearActiveColumnOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
'mousedown',
|
||||
clearActiveColumnOnOutsideMouseDown,
|
||||
);
|
||||
document.removeEventListener('keydown', clearActiveColumnOnEscape);
|
||||
};
|
||||
}, [openFilterMenu]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => applyFilters(table.rows, filters),
|
||||
[filters, table.rows],
|
||||
|
|
@ -1418,35 +1457,46 @@ export function EnhancedTable({
|
|||
|
||||
useEffect(() => {
|
||||
if (!resizingColumn) return;
|
||||
const applyPendingResize = () => {
|
||||
const nextWidth = pendingResizeWidthRef.current;
|
||||
pendingResizeWidthRef.current = null;
|
||||
if (resizeFrameRef.current) {
|
||||
cancelAnimationFrame(resizeFrameRef.current);
|
||||
resizeFrameRef.current = 0;
|
||||
}
|
||||
if (nextWidth === null) return;
|
||||
setColumnWidths((current) =>
|
||||
applyColumnWidth(current, resizingColumn.columnIndex, nextWidth),
|
||||
);
|
||||
};
|
||||
const resizeColumn = (event: MouseEvent) => {
|
||||
const nextWidth = clampColumnWidth(
|
||||
pendingResizeWidthRef.current = 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,
|
||||
};
|
||||
});
|
||||
if (resizeFrameRef.current) return;
|
||||
resizeFrameRef.current = requestAnimationFrame(applyPendingResize);
|
||||
};
|
||||
const stopResize = () => {
|
||||
applyPendingResize();
|
||||
setResizingColumn(null);
|
||||
};
|
||||
const stopResizeWhenHidden = () => {
|
||||
if (document.hidden) stopResize();
|
||||
};
|
||||
const stopResize = () => setResizingColumn(null);
|
||||
window.addEventListener('mousemove', resizeColumn);
|
||||
window.addEventListener('mouseup', stopResize);
|
||||
window.addEventListener('blur', stopResize);
|
||||
document.addEventListener('visibilitychange', stopResize);
|
||||
document.addEventListener('visibilitychange', stopResizeWhenHidden);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', resizeColumn);
|
||||
window.removeEventListener('mouseup', stopResize);
|
||||
window.removeEventListener('blur', stopResize);
|
||||
document.removeEventListener('visibilitychange', stopResize);
|
||||
document.removeEventListener('visibilitychange', stopResizeWhenHidden);
|
||||
if (resizeFrameRef.current) {
|
||||
cancelAnimationFrame(resizeFrameRef.current);
|
||||
resizeFrameRef.current = 0;
|
||||
}
|
||||
pendingResizeWidthRef.current = null;
|
||||
};
|
||||
}, [resizingColumn]);
|
||||
|
||||
|
|
@ -1615,6 +1665,9 @@ export function EnhancedTable({
|
|||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
columnIndex: number,
|
||||
) => {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey)
|
||||
return;
|
||||
|
||||
let delta = 0;
|
||||
if (event.key === 'ArrowRight') {
|
||||
delta = KEYBOARD_COLUMN_RESIZE_STEP;
|
||||
|
|
@ -1629,16 +1682,7 @@ export function EnhancedTable({
|
|||
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,
|
||||
};
|
||||
return applyColumnWidth(current, columnIndex, nextWidth);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -1674,10 +1718,7 @@ export function EnhancedTable({
|
|||
) => {
|
||||
const sourceColumnIndex = draggingColumnRef.current;
|
||||
stopColumnDrag();
|
||||
if (
|
||||
sourceColumnIndex === null ||
|
||||
!hasColumnDragData(event.dataTransfer)
|
||||
) {
|
||||
if (sourceColumnIndex === null || !hasColumnDragData(event.dataTransfer)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
|
@ -1698,6 +1739,7 @@ export function EnhancedTable({
|
|||
if (openFilterMenu !== null) {
|
||||
setOpenFilterMenu(null);
|
||||
}
|
||||
setActiveColumn(null);
|
||||
draggingRef.current = true;
|
||||
setSelection({
|
||||
anchorRow: rowIndex,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue