WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
5 changed files with 155 additions and 50 deletions
Showing only changes of commit cd17ae8123 - Show all commits
+16 -6
View File
@@ -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 阶段补充
资源工作台采用“主画布 + 子画布”的统一画本模型。主画布展示各子画布的缩略入口,内部按资源类型显示有限层叠卡片,最多三层,更多资源以虚化卡片提示。主画布预览和子画布展开态复用同一套卡片视觉与标题栏结构,共享元素快照从当前屏幕位置过渡到目标位置。子画布标题栏提供缩回主画布的按钮;子画布互切直接到达目标,不强制经过主画布。