diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 243bad0c64..2c7a9a22f3 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -18,8 +18,8 @@ en: ea872ff2c2089037 zh: b3ec3a218eacd5c8 /deepseek-ai-dsh-client-ui-dockkit/embedding-it: - en: 789c879dd83f463a - zh: 37f2dc45f9588810 + en: ff5302a42d6d68e5 + zh: 086795f6e4fc3cd1 /deepseek-ai-dsh-client-ui-dockkit/interaction-rules-worth-keeping: en: 7e5c1e9a9f9f244f zh: 7e3a8fcef86bc7dd diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 3c3b77151b..bb317db7ee 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -60,7 +60,7 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp `DockLayout`, used by the Sidebar, keeps each tab in a stable DOM cell across selection, pane moves and floating. It accepts one docked pane or two horizontal panes; CSS Grid resolves their widths. `keepMounted(tab)` retains visited bodies and `active` controls Session visibility without reparenting content. -`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. +`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. Both float renderers keep the header at least 8px below the Windows caption (8px below the viewport top in native fullscreen), including newly opened and restored panels. Dragging and resizing start at the displayed position. ## Interaction rules worth keeping diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index fb1692d168..a3fae98192 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -60,7 +60,7 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间,把每个 tab 保留在稳定的 DOM 容器中。它接受一个停靠格或左右两个格,由 CSS Grid 计算宽度。`keepMounted(tab)` 保留已访问正文,`active` 控制 Session 可见性,不改变内容的父子关系。 -`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 +`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 两种浮窗渲染器都让标题栏与 Windows 顶栏至少间隔 8px(原生全屏时距视口顶部 8px),新建和恢复的浮窗均适用。拖动和调整大小从实际显示位置开始。 ## 值得保留的交互规则 diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index 612adf89cd..b1640ccf42 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -86,7 +86,10 @@ export function useFloatGestures(state: LayoutState, intents: DockIntents) { // The press stops here: the panel's own press-to-focus would record a focus // entry before the drag's, and the release below decides which one it is. event.stopPropagation() - const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect: floatRect(getPane(state, paneId)) } + const panel = event.currentTarget.closest('[data-dockkit-float]') as HTMLElement + // Window chrome can push a saved rectangle below its recorded top. + const rect = { ...floatRect(getPane(state, paneId)), y: panel.getBoundingClientRect().y } + const start: FloatDrag = { mode, originX: event.clientX, originY: event.clientY, rect } begin(event.currentTarget, event.pointerId, { move: (moved) => { setPreview({ paneId, rect: draggedRect(start, moved.clientX, moved.clientY) }) }, up: (released) => { @@ -164,7 +167,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, data-dockkit-float-active={state.activePaneId === paneId || undefined} style={{ left: live.x, - top: live.y, + top: `max(var(--dsh-dockkit-float-top, 0px), ${live.y}px)`, width: live.width, height: live.height, zIndex: lifted === undefined ? depth + 1 : state.floats.length + 1, diff --git a/packages/client/ui-dockkit/src/components/TabLayout.tsx b/packages/client/ui-dockkit/src/components/TabLayout.tsx index 4511d64e68..f3a2081870 100644 --- a/packages/client/ui-dockkit/src/components/TabLayout.tsx +++ b/packages/client/ui-dockkit/src/components/TabLayout.tsx @@ -88,7 +88,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe data-dockkit-float-active={floating && state.activePaneId === pane.id || undefined} data-dockkit-column={!floating ? column : undefined} style={rect === undefined ? undefined : { - left: rect.x, top: rect.y, width: rect.width, height: rect.height, + left: rect.x, top: `max(var(--dsh-dockkit-float-top, 0px), ${rect.y}px)`, width: rect.width, height: rect.height, }} onPointerDown={() => { if (floating) floats.raise(pane.id) }} onClick={() => { if (!floating && state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) }}> diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index 0c69f3d0be..8cd8b5caea 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -739,6 +739,14 @@ height: 40%; } +:global(html[data-windows-titlebar]) .float { + --dsh-dockkit-float-top: calc(var(--dsh-windows-titlebar-height) + 8px); +} + +:global(html[data-windows-titlebar][data-fullscreen]) .float { + --dsh-dockkit-float-top: 8px; +} + .float { position: fixed; display: flex; diff --git a/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts b/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts index 7bf28fdcbf..0b1e2eb876 100644 --- a/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts +++ b/packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts @@ -25,6 +25,12 @@ const regionRules = [...css.matchAll(/(?[^{}]+)\{(?[^{}]*)\}/g)] /-webkit-app-region:\s*([^;]+);/.exec(body)?.[1]?.trim(), ] as const) +it('keeps Windows floating headers 8px below the caption, including restored positions', () => { + const captionRule = css.split(':global(html[data-windows-titlebar]) .float')[1]?.split('}')[0] + expect(captionRule).toContain('--dsh-dockkit-float-top: calc(var(--dsh-windows-titlebar-height) + 8px);') + expect(css).toMatch(/:global\(html\[data-windows-titlebar\]\[data-fullscreen\]\) \.float\s*\{\s*--dsh-dockkit-float-top: 8px;/) +}) + describe('macOS app-region ownership', () => { it('subtracts the chip run, strip-end chrome, pane body, and divider, and declares no drag itself', () => { // The strip row's drag is the markup mark's job: a sheet that declares it diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 45ee2672ca..71cd676677 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { DockIntents } from '../src/contract/adapter.ts' import type { PaneId, TabId } from '../src/contract/types.ts' import { DockController } from '../src/engine/controller.ts' @@ -1304,6 +1304,19 @@ describe('divider drags', () => { }) describe('FloatLayer', () => { + beforeEach(() => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(box(100, 80, 300, 200)) + }) + + it.each(['move', 'resize'] as const)('starts %s from the displayed position when window chrome displaces a saved float', (mode) => { + const { intents, paneId, panel } = floating() + vi.spyOn(panel, 'getBoundingClientRect').mockReturnValue(box(100, 48, 300, 200)) + const handle = panel.querySelector(`[data-dockkit-float-${mode === 'move' ? 'grip' : 'resize'}]`)! + drag(handle, [150, 58], [170, 78]) + if (mode === 'move') expect(intents.moveFloat).toHaveBeenCalledWith(paneId, 120, 68) + else expect(intents.resizeFloat).toHaveBeenCalledWith(paneId, { x: 100, y: 48, width: 320, height: 220 }) + }) + /** Two floating panels, `lower` under `upper`, with `upper` active. */ function twoFloats(): { intents: ReturnType @@ -1438,7 +1451,7 @@ describe('FloatLayer', () => { drag(grip, [150, 90], [180, 110], false) expect(panel.style.left).toBe('130px') - expect(panel.style.top).toBe('100px') + expect(panel.style.top).toBe('max(var(--dsh-dockkit-float-top, 0px), 100px)') // Mid-gesture the panel draws on top, as the move it records will leave it. expect(panel.style.zIndex).toBe('3') fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 })