Преглед изворни кода

WIP: 修复 DOCX 预览左侧裁切

[gstack-context]
Decisions: DOCX 包装层保持 100% 宽并左对齐,页面用自动横向外边距在可容纳时居中、溢出时从左侧滚动。
Remaining: 运行最终 Vitest、完整 Playwright,并复核工作区。
Tried: max-content 可恢复左边界但会把复杂文档撑到 7806px,已弃用。
Skill: /investigate
[/gstack-context]
samien пре 2 месеци
родитељ
комит
8696b4a236
2 измењених фајлова са 81 додато и 1 уклоњено
  1. 1 1
      src/client/components/library/PreviewInspector.vue
  2. 80 0
      tests/e2e/workspace.spec.ts

+ 1 - 1
src/client/components/library/PreviewInspector.vue

@@ -123,7 +123,7 @@ watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
 .preview-inspector { display: flex; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; }
 header { display: flex; min-height: 56px; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); } header > strong { min-width: 0; flex: 1; overflow: hidden; font-size: 12px; font-weight: 630; text-overflow: ellipsis; white-space: nowrap; }.preview-header-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 3px; }.preview-header-actions .icon-button { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; text-decoration: none; }.preview-header-actions .icon-button:hover { background: var(--surface-soft); color: var(--text-primary); }
 .preview-stage { display: grid; min-height: 0; flex: 1 1 auto; place-items: center; overflow: hidden; background: var(--surface-soft); touch-action: pan-y; }.preview-stage img, .preview-stage video { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }.preview-stage audio { width: calc(100% - 34px); }.preview-stage object { width: 100%; height: 100%; border: 0; }.preview-text { width: 100%; height: 100%; box-sizing: border-box; padding: 20px 28px 28px; overflow: auto; background: var(--surface-raised); }.preview-text pre { margin: 0; color: var(--text-secondary); font: 10px/1.65 var(--font-code); white-space: pre-wrap; overflow-wrap: anywhere; }
-.preview-office { width: 100%; height: 100%; overflow: auto; background: #e6e6e4; color: #111; }.preview-office :deep(.docx-wrapper) { padding: 18px 0; background: #e6e6e4; }.preview-office :deep(.docx) { margin-bottom: 14px; box-shadow: 0 4px 18px rgba(0,0,0,.12); }
+.preview-office { width: 100%; height: 100%; overflow: auto; background: #e6e6e4; color: #111; }.preview-office :deep(.docx-wrapper) { width: 100%; min-width: 0; box-sizing: border-box; align-items: flex-start; padding: 18px; background: #e6e6e4; }.preview-office :deep(.docx) { margin-inline: auto; margin-bottom: 14px; box-shadow: 0 4px 18px rgba(0,0,0,.12); }
 .preview-pdf { display: flex; width: 100%; height: 100%; box-sizing: border-box; align-items: center; flex-direction: column; gap: 12px; padding: 12px; overflow: auto; background: #dadad7; }.preview-pdf :deep(.pdf-page) { flex: 0 0 auto; overflow: hidden; background: #fff; box-shadow: 0 3px 14px rgba(0,0,0,.15); }.preview-pdf :deep(canvas) { display: block; }
 .preview-sheet { width: 100%; height: 100%; overflow: auto; background: var(--surface-raised); }.preview-sheet table { min-width: 100%; border-collapse: collapse; font-size: 9px; }.preview-sheet td { min-width: 72px; max-width: 240px; padding: 6px 8px; overflow: hidden; border: 1px solid var(--line); color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }.preview-sheet tr:first-child td { position: sticky; top: 0; background: var(--surface-soft); color: var(--text-primary); font-weight: 600; }
 .preview-link { display: flex; width: calc(100% - 34px); flex-direction: column; align-items: center; color: var(--text-secondary); text-decoration: none; text-align: center; }.preview-link > span, .preview-unavailable > span { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 12px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface-raised); }.preview-link strong { max-width: 100%; overflow: hidden; color: var(--text-primary); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.preview-link small { margin-top: 5px; color: var(--text-muted); font-size: 9px; }

+ 80 - 0
tests/e2e/workspace.spec.ts

@@ -1,5 +1,7 @@
 import { expect, test } from '@playwright/test'
 
+const WIDE_DOCX_BASE64 = 'UEsDBAoAAAAIAIAjFF15bjPX6AAAAK0BAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbH1QyU7DMBD9FWuuKHHggBCK0wPLETiUDxjZk8SqN3nc0v49Tlt6QIXjzFv1+tXeO7GjzDYGBbdtB4KCjsaGScHn+rV5AMEFg0EXAyk4EMNq6NeHRCyqNrCCuZT0KCXrmTxyGxOFiowxeyz1zJNMqDc4kbzrunupYygUSlMWDxj6Zxpx64p42df3qUcmxyCeTsQlSwGm5KzGUnG5C+ZXSnNOaKvyyOHZJr6pBJBXExbk74Cz7r0Ok60h8YG5vKGvLPkVs5Em6q2vyvZ/mys94zhaTRf94pZy1MRcF/euvSAebfjpL49zD99QSwMECgAAAAAAgCMUXQAAAAAAAAAAAAAAAAYAAABfcmVscy9QSwMECgAAAAgAgCMUXZv9N+qtAAAAKQEAAAsAAABfcmVscy8ucmVsc43POw7CMAwG4KtE3mlaBoRQ0y4IqSsqB7ASN61oHkrCo7cnAwNFDIy2f3+W6/ZpZnanECdnBVRFCYysdGqyWsClP232wGJCq3B2lgQsFKFt6jPNmPJKHCcfWTZsFDCm5A+cRzmSwVg4TzZPBhcMplwGzT3KK2ri27Lc8fBpwNpknRIQOlUB6xdP/9huGCZJRydvhmz6ceIrkWUMmpKAhwuKq3e7yCzwpuarF5sXUEsDBAoAAAAAAIAjFF0AAAAAAAAAAAAAAAAFAAAAd29yZC9QSwMECgAAAAgAgCMUXeKw1aL3AAAAgQEAABEAAAB3b3JkL2RvY3VtZW50LnhtbEWQTU8EIQyG/wrh7jI78WMzGWYPGm9GEzV6ZYfORzJQAt3F9dcL6I6Xlz7Qvm1p919mYSfwYUYr+XZTcQa2Rz3bUfL3t8erHWeBlNVqQQuSnyHwfdfGRmN/NGCJJQMbmij5ROQaIUI/gVFhgw5sehvQG0UJ/Sgieu089hBC8jeLqKvqVhg1W54tD6jP+XRZfBbqPmYN7OH5/pM5D6cZIltgIAZ6hFbkhKy+aCkL0NNLqXXj6zeLebBtXV+nvWIzpfhml2Lxm/CkfLoldJLf1SXDz+NEKx2QCM2KufMKEygNXvICAyKtMB7pD0SZ6zKSuGwo/n+v+wFQSwECFAAKAAAACACAIxRdeW4z1+gAAACtAQAAEwAAAAAAAAAAAAAAAAAAAAAAW0NvbnRlbnRfVHlwZXNdLnhtbFBLAQIUAAoAAAAAAIAjFF0AAAAAAAAAAAAAAAAGAAAAAAAAAAAAEAAAABkBAABfcmVscy9QSwECFAAKAAAACACAIxRdm/036q0AAAApAQAACwAAAAAAAAAAAAAAAAA9AQAAX3JlbHMvLnJlbHNQSwECFAAKAAAAAACAIxRdAAAAAAAAAAAAAAAABQAAAAAAAAAAABAAAAATAgAAd29yZC9QSwECFAAKAAAACACAIxRd4rDVovcAAACBAQAAEQAAAAAAAAAAAAAAAAA2AgAAd29yZC9kb2N1bWVudC54bWxQSwUGAAAAAAUABQAgAQAAXAMAAAAA'
+
 test.beforeEach(async ({ page }) => {
   await page.goto('/chat')
   if (await page.getByRole('heading', { name: '登录 Hermes' }).isVisible()) {
@@ -156,6 +158,84 @@ test('previews an octet-stream Markdown file from the file library', async ({ pa
   await expect(emptyDialog.locator('.preview-unavailable')).toHaveCount(0)
 })
 
+test('keeps the left edge reachable for a wide DOCX preview', async ({ page }) => {
+  await page.setViewportSize({ width: 620, height: 760 })
+  await page.route('**/api/app/files**', async route => {
+    const url = new URL(route.request().url())
+    if (url.pathname === '/api/app/files') {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({
+          profile: 'yaoyao',
+          items: [{
+            id: 'wide-docx-file',
+            path: '/tmp/宽版文档.docx',
+            name: '宽版文档.docx',
+            extension: 'docx',
+            mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+            size: 1024,
+            modifiedAt: Date.now() / 1000,
+            exists: true,
+            origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话' }],
+          }],
+          nextCursor: null,
+          total: 1,
+        }),
+      })
+      return
+    }
+    if (url.pathname === '/api/app/files/wide-docx-file/preview') {
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+        body: Buffer.from(WIDE_DOCX_BASE64, 'base64'),
+      })
+      return
+    }
+    await route.continue()
+  })
+
+  await page.goto('/files')
+  await page.getByText('宽版文档.docx', { exact: true }).click()
+  const dialog = page.getByRole('dialog', { name: '预览 宽版文档.docx' })
+  const office = dialog.locator('.preview-office')
+  await expect(office.locator('section.docx')).toBeVisible()
+  const geometry = await office.evaluate(element => {
+    const wrapper = element.querySelector<HTMLElement>('.docx-wrapper')!
+    const pageElement = element.querySelector<HTMLElement>('section.docx')!
+    const officeRect = element.getBoundingClientRect()
+    const pageRect = pageElement.getBoundingClientRect()
+    return {
+      officeLeft: officeRect.left,
+      pageLeft: pageRect.left,
+      pageWidth: pageRect.width,
+      clientWidth: element.clientWidth,
+      scrollWidth: element.scrollWidth,
+      wrapperWidth: wrapper.getBoundingClientRect().width,
+    }
+  })
+  expect(geometry.pageWidth).toBeGreaterThan(geometry.clientWidth)
+  expect(geometry.pageLeft).toBeGreaterThanOrEqual(geometry.officeLeft)
+  expect(geometry.scrollWidth).toBeGreaterThanOrEqual(geometry.pageWidth)
+  expect(Math.abs(geometry.wrapperWidth - geometry.clientWidth)).toBeLessThan(1)
+
+  await page.setViewportSize({ width: 1200, height: 760 })
+  const centered = await office.evaluate(element => {
+    const pageElement = element.querySelector<HTMLElement>('section.docx')!
+    const officeRect = element.getBoundingClientRect()
+    const pageRect = pageElement.getBoundingClientRect()
+    return {
+      pageWidth: pageRect.width,
+      clientWidth: element.clientWidth,
+      leftGap: pageRect.left - officeRect.left,
+      rightGap: officeRect.right - pageRect.right,
+    }
+  })
+  expect(centered.pageWidth).toBeLessThan(centered.clientWidth)
+  expect(Math.abs(centered.leftGap - centered.rightGap)).toBeLessThan(1)
+})
+
 test('keeps pins first and loads the next session page at the list bottom', async ({ page }) => {
   const sidebar = page.locator('.desktop-sidebar')
   const items = sidebar.locator('.sidebar-list [role="option"]')