修掉「拖动占位卡仍触发点击」的真因:点击抑制的计时起点必须在松手那一刻

- useResourceCanvasGenerationPlaceholders:抑制状态在越过拖动阈值时只登记、不起计时器,改到 pointerup 才起 0ms 计时(deferDragClickSuppressionCleanup);pointercancel 与切项目走 clearDragClickSuppression 立刻收干净。
- 原实现(越过阈值时就起 0ms 计时器)在真实鼠标下不成立:一次拖动里 pointermove 横跨多轮宏任务,计时器早在松手前就把抑制清掉,收尾那次 click 照样生效——jsdom 同步发 move/up 看不见这一档,所以上一版用例是假绿。
- 用例补上真实时序:拖动过程中显式让时钟走一轮宏任务再松手;宿主用例还补上浏览器在 pointerup 之后补发的 lostpointercapture,并断言它不能把这次拖动的抑制一起清掉。
- 敏感性已双向验证:把计时器搬回「越过阈值那一刻」、以及让 lostpointercapture 清抑制,两条用例都会红;改用例前的同步版本对两种错法都会绿(所以上一版没抓住)。
- 注释写明计时起点为什么是松手那一刻,并与资源卡链路的 deferSkippedResourceCardClickCleanup 对齐口径。
This commit is contained in:
2026-09-21 12:34:47 +08:00
parent 7b57cba14d
commit b4ce6877c8
3 changed files with 64 additions and 16 deletions
@@ -75,26 +75,31 @@ export function useResourceCanvasGenerationPlaceholders({
/**
* 拖动收尾的那次 `click` 一次性抑制:拖动(down/up 落在同一张卡上)必然引出一次 click,
* 不挡掉它,拖完占位就会顺手开合一次生成浮层。与资源卡那条链路同一口径
* `skipNextResourceCardClickRef` + 下一个宏任务收干净)。
* `skipNextResourceCardClickRef` + `deferSkippedResourceCardClickCleanup`)。
*/
const suppressedClickDraftIdRef = useRef<string | null>(null);
const suppressedClickTimerRef = useRef<number | null>(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<HTMLDivElement>) => {
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(
@@ -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();
@@ -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 被吃掉一次,之后恢复正常。