diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 16fd3955ba..720bbef2f1 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -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()), +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('[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 diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index e03fa9307f..829dd60b69 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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(' '); + + {(() => { + const contentClassName = [ + styles.content, + showFloatingTodos || + displayMessages.length > 0 || + pendingApproval + ? styles.contentHasMessages + : undefined, + ] + .filter(Boolean) + .join(' '); - const messageList = ( + const messageList = ( - ); + ); - const btwPanel = - !showMobileWelcomeFooterMiddle && - btwMessage?.role === 'btw' ? ( + const btwPanel = + !showMobileWelcomeFooterMiddle && + btwMessage?.role === 'btw' ? (
- ) : null; + ) : null; - if (showMobileWelcomeFooterMiddle) { + if (showMobileWelcomeFooterMiddle) { + return ( +
+
+ {messageList} + {btwPanel} +
+
+ {welcomeFooter} +
+
+ ); + } return ( -
-
- {messageList} - {btwPanel} -
-
- {welcomeFooter} -
+
+ {messageList} + {btwPanel}
); - } - return ( -
- {messageList} - {btwPanel} -
- ); - })()} + })()} + diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css index 5d8249af1b..5c37715dfc 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css @@ -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; + } +} diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx index b23dec7d71..f544e03fed 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx @@ -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('[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([ + + + Formula + + , + + + =IMPORTXML("https://example.com") + + , + ]); + + 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( + + + + + Team + + + + + {value} + + + + , + ); + }); + }; + 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([ + + + Team + + , + + + + + , + ]); + + 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('[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([ + + + Link + + , + + + + Open details + + + , + ]); + 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(); diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx index f3f59a59f8..c4c9a618ab 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx @@ -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(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(null); const [filters, setFilters] = useState>({}); @@ -1224,11 +1245,13 @@ export function EnhancedTable({ const [resizingColumn, setResizingColumn] = useState(null); const [detailRowKey, setDetailRowKey] = useState(null); + const [cellDialog, setCellDialog] = useState(null); const [longTextExpanded, setLongTextExpanded] = useState(false); const [density, setDensity] = useState('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 | null>( null, @@ -1236,14 +1259,20 @@ export function EnhancedTable({ const copiedSelectionTimerRef = useRef | null>( null, ); + const copiedCellDialogTimerRef = useRef | null>( + null, + ); const copiedVisibleGenRef = useRef(0); const copiedSelectionGenRef = useRef(0); + const copiedCellDialogGenRef = useRef(0); const mountedRef = useRef(true); const shellRef = useRef(null); const containerRef = useRef(null); const filterMenuRef = useRef(null); const columnContextMenuRef = useRef(null); const filterTriggerRef = useRef(null); + const cellDialogRef = useRef(null); + const cellDialogFocusReturnRef = useRef(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)} @@ -2462,6 +2636,65 @@ export function EnhancedTable({ onSort={setColumnSort} /> )} + {cellDialog && + currentCellDialogCell && + createPortal( +
+
event.stopPropagation()} + > + +
+ {t('markdownTable.cellDialogTitle')} +
+
+ {currentCellDialogCell.content} +
+
+
+ + +
+
+
+
, + document.body, + )}
); } diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 449ff98569..4a3c71c1f9 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -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 ?? ''} 已升序排序`, diff --git a/packages/web-shell/client/interactionBlockContext.ts b/packages/web-shell/client/interactionBlockContext.ts new file mode 100644 index 0000000000..53a84ca186 --- /dev/null +++ b/packages/web-shell/client/interactionBlockContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +export type RegisterInteractionBlocker = () => () => void; + +const noopRelease = () => {}; +const noopRegisterInteractionBlocker = () => noopRelease; + +export const InteractionBlockContext = + createContext(noopRegisterInteractionBlocker); + +export function useInteractionBlocker(): RegisterInteractionBlocker { + return useContext(InteractionBlockContext); +}