Compare commits
12 Commits
master
...
3acfaa3795
| Author | SHA1 | Date | |
|---|---|---|---|
| 3acfaa3795 | |||
| 9895666257 | |||
| e9ca9a456b | |||
| 89fc64a54d | |||
| cd75b9a1b9 | |||
| 7bacfebcfd | |||
| 3520cd3915 | |||
| 398ee64388 | |||
| be0ae6d716 | |||
| f24949f732 | |||
| a3dfd37576 | |||
| fb8720e3af |
@@ -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 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 坐标。
|
||||
|
||||
#### 资源管理串行改造:本体卡、分区缩放与依赖聚类
|
||||
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user