feat(web-shell): add cell value dialog on double-click in markdown tables (#6530)

* feat(web-shell): add cell value dialog on double-click in markdown tables

Double-clicking a table cell opens a modal dialog showing the cell's full
content with copy and close actions. The dialog clears any active selection
or row details on open, supports Escape to dismiss, and click-outside to
close. Adds EN/ZH i18n keys and four unit tests covering open, copy,
dismiss, and state-clearing behaviour.

* fix(web-shell): improve table cell dialog interactions

* fix(web-shell): portal table cell dialog

* fix(web-shell): block shortcuts behind cell dialog

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
jifeng 2026-07-11 23:21:34 +08:00 committed by GitHub
parent b19ebd8fc6
commit fc77764f83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 845 additions and 47 deletions

View file

@ -96,6 +96,7 @@ const {
},
mockStore: {
dispatch: vi.fn(),
reset: vi.fn(),
appendLocalUserMessage: vi.fn(),
appendLocalAssistantMessage: vi.fn(),
},
@ -155,8 +156,8 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
}),
}));
vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => ({
...(await importOriginal<typeof import('@qwen-code/sdk/daemon')>()),
vi.mock('@qwen-code/sdk/daemon', () => ({
DAEMON_GOAL_STATUS_SENTINEL_PREFIX: 'qwen-goal-status:',
isDaemonTurnError: () => false,
}));
@ -221,6 +222,27 @@ vi.mock('./components/ChatEditor', async () => {
vi.mock('./components/MessageList', async () => {
const React = await import('react');
const { useInteractionBlocker } = await import('./interactionBlockContext');
function InteractionBlockerProbe() {
const registerInteractionBlocker = useInteractionBlocker();
const releaseRef = React.useRef<(() => void) | null>(null);
return React.createElement(
'button',
{
'data-testid': 'interaction-blocker',
onClick: () => {
if (releaseRef.current) {
releaseRef.current();
releaseRef.current = null;
} else {
releaseRef.current = registerInteractionBlocker();
}
},
type: 'button',
},
releaseRef.current ? 'release blocker' : 'register blocker',
);
}
return {
MessageList: React.forwardRef(function MessageList(
props: { showRetryHint?: boolean; onRetryClick?: () => void },
@ -230,6 +252,7 @@ vi.mock('./components/MessageList', async () => {
return React.createElement(
'div',
{ 'data-testid': 'messages' },
React.createElement(InteractionBlockerProbe),
props.showRetryHint
? React.createElement(
'button',
@ -659,6 +682,8 @@ beforeEach(() => {
mockSessionActions.sendShellCommand.mockResolvedValue(undefined);
mockSessionActions.getStats.mockResolvedValue({});
mockSessionActions.loadSession.mockResolvedValue(undefined);
mockStore.reset.mockClear();
mockStore.dispatch.mockClear();
mockWorkspaceActions.loadSkillsStatus.mockResolvedValue({ skills: [] });
mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null });
mockWorkspaceActions.loadPreflight.mockResolvedValue(null);
@ -2076,6 +2101,43 @@ describe('App session callbacks', () => {
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
});
it('blocks app-level shortcuts while an external modal is registered', async () => {
const { container } = renderApp();
await flush();
expect(testState.latestChatEditorProps?.dialogOpen).toBe(false);
await act(async () => {
container
.querySelector<HTMLButtonElement>('[data-testid="interaction-blocker"]')
?.click();
await Promise.resolve();
});
expect(testState.latestChatEditorProps?.dialogOpen).toBe(true);
act(() => {
window.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: 'l',
}),
);
window.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: 'y',
}),
);
});
expect(mockStore.reset).not.toHaveBeenCalled();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('restores composer focus after an approval resolves following a panel auto-close', async () => {
// Regression: on panel auto-close the editor focus is intentionally skipped
// (the approval owns the keyboard); when the approval later resolves with no

View file

@ -180,6 +180,7 @@ import {
type TodoSnapshotDiff,
} from './utils/todos';
import { ThemeProvider } from './themeContext';
import { InteractionBlockContext } from './interactionBlockContext';
import {
WebShellThemeId,
THEME_SETTING_KEY,
@ -2178,6 +2179,17 @@ export function App({
const [showAuthDialog, setShowAuthDialog] = useState(false);
const [memoryRefreshSignal, setMemoryRefreshSignal] = useState(0);
const [memoryAddSignal, setMemoryAddSignal] = useState(0);
const [externalInteractionBlockCount, setExternalInteractionBlockCount] =
useState(0);
const registerInteractionBlocker = useCallback(() => {
let released = false;
setExternalInteractionBlockCount((count) => count + 1);
return () => {
if (released) return;
released = true;
setExternalInteractionBlockCount((count) => Math.max(0, count - 1));
};
}, []);
// Refresh commands when extensions change (install/uninstall/update).
const workspaceEventSignals = useWorkspaceEventSignals();
@ -2468,6 +2480,7 @@ export function App({
agentsDialogMode !== null ||
showMemoryDialog ||
showAuthDialog ||
externalInteractionBlockCount > 0 ||
// The Settings / Daemon Status panel replaces the chat surface, so — like a
// modal — it must suppress chat-only global shortcuts (Ctrl+L/O/Y, the
// Shift+Tab mode cycle, the btw hotkey). Escape is intercepted earlier and
@ -5626,19 +5639,22 @@ export function App({
timeline={todoTimeline}
details={todoDetails}
>
{(() => {
const contentClassName = [
styles.content,
showFloatingTodos ||
displayMessages.length > 0 ||
pendingApproval
? styles.contentHasMessages
: undefined,
]
.filter(Boolean)
.join(' ');
<InteractionBlockContext.Provider
value={registerInteractionBlocker}
>
{(() => {
const contentClassName = [
styles.content,
showFloatingTodos ||
displayMessages.length > 0 ||
pendingApproval
? styles.contentHasMessages
: undefined,
]
.filter(Boolean)
.join(' ');
const messageList = (
const messageList = (
<MessageList
ref={messageListRef}
messages={displayMessages}
@ -5688,11 +5704,11 @@ export function App({
onOpenArtifact={openArtifactPanel}
onOpenScheduledTask={openScheduledTaskPanel}
/>
);
);
const btwPanel =
!showMobileWelcomeFooterMiddle &&
btwMessage?.role === 'btw' ? (
const btwPanel =
!showMobileWelcomeFooterMiddle &&
btwMessage?.role === 'btw' ? (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
@ -5700,36 +5716,37 @@ export function App({
isPending={btwMessage.isPending}
/>
</div>
) : null;
) : null;
if (showMobileWelcomeFooterMiddle) {
if (showMobileWelcomeFooterMiddle) {
return (
<div className={styles.mobileWelcomeGroup}>
<div
style={contentStyle}
className={contentClassName}
>
{messageList}
{btwPanel}
</div>
<div
className={styles.mobileWelcomeFooterMiddle}
>
{welcomeFooter}
</div>
</div>
);
}
return (
<div className={styles.mobileWelcomeGroup}>
<div
style={contentStyle}
className={contentClassName}
>
{messageList}
{btwPanel}
</div>
<div
className={styles.mobileWelcomeFooterMiddle}
>
{welcomeFooter}
</div>
<div
style={contentStyle}
className={contentClassName}
>
{messageList}
{btwPanel}
</div>
);
}
return (
<div
style={contentStyle}
className={contentClassName}
>
{messageList}
{btwPanel}
</div>
);
})()}
})()}
</InteractionBlockContext.Provider>
</TodoContextsProvider>
</CompactModeContext.Provider>

View file

@ -631,6 +631,141 @@ tr:hover .frozenCell.selectedCell {
white-space: pre-wrap;
}
.themeDark {
--background: #0d0d0d;
--foreground: #fafafa;
--muted-foreground: #808598;
--border: #2a2a2a;
--card: #161616;
--agent-blue-500: #6785ff;
}
.themeLight {
--background: #ffffff;
--foreground: #0a0a0b;
--muted-foreground: #a3a3a5;
--border: #e3e4e6;
--card: #ffffff;
--agent-blue-500: #0033ff;
}
.cellDialogBackdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: color-mix(in srgb, var(--background) 62%, transparent);
}
.cellDialog {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: min(520px, calc(100vw - 32px));
min-height: 190px;
padding: 20px 24px 16px;
border: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
border-radius: 10px;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--card) 96%, white 4%),
color-mix(in srgb, var(--card) 92%, black 8%)
);
box-shadow: 0 18px 56px rgb(0 0 0 / 38%);
color: var(--foreground);
font-size: 13px;
}
.cellDialogCloseIcon {
position: absolute;
top: 12px;
right: 14px;
display: grid;
width: 24px;
height: 24px;
place-items: center;
border: 0;
background: transparent;
color: var(--muted-foreground);
font: inherit;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.cellDialogCloseIcon:hover {
color: var(--foreground);
}
.cellDialogTitle {
margin-bottom: 16px;
font-size: inherit;
font-weight: 800;
line-height: 1.2;
}
.cellDialogValue {
min-height: 100px;
max-height: min(32vh, 200px);
overflow: auto;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--background) 72%, transparent);
color: var(--foreground);
font: inherit;
font-weight: 600;
line-height: 1.45;
cursor: text;
user-select: text;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.cellDialogFooter {
display: flex;
justify-content: flex-end;
gap: 10px;
align-items: center;
margin-top: auto;
padding-top: 20px;
}
.cellDialogActions {
display: flex;
gap: 12px;
align-items: center;
}
.cellDialogButton {
min-width: 64px;
padding: 6px 14px;
border: 0;
border-radius: 6px;
background: color-mix(in srgb, var(--foreground) 12%, transparent);
color: var(--foreground);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.cellDialogButton:hover {
background: color-mix(in srgb, var(--foreground) 18%, transparent);
}
.cellDialogPrimaryButton {
background: var(--agent-blue-500);
color: white;
}
.cellDialogPrimaryButton:hover {
background: color-mix(in srgb, var(--agent-blue-500) 86%, white 14%);
}
.emptyValue {
color: var(--muted-foreground);
font-style: italic;
@ -642,3 +777,34 @@ tr:hover .frozenCell.selectedCell {
font-size: 13px;
text-align: center;
}
@media (max-width: 640px) {
.cellDialogBackdrop {
padding: 10px;
}
.cellDialog {
width: 100%;
min-height: 0;
padding: 20px 14px 14px;
}
.cellDialogTitle {
margin-bottom: 14px;
}
.cellDialogFooter {
flex-direction: column;
align-items: stretch;
padding-top: 18px;
}
.cellDialogActions {
justify-content: flex-end;
}
.cellDialogButton {
min-width: 0;
padding: 6px 12px;
}
}

View file

@ -120,6 +120,12 @@ function click(el: Element): void {
});
}
function doubleClick(el: Element): void {
act(() => {
el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
});
}
function rightClick(el: Element): MouseEvent {
const event = new MouseEvent('contextmenu', {
bubbles: true,
@ -191,6 +197,10 @@ function textButton(container: HTMLElement, text: string): HTMLButtonElement {
return el!;
}
function cellDialog(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="dialog"]');
}
function textButtonContaining(
container: HTMLElement,
text: string,
@ -722,6 +732,297 @@ describe('EnhancedMarkdownTable', () => {
).toBe('');
});
it('opens a selectable cell value dialog on double click', () => {
const container = renderTable();
doubleClick(dataCell(container, 0, 0));
const dialog = cellDialog();
expect(dialog).not.toBeNull();
expect(dialog?.textContent).toContain('Current field value');
expect(dialog?.textContent).toContain('Alpha');
});
it('copies the current cell value from the dialog', async () => {
const writeText = mockClipboard();
const container = renderTable();
doubleClick(dataCell(container, 1, 0));
await act(async () => {
textButton(document.body, 'Copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith('Beta');
expect(document.body.textContent).toContain('Copied!');
});
it('sanitizes the current cell value copied from the dialog', async () => {
const writeText = mockClipboard();
const container = renderTableContent([
<thead key="head">
<tr>
<th>Formula</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>=IMPORTXML(&quot;https://example.com&quot;)</td>
</tr>
</tbody>,
]);
doubleClick(dataCell(container, 0, 0));
await act(async () => {
textButton(document.body, 'Copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith(
'\'=IMPORTXML("https://example.com")',
);
});
it('keeps the cell value dialog in sync with table updates', async () => {
const writeText = mockClipboard();
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const render = (value: string) => {
act(() => {
root.render(
<I18nProvider language="en">
<EnhancedMarkdownTable>
<thead>
<tr>
<th>Team</th>
</tr>
</thead>
<tbody>
<tr>
<td>{value}</td>
</tr>
</tbody>
</EnhancedMarkdownTable>
</I18nProvider>,
);
});
};
mounted.push({ root, container });
render('Alpha');
doubleClick(dataCell(container, 0, 0));
expect(cellDialog()?.textContent).toContain('Alpha');
await act(async () => {
textButton(document.body, 'Copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(cellDialog()?.textContent).toContain('Copied!');
render('Beta');
expect(cellDialog()?.textContent).not.toContain('Alpha');
expect(cellDialog()?.textContent).toContain('Beta');
expect(cellDialog()?.textContent).not.toContain('Copied!');
await act(async () => {
textButton(document.body, 'Copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith('Beta');
});
it('copies an empty cell value from the dialog', async () => {
const writeText = mockClipboard();
const container = renderTableContent([
<thead key="head">
<tr>
<th>Team</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td />
</tr>
</tbody>,
]);
doubleClick(dataCell(container, 0, 0));
await act(async () => {
textButton(document.body, 'Copy').click();
await Promise.resolve();
await Promise.resolve();
});
expect(writeText).toHaveBeenCalledWith('');
});
it('closes the cell value dialog with Escape', () => {
const container = renderTable();
doubleClick(dataCell(container, 0, 0));
expect(cellDialog()).not.toBeNull();
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
);
});
expect(cellDialog()).toBeNull();
});
it('closes the cell value dialog from the backdrop and buttons', () => {
const container = renderTable();
doubleClick(dataCell(container, 0, 0));
const backdrop = cellDialog()?.parentElement;
expect(backdrop).not.toBeNull();
act(() => {
backdrop!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
});
expect(cellDialog()).toBeNull();
doubleClick(dataCell(container, 0, 0));
click(button(document.body, 'Close'));
expect(cellDialog()).toBeNull();
doubleClick(dataCell(container, 0, 0));
click(textButton(document.body, 'Close'));
expect(cellDialog()).toBeNull();
});
it('restores focus when closing the cell value dialog', () => {
const container = renderTable();
const scroller = container.querySelector<HTMLElement>('[tabindex="0"]');
expect(scroller).not.toBeNull();
act(() => {
scroller!.focus();
});
doubleClick(dataCell(container, 0, 0));
expect(document.activeElement).not.toBe(scroller);
click(textButton(document.body, 'Close'));
expect(document.activeElement).toBe(scroller);
});
it('traps focus inside the cell value dialog', () => {
const container = renderTable();
doubleClick(dataCell(container, 0, 0));
const iconCloseButton = button(document.body, 'Close');
const footerCloseButton = textButton(document.body, 'Close');
expect(document.activeElement).toBe(iconCloseButton);
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
key: 'Tab',
shiftKey: true,
}),
);
});
expect(document.activeElement).toBe(footerCloseButton);
act(() => {
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Tab' }),
);
});
expect(document.activeElement).toBe(iconCloseButton);
const dialog = cellDialog();
expect(dialog).not.toBeNull();
act(() => {
dialog!.focus();
document.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Tab' }),
);
});
expect(document.activeElement).toBe(iconCloseButton);
});
it('keeps table Escape handling from running behind the cell dialog', () => {
const container = renderTable();
const teamHandle = button(container, 'Move Team');
click(button(container, 'Sort by Team'));
expect(teamHandle.className).toContain('reorderHandleVisible');
doubleClick(dataCell(container, 0, 0));
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Escape',
});
act(() => {
document.dispatchEvent(event);
});
expect(event.defaultPrevented).toBe(true);
expect(cellDialog()).toBeNull();
expect(teamHandle.className).toContain('reorderHandleVisible');
});
it('clears table selection and row details when opening a cell dialog', () => {
const container = renderTable();
dragCells(dataCell(container, 0, 0), dataCell(container, 0, 0));
expect(container.textContent).toContain('1 cell selected');
click(button(container, 'View details for row 1'));
expect(container.textContent).toContain('Row details');
doubleClick(dataCell(container, 0, 0));
expect(container.textContent).not.toContain('1 cell selected');
expect(container.textContent).not.toContain('Row details');
expect(cellDialog()).not.toBeNull();
});
it('closes an open filter menu when opening a cell dialog', () => {
const container = renderTable();
click(button(container, 'Filter Team'));
expect(container.textContent).toContain('Custom filter');
doubleClick(dataCell(container, 0, 0));
expect(container.textContent).not.toContain('Custom filter');
expect(cellDialog()).not.toBeNull();
});
it('does not open the cell dialog when double clicking an interactive target', () => {
const container = renderTableContent([
<thead key="head">
<tr>
<th>Link</th>
</tr>
</thead>,
<tbody key="body">
<tr>
<td>
<a href="#details">Open details</a>
</td>
</tr>
</tbody>,
]);
const link = container.querySelector('a');
expect(link).not.toBeNull();
doubleClick(link!);
expect(cellDialog()).toBeNull();
});
it('quick copies the visible sorted table', () => {
const writeText = mockClipboard();
const container = renderTable();

View file

@ -18,7 +18,10 @@ import {
type RefObject,
type TouchEvent as ReactTouchEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { useI18n } from '../../i18n';
import { useInteractionBlocker } from '../../interactionBlockContext';
import { useTheme, WebShellThemeId } from '../../themeContext';
import styles from './EnhancedMarkdownTable.module.css';
type TableElement = ReactElement<{
@ -96,6 +99,11 @@ interface ColumnResizeState {
startWidth: number;
}
interface CellDialogState {
rowKey: string;
columnIndex: number;
}
interface FilterOption {
value: string;
label: string;
@ -143,6 +151,8 @@ function clampColumnWidth(width: number): number {
const FOCUSABLE_FILTER_MENU_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const FOCUSABLE_CELL_DIALOG_SELECTOR =
'a[href]:not([hidden]), button:not([disabled]):not([hidden]), input:not([disabled]):not([hidden]), select:not([disabled]):not([hidden]), textarea:not([disabled]):not([hidden]), [tabindex]:not([tabindex="-1"]):not([hidden])';
function getFocusableFilterMenuElements(container: HTMLElement): HTMLElement[] {
return Array.from(
@ -150,6 +160,15 @@ function getFocusableFilterMenuElements(container: HTMLElement): HTMLElement[] {
).filter((element) => !element.hasAttribute('hidden'));
}
function getFocusableCellDialogElements(
container: HTMLElement | null,
): HTMLElement[] {
if (!container) return [];
return Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_CELL_DIALOG_SELECTOR),
);
}
function isInteractiveSelectionTarget(target: EventTarget | null): boolean {
return (
target instanceof Element &&
@ -1203,6 +1222,8 @@ export function EnhancedTable({
toolbarExtra?: ReactNode;
}) {
const { t } = useI18n();
const theme = useTheme();
const registerInteractionBlocker = useInteractionBlocker();
const tableId = useId();
const [sort, setSort] = useState<SortState | null>(null);
const [filters, setFilters] = useState<Record<number, ColumnFilter>>({});
@ -1224,11 +1245,13 @@ export function EnhancedTable({
const [resizingColumn, setResizingColumn] =
useState<ColumnResizeState | null>(null);
const [detailRowKey, setDetailRowKey] = useState<string | null>(null);
const [cellDialog, setCellDialog] = useState<CellDialogState | null>(null);
const [longTextExpanded, setLongTextExpanded] = useState(false);
const [density, setDensity] = useState<TableDensity>('standard');
const [isDragging, setIsDragging] = useState(false);
const [copiedVisible, setCopiedVisible] = useState(false);
const [copiedSelection, setCopiedSelection] = useState(false);
const [copiedCellDialog, setCopiedCellDialog] = useState(false);
const draggingRef = useRef(false);
const copiedVisibleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
@ -1236,14 +1259,20 @@ export function EnhancedTable({
const copiedSelectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const copiedCellDialogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const copiedVisibleGenRef = useRef(0);
const copiedSelectionGenRef = useRef(0);
const copiedCellDialogGenRef = useRef(0);
const mountedRef = useRef(true);
const shellRef = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const filterMenuRef = useRef<HTMLDivElement | null>(null);
const columnContextMenuRef = useRef<HTMLDivElement | null>(null);
const filterTriggerRef = useRef<HTMLButtonElement | null>(null);
const cellDialogRef = useRef<HTMLDivElement | null>(null);
const cellDialogFocusReturnRef = useRef<HTMLElement | null>(null);
const focusReturnFrameRef = useRef(0);
const pendingSelectionRef = useRef<{
rowIndex: number;
@ -1294,6 +1323,15 @@ export function EnhancedTable({
setCopiedSelection(false);
}, []);
const resetCopiedCellDialog = useCallback(() => {
copiedCellDialogGenRef.current += 1;
if (copiedCellDialogTimerRef.current) {
clearTimeout(copiedCellDialogTimerRef.current);
copiedCellDialogTimerRef.current = null;
}
setCopiedCellDialog(false);
}, []);
const flushPendingSelection = useCallback(() => {
if (selectionFrameRef.current) {
cancelAnimationFrame(selectionFrameRef.current);
@ -1354,15 +1392,18 @@ export function EnhancedTable({
setFreezeFirstColumn(false);
setResizingColumn(null);
setDetailRowKey(null);
setCellDialog(null);
setLongTextExpanded(false);
setDensity('standard');
resetCopiedVisible();
resetCopiedSelection();
resetCopiedCellDialog();
draggingRef.current = false;
draggingColumnRef.current = null;
setIsDragging(false);
}, [
resetCopiedSelection,
resetCopiedCellDialog,
resetCopiedVisible,
table.columnCount,
tableStructureKey,
@ -1386,6 +1427,10 @@ export function EnhancedTable({
clearTimeout(copiedSelectionTimerRef.current);
copiedSelectionTimerRef.current = null;
}
if (copiedCellDialogTimerRef.current) {
clearTimeout(copiedCellDialogTimerRef.current);
copiedCellDialogTimerRef.current = null;
}
};
}, []);
@ -1491,7 +1536,7 @@ export function EnhancedTable({
}
};
const clearActiveColumnOnEscape = (event: KeyboardEvent) => {
if (event.defaultPrevented || openFilterMenu || columnContextMenu) return;
if (event.defaultPrevented || openFilterMenu || cellDialog || columnContextMenu) return;
if (event.key === 'Escape') setActiveColumn(null);
};
document.addEventListener('mousedown', clearActiveColumnOnOutsideMouseDown);
@ -1503,7 +1548,7 @@ export function EnhancedTable({
);
document.removeEventListener('keydown', clearActiveColumnOnEscape);
};
}, [columnContextMenu, openFilterMenu]);
}, [cellDialog, columnContextMenu, openFilterMenu]);
const filteredRows = useMemo(
() => applyFilters(table.rows, filters),
@ -1539,6 +1584,14 @@ export function EnhancedTable({
const frozenColumnIndex = freezeFirstColumn
? orderedVisibleColumnIndexes[0]
: undefined;
const currentCellDialogCell = useMemo(() => {
if (!cellDialog) return null;
const row = visibleRows.find((item) => item.key === cellDialog.rowKey);
return row?.cells[cellDialog.columnIndex] ?? null;
}, [cellDialog, visibleRows]);
const currentCellDialogText = currentCellDialogCell?.text;
const cellDialogThemeClass =
theme === WebShellThemeId.Light ? styles.themeLight : styles.themeDark;
useEffect(() => {
resetCopiedVisible();
@ -1593,7 +1646,78 @@ export function EnhancedTable({
if (detailRowKey && !visibleRows.some((row) => row.key === detailRowKey)) {
setDetailRowKey(null);
}
}, [detailRowKey, visibleRows]);
if (
cellDialog &&
!visibleRows.some(
(row) =>
row.key === cellDialog.rowKey && row.cells[cellDialog.columnIndex],
)
) {
setCellDialog(null);
}
}, [cellDialog, detailRowKey, visibleRows]);
useEffect(() => {
if (!cellDialog) return;
return registerInteractionBlocker();
}, [cellDialog, registerInteractionBlocker]);
useEffect(() => {
if (!cellDialog) return;
resetCopiedCellDialog();
}, [cellDialog, currentCellDialogText, resetCopiedCellDialog]);
useEffect(() => {
if (!cellDialog) return;
const dialog = cellDialogRef.current;
const focusableElements = getFocusableCellDialogElements(dialog);
(focusableElements[0] ?? dialog)?.focus();
const handleDialogKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
if (event.isComposing || event.keyCode === 229) return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setCellDialog(null);
resetCopiedCellDialog();
return;
}
if (event.key !== 'Tab') return;
const nextFocusableElements = getFocusableCellDialogElements(
cellDialogRef.current,
);
if (nextFocusableElements.length === 0) {
event.preventDefault();
cellDialogRef.current?.focus();
return;
}
const first = nextFocusableElements[0];
const last = nextFocusableElements[nextFocusableElements.length - 1];
const activeElement = document.activeElement;
const currentIndex =
activeElement instanceof HTMLElement
? nextFocusableElements.indexOf(activeElement)
: -1;
if (event.shiftKey && (activeElement === first || currentIndex === -1)) {
event.preventDefault();
last?.focus();
} else if (
!event.shiftKey &&
(activeElement === last || currentIndex === -1)
) {
event.preventDefault();
first?.focus();
}
};
document.addEventListener('keydown', handleDialogKeyDown);
return () => {
document.removeEventListener('keydown', handleDialogKeyDown);
const focusReturn = cellDialogFocusReturnRef.current;
cellDialogFocusReturnRef.current = null;
if (focusReturn?.isConnected) focusReturn.focus();
};
}, [cellDialog, resetCopiedCellDialog]);
const setColumnFilter = (
columnIndex: number,
@ -1727,9 +1851,53 @@ export function EnhancedTable({
const toggleRowDetail = (rowKey: string) => {
setSelection(null);
setCellDialog(null);
resetCopiedCellDialog();
setDetailRowKey((current) => (current === rowKey ? null : rowKey));
};
const openCellDialog = (rowKey: string, columnIndex: number) => {
cellDialogFocusReturnRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setSelection(null);
setOpenFilterMenu(null);
setDetailRowKey(null);
resetCopiedCellDialog();
setCellDialog({
rowKey,
columnIndex,
});
};
const closeCellDialog = () => {
setCellDialog(null);
resetCopiedCellDialog();
};
const copyCellDialogValue = () => {
if (currentCellDialogText == null || !navigator.clipboard) return;
const copyGeneration = copiedCellDialogGenRef.current;
void navigator.clipboard
.writeText(sanitizeForClipboard(currentCellDialogText))
.then(() => {
if (!mountedRef.current) return;
if (copiedCellDialogGenRef.current !== copyGeneration) return;
if (copiedCellDialogTimerRef.current) {
clearTimeout(copiedCellDialogTimerRef.current);
}
setCopiedCellDialog(true);
copiedCellDialogTimerRef.current = setTimeout(
() => setCopiedCellDialog(false),
2000,
);
})
.catch((error: unknown) =>
console.warn('[web-shell] clipboard write failed:', error),
);
};
const selectionRowBounds = useMemo(
() => (selection ? getSelectionRowBounds(selection) : null),
[selection],
@ -2367,6 +2535,12 @@ export function EnhancedTable({
onTouchMove={extendTouchSelection}
onTouchEnd={stopDragging}
onTouchCancel={stopDragging}
onDoubleClick={(event) => {
if (isInteractiveSelectionTarget(event.target)) {
return;
}
openCellDialog(row.key, columnIndex);
}}
>
{renderCellContent(cell, longTextExpanded)}
</td>
@ -2462,6 +2636,65 @@ export function EnhancedTable({
onSort={setColumnSort}
/>
)}
{cellDialog &&
currentCellDialogCell &&
createPortal(
<div
className={`${styles.cellDialogBackdrop} ${cellDialogThemeClass}`}
onMouseDown={closeCellDialog}
>
<div
ref={cellDialogRef}
className={styles.cellDialog}
role="dialog"
aria-modal="true"
aria-labelledby={`${tableId}-cell-dialog-title`}
tabIndex={-1}
onMouseDown={(event) => event.stopPropagation()}
>
<button
className={styles.cellDialogCloseIcon}
type="button"
onClick={closeCellDialog}
aria-label={t('markdownTable.close')}
>
×
</button>
<div
id={`${tableId}-cell-dialog-title`}
className={styles.cellDialogTitle}
>
{t('markdownTable.cellDialogTitle')}
</div>
<div className={styles.cellDialogValue}>
{currentCellDialogCell.content}
</div>
<div className={styles.cellDialogFooter}>
<div className={styles.cellDialogActions}>
<button
className={styles.cellDialogButton}
type="button"
onClick={copyCellDialogValue}
>
{copiedCellDialog
? t('code.copied')
: t('markdownTable.copyCell')}
</button>
<button
className={`${styles.cellDialogButton} ${
styles.cellDialogPrimaryButton
}`}
type="button"
onClick={closeCellDialog}
>
{t('markdownTable.close')}
</button>
</div>
</div>
</div>
</div>,
document.body,
)}
</div>
);
}

View file

@ -342,6 +342,9 @@ const EN: Messages = {
'markdownTable.closeRowDetailsAria': (v) =>
`Hide details for row ${v?.index ?? ''}`,
'markdownTable.detailsHeader': 'Row details',
'markdownTable.cellDialogTitle': 'Current field value',
'markdownTable.copyCell': 'Copy',
'markdownTable.close': 'Close',
'markdownTable.actions': 'Actions',
'markdownTable.sortByColumn': (v) => `Sort by ${v?.column ?? ''}`,
'markdownTable.sortByColumnAsc': (v) =>
@ -2111,6 +2114,9 @@ const ZH: Messages = {
'markdownTable.rowDetailsAria': (v) => `查看第 ${v?.index ?? ''} 行详情`,
'markdownTable.closeRowDetailsAria': (v) => `收起第 ${v?.index ?? ''} 行详情`,
'markdownTable.detailsHeader': '单行详情',
'markdownTable.cellDialogTitle': '当前字段值',
'markdownTable.copyCell': '复制',
'markdownTable.close': '关闭',
'markdownTable.actions': '操作',
'markdownTable.sortByColumn': (v) => `${v?.column ?? ''} 排序`,
'markdownTable.sortByColumnAsc': (v) => `${v?.column ?? ''} 已升序排序`,

View file

@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';
export type RegisterInteractionBlocker = () => () => void;
const noopRelease = () => {};
const noopRegisterInteractionBlocker = () => noopRelease;
export const InteractionBlockContext =
createContext<RegisterInteractionBlocker>(noopRegisterInteractionBlocker);
export function useInteractionBlocker(): RegisterInteractionBlocker {
return useContext(InteractionBlockContext);
}