🐛 fix(client): tie drag completion to the source row
This commit is contained in:
1 parent
eb8ef617c0
commit
4470ca8910
7 files changed
+82
-17
No files matched your search
@@ -51,8 +51,8 @@
|
||||
en: ad4a333892f2fe92
|
||||
zh: 3a670fd401f4d7a7
|
||||
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/view-state:
|
||||
en: 9234b8ecdff3cb1e
|
||||
zh: 416f584065690b76
|
||||
en: faedf74d08113970
|
||||
zh: c5f383727a65a1b1
|
||||
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards:
|
||||
en: 8bcb7bd90d7c5773
|
||||
zh: 17663be926a099d2
|
||||
|
||||
@@ -190,7 +190,7 @@ The sidebar hides durable summaries with `origin: 'subagent'`. A visible ordinar
|
||||
|
||||
Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measures keyed rows before and after React commits that change their membership or order, and uses native movement and opacity animations. Removed rows fade as inert copies outside the scrolling list; they do not delay React unmounting or extend its scroll range. Initial loading, drag commits, overflow expansion, and view-option changes settle immediately. The animator has no layout observers or polling and does not measure content-only updates or scrolling.
|
||||
|
||||
Native Workspace and Session drags use an inert offscreen copy for their preview, with the row's dimensions and indentation over the themed sidebar fill. [useRowDragPreview](src/client/rows/useRowDragPreview.ts) removes the copy on drag end, replacement, or row unmount. Source rows retain their normal backgrounds and drag-and-drop handlers.
|
||||
Native Workspace and Session drags use an inert offscreen copy for their preview, with the row's dimensions and indentation over the themed sidebar fill. [useRowDragPreview](src/client/rows/useRowDragPreview.ts) removes the copy on drag end, replacement, or row unmount. The preview does not change the source row's background or drag-and-drop handlers. If the browser rejects a custom drag image, the copy is removed and native dragging continues.
|
||||
|
||||
### Hover cards
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序
|
||||
|
||||
行动画由 [AnimatedRows](src/client/rows/AnimatedRows.tsx) 负责。它仅在 React 提交改变行成员或顺序时读取更新前后的位置,并使用浏览器原生位移与透明度动画。被移除的行以不可交互的副本在滚动列表外淡出,不会延迟 React 卸载,也不会扩大列表的滚动范围。初始加载、拖拽提交、展开其余会话和视图选项变化直接完成。动画组件不使用布局观察器或轮询,也不会因仅内容更新或滚动而测量位置。
|
||||
|
||||
Workspace 和 Session 的原生拖拽预览使用不可交互的离屏副本,在主题侧栏底色上保留行尺寸和缩进。[useRowDragPreview](src/client/rows/useRowDragPreview.ts) 在拖拽结束、替换预览或行卸载时移除副本。原行保留正常背景和拖放事件处理。
|
||||
Workspace 和 Session 的原生拖拽预览使用不可交互的离屏副本,在主题侧栏底色上保留行尺寸和缩进。[useRowDragPreview](src/client/rows/useRowDragPreview.ts) 在拖拽结束、替换预览或行卸载时移除副本。预览不改变原行的背景或拖放事件处理。如果浏览器拒绝自定义拖拽图片,则移除副本并继续原生拖动。
|
||||
|
||||
### 悬浮卡片
|
||||
|
||||
|
||||
@@ -28,6 +28,13 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dragPreview > :first-child {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.searchResultRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -255,10 +255,10 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
|
||||
: (e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', row.key)
|
||||
dragPreview.start(e)
|
||||
drag.start()
|
||||
dragPreview.start(e)
|
||||
}}
|
||||
onDragEnd={() => { dragPreview.clear(); drag?.end() }}
|
||||
onDragEnd={() => { if (dragPreview.clear()) drag?.end() }}
|
||||
>
|
||||
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
|
||||
{row.expanded ? <IconFolderOpenRegular /> : <IconFolderCloseRegular />}
|
||||
@@ -626,10 +626,10 @@ export function SessionNodeItem({
|
||||
: (e) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', node.id)
|
||||
dragPreview.start(e)
|
||||
drag.start()
|
||||
dragPreview.start(e)
|
||||
}}
|
||||
onDragEnd={() => { dragPreview.clear(); drag?.end() }}
|
||||
onDragEnd={() => { if (dragPreview.clear()) drag?.end() }}
|
||||
onDragOver={drag === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
|
||||
@@ -6,31 +6,40 @@ import css from './Rows.module.css'
|
||||
/**
|
||||
* Own a row's inert drag preview until drag end, replacement, or unmount.
|
||||
* The offscreen copy keeps WebKit snapshots separate from overlapping panels.
|
||||
* @returns preview creation and cleanup callbacks for native drag handlers.
|
||||
* Its cloned slot DOM remains in document.body for that lifetime; query live rows
|
||||
* within their list to exclude duplicate text and element IDs.
|
||||
* @returns preview callbacks; cleanup reports whether this row started a drag,
|
||||
* even when its preview failed or the row became non-draggable before drag end.
|
||||
*/
|
||||
export function useRowDragPreview(): {
|
||||
start: (event: DragEvent<HTMLElement>) => void
|
||||
clear: () => void
|
||||
clear: () => boolean
|
||||
} {
|
||||
const current = useRef<HTMLDivElement | null>(null)
|
||||
const dragging = useRef(false)
|
||||
const preview = useMemo(() => {
|
||||
const clear = (): void => {
|
||||
const clear = (): boolean => {
|
||||
const wasDragging = dragging.current
|
||||
dragging.current = false
|
||||
current.current?.remove()
|
||||
current.current = null
|
||||
return wasDragging
|
||||
}
|
||||
return {
|
||||
clear,
|
||||
start: (event: DragEvent<HTMLElement>): void => {
|
||||
clear()
|
||||
dragging.current = true
|
||||
const row = event.currentTarget
|
||||
const rect = row.getBoundingClientRect()
|
||||
const style = getComputedStyle(row)
|
||||
const copy = row.cloneNode(true) as HTMLElement
|
||||
copy.removeAttribute('data-row-key')
|
||||
Object.assign(copy.style, {
|
||||
boxSizing: 'border-box', margin: '0', width: '100%', height: '100%',
|
||||
color: style.color,
|
||||
})
|
||||
copy.tabIndex = -1
|
||||
for (const control of copy.querySelectorAll<HTMLElement>(
|
||||
'button, a[href], input, select, textarea, [tabindex], [contenteditable]',
|
||||
)) control.tabIndex = -1
|
||||
copy.style.color = style.color
|
||||
const image = row.ownerDocument.createElement('div')
|
||||
image.className = css.dragPreview as string
|
||||
image.inert = true
|
||||
@@ -43,10 +52,16 @@ export function useRowDragPreview(): {
|
||||
image.append(copy)
|
||||
row.ownerDocument.body.append(image)
|
||||
current.current = image
|
||||
event.dataTransfer.setDragImage(image, event.clientX - rect.left, event.clientY - rect.top)
|
||||
try {
|
||||
event.dataTransfer.setDragImage(image, event.clientX - rect.left, event.clientY - rect.top)
|
||||
} catch (_error) {
|
||||
// Browsers may reject a custom image; retain native dragging without the copy.
|
||||
image.remove()
|
||||
current.current = null
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => preview.clear, [preview])
|
||||
useEffect(() => () => { preview.clear() }, [preview])
|
||||
return preview
|
||||
}
|
||||
@@ -167,6 +167,8 @@ describe('native row drag previews', () => {
|
||||
it.each(['workspace', 'session'] as const)('uses an inert offscreen %s copy without changing the source row', (kind) => {
|
||||
const { row, drag, transfer, start } = fixture(kind)
|
||||
const originalStyle = row.getAttribute('style')
|
||||
fireEvent.dragEnd(row)
|
||||
expect(drag.end).not.toHaveBeenCalled()
|
||||
const image = start()
|
||||
expect(transfer.setDragImage).toHaveBeenCalledWith(image, 65, 12)
|
||||
expect(transfer.effectAllowed).toBe('move')
|
||||
@@ -175,6 +177,9 @@ describe('native row drag previews', () => {
|
||||
expect(image.parentElement).toBe(document.body)
|
||||
expect(image.inert).toBe(true)
|
||||
expect(image.getAttribute('aria-hidden')).toBe('true')
|
||||
const controls = image.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')
|
||||
expect(controls.length).toBeGreaterThan(0)
|
||||
expect([...controls].every(control => control.tabIndex === -1)).toBe(true)
|
||||
expect(image.style.left).toBe('-200px')
|
||||
expect(image.style.width).toBe('200px')
|
||||
expect(image.style.height).toBe('34px')
|
||||
@@ -187,6 +192,31 @@ describe('native row drag previews', () => {
|
||||
expect(row.getAttribute('draggable')).toBe('true')
|
||||
})
|
||||
|
||||
it.each(['workspace', 'session'] as const)('keeps %s dragging active when the browser rejects its preview', (kind) => {
|
||||
const { drag, transfer, start } = fixture(kind)
|
||||
transfer.setDragImage.mockImplementation(() => { throw new DOMException('Image unavailable') })
|
||||
const image = start()
|
||||
expect(drag.start).toHaveBeenCalledOnce()
|
||||
expect(vi.mocked(drag.start).mock.invocationCallOrder[0])
|
||||
.toBeLessThan(transfer.setDragImage.mock.invocationCallOrder[0]!)
|
||||
expect(image.isConnected).toBe(false)
|
||||
fireEvent.dragEnd(screen.getByRole('treeitem'))
|
||||
expect(drag.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('finishes a Session drag when the source becomes archived before drag end', () => {
|
||||
const { row, drag, start, rerender } = fixture('session')
|
||||
const image = start()
|
||||
rerender(<SessionNodeItem node={{
|
||||
id: sid('session'), title: 'Draggable row', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: true,
|
||||
}} currentId={undefined} now={0} onOpen={vi.fn()} drag={drag} t={t} />)
|
||||
expect(row.getAttribute('draggable')).toBe('false')
|
||||
fireEvent.dragEnd(row)
|
||||
expect(image.isConnected).toBe(false)
|
||||
expect(drag.end).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['workspace', 'session'] as const)('removes %s previews on replacement, drag end, and unmount', (kind) => {
|
||||
const { row, drag, start, unmount } = fixture(kind)
|
||||
const first = start()
|
||||
@@ -1164,6 +1194,19 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('img', { name: '已置顶' })).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['blank', 'archived'] as const)('ignores child drag end on a %s Session row', (state) => {
|
||||
const drag = dragProps({ active: true, marker: 'before' })
|
||||
render(<SessionNodeItem node={{
|
||||
id: sid('not-draggable'), title: 'Not draggable', blank: state === 'blank', running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: state === 'archived',
|
||||
}} currentId={undefined} now={0} onOpen={vi.fn()} drag={drag} t={t} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.getAttribute('draggable')).toBe('false')
|
||||
fireEvent.dragEnd(row.firstElementChild!)
|
||||
fireEvent.dragEnd(row)
|
||||
expect(drag.end).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks an archived row and blocks dragging it', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'Stored', blank: false, running: false,
|
||||
|
||||
Reference in new issue
Block a user