fix(web): preserve share export layout fidelity (#1277)

* fix(web): preserve share export layout fidelity

* fix(web): keep mobile and multi-image exports responsive

* fix(web): distinguish coarse pointers from touch desktops
This commit is contained in:
Ananovo
2026-08-01 17:10:51 +08:00
committed by GitHub
parent 01bffa3c8c
commit bbcef8c300
4 changed files with 181 additions and 46 deletions
+81 -1
View File
@@ -60,7 +60,7 @@ for (const viewport of [
}
}
const mediaGrid = page.getByRole('dialog').locator('.hapi-share-media-grid')
await expect(mediaGrid).toHaveCSS('display', 'grid')
await expect(mediaGrid).toHaveCSS('display', 'flex')
const imageTops = await mediaGrid.locator('img').evaluateAll((images) => images.map((image) => image.getBoundingClientRect().top))
expect(imageTops).toHaveLength(2)
expect(Math.abs(imageTops[0] - imageTops[1])).toBeLessThan(1)
@@ -192,6 +192,86 @@ test('keeps code and image controls interactive in preview', async ({ page }, te
await expect(dialog).toBeVisible()
})
test('preserves the desktop source width but keeps mobile export width stable', async ({ page }, testInfo) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
})
await page.setViewportSize({ width: 1440, height: 1600 })
await page.goto('/e2e-fixtures/share-turn-fixture.html?wide=1')
const sourceWidth = await page.getByTestId('source-turn').evaluate((element) => element.getBoundingClientRect().width)
expect(sourceWidth).toBe(1080)
await page.getByRole('button', { name: 'Open share preview' }).click()
const preview = page.getByRole('dialog').locator('.hapi-share-preview-root')
const previewWidth = await preview.evaluate((element) => element.getBoundingClientRect().width)
expect(previewWidth).toBeGreaterThan(650)
expect(previewWidth).toBeLessThanOrEqual(720)
const inlineCode = preview.locator('.aui-md-code').last()
await expect(inlineCode).toHaveCSS('display', 'inline')
const desktopDownloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'Download' }).click()
const desktopDownload = await desktopDownloadPromise
const desktopPath = testInfo.outputPath('source-width-desktop.png')
await desktopDownload.saveAs(desktopPath)
expect(pngSize(await readFile(desktopPath)).width).toBe(2240)
await page.setViewportSize({ width: 390, height: 844 })
await page.reload()
await page.getByRole('button', { name: 'Open share preview' }).click()
const mobileDownloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'Download' }).click()
const mobileDownload = await mobileDownloadPromise
const mobilePath = testInfo.outputPath('source-width-mobile.png')
await mobileDownload.saveAs(mobilePath)
expect(pngSize(await readFile(mobilePath)).width).toBe(1920)
})
test('keeps a landscape touch device on the fixed mobile export path', async ({ page }, testInfo) => {
await page.addInitScript(() => {
const nativeMatchMedia = window.matchMedia.bind(window)
window.matchMedia = (query: string) => {
if (query !== '(pointer: coarse)') return nativeMatchMedia(query)
return {
matches: true,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}
}
})
await page.setViewportSize({ width: 844, height: 390 })
await page.goto('/e2e-fixtures/share-turn-fixture.html?wide=1')
await page.getByRole('button', { name: 'Open share preview' }).click()
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'Download' }).click()
const download = await downloadPromise
const path = testInfo.outputPath('landscape-touch-mobile.png')
await download.saveAs(path)
expect(pngSize(await readFile(path)).width).toBe(1920)
})
test('allows three or more attachments to wrap instead of shrinking into one row', async ({ page }) => {
await page.goto('/e2e-fixtures/share-turn-fixture.html')
await page.getByTestId('source-turn').evaluate((source) => {
const grid = source.querySelector<HTMLElement>('.hapi-share-media-grid')
const firstAttachment = grid?.querySelector<HTMLButtonElement>('button')
if (!grid || !firstAttachment) throw new Error('Missing attachment fixture')
grid.appendChild(firstAttachment.cloneNode(true))
grid.dataset.hapiImageCount = '3'
})
await page.getByRole('button', { name: 'Open share preview' }).click()
const mediaGrid = page.getByRole('dialog').locator('.hapi-share-media-grid')
await expect(mediaGrid).toHaveCSS('flex-wrap', 'wrap')
await expect(mediaGrid.locator(':scope > button')).toHaveCount(3)
})
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: '' }
+11 -6
View File
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react'
import ReactDOM from 'react-dom/client'
import '../src/index.css'
import { ShareTurnDialog } from '../src/components/AssistantChat/ShareTurnDialog'
import { getUserBubbleClassName } from '../src/components/AssistantChat/messages/user-bubble'
import { getUserBubbleClassName, UserBubbleContent } from '../src/components/AssistantChat/messages/user-bubble'
import { MarkdownRenderer } from '../src/components/MarkdownRenderer'
import { I18nProvider } from '../src/lib/i18n-context'
@@ -26,6 +26,8 @@ const portraitFixtureImage = 'data:image/svg+xml;charset=utf-8,' + encodeURIComp
const markdown = `## Complex response fixture
## ✅ AList 的 frp 和 Caddy 配置已彻底移除
This paragraph contains **bold text**, *emphasis*, ~~strikethrough~~, inline \`code\`, a [safe link](https://example.com), 中文内容,以及一段足够长的文字,用于验证换行、行高和宽屏导出效果是否与原始 HAPI 页面保持一致。
> A multi-line blockquote used to verify borders, indentation, colors, and wrapping. \n> 第二行引用包含中文。
@@ -54,6 +56,10 @@ console.log(result)
---
Final paragraph after the divider.
\`alist.techotaku39.top\` 已失效,5244 端口也不再监听。
请将 \`machine-status-widget/hapi-machine-status.user.js\` 全文覆盖到油猴脚本中,然后强制刷新页面。
`
if (new URLSearchParams(window.location.search).get('theme') === 'dark') {
@@ -66,6 +72,7 @@ function App() {
const sourceRef = useRef<HTMLDivElement>(null)
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
const [open, setOpen] = useState(false)
const wideSource = new URLSearchParams(window.location.search).get('wide') === '1'
const openShare = () => {
const searchParams = new URLSearchParams(window.location.search)
@@ -91,14 +98,11 @@ function App() {
return (
<I18nProvider>
<main className="mx-auto w-[960px] max-w-full bg-[var(--app-bg)] p-5 text-[var(--app-fg)]">
<main className={`mx-auto max-w-full bg-[var(--app-bg)] p-5 text-[var(--app-fg)] ${wideSource ? 'w-[1120px]' : 'w-[960px]'}`}>
<div ref={sourceRef} data-testid="source-turn" className="flex flex-col gap-3">
<div data-hapi-message-role="user" className="happy-message flex flex-col items-end">
<div className={getUserBubbleClassName()}>
<div className="happy-chat-text whitespace-pre-wrap">
<span className="mr-1 inline-flex rounded-full bg-[var(--app-chat-user-chip-bg)] px-2 py-px text-[var(--app-chat-user-chip-fg)]">plan</span>
{'请导出这一轮复杂对话,并确保代码、表格、图片附件和长文本的样式全部保留。\n第二行用于验证换行。'}
</div>
<UserBubbleContent text={'这个失败不用说吧:`bun run test` was also attempted. The Web suite passes, but the complete repository run is currently blocked on Windows by unrelated tests.\n第二行用于验证换行。'} />
<div className="hapi-share-media-grid mt-3 flex flex-wrap gap-2" data-hapi-image-count="2">
<button type="button" title="Click to zoom" data-image-preview-trigger="" data-image-preview-label="HAPI landscape export fixture" className="overflow-hidden rounded-xl">
<img className="max-h-60" src={fixtureImage} alt="HAPI landscape export fixture" />
@@ -132,6 +136,7 @@ function App() {
showFastBadge={false}
worktreeBranch="feat/share-turn-polish"
sourceSnapshots={snapshots}
sourceContentWidth={sourceRef.current?.getBoundingClientRect().width ?? null}
onClose={() => setOpen(false)}
/>
</I18nProvider>
@@ -52,6 +52,7 @@ type ShareTurnState = {
id: number
snapshots: ShareTurnSnapshot[]
title: string
sourceContentWidth: number | null
} | null
type ShareTurnSnapshot = {
@@ -1333,6 +1334,9 @@ export function HappyThread(props: {
) => {
const content = contentRef.current
if (!content) return
const messageContainer = content.querySelector<HTMLElement>('.happy-thread-messages')
const sourceContentWidth = messageContainer?.getBoundingClientRect().width
?? content.getBoundingClientRect().width
let target: HTMLElement | null = typeof messageTarget === 'string'
? document.getElementById(messageTarget)
@@ -1345,6 +1349,7 @@ export function HappyThread(props: {
id: ++shareTurnIdRef.current,
snapshots: fallbackSnapshot ? [fallbackSnapshot] : [],
title: props.metadata?.summary?.text ?? props.metadata?.name ?? props.metadata?.path ?? props.sessionId.slice(0, 8),
sourceContentWidth: sourceContentWidth > 0 ? sourceContentWidth : null,
})
return
}
@@ -1388,6 +1393,7 @@ export function HappyThread(props: {
id: ++shareTurnIdRef.current,
snapshots: completeSnapshots,
title: props.metadata?.summary?.text ?? props.metadata?.name ?? props.metadata?.path ?? props.sessionId.slice(0, 8),
sourceContentWidth: sourceContentWidth > 0 ? sourceContentWidth : null,
})
}, [props.metadata, props.sessionId])
@@ -1500,6 +1506,7 @@ export function HappyThread(props: {
showFastBadge={props.session.metadata?.flavor === 'codex' && isFastServiceTier(props.session.serviceTier)}
worktreeBranch={props.session.metadata?.worktree?.branch ?? null}
sourceSnapshots={shareTurn?.snapshots ?? []}
sourceContentWidth={shareTurn?.sourceContentWidth ?? null}
onClose={() => setShareTurn(null)}
/>
</ThreadPrimitive.Root>
@@ -18,12 +18,14 @@ type ShareTurnDialogProps = {
text: string
role?: 'user' | 'assistant'
}>
sourceContentWidth?: number | null
onClose: () => void
}
type ShareTurnSnapshot = ShareTurnDialogProps['sourceSnapshots'][number]
const SHARE_EXPORT_WIDTH = 960
const SHARE_EXPORT_HORIZONTAL_PADDING = 40
const SHARE_EXPORT_SCALE = 2
const MAX_EXPORT_PIXELS = 24_000_000
const SHARE_HIDDEN_CONTENT_SELECTOR = '[data-hapi-share-exclude="true"], .aui-reasoning-group'
@@ -143,12 +145,20 @@ function getShareFileName(title: string): string {
return `HAPI-${sanitizeShareFileNamePart(title)}-${formatShareTimestamp()}.png`
}
function prepareExportElement(element: HTMLElement): HTMLElement {
function prepareExportElement(element: HTMLElement, exportWidth: number, preserveSourceLayout: boolean): HTMLElement {
const captureElement = element.cloneNode(true)
if (!(captureElement instanceof HTMLElement)) {
throw new Error('Failed to prepare shared image')
}
for (const code of Array.from(captureElement.querySelectorAll<HTMLElement>('code'))) {
if (code.closest('pre')) continue
const textWrapper = document.createElement('span')
textWrapper.dataset.hapiInlineCodeText = 'true'
while (code.firstChild) textWrapper.appendChild(code.firstChild)
code.appendChild(textWrapper)
}
const elementStyle = getComputedStyle(element)
const backgroundColor = elementStyle.backgroundColor && elementStyle.backgroundColor !== 'rgba(0, 0, 0, 0)'
? elementStyle.backgroundColor
@@ -162,16 +172,16 @@ function prepareExportElement(element: HTMLElement): HTMLElement {
'left:0',
'top:0',
'z-index:-1',
`width:${SHARE_EXPORT_WIDTH}px`,
`max-width:${SHARE_EXPORT_WIDTH}px`,
`width:${exportWidth}px`,
`max-width:${exportWidth}px`,
'box-sizing:border-box',
'transform:none',
'zoom:1',
'pointer-events:none',
'overflow:visible',
'-webkit-text-size-adjust:100%',
'text-size-adjust:100%',
'font-size:14px',
'line-height:1.6',
...(preserveSourceLayout ? [] : ['font-size:14px', 'line-height:1.6']),
`background:${backgroundColor}`,
`color:${color}`
].join(';')
@@ -184,8 +194,10 @@ function prepareExportElement(element: HTMLElement): HTMLElement {
box-sizing: border-box !important;
-webkit-text-size-adjust: 100% !important;
text-size-adjust: 100% !important;
font-size: 14px !important;
line-height: 1.6 !important;
${preserveSourceLayout ? '' : `
font-size: 14px !important;
line-height: 1.6 !important;
`}
}
.hapi-share-export-root [data-hapi-share-exclude="true"],
.hapi-share-export-root .aui-reasoning-group,
@@ -193,6 +205,29 @@ function prepareExportElement(element: HTMLElement): HTMLElement {
.hapi-share-export-root [data-hapi-share-export-exclude="true"] {
display: none !important;
}
.hapi-share-export-root,
.hapi-share-export-root * {
/* html2canvas-pro renders non-zero letter spacing one grapheme at
a time and switches CJK glyphs to an ideographic baseline.
Mixed CJK/Latin text then no longer shares the browser's single
shaped baseline. Zero spacing keeps each text run intact. */
letter-spacing: 0 !important;
}
.hapi-share-export-root :not(pre) > code {
position: relative !important;
/* html2canvas-pro paints an inline element's background using its
full inherited line box. Collapse the code element's own line
box to its em square so the existing block padding produces the
same pill height as the browser for both message roles. */
line-height: 1em !important;
}
.hapi-share-export-root [data-hapi-inline-code-text="true"] {
/* Move only the monospace glyphs. Keep the inline-code border and
background in the same position as the browser layout. */
position: relative !important;
top: -1px !important;
}
${preserveSourceLayout ? '' : `
.hapi-share-export-root img,
.hapi-share-export-root video,
.hapi-share-export-root canvas,
@@ -229,6 +264,7 @@ function prepareExportElement(element: HTMLElement): HTMLElement {
.hapi-share-export-root button:has(img) > :not(img) {
display: none !important;
}
`}
.hapi-share-export-root .sr-only {
display: none !important;
}
@@ -236,18 +272,11 @@ function prepareExportElement(element: HTMLElement): HTMLElement {
display: block !important;
height: 0.75rem !important;
}
.hapi-share-export-root .aui-md-code:not(.aui-md-codeblockcode) {
display: inline-block !important;
width: auto !important;
max-width: 100% !important;
white-space: normal !important;
overflow-wrap: anywhere !important;
direction: ltr !important;
unicode-bidi: isolate !important;
text-align: left !important;
vertical-align: baseline !important;
padding-left: 0.2em !important;
padding-right: 0.2em !important;
.hapi-share-export-root [data-hapi-code-body="true"] {
scrollbar-width: none !important;
}
.hapi-share-export-root [data-hapi-code-body="true"]::-webkit-scrollbar {
display: none !important;
}
`
captureElement.prepend(style)
@@ -368,15 +397,18 @@ async function waitForExportReady(root: HTMLElement): Promise<void> {
await nextFrame()
}
async function elementToPngBlob(element: HTMLElement): Promise<Blob> {
const { default: html2canvas } = await import('html2canvas-pro')
async function elementToPngBlob(
element: HTMLElement,
exportWidth: number,
preserveSourceLayout: boolean
): Promise<Blob> {
const frame = document.createElement('iframe')
frame.setAttribute('aria-hidden', 'true')
frame.style.cssText = [
'position:fixed',
'left:-10000px',
'top:0',
`width:${SHARE_EXPORT_WIDTH}px`,
`width:${exportWidth}px`,
'height:1000px',
'border:0',
'opacity:0',
@@ -403,7 +435,7 @@ async function elementToPngBlob(element: HTMLElement): Promise<Blob> {
frameDocument.body.setAttribute('style', [
document.body.getAttribute('style') ?? '',
'margin:0',
`width:${SHARE_EXPORT_WIDTH}px`,
`width:${exportWidth}px`,
'min-height:1000px',
'overflow:visible',
'background:transparent'
@@ -415,7 +447,7 @@ async function elementToPngBlob(element: HTMLElement): Promise<Blob> {
copyLoadedStyleSheets(document, frameDocument)
const captureElement = prepareExportElement(element)
const captureElement = prepareExportElement(element, exportWidth, preserveSourceLayout)
captureElement.style.position = 'static'
captureElement.style.left = 'auto'
captureElement.style.top = 'auto'
@@ -428,8 +460,10 @@ async function elementToPngBlob(element: HTMLElement): Promise<Blob> {
const captureHeight = captureElement.scrollHeight
const maxScale = Math.sqrt(MAX_EXPORT_PIXELS / Math.max(1, captureWidth * captureHeight))
const scale = Math.min(SHARE_EXPORT_SCALE, maxScale)
const backgroundColor = getComputedStyle(captureElement).backgroundColor || '#ffffff'
const { default: html2canvas } = await import('html2canvas-pro')
canvas = await html2canvas(captureElement, {
backgroundColor: getComputedStyle(captureElement).backgroundColor || '#ffffff',
backgroundColor,
foreignObjectRendering: false,
imageTimeout: 15000,
logging: false,
@@ -438,7 +472,7 @@ async function elementToPngBlob(element: HTMLElement): Promise<Blob> {
useCORS: true,
width: captureWidth,
height: captureHeight,
windowWidth: Math.max(SHARE_EXPORT_WIDTH, captureWidth),
windowWidth: Math.max(exportWidth, captureWidth),
windowHeight: Math.max(document.documentElement.clientHeight, captureHeight),
})
} finally {
@@ -501,6 +535,16 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
naturalHeight: number
} | null>(null)
const showNativeShareButton = true
const usesCoarsePrimaryPointer = window.matchMedia('(pointer: coarse)').matches
const preserveSourceLayout = Boolean(
props.sourceContentWidth
&& props.sourceContentWidth > 0
&& window.matchMedia('(min-width: 640px)').matches
&& !usesCoarsePrimaryPointer
)
const exportWidth = preserveSourceLayout
? Math.min(1240, Math.max(480, Math.ceil((props.sourceContentWidth ?? 0) + SHARE_EXPORT_HORIZONTAL_PADDING)))
: SHARE_EXPORT_WIDTH
useLayoutEffect(() => {
setReady(false)
@@ -558,7 +602,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
let cancelled = false
setPreparedBlob(null)
void elementToPngBlob(capture).then((blob) => {
void elementToPngBlob(capture, exportWidth, preserveSourceLayout).then((blob) => {
if (!cancelled) setPreparedBlob(blob)
}).catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to create image')
@@ -566,7 +610,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
return () => {
cancelled = true
}
}, [props.isOpen, props.sourceSnapshots, ready, restoreTick, previewRevision])
}, [props.isOpen, props.sourceSnapshots, ready, restoreTick, previewRevision, exportWidth, preserveSourceLayout])
const handlePreviewClick = (event: ReactMouseEvent<HTMLElement>) => {
const target = event.target
@@ -662,7 +706,7 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
setBusy(mode)
setError(null)
try {
const blob = await elementToPngBlob(capture)
const blob = await elementToPngBlob(capture, exportWidth, preserveSourceLayout)
await action(blob)
if (mode === 'copy') setCopied(true)
} catch (err) {
@@ -693,23 +737,22 @@ export function ShareTurnDialog(props: ShareTurnDialogProps) {
display: block !important;
height: 0.75rem !important;
}
.hapi-share-preview-root .hapi-share-media-grid:not([data-hapi-image-count="1"]) {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
align-items: start !important;
width: 100% !important;
}
.hapi-share-preview-root .hapi-share-media-grid:not([data-hapi-image-count="1"]) > button,
.hapi-share-preview-root .hapi-share-media-grid:not([data-hapi-image-count="1"]) > button > img {
width: 100% !important;
min-width: 0 !important;
.hapi-share-preview-root .hapi-share-media-grid[data-hapi-image-count="2"] {
flex-wrap: nowrap !important;
}
.hapi-share-preview-root .hapi-share-media-grid > button {
height: auto !important;
align-self: start !important;
flex-shrink: 1 !important;
min-width: 0 !important;
cursor: zoom-in !important;
pointer-events: auto !important;
}
.hapi-share-preview-root .hapi-share-media-grid > button > img {
width: auto !important;
max-width: 100% !important;
height: auto !important;
}
`}</style>
<div className="mb-4 border-b border-[var(--app-divider)] pb-3">
<div className="min-w-0">