diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index ccceabe69d..97adbc6c21 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -30,8 +30,8 @@ en: 372d8ce6492e6896 zh: 912f613bc9221aa3 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/navigation: - en: 35f843f02c858674 - zh: 44af8e323a12d4dc + en: 59256644b39e412d + zh: 8ab908393b018431 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/model-experience: en: ca1deb99d0e44755 zh: f285fc56cee669ec diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index ff61b2bd5b..43ac41c129 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -123,7 +123,7 @@ The page refresh shortcut reuses the selected preview’s reload operation, incl ## 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 briefly and then fades out over two seconds; 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 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. ## Model Experience diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 586288b986..daaf6288ab 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -123,7 +123,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8 ## 导航 -`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。落定的行显示一条短暂停留后在两秒内渐隐的高亮带;重新挂载的 tab 恢复滚动位置时不显示高亮带。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。 +`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。落定的行显示一条高亮带,停留 0.6 秒后在 1.4 秒内渐隐;切换换行或调整预览尺寸会提前移除它;重新挂载的 tab 恢复滚动位置时不显示高亮带。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。 ## 模型体验 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index 34bed20455..dc6a77e68f 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -109,7 +109,8 @@ /* A navigated line's mark: a band in the scrollport over the line, so renderer re-renders such as syntax highlighting do not drop it. It holds briefly, then - fades out and removes itself. */ + fades out and removes itself. Reduced motion keeps the fade: it is an + opacity change, not movement, and its end removes the band. */ .lineMark { position: absolute; left: 0; diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 27044d5653..ecb0f6cb13 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -29,7 +29,7 @@ import { binaryDocumentPath, matchingDocumentPreviews } from './document/registr import type { DocumentPreviewDefinition } from './document/registry.ts' import { unviewableBinaryPath } from './document/unviewable.ts' import { PLAIN_BODY_ID } from './text/index.ts' -import { loadedPages, lastLineLoaded, markLine, scrollToLine } from './text/lines.ts' +import { clearLineMark, loadedPages, lastLineLoaded, markLine, scrollToLine } from './text/lines.ts' import css from './TextPreview.module.css' export { linesOf, loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' @@ -293,7 +293,13 @@ export function TextPreview({ aria-pressed={state.wrap} aria-label={t('wrap.aria')} data-textpreview-tool="wrap" - onClick={() => { actions.toggledWrap(tab.id) }} + onClick={() => { + actions.toggledWrap(tab.id) + // Wrapping reflows the lines under a line mark without resizing its scrollport. + const body = scrollportRef.current + /* v8 ignore next -- callback refs bind the scrollport during commit, before user input. */ + if (body !== null) clearLineMark(body) + }} > {state.wrap ? : } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts b/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts index 2100a3c123..5c702d6aae 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts @@ -55,20 +55,40 @@ export function scrollToLine(body: HTMLElement, line: number): HTMLElement | und /** * Mark a revealed line with a fading band. The band is a child of the scrollport, - * not of the line, so a renderer that rebuilds its lines keeps the mark; it - * replaces an earlier mark and removes itself when its animation ends. + * not of the line, so a renderer that rebuilds its lines keeps the mark. It + * replaces an earlier mark and removes itself when its animation ends or when + * the scrollport is resized, because a resize can reflow the lines above it. * @param body - the scrollport that {@link scrollToLine} scrolled. * @param row - the line element it returned. * @param line - 1-based source line, recorded on the band. */ export function markLine(body: HTMLElement, row: HTMLElement, line: number): void { - body.querySelector(':scope > [data-textpreview-mark]')?.remove() + clearLineMark(body) const mark = body.ownerDocument.createElement('div') mark.className = clsx(css.lineMark) mark.setAttribute('data-textpreview-mark', String(line)) mark.style.top = `${row.offsetTop}px` mark.style.height = `${row.offsetHeight}px` mark.style.width = `${body.scrollWidth}px` - mark.addEventListener('animationend', () => { mark.remove() }, { once: true }) + // The first observation reports the current size; only later ones are resizes. + let measured = false + const observer = new ResizeObserver(() => { + if (measured) dismiss() + measured = true + }) + function dismiss(): void { + observer.disconnect() + mark.remove() + } + mark.addEventListener('animationend', dismiss, { once: true }) body.append(mark) + observer.observe(body) +} + +/** + * Remove the scrollport's line mark, for layout changes that move lines without resizing it. + * @param body - the scrollport that may hold a mark. + */ +export function clearLineMark(body: HTMLElement): void { + body.querySelector(':scope > [data-textpreview-mark]')?.remove() } diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx index 627f6a9e9b..1975816c27 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx @@ -9,7 +9,7 @@ * set to. Both are the browser's job; the specs assert the body's arithmetic * over them. */ -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' @@ -54,6 +54,21 @@ afterAll(() => { } }) +/** Records each scrollport observer so a spec can report a resize to it. */ +class RecordingResizeObserver { + static instances: RecordingResizeObserver[] = [] + readonly targets: Element[] = [] + constructor(readonly callback: () => void) { RecordingResizeObserver.instances.push(this) } + observe(target: Element): void { this.targets.push(target) } + disconnect(): void {} + resize(): void { act(() => { this.callback() }) } +} + +beforeEach(() => { + RecordingResizeObserver.instances = [] + vi.stubGlobal('ResizeObserver', RecordingResizeObserver) +}) + afterEach(() => { cleanup() vi.unstubAllGlobals() @@ -583,6 +598,22 @@ describe('TextPreview — navigation and view', () => { expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2) }) + it('drops the line mark when the scrollport resizes or wrapping reflows its lines', async () => { + const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) + const view = render() + await settle() + const observer = RecordingResizeObserver.instances.find(item => item.targets.includes(body(view.container))) + observer!.resize() + expect(marks(view.container).map(mark => mark.dataset.textpreviewMark)).toEqual(['3']) + observer!.resize() + expect(marks(view.container)).toEqual([]) + + view.rerender() + expect(marks(view.container)).toHaveLength(1) + fireEvent.click(view.container.querySelector('[data-textpreview-tool="wrap"]')!) + expect(marks(view.container)).toEqual([]) + }) + it('stops at the end of the file for a line past it, and answers a navigation without a line', async () => { const h = harness({ 1: page(1, ['a', 'b'], true) }) const view = render()