修复资源管理滚轮翻页偶发失效
将滚轮分页意图与资源渲染解耦,避免重渲染阻塞连续滚动。 在缩放、手动切页、排序和拖拽时取消过期分页任务。 合并依赖浮层测量调度并补充边界与回归测试。 同步更新资源自由画板技术方案。
This commit is contained in:
+10
-1
@@ -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/,
|
||||
|
||||
Reference in New Issue
Block a user