Merge pull request #6948 from deepseek-harness/fix/sidebar-noop-drag-caret
fix(sidebar): 隐藏不改变标签顺序的拖拽定位条
This commit is contained in:
commit
da910572c5
5 files changed
+46
-15
No files matched your search
@@ -15,8 +15,8 @@
|
||||
en: 42038fdc402b9c2a
|
||||
zh: 99890f8f908624ce
|
||||
/deepseek-ai-dsh-client-ui-dockkit/the-two-layers:
|
||||
en: 99356ea8e3d1e7aa
|
||||
zh: 870f746730d0e6cf
|
||||
en: 718f52cfce426009
|
||||
zh: e8282e2e3713d242
|
||||
/deepseek-ai-dsh-client-ui-dockkit/embedding-it:
|
||||
en: c4ba08038bfa6f30
|
||||
zh: 95db30618b52babe
|
||||
|
||||
@@ -37,10 +37,12 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
|
||||
- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory — withholding the factory keeps the merge and leaves the root pane empty. An embedder that wants empty panes simply does not call it, or calls it without a factory. `planDropTab` takes the same factory: with one, a sole tab released on its own pane's edge splits and the factory's tab backfills the pane it vacates (the dragged tab stays focused); without one that release changes nothing.
|
||||
- `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does).
|
||||
|
||||
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
|
||||
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports a placement only when a caret is drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
|
||||
|
||||
Middle-clicking anywhere on a closable tab chip, including its close control, closes it through the same intent as its close button, without selecting it first. The chip owns the middle click and suppresses nested title content’s default action. Closing a background tab preserves the selected tab. Only the primary button starts tab dragging; a middle click while another mouse button is held does not close the tab.
|
||||
|
||||
Strip slots that preserve the dragged tab’s position show no insertion caret and report no placement; the tab can still be dragged to split a pane or float.
|
||||
|
||||
Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it.
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
|
||||
@@ -37,10 +37,12 @@ kind: "package-reference"
|
||||
- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种——不传工厂则只并格、让根格保持为空。想要空格的嵌入方只需不调用它,或不带工厂调用。`planDropTab` 接受同一工厂:带工厂时,唯一 tab 放到本格边缘会分栏,工厂的 tab 回填它腾出的格(被拖的 tab 保持聚焦);不带工厂时这种释放不改变任何东西。
|
||||
- `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。
|
||||
|
||||
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
|
||||
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手时,仅当定位条可见才上报插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
|
||||
|
||||
中键点击可关闭标签页 chip 的任意位置(包括关闭控件)时,通过与关闭按钮相同的意图关闭,无需先选中。chip 接管中键点击,并阻止嵌套标题内容的默认动作。关闭后台标签页会保留当前选中的标签页。只有主键会开始拖动标签页;其它鼠标按键仍按住时,中键点击不会关闭标签页。
|
||||
|
||||
不改变所拖标签位置的标签条落点不显示插入定位条,也不上报放置操作;标签仍可拖出分栏或浮动。
|
||||
|
||||
标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*
|
||||
* A gesture only previews until it ends, then leaves through one intent, so the
|
||||
* embedder's operation sequence stays the single source of truth. Releasing a tab
|
||||
* clear of this surface floats it; releasing inside it but on no pane is not a
|
||||
* move at all.
|
||||
* clear of this surface floats it; releasing inside it without an effective
|
||||
* drop target changes nothing.
|
||||
*/
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -110,12 +110,15 @@ const ALWAYS = (): boolean => true
|
||||
/**
|
||||
* Resolve where a pointer sits inside the docked surface. An edge zone is only
|
||||
* offered where the split it would make is allowed: within the pane budget and
|
||||
* with room for two halves; otherwise the release is not a move at all.
|
||||
* with room for two halves. Strip slots that preserve the dragged tab's position
|
||||
* offer no caret or placement intent. An undefined target inside the surface
|
||||
* leaves the layout unchanged on release.
|
||||
*/
|
||||
function hitTest(
|
||||
root: HTMLElement,
|
||||
x: number,
|
||||
y: number,
|
||||
tabId: TabId,
|
||||
canSplit: boolean,
|
||||
fits: ReadonlyMap<PaneId, HalvesFit>,
|
||||
dropZones: 'edges' | 'horizontal',
|
||||
@@ -126,7 +129,10 @@ function hitTest(
|
||||
const strip = pane.querySelector<HTMLElement>('[data-dockkit-strip]')
|
||||
if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) {
|
||||
const tabs = [...strip.querySelectorAll<HTMLElement>('[data-dockkit-tab]')]
|
||||
return { kind: 'strip', paneId, index: insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x) }
|
||||
const index = insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x)
|
||||
const sourceIndex = tabs.findIndex(tab => tab.dataset.dockkitTab === tabId)
|
||||
if (sourceIndex >= 0 && (index === sourceIndex || index === sourceIndex + 1)) return undefined
|
||||
return { kind: 'strip', paneId, index }
|
||||
}
|
||||
const zone = dropZones === 'horizontal'
|
||||
? canSplit && fitOf(fits, paneId).row
|
||||
@@ -229,12 +235,12 @@ function Surface({
|
||||
setPreview({
|
||||
...NO_PREVIEW,
|
||||
draggingTabId: tabId,
|
||||
dropTarget: hitTest(root, moved.clientX, moved.clientY, canSplit, fits, dropZones),
|
||||
dropTarget: hitTest(root, moved.clientX, moved.clientY, tabId, canSplit, fits, dropZones),
|
||||
})
|
||||
},
|
||||
up: (released) => {
|
||||
if (!dragging) return
|
||||
const target = hitTest(root, released.clientX, released.clientY, canSplit, fits, dropZones)
|
||||
const target = hitTest(root, released.clientX, released.clientY, tabId, canSplit, fits, dropZones)
|
||||
if (target === undefined) {
|
||||
if (containsPoint(root.getBoundingClientRect(), released.clientX, released.clientY)) return
|
||||
intents.floatTab(tabId, floatRectAt(released.clientX, released.clientY, FLOAT_DEFAULT_SIZE))
|
||||
|
||||
@@ -999,21 +999,42 @@ describe('tab drags', () => {
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
})
|
||||
|
||||
it('previews a caret at the slot under the pointer and reports that slot on release', () => {
|
||||
it('previews only strip slots that change the tab order', () => {
|
||||
const { intents, first, fileTabId, chip } = twoPanes()
|
||||
// Left of the first chip's midpoint: slot 0.
|
||||
drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
|
||||
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
|
||||
// Past both chips: the slot after the last one, drawn at the strip's end.
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
|
||||
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('2')
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 120, clientY: 18 })
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 30, clientY: 18 })
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
|
||||
// The slot counts the dragged chip itself; the planner turns it into a reorder.
|
||||
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 2)
|
||||
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 0)
|
||||
expect(gestureIntents(intents)).toEqual(['placeTab'])
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([30, 300])('does not offer a strip placement for the lone Start tab at x=%s', (x) => {
|
||||
const controller = seededController()
|
||||
controller.setExpanded(true)
|
||||
const panes = dockPaneIds(controller.getSnapshot().state)
|
||||
layOut(panes, 520)
|
||||
const intents = spyIntents()
|
||||
renderSurface(controller, intents)
|
||||
const chip = screen.getByRole('tab')
|
||||
drag(chip, [51, 18], [x, 18], false)
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: x, clientY: 18 })
|
||||
expect(gestureIntents(intents)).toEqual([])
|
||||
|
||||
drag(chip, [51, 18], [260, 590], false)
|
||||
expect(document.querySelector('[data-dockkit-drop-active]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom')
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 260, clientY: 590 })
|
||||
expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(chip.getAttribute('data-dockkit-tab'), panes[0], 'bottom')
|
||||
})
|
||||
|
||||
it('reports a slot in another pane\'s strip as a placement there', () => {
|
||||
const { intents, second, fileTabId, chip } = twoPanes()
|
||||
drag(chip(fileTabId), FILE_CHIP, [420 + 30, 18])
|
||||
|
||||
Reference in new issue
Block a user