fix(client): drop the line mark when its preview reflows
A resized scrollport or a wrap toggle can move the marked line, so the band is removed instead of marking another line. Document the exact timing and why reduced motion keeps the opacity fade.
This commit is contained in:
1 parent
dbf7d54a18
commit
7ab2ddd7a9
7 files changed
+70
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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. 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.
|
||||
|
||||
<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 不提供。所选渲染器没有锚点时,导航保持待处理;用户切换到纯文本或代码后执行。代码导航直接滚动内部源码视口。落定的行显示一条短暂停留后在两秒内渐隐的高亮带;重新挂载的 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。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? <IconNowrapFillRegular /> : <IconWrapFillRegular />}
|
||||
</button>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(<TextPreview {...h.props({ params: { line: 3 }, revision: 1 })} />)
|
||||
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(<TextPreview {...h.props({ params: { line: 2 }, revision: 2 })} />)
|
||||
expect(marks(view.container)).toHaveLength(1)
|
||||
fireEvent.click(view.container.querySelector<HTMLButtonElement>('[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(<TextPreview {...h.props({ params: { line: 99 }, revision: 1 })} />)
|
||||
|
||||
Reference in new issue
Block a user