Make the preview pane the only scrolling preview
Why: The pane pinned .ui-fp to the column height, and the docx fit host then clipped overflow, so the right-hand preview could not scroll. The fullscreen DocViewer was a second shell around the same FilePreview. Evidence: npx vitest run (17 files, 82 tests) and npx tsc --noEmit passed. Headless Chromium laid out a 1400px docx host inside a 764px pane (body scrollHeight 1400) and a pdf iframe that filled that pane. Impact: .preview-pane-body is the scroll owner. Docx refit also watches page height. The overlay viewer is gone; expand/restore and text edit/save live on the pane. Refs: Agent-System-Dev#196, Agent-System-Frontend#44 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+6
-6
@@ -14,7 +14,6 @@ import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
||||
import { AgentTracker } from './components/AgentTracker'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||||
import { DocViewer } from './components/DocViewer'
|
||||
import { TopBar } from './components/TopBar'
|
||||
import { workspaceEntries } from './components/Workspaces'
|
||||
|
||||
@@ -37,7 +36,7 @@ export function App() {
|
||||
})
|
||||
const toast = useStore(s => s.toast)
|
||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
|
||||
const previewExpanded = previewOpen && ui.previewExpanded
|
||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||||
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||||
@@ -143,14 +142,16 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="main">
|
||||
<div className="main" hidden={previewExpanded}>
|
||||
<Conversation />
|
||||
</div>
|
||||
|
||||
{previewOpen && (
|
||||
<>
|
||||
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
|
||||
<div className="preview-slot" style={{ width: ui.previewWidth }}>
|
||||
{!previewExpanded && (
|
||||
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
|
||||
)}
|
||||
<div className={`preview-slot mk-panel-grow${previewExpanded ? ' expanded' : ''}`} style={previewExpanded ? undefined : { width: ui.previewWidth }}>
|
||||
<PreviewPane />
|
||||
</div>
|
||||
</>
|
||||
@@ -180,7 +181,6 @@ export function App() {
|
||||
{ui.projectSettingsId && <ProjectSettingsModal />}
|
||||
{ui.adminZoneOpen && <AdminZone />}
|
||||
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||||
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ export function ArtifactsPanel() {
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
// 预览落在对话右侧预览区。弹层盖住主区,先收起再打开。
|
||||
// 预览落在对话右侧预览区。工作区弹层盖住主区,先收起再打开。
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}`}>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
@@ -46,7 +46,7 @@ export function ArtifactsPanel() {
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="全屏预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a, 'full') }}><Icon name="eye" size={14} /></button>
|
||||
<button className="icon-btn" title="展开预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// 文档查看器(4.4 → 预览能力收敛):格式渲染统一交给 @ui FilePreview
|
||||
// (md/图片/pdf/代码/数据/docx/xlsx/pptx/URL 一套分派),这里只保留 OverlayHost
|
||||
// 容器、工具栏与文本族的在线编辑保存。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store } from '../store'
|
||||
import { OverlayHost, Icon, Btn, FilePreview } from '@ui'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
|
||||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const textKind = isTextKind(artifact)
|
||||
useEffect(() => {
|
||||
if (!textKind) return
|
||||
setContent(null)
|
||||
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
|
||||
}, [artifact.id])
|
||||
|
||||
const source = {
|
||||
kind: 'artifact' as const,
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
mime: artifact.mime,
|
||||
contentUrl: api.artifactContentUrl(artifact.id),
|
||||
downloadUrl: api.artifactDownloadUrl(artifact.id),
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
||||
<div className="docv">
|
||||
<div className="docv-toolbar">
|
||||
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
|
||||
<span className="spacer" />
|
||||
{dirty && <span className="docv-dirty">未保存</span>}
|
||||
{textKind && (
|
||||
<>
|
||||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||
await api.saveArtifactContent(artifact.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
<a className="btn v-ghost docv-dl" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
|
||||
<Icon name="download" size={14} /> 下载
|
||||
</a>
|
||||
</div>
|
||||
<div className="docv-body">
|
||||
{editing && textKind ? (
|
||||
<textarea className="search-input docv-editor" value={content ?? ''}
|
||||
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
) : (
|
||||
<FilePreview source={source} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OverlayHost>
|
||||
)
|
||||
}
|
||||
|
||||
function isTextKind(a: Artifact): boolean {
|
||||
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// 对话右侧预览区:多标签、可关、全屏与下载。宽度由 App 的分隔条控制。
|
||||
import { FilePreview, TabStrip, Icon } from '@ui'
|
||||
// 对话右侧预览区:多标签、可关、展开与下载。宽度由 App 的分隔条控制;
|
||||
// 展开态铺满主区。文本类文件在这里编辑保存。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Btn, FilePreview, Icon, TabStrip } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
import { store, useStore } from '../store'
|
||||
|
||||
export function PreviewPane() {
|
||||
@@ -8,9 +12,28 @@ export function PreviewPane() {
|
||||
const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? [])
|
||||
const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? null)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const expanded = useStore(s => s.ui.previewExpanded)
|
||||
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const textKind = !!art && isTextKind(art)
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false)
|
||||
setDirty(false)
|
||||
setContent(null)
|
||||
if (!art || !isTextKind(art)) return
|
||||
let cancelled = false
|
||||
fetch(api.artifactContentUrl(art.id))
|
||||
.then(r => r.text())
|
||||
.then(text => { if (!cancelled) setContent(text) })
|
||||
.catch(() => { if (!cancelled) setContent('(读取失败)') })
|
||||
return () => { cancelled = true }
|
||||
}, [art?.id])
|
||||
|
||||
if (!active) return null
|
||||
const art = artifacts.find(a => a.id === active.id)
|
||||
return (
|
||||
<div className="preview-pane">
|
||||
<TabStrip
|
||||
@@ -21,11 +44,25 @@ export function PreviewPane() {
|
||||
/>
|
||||
<div className="preview-pane-bar">
|
||||
<span className="preview-pane-name" title={active.name}>{active.name}</span>
|
||||
{art && (
|
||||
<span className="preview-pane-meta">{art.kind}{humanSize(art.size) ? ` · ${humanSize(art.size)}` : ''}</span>
|
||||
)}
|
||||
<span className="preview-pane-spacer" />
|
||||
{art && (
|
||||
<>
|
||||
<button type="button" className="icon-btn" title="全屏预览" onClick={() => store.requestPreview(art, 'full')}>
|
||||
<Icon name="eye" size={14} />
|
||||
{dirty && <span className="preview-pane-dirty">未保存</span>}
|
||||
{textKind && (
|
||||
<>
|
||||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||
await api.saveArtifactContent(art.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="icon-btn" title={expanded ? '还原预览' : '展开预览'} aria-pressed={expanded} onClick={() => store.togglePreviewExpanded()}>
|
||||
<Icon name={expanded ? 'minimize' : 'maximize'} size={14} />
|
||||
</button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(art.id)} download={art.name}>
|
||||
<Icon name="download" size={14} />
|
||||
@@ -34,7 +71,10 @@ export function PreviewPane() {
|
||||
)}
|
||||
</div>
|
||||
<div className="preview-pane-body">
|
||||
{art ? (
|
||||
{editing && textKind ? (
|
||||
<textarea className="search-input preview-pane-editor" value={content ?? ''}
|
||||
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
) : art ? (
|
||||
<FilePreview source={{
|
||||
kind: 'artifact',
|
||||
id: art.id,
|
||||
@@ -48,3 +88,7 @@ export function PreviewPane() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isTextKind(a: Artifact): boolean {
|
||||
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
|
||||
}
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ export function workedLabel(createdAt?: string, endedAt?: string): string {
|
||||
return text ? `已工作 ${text}` : '已工作'
|
||||
}
|
||||
|
||||
/** 文件大小人类可读(工件面板/DocViewer 共用)。 */
|
||||
/** 文件大小人类可读(工件面板/预览栏共用)。 */
|
||||
export function humanSize(n: number | undefined): string {
|
||||
if (!n || n <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
|
||||
@@ -32,6 +32,9 @@ export function fitPagesToWidth(host: HTMLElement, el: HTMLElement): () => void
|
||||
}
|
||||
const observer = new ResizeObserver(() => apply())
|
||||
observer.observe(el)
|
||||
// 页内图片后加载只改变页纸高度,不改变栏宽。el 变宽和 wrapper 变高都要重算宿主高度。
|
||||
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
|
||||
if (wrapper && wrapper !== el) observer.observe(wrapper)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
clearFit(host)
|
||||
|
||||
+10
-7
@@ -43,9 +43,10 @@ export interface UIState {
|
||||
autoCollapseProcess: boolean
|
||||
settingsOpen: boolean
|
||||
trackerRunId: string | null
|
||||
viewingArtifact: Artifact | null
|
||||
/** 预览区宽度。标签列表按会话放在 AppState,不进本地偏好。 */
|
||||
previewWidth: number
|
||||
/** 预览栏铺满主区。标签不持久化,这个标志在 loadUI 时强制复位。 */
|
||||
previewExpanded: boolean
|
||||
activeWorkspace: string | null
|
||||
taskOrg: 'project' | 'timeline'
|
||||
showArchived: boolean
|
||||
@@ -105,8 +106,8 @@ const DEFAULT_UI: UIState = {
|
||||
autoCollapseProcess: true,
|
||||
settingsOpen: false,
|
||||
trackerRunId: null,
|
||||
viewingArtifact: null,
|
||||
previewWidth: 640,
|
||||
previewExpanded: false,
|
||||
taskOrg: 'project',
|
||||
showArchived: false,
|
||||
lastProjectId: null,
|
||||
@@ -122,6 +123,8 @@ function loadUI(): UIState {
|
||||
const ui = { ...DEFAULT_UI, ...JSON.parse(raw) } as UIState
|
||||
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
|
||||
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
|
||||
// 预览标签不进本地偏好。展开态若随刷新残留,会在没有标签时藏起对话。
|
||||
ui.previewExpanded = false
|
||||
return ui
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -778,13 +781,11 @@ export const store = {
|
||||
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
|
||||
},
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
|
||||
/**
|
||||
* 工件预览唯一入口。默认在对话右侧预览区打开或激活标签;hint 'full' 落位全屏
|
||||
* DocViewer。打开第一张标签时收起右栏,关掉最后一张时恢复。
|
||||
* 工件预览唯一入口:在对话右侧预览区打开或激活标签。
|
||||
* 打开第一张标签时收起右栏,关掉最后一张时恢复,并收回展开态。
|
||||
*/
|
||||
requestPreview(a: Artifact, hint?: 'full') {
|
||||
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
|
||||
requestPreview(a: Artifact) {
|
||||
const conv = state.currentConvId ?? '_'
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
|
||||
set({
|
||||
@@ -793,6 +794,7 @@ export const store = {
|
||||
})
|
||||
syncPreviewRail(true)
|
||||
},
|
||||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||
activatePreview(id: string) {
|
||||
const conv = state.currentConvId ?? '_'
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id } })
|
||||
@@ -804,6 +806,7 @@ export const store = {
|
||||
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||||
})
|
||||
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||||
syncPreviewRail(next.tabs.length > 0)
|
||||
},
|
||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||
|
||||
+9
-13
@@ -196,13 +196,21 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
|
||||
/* 对话右侧预览区 */
|
||||
.preview-slot { flex-shrink: 0; min-width: 0; display: flex; }
|
||||
.preview-slot.expanded { flex: 1 1 auto; width: auto; }
|
||||
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.preview-pane-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); }
|
||||
.preview-pane-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.preview-pane-spacer { flex: 1; }
|
||||
.preview-pane-body { flex: 1; min-height: 0; overflow: auto; }
|
||||
.preview-pane-body .ui-fp { height: 100%; }
|
||||
/* 只给下限,不钉死高度:内容超出时由本层滚动。PDF iframe 靠自身 flex:1 撑满。 */
|
||||
.preview-pane-body .ui-fp { min-height: 100%; }
|
||||
.preview-pane-body .ui-fp-pdf { height: 100%; min-height: 480px; border: none; }
|
||||
.preview-pane-meta { flex-shrink: 0; font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
|
||||
.preview-pane-dirty { color: var(--warn); font-size: var(--fs-ui-sm); }
|
||||
.preview-pane-editor {
|
||||
display: block; width: 100%; height: 100%; min-height: 0; resize: none;
|
||||
font-family: var(--mono); font-size: var(--fs-ui-sm); background: var(--bg); color: var(--text);
|
||||
}
|
||||
.docx-wrapper { background: var(--bg-inset) !important; padding: 12px; display: flex; flex-direction: column; align-items: flex-start !important; }
|
||||
.docx-wrapper > section.docx { background: var(--paper) !important; color: var(--paper-ink); box-shadow: var(--shadow-md); }
|
||||
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
|
||||
@@ -318,18 +326,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.effect-hot { color: var(--ok); border-color: var(--ok); }
|
||||
.effect-new-run { color: var(--warn); border-color: var(--warn); }
|
||||
|
||||
/* ---- 文档查看器(单层 OverlayHost 内)---- */
|
||||
.docv { display: flex; flex-direction: column; min-height: 0; }
|
||||
.docv-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; border-bottom: 1px solid var(--border-soft); margin-bottom: 10px; }
|
||||
.docv-toolbar .spacer { flex: 1; }
|
||||
.docv-meta { font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
|
||||
.docv-dirty { color: var(--warn); font-size: var(--fs-ui-sm); }
|
||||
.docv-dl { text-decoration: none; }
|
||||
.docv-body { flex: 1; min-height: 0; overflow: auto; }
|
||||
.docv-pdf { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg); }
|
||||
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: var(--fs-ui-sm); resize: vertical; }
|
||||
.docv-pre { font-family: var(--mono); font-size: var(--fs-ui-sm); white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
|
||||
|
||||
/* ---- 杂项 ---- */
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: var(--fs-ui-caption); }
|
||||
.conn-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
|
||||
+91
-2
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeFitScale } from '../src/previewfit'
|
||||
import { afterEach, describe, it, expect } from 'vitest'
|
||||
import { computeFitScale, fitPagesToWidth } from '../src/previewfit'
|
||||
|
||||
describe('computeFitScale', () => {
|
||||
it('keeps the page at 100% when the column is wider than the page', () => {
|
||||
@@ -19,3 +19,92 @@ describe('computeFitScale', () => {
|
||||
expect(computeFitScale(Number.NaN, 794, 24)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// node 测试环境没有 DOM。这里只实现 fitPagesToWidth 会碰到的查询与尺寸。
|
||||
class FakeEl {
|
||||
style: { height: string; overflow: string; minHeight: string; transform: string; transformOrigin: string } = {
|
||||
height: '', overflow: '', minHeight: '', transform: '', transformOrigin: '',
|
||||
}
|
||||
clientWidth = 0
|
||||
offsetWidth = 0
|
||||
offsetHeight = 0
|
||||
className = ''
|
||||
tag = 'div'
|
||||
children: FakeEl[] = []
|
||||
querySelector(sel: string): FakeEl | null {
|
||||
const walk = (nodes: FakeEl[]): FakeEl | null => {
|
||||
for (const node of nodes) {
|
||||
if (matches(node, sel)) return node
|
||||
const hit = walk(node.children)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(this.children)
|
||||
}
|
||||
}
|
||||
|
||||
function matches(el: FakeEl, sel: string): boolean {
|
||||
const classes = el.className.split(/\s+/).filter(Boolean)
|
||||
if (sel.startsWith('.')) return classes.includes(sel.slice(1))
|
||||
const [tag, cls] = sel.split('.')
|
||||
return el.tag === tag && classes.includes(cls)
|
||||
}
|
||||
|
||||
class FakeResizeObserver {
|
||||
static observed: FakeEl[] = []
|
||||
static instances: FakeResizeObserver[] = []
|
||||
constructor(private callback: () => void) {
|
||||
FakeResizeObserver.instances.push(this)
|
||||
}
|
||||
observe(el: FakeEl) { FakeResizeObserver.observed.push(el) }
|
||||
disconnect() { /* dispose 只要求卸下监听 */ }
|
||||
static fire() {
|
||||
for (const instance of FakeResizeObserver.instances) instance.callback()
|
||||
}
|
||||
static reset() {
|
||||
FakeResizeObserver.observed = []
|
||||
FakeResizeObserver.instances = []
|
||||
}
|
||||
}
|
||||
|
||||
describe('fitPagesToWidth', () => {
|
||||
const previous = globalThis.ResizeObserver
|
||||
|
||||
afterEach(() => {
|
||||
FakeResizeObserver.reset()
|
||||
globalThis.ResizeObserver = previous
|
||||
})
|
||||
|
||||
it('recomputes the host height when the page wrapper grows and clears inline styles on dispose', () => {
|
||||
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
|
||||
const host = new FakeEl()
|
||||
host.clientWidth = 420
|
||||
const wrapper = new FakeEl()
|
||||
wrapper.className = 'docx-wrapper'
|
||||
wrapper.offsetHeight = 1000
|
||||
const page = new FakeEl()
|
||||
page.tag = 'section'
|
||||
page.className = 'docx'
|
||||
page.offsetWidth = 794
|
||||
wrapper.children.push(page)
|
||||
host.children.push(wrapper)
|
||||
|
||||
const dispose = fitPagesToWidth(host as unknown as HTMLElement, host as unknown as HTMLElement)
|
||||
const scale = (420 - 24) / 794
|
||||
expect(host.style.height).toBe(`${Math.round(1000 * scale)}px`)
|
||||
expect(host.style.overflow).toBe('hidden')
|
||||
expect(FakeResizeObserver.observed).toContain(wrapper)
|
||||
|
||||
wrapper.offsetHeight = 1500
|
||||
FakeResizeObserver.fire()
|
||||
expect(host.style.height).toBe(`${Math.round(1500 * scale)}px`)
|
||||
|
||||
dispose()
|
||||
expect(host.style.height).toBe('')
|
||||
expect(host.style.overflow).toBe('')
|
||||
expect(host.style.minHeight).toBe('')
|
||||
expect(wrapper.style.transform).toBe('')
|
||||
expect(wrapper.style.transformOrigin).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user