diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e1d59773f..b2f6b9027e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,7 @@ jobs: - run: bun typecheck - run: bunx playwright install --with-deps chromium - run: bun run test:e2e -- terminal-wrap-fidelity.spec.ts composer-copy.spec.ts + - run: cd web && bunx playwright test --config playwright.config.ts e2e/markdown-table.spec.ts e2e/markdown-table-mobile.spec.ts - run: bun run test # Serial runner-integration suite: starts real detached runner/session diff --git a/web/e2e-fixtures/markdown-table-fixture.html b/web/e2e-fixtures/markdown-table-fixture.html new file mode 100644 index 0000000000..fd5b261141 --- /dev/null +++ b/web/e2e-fixtures/markdown-table-fixture.html @@ -0,0 +1,17 @@ + + + + + + HAPI Markdown table fixture + + + +
+ + + diff --git a/web/e2e-fixtures/markdown-table-fixture.tsx b/web/e2e-fixtures/markdown-table-fixture.tsx new file mode 100644 index 0000000000..dd2d1aa330 --- /dev/null +++ b/web/e2e-fixtures/markdown-table-fixture.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { HappyChatProvider, type HappyChatContextValue } from '../src/components/AssistantChat/context' +import { I18nProvider } from '../src/lib/i18n-context' +import { MarkdownRenderer } from '../src/components/MarkdownRenderer' + +const TABLE_MARKDOWN = `# Repository activity + +| Project | Stars | Language | Latest release | Maintainer | Notes | +| --- | ---: | --- | --- | --- | --- | +| HAPI | 128 | TypeScript | 0.28.0 | Local-first team | Remote control for coding agents | +| HAPI, local-first | 42 | TypeScript | 0.27.3 | Community | A deliberately long description for horizontal table scrolling | +| Example | 7 | Rust | 1.2.0 | Open source | Stable fixture row |` + +const NEAR_BOTTOM_VERTICAL_TABLE_MARKDOWN = `# Near bottom table + +| Item | Status | +| --- | --- | +${Array.from({ length: 14 }, (_, index) => `| Item ${index + 1} | Ready |`).join('\n')}` + +function MarkdownTableFixture() { + const query = new URLSearchParams(window.location.search) + const content = query.has('near-bottom-scroll') + ? NEAR_BOTTOM_VERTICAL_TABLE_MARKDOWN + : TABLE_MARKDOWN + + return ( + +
+ +
+
+ ) +} + +const root = document.getElementById('root') +if (root) { + ReactDOM.createRoot(root).render( + + + + + , + ) +} diff --git a/web/e2e/markdown-table-mobile.spec.ts b/web/e2e/markdown-table-mobile.spec.ts new file mode 100644 index 0000000000..8efc258cfc --- /dev/null +++ b/web/e2e/markdown-table-mobile.spec.ts @@ -0,0 +1,124 @@ +import { devices, expect, test } from '@playwright/test' + +test.use({ ...devices['Pixel 7'] }) + +test('mobile markdown table viewer requests landscape and releases orientation controls', async ({ page }) => { + await page.goto('/e2e-fixtures/markdown-table-fixture.html') + await page.evaluate(() => { + const state = { requestFullscreen: 0, exitFullscreen: 0, locks: [] as string[], unlocks: 0 } + Object.defineProperty(window, '__hapiTableViewerState', { configurable: true, value: state }) + Object.defineProperty(document.documentElement, 'requestFullscreen', { + configurable: true, + value: () => { + state.requestFullscreen += 1 + return Promise.resolve() + }, + }) + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: () => { + state.exitFullscreen += 1 + return Promise.resolve() + }, + }) + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: { + lock: (value: string) => { + state.locks.push(value) + return Promise.resolve() + }, + unlock: () => { + state.unlocks += 1 + }, + }, + }) + }) + + const inlineActions = page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-actions') + await expect(inlineActions).toBeVisible() + await expect(inlineActions.getByRole('button')).toHaveCount(1) + await page.getByRole('button', { name: 'Open table full screen' }).click() + const dialog = page.getByRole('dialog', { name: 'Table filename fixture' }) + await expect(dialog).toBeVisible() + // A real mobile browser can rotate to a landscape CSS viewport. Keep the + // mobile title unshifted even when its width becomes desktop-sized. + await page.setViewportSize({ width: 915, height: 412 }) + const wrapButton = dialog.locator('button[data-hapi-table-wrap-toggle="true"]') + await expect(wrapButton).toBeVisible() + await expect(wrapButton).toHaveAttribute('aria-pressed', /true|false/) + await expect(dialog.getByRole('button', { name: 'Copy table as Markdown' })).toBeVisible() + const downloadButton = dialog.getByRole('button', { name: 'Download table' }) + await expect(downloadButton).toBeVisible() + const initiallyWrapped = await wrapButton.getAttribute('aria-pressed') + await wrapButton.click() + await expect(wrapButton).toHaveAttribute('aria-pressed', initiallyWrapped === 'true' ? 'false' : 'true') + await expect.poll(() => dialog.locator('[data-hapi-table-viewer-toolbar="true"]').evaluate((element) => { + const style = getComputedStyle(element) + return `${style.paddingLeft}:${style.paddingRight}:${style.paddingTop}:${style.paddingBottom}` + })).toBe('6px:6px:0px:0px') + await expect.poll(() => dialog.locator('[data-hapi-table-viewer-toolbar="true"]').evaluate((element) => getComputedStyle(element).columnGap)).toBe('4px') + await expect.poll(() => dialog.locator('[data-hapi-table-viewer-heading="true"]').evaluate((element) => getComputedStyle(element).transform)).toBe('none') + await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"] thead th').first().evaluate((element) => { + const thead = element.closest('thead') + return `${getComputedStyle(thead ?? element).position}:${getComputedStyle(element).position}:${getComputedStyle(element).top}` + })).toBe('static:sticky:0px') + await expect.poll(() => page.evaluate(() => { + const state = (window as Window & { __hapiTableViewerState?: { requestFullscreen: number; locks: string[] } }).__hapiTableViewerState + return state ? `${state.requestFullscreen}:${state.locks.join(',')}` : '' + })).toBe('1:landscape') + + await downloadButton.click() + await expect(page.getByRole('menuitem', { name: 'Download PNG' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Download CSV' })).toBeVisible() + const imageDownloadPromise = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Download PNG' }).click() + const imageDownload = await imageDownloadPromise + expect(imageDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.png$/) + + const csvDownloadPromise = page.waitForEvent('download') + await downloadButton.click() + await page.getByRole('menuitem', { name: 'Download CSV' }).click() + const csvDownload = await csvDownloadPromise + expect(csvDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.csv$/) + + await dialog.getByRole('button', { name: 'Close table full screen' }).click() + await expect.poll(() => page.evaluate(() => { + const state = (window as Window & { __hapiTableViewerState?: { exitFullscreen: number; unlocks: number } }).__hapiTableViewerState + return state ? `${state.exitFullscreen}:${state.unlocks}` : '' + })).toBe('1:1') +}) + +test('mobile markdown table viewer detects a phone that starts in landscape', async ({ page }) => { + await page.setViewportSize({ width: 915, height: 412 }) + await page.goto('/e2e-fixtures/markdown-table-fixture.html') + await expect(page.getByRole('button', { name: 'Open table full screen' })).toBeVisible() + await page.evaluate(() => { + const state = { requestFullscreen: 0, locks: [] as string[] } + Object.defineProperty(window, '__hapiTableViewerState', { configurable: true, value: state }) + Object.defineProperty(document.documentElement, 'requestFullscreen', { + configurable: true, + value: () => { + state.requestFullscreen += 1 + return Promise.resolve() + }, + }) + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: { + lock: (value: string) => { + state.locks.push(value) + return Promise.resolve() + }, + unlock: () => {}, + }, + }) + }) + + await page.getByRole('button', { name: 'Open table full screen' }).click() + await expect(page.getByRole('dialog', { name: 'Table filename fixture' })).toBeVisible() + await expect.poll(() => page.evaluate(() => { + const state = (window as Window & { __hapiTableViewerState?: { requestFullscreen: number; locks: string[] } }).__hapiTableViewerState + return state ? `${state.requestFullscreen}:${state.locks.join(',')}` : '' + })).toBe('1:landscape') +}) diff --git a/web/e2e/markdown-table.spec.ts b/web/e2e/markdown-table.spec.ts new file mode 100644 index 0000000000..16db1eb715 --- /dev/null +++ b/web/e2e/markdown-table.spec.ts @@ -0,0 +1,288 @@ +import { readFile } from 'node:fs/promises' +import { expect, test } from '@playwright/test' + +test.describe('markdown table actions', () => { + test('opens a viewport-sized PC viewer and downloads the CSV', async ({ page }) => { + await page.goto('/e2e-fixtures/markdown-table-fixture.html') + + const inlineTable = page.locator('[data-testid="markdown-table-fixture"] table') + await expect(inlineTable).toBeVisible() + await expect(inlineTable.locator('thead')).toBeVisible() + + const tableFrame = page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame') + const actions = tableFrame.locator('.aui-md-table-actions') + await expect(actions).toBeAttached() + await expect(actions.getByRole('button')).toHaveCount(1) + const inlineButtonStyles = await actions.getByRole('button').evaluate((element) => { + const style = getComputedStyle(element) + return { backgroundColor: style.backgroundColor, borderWidth: style.borderTopWidth, backdropFilter: style.backdropFilter } + }) + expect(inlineButtonStyles.backgroundColor).toMatch(/rgba\(0, 0, 0, 0\)|transparent/) + expect(inlineButtonStyles.borderWidth).toBe('0px') + expect(inlineButtonStyles.backdropFilter).toBe('none') + await expect.poll(() => actions.evaluate((element) => { + const style = getComputedStyle(element) + return `${style.top}:${style.right}` + })).toBe('0px:3px') + await expect.poll(() => actions.evaluate((element) => getComputedStyle(element).opacity)).toBe('0') + await tableFrame.hover() + await expect.poll(() => actions.evaluate((element) => getComputedStyle(element).opacity)).toBe('1') + + await page.getByRole('button', { name: 'Open table full screen' }).click() + const dialog = page.getByRole('dialog', { name: 'Table filename fixture' }) + await expect(dialog).toBeVisible() + + const viewerHeading = dialog.locator('[data-hapi-table-viewer-heading="true"]') + await expect(viewerHeading).toHaveText('Table filename fixture') + await expect.poll(() => viewerHeading.evaluate((element) => getComputedStyle(element).fontSize)).toBe('18px') + await expect.poll(() => viewerHeading.evaluate((element) => getComputedStyle(element).transform)).toBe('matrix(1, 0, 0, 1, 0, -1)') + const toolbar = dialog.locator('[data-hapi-table-viewer-toolbar="true"]') + await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('0px') + await expect.poll(() => toolbar.evaluate((element) => `${getComputedStyle(element).paddingLeft}:${getComputedStyle(element).paddingRight}`)).toBe('6px:6px') + await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).columnGap)).toBe('4px') + await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).paddingTop)).toBe('0px') + await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).paddingBottom)).toBe('0px') + const toolbarEdges = await toolbar.evaluate((element) => { + const buttons = element.querySelectorAll('button') + const first = buttons[0]?.getBoundingClientRect() + const last = buttons[buttons.length - 1]?.getBoundingClientRect() + const toolbarRect = element.getBoundingClientRect() + return { + leftGap: Math.round((first?.left ?? 0) - toolbarRect.left), + rightGap: Math.round(toolbarRect.right - (last?.right ?? 0)), + } + }) + expect(toolbarEdges).toEqual({ leftGap: 6, rightGap: 6 }) + + const box = await dialog.boundingBox() + expect(box?.width).toBeGreaterThanOrEqual(1400) + expect(box?.height).toBeGreaterThanOrEqual(850) + await expect(dialog.locator('[data-hapi-table-viewer="true"] .aui-md-thead')).toBeVisible() + const wrapButton = dialog.getByRole('button', { name: 'Enable table wrapping' }) + await expect(wrapButton).toHaveAttribute('aria-pressed', 'false') + await wrapButton.click() + await expect(dialog.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true') + await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => { + const table = element.querySelector('table') + const cell = table?.querySelector('td') + return table && cell + ? `${getComputedStyle(element).overflowX}:${getComputedStyle(table).tableLayout}:${getComputedStyle(cell).whiteSpace}` + : '' + })).toBe('hidden:fixed:normal') + await expect.poll(async () => { + const toolbarHeight = (await toolbar.boundingBox())?.height ?? 0 + const headerHeight = await dialog.locator('[data-hapi-table-viewer="true"] thead').evaluate((element) => element.getBoundingClientRect().height) + return Math.round(toolbarHeight) - Math.round(headerHeight) + }).toBe(0) + const viewerLeftOffset = await dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => { + const table = element.querySelector('table') + if (!table) return -1 + return Math.round(table.getBoundingClientRect().left - element.getBoundingClientRect().left) + }) + expect(viewerLeftOffset).toBe(0) + await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => getComputedStyle(element).paddingRight)).toBe('0px') + await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => getComputedStyle(element).paddingBottom)).toBe('0px') + + await page.evaluate(() => { + let copied = '' + Object.defineProperty(window, '__hapiCopiedTableMarkdown', { + configurable: true, + get: () => copied, + }) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: async (text: string) => { copied = text } }, + }) + }) + await dialog.getByRole('button', { name: 'Copy table' }).click() + await expect(page.getByRole('menuitem').nth(0)).toHaveText('Copy image') + await expect(page.getByRole('menuitem').nth(1)).toHaveText('Copy Markdown') + await page.getByRole('menuitem', { name: 'Copy Markdown' }).click() + await expect.poll(() => page.evaluate(() => (window as Window & { __hapiCopiedTableMarkdown?: string }).__hapiCopiedTableMarkdown ?? '')).toContain('| Project | Stars |') + + const imageDownloadPromise = page.waitForEvent('download') + await dialog.getByRole('button', { name: 'Download table' }).click() + await expect(page.getByRole('menuitem', { name: 'Download PNG' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Download CSV' })).toBeVisible() + await page.getByRole('menuitem', { name: 'Download PNG' }).click() + const imageDownload = await imageDownloadPromise + expect(imageDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.png$/) + const imagePath = await imageDownload.path() + if (!imagePath) throw new Error('PNG download did not produce a file path') + const png = await readFile(imagePath) + expect(png.subarray(1, 4).toString()).toBe('PNG') + const exportedHeight = png.readUInt32BE(20) + const tableExportMetrics = await dialog.locator('[data-hapi-table-viewer="true"] table').evaluate((table) => { + const tableRect = table.getBoundingClientRect() + const rowBottoms = Array.from(table.rows) + .map((row) => row.getBoundingClientRect()) + .filter((rowRect) => rowRect.height > 0) + .map((rowRect) => rowRect.bottom - tableRect.top) + const fallbackHeight = Math.max(table.scrollHeight, Math.ceil(tableRect.height), 1) + const height = rowBottoms.length > 0 + ? Math.min(fallbackHeight, Math.ceil(Math.max(...rowBottoms))) + : fallbackHeight + return { + width: Math.max(table.scrollWidth, Math.ceil(tableRect.width), 1), + height, + devicePixelRatio: window.devicePixelRatio, + } + }) + const scale = Math.min( + tableExportMetrics.devicePixelRatio || 1, + 2, + Math.sqrt(36_000_000 / (tableExportMetrics.width * tableExportMetrics.height)), + ) + expect(exportedHeight).toBe(Math.ceil(tableExportMetrics.height * scale)) + + const downloadPromise = page.waitForEvent('download') + await dialog.getByRole('button', { name: 'Download table' }).click() + await expect(page.getByRole('menuitem', { name: 'Download CSV' })).toBeVisible() + await page.getByRole('menuitem', { name: 'Download CSV' }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.csv$/) + + await dialog.getByRole('button', { name: 'Close table full screen' }).click() + await expect(dialog).toBeHidden() + }) + + test('sizes each action menu to its longest option with symmetric padding', async ({ page }) => { + await page.goto('/e2e-fixtures/markdown-table-fixture.html') + await page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame').hover() + await page.getByRole('button', { name: 'Open table full screen' }).click() + + const dialog = page.getByRole('dialog', { name: 'Table filename fixture' }) + await expect(dialog).toBeVisible() + + const measureMenu = async (triggerName: string) => { + await dialog.getByRole('button', { name: triggerName }).click() + const menu = page.getByRole('menu', { name: triggerName }) + await expect(menu).toBeVisible() + return menu.evaluate((element) => { + const menuRect = element.getBoundingClientRect() + const options = Array.from(element.querySelectorAll('button')).map((button) => { + const range = document.createRange() + range.selectNodeContents(button) + const textRect = range.getBoundingClientRect() + return { + text: button.textContent, + textWidth: textRect.width, + leftGap: textRect.left - menuRect.left, + rightGap: menuRect.right - textRect.right, + } + }) + const longest = options.reduce((current, option) => { + const currentWidth = current.textWidth + const optionWidth = option.textWidth + return optionWidth > currentWidth ? option : current + }) + return { options, longest } + }) + } + + const copyMenu = await measureMenu('Copy table') + expect(copyMenu.options.map((option) => option.text)).toEqual(['Copy image', 'Copy Markdown']) + expect(Math.abs(copyMenu.longest.leftGap - copyMenu.longest.rightGap)).toBeLessThanOrEqual(1) + await page.keyboard.press('Escape') + + const downloadMenu = await measureMenu('Download table') + expect(downloadMenu.options.map((option) => option.text)).toEqual(['Download PNG', 'Download CSV']) + expect(Math.abs(downloadMenu.longest.leftGap - downloadMenu.longest.rightGap)).toBeLessThanOrEqual(1) + }) + + test('keeps file-preview table header geometry aligned with chat tables', async ({ page }) => { + await page.goto('/e2e-fixtures/markdown-table-fixture.html') + + const surface = page.locator('[data-testid="markdown-table-fixture"]') + const readGeometry = () => surface.evaluate((element) => { + const table = element.querySelector('table') + const header = table?.querySelector('thead th') + const actions = element.querySelector('.aui-md-table-actions') + if (!table || !header || !actions) throw new Error('Markdown table geometry is incomplete') + + const headerRect = header.getBoundingClientRect() + const actionRect = actions.getBoundingClientRect() + const headerStyle = getComputedStyle(header) + return { + paddingTop: headerStyle.paddingTop, + paddingBottom: headerStyle.paddingBottom, + paddingLeft: headerStyle.paddingLeft, + paddingRight: headerStyle.paddingRight, + lineHeight: headerStyle.lineHeight, + actionHeaderCenterDelta: Math.round(Math.abs( + (actionRect.top + actionRect.height / 2) - (headerRect.top + headerRect.height / 2), + )), + } + }) + + const chatGeometry = await readGeometry() + await surface.evaluate((element) => element.classList.add('markdown-content')) + const filePreviewGeometry = await readGeometry() + + expect(filePreviewGeometry).toEqual(chatGeometry) + expect(filePreviewGeometry.actionHeaderCenterDelta).toBeLessThanOrEqual(2) + }) + + test('defaults an overflowing table to wrapping and remembers an explicit choice', async ({ page }) => { + await page.setViewportSize({ width: 600, height: 900 }) + await page.goto('/e2e-fixtures/markdown-table-fixture.html') + await page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame').hover() + await page.getByRole('button', { name: 'Open table full screen' }).click() + + const dialog = page.getByRole('dialog', { name: 'Table filename fixture' }) + await expect(dialog).toBeVisible() + const wrappedButton = dialog.getByRole('button', { name: 'Disable table wrapping' }) + await expect(wrappedButton).toHaveAttribute('aria-pressed', 'true') + await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => { + const table = element.querySelector('table') + return table && element.scrollWidth <= element.clientWidth + ? `${getComputedStyle(table).tableLayout}:${getComputedStyle(element).overflowX}` + : '' + })).toBe('fixed:hidden') + + await wrappedButton.click() + await expect(dialog.getByRole('button', { name: 'Enable table wrapping' })).toHaveAttribute('aria-pressed', 'false') + await dialog.getByRole('button', { name: 'Close table full screen' }).click() + await expect(dialog).toBeHidden() + + await page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame').hover() + await page.getByRole('button', { name: 'Open table full screen' }).click() + const reopenedDialog = page.getByRole('dialog', { name: 'Table filename fixture' }) + await expect(reopenedDialog).toBeVisible() + await expect(reopenedDialog.getByRole('button', { name: 'Enable table wrapping' })).toHaveAttribute('aria-pressed', 'false') + }) + + test('does not rebound at the bottom when toolbar space exceeds remaining table overflow', async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 500 }) + await page.goto('/e2e-fixtures/markdown-table-fixture.html?near-bottom-scroll') + await page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame').hover() + await page.getByRole('button', { name: 'Open table full screen' }).click() + + const dialog = page.getByRole('dialog', { name: 'Table filename fixture' }) + const viewer = dialog.locator('[data-hapi-table-viewer="true"]') + await expect(dialog).toBeVisible() + await expect.poll(() => viewer.evaluate((element) => element.scrollHeight > element.clientHeight && element.scrollWidth <= element.clientWidth + 1)).toBe(true) + + await expect(dialog.getByRole('button', { name: 'Enable table wrapping' })).toHaveAttribute('aria-pressed', 'false') + const viewerBox = await viewer.boundingBox() + if (!viewerBox) throw new Error('Long table viewer has no bounding box') + await page.mouse.move(viewerBox.x + viewerBox.width / 2, viewerBox.y + viewerBox.height / 2) + const readScrollState = () => viewer.evaluate((element) => ({ + scrollTop: element.scrollTop, + maxScrollTop: Math.max(0, element.scrollHeight - element.clientHeight), + toolbarHidden: document.querySelector('[data-hapi-table-viewer-toolbar="true"]')?.getAttribute('aria-hidden') === 'true', + })) + await page.mouse.wheel(0, 10_000) + const bottomSamples = [await readScrollState()] + for (let index = 0; index < 12; index += 1) { + await page.waitForTimeout(25) + bottomSamples.push(await readScrollState()) + } + + expect(bottomSamples.at(-1)?.scrollTop).toBeGreaterThanOrEqual((bottomSamples.at(-1)?.maxScrollTop ?? 0) - 1) + expect(bottomSamples.some((sample) => sample.toolbarHidden)).toBe(false) + for (let index = 1; index < bottomSamples.length; index += 1) { + expect(bottomSamples[index]?.scrollTop).toBeGreaterThanOrEqual((bottomSamples[index - 1]?.scrollTop ?? 0) - 1) + } + }) +}) diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 79bd4d1ff5..0e6a242f6e 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -501,7 +501,10 @@ export function HappyThread(props: { const machineLabelsById = useMachineLabels(machines) const [shareTurn, setShareTurn] = useState(null) const shareDialogOpen = shareTurn !== null - const shareTitle = shareTurn ? getSessionTitle(props.session) : '' + const sessionTitle = typeof props.session?.id === 'string' + ? getSessionTitle(props.session) + : undefined + const shareTitle = shareTurn ? (sessionTitle ?? '') : '' const shareRelativeTimeTick = useMinuteTick(headerMetadata.lastActive && shareDialogOpen) const shareMetadataItems = useMemo(() => { const agentFlavor = props.session.metadata?.flavor ?? null @@ -1606,6 +1609,7 @@ export function HappyThread(props: { vi.fn()) +vi.mock('html2canvas-pro', () => ({ default: html2canvas })) + +describe('ShareTurnDialog preview cleanup', () => { + beforeEach(() => { + html2canvas.mockReset() + html2canvas.mockResolvedValue({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + }) + + it('keeps table fullscreen controls interactive in the preview but removes them from exports', () => { + const root = document.createElement('div') + root.innerHTML = ` +
+ + + +
Configuration
apiUrl
+
+ +
+
+ ` + + stripCaptureOnlyControls(root) + + expect(root.querySelector('.aui-md-table-actions')).not.toBeNull() + expect(root.querySelector('table')).not.toBeNull() + expect(root).toHaveTextContent('apiUrl') + + stripExportControls(root) + expect(root.querySelector('.aui-md-table-actions')).toBeNull() + }) + + it('preserves the link target for Markdown copied from the share preview', () => { + const root = document.createElement('div') + root.innerHTML = 'Docs' + + stripCaptureOnlyControls(root) + + const anchor = root.querySelector('a') + expect(anchor).not.toHaveAttribute('href') + expect(anchor).toHaveAttribute('data-hapi-markdown-href', 'https://example.com/docs') + }) + + it('opens the shared table viewer from the preview action', async () => { + render( + + +
+ + + +
Configuration
apiUrl
+
+ +
+
+ + `, + text: 'Configuration apiUrl', + }]} + onClose={vi.fn()} + /> +
, + ) + + fireEvent.click(await screen.findByRole('button', { name: 'Open table full screen' })) + expect(await screen.findByRole('dialog', { name: 'Share table' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close table full screen' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' })) + await waitFor(() => expect(screen.queryByRole('button', { name: 'Close table full screen' })).not.toBeInTheDocument()) + }) +}) diff --git a/web/src/components/AssistantChat/ShareTurnDialog.tsx b/web/src/components/AssistantChat/ShareTurnDialog.tsx index 019f328228..d3019be3e6 100644 --- a/web/src/components/AssistantChat/ShareTurnDialog.tsx +++ b/web/src/components/AssistantChat/ShareTurnDialog.tsx @@ -1,10 +1,17 @@ -import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { useTranslation } from '@/lib/use-translation' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { ZoomableLightbox } from '@/components/ZoomableLightbox' +import { + enterMobileTableViewer, + isMobileTableViewerViewport, + leaveMobileTableViewer, + TableViewerFromElement, +} from '@/components/assistant-ui/MarkdownTable' import { safeCopyToClipboard } from '@/lib/clipboard' import type { ShareTurnMetadataItem } from '@/lib/shareTurnMetadata' +import { getShareImageFileName } from '@/lib/share-image-filename' type ShareTurnDialogProps = { isOpen: boolean @@ -33,7 +40,7 @@ function nextFrame(): Promise { }) } -function stripCaptureOnlyControls(root: HTMLElement): void { +export function stripCaptureOnlyControls(root: HTMLElement): void { for (const element of Array.from(root.querySelectorAll(SHARE_HIDDEN_CONTENT_SELECTOR))) { if (!(element instanceof HTMLElement) || !root.contains(element)) continue @@ -58,6 +65,10 @@ function stripCaptureOnlyControls(root: HTMLElement): void { element.remove() } for (const anchor of Array.from(root.querySelectorAll('a'))) { + const href = anchor.getAttribute('href') + if (href && !anchor.dataset.hapiMarkdownHref) { + anchor.dataset.hapiMarkdownHref = href + } anchor.removeAttribute('href') anchor.removeAttribute('target') anchor.removeAttribute('rel') @@ -71,8 +82,8 @@ function stripCaptureOnlyControls(root: HTMLElement): void { } } -function stripExportControls(root: HTMLElement): void { - for (const element of Array.from(root.querySelectorAll('[data-hapi-share-export-exclude="true"]'))) { +export function stripExportControls(root: HTMLElement): void { + for (const element of Array.from(root.querySelectorAll('[data-hapi-share-export-exclude="true"], .aui-md-table-actions'))) { element.remove() } for (const imageButton of Array.from(root.querySelectorAll('button:has(img)'))) { @@ -110,38 +121,6 @@ function setPreviewCodeWrap(control: HTMLElement, enabled: boolean): void { } } -function formatShareTimestamp(date = new Date()): string { - const pad = (value: number) => String(value).padStart(2, '0') - return [ - date.getFullYear(), - pad(date.getMonth() + 1), - pad(date.getDate()), - pad(date.getHours()), - pad(date.getMinutes()), - pad(date.getSeconds()) - ].join('') -} - -function sanitizeShareFileNamePart(title: string): string { - const withoutControlCharacters = Array.from(title.normalize('NFKC')) - .filter((character) => { - const codePoint = character.codePointAt(0) ?? 0 - return codePoint >= 32 && codePoint !== 127 - }) - .join('') - const sanitized = withoutControlCharacters - .replace(/[<>:"/\\|?*]+/g, '-') - .replace(/\s+/g, ' ') - .replace(/-+/g, '-') - .replace(/^[ .-]+|[ .-]+$/g, '') - .trim() - return Array.from(sanitized || 'Shared turn').slice(0, 80).join('').trim() -} - -function getShareFileName(title: string): string { - return `HAPI-${sanitizeShareFileNamePart(title)}-${formatShareTimestamp()}.png` -} - function prepareExportElement(element: HTMLElement, exportWidth: number, preserveSourceLayout: boolean): HTMLElement { const captureElement = element.cloneNode(true) if (!(captureElement instanceof HTMLElement)) { @@ -531,6 +510,11 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { naturalWidth: number naturalHeight: number } | null>(null) + const [previewTable, setPreviewTable] = useState(null) + const previewTableRef = useRef(null) + const previewTableOpenRef = useRef(false) + const previewTableMobileRef = useRef(false) + const previewTableFullscreenRef = useRef(false) const showNativeShareButton = true const usesCoarsePrimaryPointer = window.matchMedia('(pointer: coarse)').matches const preserveSourceLayout = Boolean( @@ -543,6 +527,37 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { ? Math.min(1240, Math.max(480, Math.ceil((props.sourceContentWidth ?? 0) + SHARE_EXPORT_HORIZONTAL_PADDING))) : SHARE_EXPORT_WIDTH + const closeTablePreview = useCallback(() => { + const wasMobile = previewTableMobileRef.current + const enteredFullscreen = previewTableFullscreenRef.current + previewTableOpenRef.current = false + previewTableRef.current = null + previewTableMobileRef.current = false + previewTableFullscreenRef.current = false + setPreviewTable(null) + if (wasMobile) leaveMobileTableViewer(enteredFullscreen) + }, []) + + const openTablePreview = useCallback((table: HTMLTableElement) => { + previewTableRef.current = table + previewTableOpenRef.current = true + const isMobile = isMobileTableViewerViewport() + previewTableMobileRef.current = isMobile + previewTableFullscreenRef.current = false + setPreviewTable(table) + if (!isMobile) return + + // Start the browser activation-sensitive request directly from the + // table button click. The viewer itself is rendered after state updates. + void enterMobileTableViewer().then((enteredFullscreen) => { + if (!previewTableOpenRef.current || previewTableRef.current !== table) { + leaveMobileTableViewer(enteredFullscreen) + return + } + previewTableFullscreenRef.current = enteredFullscreen + }) + }, []) + useLayoutEffect(() => { setReady(false) if (!props.isOpen) return undefined @@ -576,6 +591,10 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { if (!appendedSnapshot && isTextOnlySnapshot) appendTextFallback(fragment, snapshot) } body.replaceChildren(fragment) + // Run one final pass over the assembled preview. This also covers + // capture-only controls nested in a snapshot root that was assembled + // from multiple top-level nodes. + stripCaptureOnlyControls(body) if ((body.innerText || body.textContent || '').trim().length === 0 && textLength > 0) { for (const snapshot of props.sourceSnapshots) { @@ -590,6 +609,24 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { return undefined }, [props.isOpen, props.sourceSnapshots, restoreTick]) + useEffect(() => { + if (props.isOpen) return undefined + closeTablePreview() + return undefined + }, [closeTablePreview, props.isOpen]) + + useEffect(() => { + const handleFullscreenChange = () => { + if (!previewTableOpenRef.current || !previewTableMobileRef.current || !previewTableFullscreenRef.current) return + if (document.fullscreenElement) return + closeTablePreview() + } + document.addEventListener('fullscreenchange', handleFullscreenChange) + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) + }, [closeTablePreview]) + + useEffect(() => () => closeTablePreview(), [closeTablePreview]) + useEffect(() => { const capture = captureRef.current if (!props.isOpen || !ready || !capture) { @@ -613,6 +650,15 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { const target = event.target if (!(target instanceof Element)) return + const tableAction = target.closest('.aui-md-table-actions button') + if (tableAction) { + event.preventDefault() + event.stopPropagation() + const table = tableAction.closest('.aui-md-table-frame')?.querySelector('table') + if (table) openTablePreview(table) + return + } + const wrapButton = target.closest('[data-hapi-code-wrap-toggle="true"]') if (wrapButton) { event.preventDefault() @@ -714,7 +760,8 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { } return ( - { if (!open) props.onClose() }}> + <> + { if (!open) props.onClose() }}> shareImageBlob(blob, getShareFileName(props.title)), + (blob) => shareImageBlob(blob, getShareImageFileName(props.title)), 'share' ) } @@ -832,7 +879,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { - + + {previewTable ? ( + + ) : null} + ) } diff --git a/web/src/components/AssistantChat/context.tsx b/web/src/components/AssistantChat/context.tsx index dd1a4c1907..23835f8fcd 100644 --- a/web/src/components/AssistantChat/context.tsx +++ b/web/src/components/AssistantChat/context.tsx @@ -9,6 +9,7 @@ export type OlderHistoryLoadResult = 'loaded' | 'transient-stop' | 'terminal-sto export type HappyChatContextValue = { api: ApiClient sessionId: string + sessionTitle?: string metadata: SessionMetadataSummary | null terminalToolDisplayMode: TerminalToolDisplayMode /** Hub-wide AGENT_NOTIFY_SUMMARY chat display; polled once at chat shell. */ diff --git a/web/src/components/assistant-ui/MarkdownTable.test.tsx b/web/src/components/assistant-ui/MarkdownTable.test.tsx new file mode 100644 index 0000000000..b4615e7821 --- /dev/null +++ b/web/src/components/assistant-ui/MarkdownTable.test.tsx @@ -0,0 +1,1152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' + +const html2canvas = vi.hoisted(() => vi.fn()) +vi.mock('html2canvas-pro', () => ({ default: html2canvas })) + +import { + downloadTableAsCsv, + getTableExportScale, + getTableExportHeight, + getTableExportTileHeight, + isMobileTableViewerViewport, + MAX_TABLE_EXPORT_DIMENSION, + MAX_TABLE_EXPORT_PIXELS, + MAX_TABLE_EXPORT_TILE_PIXELS, + renderTableAsImage, + saveTableAsImage, + shouldWrapTableByDefault, + serializeTableToMarkdown, + serializeTableToCsv, + TableViewerFromElement, +} from './MarkdownTable' + +const TABLE_MARKDOWN = `| Project | Stars | +| --- | ---: | +| HAPI | 128 | +| HAPI, local-first | 42 |` + +function renderTable(locale: 'en' | 'zh-CN' = 'en') { + localStorage.setItem('hapi-lang', locale) + return render( + + + , + ) +} + +describe('MarkdownTable', () => { + const originalMatchMedia = window.matchMedia + const originalOrientation = window.screen.orientation + const originalNavigatorShare = navigator.share + const originalNavigatorCanShare = navigator.canShare + const originalNavigatorClipboard = navigator.clipboard + const originalClipboardItem = window.ClipboardItem + const originalMaxTouchPoints = navigator.maxTouchPoints + const originalUserAgent = navigator.userAgent + const originalInnerWidth = window.innerWidth + const originalInnerHeight = window.innerHeight + + beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() + html2canvas.mockReset() + window.matchMedia = originalMatchMedia + Object.defineProperty(navigator, 'share', { + configurable: true, + value: originalNavigatorShare, + }) + Object.defineProperty(navigator, 'canShare', { + configurable: true, + value: originalNavigatorCanShare, + }) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalNavigatorClipboard }) + Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: originalClipboardItem }) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: originalMaxTouchPoints }) + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: originalUserAgent }) + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: originalOrientation, + }) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight }) + }) + + afterEach(() => { + window.matchMedia = originalMatchMedia + Object.defineProperty(navigator, 'share', { + configurable: true, + value: originalNavigatorShare, + }) + Object.defineProperty(navigator, 'canShare', { + configurable: true, + value: originalNavigatorCanShare, + }) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: originalNavigatorClipboard }) + Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: originalClipboardItem }) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: originalMaxTouchPoints }) + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: originalUserAgent }) + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: originalOrientation, + }) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight }) + }) + + it('keeps the table header as the first row while exposing only a plain fullscreen action', () => { + renderTable() + + const table = screen.getByRole('table') + expect(table.firstElementChild?.tagName).toBe('THEAD') + expect(table.parentElement?.parentElement).toHaveClass('aui-md-table-shell') + const actions = table.parentElement?.parentElement?.querySelector('.aui-md-table-actions') + expect(actions?.querySelectorAll('button')).toHaveLength(1) + expect(actions).toHaveAttribute('data-hapi-share-export-exclude', 'true') + expect(screen.getByRole('button', { name: 'Open table full screen' })).toBeInTheDocument() + }) + + it('keeps inline table actions as tall as a wrapped header row', async () => { + let resizeCallback: ResizeObserverCallback | undefined + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + observe() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', TestResizeObserver) + + try { + renderTable() + const table = screen.getByRole('table') + const row = table.tHead?.rows[0] + const actions = table.parentElement?.parentElement?.querySelector('.aui-md-table-actions') + if (!row || !actions) throw new Error('Inline table action geometry is incomplete') + + vi.spyOn(row, 'getBoundingClientRect').mockReturnValue({ height: 56 } as DOMRect) + await waitFor(() => expect(resizeCallback).toBeDefined()) + resizeCallback?.([], {} as ResizeObserver) + expect(actions).toHaveStyle({ height: '56px' }) + } finally { + vi.unstubAllGlobals() + } + }) + + it('opens an enlarged PC viewer without requesting browser fullscreen or orientation lock', async () => { + const requestFullscreen = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(document.documentElement, 'requestFullscreen', { + configurable: true, + value: requestFullscreen, + }) + const lock = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: { lock, unlock: vi.fn() }, + }) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + + expect(await screen.findByRole('dialog', { name: 'Table' })).toBeInTheDocument() + const dialog = screen.getByRole('dialog', { name: 'Table' }) + expect(dialog).toContainElement(screen.getByRole('table')) + const wrapButton = screen.getByRole('button', { name: 'Enable table wrapping' }) + expect(wrapButton).toHaveAttribute('aria-pressed', 'false') + const copyMenuTrigger = screen.getByRole('button', { name: 'Copy table' }) + expect(copyMenuTrigger).toHaveAttribute('aria-haspopup', 'menu') + fireEvent.click(copyMenuTrigger) + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['Copy image', 'Copy Markdown']) + fireEvent.click(screen.getByRole('menuitem', { name: 'Copy Markdown' })) + const downloadMenuTrigger = screen.getByRole('button', { name: 'Download table' }) + expect(downloadMenuTrigger).toHaveAttribute('aria-haspopup', 'menu') + fireEvent.click(downloadMenuTrigger) + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['Download PNG', 'Download CSV']) + expect(requestFullscreen).not.toHaveBeenCalled() + expect(lock).not.toHaveBeenCalled() + + fireEvent.click(wrapButton) + expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true') + expect(dialog.querySelector('[data-hapi-table-viewer="true"]')).toHaveClass('overflow-x-hidden', 'overflow-y-auto') + expect(dialog.querySelector('[data-hapi-table-wrap="true"]')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument()) + }) + + it('uses concise Chinese labels for the copy and download menus', async () => { + renderTable('zh-CN') + fireEvent.click(screen.getByRole('button', { name: '横向全屏查看表格' })) + + await screen.findByRole('dialog', { name: '表格' }) + fireEvent.click(screen.getByRole('button', { name: '复制表格' })) + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['复制图片', '复制 Markdown']) + fireEvent.click(screen.getByRole('menuitem', { name: '复制 Markdown' })) + + fireEvent.click(screen.getByRole('button', { name: '下载表格' })) + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['下载 PNG', '下载 CSV']) + }) + + it('keeps shared-preview tables at max-content width when the source uses w-full', async () => { + const sourceTable = document.createElement('table') + sourceTable.className = 'aui-md-table w-full border-collapse text-sm' + sourceTable.innerHTML = 'ProjectHAPI' + + render( + + {}} + table={sourceTable} + imageTitle="Shared table" + /> + , + ) + + const dialog = await screen.findByRole('dialog', { name: 'Shared table' }) + const viewerTable = dialog.querySelector('table') + expect(viewerTable).toHaveClass('w-max', 'min-w-full') + expect(viewerTable).not.toHaveClass('w-full') + }) + + it('remembers an explicit wrapping choice when the same table is reopened', async () => { + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await screen.findByRole('dialog', { name: 'Table' }) + fireEvent.click(screen.getByRole('button', { name: 'Enable table wrapping' })) + expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + + const reopenedDialog = await screen.findByRole('dialog', { name: 'Table' }) + expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true') + fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument()) + expect(reopenedDialog).toBeTruthy() + }) + + it('uses horizontal overflow to choose the first-open default', () => { + const table = document.createElement('table') + const viewer = document.createElement('div') + Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 901 }) + Object.defineProperty(viewer, 'clientWidth', { configurable: true, value: 900 }) + expect(shouldWrapTableByDefault(table, viewer)).toBe(false) + + Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 902 }) + expect(shouldWrapTableByDefault(table, viewer)).toBe(true) + }) + + it('shows a saving status while the PNG is being generated', async () => { + type CanvasStub = { toBlob: (callback: BlobCallback) => void } + let resolveCanvas: ((value: CanvasStub | PromiseLike) => void) | undefined + html2canvas.mockReturnValue(new Promise((resolve) => { + resolveCanvas = resolve + })) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table-image') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + fireEvent.click(await screen.findByRole('button', { name: 'Download table' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' })) + + const savingStatus = screen.getByRole('status') + expect(savingStatus).toHaveTextContent('Saving image…') + expect(savingStatus).toHaveAttribute('data-hapi-table-save-status', 'true') + expect(savingStatus).toHaveClass('left-1/2', '-translate-x-1/2', 'rounded-full') + expect(savingStatus.querySelector('svg')).toHaveClass('animate-spin') + + resolveCanvas?.({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()) + }) + + it('copies a generated table image from the PC copy menu', async () => { + type CanvasStub = { toBlob: (callback: BlobCallback) => void } + let resolveCanvas: ((value: CanvasStub | PromiseLike) => void) | undefined + html2canvas.mockReturnValue(new Promise((resolve) => { + resolveCanvas = resolve + })) + const write = vi.fn().mockResolvedValue(undefined) + class ClipboardItemStub { + constructor(public readonly data: Record>) {} + } + Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: ClipboardItemStub }) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write } }) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await screen.findByRole('dialog', { name: 'Table' }) + fireEvent.click(screen.getByRole('button', { name: 'Copy table' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' })) + + expect(write).toHaveBeenCalledTimes(1) + const clipboardItem = write.mock.calls[0]?.[0]?.[0] as ClipboardItemStub | undefined + expect(clipboardItem?.data['image/png']).toBeInstanceOf(Promise) + expect(screen.getByRole('status')).toHaveTextContent('Copying image…') + expect(screen.getByRole('status')).toHaveAttribute('data-hapi-table-save-status-action', 'copy') + expect(screen.getByRole('status')).toHaveClass( + 'border-[var(--app-fg)]', + 'bg-[var(--app-fg)]', + 'text-[var(--app-bg)]', + 'shadow-md', + ) + resolveCanvas?.({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + await waitFor(() => expect(write).toHaveBeenCalledWith([expect.any(ClipboardItemStub)])) + await waitFor(() => expect(screen.getByRole('button', { name: 'Copy table' }).querySelector('polyline')).toBeInTheDocument()) + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()) + }) + + it('shows an error when PNG generation fails', async () => { + html2canvas.mockRejectedValue(new Error('render failed')) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await screen.findByRole('dialog', { name: 'Table' }) + fireEvent.click(screen.getByRole('button', { name: 'Download table' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' })) + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not process table image.')) + }) + + it('shows an error when copying the generated table image fails', async () => { + html2canvas.mockResolvedValue({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + const write = vi.fn().mockRejectedValue(new Error('clipboard denied')) + class ClipboardItemStub { + constructor(public readonly data: Record>) {} + } + Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: ClipboardItemStub }) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write } }) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await screen.findByRole('dialog', { name: 'Table' }) + fireEvent.click(screen.getByRole('button', { name: 'Copy table' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' })) + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not process table image.')) + }) + + it('shows an error when creating the image clipboard item throws', async () => { + html2canvas.mockResolvedValue({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + class ClipboardItemStub { + constructor() { + throw new Error('clipboard item failed') + } + } + Object.defineProperty(window, 'ClipboardItem', { configurable: true, value: ClipboardItemStub }) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { write: vi.fn() } }) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await screen.findByRole('dialog', { name: 'Table' }) + fireEvent.click(screen.getByRole('button', { name: 'Copy table' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Copy image' })) + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Could not process table image.')) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + + it('re-evaluates the automatic wrapping choice when the viewer width changes', async () => { + let resizeCallback: ResizeObserverCallback | undefined + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } + observe() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', TestResizeObserver) + + try { + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + const dialog = await screen.findByRole('dialog', { name: 'Table' }) + const viewer = dialog.querySelector('[data-hapi-table-viewer="true"]') + const table = viewer?.querySelector('table') + if (!viewer || !table) throw new Error('Table viewer did not render') + + let viewerWidth = 100 + let tableWidth = 200 + Object.defineProperty(viewer, 'clientWidth', { + configurable: true, + get: () => viewerWidth, + }) + Object.defineProperty(table, 'scrollWidth', { + configurable: true, + get: () => table.hasAttribute('data-hapi-table-wrap') + ? Math.min(tableWidth, viewerWidth) + : tableWidth, + }) + + await waitFor(() => expect(resizeCallback).toBeDefined()) + resizeCallback?.([], {} as ResizeObserver) + await waitFor(() => expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true')) + + viewerWidth = 150 + resizeCallback?.([], {} as ResizeObserver) + await waitFor(() => expect(screen.getByRole('button', { name: 'Disable table wrapping' })).toHaveAttribute('aria-pressed', 'true')) + + viewerWidth = 300 + tableWidth = 200 + resizeCallback?.([], {} as ResizeObserver) + await waitFor(() => expect(screen.getByRole('button', { name: 'Enable table wrapping' })).toHaveAttribute('aria-pressed', 'false')) + } finally { + vi.unstubAllGlobals() + } + }) + + it('requests mobile browser fullscreen and landscape orientation, then releases both on close', async () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 }) + + const requestFullscreen = vi.fn().mockResolvedValue(undefined) + const exitFullscreen = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(document.documentElement, 'requestFullscreen', { + configurable: true, + value: requestFullscreen, + }) + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: exitFullscreen, + }) + const lock = vi.fn().mockResolvedValue(undefined) + const unlock = vi.fn() + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: { lock, unlock }, + }) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + + await waitFor(() => { + expect(requestFullscreen).toHaveBeenCalledTimes(1) + expect(lock).toHaveBeenCalledWith('landscape') + }) + + fireEvent.click(screen.getByRole('button', { name: 'Close table full screen' })) + await waitFor(() => expect(exitFullscreen).toHaveBeenCalledTimes(1)) + expect(unlock).toHaveBeenCalledTimes(1) + }) + + it('releases orientation when the browser exits fullscreen externally', async () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 }) + + const requestFullscreen = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(document.documentElement, 'requestFullscreen', { + configurable: true, + value: requestFullscreen, + }) + const lock = vi.fn().mockResolvedValue(undefined) + const unlock = vi.fn() + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: { lock, unlock }, + }) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await waitFor(() => { + expect(requestFullscreen).toHaveBeenCalledTimes(1) + expect(lock).toHaveBeenCalledWith('landscape') + }) + + document.dispatchEvent(new Event('fullscreenchange')) + await waitFor(() => expect(unlock).toHaveBeenCalledTimes(1)) + expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument() + }) + + it('does not miss external fullscreen exit while orientation lock is pending', async () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 }) + + const requestFullscreen = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(document.documentElement, 'requestFullscreen', { + configurable: true, + value: requestFullscreen, + }) + let resolveLock: (() => void) | undefined + const lock = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveLock = resolve + })) + const unlock = vi.fn() + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: { lock, unlock }, + }) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await waitFor(() => { + expect(requestFullscreen).toHaveBeenCalledTimes(1) + expect(lock).toHaveBeenCalledWith('landscape') + }) + + document.dispatchEvent(new Event('fullscreenchange')) + await waitFor(() => expect(unlock).toHaveBeenCalledTimes(1)) + expect(screen.queryByRole('dialog', { name: 'Table' })).not.toBeInTheDocument() + resolveLock?.() + }) + + it('cleans up a pending mobile fullscreen request when the table unmounts', async () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 }) + + let resolveFullscreen: (() => void) | undefined + const requestFullscreen = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveFullscreen = resolve + })) + const exitFullscreen = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(document.documentElement, 'requestFullscreen', { + configurable: true, + value: requestFullscreen, + }) + Object.defineProperty(document, 'exitFullscreen', { + configurable: true, + value: exitFullscreen, + }) + const unlock = vi.fn() + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: { lock: vi.fn().mockResolvedValue(undefined), unlock }, + }) + + const rendered = renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(1)) + + rendered.unmount() + resolveFullscreen?.() + await waitFor(() => expect(exitFullscreen).toHaveBeenCalledTimes(1)) + expect(unlock).toHaveBeenCalled() + }) + + it('defers PNG rendering until the user requests an image save', async () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes('pointer: coarse'), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 }) + html2canvas.mockResolvedValue({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table-image') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + await screen.findByRole('dialog', { name: 'Table' }) + expect(html2canvas).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Download table' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' })) + await waitFor(() => expect(html2canvas).toHaveBeenCalledTimes(1)) + }) + + it('renders a fresh PNG after wrapping changes during an earlier render', async () => { + type CanvasStub = { toBlob: (callback: BlobCallback) => void } + const pending: Array<(value: CanvasStub | PromiseLike) => void> = [] + html2canvas.mockImplementation(() => new Promise((resolve) => pending.push(resolve))) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table-image') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + const dialog = await screen.findByRole('dialog', { name: 'Table' }) + fireEvent.click(screen.getByRole('button', { name: 'Download table' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' })) + await waitFor(() => expect(html2canvas).toHaveBeenCalledTimes(1)) + + fireEvent.click(screen.getByRole('button', { name: 'Enable table wrapping' })) + pending[0]?.({ + toBlob: (callback: BlobCallback) => callback(new Blob(['first'], { type: 'image/png' })), + }) + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: 'Download table' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Download PNG' })) + await waitFor(() => expect(html2canvas).toHaveBeenCalledTimes(2)) + pending[1]?.({ + toBlob: (callback: BlobCallback) => callback(new Blob(['second'], { type: 'image/png' })), + }) + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()) + }) + + it('recognizes a coarse-pointer phone that starts in landscape', () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes('pointer: coarse'), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 915 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 412 }) + + expect(isMobileTableViewerViewport()).toBe(true) + }) + + it('does not classify a touch-enabled Windows laptop as mobile', () => { + window.matchMedia = vi.fn(() => ({ + matches: false, + media: '', + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1366 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 700 }) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 }) + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }) + + expect(isMobileTableViewerViewport()).toBe(false) + }) + + it('hides the viewer toolbar while scrolling down and restores it while scrolling up', async () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes('max-width: 767px') || query.includes('pointer: coarse'), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 844 }) + + Object.defineProperty(document.documentElement, 'requestFullscreen', { + configurable: true, + value: vi.fn().mockResolvedValue(undefined), + }) + Object.defineProperty(window.screen, 'orientation', { + configurable: true, + value: { lock: vi.fn().mockResolvedValue(undefined), unlock: vi.fn() }, + }) + + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + const dialog = await screen.findByRole('dialog', { name: 'Table' }) + const toolbar = dialog.querySelector('[data-hapi-table-viewer-toolbar="true"]') + const viewer = dialog.querySelector('[data-hapi-table-viewer="true"]') as HTMLDivElement + expect(toolbar).toHaveAttribute('aria-hidden', 'false') + + let scrollTop = 0 + Object.defineProperty(viewer, 'scrollTop', { + configurable: true, + get: () => scrollTop, + }) + Object.defineProperty(viewer, 'scrollHeight', { + configurable: true, + get: () => 100, + }) + Object.defineProperty(viewer, 'clientHeight', { + configurable: true, + get: () => 20, + }) + + scrollTop = 20 + fireEvent.scroll(viewer) + await waitFor(() => expect(toolbar).toHaveAttribute('aria-hidden', 'true')) + + scrollTop = 15 + fireEvent.scroll(viewer) + expect(toolbar).toHaveAttribute('aria-hidden', 'true') + + scrollTop = 80 + fireEvent.scroll(viewer) + expect(toolbar).toHaveAttribute('aria-hidden', 'true') + + scrollTop = 79 + fireEvent.scroll(viewer) + expect(toolbar).toHaveAttribute('aria-hidden', 'true') + + scrollTop = 80 + fireEvent.scroll(viewer) + expect(toolbar).toHaveAttribute('aria-hidden', 'true') + + scrollTop = 79 + fireEvent.scroll(viewer) + expect(toolbar).toHaveAttribute('aria-hidden', 'true') + + scrollTop = 65 + fireEvent.scroll(viewer) + await waitFor(() => expect(toolbar).toHaveAttribute('aria-hidden', 'false')) + + scrollTop = 0 + fireEvent.scroll(viewer) + await waitFor(() => expect(toolbar).toHaveAttribute('aria-hidden', 'false')) + }) + + it('keeps the toolbar visible when collapsing it would clamp the bottom scroll position', async () => { + renderTable() + fireEvent.click(screen.getByRole('button', { name: 'Open table full screen' })) + const dialog = await screen.findByRole('dialog', { name: 'Table' }) + const toolbar = dialog.querySelector('[data-hapi-table-viewer-toolbar="true"]') as HTMLDivElement + const viewer = dialog.querySelector('[data-hapi-table-viewer="true"]') as HTMLDivElement + vi.spyOn(toolbar, 'getBoundingClientRect').mockReturnValue({ height: 36 } as DOMRect) + + let scrollTop = 0 + Object.defineProperty(viewer, 'scrollTop', { + configurable: true, + get: () => scrollTop, + }) + Object.defineProperty(viewer, 'scrollHeight', { + configurable: true, + get: () => 100, + }) + Object.defineProperty(viewer, 'clientHeight', { + configurable: true, + get: () => 64, + }) + + scrollTop = 36 + fireEvent.scroll(viewer) + await waitFor(() => expect(toolbar).toHaveAttribute('aria-hidden', 'false')) + }) + + it('serializes table cells as an Excel-friendly CSV', () => { + const table = document.createElement('table') + table.innerHTML = 'ProjectStarsHAPI128HAPI, local-first42' + + expect(serializeTableToCsv(table)).toBe('\uFEFF"Project","Stars"\r\n"HAPI","128"\r\n"HAPI, local-first","42"\r\n') + }) + + it('neutralizes formula-leading CSV cells', () => { + const table = document.createElement('table') + table.innerHTML = '=SUM(A1:A2)+10-1@command' + + expect(serializeTableToCsv(table)).toBe('\uFEFF"\'=SUM(A1:A2)","\'+10","\'-1","\'@command"\r\n') + }) + + it('serializes the rendered table as Markdown', () => { + const table = document.createElement('table') + table.innerHTML = 'ProjectNotesHAPISupports | tables' + + expect(serializeTableToMarkdown(table)).toBe('| Project | Notes |\n| --- | --- |\n| HAPI | Supports \\| tables |\n') + }) + + it('preserves inline Markdown formatting when copying a table', () => { + const table = document.createElement('table') + table.innerHTML = 'ProjectNotesDocshapi and fast local' + + expect(serializeTableToMarkdown(table)).toBe('| Project | Notes |\n| --- | --- |\n| [Docs](https://example.com) | `hapi` and **fast** *local* |\n') + }) + + it('preserves original link targets and safe code fences when copying Markdown', () => { + const table = document.createElement('table') + table.innerHTML = 'ProjectNotesOpena`b a b ' + + expect(serializeTableToMarkdown(table)).toBe('| Project | Notes |\n| --- | --- |\n| [Open](custom://target) | ``a`b`` ` a b ` |\n') + }) + + it('preserves backslashes inside inline code when copying Markdown', () => { + const table = document.createElement('table') + table.innerHTML = 'Path' + const code = document.createElement('code') + code.textContent = 'C:\\tmp' + table.tBodies[0]!.rows[0]!.cells[0]!.append(code) + + expect(serializeTableToMarkdown(table)).toBe('| Path |\n| --- |\n| `C:\\tmp` |\n') + }) + + it('omits internal HAPI file-link schemes when copying Markdown', () => { + const table = document.createElement('table') + table.innerHTML = 'Files' + const cell = table.tBodies[0]!.rows[0]!.cells[0]! + const plainLink = document.createElement('a') + plainLink.setAttribute('href', 'hapi-file:docs%2Frouter.tsx') + plainLink.textContent = 'docs/router.tsx' + const codeLink = document.createElement('a') + codeLink.setAttribute('href', 'hapi-file-candidate:docs%2Fmain.ts') + const code = document.createElement('code') + code.textContent = 'docs/main.ts' + codeLink.append(code) + cell.append(plainLink, ' ', codeLink) + + expect(serializeTableToMarkdown(table)).toBe('| Files |\n| --- |\n| docs/router.tsx `docs/main.ts` |\n') + }) + + it('escapes image alt text before rebuilding Markdown', () => { + const table = document.createElement('table') + table.innerHTML = 'Image' + const image = document.createElement('img') + image.setAttribute('src', 'https://example.com/table.png') + image.setAttribute('alt', 'a]b' + '\\' + '|c') + table.tBodies[0]!.rows[0]!.cells[0]!.append(image) + + const expectedAlt = 'a' + '\\]' + 'b' + '\\\\' + '\\|' + 'c' + expect(serializeTableToMarkdown(table)).toBe(`| Image |\n| --- |\n| ![${expectedAlt}](https://example.com/table.png) |\n`) + }) + + it('keeps sanitized custom-link destinations available for Markdown copying', () => { + render( + + + , + ) + + const link = screen.getByRole('link', { name: 'Open' }) + expect(link).toHaveAttribute('href', '#') + expect(link).toHaveAttribute('data-hapi-markdown-href', 'custom://target') + expect(serializeTableToMarkdown(screen.getByRole('table'))).toContain('[Open](custom://target)') + }) + + it('round-trips literal Markdown metacharacters in plain table text', () => { + const source = '| Value |\n| --- |\n| \\*literal\\* \\_literal\\_ \\[brackets\\] \\`ticks\\` \\~\\~tilde\\~\\~ \\ |' + const firstRender = render( + + + , + ) + + const copied = serializeTableToMarkdown(screen.getByRole('table')) + expect(copied).toBe(`${source}\n`) + firstRender.unmount() + + render( + + + , + ) + expect(screen.getByRole('table').tBodies[0]?.rows[0]?.cells[0]).toHaveTextContent('*literal* _literal_ [brackets] `ticks` ~~tilde~~ ') + }) + + it('preserves Markdown column alignment when copying a table', () => { + const table = document.createElement('table') + table.innerHTML = 'ProjectStarsStatusHAPI128Active' + + expect(serializeTableToMarkdown(table)).toBe('| Project | Stars | Status |\n| --- | ---: | :---: |\n| HAPI | 128 | Active |\n') + }) + + it('downloads the rendered table as CSV and keeps its Blob URL alive briefly', () => { + vi.useFakeTimers() + const table = document.createElement('table') + table.innerHTML = 'HAPI' + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table') + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + let clickedRel: string | undefined + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + clickedRel = this.rel + }) + + try { + downloadTableAsCsv(table, 'repositories.csv') + + expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)) + expect(click).toHaveBeenCalledTimes(1) + expect(clickedRel).toBe('') + expect(document.querySelector('a[download="repositories.csv"]')).toBeNull() + expect(revokeObjectURL).not.toHaveBeenCalled() + + vi.advanceTimersByTime(999) + expect(revokeObjectURL).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(revokeObjectURL).toHaveBeenCalledWith('blob:hapi-table') + } finally { + vi.useRealTimers() + } + }) + + it('renders and downloads the table as a PNG image', async () => { + const table = document.createElement('table') + table.innerHTML = 'HAPI' + html2canvas.mockResolvedValue({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:hapi-table-image') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + await saveTableAsImage(table, 'repositories.png') + + expect(html2canvas).toHaveBeenCalledWith(expect.any(HTMLTableElement), expect.objectContaining({ useCORS: true })) + expect(html2canvas.mock.calls[0]?.[0]).not.toBe(table) + expect(click).toHaveBeenCalledTimes(1) + expect(document.querySelector('a[download="repositories.png"]')).toBeNull() + }) + + it('renders a static table copy when the live header is sticky', async () => { + const table = document.createElement('table') + table.innerHTML = 'ProjectHAPI' + table.querySelector('thead')?.setAttribute('style', 'position: sticky; top: 0;') + + html2canvas.mockImplementation(async () => { + return { + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + } + }) + + await renderTableAsImage(table) + + const renderedTable = html2canvas.mock.calls[0]?.[0] as HTMLTableElement | undefined + expect(renderedTable).toBeDefined() + expect(renderedTable).not.toBe(table) + expect(renderedTable?.querySelector('thead')?.getAttribute('style')).not.toContain('position: sticky') + expect(document.querySelector('[data-hapi-table-image-render="true"]')).toBeNull() + expect(table.querySelector('thead')).toHaveStyle({ position: 'sticky', top: '0px' }) + }) + + it('preserves source column widths and header styling in the image clone', async () => { + const table = document.createElement('table') + table.innerHTML = 'ProjectStatusHAPIReady' + table.style.backgroundColor = 'rgb(28, 28, 30)' + table.tHead!.style.backgroundColor = 'rgb(53, 59, 67)' + table.tHead!.rows[0]!.cells[0]!.style.backgroundColor = 'rgb(70, 70, 70)' + table.tHead!.rows[0]!.cells[1]!.style.backgroundColor = 'rgb(80, 80, 80)' + Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 1_200 }) + vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({ top: 0, width: 1_200, height: 120 } as DOMRect) + const rows = Array.from(table.rows) + rows.forEach((row, rowIndex) => { + vi.spyOn(row, 'getBoundingClientRect').mockReturnValue({ top: rowIndex * 60, bottom: (rowIndex + 1) * 60, height: 60 } as DOMRect) + Array.from(row.cells).forEach((cell, cellIndex) => { + vi.spyOn(cell, 'getBoundingClientRect').mockReturnValue({ + width: 600, + height: 60, + left: cellIndex * 600, + right: (cellIndex + 1) * 600, + } as DOMRect) + }) + }) + html2canvas.mockResolvedValue({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + + await renderTableAsImage(table) + + const renderedTable = html2canvas.mock.calls[0]?.[0] as HTMLTableElement | undefined + expect(renderedTable?.style.tableLayout).toBe('fixed') + expect(Array.from(renderedTable?.querySelectorAll('col') ?? []).map((col) => col.style.width)).toEqual(['600px', '600px']) + expect(renderedTable?.tHead?.style.backgroundColor).toBe('rgb(53, 59, 67)') + expect(Array.from(renderedTable?.tHead?.querySelectorAll('th') ?? []).map((cell) => cell.style.backgroundColor)).toEqual([ + 'rgb(70, 70, 70)', + 'rgb(80, 80, 80)', + ]) + }) + + it('crops trailing table box space from PNG exports', async () => { + const table = document.createElement('table') + table.innerHTML = 'HAPI' + Object.defineProperty(table, 'scrollHeight', { configurable: true, value: 844 }) + vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({ + top: 120, + width: 1_851, + height: 844, + } as DOMRect) + const row = table.querySelector('tr') + vi.spyOn(row!, 'getBoundingClientRect').mockReturnValue({ + top: 120, + bottom: 720, + height: 600, + } as DOMRect) + html2canvas.mockResolvedValue({ + toBlob: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), + }) + + expect(getTableExportHeight(table)).toBe(600) + await renderTableAsImage(table) + + const renderedTable = html2canvas.mock.calls[0]?.[0] as HTMLTableElement | undefined + const options = html2canvas.mock.calls[0]?.[1] as { height: number } | undefined + expect(options?.height).toBe(600) + expect(renderedTable?.style.height).toBe('600px') + }) + + it('keeps export scale bounded and calculates safe vertical tiles', () => { + const scale = getTableExportScale(10_000, 10_000, 1) + expect(scale).toBeLessThan(1) + expect(10_000 * 10_000 * scale ** 2).toBeLessThanOrEqual(MAX_TABLE_EXPORT_PIXELS) + + const tallScale = getTableExportScale(100, 100_000, 2) + expect(Math.ceil(100 * tallScale)).toBeLessThanOrEqual(MAX_TABLE_EXPORT_DIMENSION) + expect(Math.ceil(100_000 * tallScale)).toBeLessThanOrEqual(MAX_TABLE_EXPORT_DIMENSION) + + const tileHeight = getTableExportTileHeight(3_000, 2) + expect(3_000 * tileHeight * 2 ** 2).toBeLessThanOrEqual(MAX_TABLE_EXPORT_TILE_PIXELS) + expect(3_000 * (tileHeight + 1) * 2 ** 2).toBeGreaterThan(MAX_TABLE_EXPORT_TILE_PIXELS) + }) + + it('measures intrinsic width while a table is currently wrapped', () => { + const table = document.createElement('table') + const viewer = document.createElement('div') + table.setAttribute('data-hapi-table-wrap', 'true') + Object.defineProperty(viewer, 'clientWidth', { configurable: true, value: 150 }) + Object.defineProperty(table, 'scrollWidth', { + configurable: true, + get: () => table.hasAttribute('data-hapi-table-wrap') ? 150 : 200, + }) + + expect(shouldWrapTableByDefault(table, viewer)).toBe(true) + expect(table.getAttribute('data-hapi-table-wrap')).toBe('true') + }) + + it('stitches oversized exports from bounded vertical tiles', async () => { + const table = document.createElement('table') + table.innerHTML = 'HAPI' + Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 3_000 }) + Object.defineProperty(table, 'scrollHeight', { configurable: true, value: 4_000 }) + vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({ width: 3_000, height: 4_000 } as DOMRect) + + const originalDevicePixelRatio = window.devicePixelRatio + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 2 }) + const drawImage = vi.fn() + const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D) + const toBlob = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => { + callback(new Blob(['png'], { type: 'image/png' })) + }) + html2canvas.mockImplementation(async (_element: HTMLElement, options: { width: number; height: number; scale: number }) => ({ + width: Math.ceil(options.width * options.scale), + height: Math.ceil(options.height * options.scale), + toBlob: (callback: BlobCallback) => callback(new Blob(['tile'], { type: 'image/png' })), + })) + + try { + await renderTableAsImage(table) + + const expectedTiles = Math.ceil(4_000 / getTableExportTileHeight(3_000, getTableExportScale(3_000, 4_000, 2))) + expect(html2canvas).toHaveBeenCalledTimes(expectedTiles) + expect(drawImage).toHaveBeenCalledTimes(expectedTiles) + const scale = getTableExportScale(3_000, 4_000, 2) + const destinationRanges = drawImage.mock.calls.map((call) => ({ + top: call[6] as number, + bottom: (call[6] as number) + (call[8] as number), + })) + for (let index = 1; index < destinationRanges.length; index += 1) { + expect(destinationRanges[index - 1]?.bottom).toBe(destinationRanges[index]?.top) + } + expect(destinationRanges.at(-1)?.bottom).toBe(Math.ceil(4_000 * scale)) + expect(toBlob).toHaveBeenCalledTimes(1) + expect(getContext).toHaveBeenCalledWith('2d') + expect(document.querySelector('[data-hapi-table-image-render="true"]')).toBeNull() + } finally { + Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: originalDevicePixelRatio }) + } + }) + + it('keeps the stitched PNG canvas within the browser dimension limit', async () => { + const table = document.createElement('table') + table.innerHTML = 'HAPI' + Object.defineProperty(table, 'scrollWidth', { configurable: true, value: 1_000 }) + Object.defineProperty(table, 'scrollHeight', { configurable: true, value: 20_000 }) + vi.spyOn(table, 'getBoundingClientRect').mockReturnValue({ width: 1_000, height: 20_000 } as DOMRect) + + const createdCanvases: HTMLCanvasElement[] = [] + const createElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => { + const element = createElement(tagName, options) + if (tagName === 'canvas') createdCanvases.push(element as HTMLCanvasElement) + return element + }) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as unknown as CanvasRenderingContext2D) + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback) => { + callback(new Blob(['png'], { type: 'image/png' })) + }) + html2canvas.mockImplementation(async (_element: HTMLElement, options: { width: number; height: number; scale: number }) => ({ + width: Math.ceil(options.width * options.scale), + height: Math.ceil(options.height * options.scale), + toBlob: (callback: BlobCallback) => callback(new Blob(['tile'], { type: 'image/png' })), + })) + + await renderTableAsImage(table) + + expect(createdCanvases).toHaveLength(1) + expect(Math.max(createdCanvases[0]?.width ?? 0, createdCanvases[0]?.height ?? 0)).toBeLessThanOrEqual(MAX_TABLE_EXPORT_DIMENSION) + }) + + it('uses the same direct download path on touch devices as shared images', () => { + window.matchMedia = vi.fn((query: string) => ({ + matches: query.includes('pointer: coarse'), + media: query, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { return false }, + })) as typeof window.matchMedia + + const share = vi.fn().mockResolvedValue(undefined) + const canShare = vi.fn().mockReturnValue(true) + Object.defineProperty(navigator, 'share', { configurable: true, value: share }) + Object.defineProperty(navigator, 'canShare', { configurable: true, value: canShare }) + + const table = document.createElement('table') + table.innerHTML = 'HAPI' + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + downloadTableAsCsv(table, 'repositories.csv') + + expect(canShare).not.toHaveBeenCalled() + expect(share).not.toHaveBeenCalled() + expect(click).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/components/assistant-ui/MarkdownTable.tsx b/web/src/components/assistant-ui/MarkdownTable.tsx new file mode 100644 index 0000000000..3867c28806 --- /dev/null +++ b/web/src/components/assistant-ui/MarkdownTable.tsx @@ -0,0 +1,1251 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ComponentPropsWithoutRef, + type RefObject, + type ReactNode, +} from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import * as Popover from '@radix-ui/react-popover' +import { CheckIcon, CloseIcon, CopyIcon, WrapIcon } from '@/components/icons' +import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' +import { Spinner } from '@/components/Spinner' +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' +import { getShareImageFileName, getShareTableFileName } from '@/lib/share-image-filename' +import { useTranslation } from '@/lib/use-translation' +import { cn } from '@/lib/utils' + +type TableProps = ComponentPropsWithoutRef<'table'> + +type IconProps = { + className?: string +} + +type TableOrientationApi = { + lock?: (orientation: 'landscape') => Promise + unlock?: () => void +} + +function ExpandIcon(props: IconProps) { + return ( + + ) +} + +function DownloadIcon(props: IconProps) { + return ( + + ) +} + +function TableActionButton(props: { + label: string + onClick: () => void + children: ReactNode + variant?: 'surface' | 'ghost' +}) { + const variantClassName = props.variant === 'ghost' + ? 'border-0 bg-transparent text-[var(--app-hint)] shadow-none hover:text-[var(--app-fg)]' + : 'border border-[var(--app-border)] bg-[var(--app-md-table-bg)]/90 text-[var(--app-hint)] shadow-sm hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]' + + return ( + + ) +} + +function isCoarsePointerDevice(): boolean { + if (typeof window === 'undefined') return false + + const coarsePointer = window.matchMedia('(pointer: coarse)').matches + const touchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints : 0 + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '' + const mobileUserAgent = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent) + const desktopModeIpad = /Macintosh/i.test(userAgent) && touchPoints > 1 + + return coarsePointer || mobileUserAgent || desktopModeIpad +} + +function TableActionMenu(props: { + label: string + children: ReactNode + tabIndex?: number + items: Array<{ + label: string + onSelect: () => void + disabled?: boolean + }> +}) { + const [open, setOpen] = useState(false) + const closeTimerRef = useRef(null) + + const clearCloseTimer = useCallback(() => { + if (closeTimerRef.current == null) return + window.clearTimeout(closeTimerRef.current) + closeTimerRef.current = null + }, []) + + const scheduleClose = useCallback(() => { + clearCloseTimer() + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null + setOpen(false) + }, 140) + }, [clearCloseTimer]) + + useEffect(() => () => clearCloseTimer(), [clearCloseTimer]) + + return ( + +
+ + + + + +
+ {props.items.map((item) => ( + + ))} +
+
+
+
+
+ ) +} + +/** Exported for responsive behavior tests and future table viewers. */ +export function isMobileTableViewerViewport(): boolean { + if (typeof window === 'undefined') return false + const shortSide = Math.min(window.innerWidth, window.innerHeight) + return shortSide <= 767 && isCoarsePointerDevice() +} + +function getTableCellText(cell: HTMLTableCellElement): string { + const innerText = cell.innerText + const text = typeof innerText === 'string' ? innerText : cell.textContent ?? '' + return text.replace(/\s+/g, ' ').trim() +} + +function escapeCsvCell(value: string): string { + const safeValue = /^[\t\r\n ]*[=+\-@]/.test(value) ? `'${value}` : value + return `"${safeValue.replace(/"/g, '""')}"` +} + +export function serializeTableToCsv(table: HTMLTableElement): string { + const rows = Array.from(table.rows).map((row) => + Array.from(row.cells).map((cell) => escapeCsvCell(getTableCellText(cell))).join(','), + ) + + return rows.length > 0 ? `\uFEFF${rows.join('\r\n')}\r\n` : '\uFEFF' +} + +function escapeMarkdownTableCell(value: string): string { + return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ') +} + +function serializeMarkdownText(value: string): string { + return value + .replace(/\s+/g, ' ') + .replace(/\\/g, '\\\\') + .replace(/([`*_\[\]<>~])/g, '\\$1') +} + +function serializeInlineMarkdown(node: Node): string { + if (node.nodeType === Node.TEXT_NODE) return serializeMarkdownText(node.textContent ?? '') + if (node.nodeType !== Node.ELEMENT_NODE) { + return Array.from(node.childNodes, serializeInlineMarkdown).join('') + } + + const element = node as HTMLElement + const children = () => Array.from(element.childNodes, serializeInlineMarkdown).join('') + switch (element.tagName.toLowerCase()) { + case 'a': { + const href = element.dataset.hapiMarkdownHref ?? element.getAttribute('href') + const label = children() + if (href?.startsWith('hapi-file:') || href?.startsWith('hapi-file-candidate:')) return label + return href ? `[${label}](${href})` : label + } + case 'code': + return serializeCodeSpan(element.textContent ?? '') + case 'strong': + case 'b': + return `**${children()}**` + case 'em': + case 'i': + return `*${children()}*` + case 'del': + case 's': + return `~~${children()}~~` + case 'br': + return ' ' + case 'img': { + const src = element.getAttribute('src') + const alt = serializeMarkdownText(element.getAttribute('alt') ?? '') + return src ? `![${alt}](${src})` : alt + } + case 'span': { + const href = element.dataset.hapiMarkdownHref + return href ? `[${children()}](${href})` : children() + } + default: + return children() + } +} + +function serializeCodeSpan(value: string): string { + const longestRun = Math.max( + 0, + ...Array.from(value.matchAll(/`+/g), (match) => match[0].length), + ) + const fence = '`'.repeat(longestRun + 1) + const needsPadding = value.startsWith('`') + || value.endsWith('`') + || (value.startsWith(' ') && value.endsWith(' ') && value.trim().length > 0) + const padding = needsPadding ? ' ' : '' + return `${fence}${padding}${value}${padding}${fence}` +} + +function getTableCellMarkdown(cell: HTMLTableCellElement): string { + return Array.from(cell.childNodes, serializeInlineMarkdown) + .join('') + .trim() +} + +function formatMarkdownTableRow(cells: string[], width: number): string { + const padded = [...cells, ...Array.from({ length: Math.max(0, width - cells.length) }, () => '')] + return `| ${padded.map(escapeMarkdownTableCell).join(' | ')} |` +} + +function getTableCellAlignment(cell: HTMLTableCellElement | undefined): 'left' | 'center' | 'right' | undefined { + const alignment = cell?.getAttribute('align') ?? cell?.style.textAlign + if (alignment === 'left' || alignment === 'center' || alignment === 'right') return alignment + return undefined +} + +function formatMarkdownAlignment(alignment: 'left' | 'center' | 'right' | undefined): string { + if (alignment === 'left') return ':---' + if (alignment === 'center') return ':---:' + if (alignment === 'right') return '---:' + return '---' +} + +export function serializeTableToMarkdown(table: HTMLTableElement): string { + const rows = Array.from(table.rows).map((row) => + Array.from(row.cells).map(getTableCellMarkdown), + ) + if (rows.length === 0) return '' + + const width = Math.max(...rows.map((row) => row.length), 1) + const header = rows[0] ?? [] + const headerCells = Array.from(table.tHead?.rows[0]?.cells ?? table.rows[0]?.cells ?? []) + const separator = Array.from({ length: width }, (_, index) => formatMarkdownAlignment(getTableCellAlignment(headerCells[index]))) + return [ + formatMarkdownTableRow(header, width), + formatMarkdownTableRow(separator, width), + ...rows.slice(1).map((row) => formatMarkdownTableRow(row, width)), + ].join('\n') + '\n' +} + +const TABLE_WRAP_PREFERENCE_PREFIX = 'hapi-table-wrap:v1' + +function hashTableWrapIdentity(value: string): string { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return (hash >>> 0).toString(16).padStart(8, '0') +} + +export function getTableWrapPreferenceKey(table: HTMLTableElement, scope = 'Table'): string { + const identity = `${scope.trim() || 'Table'}\n${serializeTableToMarkdown(table)}` + return `${TABLE_WRAP_PREFERENCE_PREFIX}:${hashTableWrapIdentity(identity)}` +} + +export function shouldWrapTableByDefault(table: HTMLTableElement, viewer: HTMLElement): boolean { + const wrappedValue = table.getAttribute('data-hapi-table-wrap') + if (wrappedValue === null) return table.scrollWidth > viewer.clientWidth + 1 + + table.removeAttribute('data-hapi-table-wrap') + try { + return table.scrollWidth > viewer.clientWidth + 1 + } finally { + table.setAttribute('data-hapi-table-wrap', wrappedValue) + } +} + +function readTableWrapPreference(key: string | undefined): boolean | null { + if (!key || typeof window === 'undefined') return null + try { + const value = window.localStorage.getItem(key) + if (value === '1') return true + if (value === '0') return false + } catch { + // Private browsing and blocked storage should not affect the viewer. + } + return null +} + +function writeTableWrapPreference(key: string | undefined, value: boolean): void { + if (!key || typeof window === 'undefined') return + try { + window.localStorage.setItem(key, value ? '1' : '0') + } catch { + // Ignore storage quota and privacy-mode failures. + } +} + +export function downloadTableAsCsv(table: HTMLTableElement, filename = 'hapi-table.csv'): void { + if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return + + const blob = new Blob([serializeTableToCsv(table)], { type: 'text/csv;charset=utf-8' }) + downloadBlob(blob, filename) +} + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + // Match the working session-image download path. Some mobile browsers + // read a Blob URL asynchronously after click; revoking it immediately + // makes the download appear to do nothing. + window.setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +const TABLE_IMAGE_STYLE_PROPERTIES = [ + 'background-color', + 'border-bottom-color', + 'border-bottom-style', + 'border-bottom-width', + 'border-collapse', + 'border-left-color', + 'border-left-style', + 'border-left-width', + 'border-right-color', + 'border-right-style', + 'border-right-width', + 'border-spacing', + 'border-top-color', + 'border-top-style', + 'border-top-width', + 'box-sizing', + 'color', + 'font-family', + 'font-size', + 'font-style', + 'font-weight', + 'letter-spacing', + 'line-height', + 'overflow-wrap', + 'padding-bottom', + 'padding-left', + 'padding-right', + 'padding-top', + 'text-align', + 'text-decoration', + 'text-transform', + 'vertical-align', + 'white-space', + 'word-break', +] as const + +function copyTableImageStyles(source: HTMLTableElement, clone: HTMLTableElement): void { + const sourceElements = [source, ...Array.from(source.querySelectorAll('*'))] + const cloneElements = [clone, ...Array.from(clone.querySelectorAll('*'))] + + sourceElements.forEach((sourceElement, index) => { + const cloneElement = cloneElements[index] + if (!cloneElement) return + const cloneStyle = (cloneElement as Element & { style?: CSSStyleDeclaration }).style + if (!cloneStyle) return + const computedStyle = getComputedStyle(sourceElement) + for (const property of TABLE_IMAGE_STYLE_PROPERTIES) { + cloneStyle.setProperty(property, computedStyle.getPropertyValue(property), 'important') + } + }) +} + +function getTableColumnWidths(table: HTMLTableElement): number[] { + const widths: number[] = [] + for (const row of Array.from(table.rows)) { + let columnIndex = 0 + for (const cell of Array.from(row.cells)) { + const span = Math.max(1, cell.colSpan || 1) + const cellWidth = cell.getBoundingClientRect().width + if (cellWidth > 0 && Number.isFinite(cellWidth)) { + const columnWidth = cellWidth / span + for (let offset = 0; offset < span; offset += 1) { + const index = columnIndex + offset + widths[index] = Math.max(widths[index] ?? 0, columnWidth) + } + } + columnIndex += span + } + } + return widths +} + +function applyTableImageColumnWidths(table: HTMLTableElement, clone: HTMLTableElement, tableWidth: number): void { + const measuredWidths = getTableColumnWidths(table) + const totalMeasuredWidth = measuredWidths.reduce((total, width) => total + width, 0) + if (measuredWidths.length === 0 || totalMeasuredWidth <= 0) return + + const widthScale = tableWidth / totalMeasuredWidth + const colgroup = document.createElement('colgroup') + for (const measuredWidth of measuredWidths) { + const col = document.createElement('col') + col.style.width = `${measuredWidth * widthScale}px` + colgroup.appendChild(col) + } + clone.querySelectorAll(':scope > colgroup').forEach((existing) => existing.remove()) + clone.insertBefore(colgroup, clone.firstChild) + clone.style.setProperty('table-layout', 'fixed', 'important') +} + +function isTransparentColor(value: string): boolean { + return value === '' || value === 'transparent' || /rgba\([^)]*,\s*0\s*\)$/i.test(value) +} + +function getTableHeaderBackground(table: HTMLTableElement): string | null { + const head = table.tHead + if (!head) return null + const computedBackground = getComputedStyle(head).backgroundColor + if (!isTransparentColor(computedBackground)) return computedBackground + + let element: Element | null = head + while (element) { + const value = getComputedStyle(element).getPropertyValue('--app-md-table-head-bg').trim() + if (value) return value + element = element.parentElement + } + return getComputedStyle(document.documentElement).getPropertyValue('--app-md-table-head-bg').trim() || null +} + +function applyTableImageHeaderBackground(table: HTMLTableElement, clone: HTMLTableElement): void { + const background = getTableHeaderBackground(table) + const cloneHead = clone.tHead + if (!background || !cloneHead) return + + cloneHead.style.setProperty('background-color', background, 'important') + cloneHead.querySelectorAll(':is(th, td)').forEach((cell) => { + if (!(cell instanceof HTMLElement)) return + if (!isTransparentColor(cell.style.getPropertyValue('background-color'))) return + cell.style.setProperty('background-color', background, 'important') + }) +} + +function createStaticTableImageClone( + table: HTMLTableElement, + tableWidth: number, + tableHeight: number, + tileTop = 0, + tileHeight?: number, +): { + table: HTMLTableElement + capture: HTMLElement + cleanup: () => void +} { + const wrapper = document.createElement('div') + wrapper.dataset.hapiTableImageRender = 'true' + Object.assign(wrapper.style, { + position: 'fixed', + left: '-100000px', + top: '0', + width: `${tableWidth}px`, + maxWidth: 'none', + height: tileHeight == null ? 'auto' : `${tileHeight}px`, + overflow: tileHeight == null ? 'visible' : 'hidden', + pointerEvents: 'none', + }) + + const clone = table.cloneNode(true) as HTMLTableElement + clone.style.setProperty('width', `${tableWidth}px`, 'important') + clone.style.setProperty('min-width', `${tableWidth}px`, 'important') + clone.style.setProperty('height', `${tableHeight}px`, 'important') + copyTableImageStyles(table, clone) + applyTableImageColumnWidths(table, clone, tableWidth) + applyTableImageHeaderBackground(table, clone) + if (tileHeight != null && tileTop > 0) { + clone.style.setProperty('transform', `translateY(-${tileTop}px)`, 'important') + } + clone.querySelectorAll('thead, thead *').forEach((element) => { + if (!(element instanceof HTMLElement)) return + element.style.setProperty('position', 'static', 'important') + element.style.removeProperty('top') + element.style.removeProperty('z-index') + }) + + wrapper.appendChild(clone) + document.body.appendChild(wrapper) + return { + table: clone, + capture: tileHeight == null ? clone : wrapper, + cleanup: () => wrapper.remove(), + } +} + +export const MAX_TABLE_EXPORT_PIXELS = 36_000_000 +export const MAX_TABLE_EXPORT_TILE_PIXELS = 12_000_000 +export const MAX_TABLE_EXPORT_DIMENSION = 16_384 + +export function getTableExportScale( + tableWidth: number, + tableHeight: number, + devicePixelRatio = typeof window === 'undefined' ? 1 : window.devicePixelRatio, +): number { + const area = Math.max(1, tableWidth) * Math.max(1, tableHeight) + const dimensionScale = MAX_TABLE_EXPORT_DIMENSION / Math.max(tableWidth, tableHeight) + return Math.min( + devicePixelRatio || 1, + 2, + Math.sqrt(MAX_TABLE_EXPORT_PIXELS / area), + dimensionScale, + ) +} + +export function getTableExportTileHeight(tableWidth: number, scale: number): number { + return Math.max(1, Math.floor( + MAX_TABLE_EXPORT_TILE_PIXELS / Math.max(1, tableWidth * scale * scale), + )) +} + +export function getTableExportHeight(table: HTMLTableElement): number { + const tableRect = table.getBoundingClientRect() + const fallbackHeight = Math.max(table.scrollHeight, Math.ceil(tableRect.height), 1) + const rowBottoms = Array.from(table.rows) + .map((row) => row.getBoundingClientRect()) + .filter((rowRect) => rowRect.height > 0 && Number.isFinite(rowRect.bottom)) + .map((rowRect) => rowRect.bottom - tableRect.top) + if (rowBottoms.length === 0) return fallbackHeight + + const contentHeight = Math.ceil(Math.max(...rowBottoms)) + return contentHeight > 0 ? Math.min(fallbackHeight, contentHeight) : fallbackHeight +} + +function canvasToPngBlob(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve) => canvas.toBlob(resolve, 'image/png')).then((blob) => { + if (!blob) throw new Error('Failed to encode table image') + return blob + }) +} + +export async function renderTableAsImage(table: HTMLTableElement): Promise { + if (typeof document === 'undefined') throw new Error('Cannot render a table outside the browser') + + const { default: html2canvas } = await import('html2canvas-pro') + const tableRect = table.getBoundingClientRect() + const tableWidth = Math.max(table.scrollWidth, Math.ceil(tableRect.width), 1) + const tableHeight = getTableExportHeight(table) + const tableBackground = getComputedStyle(table).backgroundColor + const backgroundColor = tableBackground === 'rgba(0, 0, 0, 0)' + ? getComputedStyle(document.body).backgroundColor + : tableBackground + const scale = getTableExportScale(tableWidth, tableHeight) + const rasterPixels = tableWidth * tableHeight * scale * scale + const renderOptions = { + backgroundColor: backgroundColor || null, + foreignObjectRendering: false, + logging: false, + scale, + useCORS: true, + windowWidth: Math.max(document.documentElement.clientWidth, tableWidth), + windowHeight: Math.max(document.documentElement.clientHeight, tableHeight), + } + + if (rasterPixels <= MAX_TABLE_EXPORT_TILE_PIXELS) { + const imageTable = createStaticTableImageClone(table, tableWidth, tableHeight) + try { + const canvas = await html2canvas(imageTable.capture, { + ...renderOptions, + width: tableWidth, + height: tableHeight, + }) + return canvasToPngBlob(canvas) + } finally { + imageTable.cleanup() + } + } + + const outputCanvas = document.createElement('canvas') + outputCanvas.width = Math.max(1, Math.ceil(tableWidth * scale)) + outputCanvas.height = Math.max(1, Math.ceil(tableHeight * scale)) + const outputContext = outputCanvas.getContext('2d') + if (!outputContext) throw new Error('Failed to create table image canvas') + + const tileHeight = getTableExportTileHeight(tableWidth, scale) + for (let tileTop = 0; tileTop < tableHeight; tileTop += tileHeight) { + const currentTileHeight = Math.min(tileHeight, tableHeight - tileTop) + const imageTable = createStaticTableImageClone(table, tableWidth, tableHeight, tileTop, currentTileHeight) + try { + const tileCanvas = await html2canvas(imageTable.capture, { + ...renderOptions, + width: tableWidth, + height: currentTileHeight, + windowHeight: Math.max(document.documentElement.clientHeight, currentTileHeight), + }) + const destinationTop = Math.round(tileTop * scale) + const destinationBottom = tileTop + currentTileHeight >= tableHeight + ? outputCanvas.height + : Math.round((tileTop + currentTileHeight) * scale) + outputContext.drawImage( + tileCanvas, + 0, + 0, + tileCanvas.width, + tileCanvas.height, + 0, + destinationTop, + outputCanvas.width, + Math.max(1, destinationBottom - destinationTop), + ) + } finally { + imageTable.cleanup() + } + } + + return canvasToPngBlob(outputCanvas) +} + +export async function saveTableAsImage(table: HTMLTableElement, filename = getShareImageFileName('Table', 'table')): Promise { + const blob = await renderTableAsImage(table) + await downloadBlob(blob, filename) +} + +function writeTableImageToClipboard( + mimeType: string, + image: Blob | PromiseLike, +): Promise { + try { + const ClipboardItemCtor = window.ClipboardItem + if (!navigator.clipboard?.write || !ClipboardItemCtor) { + throw new Error('Image clipboard is not supported in this browser') + } + return Promise.resolve(navigator.clipboard.write([ + new ClipboardItemCtor({ [mimeType]: image }), + ])) + } catch (error) { + return Promise.reject(error) + } +} + +export function copyTableImageToClipboard(blob: Blob): Promise { + return writeTableImageToClipboard(blob.type, blob) +} + +export function copyTableImagePromiseToClipboard(imagePromise: Promise): Promise { + return writeTableImageToClipboard('image/png', imagePromise) +} + +/** + * Mobile browsers generally only honor orientation locks from fullscreen. + * Keep this best-effort so unsupported browsers still get the full table view. + */ +export async function enterMobileTableViewer(): Promise { + if (typeof document === 'undefined') return false + + let enteredFullscreen = false + const root = document.documentElement + if (!document.fullscreenElement && typeof root.requestFullscreen === 'function') { + try { + await root.requestFullscreen() + enteredFullscreen = true + } catch { + // Fullscreen can be denied by browser policy; keep the viewer usable. + } + } + + const orientation = typeof window !== 'undefined' + ? window.screen.orientation as unknown as TableOrientationApi | undefined + : undefined + if (orientation && typeof orientation.lock === 'function') { + try { + void orientation.lock('landscape').catch(() => undefined) + } catch { + // Orientation lock is unavailable on some browsers and iOS versions. + } + } + + return enteredFullscreen +} + +export function leaveMobileTableViewer(enteredFullscreen: boolean): void { + if (typeof window !== 'undefined') { + const orientation = window.screen.orientation as unknown as TableOrientationApi | undefined + if (orientation && typeof orientation.unlock === 'function') { + try { + orientation.unlock() + } catch { + // Ignore browsers that reject unlock after an interrupted rotation. + } + } + } + + if (enteredFullscreen && typeof document !== 'undefined' && typeof document.exitFullscreen === 'function') { + void document.exitFullscreen().catch(() => { + // The user may already have exited browser fullscreen manually. + }) + } +} + +function TableViewer(props: { + open: boolean + onClose: () => void + tableProps: TableProps + tableRef: RefObject + imageTitle: string + tableWrapPreferenceKey?: string +}) { + const { t } = useTranslation() + const { className, children, ...rest } = props.tableProps + const { copied, copy, markCopied } = useCopyToClipboard() + const [imageAction, setImageAction] = useState<'copy' | 'download' | null>(null) + const [imageError, setImageError] = useState(false) + const [wrapEnabled, setWrapEnabled] = useState(false) + const [toolbarVisible, setToolbarVisible] = useState(true) + const viewerRef = useRef(null) + const toolbarRef = useRef(null) + const lastScrollTopRef = useRef(0) + const reverseScrollDistanceRef = useRef(0) + const toolbarVisibleRef = useRef(true) + const explicitWrapRef = useRef(false) + const isMobileViewer = isMobileTableViewerViewport() + + const setToolbarState = useCallback((visible: boolean) => { + if (toolbarVisibleRef.current === visible) return + toolbarVisibleRef.current = visible + setToolbarVisible(visible) + }, []) + + const handleViewerScroll = useCallback(() => { + const viewer = viewerRef.current + if (!viewer) return + + const scrollTop = viewer.scrollTop + const previousScrollTop = lastScrollTopRef.current + lastScrollTopRef.current = scrollTop + const delta = scrollTop - previousScrollTop + + if (scrollTop <= 0) { + reverseScrollDistanceRef.current = 0 + setToolbarState(true) + } else if (delta > 0) { + reverseScrollDistanceRef.current = 0 + const toolbarHeight = toolbarRef.current?.getBoundingClientRect().height ?? 0 + const distanceToBottom = Math.max(0, viewer.scrollHeight - viewer.clientHeight - scrollTop) + setToolbarState(toolbarHeight > 0 && distanceToBottom < toolbarHeight) + } else if (delta < 0 && !toolbarVisibleRef.current) { + reverseScrollDistanceRef.current += -delta + const distanceToBottom = Math.max(0, viewer.scrollHeight - viewer.clientHeight - scrollTop) + if (reverseScrollDistanceRef.current >= 12 && distanceToBottom > 8) { + reverseScrollDistanceRef.current = 0 + setToolbarState(true) + } + } else if (delta >= 0) { + reverseScrollDistanceRef.current = 0 + } + }, [setToolbarState]) + + const setViewerElement = useCallback((viewer: HTMLDivElement | null) => { + const previousViewer = viewerRef.current + if (previousViewer) previousViewer.removeEventListener('scroll', handleViewerScroll) + + viewerRef.current = viewer + if (!viewer) return + + reverseScrollDistanceRef.current = 0 + lastScrollTopRef.current = viewer.scrollTop + setToolbarState(viewer.scrollTop <= 0) + viewer.addEventListener('scroll', handleViewerScroll, { passive: true }) + }, [handleViewerScroll, setToolbarState]) + + useEffect(() => { + if (!props.open) { + lastScrollTopRef.current = 0 + reverseScrollDistanceRef.current = 0 + setToolbarState(true) + } + }, [props.open, setToolbarState]) + + useEffect(() => () => { + viewerRef.current?.removeEventListener('scroll', handleViewerScroll) + }, [handleViewerScroll]) + + useEffect(() => { + if (!props.open) { + setWrapEnabled(false) + setImageError(false) + } + }, [props.open]) + + useLayoutEffect(() => { + if (!props.open) { + explicitWrapRef.current = false + return undefined + } + + const storedPreference = readTableWrapPreference(props.tableWrapPreferenceKey) + explicitWrapRef.current = storedPreference !== null + if (storedPreference !== null) { + setWrapEnabled(storedPreference) + return undefined + } + + const measureOverflow = () => { + if (explicitWrapRef.current) return + const viewer = viewerRef.current + const table = props.tableRef.current + if (!viewer || !table) return + const shouldWrap = shouldWrapTableByDefault(table, viewer) + setWrapEnabled(shouldWrap) + } + + let observer: ResizeObserver | undefined + const frame = window.requestAnimationFrame(() => { + measureOverflow() + const viewer = viewerRef.current + if (viewer && typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(measureOverflow) + observer.observe(viewer) + } + }) + window.addEventListener('resize', measureOverflow) + return () => { + window.cancelAnimationFrame(frame) + observer?.disconnect() + window.removeEventListener('resize', measureOverflow) + } + }, [props.open, props.tableRef, props.tableWrapPreferenceKey]) + + const handleWrapToggle = useCallback(() => { + const nextValue = !wrapEnabled + explicitWrapRef.current = true + setWrapEnabled(nextValue) + writeTableWrapPreference(props.tableWrapPreferenceKey, nextValue) + }, [props.tableWrapPreferenceKey, wrapEnabled]) + + const handleDownload = useCallback(() => { + if (props.tableRef.current) { + downloadTableAsCsv(props.tableRef.current, getShareTableFileName(props.imageTitle, 'csv')) + } + }, [props.imageTitle, props.tableRef]) + + const handleCopyMarkdown = useCallback(() => { + if (props.tableRef.current) { + void copy(serializeTableToMarkdown(props.tableRef.current)) + } + }, [copy, props.tableRef]) + + const getPreparedImage = useCallback((table: HTMLTableElement): Promise => { + return renderTableAsImage(table) + }, []) + + const handleSaveImage = useCallback(() => { + const table = props.tableRef.current + if (!table || imageAction) return + + setImageError(false) + setImageAction('download') + const filename = getShareImageFileName(props.imageTitle, 'table') + void getPreparedImage(table) + .then((blob) => downloadBlob(blob, filename)) + .catch(() => setImageError(true)) + .finally(() => setImageAction(null)) + }, [getPreparedImage, imageAction, props.imageTitle, props.tableRef]) + + const handleCopyImage = useCallback(() => { + const table = props.tableRef.current + if (!table || imageAction) return + + setImageError(false) + setImageAction('copy') + const imagePromise = getPreparedImage(table) + void copyTableImagePromiseToClipboard(imagePromise) + .then(() => markCopied()) + .catch(() => setImageError(true)) + .finally(() => setImageAction(null)) + }, [getPreparedImage, imageAction, markCopied, props.tableRef]) + + const viewerTitle = props.imageTitle.trim() || t('table.viewerTitle') + + return ( + { + if (!nextOpen) props.onClose() + }} + > + + + + {imageAction ? ( +
+ + {t(imageAction === 'copy' ? 'table.copyingImage' : 'table.savingImage')} +
+ ) : null} + {imageError ? ( +
+ {t('table.imageActionFailed')} +
+ ) : null} + + {viewerTitle} + + + {viewerTitle} + + +
+ +
+ {viewerTitle} +
+
+ + {isMobileViewer ? ( + + ) : ( + + {copied ? : } + + )} + + + +
+
+ +
+ + {children} +
+
+
+
+
+ ) +} + +/** Reuse the same table viewer for tables rendered inside a share preview. */ +export function TableViewerFromElement(props: { + open: boolean + onClose: () => void + table: HTMLTableElement + imageTitle: string +}) { + const tableRef = useRef(null) + const tableProps: TableProps = { + className: cn(props.table.getAttribute('class'), 'w-max min-w-full'), + dangerouslySetInnerHTML: { __html: props.table.innerHTML }, + } + + return ( + + ) +} + +export function MarkdownTable(props: TableProps) { + const { t } = useTranslation() + const chatContext = useOptionalHappyChatContext() + const { className, children, ...rest } = props + const inlineTableRef = useRef(null) + const inlineActionsRef = useRef(null) + const viewerTableRef = useRef(null) + const tableWrapPreferenceKeyRef = useRef(undefined) + const [viewerOpen, setViewerOpen] = useState(false) + const openRef = useRef(false) + const mobileViewerRef = useRef(false) + const enteredFullscreenRef = useRef(false) + const imageTitle = chatContext?.sessionTitle?.trim() || t('table.viewerTitle') + const tableWrapScope = chatContext?.sessionId ?? imageTitle + + const closeViewer = useCallback(() => { + openRef.current = false + setViewerOpen(false) + + if (mobileViewerRef.current) { + mobileViewerRef.current = false + const enteredFullscreen = enteredFullscreenRef.current + enteredFullscreenRef.current = false + leaveMobileTableViewer(enteredFullscreen) + } + }, []) + + const openViewer = useCallback(() => { + openRef.current = true + const table = inlineTableRef.current + tableWrapPreferenceKeyRef.current = table + ? getTableWrapPreferenceKey(table, tableWrapScope) + : undefined + setViewerOpen(true) + + const isMobile = isMobileTableViewerViewport() + mobileViewerRef.current = isMobile + if (!isMobile) return + + void enterMobileTableViewer().then((enteredFullscreen) => { + if (!openRef.current) { + leaveMobileTableViewer(enteredFullscreen) + return + } + enteredFullscreenRef.current = enteredFullscreen + }) + }, [tableWrapScope]) + + useEffect(() => { + if (typeof document === 'undefined') return undefined + + const handleFullscreenChange = () => { + if (!openRef.current || !mobileViewerRef.current || !enteredFullscreenRef.current) return + if (document.fullscreenElement) return + + leaveMobileTableViewer(false) + enteredFullscreenRef.current = false + mobileViewerRef.current = false + openRef.current = false + setViewerOpen(false) + } + + document.addEventListener('fullscreenchange', handleFullscreenChange) + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange) + }, []) + + useEffect(() => () => { + const wasMobile = mobileViewerRef.current + const enteredFullscreen = enteredFullscreenRef.current + openRef.current = false + mobileViewerRef.current = false + enteredFullscreenRef.current = false + if (wasMobile) leaveMobileTableViewer(enteredFullscreen) + }, []) + + useLayoutEffect(() => { + const row = inlineTableRef.current?.tHead?.rows[0] + const actions = inlineActionsRef.current + if (!row || !actions) return undefined + + const syncHeight = () => { + const height = row.getBoundingClientRect().height + if (height > 0) actions.style.height = `${height}px` + } + + syncHeight() + if (typeof ResizeObserver === 'undefined') return undefined + const observer = new ResizeObserver(syncHeight) + observer.observe(row) + return () => observer.disconnect() + }, [children]) + + const tableProps = { ...rest, className, children } + + return ( + <> +
+
+ + {children} +
+
+
+ + + +
+
+ + + + ) +} diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 34c9968981..5c2a92114e 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -31,6 +31,7 @@ import { classifyNoSchemeHref } from '@/lib/markdown-href-policy' import { remarkSessionPathLinks } from '@/lib/remark-session-path-links' import { buildSessionReferencePath, parseSessionPathHref } from '@/lib/sessionReference' import { UriConfirmDialog } from '@/components/UriConfirmDialog' +import { MarkdownTable } from '@/components/assistant-ui/MarkdownTable' import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown' @@ -504,6 +505,7 @@ function Code(props: ComponentPropsWithoutRef<'code'>) { function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: string; sessionId: string }) { const { filePath, sessionId, ...anchorProps } = props + const markdownHref = getMarkdownSourceHref(anchorProps, anchorProps.href) const navigate = useNavigate() const rel = anchorProps.target === '_blank' ? (anchorProps.rel ?? 'noreferrer') : anchorProps.rel const search = new URLSearchParams({ path: encodeBase64(filePath), origin: 'chat' }).toString() @@ -530,6 +532,7 @@ function FilePathAnchor(props: ComponentPropsWithoutRef<'a'> & { filePath: strin & { filePath: strin function SessionPathAnchor(props: ComponentPropsWithoutRef<'a'> & { targetSessionId: string }) { const navigate = useNavigate() const rel = props.target === '_blank' ? (props.rel ?? 'noreferrer') : props.rel + const markdownHref = getMarkdownSourceHref(props, props.href) // Preserve Vite BASE_URL for copy / open-in-new-tab (SPA click uses navigate). const href = buildSessionReferencePath(props.targetSessionId) @@ -560,6 +564,7 @@ function SessionPathAnchor(props: ComponentPropsWithoutRef<'a'> & { targetSessio & { targetSessio * which uses useNavigate for SPA routing. * - Session citation paths (`/sessions/`): SessionPathAnchor SPA navigation. */ -function InertMarkdownHref(props: { href: string; children?: ReactNode; className?: string }) { +function getMarkdownSourceHref(props: object, fallback: string | undefined): string | undefined { + const sourceHref = (props as Record)['data-hapi-markdown-href'] + return typeof sourceHref === 'string' ? sourceHref : fallback +} + +function InertMarkdownHref(props: { href: string; markdownHref?: string; children?: ReactNode; className?: string }) { // Plain/muted — intentionally not an , so middle-click / copy-link can't // invent a dead SPA route either. return ( {props.children} @@ -630,6 +641,7 @@ function A(props: ComponentPropsWithoutRef<'a'>) { } const { onClick, href, ...rest } = props + const markdownHref = getMarkdownSourceHref(rest, href) // Windows candidate (or raw / %5C-normalized drive path): classify with workspace // before painting FilePathAnchor or treating `C:` as a custom URI scheme. @@ -638,7 +650,7 @@ function A(props: ComponentPropsWithoutRef<'a'>) { const isCandidateHref = href ? normalizedScheme(href) === 'hapi-file-candidate' : false if (isCandidateHref && (!candidatePath || !/^[A-Za-z]:[\\/]/.test(candidatePath))) { return ( - + {props.children} ) @@ -665,12 +677,12 @@ function A(props: ComponentPropsWithoutRef<'a'>) { }) if (decision.action === 'file') { if (!chat) { - return {props.children} + return {props.children} } return } if (decision.action === 'inert') { - return {props.children} + return {props.children} } // action === 'navigate' → fall through (only for non-Windows scheme-less SPA) } @@ -727,6 +739,7 @@ function A(props: ComponentPropsWithoutRef<'a'>) { ) { return
} -function Table(props: ComponentPropsWithoutRef<'table'>) { - const { className, ...rest } = props - - return ( -
- - - ) -} - function Thead(props: ComponentPropsWithoutRef<'thead'>) { return } @@ -868,7 +871,7 @@ export const defaultComponents: DefaultComponentsMap = memoizeMarkdownComponents ol: OrderedList, li: ListItem, hr: Hr, - table: Table, + table: MarkdownTable, thead: Thead, tbody: Tbody, tr: Tr, diff --git a/web/src/hooks/useCopyToClipboard.ts b/web/src/hooks/useCopyToClipboard.ts index dee2666fa1..9386761f71 100644 --- a/web/src/hooks/useCopyToClipboard.ts +++ b/web/src/hooks/useCopyToClipboard.ts @@ -6,18 +6,22 @@ export function useCopyToClipboard(resetDelay = 1500) { const [copied, setCopied] = useState(false) const { haptic } = usePlatform() + const markCopied = useCallback(() => { + setCopied(true) + setTimeout(() => setCopied(false), resetDelay) + }, [resetDelay]) + const copy = useCallback(async (text: string) => { try { await safeCopyToClipboard(text) haptic.notification('success') - setCopied(true) - setTimeout(() => setCopied(false), resetDelay) + markCopied() return true } catch { haptic.notification('error') return false } - }, [haptic, resetDelay]) + }, [haptic, markCopied]) - return { copied, copy } + return { copied, copy, markCopied } } diff --git a/web/src/index.css b/web/src/index.css index 2e5a087dab..64f86e456a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -445,8 +445,8 @@ html:has(.chat-scroll-y) #root { .markdown-content code { background: var(--app-inline-code-bg); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.9em; } .markdown-content blockquote { border-left: 3px solid var(--app-hint); padding-left: 0.75rem; margin: 0.5rem 0; opacity: 0.85; } .markdown-content ul, .markdown-content ol { padding-left: 1.5rem; margin: 0.5rem 0; } -.markdown-content table { border-collapse: collapse; width: 100%; } -.markdown-content th, .markdown-content td { border: 1px solid var(--app-border); padding: 0.25rem 0.5rem; } +.markdown-content table:not(.aui-md-table) { border-collapse: collapse; width: 100%; } +.markdown-content th:not(.aui-md-th), .markdown-content td:not(.aui-md-td) { border: 1px solid var(--app-border); padding: 0.25rem 0.5rem; } .aui-md { color: var(--app-fg); @@ -479,6 +479,79 @@ html:has(.chat-scroll-y) #root { margin-top: max(0px, calc((var(--app-chat-line-height) - var(--app-message-action-size)) / 2)); } +/* Markdown tables keep their header as the first table row. Table actions sit + * in the top-right corner of that row, so they do not create a blank shelf. */ +.aui-md-table-frame { + position: relative; +} + +.aui-md-table-frame:has(> .aui-md-table-actions) .aui-md-table > thead > tr > :last-child { + padding-right: 5rem; +} + +.aui-md-table-actions { + position: absolute; + top: 0; + right: 0.1875rem; + line-height: normal; + min-height: calc(1lh + 1rem); + align-items: center; + z-index: 2; + transition: opacity 150ms ease; +} + +/* Desktop fonts make the Chinese title's visual center sit a pixel lower than + * the icon row; keep the mobile toolbar unchanged. */ +@media (hover: hover) and (pointer: fine) { + [data-hapi-table-viewer-heading="true"] { + transform: translateY(-1px); + } + + [data-hapi-table-viewer-toolbar="true"] { + padding-top: 0; + padding-bottom: 0; + } +} + +@media (hover: hover) and (pointer: fine) { + .aui-md-table-actions { + opacity: 0; + pointer-events: none; + } + + .aui-md-table-shell:hover .aui-md-table-actions, + .aui-md-table-shell:focus-within .aui-md-table-actions { + opacity: 1; + pointer-events: auto; + } +} + +/* Keep the header cells sticky instead of the whole thead. Sticky table + * sections have inconsistent painting behavior across mobile WebViews; the + * cells also let the header cover the viewer's top padding without leaving a + * visible gap while the table is being scrolled. */ +[data-hapi-table-viewer="true"] .aui-md-thead > tr > :is(th, td) { + position: sticky; + top: 0; + z-index: 2; + background: var(--app-md-table-head-bg); +} + +/* Fullscreen tables keep their readable, horizontally scrollable layout by + * default. The toolbar wrap toggle switches to a fixed-width table and lets + * every cell break long content so the viewport has no horizontal scrollbar. */ +[data-hapi-table-wrap="true"] { + width: 100% !important; + min-width: 0 !important; + table-layout: fixed !important; +} + +[data-hapi-table-wrap="true"] :is(th, td) { + white-space: normal !important; + overflow-wrap: anywhere; + word-break: break-word; +} + @media (hover: hover) and (pointer: fine) { .happy-message-actions { opacity: 0; diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 2cccf284e8..1ebee733db 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -320,6 +320,25 @@ export default { 'mermaid.loading': 'Loading diagram…', 'mermaid.renderError': 'Could not render diagram.', + // Markdown tables + 'table.openFullscreen': 'Open table full screen', + 'table.closeFullscreen': 'Close table full screen', + 'table.viewerTitle': 'Table', + 'table.copyMarkdown': 'Copy Markdown', + 'table.copyMarkdownButton': 'Copy table as Markdown', + 'table.copiedMarkdown': 'Table Markdown copied', + 'table.copy': 'Copy table', + 'table.copyImage': 'Copy image', + 'table.copyingImage': 'Copying image…', + 'table.imageActionFailed': 'Could not process table image.', + 'table.wrap.enable': 'Enable table wrapping', + 'table.wrap.disable': 'Disable table wrapping', + 'table.saveImage': 'Save table as image', + 'table.savingImage': 'Saving image…', + 'table.download': 'Download table', + 'table.downloadPng': 'Download PNG', + 'table.downloadCsv': 'Download CSV', + // Common buttons 'button.cancel': 'Cancel', 'button.save': 'Save', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2c2aea59eb..01bf1f53e9 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -324,6 +324,25 @@ export default { 'mermaid.loading': '正在加载图表…', 'mermaid.renderError': '无法渲染图表。', + // Markdown tables + 'table.openFullscreen': '横向全屏查看表格', + 'table.closeFullscreen': '关闭表格全屏', + 'table.viewerTitle': '表格', + 'table.copyMarkdown': '复制 Markdown', + 'table.copyMarkdownButton': '复制表格 Markdown', + 'table.copiedMarkdown': '表格 Markdown 已复制', + 'table.copy': '复制表格', + 'table.copyImage': '复制图片', + 'table.copyingImage': '复制图片中…', + 'table.imageActionFailed': '表格图片处理失败。', + 'table.wrap.enable': '开启表格换行', + 'table.wrap.disable': '关闭表格换行', + 'table.saveImage': '保存表格为图片', + 'table.savingImage': '保存图片中…', + 'table.download': '下载表格', + 'table.downloadPng': '下载 PNG', + 'table.downloadCsv': '下载 CSV', + // Common buttons 'button.cancel': '取消', 'button.save': '保存', diff --git a/web/src/lib/remark-file-path-links.test.ts b/web/src/lib/remark-file-path-links.test.ts index 8b74879fa9..626dd8e66f 100644 --- a/web/src/lib/remark-file-path-links.test.ts +++ b/web/src/lib/remark-file-path-links.test.ts @@ -6,6 +6,9 @@ type TestNode = { value?: string url?: string children?: TestNode[] + data?: { + hProperties?: Record + } } function transform(text: string): TestNode[] { @@ -181,7 +184,9 @@ describe('remarkFilePathLinks — explicit markdown links', () => { it('rewrites a relative link with a #fragment, stripping it from the target', () => { const nodes = transformNodes([linkNode('docs/foo.md#section')]) - expect(linkedPath(nodes.find((n) => n.type === 'link')!)).toBe('docs/foo.md') + const link = nodes.find((n) => n.type === 'link')! + expect(linkedPath(link)).toBe('docs/foo.md') + expect(link.data?.hProperties?.['data-hapi-markdown-href']).toBe('docs/foo.md#section') }) it('does not rewrite POSIX absolute file links (containment needs session cwd in )', () => { diff --git a/web/src/lib/remark-file-path-links.ts b/web/src/lib/remark-file-path-links.ts index 21e3e2aa30..91d76a72c0 100644 --- a/web/src/lib/remark-file-path-links.ts +++ b/web/src/lib/remark-file-path-links.ts @@ -28,6 +28,9 @@ type MarkdownNode = { url?: string title?: string | null children?: MarkdownNode[] + data?: { + hProperties?: Record + } } function createFileHref(path: string): string { @@ -233,6 +236,13 @@ function rewriteFileLinkNode(node: MarkdownNode): void { // backslashes survive mdast→hast URI normalization. if (isWindowsAbsolutePath(target)) { if (!hasKnownFileExtension(target)) return + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + 'data-hapi-markdown-href': url, + }, + } node.url = createFileCandidateHref(target) return } @@ -241,6 +251,13 @@ function rewriteFileLinkNode(node: MarkdownNode): void { if (!shouldLinkPath(target)) return + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + 'data-hapi-markdown-href': url, + }, + } node.url = createFileHref(target) } diff --git a/web/src/lib/share-image-filename.test.ts b/web/src/lib/share-image-filename.test.ts new file mode 100644 index 0000000000..a17b30b8a9 --- /dev/null +++ b/web/src/lib/share-image-filename.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { getShareImageFileName, getShareTableFileName, sanitizeShareFileNamePart } from './share-image-filename' + +describe('share image filenames', () => { + const timestamp = new Date(2026, 7, 18, 10, 27, 56) + + it('keeps turn image filenames compatible with the existing HAPI convention', () => { + expect(getShareImageFileName('标题', 'turn', timestamp)).toBe('HAPI-标题-20260818102756.png') + }) + + it('adds the Table discriminator while retaining the conversation title and timestamp', () => { + expect(getShareImageFileName('标题', 'table', timestamp)).toBe('HAPI Table-标题-20260818102756.png') + }) + + it('uses the same table title and timestamp for CSV downloads', () => { + expect(getShareTableFileName('标题', 'csv', timestamp)).toBe('HAPI Table-标题-20260818102756.csv') + }) + + it('sanitizes unsafe title characters before creating a filename', () => { + expect(sanitizeShareFileNamePart(' report:/ Q3 ')).toBe('report- Q3') + }) +}) diff --git a/web/src/lib/share-image-filename.ts b/web/src/lib/share-image-filename.ts new file mode 100644 index 0000000000..514adf4fcd --- /dev/null +++ b/web/src/lib/share-image-filename.ts @@ -0,0 +1,46 @@ +export type ShareImageKind = 'turn' | 'table' + +export function formatShareTimestamp(date = new Date()): string { + const pad = (value: number) => String(value).padStart(2, '0') + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()) + ].join('') +} + +export function sanitizeShareFileNamePart(title: string): string { + const withoutControlCharacters = Array.from(title.normalize('NFKC')) + .filter((character) => { + const codePoint = character.codePointAt(0) ?? 0 + return codePoint >= 32 && codePoint !== 127 + }) + .join('') + const sanitized = withoutControlCharacters + .replace(/[<>:"/\\|?*]+/g, '-') + .replace(/\s+/g, ' ') + .replace(/-+/g, '-') + .replace(/^[ .-]+|[ .-]+$/g, '') + .trim() + return Array.from(sanitized || 'Shared turn').slice(0, 80).join('').trim() +} + +export function getShareImageFileName( + title: string, + kind: ShareImageKind = 'turn', + date = new Date(), +): string { + const prefix = kind === 'table' ? 'HAPI Table' : 'HAPI' + return `${prefix}-${sanitizeShareFileNamePart(title)}-${formatShareTimestamp(date)}.png` +} + +export function getShareTableFileName( + title: string, + extension: 'png' | 'csv', + date = new Date(), +): string { + return getShareImageFileName(title, 'table', date).replace(/\.png$/, `.${extension}`) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index cb877c0349..1fc2247f90 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -189,7 +189,8 @@ export default defineConfig({ 'workbox-precaching', 'workbox-routing', 'workbox-strategies', - 'workbox-expiration' + 'workbox-expiration', + 'workbox-window' ] }, resolve: {