diff --git a/e2e/share-turn.spec.ts b/e2e/share-turn.spec.ts index 83c92321..ceffe51b 100644 --- a/e2e/share-turn.spec.ts +++ b/e2e/share-turn.spec.ts @@ -37,7 +37,7 @@ for (const viewport of [ await expect(page.getByRole('dialog').getByText('Excluded tool output')).toHaveCount(0) await expect(page.getByRole('dialog').locator('.happy-message-actions')).toHaveCount(0) await expect(page.getByRole('dialog').locator('.hapi-share-hidden-content-spacer')).toHaveCount(1) - await expect(page.getByRole('dialog').locator('[title="Click to zoom"]')).toHaveCount(0) + await expect(page.getByRole('dialog').locator('[title="Click to zoom"]')).toHaveCount(2) if (viewport.name === 'desktop') { const styles = await page.evaluate(() => { const source = document.querySelector('[data-testid="source-turn"]') @@ -134,11 +134,64 @@ test('localizes the share dialog actions in Chinese', async ({ page }) => { await expect(dialog.getByText('分享会话', { exact: true })).toHaveCount(0) await expect(dialog.getByText('Generated by HAPI', { exact: true })).toBeVisible() await expect(dialog.getByRole('button', { name: '取消' })).toHaveCount(0) - await expect(dialog.getByRole('button', { name: '复制' })).toBeVisible() + await expect(dialog.getByRole('button', { name: '复制' }).last()).toBeVisible() await expect(dialog.getByRole('button', { name: '分享' })).toBeVisible() await expect(dialog.getByRole('button', { name: '下载' })).toBeVisible() }) +test('keeps code and image controls interactive in preview', async ({ page }, testInfo) => { + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto('/e2e-fixtures/share-turn-fixture.html') + await page.getByRole('button', { name: 'Open share preview' }).click() + + const dialog = page.getByRole('dialog', { name: 'Share turn as image' }) + const wrapButton = dialog.locator('[data-hapi-code-wrap-toggle="true"]').first() + await expect(wrapButton).toHaveAttribute('aria-pressed', 'false') + + const unwrappedDownloadPromise = page.waitForEvent('download') + await dialog.getByRole('button', { name: 'Download' }).click() + const unwrappedDownload = await unwrappedDownloadPromise + const unwrappedPath = testInfo.outputPath('interactive-unwrapped.png') + await unwrappedDownload.saveAs(unwrappedPath) + const unwrappedSize = pngSize(await readFile(unwrappedPath)) + + await wrapButton.click() + await expect(wrapButton).toHaveAttribute('aria-pressed', 'true') + await expect(dialog.locator('[data-code-cell]').first()).toHaveCSS('white-space', 'pre-wrap') + + const wrappedDownloadPromise = page.waitForEvent('download') + await dialog.getByRole('button', { name: 'Download' }).click() + const wrappedDownload = await wrappedDownloadPromise + const wrappedPath = testInfo.outputPath('interactive-wrapped.png') + await wrappedDownload.saveAs(wrappedPath) + const wrappedSize = pngSize(await readFile(wrappedPath)) + expect(wrappedSize.height).toBeGreaterThan(unwrappedSize.height) + + const copyButton = dialog.locator('[data-hapi-code-copy="true"]').first() + await copyButton.click() + await expect(copyButton).toHaveAttribute('title', 'Copied') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('type ExportResult') + + const previewImage = dialog.locator('[data-image-preview-trigger] img').first() + const previewImageBox = await previewImage.boundingBox() + await previewImage.click() + const imageDialog = page.getByRole('dialog', { name: 'HAPI landscape export fixture' }) + await expect(imageDialog).toBeVisible() + const lightboxImageBox = await imageDialog.getByRole('img', { name: 'HAPI landscape export fixture' }).boundingBox() + expect(lightboxImageBox?.width ?? 0).toBeGreaterThan(previewImageBox?.width ?? 0) + const fitButton = imageDialog.getByTitle('Fit to screen') + await expect(fitButton).toHaveText('100%') + await imageDialog.getByRole('img', { name: 'HAPI landscape export fixture' }).hover() + await page.mouse.wheel(0, -100) + await expect(fitButton).not.toHaveText('100%') + + await page.keyboard.press('Tab') + await expect(imageDialog.locator(':focus')).toHaveCount(1) + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog', { name: 'HAPI landscape export fixture' })).toHaveCount(0) + await expect(dialog).toBeVisible() +}) + test('uses a prepared PNG while native share still has click activation', async ({ page }) => { await page.addInitScript(() => { const state = { calls: 0, active: false, fileType: '', fileName: '' } diff --git a/web/e2e-fixtures/share-turn-fixture.tsx b/web/e2e-fixtures/share-turn-fixture.tsx index def6d44a..4369666f 100644 --- a/web/e2e-fixtures/share-turn-fixture.tsx +++ b/web/e2e-fixtures/share-turn-fixture.tsx @@ -100,10 +100,10 @@ function App() { {'请导出这一轮复杂对话,并确保代码、表格、图片附件和长文本的样式全部保留。\n第二行用于验证换行。'}
- -
diff --git a/web/src/components/AssistantChat/ShareTurnDialog.tsx b/web/src/components/AssistantChat/ShareTurnDialog.tsx index 5c8245dc..cfffe358 100644 --- a/web/src/components/AssistantChat/ShareTurnDialog.tsx +++ b/web/src/components/AssistantChat/ShareTurnDialog.tsx @@ -1,7 +1,9 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { 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 { safeCopyToClipboard } from '@/lib/clipboard' type ShareTurnDialogProps = { isOpen: boolean @@ -61,11 +63,6 @@ function stripCaptureOnlyControls(root: HTMLElement): void { anchor.removeAttribute('target') anchor.removeAttribute('rel') } - for (const imageButton of Array.from(root.querySelectorAll('button:has(img)'))) { - imageButton.removeAttribute('title') - imageButton.removeAttribute('aria-label') - imageButton.setAttribute('tabindex', '-1') - } for (const element of Array.from(root.querySelectorAll('[role="button"], [contenteditable="true"]'))) { if (element.tagName.toLowerCase() !== 'a') { element.removeAttribute('role') @@ -75,6 +72,45 @@ function stripCaptureOnlyControls(root: HTMLElement): void { } } +function stripExportControls(root: HTMLElement): void { + for (const element of Array.from(root.querySelectorAll('[data-hapi-share-export-exclude="true"]'))) { + element.remove() + } + for (const imageButton of Array.from(root.querySelectorAll('button:has(img)'))) { + imageButton.removeAttribute('title') + imageButton.removeAttribute('aria-label') + imageButton.setAttribute('tabindex', '-1') + } +} + +function getPreviewCodeBody(control: HTMLElement): HTMLElement | null { + const block = control.closest('[data-hapi-code-block="true"]') + if (block) return block.querySelector('[data-hapi-code-body="true"]') + const header = control.closest('[data-hapi-code-header="true"]') + const body = header?.nextElementSibling + return body instanceof HTMLElement && body.matches('[data-hapi-code-body="true"]') ? body : null +} + +function setPreviewCodeWrap(control: HTMLElement, enabled: boolean): void { + const body = getPreviewCodeBody(control) + const grid = body?.querySelector('[data-hapi-code-grid="true"]') + if (!body || !grid) return + + body.classList.toggle('overflow-x-auto', !enabled) + grid.classList.toggle('w-full', enabled) + grid.classList.toggle('w-max', !enabled) + grid.classList.toggle('min-w-full', !enabled) + grid.style.gridTemplateColumns = enabled + ? grid.style.gridTemplateColumns.replace(/max-content\s*$/, 'minmax(0, 1fr)') + : grid.style.gridTemplateColumns.replace(/minmax\(0,\s*1fr\)\s*$/, 'max-content') + grid.style.whiteSpace = enabled ? 'pre-wrap' : 'pre' + grid.style.wordBreak = enabled ? 'break-word' : '' + for (const cell of Array.from(grid.querySelectorAll('[data-code-cell]'))) { + cell.style.whiteSpace = enabled ? 'pre-wrap' : 'pre' + cell.style.wordBreak = enabled ? 'break-word' : '' + } +} + function formatShareTimestamp(date = new Date()): string { const pad = (value: number) => String(value).padStart(2, '0') return [ @@ -120,6 +156,7 @@ function prepareExportElement(element: HTMLElement): HTMLElement { const color = elementStyle.color || getComputedStyle(document.documentElement).getPropertyValue('--app-fg').trim() || '#111827' captureElement.classList.add('hapi-share-export-root') + stripExportControls(captureElement) captureElement.style.cssText += [ 'position:absolute', 'left:0', @@ -153,7 +190,7 @@ function prepareExportElement(element: HTMLElement): HTMLElement { .hapi-share-export-root [data-hapi-share-exclude="true"], .hapi-share-export-root .aui-reasoning-group, .hapi-share-export-root button[aria-expanded], - .hapi-share-export-root button[title="Copy"] { + .hapi-share-export-root [data-hapi-share-export-exclude="true"] { display: none !important; } .hapi-share-export-root img, @@ -199,11 +236,6 @@ function prepareExportElement(element: HTMLElement): HTMLElement { display: block !important; height: 0.75rem !important; } - .hapi-share-export-root pre, - .hapi-share-export-root .aui-md-codeblockcode { - white-space: pre-wrap !important; - overflow-wrap: anywhere !important; - } .hapi-share-export-root .aui-md-code:not(.aui-md-codeblockcode) { display: inline-block !important; width: auto !important; @@ -461,6 +493,13 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { const [restoreTick, setRestoreTick] = useState(0) const [ready, setReady] = useState(false) const [preparedBlob, setPreparedBlob] = useState(null) + const [previewRevision, setPreviewRevision] = useState(0) + const [previewImage, setPreviewImage] = useState<{ + src: string + label: string + naturalWidth: number + naturalHeight: number + } | null>(null) const showNativeShareButton = true useLayoutEffect(() => { @@ -506,6 +545,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { setError(null) setCopied(false) + setPreviewImage(null) return undefined }, [props.isOpen, props.sourceSnapshots, restoreTick]) @@ -526,7 +566,65 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) { return () => { cancelled = true } - }, [props.isOpen, props.sourceSnapshots, ready, restoreTick]) + }, [props.isOpen, props.sourceSnapshots, ready, restoreTick, previewRevision]) + + const handlePreviewClick = (event: ReactMouseEvent) => { + const target = event.target + if (!(target instanceof Element)) return + + const wrapButton = target.closest('[data-hapi-code-wrap-toggle="true"]') + if (wrapButton) { + event.preventDefault() + event.stopPropagation() + if (!getPreviewCodeBody(wrapButton)) return + const enabled = wrapButton.getAttribute('aria-pressed') !== 'true' + wrapButton.setAttribute('aria-pressed', String(enabled)) + wrapButton.title = enabled + ? (wrapButton.dataset.hapiWrapDisableLabel ?? '') + : (wrapButton.dataset.hapiWrapEnableLabel ?? '') + setPreviewCodeWrap(wrapButton, enabled) + setPreparedBlob(null) + setPreviewRevision((revision) => revision + 1) + return + } + + const copyButton = target.closest('[data-hapi-code-copy="true"]') + if (copyButton) { + event.preventDefault() + event.stopPropagation() + const body = getPreviewCodeBody(copyButton) + const cells = Array.from(body?.querySelectorAll('[data-code-cell]') ?? []) + const code = (cells.length > 0 + ? cells.map((cell) => cell.textContent ?? '').join('\n') + : body?.querySelector('pre')?.textContent ?? '') + if (!code) return + void safeCopyToClipboard(code).then(() => { + copyButton.querySelector('[data-hapi-copy-default="true"]')?.classList.add('hidden') + copyButton.querySelector('[data-hapi-copy-success="true"]')?.classList.remove('hidden') + copyButton.title = copyButton.dataset.hapiCopiedLabel ?? copyButton.title + window.setTimeout(() => { + if (!copyButton.isConnected) return + copyButton.querySelector('[data-hapi-copy-default="true"]')?.classList.remove('hidden') + copyButton.querySelector('[data-hapi-copy-success="true"]')?.classList.add('hidden') + copyButton.title = copyButton.dataset.hapiCopyLabel ?? copyButton.title + }, 1500) + }).catch(() => undefined) + return + } + + const imageButton = target.closest('[data-image-preview-trigger]') + const image = imageButton?.querySelector('img') + if (image) { + event.preventDefault() + event.stopPropagation() + setPreviewImage({ + src: image.currentSrc || image.src, + label: image.alt || imageButton?.dataset.imagePreviewLabel || 'Image preview', + naturalWidth: image.naturalWidth || image.width, + naturalHeight: image.naturalHeight || image.height + }) + } + } const runBlobAction = ( blob: Blob, @@ -587,6 +685,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
@@ -635,6 +734,27 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
+ {previewImage ? ( + setPreviewImage(null)} + title={previewImage.label} + ariaLabel={previewImage.label} + fitContentKey={previewImage.src} + fitContentSize={previewImage.naturalWidth > 0 && previewImage.naturalHeight > 0 + ? { width: previewImage.naturalWidth, height: previewImage.naturalHeight } + : null} + > + {previewImage.label} + + ) : null} {error ? (
{error}
diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx index 35edad70..50d950ef 100644 --- a/web/src/components/CodeBlock.tsx +++ b/web/src/components/CodeBlock.tsx @@ -87,7 +87,7 @@ export function CodeBlock(props: { : { overflowY: 'hidden' as const } return ( -
+
{label} @@ -96,6 +96,10 @@ export function CodeBlock(props: { {showWrapToggle ? ( ) : null}
diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx
index 503a8e17..4b85152d 100644
--- a/web/src/components/ZoomableLightbox.tsx
+++ b/web/src/components/ZoomableLightbox.tsx
@@ -1,4 +1,5 @@
 import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react'
+import * as DialogPrimitive from '@radix-ui/react-dialog'
 import { CloseIcon } from '@/components/icons'
 
 const MIN_SCALE = 0.25
@@ -377,9 +378,6 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) {
         if (!open) return
 
         const handleKeyDown = (event: KeyboardEvent) => {
-            if (event.key === 'Escape') {
-                closeViewer()
-            }
             if (event.key === '0') {
                 resetView()
             }
@@ -404,77 +402,84 @@ export function ZoomableLightbox(props: ZoomableLightboxProps) {
     const minInteractiveScale = Math.min(MIN_SCALE, baseScale)
 
     return (
-        
{ + if (!nextOpen) closeViewer() + }} > -
-
+ - {children} -
-
-
event.stopPropagation()} - > -
-
{title ?? ariaLabel}
- -
+
+
event.stopPropagation()} > - {zoomLabel} - - - -
-
-
+
+
{title ?? ariaLabel}
+ + + + +
+
+ + + ) } diff --git a/web/src/components/assistant-ui/markdown-text.tsx b/web/src/components/assistant-ui/markdown-text.tsx index 73224aa7..f5de2517 100644 --- a/web/src/components/assistant-ui/markdown-text.tsx +++ b/web/src/components/assistant-ui/markdown-text.tsx @@ -401,13 +401,17 @@ function CodeHeader(props: CodeHeaderProps) { const language = props.language && props.language !== 'unknown' ? props.language : 'text' return ( -
+
{language}
@@ -436,8 +449,9 @@ function Pre(props: ComponentPropsWithoutRef<'pre'>) {
+ )} data-hapi-code-body="true">
+