feat(client): fade a navigated source line's highlight in the document preview

Opening a file link at a line now marks the landed line in both the code and
plain-text renderers with a band that holds briefly and fades out over two
seconds. The band lives in the scrollport, so syntax highlighting replacing
the code lines keeps it. The plain renderer's persistent target background is
removed.
This commit is contained in:
Turtle committed 2026-10-13 21:47:24 +08:00
1 parent 9f5bc9669b
commit dbf7d54a18
8 files changed
+74 -17

No files matched your search

@@ -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
@@ -123,7 +123,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. 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.
<a id="model-experience"></a>
## Model Experience
@@ -123,7 +123,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8
<a id="navigation"></a>
## 导航
`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。
<a id="model-experience"></a>
## 模型体验
@@ -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
@@ -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.
@@ -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 (
<div
key={number}
className={clsx(css.line, number === target && css.lineTarget)}
className={css.line}
data-textpreview-line={number}
{...number === target ? { 'data-textpreview-target': number } : {}}
>
@@ -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)
}
@@ -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<HTMLElement>('[data-textpreview-mark]'))
}
function click(container: HTMLElement, selector: string): void {
const button = container.querySelector<HTMLButtonElement>(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(<TextPreview {...codeProps(h, { params: { line: 3 }, revision: 2 })} />)
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(<TextPreview {...h.props({ params: { line: 3 }, revision: 1 })} />)
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(<TextPreview {...h.props({ params: { line: 2 }, revision: 2 })} />)
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(<TextPreview {...h.props({ params: {}, revision: 2 })} />)