diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 67ad419821..ccceabe69d 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: 5cd81a7b3cdc83bd - zh: 4cdd1b7ede497925 + en: 35f843f02c858674 + zh: 44af8e323a12d4dc /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 51e23e2208..ff61b2bd5b 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. 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 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. ## Model Experience diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 1c5be3fdc3..586288b986 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 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。字节模式渲染器不消费源码行导航。每个完成的导航 revision 只响应一次。不带 `revealIfOpened: false` 打开同一文件时聚焦已有 tab,并送达新 revision。 +`ctx.sidebarRight.openResource(address, { params: { line } })` 通过 `file` 参数携带 1 起算的源码行号。在 `text-pages` 模式下,owner 顺序加载到该行或 EOF。纯文本与代码渲染器提供源码行锚点;Markdown 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。落定的行显示一条短暂停留后在两秒内渐隐的高亮带;重新挂载的 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 ca454e9c7d..34bed20455 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -107,8 +107,26 @@ padding: 0 10px; } -.lineTarget { - background: var(--dsw-alias-interactive-bg-hover); +/* 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. */ +.lineMark { + position: absolute; + left: 0; + background: var(--dsw-alias-interactive-bg-hover-accent); + pointer-events: none; + animation: line-mark-fade 2s ease-in forwards; +} + +@keyframes line-mark-fade { + 0%, + 30% { + opacity: 1; + } + + 100% { + opacity: 0; + } } /* The failure with nothing read yet: the whole body, centred a little above diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 101f2debd8..27044d5653 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, scrollToLine } from './text/lines.ts' +import { loadedPages, lastLineLoaded, markLine, scrollToLine } from './text/lines.ts' import css from './TextPreview.module.css' export { linesOf, loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts' @@ -133,8 +133,8 @@ export function TextPreview({ // 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. The store remembers the answer, so a remount - // restores the reader's place instead. + // is scrolled to and marked with a fading band. 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 @@ -148,8 +148,9 @@ export function TextPreview({ } return } - const landed = scrollToLine(body, line) - if (!landed && line <= loadedThrough) return + const row = scrollToLine(body, line) + if (row === undefined && line <= loadedThrough) return + if (row !== undefined) markLine(body, row, line) actions.navigated(tab.id, navigation.revision) // Recorded here as well as by the scroll event, so the store holds the // landing before any later navigation reads it. diff --git a/packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx index 5162591967..0c26163903 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/text/TextBody.tsx @@ -1,6 +1,5 @@ /** Plain source display for files without a more specific document renderer. */ import type { ReactNode } from 'react' -import clsx from 'clsx' import type { DocumentPreviewProps } from '../document/contract.ts' import { linesOf } from './lines.ts' import css from '../TextPreview.module.css' @@ -20,7 +19,7 @@ export function TextBody({ content, useTabInfo }: DocumentPreviewProps): ReactNo return (
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 f01743635d..2100a3c123 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/text/lines.ts @@ -1,6 +1,8 @@ /** Source-line helpers shared by the plain renderer and the document scroller. */ +import clsx from 'clsx' import type { DocumentTextPage } from '../document/contract.ts' import type { TextPage } from '../store.ts' +import css from '../TextPreview.module.css' /** * Split a loaded page into its source lines. @@ -39,14 +41,34 @@ export function lastLineLoaded(pages: readonly LoadedPage[]): number { * Reveal a plain-text or highlighted source line. * @param body - scrolling document body or code-content viewport. * @param line - 1-based source line to reveal. - * @returns Whether the current renderer exposes that line. + * @returns The revealed line element, or undefined when the current renderer does not expose that line. */ -export function scrollToLine(body: HTMLElement, line: number): boolean { +export function scrollToLine(body: HTMLElement, line: number): HTMLElement | undefined { const innerCode = body.hasAttribute('data-code-block-content') const plain = body.querySelector(`[data-textpreview-line="${line}"]`) const code = innerCode ? body.querySelectorAll('pre .line').item(line - 1) : null const row = plain ?? code - if (!(row instanceof HTMLElement)) return false + if (!(row instanceof HTMLElement)) return undefined body.scrollTop = Math.max(0, row.offsetTop) - return true + return row +} + +/** + * 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. + * @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() + 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 }) + body.append(mark) } 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 999b11605a..627f6a9e9b 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 @@ -110,6 +110,10 @@ function target(container: HTMLElement): string | null { return container.querySelector('[data-textpreview-target]')?.getAttribute('data-textpreview-target') ?? null } +function marks(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll('[data-textpreview-mark]')) +} + function click(container: HTMLElement, selector: string): void { const button = container.querySelector(selector) if (button === null) throw new Error(`expected ${selector}`) @@ -525,8 +529,12 @@ describe('TextPreview — navigation and view', () => { act(() => { PendingIntersectionObserver.instances[0]!.intersect(block) }) await waitFor(() => { expect(view.container.querySelector('[data-code-preview] pre.shiki')).not.toBeNull() }) expect(scrollport(view.container)).toBe(codeScrollport) + // The band lives in the scrollport, so the highlighted swap keeps it. + expect(marks(view.container).map(mark => [mark.parentElement, mark.dataset.textpreviewMark, mark.style.top])) + .toEqual([[codeScrollport, '2', `${LINE_HEIGHT}px`]]) view.rerender() expect(codeScrollport.scrollTop).toBe(2 * LINE_HEIGHT) + expect(marks(view.container).map(mark => mark.dataset.textpreviewMark)).toEqual(['3']) expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2) }) @@ -540,6 +548,12 @@ describe('TextPreview — navigation and view', () => { expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 4, h.controller.signal) expect(body(view.container).scrollTop).toBe(4 * LINE_HEIGHT) expect(target(view.container)).toBe('5') + const [mark] = marks(view.container) + expect(mark?.parentElement).toBe(body(view.container)) + expect(mark?.dataset.textpreviewMark).toBe('5') + expect(mark?.style.top).toBe(`${4 * LINE_HEIGHT}px`) + fireEvent.animationEnd(mark!) + expect(marks(view.container)).toEqual([]) expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(4 * LINE_HEIGHT) }) @@ -554,6 +568,7 @@ describe('TextPreview — navigation and view', () => { const second = render() await settle() expect(body(second.container).scrollTop).toBe(300) + expect(marks(second.container)).toEqual([]) }) it('jumps again for a new navigation to the same tab', async () => { @@ -564,6 +579,7 @@ describe('TextPreview — navigation and view', () => { view.rerender() expect(body(view.container).scrollTop).toBe(1 * LINE_HEIGHT) expect(target(view.container)).toBe('2') + expect(marks(view.container).map(mark => mark.dataset.textpreviewMark)).toEqual(['2']) expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2) }) @@ -573,6 +589,7 @@ describe('TextPreview — navigation and view', () => { await settle() expect(h.read).toHaveBeenCalledTimes(1) expect(target(view.container)).toBeNull() + expect(marks(view.container)).toEqual([]) expect(body(view.container).scrollTop).toBe(0) expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1) view.rerender()