Merge pull request #7529 from deepseek-harness/fix/issue7241-preview-scroll
fix(artifact): 刷新后保留文本预览的阅读位置
This commit is contained in:
commit
568f93e617
10 files changed
+247
-44
No files matched your search
@@ -1563,6 +1563,62 @@ else process.exit(1);
|
||||
await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'applications-no-default.expected.md', 'paging.patch.yml'])
|
||||
})
|
||||
|
||||
it('preserves Markdown reading position beyond the first page after automatic and manual refresh', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, `screenshots/0908-document-preview/refresh-scroll-${process.pid}`))
|
||||
if (await page.getByText('LIGHTHOUSE', { exact: true }).count() === 0) {
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
await settled
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
}
|
||||
const cwd = scaffold.ctx.agents.list()[0]?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('Session has no workspace cwd')
|
||||
const path = join(cwd, 'refresh-scroll.md')
|
||||
const markdown = '# Before refresh\n\n' + Array.from({ length: PAGE_LINES * 2 }, (_, index) => `Paragraph ${index + 1}\n`).join('\n')
|
||||
await writeFile(path, markdown)
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
const expand = page.locator('[data-sidebar-right-expand]')
|
||||
if (await expand.isVisible()) await expand.click()
|
||||
const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
|
||||
if (await filesTab.count() === 0) await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await filesTab.click()
|
||||
await column.locator('[data-files-reload]').click()
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
await openPreviewFile(column, filesTab, preview, 'refresh-scroll.md')
|
||||
const body = preview.locator('[data-textpreview-body]')
|
||||
const heading = body.getByRole('heading', { level: 1 })
|
||||
await expect.poll(() => heading.textContent()).toBe('Before refresh')
|
||||
await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
|
||||
const target = await body.evaluate(node => node.scrollHeight + 100)
|
||||
await scrollForNextPage(body)
|
||||
await expect.poll(() => body.evaluate(node => node.scrollHeight - node.clientHeight)).toBeGreaterThan(target)
|
||||
await body.evaluate((node, top) => new Promise<void>((resolve) => {
|
||||
node.addEventListener('scroll', () => { resolve() }, { once: true })
|
||||
node.scrollTop = top
|
||||
}), target)
|
||||
const original = await body.evaluate(node => node.scrollTop)
|
||||
expect(original).toBe(target)
|
||||
// Content growing above the viewport keeps the pixel offset instead of following the content.
|
||||
await heading.evaluate((node) => { node.style.paddingTop = '300px' })
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
expect(await body.evaluate(node => node.scrollTop)).toBe(original)
|
||||
await heading.evaluate((node) => { node.style.paddingTop = '' })
|
||||
|
||||
await writeFile(path, markdown.replace('Before refresh', 'After refresh'))
|
||||
await expect.poll(() => heading.textContent()).toBe('After refresh')
|
||||
await expect.poll(() => body.evaluate((node, top) => Math.abs(node.scrollTop - top), original)).toBeLessThanOrEqual(1)
|
||||
|
||||
const rendered = await heading.elementHandle()
|
||||
await preview.locator('[data-textpreview-tool="reload"]').click()
|
||||
await expect.poll(() => rendered!.evaluate(node => node.isConnected)).toBe(false)
|
||||
await expect.poll(() => heading.textContent()).toBe('After refresh')
|
||||
await expect.poll(() => body.evaluate((node, top) => Math.abs(node.scrollTop - top), original)).toBeLessThanOrEqual(1)
|
||||
await rendered!.dispose()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
en: 0dd8c3a22e28aacf
|
||||
zh: c27c11bf4b5cceba
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers:
|
||||
en: a02e6fd89e332967
|
||||
zh: b5c61cb75458d1a2
|
||||
en: 2c4b32977644423b
|
||||
zh: 73dcf722f8f8ed3e
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses:
|
||||
en: 57942270e23d3a8e
|
||||
zh: 6617124e652b3f67
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads:
|
||||
en: e197303166b5a86f
|
||||
zh: 8d151e49321d67aa
|
||||
en: e3ca76083e667293
|
||||
zh: 4b52ffa2848d9f87
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
|
||||
en: 595499992ceea693
|
||||
zh: 83c6147c46a80dca
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 372d8ce6492e6896
|
||||
zh: 912f613bc9221aa3
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/navigation:
|
||||
en: 7313ef5591ef162d
|
||||
zh: fbc3948329ac5bab
|
||||
en: 33215200b3e4a8ed
|
||||
zh: 499e4b834f2db95a
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/model-experience:
|
||||
en: ca1deb99d0e44755
|
||||
zh: f285fc56cee669ec
|
||||
@@ -39,8 +39,8 @@
|
||||
en: 7edb4c0bb5f13847
|
||||
zh: ae5d0bbebe4aa563
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/known-limitations-and-deferred-work:
|
||||
en: 7be2ad2e198131f8
|
||||
zh: 21b35eae72a49ad2
|
||||
en: ad44f28dc14beb4a
|
||||
zh: e9f096bd9e63e72e
|
||||
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -34,7 +34,7 @@ Image drag actions arrive through the image entry’s inject face from [ui-file-
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
|
||||
- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
|
||||
|
||||
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode.
|
||||
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. `sourceLines` declares that a `text-pages` body exposes source-line anchors for [navigation](#navigation). Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode.
|
||||
|
||||
`loading: 'text-pages'` and `'bytes-complete'` use the shared file reader. With `'renderer'`, the selected body mounts before any bytes are read and receives `content: { kind: 'renderer', revision, loaded, failed, reload }`. Its injected face owns content loading, errors, store updates, and cancellation. `loaded(version)` records the displayed source version and ends loading. `failed()` ends loading without recording a successful version, allowing a later file change to retry; reports from replaced revisions are ignored. `reload()` increments the revision, which the body observes to cancel and replace its request. The body also cancels on unmount and tab closure, retains settled content in its declared tab store, and releases that state when the tab ends. [Office previews](#office-preview) use this mode without putting converted bytes or font metadata in the shared file store.
|
||||
|
||||
@@ -57,7 +57,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. `
|
||||
- The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable.
|
||||
- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it.
|
||||
- **Complete bytes** — PDF, HTML, common images, and spreadsheets call `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`. The binary Remote returns `data: Uint8Array<ArrayBuffer>` directly for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF and spreadsheet renderers copy retained bytes before Worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results.
|
||||
- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice.
|
||||
- **Reload** — manual reload rereads only the current Preview tab through its Remote callbacks, preserving its scroll preference and retiring older requests. Automatic refresh uses the same callbacks after a `ResourceGroup` member changes. Each tab remembers its scroll offset; new content moves the view down to that offset when it is below the current one, and text previews read further pages until the offset is reachable or the file ends. A scroll event at the end of content shorter than the remembered offset is a layout clamp and leaves the offset unchanged, so a file that shrinks and grows back returns to it. The offset is a pixel distance; it does not follow a paragraph that moves when earlier content changes. Each member's first metadata establishes a baseline without triggering reload or first-read version reconciliation; later changes received during a read remain pending for another refresh. Reads neither refresh shared metadata nor clear another tab's notice.
|
||||
|
||||
When Coding Tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `<base href>` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL.
|
||||
|
||||
@@ -125,7 +125,7 @@ The page refresh shortcut reuses the selected preview’s reload operation, incl
|
||||
<a id="navigation"></a>
|
||||
## Navigation
|
||||
|
||||
`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers expose source-line anchors; Markdown does not. A navigation remains pending while its selected renderer has no anchor and runs if the user switches to plain text or code. Code navigation scrolls the inner source viewport directly. The landed line receives a band that holds for 0.6 s and then fades out over 1.4 s; toggling wrap or resizing the preview removes it early; restoring a remounted tab's scroll position adds no band. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
|
||||
`ctx.sidebarRight.openResource(address, { params: { line } })` carries a 1-based source line through the `file` parameters. In `text-pages` mode, the owner loads sequential pages until that line or EOF. Plain-text and code renderers declare `sourceLines`; Markdown does not. A renderer without `sourceLines` answers the navigation without moving the body, and a later switch to plain text or code does not replay it. A navigation stays pending while a `sourceLines` renderer has not drawn the line. Code navigation scrolls the inner source viewport directly. The landed line receives a band that holds for 0.6 s and then fades out over 1.4 s; toggling wrap or resizing the preview removes it early; restoring a remounted tab's scroll position adds no band. Byte-mode renderers do not consume source-line navigation. Each completed navigation revision is answered once. Opening the same file without `revealIfOpened: false` focuses its existing tab and delivers a new revision.
|
||||
|
||||
Document path labels expose a file [context menu](../ui-context-menu/README.md), retaining the selected line and current pane.
|
||||
|
||||
@@ -147,7 +147,7 @@ No direct effect; what the user reads here never enters a model request.
|
||||
- **PDF raster allocation is bounded.** Each page bitmap is capped at 16,777,216 pixels; very large pages or high zoom on high-density displays can still render below device resolution.
|
||||
- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; fixed PDF and image zoom can overflow horizontally, and HTML iframe scrolling belongs to its opaque browsing context.
|
||||
- **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe.
|
||||
- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
|
||||
- **Scroll writes are unthrottled.** Every scroll event except a layout clamp records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
|
||||
- **A failed PDF chunk load requires a page reload.** React caches a rejected lazy import for the page lifetime; ordinary PDF open or render failures remain retryable inside the loaded body.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -34,7 +34,7 @@ kind: "package-reference"
|
||||
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
|
||||
- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
|
||||
|
||||
文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。
|
||||
文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。`sourceLines` 声明 `text-pages` 正文为[导航](#navigation)提供源码行锚点。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。
|
||||
|
||||
`loading: 'text-pages'` 和 `'bytes-complete'` 使用共享文件读取器。选择 `'renderer'` 时,所选正文在读取任何字节前挂载,并接收 `content: { kind: 'renderer', revision, loaded, failed, reload }`。其注入 face 负责内容加载、错误、store 更新和取消。`loaded(version)` 记录已展示的源版本并结束加载。`failed()` 结束加载但不记录成功版本,使后续文件变化可以重试;已被替换的 revision 所发出的报告会被忽略。`reload()` 增加 revision,正文据此取消并替换当前请求。正文也在卸载和 tab 关闭时取消请求,将已完成内容保留在自己声明的 tab store 中,并在 tab 结束时释放。[Office 预览](#office-preview) 使用此模式,转换后的字节和字体元数据不会进入共享文件 store。
|
||||
|
||||
@@ -57,7 +57,7 @@ Web 和桌面端均通过代码工作工具选择 HTML 预览策略。渲染器
|
||||
- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。
|
||||
- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。
|
||||
- **完整字节** —— PDF、HTML、常见图片和表格调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`。二进制 Remote 直接返回 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 和表格渲染器在传给 Worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
|
||||
- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。
|
||||
- **重新载入** —— 手动重新载入仅让当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。`ResourceGroup` 成员变化后,自动刷新使用相同回调。每个 tab 记忆自己的滚动偏移;新内容到达时,若记忆偏移在当前位置之下,视图移到该偏移,文本预览会继续读取后续页,直到该偏移可达或文件结束。内容短于记忆偏移时,停在内容末尾的滚动事件属于布局夹紧,不改写记忆偏移,因此文件变短后再变长仍会回到该位置。记忆的是像素距离,前文变化导致段落移动时不跟随段落。各成员首次元数据仅建立基线,不触发重新载入或首读版本对账;后续在读取期间收到的变化仍会留待下一次刷新。读取既不刷新共享元数据,也不清除其它 tab 的提示。
|
||||
|
||||
开启代码工作工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`<base href>` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。
|
||||
|
||||
@@ -125,7 +125,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8
|
||||
<a id="navigation"></a>
|
||||
## 导航
|
||||
|
||||
`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。落定的行显示一条高亮带,停留 0.6 秒后在 1.4 秒内渐隐;切换换行或调整预览尺寸会提前移除它;重新挂载的 tab 恢复滚动位置时不显示高亮带。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
|
||||
`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器声明 `sourceLines`;Markdown 不声明。未声明 `sourceLines` 的渲染器直接响应导航、不移动正文,之后切换到纯文本或代码也不会重放。`sourceLines` 渲染器尚未画出该行时,导航保持待处理。代码导航直接滚动内部源码视口。落定的行显示一条高亮带,停留 0.6 秒后在 1.4 秒内渐隐;切换换行或调整预览尺寸会提前移除它;重新挂载的 tab 恢复滚动位置时不显示高亮带。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。
|
||||
|
||||
文档路径标签提供文件[右键菜单](../ui-context-menu/README.zh.md),保留选中行号与当前窗格。
|
||||
|
||||
@@ -147,7 +147,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8
|
||||
- **PDF 栅格分配有上限。** 每页位图最多为 16,777,216 像素;超大页面或高像素密度屏幕上的高比例缩放仍可能低于设备分辨率。
|
||||
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;固定 PDF 与图片缩放可产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
|
||||
- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
|
||||
- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
|
||||
- **滚动写入未节流。** 除布局夹紧外,每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
|
||||
- **PDF chunk 加载失败后需要刷新页面。** React 会在页面生命周期内缓存被拒绝的 lazy import;已加载正文中的普通 PDF 打开或渲染失败仍可重试。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
/* The remembered offset is a pixel distance; browser anchoring would move it when content above it reflows. */
|
||||
overflow-anchor: none;
|
||||
/* The notice and retry surfaces in this sheet are elevated, so the file body's
|
||||
scroller rebinds the thumb indirection in a complete pair. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
|
||||
@@ -125,21 +125,31 @@ export function TextPreview({
|
||||
}, [started, tab.id, file, signal, loadPage, loadAll, prepareRenderer, canRead, mode, selected, meta.value?.version])
|
||||
|
||||
// Come back where the reader was once there is content to scroll: on a remount,
|
||||
// after a reload rebuilt the content, or after the selected renderer changed.
|
||||
// Scroll writes preserve both identities, so they never re-land.
|
||||
// after a reload rebuilt the content, after the selected renderer changed, or
|
||||
// after another text page arrived. The offset only moves down to the remembered
|
||||
// one, and the next page is read while the remembered offset lies past the
|
||||
// loaded pages. A line navigation not answered yet positions the body itself.
|
||||
// Scroll writes preserve every identity here, so they never re-land.
|
||||
useEffect(() => {
|
||||
const body = scrollportRef.current
|
||||
if (hasContent && body !== null && state !== undefined) body.scrollTop = state.scrollTop
|
||||
}, [hasContent, selected?.id])
|
||||
if (!hasContent || body === null || state === undefined) return
|
||||
if (mode === 'text-pages' && line !== undefined && current?.revision !== navigation.revision) return
|
||||
body.scrollTop = Math.max(body.scrollTop, state.scrollTop)
|
||||
if (mode === 'text-pages' && body.scrollTop + 1 < state.scrollTop && current !== undefined
|
||||
&& !current.eof && !current.loading && current.failure === undefined && canRead) {
|
||||
loadPage(tab.id, file, loadedThrough + 1, signal, meta.value?.version)
|
||||
}
|
||||
}, [hasContent, selected?.id, pages, current?.revision])
|
||||
|
||||
// Answer a navigation once: a line the pages do not reach yet loads the next
|
||||
// page (again, until the pages cover it or the file ends); a line they hold
|
||||
// is scrolled to and marked with a fading band. The store remembers the
|
||||
// answer, so a remount restores the reader's place instead.
|
||||
// is scrolled to and marked with a fading band. A renderer without source-line
|
||||
// anchors answers it without moving. The store remembers the answer, so a
|
||||
// remount restores the reader's place instead.
|
||||
useEffect(() => {
|
||||
const body = scrollportRef.current
|
||||
if (current === undefined || body === null || current.revision === navigation.revision) return
|
||||
if (line === undefined || mode !== 'text-pages') {
|
||||
if (line === undefined || mode !== 'text-pages' || selected?.sourceLines !== true) {
|
||||
actions.navigated(tab.id, navigation.revision)
|
||||
return
|
||||
}
|
||||
@@ -345,7 +355,11 @@ export function TextPreview({
|
||||
/* v8 ignore next -- callback refs bind the scrollport during commit, before user input. */
|
||||
if (body === null) return
|
||||
if (event.target !== body) return
|
||||
actions.scrolled(tab.id, body.scrollTop)
|
||||
// Shorter or emptied content pins the body to its end below the remembered
|
||||
// offset; that clamp is layout, not the reader, so the offset survives it.
|
||||
const clamped = body.scrollTop >= body.scrollHeight - body.clientHeight - 1
|
||||
&& body.scrollTop < state.scrollTop
|
||||
if (!clamped) actions.scrolled(tab.id, body.scrollTop)
|
||||
if (mode === 'text-pages' && current?.failure === undefined && body.clientHeight > 0
|
||||
&& body.scrollTop + body.clientHeight >= body.scrollHeight - 1) loadNext()
|
||||
}}
|
||||
|
||||
@@ -19,6 +19,7 @@ export function apply(ctx: Context): void {
|
||||
title: () => t('title'),
|
||||
loading: 'text-pages',
|
||||
wrap: true,
|
||||
sourceLines: true,
|
||||
}))
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register(
|
||||
{ name: 'sidebar.right.tab.document', key: ID, locale: NS }, CodeBody,
|
||||
|
||||
@@ -25,6 +25,11 @@ export interface DocumentPreviewDefinition {
|
||||
readonly loading: DocumentLoadMode
|
||||
/** Whether the implementation consumes the document's wrap preference. */
|
||||
readonly wrap?: boolean
|
||||
/**
|
||||
* Whether a `text-pages` body exposes source-line anchors for line navigation;
|
||||
* without it, a navigation to a line is answered without moving the body.
|
||||
*/
|
||||
readonly sourceLines?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ export const PLAIN_BODY_ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview
|
||||
* @returns plain-text registration metadata.
|
||||
*/
|
||||
export function textBodyDefinition(title: () => string): DocumentPreviewDefinition {
|
||||
return { id: PLAIN_BODY_ID, extensions: [], priority: 'builtin', title, loading: 'text-pages', wrap: true }
|
||||
return { id: PLAIN_BODY_ID, extensions: [], priority: 'builtin', title, loading: 'text-pages', wrap: true, sourceLines: true }
|
||||
}
|
||||
|
||||
/** @param ctx - owning plugin context. Register the fallback metadata and keyed body. */
|
||||
|
||||
@@ -99,7 +99,7 @@ class PendingIntersectionObserver {
|
||||
function codeProps(h: ReturnType<typeof harness>, navigation: { params?: unknown; revision: number }): TextPreviewProps {
|
||||
const props = h.props(navigation)
|
||||
const definition: DocumentPreviewDefinition = {
|
||||
id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true,
|
||||
id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true, sourceLines: true,
|
||||
}
|
||||
return {
|
||||
...props,
|
||||
@@ -492,36 +492,85 @@ describe('TextPreview — navigation and view', () => {
|
||||
expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(360)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Code', 'code', 2 * LINE_HEIGHT],
|
||||
['Plain text', PLAIN_BODY_ID, 2 * LINE_HEIGHT],
|
||||
])('retries a Markdown line navigation after switching to %s', async (_name, rendererId, expectedScrollTop) => {
|
||||
PendingIntersectionObserver.instances = []
|
||||
vi.stubGlobal('IntersectionObserver', PendingIntersectionObserver)
|
||||
const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
|
||||
const definitions: DocumentPreviewDefinition[] = [
|
||||
{ id: 'markdown', extensions: ['md'], title: () => 'Markdown', loading: 'text-pages', wrap: false },
|
||||
{ id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true },
|
||||
{ id: PLAIN_BODY_ID, extensions: [], title: () => 'Plain text', loading: 'text-pages', wrap: true },
|
||||
]
|
||||
const base = h.props({ params: { line: 3 }, revision: 1 })
|
||||
const props: TextPreviewProps = {
|
||||
const CODE_DEFINITION: DocumentPreviewDefinition = {
|
||||
id: 'code', extensions: ['md'], title: () => 'Code', loading: 'text-pages', wrap: true, sourceLines: true,
|
||||
}
|
||||
const PLAIN_DEFINITION: DocumentPreviewDefinition = {
|
||||
id: PLAIN_BODY_ID, extensions: [], title: () => 'Plain text', loading: 'text-pages', wrap: true, sourceLines: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer `definitions` for a Markdown file; plain text renders lines, Code renders lines unless `codeDrawsLines` is false,
|
||||
* and every other renderer draws no source lines.
|
||||
*/
|
||||
function rendererProps(
|
||||
h: ReturnType<typeof harness>,
|
||||
navigation: { params?: unknown; revision: number },
|
||||
definitions: DocumentPreviewDefinition[],
|
||||
codeDrawsLines: boolean,
|
||||
): TextPreviewProps {
|
||||
const base = h.props(navigation)
|
||||
return {
|
||||
...base,
|
||||
useDocumentPreviews: selector => selector(definitions),
|
||||
renderSlot: documentSlots((_key, owner, opts) => {
|
||||
const documentOwner = owner as unknown as OwnerOf<'sidebar.right.tab.document'>
|
||||
if (opts.entryKey === 'code') return <CodeBody {...base} {...documentOwner} t={key => key} />
|
||||
if (opts.entryKey === 'code' && codeDrawsLines) return <CodeBody {...base} {...documentOwner} t={key => key} />
|
||||
if (opts.entryKey === PLAIN_BODY_ID) return <TextBody {...base} {...documentOwner} />
|
||||
return <div data-test-no-lines />
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** A Markdown file that also offers Code and plain text; only the latter two expose source lines. */
|
||||
function markdownProps(h: ReturnType<typeof harness>, navigation: { params?: unknown; revision: number }): TextPreviewProps {
|
||||
const markdown: DocumentPreviewDefinition = { id: 'markdown', extensions: ['md'], title: () => 'Markdown', loading: 'text-pages', wrap: false }
|
||||
return rendererProps(h, navigation, [markdown, CODE_DEFINITION, PLAIN_DEFINITION], true)
|
||||
}
|
||||
|
||||
it.each([['Code', 'code'], ['Plain text', PLAIN_BODY_ID]])(
|
||||
'answers a line navigation in Markdown without moving, and switching to %s does not jump later',
|
||||
async (_name, rendererId) => {
|
||||
PendingIntersectionObserver.instances = []
|
||||
vi.stubGlobal('IntersectionObserver', PendingIntersectionObserver)
|
||||
const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
|
||||
const view = render(<TextPreview {...markdownProps(h, { params: { line: 3 }, revision: 1 })} />)
|
||||
await settle()
|
||||
expect(body(view.container).scrollTop).toBe(0)
|
||||
expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
|
||||
act(() => { h.instance.actions.selected(TAB_ID, rendererId) })
|
||||
await settle()
|
||||
expect(scrollport(view.container).scrollTop).toBe(0)
|
||||
expect(marks(view.container)).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it('restores the reading position of a Markdown tab opened at a line, on a remount and after a reload', async () => {
|
||||
const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
|
||||
const navigation = { params: { line: 3 }, revision: 1 }
|
||||
const first = render(<TextPreview {...markdownProps(h, navigation)} />)
|
||||
await settle()
|
||||
fireEvent.scroll(body(first.container), { target: { scrollTop: 300 } })
|
||||
first.unmount()
|
||||
const second = render(<TextPreview {...markdownProps(h, navigation)} />)
|
||||
await settle()
|
||||
expect(body(second.container).scrollTop).toBe(300)
|
||||
h.script(1, page(1, ['A', 'B', 'C'], true, 'v2'))
|
||||
click(second.container, '[data-textpreview-tool="reload"]')
|
||||
body(second.container).scrollTop = 0
|
||||
await settle()
|
||||
expect(body(second.container).scrollTop).toBe(300)
|
||||
})
|
||||
|
||||
it('keeps a line navigation pending while a source-line renderer has not drawn the line', async () => {
|
||||
const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
|
||||
const props = rendererProps(h, { params: { line: 3 }, revision: 1 }, [CODE_DEFINITION, PLAIN_DEFINITION], false)
|
||||
const view = render(<TextPreview {...props} />)
|
||||
await settle()
|
||||
expect(body(view.container).scrollTop).toBe(0)
|
||||
expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBeUndefined()
|
||||
act(() => { h.instance.actions.selected(TAB_ID, rendererId) })
|
||||
act(() => { h.instance.actions.selected(TAB_ID, PLAIN_BODY_ID) })
|
||||
await waitFor(() => { expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) })
|
||||
expect(scrollport(view.container).scrollTop).toBe(expectedScrollTop)
|
||||
expect(body(view.container).scrollTop).toBe(2 * LINE_HEIGHT)
|
||||
})
|
||||
|
||||
it('lands on code lines before and after syntax highlighting is ready', async () => {
|
||||
@@ -726,3 +775,79 @@ it('opens a path menu with the preview pane and requested line', async () => {
|
||||
sessionId: SESSION, path: ABSOLUTE_PATH, line: 42, paneId: 'pane-1',
|
||||
} }, { x: 0, y: 0 })
|
||||
})
|
||||
|
||||
describe('TextPreview — reading position across reloads', () => {
|
||||
const VIEW_HEIGHT = 100
|
||||
const numbered = (from: number, count: number): string[] => Array.from({ length: count }, (_, i) => `l${from + i}`)
|
||||
|
||||
/** Lay the body out like a browser: one line height per line, offsets clamped to the content. */
|
||||
function layOut(element: HTMLElement): void {
|
||||
const max = (): number => Math.max(0, element.querySelectorAll('[data-textpreview-line]').length * LINE_HEIGHT - VIEW_HEIGHT)
|
||||
let top = 0
|
||||
Object.defineProperty(element, 'clientHeight', { configurable: true, get: () => VIEW_HEIGHT })
|
||||
Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => max() + VIEW_HEIGHT })
|
||||
Object.defineProperty(element, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => { top = Math.min(top, max()); return top },
|
||||
set: (value: number) => { top = Math.max(0, Math.min(value, max())) },
|
||||
})
|
||||
}
|
||||
|
||||
it('returns to an offset past the first page after a reload emptied the body', async () => {
|
||||
const h = harness({ 1: page(1, numbered(1, 10), false), 11: page(11, numbered(11, 10), true) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
const scrolled = body(view.container)
|
||||
layOut(scrolled)
|
||||
click(view.container, '[data-textpreview-more]')
|
||||
await settle()
|
||||
fireEvent.scroll(scrolled, { target: { scrollTop: 300 } })
|
||||
h.script(1, page(1, numbered(1, 10), false, 'v2'))
|
||||
h.script(11, page(11, numbered(11, 10), true, 'v2'))
|
||||
click(view.container, '[data-textpreview-tool="reload"]')
|
||||
// The emptied body pins the browser's offset to 0 and reports it as a scroll.
|
||||
expect(scrolled.scrollTop).toBe(0)
|
||||
fireEvent.scroll(scrolled)
|
||||
expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(300)
|
||||
await settle()
|
||||
expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 11, h.controller.signal)
|
||||
expect(scrolled.scrollTop).toBe(300)
|
||||
})
|
||||
|
||||
it('keeps the remembered offset while a shorter file holds the view at its end', async () => {
|
||||
const h = harness({ 1: page(1, numbered(1, 20), true) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
const scrolled = body(view.container)
|
||||
layOut(scrolled)
|
||||
fireEvent.scroll(scrolled, { target: { scrollTop: 300 } })
|
||||
h.script(1, page(1, numbered(1, 10), true, 'v2'))
|
||||
h.setVersion('v2')
|
||||
view.rerender(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
fireEvent.scroll(scrolled)
|
||||
expect(scrolled.scrollTop).toBe(100)
|
||||
expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(300)
|
||||
h.script(1, page(1, numbered(1, 20), true, 'v3'))
|
||||
h.setVersion('v3')
|
||||
view.rerender(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
expect(scrolled.scrollTop).toBe(300)
|
||||
})
|
||||
|
||||
it('remembers where the reader scrolls up to, and the next reload keeps that place', async () => {
|
||||
const h = harness({ 1: page(1, numbered(1, 20), true) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
const scrolled = body(view.container)
|
||||
layOut(scrolled)
|
||||
fireEvent.scroll(scrolled, { target: { scrollTop: 300 } })
|
||||
fireEvent.scroll(scrolled, { target: { scrollTop: 120 } })
|
||||
expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(120)
|
||||
h.script(1, page(1, numbered(1, 20), true, 'v2'))
|
||||
click(view.container, '[data-textpreview-tool="reload"]')
|
||||
fireEvent.scroll(scrolled)
|
||||
await settle()
|
||||
expect(scrolled.scrollTop).toBe(120)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user