Skip to content

Commit f99b5ab

Browse files
wehosHongzhi Wenclaude
authored
fix(chat): 工具轮盘原点可拖动 compact 文本框 + 输入态左侧握把 (#1617)
* fix(chat): 工具轮盘原点可拖动 compact 文本框 + 输入态左侧握把 紧凑输入态里 textarea / 工具按钮都是 no-drag,原本抓不到地方拖动文本框。 - 工具轮盘原点(toggle / fan 中心)按下并移动超阈值时派发 neko:compact-surface-drag-grab(带按下点 client/screen 坐标),由宿主接管 启动既有 surface 本体拖拽;点一下仍展开/关闭轮盘,悬停展开、轮盘旋转、charge 不变。 - 输入态新增左侧拖拽握把 .compact-chat-input-drag-grip(不加 no-drag,落进本体 拖拽区):给 Wayland(只能原生 app-region:drag)等无事件式拖拽的环境留稳定可抓入口, web/X11/Windows/Mac 也多一个显眼把手。 - 拖动后补发的 click 用独立 origin suppress 标志吞掉(轮盘关闭 effect 会清 wheel suppress,不能复用)。app-react-chat-window.js 非 Electron 时接同一事件起拖。 - 补 origin-drag / 握把契约测试。 需配套 N.E.K.O.-PC 的 preload 监听(同名事件走原生窗口拖拽)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): 原点拖拽抑制改为常驻至 click 消费 + currentcolor 小写 回应 review: - 慢速拖拽(>120ms,几乎所有真实拖动)下,原本 120ms 定时器会在 release click 之前清掉抑制标志,导致拖完轮盘被误开关。改为:拖动置位后一直 armed,到那次 click 被消费、或下次原点/轮盘按下时清零;不再用定时器。补慢速拖拽回归用例。 - grip 的 radial-gradient 关键字 currentColor → currentcolor(CSS 规范小写)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 25fb7c4 commit f99b5ab

5 files changed

Lines changed: 326 additions & 1 deletion

File tree

docs/design/compact-chat-mode-design.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
- `data-compact-geometry-part="capsuleBody" | "inputBody"`
9191
- `data-compact-drag-surface="true"` 声明 compact 对话框本体 surface;整体拖拽只指这个本体,不包含历史、工具轮盘、选项层等浮层。
9292
- `data-compact-no-drag="true"` 声明 textarea、工具按钮、resize、历史、选项等真实控件和浮层排除拖拽。
93+
- 工具轮盘 toggle / fan 原点仍是 `data-compact-no-drag`(宿主命中判定不自动起拖),但 App.tsx 在原点按下并移动超阈值时额外派发 `neko:compact-surface-drag-grab`(带按下点 client/screen 坐标),让宿主以该点为锚启动本体拖拽——使轮盘中心兼作「按住拖动文本框」把手;点按仍展开/关闭轮盘、悬停展开保留、轮盘边缘拖动仍旋转。拖动后补发的 click 用独立的 origin suppress 标志吞掉(不能复用 wheel suppress,轮盘关闭 effect 会清它)。Wayland 走原生 app-region 拖拽,事件式起拖不适用。
9394
6. 早期 `.compact-chat-capsule-shell` / `.compact-chat-input-shell` 已不是当前主体事实,后续文档和实现不要再按这两个旧类名设计。
9495
7. `.compact-chat-surface-frame` 是同一个 54px 高的本体:`default/options` 内放 capsule button,`input` 内放 textarea 和右侧工具/发送按钮。
9596
8. 旧蓝线拖拽手柄 `.compact-chat-drag-handle` / `data-compact-drag-handle="true"` 已删除,不应恢复;对话框本体拖拽走 `data-compact-drag-surface` / `data-compact-no-drag`
@@ -121,6 +122,7 @@
121122
- surface geometry 收集、union、hit rect、native rect 输出。
122123
- 最小化 ball 的独立定位和 geometry。
123124
- resize session、desktop resize active 和 layout-change 事件。
125+
- 监听 `neko:compact-surface-drag-grab`(来自 React 工具轮盘原点拖拽),非 Electron 时以事件坐标为锚启动 compact surface 本体拖拽(复用既有 startDrag/全局 mousemove/mouseup 与落点 click 守卫)。Electron 由 `preload-chat-react.js` 监听同一事件改走原生窗口拖拽。
124126
5. `static/app-buttons.js` 是发送桥之一。compact history 文本发送必须带清晰 session / request 语义,不能让已有 composer 附件在 deferred send 中被误带上。
125127
6. 语音模式 / `composerHidden` 下的 history drop 只保留前端拖拽、命中和收束动效;真实发送必须在 `sendCompactHistoryDropPayload` 边界跳过,不能通过改 React 拖拽 phase 或样式来伪装。
126128

frontend/react-neko-chat/src/App.test.tsx

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4385,6 +4385,149 @@ describe('App', () => {
43854385
}
43864386
});
43874387

4388+
it('exposes a draggable left grip in compact input mode as part of the surface drag region', () => {
4389+
const { container, rerender } = render(
4390+
<App chatSurfaceMode="compact" compactChatState="input" />,
4391+
);
4392+
const grip = container.querySelector('.compact-chat-input-drag-grip');
4393+
expect(grip).not.toBeNull();
4394+
// 握把属于本体拖拽区:自身不是 no-drag,祖先是 drag-surface,且不在任何 no-drag 子树里。
4395+
expect(grip).not.toHaveAttribute('data-compact-no-drag');
4396+
expect(grip!.closest('[data-compact-drag-surface="true"]')).not.toBeNull();
4397+
expect(grip!.closest('[data-compact-no-drag="true"]')).toBeNull();
4398+
// 仅输入态出现;胶囊态本体本身可拖,不需要单独握把。
4399+
rerender(<App chatSurfaceMode="compact" compactChatState="default" />);
4400+
expect(container.querySelector('.compact-chat-input-drag-grip')).toBeNull();
4401+
});
4402+
4403+
it('dispatches a compact surface drag-grab from the tool toggle when pressed and moved past threshold', () => {
4404+
render(
4405+
<App
4406+
chatSurfaceMode="compact"
4407+
compactChatState="input"
4408+
/>,
4409+
);
4410+
const toggle = document.body.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
4411+
expect(toggle).not.toBeNull();
4412+
const grabs: Array<Record<string, number>> = [];
4413+
const onGrab = (event: Event) => grabs.push((event as CustomEvent).detail);
4414+
window.addEventListener('neko:compact-surface-drag-grab', onGrab);
4415+
try {
4416+
fireEvent.pointerDown(toggle, {
4417+
pointerId: 7, clientX: 100, clientY: 100, screenX: 300, screenY: 320,
4418+
button: 0, buttons: 1, pointerType: 'mouse',
4419+
});
4420+
fireEvent.pointerMove(toggle, {
4421+
pointerId: 7, clientX: 122, clientY: 108, buttons: 1, pointerType: 'mouse',
4422+
});
4423+
// 拖动超阈值 → 派发一次抓取事件,锚点用按下点(不跳变)。
4424+
expect(grabs).toHaveLength(1);
4425+
expect(grabs[0]).toMatchObject({ clientX: 100, clientY: 100, screenX: 300, screenY: 320 });
4426+
fireEvent.pointerUp(toggle, {
4427+
pointerId: 7, clientX: 122, clientY: 108, buttons: 0, pointerType: 'mouse',
4428+
});
4429+
// 拖完补发的 click 被吞掉,不应展开轮盘。
4430+
fireEvent.click(toggle);
4431+
const fan = document.body.querySelector('.compact-input-tool-fan');
4432+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'false');
4433+
} finally {
4434+
window.removeEventListener('neko:compact-surface-drag-grab', onGrab);
4435+
}
4436+
});
4437+
4438+
it('keeps origin drag click suppression armed across a slow drag (no timeout clear)', () => {
4439+
vi.useFakeTimers();
4440+
try {
4441+
render(
4442+
<App chatSurfaceMode="compact" compactChatState="input" />,
4443+
);
4444+
const toggle = document.body.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
4445+
fireEvent.pointerDown(toggle, {
4446+
pointerId: 31, clientX: 100, clientY: 100, screenX: 300, screenY: 300,
4447+
button: 0, buttons: 1, pointerType: 'mouse',
4448+
});
4449+
fireEvent.pointerMove(toggle, {
4450+
pointerId: 31, clientX: 130, clientY: 110, buttons: 1, pointerType: 'mouse',
4451+
});
4452+
// 慢速拖拽:跨过任何旧的固定时长窗口(曾经的 120ms 定时器会在此误清抑制标志)。
4453+
vi.advanceTimersByTime(1000);
4454+
fireEvent.pointerUp(toggle, {
4455+
pointerId: 31, clientX: 130, clientY: 110, buttons: 0, pointerType: 'mouse',
4456+
});
4457+
// 释放后补发的 click 仍应被吞掉,轮盘不被误展开。
4458+
fireEvent.click(toggle);
4459+
const fan = document.body.querySelector('.compact-input-tool-fan');
4460+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'false');
4461+
} finally {
4462+
vi.useRealTimers();
4463+
}
4464+
});
4465+
4466+
it('treats a stationary tap on the tool toggle as open (no drag-grab)', () => {
4467+
render(
4468+
<App
4469+
chatSurfaceMode="compact"
4470+
compactChatState="input"
4471+
/>,
4472+
);
4473+
const toggle = document.body.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
4474+
const grabs: Event[] = [];
4475+
const onGrab = (event: Event) => grabs.push(event);
4476+
window.addEventListener('neko:compact-surface-drag-grab', onGrab);
4477+
try {
4478+
fireEvent.pointerDown(toggle, {
4479+
pointerId: 8, clientX: 100, clientY: 100, screenX: 300, screenY: 320,
4480+
button: 0, buttons: 1, pointerType: 'mouse',
4481+
});
4482+
fireEvent.pointerUp(toggle, {
4483+
pointerId: 8, clientX: 101, clientY: 100, buttons: 0, pointerType: 'mouse',
4484+
});
4485+
expect(grabs).toHaveLength(0);
4486+
fireEvent.click(toggle);
4487+
const fan = document.body.querySelector('.compact-input-tool-fan');
4488+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
4489+
} finally {
4490+
window.removeEventListener('neko:compact-surface-drag-grab', onGrab);
4491+
}
4492+
});
4493+
4494+
it('dispatches a drag-grab from the open wheel origin and collapses the wheel', () => {
4495+
render(
4496+
<App
4497+
chatSurfaceMode="compact"
4498+
compactChatState="input"
4499+
/>,
4500+
);
4501+
const toggle = document.body.querySelector('.compact-input-tool-toggle') as HTMLButtonElement;
4502+
fireEvent.click(toggle);
4503+
const fan = document.body.querySelector('.compact-input-tool-fan') as HTMLDivElement;
4504+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'true');
4505+
const fanRectSpy = vi.spyOn(fan, 'getBoundingClientRect').mockReturnValue({
4506+
left: 0, top: 0, right: 232, bottom: 232, width: 232, height: 232, x: 0, y: 0,
4507+
toJSON: () => ({}),
4508+
} as DOMRect);
4509+
const grabs: Array<Record<string, number>> = [];
4510+
const onGrab = (event: Event) => grabs.push((event as CustomEvent).detail);
4511+
window.addEventListener('neko:compact-surface-drag-grab', onGrab);
4512+
try {
4513+
// 在轮盘中心(origin)按下并拖动 → 移动文本框而非旋转轮盘。
4514+
fireEvent.pointerDown(fan, {
4515+
pointerId: 9, clientX: 10, clientY: 10, screenX: 210, screenY: 210,
4516+
button: 0, buttons: 1, pointerType: 'mouse',
4517+
});
4518+
fireEvent.pointerMove(fan, {
4519+
pointerId: 9, clientX: 32, clientY: 16, buttons: 1, pointerType: 'mouse',
4520+
});
4521+
expect(grabs).toHaveLength(1);
4522+
expect(grabs[0]).toMatchObject({ clientX: 10, clientY: 10, screenX: 210, screenY: 210 });
4523+
// 拖动是移动手势 → 轮盘收起。
4524+
expect(fan).toHaveAttribute('data-compact-input-tool-fan-open', 'false');
4525+
} finally {
4526+
window.removeEventListener('neko:compact-surface-drag-grab', onGrab);
4527+
fanRectSpy.mockRestore();
4528+
}
4529+
});
4530+
43884531
it('keeps angular wheel drag direction while crossing behind the center', () => {
43894532
render(
43904533
<App

0 commit comments

Comments
 (0)