diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 4d5bb02ae..d299b7c6f 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -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; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 419954f08..eb135b965 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -3540,12 +3540,11 @@ export default function ProjectDevelopmentView({ ); const handleResourceBookWheel = useCallback( - (event: ReactWheelEvent) => { - if (!(event.ctrlKey || event.metaKey)) { - if (resourceBookView !== 'main') { - resourceCanvasWheelHandlerRef.current?.(event.nativeEvent); - event.nativeEvent.preventDefault(); - } + (event: ReactWheelEvent | 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( + '.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) => { 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) => { 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({
-
-
- 资源画本 -

主画布

+
+
+
+ 资源画本 +

主画布

+
+ + {canvasResources.length > 0 + ? `${canvasResources.length} 项正式与候选资源` + : '从子画布开始整理资源'} + +
+
+ {visibleCategoryOrder.map((category) => ( + + ))}
- - {canvasResources.length > 0 - ? `${canvasResources.length} 项正式与候选资源` - : '从子画布开始整理资源'} - -
-
- {visibleCategoryOrder.map((category) => ( - - ))}
( + '.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); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 320988776..cf4fa10a0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4,6 +4,8 @@ ## 2026-09-05 资源画本转场与视口分离 +- 无限画布的指针交互必须绑定未变换、铺满可视区域的视口,平移和缩放只作用于内部 world;若连交互容器一起移动或缩小,内容移走后的空白处会失去拖动入口。内容层不裁剪,裁剪仅由固定视口承担;主画布滚轮使用非 passive 监听,避免浏览器默认滚动或缩放。 + - `resourceBookController` 使用独立 FLIP 快照层和 Web Animations 完成信号;逻辑栏目立即提交,不用定时器猜 CSS 动画终点。再次导航从当前快照位置开始,旧完成回调由 token 失效。 - 屏幕 DOMRect 与逻辑布局坐标不能混用。主画布缩略入口测量先逆 viewport;快照复制必须保留祖先缩放对内部图片、文字的影响,单纯把克隆外框改成屏幕宽高会产生内部留白。 - 点击和滚轮共用导航入口,但滚轮提交不得重置已有的冷却和队列;监听器使用最新导航回调引用,避免视觉 phase 更新重装监听器并清空待处理手势。 diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index fe1af6e5e..f8e18c68d 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -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 阶段补充 资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,共享元素快照从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。