diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts index ae1f93395..0beb13a7c 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts @@ -75,26 +75,31 @@ export function useResourceCanvasGenerationPlaceholders({ /** * 拖动收尾的那次 `click` 一次性抑制:拖动(down/up 落在同一张卡上)必然引出一次 click, * 不挡掉它,拖完占位就会顺手开合一次生成浮层。与资源卡那条链路同一口径 - * (`skipNextResourceCardClickRef` + 下一个宏任务收干净)。 + * (`skipNextResourceCardClickRef` + `deferSkippedResourceCardClickCleanup`)。 */ const suppressedClickDraftIdRef = useRef(null); const suppressedClickTimerRef = useRef(null); const projectIdRef = useRef(projectId); projectIdRef.current = projectId; - // 切项目:未提交草稿与界面位置一并作废,拖动中也要收掉,避免把上一份会话的占位写进新项目。 - useEffect(() => { - dragRef.current = null; - setDraggingDraftId(null); - suppressedClickDraftIdRef.current = null; + /** 取消抑制(这一次手势不会有收尾 click,或者手势被取消时用)。 */ + const clearDragClickSuppression = useCallback(() => { if (suppressedClickTimerRef.current !== null) { window.clearTimeout(suppressedClickTimerRef.current); suppressedClickTimerRef.current = null; } + suppressedClickDraftIdRef.current = null; + }, []); + + // 切项目:未提交草稿与界面位置一并作废,拖动中也要收掉,避免把上一份会话的占位写进新项目。 + useEffect(() => { + dragRef.current = null; + setDraggingDraftId(null); + clearDragClickSuppression(); setPlaceholders((current) => resourceCanvasGenerationPlaceholdersForProject(current, projectId), ); - }, [projectId]); + }, [clearDragClickSuppression, projectId]); const createPlaceholder = useCallback( (input: ResourceCanvasGenerationPlaceholderDraftInput) => { @@ -181,18 +186,24 @@ export function useResourceCanvasGenerationPlaceholders({ }, []); /** - * 登记一次性点击抑制,并在**下一个宏任务**里收干净。 + * 松手之后把抑制再留**一个宏任务**,然后按 draftId 收干净。 * - * `click` 由浏览器紧接着 `pointerup` 补发(同一轮任务内),所以抑制状态只需要活过一个宏任务; - * 定时清掉的好处是「拖完松手落在卡外、浏览器没补 click」时也不会把抑制残留到下一次真点击上。 + * 计时起点是这条链路的关键:抑制必须在**手势越过阈值那一刻**就登记(那时还不知道会不会 + * 出现收尾 click),但只能在**松手那一刻**才开始计时——`click` 是浏览器紧接着 `pointerup` + * 补发的(同一轮任务内),而一次真实拖动里 pointermove 会横跨很多轮宏任务。若在越过阈值时 + * 就起 0ms 计时器,计时器早在松手之前就把抑制清掉了,收尾 click 照样生效;jsdom 里同步发 + * move / up 看不见这一档,真实鼠标一定看得见(验收现场「拖一下就触发点击」仍在)。 + * + * 与资源卡那条链路同口径(`deferSkippedResourceCardClickCleanup`)。 */ - const suppressClickAfterDrag = useCallback((draftId: string) => { + const deferDragClickSuppressionCleanup = useCallback((draftId: string) => { if (suppressedClickTimerRef.current !== null) { window.clearTimeout(suppressedClickTimerRef.current); } - suppressedClickDraftIdRef.current = draftId; suppressedClickTimerRef.current = window.setTimeout(() => { - suppressedClickDraftIdRef.current = null; + if (suppressedClickDraftIdRef.current === draftId) { + suppressedClickDraftIdRef.current = null; + } suppressedClickTimerRef.current = null; }, 0); }, []); @@ -273,7 +284,8 @@ export function useResourceCanvasGenerationPlaceholders({ return; } drag.changed = true; - suppressClickAfterDrag(drag.draftId); + // 只登记,不起计时器:计时起点必须是松手那一刻(见 deferDragClickSuppressionCleanup)。 + suppressedClickDraftIdRef.current = drag.draftId; // 拖动样式也从越过阈值这一刻起才亮:点一下不该闪一次「拖动中」。 setDraggingDraftId(drag.draftId); } @@ -289,6 +301,12 @@ export function useResourceCanvasGenerationPlaceholders({ return; } event.stopPropagation(); + // 收尾那次 click 就在这一轮任务里跟着来:抑制从现在起再活一个宏任务。 + if (drag.changed) { + deferDragClickSuppressionCleanup(drag.draftId); + } else { + clearDragClickSuppression(); + } }, onPointerCancel: (event: ReactPointerEvent) => { const drag = clearDrag(event.pointerId); @@ -297,9 +315,17 @@ export function useResourceCanvasGenerationPlaceholders({ } // 取消手势(指针捕获丢失 / 窗口失焦):回到拖动前的位置,不留下半截坐标。 move(drag.draftId, drag.startX, drag.startY); + // 取消不会有收尾 click:抑制立刻收干净,别留到下一次真点击上。 + clearDragClickSuppression(); }, }), - [clearDrag, move, suppressClickAfterDrag, viewportScale], + [ + clearDrag, + clearDragClickSuppression, + deferDragClickSuppressionCleanup, + move, + viewportScale, + ], ); const projectPlaceholders = useMemo( diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx index 4066c9902..9d2c9b104 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx @@ -1046,11 +1046,22 @@ describe('拖动生成占位卡不触发卡片的点击(宿主回归)', () = clientX: 260, clientY: 230, }); + /* + * 真实鼠标的拖动会横跨很多轮宏任务(pointermove 一次次派发):这里让时钟真的走一轮再松手。 + * 抑制的计时起点必须在松手那一刻;若实现改回「越过阈值时就起 0ms 计时器」,这一轮之后抑制 + * 就已经被清掉,紧随其后的那次 click 会把浮层关掉,下面的断言立刻红。 + */ + await settle(); fireEvent.pointerUp(placeholder, { pointerId: 7, clientX: 260, clientY: 230, }); + /* + * 真实浏览器在 pointerup 之后会自动释放指针捕获并补一个 `lostpointercapture`(卡片把它接到 + * 取消手势上)。它必须**不能**把这次拖动的点击抑制一起清掉——否则补上来的 click 又会漏过去。 + */ + fireEvent.lostPointerCapture(placeholder, { pointerId: 7 }); // 浏览器补的那一次 click:抑制没生效时这里会立刻把浮层关掉。 fireEvent.click(placeholder); await settle(); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx index 5b8bd9269..41bbf3ec1 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx @@ -447,7 +447,7 @@ describe('占位宿主 Hook', () => { expect(hook().consumeDragClick(draftId)).toBe(false); }); - test('越过阈值才算拖动:登记一次性点击抑制,下一次点击照常生效', () => { + test('越过阈值才算拖动:抑制活到松手那一刻,下一次点击照常生效', async () => { const { hook } = renderDragHarness(); const card = screen.getByRole('button', { name: 'AI 生成图片(待提交)', @@ -466,6 +466,17 @@ describe('占位宿主 Hook', () => { expect(hook().draggingDraftId).toBe(draftId); expect(card.style.left).toBe('13px'); + /* + * 真实鼠标的拖动会横跨很多轮宏任务:这里显式让时钟走一轮再继续。 + * + * 这一行是这条用例的关键——抑制的**计时起点**必须是松手那一刻;如果实现改回「越过阈值时 + * 就起 0ms 计时器」,抑制会在松手之前被清掉,下面那次 `consumeDragClick` 就会变成 false, + * 而真实浏览器里表现为「拖一下还是触发了点击」(jsdom 同步发 move / up 时看不见这一档)。 + */ + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + fireEvent.pointerUp(card, { pointerId: 12, clientX: 106, clientY: 100 }); expect(hook().draggingDraftId).toBeNull(); // 拖动收尾那次 click 被吃掉一次,之后恢复正常。