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:
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 })} />)
|
||||
|
||||
Reference in new issue
Block a user