修掉「拖动占位卡仍触发点击」的真因:点击抑制的计时起点必须在松手那一刻
- useResourceCanvasGenerationPlaceholders:抑制状态在越过拖动阈值时只登记、不起计时器,改到 pointerup 才起 0ms 计时(deferDragClickSuppressionCleanup);pointercancel 与切项目走 clearDragClickSuppression 立刻收干净。 - 原实现(越过阈值时就起 0ms 计时器)在真实鼠标下不成立:一次拖动里 pointermove 横跨多轮宏任务,计时器早在松手前就把抑制清掉,收尾那次 click 照样生效——jsdom 同步发 move/up 看不见这一档,所以上一版用例是假绿。 - 用例补上真实时序:拖动过程中显式让时钟走一轮宏任务再松手;宿主用例还补上浏览器在 pointerup 之后补发的 lostpointercapture,并断言它不能把这次拖动的抑制一起清掉。 - 敏感性已双向验证:把计时器搬回「越过阈值那一刻」、以及让 lostpointercapture 清抑制,两条用例都会红;改用例前的同步版本对两种错法都会绿(所以上一版没抓住)。 - 注释写明计时起点为什么是松手那一刻,并与资源卡链路的 deferSkippedResourceCardClickCleanup 对齐口径。
This commit is contained in:
+41
-15
@@ -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 被吃掉一次,之后恢复正常。
|
||||
|
||||
Reference in New Issue
Block a user