diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css
index 201a310a2..09729a15b 100644
--- a/apps/ai-game-creator-shell/src/styles.css
+++ b/apps/ai-game-creator-shell/src/styles.css
@@ -5869,7 +5869,7 @@ iframe.preview-frame {
height: var(--resource-card-height);
min-height: var(--resource-card-height);
padding: 0;
- overflow: hidden;
+ overflow: visible;
border: 1px solid #eadbd4;
border-radius: 12px;
background: #fff;
@@ -5912,6 +5912,7 @@ iframe.preview-frame {
inset: 0;
display: grid;
overflow: hidden;
+ border-radius: inherit;
background: linear-gradient(145deg, #fffaf6, #f6ece6);
color: #c46a40;
place-items: center;
@@ -5973,6 +5974,13 @@ iframe.preview-frame {
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 8px 18px rgb(96 62 47 / 20%);
+ transform: scale(var(--genarrative-image-canvas-inverse-scale, 1));
+ transform-origin: top right;
+}
+
+.game-resource-card-media-control {
+ transform: scale(var(--genarrative-image-canvas-inverse-scale, 1));
+ transform-origin: bottom right;
}
.game-resource-card-open {
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 f539fc02f..6735ec1b4 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
@@ -3,6 +3,7 @@ import {
type CanvasViewport,
resolveViewportFromWheel,
} from '@genarrative/image-canvas-core';
+import { CanvasWorld } from '@genarrative/image-canvas-react';
import {
Code2,
FileText,
@@ -4906,17 +4907,12 @@ export default function ProjectDevelopmentView({
>
-
{sortMode === 'dependency' ? (
);
})}
-
+
{(
visibleResourcesByCategory.get(
activePageCategory,
diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
index 64373c01d..e92aab6d7 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts
@@ -1986,6 +1986,9 @@ export function registerProjectWorkbenchFoundationTests() {
expect(styles).toMatch(
/\.game-resource-card-open\s*\{[^}]*cursor:\s*pointer[^}]*touch-action:\s*manipulation/s,
);
+ expect(styles).toMatch(
+ /\.game-resource-card-media-control\s*\{[^}]*transform:\s*scale\(var\(--genarrative-image-canvas-inverse-scale,\s*1\)\)[^}]*transform-origin:\s*bottom\s+right/s,
+ );
expect(styles).toMatch(
/\.game-resource-canvas\s*\{[^}]*user-select:\s*none/s,
);
diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx
index 10ff7a0c7..8e69a5476 100644
--- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx
+++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx
@@ -11,6 +11,7 @@ import type {
ImageCanvasHostScope,
ImageCanvasProjectPort,
} from '@genarrative/image-canvas-core';
+import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react';
import {
act,
fireEvent,
@@ -1125,7 +1126,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(
document.querySelector('.genarrative-image-canvas__world')
?.style.transform,
- ).toBe('translate(-2570px, -2495px) scale(1)');
+ ).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 }));
expect(screen.getByText('已定位当前最终图')).toBeTruthy();
});
@@ -1170,7 +1171,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(
document.querySelector('.genarrative-image-canvas__world')
?.style.transform,
- ).toBe('translate(-2570px, -2495px) scale(1)');
+ ).toBe(canvasViewportToWorldTransform({ x: -2570, y: -2495, scale: 1 }));
expect(screen.getByText('已定位当前最终图')).toBeTruthy();
fireEvent.click(layerButton);
@@ -2062,7 +2063,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
clientY: 60,
});
await waitFor(() =>
- expect(world.style.transform).toBe('translate(30px, 30px) scale(0.5)'),
+ expect(world.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: 30, y: 30, scale: 0.5 }),
+ ),
);
fireEvent.pointerMove(window, {
pointerId: 16,
@@ -2070,7 +2073,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
clientY: 30,
});
await waitFor(() =>
- expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)'),
+ expect(world.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }),
+ ),
);
fireEvent.pointerUp(window, {
pointerId: 16,
@@ -2315,7 +2320,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
const world = document.querySelector(
'.genarrative-image-canvas__world',
) as HTMLElement;
- expect(world.style.transform).toBe('translate(0px, 0px) scale(0.5)');
+ expect(world.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }),
+ );
fireEvent.wheel(viewport, {
deltaX: 4,
@@ -2324,7 +2331,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
clientY: 80,
});
await waitFor(() =>
- expect(world.style.transform).toBe('translate(-4px, -10px) scale(0.5)'),
+ expect(world.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: -4, y: -10, scale: 0.5 }),
+ ),
);
fireEvent.wheel(viewport, {
deltaX: 0,
@@ -2334,7 +2343,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
shiftKey: true,
});
await waitFor(() =>
- expect(world.style.transform).toBe('translate(-14px, -10px) scale(0.5)'),
+ expect(world.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: -14, y: -10, scale: 0.5 }),
+ ),
);
fireEvent.wheel(viewport, {
deltaX: 0,
@@ -2344,7 +2355,13 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
ctrlKey: true,
});
await waitFor(() =>
- expect(world.style.transform).not.toContain('scale(0.5)'),
+ expect(world.style.transform).toBe(
+ canvasViewportToWorldTransform({
+ x: 100 - 228 * 0.55,
+ y: 80 - 180 * 0.55,
+ scale: 0.55,
+ }),
+ ),
);
});
diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx
index 908086f1d..50a89c1e2 100644
--- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx
+++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx
@@ -87,13 +87,17 @@ function createCanvas(
};
}
-function renderedScale(container: HTMLElement) {
- const transform = (
- container.querySelector('.genarrative-image-canvas__world') as HTMLElement
- ).style.transform;
- const scale = /scale\(([^)]+)\)/.exec(transform)?.[1];
- if (!scale) throw new Error(`无法从 ${transform} 读取缩放值`);
- return Number(scale);
+function logicalViewportScale(container: HTMLElement) {
+ const world = container.querySelector(
+ '.genarrative-image-canvas__world',
+ ) as HTMLElement;
+ const inverseScale = Number(
+ world.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'),
+ );
+ if (!Number.isFinite(inverseScale) || inverseScale <= 0) {
+ throw new Error(`无法从 world 读取逻辑缩放值`);
+ }
+ return 1 / inverseScale;
}
beforeEach(() => {
@@ -110,7 +114,7 @@ describe('PreviewWorkspace quick zoom', () => {
it('uses the toolbar zoom step while the preview is hovered', () => {
const rendered = render();
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
- const before = renderedScale(rendered.container);
+ const before = logicalViewportScale(rendered.container);
fireEvent.pointerEnter(preview);
const wasNotCancelled = fireEvent.keyDown(window, {
@@ -121,14 +125,14 @@ describe('PreviewWorkspace quick zoom', () => {
});
expect(wasNotCancelled).toBe(false);
- expect(renderedScale(rendered.container)).toBeCloseTo(before * 1.16);
+ expect(logicalViewportScale(rendered.container)).toBeCloseTo(before * 1.16);
});
it('requires hover or focus and ignores interactive targets', () => {
const rendered = render();
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
const zoomButton = screen.getByRole('button', { name: '放大画布' });
- const initial = renderedScale(rendered.container);
+ const initial = logicalViewportScale(rendered.container);
expect(
fireEvent.keyDown(window, {
@@ -137,7 +141,7 @@ describe('PreviewWorkspace quick zoom', () => {
cancelable: true,
}),
).toBe(true);
- expect(renderedScale(rendered.container)).toBe(initial);
+ expect(logicalViewportScale(rendered.container)).toBe(initial);
fireEvent.pointerEnter(preview);
expect(
@@ -147,7 +151,7 @@ describe('PreviewWorkspace quick zoom', () => {
cancelable: true,
}),
).toBe(true);
- expect(renderedScale(rendered.container)).toBe(initial);
+ expect(logicalViewportScale(rendered.container)).toBe(initial);
fireEvent.pointerLeave(preview);
fireEvent.focus(preview);
@@ -157,13 +161,15 @@ describe('PreviewWorkspace quick zoom', () => {
ctrlKey: true,
cancelable: true,
});
- expect(renderedScale(rendered.container)).toBeCloseTo(initial * 0.86);
+ expect(logicalViewportScale(rendered.container)).toBeCloseTo(
+ initial * 0.86,
+ );
});
it('keeps actual-size and fit shortcuts within the preview scope', () => {
const rendered = render();
const preview = screen.getByRole('region', { name: 'UI 预览画布' });
- const fitted = renderedScale(rendered.container);
+ const fitted = logicalViewportScale(rendered.container);
fireEvent.focus(preview);
fireEvent.keyDown(window, {
@@ -171,14 +177,14 @@ describe('PreviewWorkspace quick zoom', () => {
ctrlKey: true,
cancelable: true,
});
- expect(renderedScale(rendered.container)).toBe(1);
+ expect(logicalViewportScale(rendered.container)).toBe(1);
fireEvent.keyDown(window, {
key: '0',
ctrlKey: true,
cancelable: true,
});
- expect(renderedScale(rendered.container)).toBe(fitted);
+ expect(logicalViewportScale(rendered.container)).toBe(fitted);
});
it('leaves browser zoom untouched when the preview has no content', () => {
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 60aa9f603..d1c0b090c 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -8052,6 +8052,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 新建/恢复 ephemeral Codex thread 时,replay 使用 `contextWindowTokens`、`autoCompactTokenLimit`、本次 `maxOutputTokens` 与 4096 安全余量计算预算,从最新记录向前选择连续完整的 `user` / `assistant` / `tool` 行;超预算旧前缀被省略,单条记录不截断,当前 user request 始终保留。
- 发生省略时在 prompt 开头加入普通 `system: Earlier conversation history was omitted due to context budget.` 提示;当前请求本身超过硬上下文预算则直接失败。该策略是 Direct 专用滑动窗口,不复用 Runtime Agent 的摘要、tail 或 session compaction 生命周期。
+## 2026-09-05 共享 CanvasWorld 统一超采样与资源页迁移
+
+- 背景:资源总览页曾在业务层重复实现 world 的渲染与尺寸逻辑,需与网站 / Tauri 共用的 `@genarrative/image-canvas-react` 统一。
+- 决策:`CanvasWorld` 的公开契约为逻辑 `viewport`,最终视觉比例由 `viewport.scale` 定义。渲染细节由共享包封装,调用方不得自行换算。所有调用方继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不引入 `worldWidth/worldHeight` 等矩形 API。资源页 `navigationBounds` 只保留布局、fit、依赖线 geometry 和 data 属性用途,不再驱动 world DOM 尺寸。非缩放描边只应用于资源依赖关系线等几何 overlay,静态卡片图标不继承统一 SVG 描边规则;位图维持浏览器默认插值,不全局启用 `pixelated`。
+- 影响范围:资源总览迁移为直接渲染共享 `CanvasWorld`,卡片控件通过共享 inverse-scale CSS 变量保持屏幕级尺寸;Asset Canvas、UI Editor 和依赖线继续消费相同 viewport 坐标与事件换算,不改变业务状态或持久化合同。
+- 验证方式:调用方测试以预期逻辑 `{ x, y, scale }` 调用公开 helper `canvasViewportToWorldTransform`,再与实际 world 的 `style.transform` 比较;不手写渲染换算,不以缩放按钮文案替代 viewport 断言。运行共享包与 AGC 定向类型 / 测试、资源页集成测试、`npm run check:encoding` 和 `git diff --check`,覆盖 SVG 描边、拖拽坐标、关系线端点和默认 world 尺寸。
+
## 2026-09-07 DirectProject 用户消息由 AGC 预写并过滤 Codex 回显
- `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 1a7733a36..c864f70fb 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -5023,3 +5023,11 @@
- 外层 `start-tauri-dev.mjs` 应在启动 Tauri 前完成配套开发服务准备,并统一收束自有服务进程树;不要让冷编译和数据库发布挤占 Tauri 的前端就绪等待。自动发布必须保留数据库,不能靠清库解决启动问题。
- CLI 与 standalone 可能是两个独立软链接。必须同时核对 `spacetime --version` 和 `spacetimedb-standalone --version`,不能把 CLI 的版本记录当作宿主版本证明;PATH 中存在宿主时启动器检查两者一致。更换宿主前停机备份数据,按原目录启动,不通过清库处理版本错配。
- Router 配置缺失不应只在首次请求时报错。API/All 启动必须先校验官方地址、固定模型、provisioning secret、管理员 Token 和凭据加密密钥;否则服务看似 healthy,但登录后的 provisioning/模型调用才延迟失败。
+
+## 共享画布框选需要识别 world 的真实后代命中
+
+- 现象:共享 `CanvasWorld` 在 world 外层增加 `.genarrative-image-canvas__world-content` 后,点击或拖拽空白画布时事件 `target` 是内层 div;框选逻辑若只检查外层 world 本身,就会只清除焦点而不创建选框。
+- 原因:viewport 上的 pointer 事件通过冒泡接收,`event.currentTarget` 是 viewport,空白区域的 `event.target` 可能是 world 的任意后代,不保证命中外层元素。
+- 处理:框选命中判断使用 `Element.closest('.genarrative-image-canvas__world')`,并保留 viewport 自身命中路径;回归测试通过真实 `CanvasWorld` DOM 的 `pointerdown` 冒泡覆盖内层 world content。
+- 验证:`src/components/image-editor/useImageCanvasStageInteractions.test.tsx` 覆盖内层 world content 命中,定向交互测试通过。
+- 关联:`packages/image-canvas-react/src/useImageCanvasStageInteractions.ts`、`packages/image-canvas-react/src/CanvasWorld.tsx`。
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index 62a305607..5cf960dcf 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -582,6 +582,7 @@ game-project/
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源卡拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供工具栏、工具侧边栏或可拖动标题栏。2026-08-10 起聚焦态以资源元数据、Rust 权威深度、同类型上下游 / 任务流和版本信息为首屏;美术图片与视频只保留卡内本体,不在详情重复放大。安全文档正文与按意图读取的音频控制位于元数据之后;美术编辑、音频编辑 / 替换、版本替换或运行模块仍不在本阶段。
+- `@genarrative/image-canvas-react` 的 `CanvasWorld` 接收逻辑 `viewport`,`viewport.scale` 表示最终视觉比例;渲染细节由共享包封装。调用方测试将预期逻辑 `{ x, y, scale }` 传入公开 helper `canvasViewportToWorldTransform`,再比较实际 world 的 `style.transform`;不自行换算渲染值,不以缩放按钮文案替代 viewport 断言。world 继续使用共享默认正方形 `CANVAS_WORLD_SIZE = 12000`,不新增矩形尺寸 API;资源页的 `navigationBounds` 只用于布局、fit、关系线 geometry 和数据属性,不能再设置 world DOM 宽高。非缩放描边只应用于资源依赖关系线等几何 overlay,Lucide 等静态卡片图标保持自身正常缩放,避免被统一 SVG 规则压窄;普通位图不强制 `pixelated` 插值。依赖线、卡片拖拽、平移和滚轮锚点继续使用同一逻辑 viewport 坐标。
#### 资源管理串行改造:本体卡、分区缩放与依赖聚类
diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
index 198d7788a..497fb7e85 100644
--- a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
+++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
@@ -19,6 +19,7 @@
- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。
- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。
- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。
+- 快捷键和百分比使用逻辑 `viewport.scale` 作为缩放真相;共享 `CanvasWorld` 的渲染 transform 可能包含超采样换算,验证时不得直接把 CSS transform 值当作用户可见比例。
- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。
## 验收
diff --git a/packages/image-canvas-react/src/CanvasWorld.tsx b/packages/image-canvas-react/src/CanvasWorld.tsx
index 3437360ab..bc089d1ce 100644
--- a/packages/image-canvas-react/src/CanvasWorld.tsx
+++ b/packages/image-canvas-react/src/CanvasWorld.tsx
@@ -2,7 +2,13 @@ import {
CANVAS_WORLD_SIZE,
type CanvasViewport,
} from '@genarrative/image-canvas-core';
-import type { HTMLAttributes, ReactNode } from 'react';
+import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
+
+import {
+ CANVAS_RENDER_SUPERSAMPLE,
+ canvasViewportToWorldTransform,
+ getSafeViewportScale,
+} from './canvasWorldRendering';
export function CanvasWorld({
children,
@@ -16,18 +22,33 @@ export function CanvasWorld({
viewport: CanvasViewport;
worldSize?: number;
}) {
+ const viewportScale = getSafeViewportScale(viewport);
+
return (
- {children}
+
+ {children}
+
);
}
diff --git a/packages/image-canvas-react/src/canvasWorldRendering.ts b/packages/image-canvas-react/src/canvasWorldRendering.ts
new file mode 100644
index 000000000..c298c716c
--- /dev/null
+++ b/packages/image-canvas-react/src/canvasWorldRendering.ts
@@ -0,0 +1,16 @@
+import type { CanvasViewport } from '@genarrative/image-canvas-core';
+
+export const CANVAS_RENDER_SUPERSAMPLE = 2;
+
+/** 将 viewport scale 规范化为可用于渲染和反向缩放的正有限值。 */
+export function getSafeViewportScale(viewport: CanvasViewport) {
+ return Number.isFinite(viewport.scale) && viewport.scale > 0
+ ? viewport.scale
+ : 1;
+}
+
+/** 将逻辑 viewport 转换为 world 的渲染 transform。 */
+export function canvasViewportToWorldTransform(viewport: CanvasViewport) {
+ const scale = getSafeViewportScale(viewport);
+ return `translate(${viewport.x}px, ${viewport.y}px) scale(${scale / CANVAS_RENDER_SUPERSAMPLE})`;
+}
diff --git a/packages/image-canvas-react/src/index.ts b/packages/image-canvas-react/src/index.ts
index bb4023c2c..a1ea0ab9d 100644
--- a/packages/image-canvas-react/src/index.ts
+++ b/packages/image-canvas-react/src/index.ts
@@ -2,6 +2,10 @@ export * from './CanvasChrome';
export * from './CanvasPortal';
export * from './CanvasViewport';
export * from './CanvasWorld';
+export {
+ canvasViewportToWorldTransform,
+ getSafeViewportScale,
+} from './canvasWorldRendering';
export * from './LayerRenderer';
export * from './Minimap';
export * from './SelectionOverlay';
diff --git a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx
index 81304b756..56ffd5145 100644
--- a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx
+++ b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx
@@ -1,10 +1,15 @@
/* @vitest-environment jsdom */
import type { CanvasLayer } from '@genarrative/image-canvas-core';
-import { act, renderHook } from '@testing-library/react';
+import { act, render, renderHook, screen } from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
+import { CanvasWorld } from './CanvasWorld';
+import {
+ CANVAS_RENDER_SUPERSAMPLE,
+ canvasViewportToWorldTransform,
+} from './canvasWorldRendering';
import { useImageCanvasViewportControls } from './useImageCanvasViewportControls';
function layer(id: string, x: number): CanvasLayer {
@@ -52,6 +57,26 @@ afterEach(() => {
});
describe('shared canvas React lifecycle', () => {
+ it('renders a supersampled world while preserving the public viewport scale', () => {
+ render(
+
+ content
+ ,
+ );
+
+ const world = screen.getByText('content').parentElement?.parentElement;
+ const content = screen.getByText('content').parentElement;
+ expect(world?.style.width).toBe('12000px');
+ expect(world?.style.height).toBe('12000px');
+ expect(world?.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: 12, y: 24, scale: 2 }),
+ );
+ expect(
+ world?.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'),
+ ).toBe('0.5');
+ expect(content?.style.zoom).toBe(String(CANVAS_RENDER_SUPERSAMPLE));
+ });
+
it('removes every native listener across repeated mounts', () => {
const viewport = createViewport();
const addListener = vi.spyOn(viewport, 'addEventListener');
diff --git a/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts b/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts
index d5b3411cc..ce106893e 100644
--- a/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts
+++ b/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts
@@ -342,11 +342,12 @@ export function useImageCanvasStageInteractions({
event.preventDefault();
return;
}
- const target = event.target as HTMLElement;
+ const target = event.target;
if (
effectiveTool === 'select' &&
(event.target === event.currentTarget ||
- target.classList.contains('genarrative-image-canvas__world'))
+ (target instanceof Element &&
+ target.closest('.genarrative-image-canvas__world') !== null))
) {
event.preventDefault();
const rect = canvasViewportRef.current?.getBoundingClientRect();
diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx
index 8012511cb..32d777569 100644
--- a/src/components/image-editor/ImageCanvasEditorView.test.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
+import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react';
import {
act,
fireEvent,
@@ -2412,11 +2413,13 @@ describe('ImageCanvasEditorView', () => {
act(() => rafCallbacks.shift()?.(210));
expect(world.style.transform).not.toBe(initialTransform);
expect(world.style.transform).not.toBe(
- 'translate(-840px, -246.5px) scale(1)',
+ canvasViewportToWorldTransform({ x: -840, y: -246.5, scale: 1 }),
);
act(() => rafCallbacks.shift()?.(420));
- expect(world.style.transform).toBe('translate(-840px, -246.5px) scale(1)');
+ expect(world.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: -840, y: -246.5, scale: 1 }),
+ );
expect(screen.getByLabelText('发送给画布 Agent')).toBeTruthy();
expect(
puzzleLayer.classList.contains('image-canvas-editor__layer--selected'),
@@ -2532,7 +2535,9 @@ describe('ImageCanvasEditorView', () => {
clientX: 400,
clientY: 280,
});
- expect(world.style.transform).toBe('translate(-80px, -120px) scale(0.5)');
+ expect(world.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: -80, y: -120, scale: 0.5 }),
+ );
expect(
screen.getByRole('button', { name: '当前缩放比例 100%' }),
).toBeTruthy();
@@ -2567,7 +2572,7 @@ describe('ImageCanvasEditorView', () => {
).toBeTruthy();
expect(
(screen.getByTestId('image-canvas-world') as HTMLElement).style.transform,
- ).toBe('translate(0px, 0px) scale(0.5)');
+ ).toBe(canvasViewportToWorldTransform({ x: 0, y: 0, scale: 0.5 }));
});
it('selects multiple canvas layers with shift click', async () => {
diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx
index cc98a733e..ebc0b3b75 100644
--- a/src/components/image-editor/ImageCanvasWorldView.test.tsx
+++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
+import { canvasViewportToWorldTransform } from '@genarrative/image-canvas-react';
import {
act,
fireEvent,
@@ -416,13 +417,15 @@ describe('ImageCanvasWorldView', () => {
).toBe('344px');
expect(screen.getByText('生成中')).toBeTruthy();
- const world = document.querySelector('.image-canvas-editor__world');
+ const world = document.querySelector(
+ '.image-canvas-editor__world',
+ );
const marquee = world?.querySelector(
'.image-canvas-editor__canvas-marquee',
);
- expect(world?.getAttribute('style')).toContain(
- 'transform: translate(10px, 20px) scale(1.5)',
+ expect(world?.style.transform).toBe(
+ canvasViewportToWorldTransform({ x: 10, y: 20, scale: 1.5 }),
);
expect((marquee as HTMLElement | null)?.style.width).toBe('100px');
expect((marquee as HTMLElement | null)?.style.height).toBe('120px');
diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
index d50e63623..fc510d559 100644
--- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
+++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
+import { CanvasWorld } from '@genarrative/image-canvas-react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
type PointerEvent as ReactPointerEvent,
@@ -150,7 +151,6 @@ function StageInteractionsHarness({
) => void;
}) {
const canvasViewportRef = useRef(null);
- const worldRef = useRef(null);
const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select');
const [layers, setLayers] = useState(() => [
createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }),
@@ -288,11 +288,9 @@ function StageInteractionsHarness({
onPointerMove={interaction.handlePointerMove}
onPointerUp={interaction.finishDrag}
>
-
+
+
+
{layers.map((layer) => (