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

WIP: 移除 DOCX 预览灰色画布

[gstack-context]
Decisions: DOCX 画布和包装层改为纯白且上左右 padding 0;正文 padding 0;通过 ResizeObserver 按最宽页面等比缩放,保留固定宽表格与图片布局。
Remaining: 重跑最终 Vitest、完整 Playwright 并独立复审。
Tried: 直接将 section 改成 100% 会静默裁切固定宽复杂内容,已改为整页 zoom 等比适配。
Skill: /investigate
[/gstack-context]
samien пре 2 месеци
родитељ
комит
b6b35ca200
2 измењених фајлова са 51 додато и 8 уклоњено
  1. 28 2
      src/client/components/library/PreviewInspector.vue
  2. 23 6
      tests/e2e/workspace.spec.ts

+ 28 - 2
src/client/components/library/PreviewInspector.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, nextTick, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
 import MarkdownContent from '@/components/messages/MarkdownContent.vue'
 import type { UiLibraryItem } from './types'
@@ -25,11 +25,34 @@ const isWord = computed(() => extension.value === 'docx' || /wordprocessingml/.t
 const isSpreadsheet = computed(() => ['xlsx', 'xls'].includes(extension.value) || /spreadsheetml|excel/.test(props.item.mimeType || ''))
 const isPdf = computed(() => props.item.kind === 'pdf' || extension.value === 'pdf' || props.item.mimeType === 'application/pdf')
 let previewGeneration = 0
+let officeResizeObserver: ResizeObserver | undefined
 
 const PDF_WORKER_URL = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).href
 
+function fitOfficePreview() {
+  const root = officeRoot.value
+  if (!root) return
+  root.style.setProperty('--docx-preview-scale', '1')
+  const pages = [...root.querySelectorAll<HTMLElement>('section.docx')]
+  const widestPage = Math.max(0, ...pages.map(page => page.getBoundingClientRect().width))
+  if (!widestPage) return
+  const availableWidth = Math.max(1, root.clientWidth)
+  const scale = Math.min(1, Math.max(0.1, availableWidth / widestPage))
+  root.style.setProperty('--docx-preview-scale', scale.toFixed(4))
+}
+
+function observeOfficePreview() {
+  officeResizeObserver?.disconnect()
+  if (!officeRoot.value || typeof ResizeObserver === 'undefined') return
+  officeResizeObserver = new ResizeObserver(fitOfficePreview)
+  officeResizeObserver.observe(officeRoot.value)
+}
+
 async function loadRichPreview() {
   const generation = ++previewGeneration
+  officeResizeObserver?.disconnect()
+  officeResizeObserver = undefined
+  officeRoot.value?.style.removeProperty('--docx-preview-scale')
   spreadsheetRows.value = []
   previewText.value = props.item.textContent || ''
   previewTextLoaded.value = props.item.textContent !== undefined
@@ -74,6 +97,8 @@ async function loadRichPreview() {
       if (!officeRoot.value) return
       officeRoot.value.replaceChildren()
       await renderAsync(blob, officeRoot.value, undefined, { inWrapper: true, breakPages: true, ignoreWidth: false, ignoreHeight: false })
+      fitOfficePreview()
+      observeOfficePreview()
     } else if (isSpreadsheet.value) {
       const { default: readXlsxFile } = await import('read-excel-file/browser')
       spreadsheetRows.value = await readXlsxFile(blob) as unknown[][]
@@ -89,6 +114,7 @@ async function loadRichPreview() {
 }
 
 watch(() => props.item.id, loadRichPreview, { immediate: true, flush: 'post' })
+onBeforeUnmount(() => officeResizeObserver?.disconnect())
 
 </script>
 
@@ -123,7 +149,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) { width: 100%; min-width: 0; box-sizing: border-box; align-items: flex-start; padding: 18px; background: #e6e6e4; }.preview-office :deep(.docx) { width: 100%; min-width: 0; max-width: 100%; margin-inline: auto; margin-bottom: 14px; box-shadow: 0 4px 18px rgba(0,0,0,.12); }.preview-office :deep(.docx > article) { width: 100%; min-width: 0; box-sizing: border-box; padding-inline: clamp(24px, 3vw, 36px); }
+.preview-office { width: 100%; height: 100%; overflow: auto; background: #fff; color: #111; }.preview-office :deep(.docx-wrapper) { width: 100%; min-width: 0; box-sizing: border-box; align-items: flex-start; padding: 0; background: #fff; }.preview-office :deep(.docx) { margin: 0 auto; zoom: var(--docx-preview-scale, 1); box-shadow: none; }.preview-office :deep(.docx > article) { padding: 0; }
 .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; }

+ 23 - 6
tests/e2e/workspace.spec.ts

@@ -1,6 +1,6 @@
 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'
+const WIDE_DOCX_BASE64 = 'UEsDBAoAAAAIAKwmFF15bjPX6AAAAK0BAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbH1QyU7DMBD9FWuuKHHggBCK0wPLETiUDxjZk8SqN3nc0v49Tlt6QIXjzFv1+tXeO7GjzDYGBbdtB4KCjsaGScHn+rV5AMEFg0EXAyk4EMNq6NeHRCyqNrCCuZT0KCXrmTxyGxOFiowxeyz1zJNMqDc4kbzrunupYygUSlMWDxj6Zxpx64p42df3qUcmxyCeTsQlSwGm5KzGUnG5C+ZXSnNOaKvyyOHZJr6pBJBXExbk74Cz7r0Ok60h8YG5vKGvLPkVs5Em6q2vyvZ/mys94zhaTRf94pZy1MRcF/euvSAebfjpL49zD99QSwMECgAAAAAArCYUXQAAAAAAAAAAAAAAAAYAAABfcmVscy9QSwMECgAAAAgArCYUXZv9N+qtAAAAKQEAAAsAAABfcmVscy8ucmVsc43POw7CMAwG4KtE3mlaBoRQ0y4IqSsqB7ASN61oHkrCo7cnAwNFDIy2f3+W6/ZpZnanECdnBVRFCYysdGqyWsClP232wGJCq3B2lgQsFKFt6jPNmPJKHCcfWTZsFDCm5A+cRzmSwVg4TzZPBhcMplwGzT3KK2ri27Lc8fBpwNpknRIQOlUB6xdP/9huGCZJRydvhmz6ceIrkWUMmpKAhwuKq3e7yCzwpuarF5sXUEsDBAoAAAAAAKwmFF0AAAAAAAAAAAAAAAAFAAAAd29yZC9QSwMECgAAAAgArCYUXalT+9hjAQAABwMAABEAAAB3b3JkL2RvY3VtZW50LnhtbKVSW0/CMBT+K03fpXMBJYRBFAaaYDSKwdeydluTrW3awsBfb7uVDYwmJr6cc75z+c6lHU8PZQH2VGkmeASvewEElCeCMJ5F8H29uBpCoA3mBBeC0wgeqYbTybgaEZHsSsoNsARcj6oI5sbIEUI6yWmJdU9Iym0sFarExkKVoUooIpVIqNaWvyxQGAQ3qMSMQ0e5FeTotHRCOWEmG0YomD/PPoBUdM9oNUbO7aSqZZ1stoVXL8obG1C5oa6DYWB3sq6jtOOTA4bIZ6zwUexMG0rZgRIXRJdES8WIMzOrZ6JoaAd9y4p+daOLStNQJY30xMnmrOT7fKhLPD/GKl6swfrufhWDeL6MfzgF6vr8u9vr4/Lhb+1QsyNqH0LTxHjC7O3Tv0QY9uveubUHw76/n8yesHITCRnB27DOUCzLTYu2whhRtrCgaRfLKSZURbAGqRCmBdnOeNBseBoJnf4Z6v7w5AtQSwECFAAKAAAACACsJhRdeW4z1+gAAACtAQAAEwAAAAAAAAAAAAAAAAAAAAAAW0NvbnRlbnRfVHlwZXNdLnhtbFBLAQIUAAoAAAAAAKwmFF0AAAAAAAAAAAAAAAAGAAAAAAAAAAAAEAAAABkBAABfcmVscy9QSwECFAAKAAAACACsJhRdm/036q0AAAApAQAACwAAAAAAAAAAAAAAAAA9AQAAX3JlbHMvLnJlbHNQSwECFAAKAAAAAACsJhRdAAAAAAAAAAAAAAAABQAAAAAAAAAAABAAAAATAgAAd29yZC9QSwECFAAKAAAACACsJhRdqVP72GMBAAAHAwAAEQAAAAAAAAAAAAAAAAA2AgAAd29yZC9kb2N1bWVudC54bWxQSwUGAAAAAAUABQAgAQAAyAMAAAAA'
 
 test.beforeEach(async ({ page }) => {
   await page.goto('/chat')
@@ -158,7 +158,7 @@ test('previews an octet-stream Markdown file from the file library', async ({ pa
   await expect(emptyDialog.locator('.preview-unavailable')).toHaveCount(0)
 })
 
-test('keeps DOCX text inset without horizontal clipping', async ({ page }) => {
+test('keeps DOCX content at zero inset without horizontal clipping', async ({ page }) => {
   await page.setViewportSize({ width: 620, height: 760 })
   await page.route('**/api/app/files**', async route => {
     const url = new URL(route.request().url())
@@ -205,8 +205,11 @@ test('keeps DOCX text inset without horizontal clipping', async ({ page }) => {
     const wrapper = element.querySelector<HTMLElement>('.docx-wrapper')!
     const pageElement = element.querySelector<HTMLElement>('section.docx')!
     const article = pageElement.querySelector<HTMLElement>('article')!
+    const table = article.querySelector<HTMLElement>('table')!
     const officeRect = element.getBoundingClientRect()
     const pageRect = pageElement.getBoundingClientRect()
+    const officeStyle = getComputedStyle(element)
+    const wrapperStyle = getComputedStyle(wrapper)
     const articleStyle = getComputedStyle(article)
     return {
       officeLeft: officeRect.left,
@@ -215,18 +218,32 @@ test('keeps DOCX text inset without horizontal clipping', async ({ page }) => {
       clientWidth: element.clientWidth,
       scrollWidth: element.scrollWidth,
       wrapperWidth: wrapper.getBoundingClientRect().width,
+      articlePaddingTop: Number.parseFloat(articleStyle.paddingTop),
       articlePaddingLeft: Number.parseFloat(articleStyle.paddingLeft),
       articlePaddingRight: Number.parseFloat(articleStyle.paddingRight),
-      articleMinWidth: articleStyle.minWidth,
+      tableLeft: table.getBoundingClientRect().left,
+      tableRight: table.getBoundingClientRect().right,
+      officeBackground: officeStyle.backgroundColor,
+      wrapperBackground: wrapperStyle.backgroundColor,
+      wrapperPaddingTop: Number.parseFloat(wrapperStyle.paddingTop),
+      wrapperPaddingLeft: Number.parseFloat(wrapperStyle.paddingLeft),
+      wrapperPaddingRight: Number.parseFloat(wrapperStyle.paddingRight),
     }
   })
   expect(geometry.pageWidth).toBeLessThanOrEqual(geometry.clientWidth)
   expect(geometry.pageLeft).toBeGreaterThanOrEqual(geometry.officeLeft)
   expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth + 1)
   expect(Math.abs(geometry.wrapperWidth - geometry.clientWidth)).toBeLessThan(1)
-  expect(geometry.articlePaddingLeft).toBeGreaterThanOrEqual(24)
-  expect(geometry.articlePaddingRight).toBeGreaterThanOrEqual(24)
-  expect(geometry.articleMinWidth).toBe('0px')
+  expect(geometry.articlePaddingTop).toBe(0)
+  expect(geometry.articlePaddingLeft).toBe(0)
+  expect(geometry.articlePaddingRight).toBe(0)
+  expect(geometry.tableLeft).toBeGreaterThanOrEqual(geometry.pageLeft - 1)
+  expect(geometry.tableRight).toBeLessThanOrEqual(geometry.pageLeft + geometry.pageWidth + 1)
+  expect(geometry.officeBackground).toBe('rgb(255, 255, 255)')
+  expect(geometry.wrapperBackground).toBe('rgb(255, 255, 255)')
+  expect(geometry.wrapperPaddingTop).toBe(0)
+  expect(geometry.wrapperPaddingLeft).toBe(0)
+  expect(geometry.wrapperPaddingRight).toBe(0)
 
   await page.setViewportSize({ width: 930, height: 760 })
   const centered = await office.evaluate(element => {