@@ -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