Compare commits

...

12 Commits

Author SHA1 Message Date
k88936 3acfaa3795 Merge remote-tracking branch 'origin/master' into fix/blur-font
Project CI / Repository checks (pull_request) Successful in 5m29s
Project CI / Frontend tests (pull_request) Successful in 5m34s
Project CI / Backend tests (pull_request) Failing after 6m26s
Project CI / Native shell tests (pull_request) Failing after 5m8s
# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
2026-09-09 10:17:54 +08:00
k88936 9895666257 Merge branch 'master' into fix/blur-font
Project CI / Repository checks (pull_request) Successful in 2m22s
Project CI / Native shell tests (pull_request) Failing after 14m6s
Project CI / Frontend tests (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Successful in 6m19s
2026-09-08 18:53:06 +08:00
k88936 e9ca9a456b 修正预览画布快捷缩放测试
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Native shell tests (pull_request) Successful in 17m4s
预览缩放测试改按逻辑 viewport.scale 断言

补充超采样渲染与用户缩放比例的验证约定
2026-09-08 17:43:09 +08:00
k88936 89fc64a54d Merge branch 'master' into fix/blur-font
Project CI / Repository checks (pull_request) Successful in 2m8s
Project CI / Frontend tests (pull_request) Failing after 5m33s
Project CI / Backend tests (pull_request) Successful in 9m23s
Project CI / Native shell tests (pull_request) Failing after 5m30s
2026-09-08 11:59:09 +08:00
k88936 cd75b9a1b9 修复资源画布播放控件缩放越界
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m12s
Project CI / Native shell tests (pull_request) Successful in 19m7s
将资源卡音视频播放控件的缩放原点固定到右下角。

增加播放控件缩放原点的样式回归断言。
2026-09-08 11:06:03 +08:00
k88936 7bacfebcfd Merge branch 'master' into fix/blur-font
Project CI / Repository checks (pull_request) Successful in 2m55s
Project CI / Frontend tests (pull_request) Successful in 3m22s
Project CI / Backend tests (pull_request) Successful in 6m15s
Project CI / Native shell tests (pull_request) Successful in 19m55s
2026-09-07 15:44:33 +08:00
k88936 3520cd3915 Merge remote-tracking branch 'origin/master' into fix/blur-font
Project CI / Repository checks (pull_request) Successful in 5m1s
Project CI / Frontend tests (pull_request) Successful in 5m54s
Project CI / Backend tests (pull_request) Successful in 7m19s
Project CI / Native shell tests (pull_request) Successful in 19m58s
# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-07 10:46:40 +08:00
k88936 398ee64388 修复画布内层空白区域框选
框选命中判断支持 world 的真实后代元素

回归测试覆盖共享 CanvasWorld 内层 pointerdown 冒泡

同步记录共享画布框选命中排障经验
2026-09-07 10:38:36 +08:00
k88936 be0ae6d716 修复画布旧测试的逻辑缩放断言
Project CI / Repository checks (pull_request) Successful in 3m56s
Project CI / Frontend tests (pull_request) Successful in 4m31s
Project CI / Native shell tests (pull_request) Successful in 21m16s
Project CI / Backend tests (pull_request) Successful in 49m46s
抽取并导出逻辑 viewport 到渲染 transform 的共享转换函数

更新网站与 Tauri 画布测试,通过逻辑坐标和缩放值验证实际样式

同步画布公开契约和测试边界文档
2026-09-05 15:18:44 +08:00
k88936 f24949f732 共享 CanvasWorld 统一超采样
Project CI / Repository checks (pull_request) Successful in 2m45s
Project CI / Frontend tests (pull_request) Failing after 2m52s
Project CI / Native shell tests (pull_request) Failing after 5m49s
Project CI / Backend tests (pull_request) Successful in 7m24s
默认以 2 倍内容分辨率渲染并保持 viewport.scale 的最终视觉比例

用反向 stroke-width 抵消资源卡 SVG 图标的超采样线宽

补充共享 CanvasWorld 超采样契约测试
2026-09-05 12:45:00 +08:00
k88936 a3dfd37576 补充共享画布超采样决策文档
记录 CanvasWorld 默认 2 倍超采样与 viewport.scale 视觉比例契约

记录资源页继续使用 12000 正方形 world 且不新增矩形尺寸 API

记录 SVG 描边与资源页迁移验收边界
2026-09-05 12:43:22 +08:00
k88936 fb8720e3af 资源页复用共享 CanvasWorld
移除项目开发页自有 world 变换与动态矩形尺寸

通过共享 CanvasWorld 提供资源卡控件反向缩放变量
2026-09-05 12:42:42 +08:00
17 changed files with 196 additions and 57 deletions
+13
View File
@@ -5973,6 +5973,19 @@ 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-placeholder > svg *,
.game-resource-card-version-visual > svg *,
.game-resource-card-audio-visual > svg * {
stroke-width: calc(2px * var(--genarrative-image-canvas-inverse-scale, 1));
}
.game-resource-card-open {
@@ -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({
>
<RotateCcw size={15} aria-hidden="true" />
</button>
<div
<CanvasWorld
className="game-resource-page-world"
viewport={activeResourceCanvasViewport}
data-resource-boundary={`${resourceCanvasNavigationBounds.x},${resourceCanvasNavigationBounds.y},${resourceCanvasNavigationBounds.width},${resourceCanvasNavigationBounds.height}`}
data-resource-fit-boundary={`${resourceCanvasFitBounds.x},${resourceCanvasFitBounds.y},${resourceCanvasFitBounds.width},${resourceCanvasFitBounds.height}`}
data-resource-viewport={`${activeResourceCanvasViewport.x},${activeResourceCanvasViewport.y},${activeResourceCanvasViewport.scale}`}
style={{
width: `${resourceCanvasNavigationBounds.width}px`,
height: `${resourceCanvasNavigationBounds.height}px`,
transform: `translate(${activeResourceCanvasViewport.x}px, ${activeResourceCanvasViewport.y}px) scale(${activeResourceCanvasViewport.scale})`,
transformOrigin: '0 0',
}}
>
{sortMode === 'dependency' ? (
<ResourceDependencyOverlay
@@ -5016,7 +5012,7 @@ export default function ProjectDevelopmentView({
/>
);
})}
</div>
</CanvasWorld>
{(
visibleResourcesByCategory.get(
activePageCategory,
@@ -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,
);
@@ -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<HTMLElement>('.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<HTMLElement>('.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,
}),
),
);
});
@@ -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(<PreviewWorkspace canvas={createCanvas()} />);
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(<PreviewWorkspace canvas={createCanvas()} />);
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(<PreviewWorkspace canvas={createCanvas()} />);
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', () => {
@@ -7961,6 +7961,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。
@@ -4991,3 +4991,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`
@@ -568,6 +568,7 @@ game-project/
- 中间主视窗提供 `资源管理 / 运行` 切换。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源管理只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-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 坐标。
#### 资源管理串行改造:本体卡、分区缩放与依赖聚类
@@ -19,6 +19,7 @@
- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。
- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()``stopPropagation()`,防止浏览器页面同时缩放。
- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。
- 快捷键和百分比使用逻辑 `viewport.scale` 作为缩放真相;共享 `CanvasWorld` 的渲染 transform 可能包含超采样换算,验证时不得直接把 CSS transform 值当作用户可见比例。
- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。
## 验收
@@ -2,7 +2,12 @@ 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,
} from './canvasWorldRendering';
export function CanvasWorld({
children,
@@ -16,18 +21,34 @@ export function CanvasWorld({
viewport: CanvasViewport;
worldSize?: number;
}) {
const viewportScale =
Number.isFinite(viewport.scale) && viewport.scale > 0 ? viewport.scale : 1;
return (
<div
{...props}
className={`genarrative-image-canvas__world ${className}`.trim()}
style={{
...style,
width: worldSize,
height: worldSize,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
}}
style={
{
...style,
width: worldSize,
height: worldSize,
transform: canvasViewportToWorldTransform(viewport),
'--genarrative-image-canvas-inverse-scale': String(1 / viewportScale),
} as CSSProperties
}
>
{children}
<div
className="genarrative-image-canvas__world-content"
style={{
position: 'relative',
width: worldSize,
height: worldSize,
zoom: CANVAS_RENDER_SUPERSAMPLE,
}}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import type { CanvasViewport } from '@genarrative/image-canvas-core';
export const CANVAS_RENDER_SUPERSAMPLE = 2;
/** 将逻辑 viewport 转换为 world 的渲染 transform。 */
export function canvasViewportToWorldTransform(viewport: CanvasViewport) {
const scale =
Number.isFinite(viewport.scale) && viewport.scale > 0 ? viewport.scale : 1;
return `translate(${viewport.x}px, ${viewport.y}px) scale(${scale / CANVAS_RENDER_SUPERSAMPLE})`;
}
+1
View File
@@ -2,6 +2,7 @@ export * from './CanvasChrome';
export * from './CanvasPortal';
export * from './CanvasViewport';
export * from './CanvasWorld';
export { canvasViewportToWorldTransform } from './canvasWorldRendering';
export * from './LayerRenderer';
export * from './Minimap';
export * from './SelectionOverlay';
@@ -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(
<CanvasWorld viewport={{ x: 12, y: 24, scale: 2 }}>
<span>content</span>
</CanvasWorld>,
);
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');
@@ -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();
@@ -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 () => {
@@ -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<HTMLElement>(
'.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');
@@ -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<HTMLDivElement | null>(null);
const worldRef = useRef<HTMLDivElement | null>(null);
const [activeTool, setActiveTool] = useState<'select' | 'hand'>('select');
const [layers, setLayers] = useState<CanvasLayer[]>(() => [
createLayer({ id: 'first', x: 40, y: 40, zIndex: 1 }),
@@ -288,11 +288,9 @@ function StageInteractionsHarness({
onPointerMove={interaction.handlePointerMove}
onPointerUp={interaction.finishDrag}
>
<div
ref={worldRef}
className="genarrative-image-canvas__world"
data-testid="world"
/>
<CanvasWorld viewport={viewport} data-testid="world">
<span aria-hidden="true" />
</CanvasWorld>
{layers.map((layer) => (
<button
key={layer.id}
@@ -565,7 +563,8 @@ function StageInteractionsHarness({
pointerId: 3,
clientX: 20,
clientY: 20,
target: worldRef.current ?? undefined,
target:
document.querySelector('[data-testid="world"]') ?? undefined,
}),
);
}}
@@ -1240,6 +1239,28 @@ describe('useImageCanvasStageInteractions', () => {
);
});
it('starts a marquee when the real world content receives the pointer down', () => {
render(<StageInteractionsHarness />);
const worldContent = screen
.getByTestId('world')
.querySelector('.genarrative-image-canvas__world-content');
expect(worldContent).not.toBeNull();
const pointerDown = new Event('pointerdown', { bubbles: true });
Object.defineProperties(pointerDown, {
pointerId: { configurable: true, value: 30 },
clientX: { configurable: true, value: 20 },
clientY: { configurable: true, value: 20 },
button: { configurable: true, value: 0 },
buttons: { configurable: true, value: 1 },
});
act(() => {
worldContent?.dispatchEvent(pointerDown);
});
expect(screen.getByTestId('marquee').textContent).toBe('20,20');
});
it('shows snap guides when generation frames drag near layer alignment', () => {
render(<StageInteractionsHarness />);