修复资源管理滚轮翻页偶发失效

将滚轮分页意图与资源渲染解耦,避免重渲染阻塞连续滚动。

在缩放、手动切页、排序和拖拽时取消过期分页任务。

合并依赖浮层测量调度并补充边界与回归测试。

同步更新资源自由画板技术方案。
This commit is contained in:
2026-08-25 18:38:15 +08:00
parent 7cb79e3aa4
commit 36da81d45d
5 changed files with 187 additions and 41 deletions
@@ -473,6 +473,8 @@ export const ResourceDependencyOverlay = forwardRef<
) {
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
const overlayRef = useRef<SVGSVGElement>(null);
const canvasViewportRef = useRef(canvasViewport);
const scheduleViewportMeasureRef = useRef<() => void>(() => undefined);
const [logicalViewport, setLogicalViewport] = useState<Rect | null>(null);
const [dragPreview, setDragPreview] = useState<
(Point & { resourceId: string }) | null
@@ -507,7 +509,7 @@ export const ResourceDependencyOverlay = forwardRef<
const transformedViewport = plane.closest(
'.game-resource-canvas--dependency',
)
? canvasViewport
? canvasViewportRef.current
: undefined;
const scale = transformedViewport?.scale ?? legacyScale;
const viewportRect = viewport.getBoundingClientRect();
@@ -536,6 +538,7 @@ export const ResourceDependencyOverlay = forwardRef<
}
frameId = window.requestAnimationFrame(measure);
};
scheduleViewportMeasureRef.current = scheduleMeasure;
measure();
const ResizeObserverClass = window.ResizeObserver;
const observer = ResizeObserverClass
@@ -552,7 +555,13 @@ export const ResourceDependencyOverlay = forwardRef<
observer?.disconnect();
viewport.removeEventListener('scroll', scheduleMeasure);
window.removeEventListener('resize', scheduleMeasure);
scheduleViewportMeasureRef.current = () => undefined;
};
}, []);
useLayoutEffect(() => {
canvasViewportRef.current = canvasViewport;
scheduleViewportMeasureRef.current();
}, [canvasViewport, geometryRevision]);
const rectByResourceId = useMemo(() => {
@@ -26,6 +26,7 @@ import {
memo,
type PointerEvent as ReactPointerEvent,
type ReactNode,
startTransition,
useCallback,
useEffect,
useId,
@@ -850,6 +851,9 @@ export default function ProjectDevelopmentView({
const [searchText, setSearchText] = useState('');
const [resourceCanvasViewports, setResourceCanvasViewports] =
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
const resourceCanvasViewportTargetsRef = useRef(
defaultResourceCanvasViewports(),
);
const [activeResourceCategory, setActiveResourceCategory] =
useState<ResourceCategory | null>(null);
const [unreadResourceCategoryState, setUnreadResourceCategoryState] =
@@ -942,8 +946,10 @@ export default function ProjectDevelopmentView({
pendingStepCount: 0,
pendingTimerId: null,
});
const renderedResourceCategoryRef = useRef<ResourceCategory | null>(null);
const resourceCanvasPanRef = useRef<{
pointerId: number;
category: ResourceCategory;
startClientX: number;
startClientY: number;
startViewport: CanvasViewport;
@@ -1499,7 +1505,6 @@ export default function ProjectDevelopmentView({
unreadResourceCategoryState.scopeKey === resourceCategoryScopeKey
? unreadResourceCategoryState.categories
: new Set<ResourceCategory>();
resourceCanvasPageWheelRef.current.activeCategory = activePageCategory;
const nextPageCategory =
activePageCategory && resourcePageCategories.length > 1
? resourcePageCategories[
@@ -1608,36 +1613,74 @@ export default function ProjectDevelopmentView({
resourcePositionsByCategory,
],
);
const activeResourceCanvasViewport = activePageCategory
? resourceCanvasViewports[sortMode][activePageCategory]
: { x: 48, y: 48, scale: 1 };
const activeResourceCanvasViewport = useMemo(
() =>
activePageCategory
? resourceCanvasViewports[sortMode][activePageCategory]
: { x: 48, y: 48, scale: 1 },
[activePageCategory, resourceCanvasViewports, sortMode],
);
const setResourceCanvasViewport = useCallback(
(candidate: CanvasViewport) => {
const activeCategory = resourceCanvasPageWheelRef.current.activeCategory;
if (!activeCategory) {
(
category: ResourceCategory,
candidate: CanvasViewport,
deferRender = false,
) => {
if (!resourcePageCategories.includes(category)) {
return;
}
const next = normalizeInfiniteResourceCanvasViewport(candidate);
resourceCanvasViewportRef.current = next;
setResourceCanvasViewports((current) => {
const currentSortViewports = current[sortMode];
return resourceCanvasViewportsEqual(
currentSortViewports[activeCategory],
next,
)
? current
: {
...current,
[sortMode]: {
...currentSortViewports,
[activeCategory]: next,
},
};
});
resourceCanvasViewportTargetsRef.current = {
...resourceCanvasViewportTargetsRef.current,
[sortMode]: {
...resourceCanvasViewportTargetsRef.current[sortMode],
[category]: next,
},
};
const commitViewport = () => {
setResourceCanvasViewports((current) => {
const currentSortViewports = current[sortMode];
if (
!resourceCanvasViewportsEqual(
resourceCanvasViewportTargetsRef.current[sortMode][category],
next,
)
) {
return current;
}
return resourceCanvasViewportsEqual(
currentSortViewports[category],
next,
)
? current
: {
...current,
[sortMode]: {
...currentSortViewports,
[category]: next,
},
};
});
};
if (deferRender) {
startTransition(commitViewport);
} else {
commitViewport();
}
},
[sortMode],
[resourcePageCategories, sortMode],
);
resourceCanvasViewportRef.current = activeResourceCanvasViewport;
useLayoutEffect(() => {
const pageWheelState = resourceCanvasPageWheelRef.current;
renderedResourceCategoryRef.current = activePageCategory;
if (pageWheelState.activeCategory === null) {
pageWheelState.activeCategory = activePageCategory;
}
if (pageWheelState.activeCategory === activePageCategory) {
resourceCanvasViewportRef.current = activeResourceCanvasViewport;
}
}, [activePageCategory, activeResourceCanvasViewport]);
const selectedResource =
resources.find((resource) => resource.id === selectedResourceId) ?? null;
const focusedResource =
@@ -1852,6 +1895,7 @@ export default function ProjectDevelopmentView({
state.lastSwitchAt = 0;
state.pendingStepCount = 0;
state.pendingTimerId = null;
state.activeCategory = renderedResourceCategoryRef.current;
}, []);
const cancelResourceCanvasPan = useCallback(() => {
@@ -1919,7 +1963,13 @@ export default function ProjectDevelopmentView({
cancelResourceCardDrag();
cancelResourceCanvasPan();
resourceCanvasPageWheelRef.current.activeCategory = nextCategory;
setActiveResourceCategory(nextCategory);
startTransition(() => {
setActiveResourceCategory((currentCategory) =>
resourceCanvasPageWheelRef.current.activeCategory === nextCategory
? nextCategory
: currentCategory,
);
});
},
[cancelResourceCanvasPan, cancelResourceCardDrag, resourcePageCategories],
);
@@ -1948,10 +1998,15 @@ export default function ProjectDevelopmentView({
const waitMs = pageWheelSwitchIntervalMs - (now - state.lastSwitchAt);
if (waitMs > 0) {
if (state.pendingTimerId === null) {
state.pendingTimerId = window.setTimeout(() => {
resourceCanvasPageWheelRef.current.pendingTimerId = null;
const timerId = window.setTimeout(() => {
const latestState = resourceCanvasPageWheelRef.current;
if (latestState.pendingTimerId !== timerId) {
return;
}
latestState.pendingTimerId = null;
flushPageWheel();
}, waitMs);
state.pendingTimerId = timerId;
}
return;
}
@@ -1974,8 +2029,15 @@ export default function ProjectDevelopmentView({
if (resourcePageCategories.length > 0) {
event.preventDefault();
if (event.ctrlKey || event.metaKey) {
const rect =
resourceCanvasViewportElement(canvas)!.getBoundingClientRect();
cancelQueuedResourceCanvasPageWheel();
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
| ResourceCategory
| undefined;
if (!viewport || !category) {
return;
}
const rect = viewport.getBoundingClientRect();
const result = resolveViewportFromWheel({
viewport: resourceCanvasViewportRef.current,
deltaX: event.deltaX,
@@ -1988,7 +2050,7 @@ export default function ProjectDevelopmentView({
y: event.clientY - rect.top,
},
});
setResourceCanvasViewport(result.viewport);
setResourceCanvasViewport(category, result.viewport, true);
return;
}
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) {
@@ -2064,6 +2126,7 @@ export default function ProjectDevelopmentView({
};
const gestureStart = (rawEvent: Event) => {
const event = rawEvent as WebKitGestureEvent;
cancelQueuedResourceCanvasPageWheel();
const viewport = resolveViewport(event);
const category = viewport?.dataset.resourceSectionScroll as
| ResourceCategory
@@ -2148,6 +2211,7 @@ export default function ProjectDevelopmentView({
}, [
advanceResourceCanvasPage,
applyAnchoredResourceSectionZoom,
cancelQueuedResourceCanvasPageWheel,
mode,
queueResourceSectionZoomScroll,
resourcePageCategories.length,
@@ -2175,6 +2239,7 @@ export default function ProjectDevelopmentView({
if (measuredWidth > 0 && measuredHeight > 0) {
resourceCanvasFitKeysRef.current.add(fitKey);
setResourceCanvasViewport(
activePageCategory,
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize: { width: measuredWidth, height: measuredHeight },
@@ -2186,7 +2251,10 @@ export default function ProjectDevelopmentView({
}
// Resource extents can change after an image finishes measuring or a
// card is manually moved. Keep the user's infinite pan/zoom unchanged.
setResourceCanvasViewport(resourceCanvasViewportRef.current);
setResourceCanvasViewport(
activePageCategory,
resourceCanvasViewportRef.current,
);
};
fitInitialViewportOrPreserve();
const observer = window.ResizeObserver
@@ -2346,6 +2414,7 @@ export default function ProjectDevelopmentView({
restoreResourceListScrollRef.current = false;
setActiveResourceCategory(null);
const defaultViewports = defaultResourceCanvasViewports();
resourceCanvasViewportTargetsRef.current = defaultViewports;
setResourceCanvasViewports(defaultViewports);
resourceCanvasFitKeysRef.current.clear();
resourceCanvasViewportRef.current = defaultViewports.dependency.document;
@@ -2769,15 +2838,19 @@ export default function ProjectDevelopmentView({
);
const resetResourceCanvasViewport = useCallback(() => {
if (!activePageCategory) {
return;
}
const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current);
setResourceCanvasViewport(
activePageCategory,
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
}, [resourceCanvasFitBounds, setResourceCanvasViewport]);
}, [activePageCategory, resourceCanvasFitBounds, setResourceCanvasViewport]);
const handleResourceCardPointerDown = useCallback(
(
@@ -2931,6 +3004,7 @@ export default function ProjectDevelopmentView({
event.currentTarget.setPointerCapture(event.pointerId);
resourceCanvasPanRef.current = {
pointerId: event.pointerId,
category: activePageCategory,
startClientX: event.clientX,
startClientY: event.clientY,
startViewport: resourceCanvasViewportRef.current,
@@ -2945,11 +3019,15 @@ export default function ProjectDevelopmentView({
if (!pan || pan.pointerId !== event.pointerId) {
return;
}
setResourceCanvasViewport({
...pan.startViewport,
x: pan.startViewport.x + event.clientX - pan.startClientX,
y: pan.startViewport.y + event.clientY - pan.startClientY,
});
setResourceCanvasViewport(
pan.category,
{
...pan.startViewport,
x: pan.startViewport.x + event.clientX - pan.startClientX,
y: pan.startViewport.y + event.clientY - pan.startClientY,
},
true,
);
},
[setResourceCanvasViewport],
);
@@ -2973,6 +3051,7 @@ export default function ProjectDevelopmentView({
advanceFocusGeneration();
cancelResourceCardDrag();
cancelResourceCanvasPan();
cancelQueuedResourceCanvasPageWheel();
captureResourceListScrollPosition();
captureResourceSectionScrollPositions();
setSortMode(nextMode);
@@ -986,8 +986,11 @@ describe('ResourceDependencyOverlay', () => {
it('disconnects layout observers when the SVG layer is destroyed', () => {
const observe = vi.fn();
const disconnect = vi.fn();
const construct = vi.fn();
class TestResizeObserver {
constructor(_callback: ResizeObserverCallback) {}
constructor(_callback: ResizeObserverCallback) {
construct();
}
observe = observe;
unobserve = vi.fn();
@@ -1004,6 +1007,7 @@ describe('ResourceDependencyOverlay', () => {
overlayView(graph, positions, new Set(graph.resourceIds), overlayRef),
);
expect(construct).toHaveBeenCalledTimes(1);
expect(observe).toHaveBeenCalledTimes(2);
act(() =>
overlayRef.current?.updateDragPreview({
@@ -1012,6 +1016,10 @@ describe('ResourceDependencyOverlay', () => {
y: 24,
}),
);
view.rerender(
overlayView(graph, positions, new Set(graph.resourceIds), overlayRef),
);
expect(construct).toHaveBeenCalledTimes(1);
expect(observe).toHaveBeenCalledTimes(2);
view.unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
@@ -643,6 +643,56 @@ export function registerProjectWorkbenchFoundationTests() {
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
dispatchPageWheel(dependencyCanvas);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
dispatchPageWheel(dependencyCanvas);
const zoomWhilePageWheelQueued = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaX: 0,
deltaY: -120,
clientX: 80,
clientY: 60,
});
act(() => {
expect(
screen
.getByRole('button', { name: '复位资源画布' })
.dispatchEvent(zoomWhilePageWheelQueued),
).toBe(false);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 220));
});
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
dispatchPageWheel(dependencyCanvas);
expect(
screen.getByRole('region', { name: '音乐音效资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
act(() => {
dependencyCanvas.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY: 160,
}),
);
within(outline)
.getByRole('button', { name: /^项目版本/ })
.click();
});
expect(
screen.getByRole('region', { name: '项目版本资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const dragCard = screen
.getByRole('button', {
name: /打开资源详情:设计文档 section\.md/,