From 7a59f73fa4845fb6fc5bcbe2e4a6ecc4a44fbf1b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 05:11:46 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=98=BB=E6=AD=A2=20TaskTree=20?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E8=BE=B9=E7=95=8C=E7=A9=BF=E9=80=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/utils/nested-wheel-boundary.ts | 51 +++++++++++++++++++ .../src/views/projects/TaskTreeView.vue | 17 ++++++- 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 web/hwlab-cloud-web/src/utils/nested-wheel-boundary.ts diff --git a/web/hwlab-cloud-web/src/utils/nested-wheel-boundary.ts b/web/hwlab-cloud-web/src/utils/nested-wheel-boundary.ts new file mode 100644 index 00000000..ce527bad --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/nested-wheel-boundary.ts @@ -0,0 +1,51 @@ +export const nestedWheelGestureIdleMs = 180; + +export type NestedWheelBoundaryState = { + direction: -1 | 1; + phase: "holding" | "outer"; + lastEventAt: number; +}; + +export type NestedWheelBoundaryDecision = { + action: "inner" | "hold" | "outer"; + state: NestedWheelBoundaryState | null; +}; + +export function decideNestedWheelBoundary(input: { + available: number; + distance: number; + direction: -1 | 1; + eventAt: number; + state: NestedWheelBoundaryState | null; + gestureIdleMs?: number; +}): NestedWheelBoundaryDecision { + const available = Math.max(0, input.available); + const distance = Math.max(0, input.distance); + const gestureIdleMs = input.gestureIdleMs ?? nestedWheelGestureIdleMs; + + if (available > 1 && distance <= available + 1) { + return { action: "inner", state: null }; + } + + const nextHoldingState: NestedWheelBoundaryState = { + direction: input.direction, + phase: "holding", + lastEventAt: input.eventAt + }; + if (available > 1 || !input.state || input.state.direction !== input.direction) { + return { action: "hold", state: nextHoldingState }; + } + if (input.state.phase === "outer") { + return { + action: "outer", + state: { ...input.state, lastEventAt: input.eventAt } + }; + } + if (input.eventAt - input.state.lastEventAt > gestureIdleMs) { + return { + action: "outer", + state: { direction: input.direction, phase: "outer", lastEventAt: input.eventAt } + }; + } + return { action: "hold", state: nextHoldingState }; +} diff --git a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue index d7704812..1959f63c 100644 --- a/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue +++ b/web/hwlab-cloud-web/src/views/projects/TaskTreeView.vue @@ -8,6 +8,7 @@ import BaseDialog from "@/components/common/BaseDialog.vue"; import TaskTreeDetailPanel from "@/components/tasktree/TaskTreeDetailPanel.vue"; import MessageMarkdown from "@/components/workbench/MessageMarkdown.vue"; import { displayTimeConfig } from "@/config/runtime"; +import { decideNestedWheelBoundary, type NestedWheelBoundaryState } from "@/utils/nested-wheel-boundary"; import { deriveEffectiveTaskTimeRanges } from "@/utils/tasktree-time-range"; const route = useRoute(); @@ -26,6 +27,7 @@ const labelChildScrollers = new Map(); const timelineChildScrollers = new Map(); const initializedChildGroups = new Set(); const childScrollEdges = ref(new Map()); +const childWheelBoundaryStates = new Map(); const timelineScaleIndex = ref(1); const taskColumnWidth = ref(typeof window !== "undefined" ? Math.round(window.innerWidth * (window.innerWidth <= 720 ? 0.55 : 0.21)) : 400); const loading = ref(false); @@ -181,6 +183,7 @@ function setChildScroller(side: "labels" | "timeline", groupId: string, element: const map = side === "labels" ? labelChildScrollers : timelineChildScrollers; if (!element) { map.delete(groupId); + childWheelBoundaryStates.delete(groupId); const edges = new Map(childScrollEdges.value); edges.delete(`${side}:${groupId}`); childScrollEdges.value = edges; @@ -238,13 +241,23 @@ function handleChildWheel(event: WheelEvent, groupId: string, side: "labels" | " const scroller = (side === "labels" ? labelChildScrollers : timelineChildScrollers).get(groupId); if (!scroller) return; const delta = event.deltaY * (event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 40 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? scroller.clientHeight : 1); + if (delta === 0) return; const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); const available = Math.max(0, delta < 0 ? scroller.scrollTop : maxScrollTop - scroller.scrollTop); - if (Math.abs(delta) <= available + 1) return; + const decision = decideNestedWheelBoundary({ + available, + distance: Math.abs(delta), + direction: delta < 0 ? -1 : 1, + eventAt: event.timeStamp, + state: childWheelBoundaryStates.get(groupId) ?? null + }); + if (decision.state) childWheelBoundaryStates.set(groupId, decision.state); + else childWheelBoundaryStates.delete(groupId); + if (decision.action === "inner") return; event.preventDefault(); scroller.scrollTop = delta < 0 ? 0 : maxScrollTop; syncChildScroller(groupId, side); - scrollOuterBy(side, delta < 0 ? delta + available : delta - available); + if (decision.action === "outer") scrollOuterBy(side, delta); } function scrollOuterBy(side: "labels" | "timeline", delta: number) { const source = side === "labels" ? taskLabelsElement.value : timelineBodyElement.value;