Merge pull request #2773 from pikasTech/fix/tasktree-scroll-boundary

fix: 阻止 TaskTree 嵌套滚动边界穿透
This commit is contained in:
Lyon
2026-07-23 11:12:59 +08:00
committed by GitHub
2 changed files with 66 additions and 2 deletions
@@ -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 };
}
@@ -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<string, HTMLElement>();
const timelineChildScrollers = new Map<string, HTMLElement>();
const initializedChildGroups = new Set<string>();
const childScrollEdges = ref(new Map<string, { atTop: boolean; atBottom: boolean }>());
const childWheelBoundaryStates = new Map<string, NestedWheelBoundaryState>();
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;