修复资源主画布无限平移与滚轮交互
分离固定交互视口与可平移缩放的内容层 支持主画布滚轮平移并保留缩放及主子画布视口记忆 补充越界拖动和滚轮回归测试并同步技术文档
This commit is contained in:
@@ -5381,19 +5381,29 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-resource-book-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: clamp(24px, 4vw, 52px);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
background:
|
||||
radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
|
||||
linear-gradient(145deg, #fffdfa 0%, #fff8f2 100%);
|
||||
}
|
||||
|
||||
.game-resource-book-main:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.game-resource-book-main-world {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
padding: clamp(24px, 4vw, 52px);
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.game-resource-book-main-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
|
||||
@@ -3540,12 +3540,11 @@ export default function ProjectDevelopmentView({
|
||||
);
|
||||
|
||||
const handleResourceBookWheel = useCallback(
|
||||
(event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
if (resourceBookView !== 'main') {
|
||||
resourceCanvasWheelHandlerRef.current?.(event.nativeEvent);
|
||||
event.nativeEvent.preventDefault();
|
||||
}
|
||||
(event: ReactWheelEvent<HTMLDivElement> | WheelEvent) => {
|
||||
if (resourceBookView !== 'main' && !(event.ctrlKey || event.metaKey)) {
|
||||
const nativeEvent = 'nativeEvent' in event ? event.nativeEvent : event;
|
||||
resourceCanvasWheelHandlerRef.current?.(nativeEvent);
|
||||
nativeEvent.preventDefault();
|
||||
return;
|
||||
}
|
||||
resourceBookTransitionControllerRef.current.settle();
|
||||
@@ -3598,6 +3597,17 @@ export default function ProjectDevelopmentView({
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const main = resourceBookManagerRef.current?.querySelector<HTMLElement>(
|
||||
'.game-resource-book-main',
|
||||
);
|
||||
if (!main) return;
|
||||
// React wheel listeners are passive; canvas gestures must suppress native
|
||||
// scrolling/zooming even when all content has been panned off screen.
|
||||
main.addEventListener('wheel', handleResourceBookWheel, { passive: false });
|
||||
return () => main.removeEventListener('wheel', handleResourceBookWheel);
|
||||
}, [handleResourceBookWheel, mode]);
|
||||
|
||||
const handleResourceBookMainPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (resourceBookView !== 'main' || event.button !== 0) {
|
||||
@@ -3611,7 +3621,8 @@ export default function ProjectDevelopmentView({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
resourceBookTransitionControllerRef.current.settle();
|
||||
resourceBookMainPanRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
@@ -3643,8 +3654,8 @@ export default function ProjectDevelopmentView({
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (resourceBookMainPanRef.current?.pointerId === event.pointerId) {
|
||||
resourceBookMainPanRef.current = null;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5716,43 +5727,46 @@ export default function ProjectDevelopmentView({
|
||||
<section
|
||||
className={`game-resource-book-main${resourceBookState.view === 'child' ? ' is-background' : ''}`}
|
||||
aria-label="资源主画布"
|
||||
style={
|
||||
resourceBookState.view === 'main'
|
||||
? ({
|
||||
transform: `translate(${resourceBookMainViewport.x}px, ${resourceBookMainViewport.y}px) scale(${resourceBookMainViewport.scale})`,
|
||||
transformOrigin: '0 0',
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={handleResourceBookMainPointerDown}
|
||||
onPointerMove={handleResourceBookMainPointerMove}
|
||||
onPointerUp={stopResourceBookMainPan}
|
||||
onPointerCancel={stopResourceBookMainPan}
|
||||
onWheel={handleResourceBookWheel}
|
||||
onLostPointerCapture={stopResourceBookMainPan}
|
||||
>
|
||||
<header className="game-resource-book-main-heading">
|
||||
<div>
|
||||
<small>资源画本</small>
|
||||
<h2>主画布</h2>
|
||||
<div
|
||||
className="game-resource-book-main-world"
|
||||
style={
|
||||
resourceBookState.view === 'main'
|
||||
? {
|
||||
transform: `translate(${resourceBookMainViewport.x}px, ${resourceBookMainViewport.y}px) scale(${resourceBookMainViewport.scale})`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<header className="game-resource-book-main-heading">
|
||||
<div>
|
||||
<small>资源画本</small>
|
||||
<h2>主画布</h2>
|
||||
</div>
|
||||
<span>
|
||||
{canvasResources.length > 0
|
||||
? `${canvasResources.length} 项正式与候选资源`
|
||||
: '从子画布开始整理资源'}
|
||||
</span>
|
||||
</header>
|
||||
<div className="game-resource-book-main-grid">
|
||||
{visibleCategoryOrder.map((category) => (
|
||||
<ResourceBookThumbnail
|
||||
key={category}
|
||||
category={category}
|
||||
resources={
|
||||
projectResourcesByCategory.get(category) ?? []
|
||||
}
|
||||
onOpen={openResourceBookChild}
|
||||
registerElement={registerResourceBookThumbnail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>
|
||||
{canvasResources.length > 0
|
||||
? `${canvasResources.length} 项正式与候选资源`
|
||||
: '从子画布开始整理资源'}
|
||||
</span>
|
||||
</header>
|
||||
<div className="game-resource-book-main-grid">
|
||||
{visibleCategoryOrder.map((category) => (
|
||||
<ResourceBookThumbnail
|
||||
key={category}
|
||||
category={category}
|
||||
resources={
|
||||
projectResourcesByCategory.get(category) ?? []
|
||||
}
|
||||
onOpen={openResourceBookChild}
|
||||
registerElement={registerResourceBookThumbnail}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
|
||||
@@ -575,12 +575,88 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
'.game-resource-book-scene-world',
|
||||
);
|
||||
const mainCanvas = screen.getByRole('region', { name: '资源主画布' });
|
||||
const mainWorld = mainCanvas.querySelector<HTMLElement>(
|
||||
'.game-resource-book-main-world',
|
||||
)!;
|
||||
expect(mainWorld).not.toBeNull();
|
||||
const mainTransformBeforeZoom = mainSceneWorld?.style.transform;
|
||||
const mainCanvasTransformBeforeZoom = mainCanvas.style.transform;
|
||||
fireEvent.click(screen.getByRole('button', { name: '放大画布' }));
|
||||
expect(mainSceneWorld?.style.transform).not.toBe(mainTransformBeforeZoom);
|
||||
expect(mainCanvas.style.transform).not.toBe(mainCanvasTransformBeforeZoom);
|
||||
expect(mainCanvas.style.transform).toBe(mainSceneWorld?.style.transform);
|
||||
expect(mainCanvas.style.transform).toBe('');
|
||||
expect(mainWorld.style.transform).toBe(mainSceneWorld?.style.transform);
|
||||
|
||||
const mainSetPointerCapture = vi.fn();
|
||||
const mainReleasePointerCapture = vi.fn();
|
||||
Object.defineProperties(mainCanvas, {
|
||||
setPointerCapture: { configurable: true, value: mainSetPointerCapture },
|
||||
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
|
||||
releasePointerCapture: {
|
||||
configurable: true,
|
||||
value: mainReleasePointerCapture,
|
||||
},
|
||||
});
|
||||
const readMainViewport = () =>
|
||||
mainWorld.style.transform.match(/-?\d+(?:\.\d+)?/g)!.map(Number);
|
||||
const mainBeforePan = readMainViewport();
|
||||
const dragMain = (x: number, y: number) => {
|
||||
fireEvent.pointerDown(mainCanvas, {
|
||||
pointerId: 81,
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
});
|
||||
fireEvent.pointerMove(mainCanvas, {
|
||||
pointerId: 81,
|
||||
clientX: 100 + x,
|
||||
clientY: 100 + y,
|
||||
});
|
||||
fireEvent.pointerUp(mainCanvas, { pointerId: 81 });
|
||||
};
|
||||
dragMain(-4_000, 3_000);
|
||||
expect(readMainViewport()).toEqual([
|
||||
mainBeforePan[0]! - 4_000,
|
||||
mainBeforePan[1]! + 3_000,
|
||||
mainBeforePan[2],
|
||||
]);
|
||||
dragMain(8_000, -6_000);
|
||||
expect(readMainViewport()).toEqual([
|
||||
mainBeforePan[0]! + 4_000,
|
||||
mainBeforePan[1]! - 3_000,
|
||||
mainBeforePan[2],
|
||||
]);
|
||||
expect(mainSetPointerCapture).toHaveBeenCalledWith(81);
|
||||
expect(mainReleasePointerCapture).toHaveBeenCalledWith(81);
|
||||
dragMain(-4_000, 3_000);
|
||||
expect(readMainViewport()).toEqual(mainBeforePan);
|
||||
|
||||
const mainWheel = (init: WheelEventInit) => {
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
act(() => {
|
||||
mainCanvas.dispatchEvent(event);
|
||||
});
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
};
|
||||
mainWheel({ deltaX: 70, deltaY: 120 });
|
||||
expect(readMainViewport()).toEqual([
|
||||
mainBeforePan[0]! - 70,
|
||||
mainBeforePan[1]! - 120,
|
||||
mainBeforePan[2],
|
||||
]);
|
||||
mainWheel({ deltaY: 80, shiftKey: true });
|
||||
expect(readMainViewport()).toEqual([
|
||||
mainBeforePan[0]! - 150,
|
||||
mainBeforePan[1]! - 120,
|
||||
mainBeforePan[2],
|
||||
]);
|
||||
mainWheel({ deltaY: -120, ctrlKey: true, clientX: 80, clientY: 60 });
|
||||
expect(readMainViewport()[2]).toBeGreaterThan(mainBeforePan[2]!);
|
||||
expect(mainCanvas.style.transform).toBe('');
|
||||
expect(mainWorld.style.transform).toBe(mainSceneWorld?.style.transform);
|
||||
const mainViewportBeforeNavigation = mainWorld.style.transform;
|
||||
|
||||
const dependencyCanvas = (await screen.findByLabelText(
|
||||
'资源依赖视图',
|
||||
@@ -801,6 +877,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
.closest('[data-resource-book-view]')
|
||||
?.getAttribute('data-resource-book-transition'),
|
||||
).toBe('idle');
|
||||
expect(mainWorld.style.transform).toBe(mainViewportBeforeNavigation);
|
||||
await openResourceBookCategory('设计文档');
|
||||
|
||||
dispatchPageWheel(dependencyCanvas);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
## 2026-09-05 资源画本转场与视口分离
|
||||
|
||||
- 无限画布的指针交互必须绑定未变换、铺满可视区域的视口,平移和缩放只作用于内部 world;若连交互容器一起移动或缩小,内容移走后的空白处会失去拖动入口。内容层不裁剪,裁剪仅由固定视口承担;主画布滚轮使用非 passive 监听,避免浏览器默认滚动或缩放。
|
||||
|
||||
- `resourceBookController` 使用独立 FLIP 快照层和 Web Animations 完成信号;逻辑栏目立即提交,不用定时器猜 CSS 动画终点。再次导航从当前快照位置开始,旧完成回调由 token 失效。
|
||||
- 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;快照复制必须保留祖先缩放对内部图片、文字的影响,单纯把克隆外框改成屏幕宽高会产生内部留白。
|
||||
- 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- `ResourceBookTransitionLayer` 是 manager 内未变换的独立层。快照仅复制表现,不挂载 React 业务组件,移除重复 ID、业务 data 属性及可访问控件身份,整层 `inert / aria-hidden / pointer-events:none`。播放中的视频优先捕获当前帧;跨源禁止读取时保留已有 poster,不启动第二个播放器。
|
||||
- FLIP 只动画快照的 transform/opacity,统一时长 420ms,所有 `Animation.finished` 完成后删除快照并恢复目标真实节点;动画创建失败、零尺寸或不支持 Web Animations 时直接完成。`prefers-reduced-motion` 不创建动画,不等待定时器。
|
||||
- 再次导航先读取当前动画快照的实际位置,再取消旧动画并重新建立目标;token 隔离旧 promise,过期完成和取消均不能提交新状态。窗口尺寸变化、排序或异步布局更新时直接收尾到当前目标,避免使用过期几何。
|
||||
- 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。普通滚轮保留分页 controller 的原有阈值、冷却与队列,导航提交不能重置该冷却;Ctrl/Meta 滚轮取消待切页队列。
|
||||
- 缩放、适应内容和空白拖动先结束视觉转场再执行用户输入;转场中的资源卡 pointer-down 不启动持久化拖拽。子画布普通滚轮保留分页 controller 的原有阈值、冷却与队列,导航提交不能重置该冷却;Ctrl/Meta 滚轮取消待切页队列。
|
||||
- viewport 只属于用户交互状态,按主画布以及 `sortMode/category` 隔离保存。转场不插值也不持久化 viewport;测量主画布缩略入口时先逆变换回布局坐标,避免缩放后的 DOMRect 被二次缩放。只有真实非零测量才能标记首次 fit 完成。
|
||||
|
||||
验收使用 `resourceBookController.test.ts`、`resourceBookModel.test.ts`、`resourceBookViewport.test.ts` 和 `appSurface.test.ts`;页面 `.suite.ts` 由 `appSurface.test.ts` 注册,不能作为独立 Vitest 文件运行。真实浏览器需覆盖主/子往返、连续改选、滚轮切换、取消后缩放、尺寸变化、减少动画偏好,以及快照图片和字体的正确比例。jsdom 不模拟真实插值,不能代替浏览器验收。
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
返回主画布时立即提交主画布布局,由独立快照层完成缩回;world/main viewport 不播放附加动画。
|
||||
|
||||
主画布同样使用固定铺满 manager 的交互视口,只有内部 `.game-resource-book-main-world` 承担平移和缩放,内容层不裁剪缩略入口。空白拖动允许双向越过全部内容边界;移走内容后仍可在原视口继续拖动。普通滚轮/触控板平移,Shift 滚轮横向平移,Ctrl/Meta 滚轮以指针为锚点缩放;主画布用非 passive 的原生 wheel 监听阻止浏览器滚动或页面缩放,子画布普通滚轮继续使用栏目分页。主/子往返保留主画布原有位置和比例,只有主动适应内容才复位。
|
||||
|
||||
## 2026-09-04 资源画本 UI 阶段补充
|
||||
|
||||
资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,共享元素快照从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。
|
||||
|
||||
Reference in New Issue
Block a user