From f24949f7327f750292b60b447c5be7fac3c6e33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 5 Sep 2026 12:45:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B1=E4=BA=AB=20CanvasWorld=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=B6=85=E9=87=87=E6=A0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认以 2 倍内容分辨率渲染并保持 viewport.scale 的最终视觉比例 用反向 stroke-width 抵消资源卡 SVG 图标的超采样线宽 补充共享 CanvasWorld 超采样契约测试 --- apps/ai-game-creator-shell/src/styles.css | 11 ++++++---- .../image-canvas-react/src/CanvasWorld.tsx | 16 ++++++++++++-- .../src/sharedCanvasReact.test.tsx | 21 ++++++++++++++++++- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b1748b822..3914a60db 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5904,14 +5904,17 @@ iframe.preview-frame { transform-origin: top right; } -.game-resource-card-media-control, -.game-resource-card-placeholder > svg, -.game-resource-card-version-visual > svg, -.game-resource-card-audio-visual > svg { +.game-resource-card-media-control { transform: scale(var(--genarrative-image-canvas-inverse-scale, 1)); transform-origin: center; } +.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 { position: absolute; z-index: 1; diff --git a/packages/image-canvas-react/src/CanvasWorld.tsx b/packages/image-canvas-react/src/CanvasWorld.tsx index 4dd6bd889..d5ab20e97 100644 --- a/packages/image-canvas-react/src/CanvasWorld.tsx +++ b/packages/image-canvas-react/src/CanvasWorld.tsx @@ -4,6 +4,8 @@ import { } from '@genarrative/image-canvas-core'; import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; +const CANVAS_RENDER_SUPERSAMPLE = 2; + export function CanvasWorld({ children, className = '', @@ -28,12 +30,22 @@ export function CanvasWorld({ ...style, width: worldSize, height: worldSize, - transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewportScale})`, + transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewportScale / CANVAS_RENDER_SUPERSAMPLE})`, '--genarrative-image-canvas-inverse-scale': String(1 / viewportScale), } as CSSProperties } > - {children} +
+ {children} +
); } diff --git a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx index 81304b756..1be34a166 100644 --- a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx +++ b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx @@ -1,10 +1,11 @@ /* @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 { useImageCanvasViewportControls } from './useImageCanvasViewportControls'; function layer(id: string, x: number): CanvasLayer { @@ -52,6 +53,24 @@ 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('translate(12px, 24px) scale(1)'); + expect( + world?.style.getPropertyValue('--genarrative-image-canvas-inverse-scale'), + ).toBe('0.5'); + expect(content?.style.zoom).toBe('2'); + }); + it('removes every native listener across repeated mounts', () => { const viewport = createViewport(); const addListener = vi.spyOn(viewport, 'addEventListener');