diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 10ac003cc..629c05a3a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -495,8 +495,9 @@ function ResourceReferenceEditor({ useState(null); const [pickerPosition, setPickerPosition] = useState<{ left: number; - bottom: number; + top: number; width: number; + maxHeight: number; } | null>(null); const rootRef = useRef(null); @@ -960,18 +961,46 @@ function ResourceReferenceEditor({ const updatePickerPosition = useCallback(() => { const rect = rootRef.current?.getBoundingClientRect(); if (!rect) return; + const viewportPadding = 12; + const gap = 8; const width = Math.min( Math.max(rect.width, 360), - Math.max(280, window.innerWidth - 24), + Math.max(280, window.innerWidth - viewportPadding * 2), ); const left = Math.min( - Math.max(12, rect.left), - Math.max(12, window.innerWidth - width - 12), + Math.max(viewportPadding, rect.left), + Math.max(viewportPadding, window.innerWidth - width - viewportPadding), ); + /** + * 上边界钳制:面板高度**必须**由输入框上下实际可用的空间决定。 + * + * 之前只把底边钉在输入框上方(`bottom: 视口高 - rect.top + 8`)却让高度自由取到 480px, + * 输入框靠上时(居中弹层里的提示词输入、窄屏)整块面板的顶边会被顶出视口——顶部那一排 + * 搜索与筛选既看不见也点不到。这里与 `@` 候选菜单同一套口径:先算上下各有多少空间, + * 空间不足就翻到下方,并把高度收在该侧可用空间内,再对 `top` 兜一次底。 + */ + const availableAbove = Math.max(0, rect.top - viewportPadding - gap); + const availableBelow = Math.max( + 0, + window.innerHeight - rect.bottom - viewportPadding - gap, + ); + const openAbove = + availableAbove >= 200 || availableAbove >= availableBelow; + const maxHeight = Math.max( + 160, + Math.min(480, openAbove ? availableAbove : availableBelow), + ); + const top = openAbove + ? Math.max(viewportPadding, rect.top - gap - maxHeight) + : Math.min( + Math.max(viewportPadding, window.innerHeight - viewportPadding - maxHeight), + rect.bottom + gap, + ); setPickerPosition({ left, - bottom: Math.max(12, window.innerHeight - rect.top + 8), + top, width, + maxHeight, }); }, []); @@ -1096,10 +1125,15 @@ function ResourceReferenceEditor({ aria-modal="false" aria-label="选择素材" style={{ + // 与 `@` 候选菜单同一坐标系(fixed + top + 高度钳制):底边锚点在 + // 输入框上方会被顶出视口,只有钉住顶边并收紧高度才能保证整块面板可见。 + position: 'fixed', + top: `${pickerPosition.top}px`, left: `${pickerPosition.left}px`, - bottom: `${pickerPosition.bottom}px`, right: 'auto', + bottom: 'auto', width: `${pickerPosition.width}px`, + maxHeight: `${pickerPosition.maxHeight}px`, }} >
diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index ba079eb25..ede80749c 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -1,5 +1,5 @@ import { Sparkles, X } from 'lucide-react'; -import { type FormEvent, useState } from 'react'; +import { type CSSProperties, type FormEvent, useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; @@ -10,6 +10,7 @@ import type { } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { ThemedModal } from '../../components/modal/ThemedModal'; +import './resourceCanvasGenerationPanel.css'; import { ResourceReferenceInput } from '../project-workspace/ResourceReferenceInput'; import type { ChatComposerDraft, @@ -26,6 +27,7 @@ import { resourceCanvasAssetGenerationReferenceAssets, resourceCanvasAssetGenerationReferenceError, resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationReferenceIssue, resourceCanvasAssetGenerationUserReferenceLimit, } from './resourceCanvasAssetGenerationReferenceModel'; import { ResourcePromptPolishSlot } from './ResourcePromptPolishSlot'; @@ -72,6 +74,19 @@ export type ResourceCanvasAssetGenerationPanelViewProps = { projectPath?: string; versions?: GameIterationVersion[]; activeVersionId?: string | null; + /** + * 呈现形态。 + * + * `modal`:既有居中弹层(`ThemedModal` + 焦点陷阱)。`floating`:挂在画布占位卡下沿的 + * **独立浮层**——工具点击先建占位,浮层只是它旁边的一块 UI。 + * + * 生成浮层必须走 `floating`:`ThemedModal` 的焦点陷阱会把 `@` 引用选择器(portal 到 body 的 + * `resource-reference-picker`)挡在陷阱之外,候选项点了不生效。浮层不是模态,因此不受这条限制; + * 「关闭浮层不等于取消后台任务」的语义也由浮层形态直接成立。 + */ + variant?: 'modal' | 'floating'; + /** 浮层形态的定位样式(贴着占位卡下沿,与快速编辑 / 信息浮层同一条锚点口径)。 */ + style?: CSSProperties | null; /** * 提交回调:**同步返回**,面板不等它的结果。 * @@ -109,6 +124,8 @@ export function ResourceCanvasAssetGenerationPanelView({ projectPath, versions, activeVersionId, + variant = 'modal', + style, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { @@ -147,6 +164,16 @@ export function ResourceCanvasAssetGenerationPanelView({ action, referenceCount: referenceAssetIds.length, }); + /* + 陈旧引用(素材被删 / 改了类型 / 没有本地文件)必须在提交前报出来:过滤掉再提交等于 + 把「带参考」变成「无参考」的付费生成,用户还以为参考生效了。 + */ + const referenceIssue = referenceEnabled + ? resourceCanvasAssetGenerationReferenceIssue({ + references, + assets: assets ?? [], + }) + : null; const promptTooLong = prompt.trim().length > promptMaxLength; const promptTooLongError = promptTooLong ? `生成提示词最多 ${promptMaxLength} 个字符,当前 ${prompt.trim().length} 个` @@ -155,8 +182,9 @@ export function ResourceCanvasAssetGenerationPanelView({ prompt.trim().length > 0 && assetName.trim().length > 0 && !referenceError && + !referenceIssue && !promptTooLong; - const shownError = error ?? promptTooLongError ?? referenceError; + const shownError = error ?? referenceIssue ?? promptTooLongError ?? referenceError; const applyDraft = (next: ChatComposerDraft) => { setPrompt(next.text); setReferences(next.references); @@ -171,6 +199,7 @@ export function ResourceCanvasAssetGenerationPanelView({ !normalizedPrompt || !normalizedAssetName || referenceError || + referenceIssue || promptTooLong ) { return; @@ -189,13 +218,8 @@ export function ResourceCanvasAssetGenerationPanelView({ onClose(); } - return ( - + const panelBody = ( + <>

{action.label}

@@ -327,6 +351,32 @@ export function ResourceCanvasAssetGenerationPanelView({
+ + ); + + if (variant === 'floating') { + return ( +
event.stopPropagation()} + > + {panelBody} +
+ ); + } + + return ( + + {panelBody} ); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx index ad3eaad36..f94415b08 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPanelView.tsx @@ -1,5 +1,5 @@ import { Sparkles, X } from 'lucide-react'; -import { type FormEvent, useRef, useState } from 'react'; +import { type CSSProperties, type FormEvent, useRef, useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; @@ -36,6 +36,15 @@ export type ResourceCanvasGenerationPanelViewProps = { */ kinds?: readonly ResourceCanvasGenerationKind[]; initialKind?: ResourceCanvasGenerationKind; + /** + * 呈现形态。 + * + * 面板底部栏目的音频入口(音效 / 背景音乐)与「生成素材」入口一样:工具点击先在当前栏目 + * 建占位卡,浮层挂在占位卡下沿。占位与浮层的归属由宿主按 `draftId` 维护,面板只负责这一份 + * 草稿与失败重试。 + */ + variant?: 'modal' | 'floating'; + style?: CSSProperties | null; onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise; onClose: () => void; }; @@ -66,6 +75,8 @@ function resourceGenerationErrorMessage(error: unknown) { export function ResourceCanvasGenerationPanelView({ kinds = RESOURCE_CANVAS_GENERATION_OPTIONS.map((option) => option.kind), initialKind, + variant = 'modal', + style, onSubmit, onClose, }: ResourceCanvasGenerationPanelViewProps) { @@ -125,13 +136,8 @@ export function ResourceCanvasGenerationPanelView({ } } - return ( - + const panelBody = ( + <>

{panelTitle}

@@ -222,6 +228,32 @@ export function ResourceCanvasGenerationPanelView({ )}
+ + ); + + if (variant === 'floating') { + return ( +
event.stopPropagation()} + > + {panelBody} +
+ ); + } + + return ( + + {panelBody} ); } diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx new file mode 100644 index 000000000..f10a95d36 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView.tsx @@ -0,0 +1,95 @@ +import { Sparkles, X } from 'lucide-react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; + +import './resourceCanvasGenerationPanel.css'; +import { + RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS, + type ResourceCanvasGenerationPlaceholder, +} from './resourceCanvasGenerationPlaceholderModel'; + +export type ResourceCanvasGenerationPlaceholderCardViewProps = { + placeholder: ResourceCanvasGenerationPlaceholder; + /** 生成浮层是否正挂在这张占位下面:决定卡片的高亮与浮层开合。 */ + active: boolean; + /** 拖动中:卡片只跟指针走,不参与任何过渡。 */ + dragging: boolean; + onPointerDown: (event: ReactPointerEvent) => void; + onPointerMove: (event: ReactPointerEvent) => void; + onPointerUp: (event: ReactPointerEvent) => void; + onPointerCancel: (event: ReactPointerEvent) => void; + /** 点击卡片(不是删除按钮):打开 / 收起挂在它下面的生成浮层。 */ + onTogglePanel: () => void; + /** 删除占位:只隐藏展示,不取消后台任务。 */ + onRemove: () => void; +}; + +/** + * 画布上的生成占位卡。 + * + * 它**不是**正式资源卡:没有 manifest 身份、没有预览、不参与资源投影与布局 sidecar, + * 只在宿主临时状态里活到「提交成功落卡」或「用户删掉它」为止。点击它开 / 收挂在它下沿的 + * 独立生成浮层,拖动改的是宿主内存里的坐标(结果卡最终落在同一条最新位置)。 + * + * 指针事件的接管只到本组件为止:`stopPropagation` 阻止画布的框选 / 平移当作空白处处理。 + */ +export function ResourceCanvasGenerationPlaceholderCardView({ + placeholder, + active, + dragging, + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + onTogglePanel, + onRemove, +}: ResourceCanvasGenerationPlaceholderCardViewProps) { + return ( +
{ + event.stopPropagation(); + onTogglePanel(); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + onTogglePanel(); + }} + > +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts index 0af767737..9fbac5e64 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts @@ -65,12 +65,29 @@ export function resourceCanvasAssetGenerationReferenceAssets( ): GameCreationAppAssetManifestEntry[] { return assets.filter( (asset) => - asset.mediaType.startsWith('image/') && + resourceCanvasAssetGenerationReferenceMediaTypeSupported(asset.mediaType) && asset.localPath.trim().length > 0 && !asset.localPath.startsWith('.agent/'), ); } +/** + * 参考图只收**原生真的能读**的栅格图片。 + * + * 原生侧按 `image::load_from_memory` 解码参考图,它不认识 SVG:把 `.svg` 放进候选,用户选中后 + * 提交必失败(而且是一次付费请求的失败)。所以这里显式排除 SVG,其余 `image/*` 一律放行—— + * 不做「只许 png/jpeg」这种凭空收窄,真实支持范围由原生解码器决定。 + */ +export function resourceCanvasAssetGenerationReferenceMediaTypeSupported( + mediaType: string, +): boolean { + const normalized = mediaType.trim().toLowerCase(); + if (!normalized.startsWith('image/')) { + return false; + } + return normalized !== 'image/svg+xml' && normalized !== 'image/svg'; +} + /** * 从草稿里的引用列表取出本次生成的参考资源 ID。 * @@ -119,3 +136,47 @@ export function resourceCanvasAssetGenerationReferenceError({ ? `已选 ${referenceCount} 张参考图;该入口会带上权威规范图,用户参考最多 ${limit} 张` : `已选 ${referenceCount} 张参考图;最多 ${limit} 张`; } + +/** + * 提交前的**陈旧引用**判据:草稿里的引用必须仍然是当前项目已登记、有本地文件的图片。 + * + * 这一步不能省、也不能用过滤糊过去:草稿可能是在素材被删掉 / 改了类型之后才提交的, + * 静默过滤会让用户以为「带了那张参考」实际却发了一次无参考的付费生成。所以这里给出明确原因、 + * 挡住提交,草稿与 `@显示名` 正文都原样保留,由用户自己决定移除还是重选。 + */ +export function resourceCanvasAssetGenerationReferenceIssue({ + references, + assets, +}: { + references: readonly ChatReference[]; + assets: readonly GameCreationAppAssetManifestEntry[]; +}): string | null { + for (const reference of references) { + if (reference.type !== 'resource') { + continue; + } + const resourceId = reference.resourceId.trim(); + if (!resourceId) { + return '参考图引用缺少资源身份,请重新选择后再提交'; + } + const asset = assets.find((item) => item.id === resourceId); + if (!asset) { + return `参考图「${reference.label}」已不在当前项目,请移除后再提交`; + } + if (!asset.mediaType.startsWith('image/')) { + return `参考图「${reference.label}」不是图片,不能作为生成参考`; + } + if ( + !resourceCanvasAssetGenerationReferenceMediaTypeSupported(asset.mediaType) + ) { + return `参考图「${reference.label}」是矢量图(${asset.mediaType}),暂不支持作为生成参考,请换栅格图片`; + } + if ( + !asset.localPath.trim() || + asset.localPath.startsWith('.agent/') + ) { + return `参考图「${reference.label}」没有本地文件,无法作为参考传递`; + } + } + return null; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index 1bb3b32c9..a6c4765f0 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -34,6 +34,13 @@ export type LocalProjectAssetGenerationTaskRecord = { export type ResourceCanvasAssetGenerationTask = { /** 本地任务 id,同时作为提交给后端的 taskId(重开项目后靠它对上账本记录)。 */ taskId: string; + /** + * 这张任务是从哪个生成占位提交的。 + * + * 成功落点要用**占位的最新位置**、失败重试也要回到同一张占位,所以这条归属必须跟着任务走; + * 账本里没有它(后端不认占位),恢复出来的历史任务按 `null` 读。 + */ + draftId: string | null; actionId: string; actionLabel: string; assetKind: string; @@ -151,6 +158,7 @@ export function resourceCanvasAssetGenerationTaskIsTerminal( /** 新提交的任务:先本地排队,派发之前不进后端账本。 */ export function createResourceCanvasAssetGenerationTask(input: { taskId: string; + draftId?: string | null; action: ResourceCanvasAssetToolAction; prompt: string; assetName: string; @@ -163,6 +171,7 @@ export function createResourceCanvasAssetGenerationTask(input: { }): ResourceCanvasAssetGenerationTask { return { taskId: input.taskId, + draftId: input.draftId ?? null, actionId: input.action.id, actionLabel: input.action.label, assetKind: input.action.assetKind, @@ -170,7 +179,15 @@ export function createResourceCanvasAssetGenerationTask(input: { prompt: input.prompt, aspectRatio: input.aspectRatio, imageSize: input.imageSize, - referenceAssetIds: [...(input.referenceAssetIds ?? [])], + // 参考图去重(保持用户选择顺序):同一张素材在一份草稿里被选两次仍只算一次参考, + // 底层工厂也按这条口径收口,不把重复项留给原生侧与远端。 + referenceAssetIds: [ + ...new Set( + (input.referenceAssetIds ?? []) + .map((assetId) => assetId.trim()) + .filter((assetId) => assetId.length > 0), + ), + ], outputPath: input.outputPath, projectId: input.projectId, dispatched: false, @@ -193,6 +210,7 @@ export function restoreResourceCanvasAssetGenerationTask( ): ResourceCanvasAssetGenerationTask { return { taskId: record.taskId, + draftId: null, actionId: `restored:${record.kind}`, actionLabel: resourceCanvasAssetGenerationKindLabel(record.kind) ?? record.assetName, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css new file mode 100644 index 000000000..f0b973778 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css @@ -0,0 +1,94 @@ +/* + * 生成占位卡与「卡下独立浮层」的局部样式。 + * + * 只服务栏目画布上的临时占位(宿主内存态)与挂在它下沿的生成浮层:两者都不是正式素材, + * 所以样式也刻意与资源卡区分开(虚线描边 + 生成图标),避免被误读成已经落地的素材。 + * 放在独立文件里而不是并进 `resourceCanvasChrome.css`:这条链路可以整体回滚, + * 也不与画布手势/卡片展示的改动互相冲突。 + */ + +.game-resource-generation-placeholder { + position: absolute; + display: grid; + place-items: center; + align-content: center; + gap: 4px; + padding: 8px; + border: 1px dashed #c9a493; + border-radius: 14px; + background: rgb(255 250 247 / 88%); + color: #8a6a5c; + text-align: center; + cursor: grab; + user-select: none; + touch-action: none; +} + +.game-resource-generation-placeholder.is-active { + border-color: #e2835a; + box-shadow: 0 10px 26px rgb(62 37 27 / 18%); + color: #6f4a3b; +} + +.game-resource-generation-placeholder.is-dragging { + cursor: grabbing; + box-shadow: 0 16px 32px rgb(62 37 27 / 24%); +} + +.game-resource-generation-placeholder > strong { + max-width: 100%; + overflow: hidden; + font-size: 12px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-resource-generation-placeholder > small { + font-size: 11px; + color: #a68a7d; +} + +.game-resource-generation-placeholder > button { + position: absolute; + top: 4px; + right: 4px; + display: grid; + place-items: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; +} + +.game-resource-generation-placeholder > button:hover { + background: rgb(62 37 27 / 10%); +} + +/* + * 独立浮层:定位由宿主按占位卡下沿算好(与快速编辑 / 信息浮层同一条锚点口径), + * 所以这里只负责面板外观与「不被画布手势当空白」的层级。 + */ +.resource-canvas-generation-floating-panel { + position: absolute; + z-index: 70; + transform: translateX(-50%); + max-height: min(560px, calc(100dvh - 120px)); + overflow: auto; + pointer-events: auto; +} + +.resource-canvas-asset-generation-prompt-input { + max-height: 180px; + overflow: auto; +} + +.resource-canvas-asset-generation-reference-hint { + margin: 0; + color: #9a7d70; + font-size: 11px; +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts new file mode 100644 index 000000000..c58445c80 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel.ts @@ -0,0 +1,262 @@ +import type { CanvasLayer } from '../../../../../packages/image-canvas-core/src/types'; +import type { CanvasViewport } from '../../../../../packages/image-canvas-core/src/types'; +import { + type CanvasOverlayStyle, + resolveQuickEditPanelStyle, +} from '../../../../../packages/image-canvas-core/src/overlays'; +import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + RESOURCE_CANVAS_CARD_HEIGHT, + RESOURCE_CANVAS_CARD_WIDTH, + RESOURCE_CANVAS_ROW_GAP, + type ResourceCanvasCardSize, +} from '../../view/project-development/resourceCanvasLayoutModel'; +import type { ResourceCanvasAssetToolAction } from './resourceCanvasBottomToolbarModel'; +import type { ResourceCanvasGenerationKind } from './resourceCanvasGenerationModel'; + +/** + * 挂在这张占位下的生成浮层是谁。 + * + * 工具点击时就把「哪块面板 + 哪份草稿」一起记在占位上,所以收起浮层后再点占位卡能精确回到 + * 同一块面板与同一份输入(而不是重新猜一次默认参数)。 + */ +export type ResourceCanvasGenerationPlaceholderPanel = + | { route: 'asset'; action: ResourceCanvasAssetToolAction } + | { + route: 'audio'; + kinds: readonly ResourceCanvasGenerationKind[]; + initialKind: ResourceCanvasGenerationKind; + }; + +/** + * 生成占位卡的状态。 + * + * - `draft`:工具刚点开、请求还没交给后端;关掉浮层等于放弃这次草稿。 + * - `submitted`:已提交,任务在后台跑;关掉浮层**不等于**取消,占位继续显示在途。 + * - `failed`:这次生成失败;占位留在画布上,点它可以用同一份输入与引用重试。 + */ +export type ResourceCanvasGenerationPlaceholderStatus = + | 'draft' + | 'submitted' + | 'failed'; + +/** + * 工具点击后先在当前栏目创建的临时占位卡。 + * + * 它是**宿主临时状态**,不是正式素材:不登记 manifest、不进 Agent 可引用资源集、不写布局 + * sidecar,重开项目也不恢复位置。归属键是 `projectId + draftId`:切项目清掉上一份会话里未提交 + * 的草稿与界面位置;提交后用 `taskId` 关联后台任务,成功结果落到它的最新位置。 + */ +export type ResourceCanvasGenerationPlaceholder = { + /** 本次草稿的独立身份;同一个占位的重试沿用同一个 draftId。 */ + draftId: string; + /** 归属项目:切项目时未提交草稿与界面位置一并作废。 */ + projectId: string; + /** 占位所在的栏目(工具点击时用户正在看的那个栏目)。 */ + category: ProjectResourceCanvasCategory; + actionId: string; + actionLabel: string; + assetName: string; + /** 点这张占位要重新挂上的浮层(工具点击那一刻的动作,含全部默认参数)。 */ + panel: ResourceCanvasGenerationPlaceholderPanel; + /** 画布局部坐标;与同栏目资源卡同一坐标系,拖动只改这里。 */ + x: number; + y: number; + width: number; + height: number; + /** 提交后绑定的生成任务 id;未提交为 null。 */ + taskId: string | null; + status: ResourceCanvasGenerationPlaceholderStatus; + /** 失败原因(仅失败态有);重试面板据此给出同一条原因。 */ + error: string | null; +}; + +/** 占位卡的默认尺寸:与既有资源卡默认格同口径,占位与结果卡不会一大一小。 */ +export function resourceCanvasGenerationPlaceholderSize(): ResourceCanvasCardSize { + return { + width: RESOURCE_CANVAS_CARD_WIDTH, + height: RESOURCE_CANVAS_CARD_HEIGHT, + }; +} + +type OccupiedPlaceholderRect = { + x: number; + y: number; + width: number; + height: number; +}; + +/** + * 新占位的落点:当前栏目已有内容(资源卡与同栏目其它占位)**下方**的第一个空位。 + * + * 不用「屏幕中心」这类落点:占位必须落在它能被拖动、也能被结果接管的栏目局部坐标里, + * 而栏目内容按行铺开,追加在最后一行之下既不会盖住已有卡片,也和自动补位的方向一致。 + * 空栏目直接落在原点。 + */ +export function placeResourceCanvasGenerationPlaceholder({ + occupied, +}: { + occupied: readonly OccupiedPlaceholderRect[]; +}): { x: number; y: number } { + const bottom = occupied.reduce( + (current, rect) => Math.max(current, rect.y + rect.height), + Number.NEGATIVE_INFINITY, + ); + if (!Number.isFinite(bottom)) { + return { x: 0, y: 0 }; + } + const left = occupied.reduce( + (current, rect) => Math.min(current, rect.x), + Number.POSITIVE_INFINITY, + ); + return { + x: Math.round(Number.isFinite(left) ? Math.max(0, left) : 0), + y: Math.round(bottom + RESOURCE_CANVAS_ROW_GAP), + }; +} + +/** 拖动落点:与布局模型同一套有限数与范围收口,占位不会被拖到坐标域之外。 */ +export function moveResourceCanvasGenerationPlaceholder( + placeholder: ResourceCanvasGenerationPlaceholder, + x: number, + y: number, +): ResourceCanvasGenerationPlaceholder { + const clamp = (value: number) => + Math.min( + GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(value)), + ); + return { + ...placeholder, + x: clamp(Number.isFinite(x) ? x : placeholder.x), + y: clamp(Number.isFinite(y) ? y : placeholder.y), + }; +} + +/** 提交:把占位与后台任务绑定,之后的终局都由 taskId 找回它。 */ +export function bindResourceCanvasGenerationPlaceholderTask( + placeholder: ResourceCanvasGenerationPlaceholder, + taskId: string, +): ResourceCanvasGenerationPlaceholder { + return { ...placeholder, taskId, status: 'submitted', error: null }; +} + +/** + * 任务失败:占位留在画布上等重试,状态与原因跟着后端记录走。 + * + * 失败**不**删占位:删掉就等于把用户这次输入与位置一起丢了,而重试恰恰要用同一份输入。 + */ +export function failResourceCanvasGenerationPlaceholder( + placeholder: ResourceCanvasGenerationPlaceholder, + error: string | null, +): ResourceCanvasGenerationPlaceholder { + return { ...placeholder, status: 'failed', error }; +} + +/** + * 删除占位。 + * + * 只把这一条从宿主临时状态里去掉,**不**取消后台任务、也不丢弃正式结果:已提交的任务继续在账本里 + * 跑完并把结果登记进项目(见里程碑「删除占位只隐藏展示」)。 + */ +export function removeResourceCanvasGenerationPlaceholder( + placeholders: readonly ResourceCanvasGenerationPlaceholder[], + draftId: string, +): ResourceCanvasGenerationPlaceholder[] { + return placeholders.filter((placeholder) => placeholder.draftId !== draftId); +} + +/** 切项目:只留当前项目的占位(未提交草稿与界面位置都不跨项目)。 */ +export function resourceCanvasGenerationPlaceholdersForProject( + placeholders: readonly ResourceCanvasGenerationPlaceholder[], + projectId: string, +): ResourceCanvasGenerationPlaceholder[] { + return placeholders.filter((placeholder) => placeholder.projectId === projectId); +} + +/** 按任务找回占位:成功落点与失败收口都以 taskId 为准,不按素材名猜。 */ +export function resourceCanvasGenerationPlaceholderByTaskId( + placeholders: readonly ResourceCanvasGenerationPlaceholder[], + taskId: string, +): ResourceCanvasGenerationPlaceholder | null { + return placeholders.find((placeholder) => placeholder.taskId === taskId) ?? null; +} + +export function resourceCanvasGenerationPlaceholderByDraftId( + placeholders: readonly ResourceCanvasGenerationPlaceholder[], + draftId: string, +): ResourceCanvasGenerationPlaceholder | null { + return placeholders.find((placeholder) => placeholder.draftId === draftId) ?? null; +} + +export const RESOURCE_CANVAS_GENERATION_PLACEHOLDER_STATUS_LABELS: Record< + ResourceCanvasGenerationPlaceholderStatus, + string +> = { + draft: '待提交', + submitted: '生成中', + failed: '生成失败', +}; + +/** + * 占位卡的浮层锚点层:与快速编辑 / 信息浮层共用同一条几何口径(贴着卡片下沿居中)。 + * + * 占位不是正式资源,没有 manifest 身份,所以这里只造一个仅供锚点算法使用的壳, + * 不把它塞进资源投影或布局模型。 + */ +export function resourceCanvasGenerationPlaceholderLayer( + placeholder: ResourceCanvasGenerationPlaceholder, +): CanvasLayer { + return { + id: placeholder.draftId, + resourceId: placeholder.draftId, + title: placeholder.assetName, + src: '', + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: placeholder.height, + originalWidth: placeholder.width, + originalHeight: placeholder.height, + zIndex: 0, + sourceType: 'uploaded', + }; +} + +/** + * 锚点探针:`resolveQuickEditPanelStyle` 只读 `panel` 判空,浮层自己不需要持有一份快速编辑 + * 专属状态(与信息浮层同一条做法)。 + */ +const RESOURCE_GENERATION_PANEL_ANCHOR_PROBE = { + sourceLayerId: '', + prompt: '', + size: '', + model: '', + status: 'idle', +} as const; + +/** + * 生成浮层的落点:贴着占位卡下沿居中,与快速编辑 / 信息浮层同一套几何。 + * + * 占位是可拖动的,所以浮层必须跟着卡走:这里每次都按占位当前坐标重算,浮层不会留在原地。 + */ +export function resolveResourceCanvasGenerationPanelStyle({ + placeholder, + viewport, + canvasSize, +}: { + placeholder: ResourceCanvasGenerationPlaceholder; + viewport: CanvasViewport; + canvasSize: { width: number; height: number }; +}): CanvasOverlayStyle | null { + return resolveQuickEditPanelStyle({ + panel: { ...RESOURCE_GENERATION_PANEL_ANCHOR_PROBE }, + sourceLayer: resourceCanvasGenerationPlaceholderLayer(placeholder), + viewport, + canvasSize, + }); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts new file mode 100644 index 000000000..52b58f0b0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; + +import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + bindResourceCanvasGenerationPlaceholderTask, + failResourceCanvasGenerationPlaceholder, + moveResourceCanvasGenerationPlaceholder, + placeResourceCanvasGenerationPlaceholder, + removeResourceCanvasGenerationPlaceholder, + resourceCanvasGenerationPlaceholderSize, + resourceCanvasGenerationPlaceholderByDraftId, + resourceCanvasGenerationPlaceholderByTaskId, + resourceCanvasGenerationPlaceholdersForProject, + type ResourceCanvasGenerationPlaceholder, + type ResourceCanvasGenerationPlaceholderPanel, +} from './resourceCanvasGenerationPlaceholderModel'; + +export type ResourceCanvasGenerationPlaceholderDraftInput = { + category: ProjectResourceCanvasCategory; + actionId: string; + actionLabel: string; + assetName: string; + panel: ResourceCanvasGenerationPlaceholderPanel; +}; + +type PlaceholderDragState = { + draftId: string; + pointerId: number; + captureTarget: HTMLElement; + startClientX: number; + startClientY: number; + /** 拖动前的画布坐标:取消手势要回到它,而不是回到落点。 */ + startX: number; + startY: number; + scale: number; + changed: boolean; +}; + +/** + * 生成占位卡的宿主状态:创建、绑定任务、失败收口、删除与拖动。 + * + * 全部是**内存态**:不登记 manifest、不写布局 sidecar、重开项目不恢复位置。归属键是 + * `projectId + draftId`,切项目只留当前项目的占位(未提交草稿与界面位置都不跨项目)。 + */ +export function useResourceCanvasGenerationPlaceholders({ + projectId, + resolvePlacement, + viewportScale, +}: { + projectId: string; + /** + * 新占位的落点。 + * + * 由宿主按「当前栏目已有卡片 + 同栏目其它占位」算出:占位坐标活在与资源卡同一个栏目局部 + * 坐标系里,只有宿主知道这一层的几何。 + */ + resolvePlacement: (input: { + category: ProjectResourceCanvasCategory; + width: number; + height: number; + placeholders: readonly ResourceCanvasGenerationPlaceholder[]; + }) => { x: number; y: number }; + /** 当前场景视口缩放:拖动位移按它换算成画布坐标。 */ + viewportScale: () => number; +}) { + const [placeholders, setPlaceholders] = useState< + ResourceCanvasGenerationPlaceholder[] + >([]); + const [draggingDraftId, setDraggingDraftId] = useState(null); + const placeholdersRef = useRef([]); + placeholdersRef.current = placeholders; + const dragRef = useRef(null); + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; + + // 切项目:未提交草稿与界面位置一并作废,拖动中也要收掉,避免把上一份会话的占位写进新项目。 + useEffect(() => { + dragRef.current = null; + setDraggingDraftId(null); + setPlaceholders((current) => + resourceCanvasGenerationPlaceholdersForProject(current, projectId), + ); + }, [projectId]); + + const createPlaceholder = useCallback( + (input: ResourceCanvasGenerationPlaceholderDraftInput) => { + const size = resourceCanvasGenerationPlaceholderSize(); + const existing = resourceCanvasGenerationPlaceholdersForProject( + placeholdersRef.current, + projectIdRef.current, + ); + const placement = resolvePlacement({ + category: input.category, + width: size.width, + height: size.height, + placeholders: existing, + }); + const placeholder: ResourceCanvasGenerationPlaceholder = { + draftId: crypto.randomUUID(), + projectId: projectIdRef.current, + category: input.category, + actionId: input.actionId, + actionLabel: input.actionLabel, + assetName: input.assetName, + panel: input.panel, + x: placement.x, + y: placement.y, + width: size.width, + height: size.height, + taskId: null, + status: 'draft', + error: null, + }; + setPlaceholders((current) => [...current, placeholder]); + return placeholder; + }, + [resolvePlacement], + ); + + const bindTask = useCallback((draftId: string, taskId: string) => { + setPlaceholders((current) => + current.map((placeholder) => + placeholder.draftId === draftId + ? bindResourceCanvasGenerationPlaceholderTask(placeholder, taskId) + : placeholder, + ), + ); + }, []); + + const failTask = useCallback((taskId: string, error: string | null) => { + setPlaceholders((current) => + current.map((placeholder) => + placeholder.taskId === taskId + ? failResourceCanvasGenerationPlaceholder(placeholder, error) + : placeholder, + ), + ); + }, []); + + const remove = useCallback((draftId: string) => { + setPlaceholders((current) => + removeResourceCanvasGenerationPlaceholder(current, draftId), + ); + }, []); + + const move = useCallback((draftId: string, x: number, y: number) => { + setPlaceholders((current) => + current.map((placeholder) => + placeholder.draftId === draftId + ? moveResourceCanvasGenerationPlaceholder(placeholder, x, y) + : placeholder, + ), + ); + }, []); + + const clearDrag = useCallback((pointerId: number) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== pointerId) { + return null; + } + if (drag.captureTarget.hasPointerCapture?.(pointerId)) { + drag.captureTarget.releasePointerCapture?.(pointerId); + } + dragRef.current = null; + setDraggingDraftId(null); + return drag; + }, []); + + /** + * 占位卡自己的指针拖动。 + * + * 与资源卡手势完全分开:占位不是资源、不进框选集合、也不参与多选移动,所以这里自己起一份 + * 手势状态,并把事件挡住(画布的空白处框选 / 平移不再当它是空白)。取消手势回到拖动前坐标。 + */ + const dragHandlersFor = useCallback( + (placeholder: ResourceCanvasGenerationPlaceholder) => ({ + onPointerDown: (event: ReactPointerEvent) => { + if (event.button !== 0 || event.isPrimary === false) { + return; + } + // 画布空白处的手势(框选 / 平移)不能被这张卡触发。 + event.stopPropagation(); + const current = resourceCanvasGenerationPlaceholderByDraftId( + placeholdersRef.current, + placeholder.draftId, + ); + if (!current) { + return; + } + const scale = viewportScale(); + event.currentTarget.setPointerCapture?.(event.pointerId); + dragRef.current = { + draftId: placeholder.draftId, + pointerId: event.pointerId, + captureTarget: event.currentTarget, + startClientX: event.clientX, + startClientY: event.clientY, + startX: current.x, + startY: current.y, + scale: Number.isFinite(scale) && scale > 0 ? scale : 1, + changed: false, + }; + setDraggingDraftId(placeholder.draftId); + }, + onPointerMove: (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + event.stopPropagation(); + const nextX = drag.startX + (event.clientX - drag.startClientX) / drag.scale; + const nextY = drag.startY + (event.clientY - drag.startClientY) / drag.scale; + drag.changed = true; + move(drag.draftId, nextX, nextY); + }, + onPointerUp: (event: ReactPointerEvent) => { + const drag = clearDrag(event.pointerId); + if (!drag) { + return; + } + event.stopPropagation(); + }, + onPointerCancel: (event: ReactPointerEvent) => { + const drag = clearDrag(event.pointerId); + if (!drag) { + return; + } + // 取消手势(指针捕获丢失 / 窗口失焦):回到拖动前的位置,不留下半截坐标。 + move(drag.draftId, drag.startX, drag.startY); + }, + }), + [clearDrag, move, viewportScale], + ); + + const projectPlaceholders = useMemo( + () => resourceCanvasGenerationPlaceholdersForProject(placeholders, projectId), + [placeholders, projectId], + ); + + return { + placeholders: projectPlaceholders, + placeholdersRef, + draggingDraftId, + createPlaceholder, + bindTask, + failTask, + remove, + move, + dragHandlersFor, + placeholderByTaskId: (taskId: string) => + resourceCanvasGenerationPlaceholderByTaskId( + placeholdersRef.current, + taskId, + ), + placeholderByDraftId: (draftId: string) => + resourceCanvasGenerationPlaceholderByDraftId( + placeholdersRef.current, + draftId, + ), + }; +} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b525ce3ac..ace34579e 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -5081,10 +5081,13 @@ h2 { } .resource-reference-picker { - position: absolute; + /* + * 定位与尺寸全部由组件按输入框上下可用空间算出来(fixed + top + maxHeight): + * 这里只保留兜底上界与外观。此前是 `absolute + bottom` 钉在输入框上方, + * 输入框靠上时整块面板的顶边会被顶出视口,顶部搜索与筛选看不见也点不到。 + */ + position: fixed; z-index: 80; - right: 0; - bottom: calc(100% + 8px); display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; width: min(440px, 88vw); 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 80c67a98f..8f003568e 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 @@ -78,6 +78,7 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, GameIterationVersion, + ProjectResourceCanvasCategory, ProjectResourceCanvasLayoutMode, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { ImageCanvasCharacterAnimationPanelView } from '../../../../../src/components/image-editor/ImageCanvasCharacterAnimationPanelView'; @@ -113,6 +114,8 @@ import { isResourceReferenceOverlayTarget, resolveActiveIterationVersion, resourceReferenceCategoryLabel, + resourceReferenceFromAsset, + type ResourceReference, } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { @@ -121,6 +124,13 @@ import { type ResourceCanvasAssetGenerationSubmitInput, } from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import { resourceCanvasAssetGenerationReferenceIds } from '../../features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; +import { ResourceCanvasGenerationPlaceholderCardView } from '../../features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView'; +import { + placeResourceCanvasGenerationPlaceholder, + resolveResourceCanvasGenerationPanelStyle, + type ResourceCanvasGenerationPlaceholder, +} from '../../features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; +import { useResourceCanvasGenerationPlaceholders } from '../../features/resource-canvas/useResourceCanvasGenerationPlaceholders'; import { createResourceCanvasAssetGenerationQueue, mergeResourceCanvasAssetGenerationTasksWithRecords, @@ -1741,11 +1751,16 @@ export default function ProjectDevelopmentView({ const [resourceGenerationDraft, setResourceGenerationDraft] = useState<{ initialKind: ResourceCanvasGenerationKind; kinds: readonly ResourceCanvasGenerationKind[]; + /** 这次生成挂在哪张占位卡下面(占位按 projectId + draftId 归属宿主临时状态)。 */ + draftId: string; } | null>(null); const resourceGenerationOpen = resourceGenerationDraft !== null; /** 工具栏图片类入口打开的生成浮层;同一时刻只允许一个。 */ - const [resourceAssetGenerationAction, setResourceAssetGenerationAction] = - useState(null); + const [resourceAssetGenerationPanel, setResourceAssetGenerationPanel] = + useState<{ + action: ResourceCanvasAssetToolAction; + draftId: string; + } | null>(null); /** * 图片类生成任务的本地队列 + 后端账本视图。 * @@ -1766,6 +1781,8 @@ export default function ProjectDevelopmentView({ */ const resourceAssetGenerationPanelSubmissionRef = useRef<{ taskId: string; + /** 这次提交挂在哪张占位卡下面:失败重开浮层要回到同一张占位。 */ + draftId: string; action: ResourceCanvasAssetToolAction; draft: ResourceCanvasAssetGenerationPanelDraft; dispatchedImmediately: boolean; @@ -1776,6 +1793,8 @@ export default function ProjectDevelopmentView({ setResourceAssetGenerationPanelReopen, ] = useState<{ actionId: string; + /** 重开时要挂回的那张占位:草稿、浮层与占位三者的归属键就是它。 */ + draftId: string; draft: ResourceCanvasAssetGenerationPanelDraft; error: string; } | null>(null); @@ -1783,6 +1802,79 @@ export default function ProjectDevelopmentView({ resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen, ] = useState(false); + /** + * 占位落点要用的几何:栏目位置表与卡片尺寸表。两者都在本渲染靠后处才算出来, + * 所以用 ref 暴露给占位 Hook(工具点击发生在那之后,读到的总是最新一帧)。 + */ + const resourceCanvasGenerationPlacementRef = useRef<{ + positionsByCategory: ReadonlyMap< + string, + readonly { resourceId: string; x: number; y: number }[] + >; + cardSizeByResourceId: ReadonlyMap< + string, + { width: number; height: number } + >; + } | null>(null); + /** + * 生成占位卡(工具点击先在当前栏目创建)。 + * + * 纯宿主临时状态:按 `projectId + draftId` 归属,不登记 manifest、不写布局 sidecar; + * 提交后按 `taskId` 关联后台任务,成功结果落到占位的最新位置,删除占位只隐藏展示。 + */ + const resourceGenerationPlaceholders = + useResourceCanvasGenerationPlaceholders({ + projectId: manifest.projectId, + /* + 落点用「当前栏目已有卡片 + 同栏目其它占位」的下方空位:这一层几何只有宿主知道, + 模型保持纯函数。 + */ + resolvePlacement: ({ + category, + width, + height, + placeholders: sameSectionPlaceholders, + }) => { + const context = resourceCanvasGenerationPlacementRef.current; + const positions = context?.positionsByCategory.get(category) ?? []; + const occupied = [ + ...positions.map((position) => ({ + x: position.x, + y: position.y, + width: + context?.cardSizeByResourceId.get(position.resourceId)?.width ?? + width, + height: + context?.cardSizeByResourceId.get(position.resourceId)?.height ?? + height, + })), + ...sameSectionPlaceholders + .filter((placeholder) => placeholder.category === category) + .map((placeholder) => ({ + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: placeholder.height, + })), + ]; + return placeResourceCanvasGenerationPlaceholder({ occupied }); + }, + viewportScale: () => resourceCanvasSceneViewportRef.current.scale ?? 1, + }); + /** + * 成功结果的落点意图:占位的最新位置。 + * + * 生成完成时新资源还没进布局(reconcile 补位发生在下一次渲染),此刻直接 `commitPosition` 会在 + * positions 里找不到它、被静默忽略。所以先记下意图,等这张卡真的进了布局再提交落点。 + */ + const resourceGenerationLandingRef = useRef<{ + projectId: string; + draftId: string; + resourceId: string; + category: ProjectResourceCanvasCategory; + x: number; + y: number; + } | null>(null); /** * 「定位到素材」的聚焦请求序号。 * @@ -2080,11 +2172,12 @@ export default function ProjectDevelopmentView({ /** * 画布宿主的生成浮层是否开着(「生成素材」与工具栏图片类入口共用同一口径)。 * - * 两块面板都是 portal 到 body 的模态浮层:它们开着时,「点外部清画布焦点」与画布自己的 - * Esc 都必须让位,否则点面板里的控件会被判成点外部、Esc 会同时关面板又清选中。 + * 两块面板现在都挂在占位卡下沿、活在画布视口坐标系里(不是模态)。它们开着时,「点外部清画布 + * 焦点」与画布自己的 Esc 仍然必须让位:面板里的控件(含 `@` 引用选择器)不能被判成点外部, + * Esc 也不能同时关面板又清选中。 */ const resourceCanvasHostGenerationPanelOpen = - resourceGenerationOpen || resourceAssetGenerationAction !== null; + resourceGenerationOpen || resourceAssetGenerationPanel !== null; /** * 「编辑素材标签」与「设置素材类型」两块面板**共用一个宿主浮层判据**: @@ -2734,6 +2827,44 @@ export default function ProjectDevelopmentView({ /** 拖动起点坐标的读取口:`pointerdown` 与渲染同源,避免两处各取一份布局快照。 */ const resourceLayoutPositionsRef = useRef(resourcePositionById); resourceLayoutPositionsRef.current = resourcePositionById; + /** + * 结果落点:把成功产物落到它那张占位的**最新位置**。 + * + * 等这张卡真的进了布局再提交坐标(生成完成时 reconcile 还没补位,提前提交会被静默忽略); + * 提交后撤掉占位——结果已经接管了它的位置。切项目时落点作废,不把旧项目的坐标写进新项目。 + */ + const removeResourceGenerationPlaceholder = + resourceGenerationPlaceholders.remove; + useEffect(() => { + const landing = resourceGenerationLandingRef.current; + if (!landing) { + return; + } + if ( + landing.projectId !== manifest.projectId || + landing.projectId !== resourceLayout.projectId + ) { + resourceGenerationLandingRef.current = null; + return; + } + if (!resourcePositionById.has(landing.resourceId)) { + return; + } + resourceGenerationLandingRef.current = null; + activeResourceLayout.commitPosition( + landing.resourceId, + landing.category, + landing.x, + landing.y, + ); + removeResourceGenerationPlaceholder(landing.draftId); + }, [ + activeResourceLayout, + manifest.projectId, + removeResourceGenerationPlaceholder, + resourceLayout.projectId, + resourcePositionById, + ]); const selectedVersionBindingResourceIds = useMemo(() => { const selectedVersion = resources.find( (resource) => resource.id === selectedResourceId, @@ -3078,6 +3209,12 @@ export default function ProjectDevelopmentView({ ), [resourceLayout.positions], ); + // 占位落点要用「当前栏目已有卡片」的几何:位置表与卡片尺寸表在这里才算出来, + // 所以在这一层暴露给占位 Hook(工具点击发生在渲染之后,读到的一定是最新一帧)。 + resourceCanvasGenerationPlacementRef.current = { + positionsByCategory: resourcePositionsByCategory, + cardSizeByResourceId: resourceCardSizeByResourceId, + }; const resourceBaseExtentByCategory = useMemo( () => new Map( @@ -7177,7 +7314,10 @@ export default function ProjectDevelopmentView({ * 已有 manifest 条目都不参与派生;成功后用 `pendingResourceFocusRef` 定位新卡。 */ const submitResourceCanvasGeneration = useCallback( - async (input: ResourceCanvasGenerationSubmitInput) => { + async ( + input: ResourceCanvasGenerationSubmitInput, + draftId: string | null = null, + ) => { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { throw new Error('生成资源需要在客户端内执行'); @@ -7333,17 +7473,31 @@ export default function ProjectDevelopmentView({ settlement.record === null && submission.dispatchedImmediately ) { + // 后端从未受理:占位留在画布上,重开浮层带同一份草稿(含参考图)重试。 + resourceGenerationPlaceholders.failTask( + settlement.taskId, + settlement.error ?? '生成素材失败', + ); setResourceAssetGenerationPanelReopen({ actionId: submission.action.id, + draftId: submission.draftId, draft: submission.draft, error: settlement.error ?? '生成素材失败', }); - setResourceAssetGenerationAction(submission.action); + setResourceAssetGenerationPanel({ + action: submission.action, + draftId: submission.draftId, + }); setResourceWorkbenchNotice(''); return; } } if (settlement.status !== 'completed' || !settlement.record?.assetId) { + // 受理之后才失败:占位保留(名称、位置与重试入口都还在),后台任务在账本里收口。 + resourceGenerationPlaceholders.failTask( + settlement.taskId, + settlement.error ?? '未知原因', + ); setResourceWorkbenchNotice( `生成素材失败:${settlement.error ?? '未知原因'}`, ); @@ -7355,6 +7509,23 @@ export default function ProjectDevelopmentView({ setResourceWorkbenchNotice('生成结果需要在客户端内读取'); return; } + /* + 成功落点 = **占位的最新位置**。位置在这里冻结成意图,等新卡进了布局再提交坐标 + (生成完成时 reconcile 还没补位,此刻 commitPosition 会被静默忽略)。 + 占位已经被用户删掉时没有位置可接管,保持既有自动落位 + 定位行为。 + */ + const landingPlaceholder = + resourceGenerationPlaceholders.placeholderByTaskId(settlement.taskId); + if (landingPlaceholder) { + resourceGenerationLandingRef.current = { + projectId: landingPlaceholder.projectId, + draftId: landingPlaceholder.draftId, + resourceId: `asset:${assetId}`, + category: landingPlaceholder.category, + x: landingPlaceholder.x, + y: landingPlaceholder.y, + }; + } let fresh: Awaited< ReturnType > = null; @@ -7454,6 +7625,7 @@ export default function ProjectDevelopmentView({ ( action: ResourceCanvasAssetToolAction, input: ResourceCanvasAssetGenerationSubmitInput, + draftId: string, ) => { const queue = resourceAssetGenerationQueueRef.current; const context = resourceAssetGenerationContextRef.current; @@ -7473,6 +7645,8 @@ export default function ProjectDevelopmentView({ ); const task = createResourceCanvasAssetGenerationTask({ taskId: crypto.randomUUID(), + // 任务绑定它提交时那张占位:成功落点、失败重试都靠这条归属回到同一张占位卡。 + draftId, action, prompt: input.prompt, assetName: input.assetName, @@ -7492,6 +7666,7 @@ export default function ProjectDevelopmentView({ }); resourceAssetGenerationPanelSubmissionRef.current = { taskId: task.taskId, + draftId, action, draft: { prompt: input.prompt, @@ -7503,6 +7678,8 @@ export default function ProjectDevelopmentView({ dispatchedImmediately, }; setResourceAssetGenerationPanelReopen(null); + // 占位从「待提交」进入「生成中」:任务已经交给后台,关闭浮层不影响它。 + resourceGenerationPlaceholders.bindTask(draftId, task.taskId); setResourceAssetGenerationTasksPanelOpen(true); setResourceWorkbenchNotice( `已提交「${input.assetName}」,生成在后台继续,进度见「生成任务」`, @@ -7511,7 +7688,7 @@ export default function ProjectDevelopmentView({ // 避免出现未处理的 Promise 拒绝。 void queue.submit(task).catch(() => undefined); }, - [], + [resourceGenerationPlaceholders], ); /** @@ -7534,6 +7711,9 @@ export default function ProjectDevelopmentView({ setResourceAssetGenerationTasksPanelOpen(false); resourceAssetGenerationPanelSubmissionRef.current = null; setResourceAssetGenerationPanelReopen(null); + // 切项目:生成浮层与它的占位都不跨项目(占位本身由占位 Hook 按 projectId 收口)。 + setResourceGenerationDraft(null); + setResourceAssetGenerationPanel(null); void (async () => { const reportUnavailable = () => { if (!cancelled) { @@ -7824,21 +8004,188 @@ export default function ProjectDevelopmentView({ ).blockedReason; const handleResourceBottomToolAction = useCallback( (action: ResourceCanvasBottomToolAction) => { + /* + 工具点击**先在当前栏目创建临时占位卡**,浮层只是挂在它下沿的一块 UI: + 用户点下去立刻在画布上看到这次生成要落在哪儿,之后可以拖着它换位置, + 结果卡就落到占位的最新位置。占位不是正式素材,关闭浮层也不等于取消后台任务。 + */ + const category = resourceCanvasBottomToolbarCategory; + if (!category) { + return; + } if (action.route === 'audio') { + const placeholder = resourceGenerationPlaceholders.createPlaceholder({ + category, + actionId: action.id, + actionLabel: action.label, + assetName: action.assetName, + panel: { + route: 'audio', + kinds: [action.audioKind], + initialKind: action.audioKind, + }, + }); // 音频入口复用既有「生成素材」面板与同一条无源生成链路,只是各自只放行一种类型。 setResourceGenerationDraft({ initialKind: action.audioKind, kinds: [action.audioKind], + draftId: placeholder.draftId, }); return; } if (action.route === 'asset') { - setResourceAssetGenerationAction(action); + const placeholder = resourceGenerationPlaceholders.createPlaceholder({ + category, + actionId: action.id, + actionLabel: action.label, + assetName: action.assetName, + panel: { route: 'asset', action }, + }); + setResourceAssetGenerationPanel({ + action, + draftId: placeholder.draftId, + }); } }, + [resourceCanvasBottomToolbarCategory, resourceGenerationPlaceholders], + ); + + /** 当前挂着生成浮层的那张占位(音频与图片类入口共用同一个口径)。 */ + const resourceGenerationPanelDraftId = + resourceGenerationDraft?.draftId ?? + resourceAssetGenerationPanel?.draftId ?? + null; + const resourceGenerationPanelPlaceholder = resourceGenerationPanelDraftId + ? resourceGenerationPlaceholders.placeholderByDraftId( + resourceGenerationPanelDraftId, + ) + : null; + /** + * 浮层落点:贴着占位卡下沿(与快速编辑 / 信息浮层同一套锚点几何)。 + * + * 占位被拖走或删掉时这里立刻失去锚点:浮层跟着卡走,卡没了浮层也不再渲染。 + */ + const resourceGenerationPanelStyle = resourceGenerationPanelPlaceholder + ? resolveResourceCanvasGenerationPanelStyle({ + placeholder: resourceGenerationPanelPlaceholder, + viewport: resourceCanvasSceneViewportRef.current, + canvasSize: resourceBookSceneSize, + }) + : null; + + /** + * 收起生成浮层。 + * + * 关闭**只是把这块 UI 收起来**:不取消后台任务、不删占位、不丢草稿身份。已提交的任务继续跑完 + * 并把结果登记进项目,点占位卡随时能再挂回同一块面板。 + */ + const closeResourceGenerationFloatingPanel = useCallback( + (draftId: string) => { + setResourceGenerationDraft((current) => + current?.draftId === draftId ? null : current, + ); + setResourceAssetGenerationPanel((current) => + current?.draftId === draftId ? null : current, + ); + }, [], ); + /** + * 删除占位卡。 + * + * 只影响展示:已提交的任务照旧在账本里跑完、结果照旧登记进项目(只是不再有占位位置可接管, + * 新卡按自动坐标落位并被定位一次)。未提交的草稿随占位一起消失——它本来就什么都没发出去。 + */ + const removeResourceGenerationPlaceholderCard = useCallback( + (draftId: string, assetName: string, submitted: boolean) => { + resourceGenerationPlaceholders.remove(draftId); + closeResourceGenerationFloatingPanel(draftId); + setResourceAssetGenerationPanelReopen((current) => + current?.draftId === draftId ? null : current, + ); + setResourceWorkbenchNotice( + submitted + ? `已移除占位;「${assetName}」仍在后台生成,完成后会自动落卡` + : '', + ); + }, + [closeResourceGenerationFloatingPanel, resourceGenerationPlaceholders], + ); + + /** + * 点占位卡:开 / 收挂在它下面的生成浮层。 + * + * - 浮层开着 → 收起(任务不受影响)。 + * - 已提交 → 不重开表单(这次生成已经在跑,改参数没有意义),转而打开「生成任务」侧栏看进度。 + * - 草稿 / 失败 → 重新挂上同一块面板;失败的重试带回上一次的提示词与参考图。 + */ + const toggleResourceGenerationPlaceholderPanel = useCallback( + (placeholder: ResourceCanvasGenerationPlaceholder) => { + if (resourceGenerationPanelDraftId === placeholder.draftId) { + closeResourceGenerationFloatingPanel(placeholder.draftId); + return; + } + if (placeholder.status === 'submitted') { + setResourceAssetGenerationTasksPanelOpen(true); + setResourceWorkbenchNotice( + `「${placeholder.assetName}」正在生成,进度见「生成任务」`, + ); + return; + } + const retryTask = + resourceAssetGenerationTasksRef.current.find( + (task) => task.draftId === placeholder.draftId, + ) ?? null; + if (placeholder.panel.route === 'audio') { + setResourceGenerationDraft({ + initialKind: placeholder.panel.initialKind, + kinds: placeholder.panel.kinds, + draftId: placeholder.draftId, + }); + return; + } + if (retryTask) { + setResourceAssetGenerationPanelReopen({ + actionId: placeholder.actionId, + draftId: placeholder.draftId, + draft: { + prompt: retryTask.prompt, + assetName: retryTask.assetName, + aspectRatio: retryTask.aspectRatio, + imageSize: retryTask.imageSize, + // 参考图按资产 ID 还原成引用:只认还在清单里的那些,缺的交给提交前的陈旧引用判据报出来。 + references: retryTask.referenceAssetIds + .map((assetId) => { + const asset = manifest.assets.find( + (candidate) => candidate.id === assetId, + ); + return asset + ? resourceReferenceFromAsset(asset, 'asset-picker') + : null; + }) + .filter( + (reference): reference is ResourceReference => + reference !== null, + ), + }, + error: placeholder.error ?? '生成素材失败', + }); + } else { + setResourceAssetGenerationPanelReopen(null); + } + setResourceAssetGenerationPanel({ + action: placeholder.panel.action, + draftId: placeholder.draftId, + }); + }, + [ + closeResourceGenerationFloatingPanel, + manifest.assets, + resourceGenerationPanelDraftId, + ], + ); + /** * 「生成任务」开合入口。**两个页签下都常驻**:侧栏本体是无条件渲染的非模态浮层,运行态一样 * 可见,入口若只在资源页签,用户切到运行后关掉侧栏就再也打不开了。资源页签里它排在 @@ -8175,10 +8522,54 @@ export default function ProjectDevelopmentView({ mainViewport={resourceBookMainViewport} renderCard={renderResourceBookCard} worldOverlay={ - + <> + + {/* + 生成占位卡:活在**栏目页**的世界坐标系里(与同栏目资源卡同一坐标系), + 所以拖动、缩放、视口平移都跟画布一起动。占位不进资源投影、不参与框选, + 指针事件自己接管(见 `useResourceCanvasGenerationPlaceholders`)。 + */} + {resourceBookView === 'child' + ? resourceGenerationPlaceholders.placeholders + .filter( + (placeholder) => + placeholder.category === + resourceCanvasBottomToolbarCategory, + ) + .map((placeholder) => ( + + toggleResourceGenerationPlaceholderPanel( + placeholder, + ) + } + onRemove={() => + removeResourceGenerationPlaceholderCard( + placeholder.draftId, + placeholder.assetName, + placeholder.taskId !== null, + ) + } + /> + )) + : null} + } overlay={ resourceBookView === 'main' ? null : ( @@ -8523,6 +8914,88 @@ export default function ProjectDevelopmentView({ onClose={() => setResourceInfoPanelOpen(false)} /> ) : null} + {/* + 生成浮层:**挂在占位卡下沿的独立浮层**,不是模态。 + + 模态(`ThemedModal` + 焦点陷阱)会把 `@` 引用选择器挡在陷阱之外—— + 选择器 portal 到 body,候选项点了不生效(真实验收复现过)。浮层形态 + 既解决了这条阻断,也天然满足「关闭浮层不等于取消后台任务」。 + */} + {resourceGenerationDraft && + resourceGenerationPanelPlaceholder?.panel.route === + 'audio' && + resourceGenerationPanelStyle ? ( + + submitResourceCanvasGeneration( + input, + resourceGenerationDraft.draftId, + ) + } + onClose={() => + closeResourceGenerationFloatingPanel( + resourceGenerationDraft.draftId, + ) + } + /> + ) : null} + {resourceAssetGenerationPanel && + resourceGenerationPanelPlaceholder?.panel.route === + 'asset' && + resourceGenerationPanelStyle ? ( + + submitResourceAssetGeneration( + resourceAssetGenerationPanel.action, + input, + resourceAssetGenerationPanel.draftId, + ) + } + onClose={() => { + setResourceAssetGenerationPanelReopen((current) => + current?.draftId === + resourceAssetGenerationPanel.draftId + ? null + : current, + ); + closeResourceGenerationFloatingPanel( + resourceAssetGenerationPanel.draftId, + ); + }} + /> + ) : null} ) } @@ -9060,50 +9533,6 @@ export default function ProjectDevelopmentView({ ) : null} - {resourceGenerationDraft ? ( - setResourceGenerationDraft(null)} - /> - ) : null} - {resourceAssetGenerationAction ? ( - - submitResourceAssetGeneration(resourceAssetGenerationAction, input) - } - onClose={() => { - setResourceAssetGenerationPanelReopen(null); - setResourceAssetGenerationAction(null); - }} - /> - ) : null} {/* 「生成任务」侧栏:常驻、可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` 的模态遮挡判据——生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图, 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 ccb13311f..3e061dba9 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 @@ -7,6 +7,10 @@ import type { } from '../../../../packages/shared/src/contracts/gameCreationApp'; import { ProjectSupervisorView } from '../../src/features/project-workspace/ProjectSupervisorView'; import { RESOURCE_REFERENCE_INSERT_EVENT } from '../../src/features/project-workspace/resourceReferences'; +import { + generationPromptText, + typeGenerationPrompt, +} from '../resourceGenerationPromptTestUtils'; import { ApprovalModeDialog } from '../../src/view/project-development/ApprovalModeDialog'; import { RESOURCE_BOOK_OVERVIEW_STACK_LIMIT } from '../../src/view/project-development/resourceBookLayout'; import { @@ -346,9 +350,9 @@ async function submitBottomToolbarPanel( fireEvent.click(screen.getByRole('button', { name: label })); } const panel = await screen.findByRole('dialog', { name: label }); - fireEvent.change(within(panel).getByLabelText('生成提示词'), { - target: { value: options.prompt }, - }); + // 提示词输入区是与聊天同一份 `@` 引用输入区(Lexical):jsdom 没有可用 Selection, + // 浏览器输入事件不会落字,只能走编辑器更新(见 resourceGenerationPromptTestUtils)。 + await typeGenerationPrompt(panel, options.prompt); fireEvent.click(within(panel).getByRole('button', { name: label })); // 成功路径由宿主卸载面板:等它消失,下一个入口才不会撞上残留的浮层。 await waitFor(() => @@ -11816,9 +11820,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(firstPanel).getByLabelText('素材名称'), { target: { value: '第一条设计图' }, }); - fireEvent.change(within(firstPanel).getByLabelText('生成提示词'), { - target: { value: '第一条界面' }, - }); + await typeGenerationPrompt(firstPanel, '第一条界面'); fireEvent.click( within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }), ); @@ -11843,9 +11845,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(secondPanel).getByLabelText('素材名称'), { target: { value: '第二条设计图' }, }); - fireEvent.change(within(secondPanel).getByLabelText('生成提示词'), { - target: { value: '第二条界面' }, - }); + await typeGenerationPrompt(secondPanel, '第二条界面'); fireEvent.click( within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }), ); @@ -12403,9 +12403,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(panel).getByLabelText('素材名称'), { target: { value: '待提交设计图' }, }); - fireEvent.change(within(panel).getByLabelText('生成提示词'), { - target: { value: '主界面与背包页' }, - }); + await typeGenerationPrompt(panel, '主界面与背包页'); return panel; } @@ -12452,10 +12450,9 @@ export function registerProjectAgentStatusTests() { '项目权限策略拒绝执行:canvas.asset_generate', ), ); - expect( - (within(reopened).getByLabelText('生成提示词') as HTMLTextAreaElement) - .value, - ).toBe('主界面与背包页'); + await waitFor(() => + expect(generationPromptText(reopened)).toBe('主界面与背包页'), + ); expect( (within(reopened).getByLabelText('素材名称') as HTMLInputElement).value, ).toBe('待提交设计图'); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx index 1b7c0a8d0..0a1ba40ca 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx @@ -13,6 +13,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +import { + generationPromptText, + typeGenerationPrompt, +} from './resourceGenerationPromptTestUtils'; afterEach(() => { cleanup(); @@ -50,7 +54,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 onClose={onClose} />, ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); + await typeGenerationPrompt(document.body, '主界面与背包页'); // 点击前主按钮文案就是动作名:不是阶段、也不是「已提交」。 const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); expect( @@ -84,7 +88,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 onClose={onClose} />, ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); + await typeGenerationPrompt(document.body, '主界面与背包页'); await user.click( screen.getByRole('button', { name: '关闭生成 UI 设计图' }), @@ -112,6 +116,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 assetName: 'AI 生成 UI 设计图', aspectRatio: '16:9', imageSize: '1K', + references: [], }} error="生成素材失败:远端拒绝" onSubmit={onSubmit} @@ -122,9 +127,9 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 expect(screen.getByRole('alert').textContent).toContain( '生成素材失败:远端拒绝', ); - expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, - ).toBe('主界面与背包页'); + await waitFor(() => + expect(generationPromptText(document.body)).toBe('主界面与背包页'), + ); await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); expect(onSubmit).toHaveBeenCalledTimes(1); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index de46eefd5..8cdfb6247 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -83,7 +83,7 @@ describe('生成任务模型', () => { expect(task.referenceAssetIds).toEqual([]); }); - test('参考图资产 ID 随任务保存,重开项目恢复出来的历史任务不带参考选择', () => { + test('参考图资产 ID 去重后随任务保存,重开项目恢复出来的历史任务不带参考选择', () => { const task = createResourceCanvasAssetGenerationTask({ taskId: 'task-ref', action: uiPrototypeAction, @@ -91,12 +91,12 @@ describe('生成任务模型', () => { assetName: 'UI', aspectRatio: '16:9', imageSize: '1K', - referenceAssetIds: ['asset-a', 'asset-b', 'asset-a'], + referenceAssetIds: ['asset-a', ' asset-b ', 'asset-a', ''], outputPath: null, projectId: 'project-1', nowMillis: 10, }); - expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b', 'asset-a']); + expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b']); const restored = applyLocalProjectAssetGenerationRecords( [], diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx index c70900b2c..22ce65ddb 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -16,6 +16,7 @@ import { resourceCanvasAssetGenerationReferenceAssets, resourceCanvasAssetGenerationReferenceError, resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationReferenceIssue, resourceCanvasAssetGenerationUserReferenceLimit, } from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; @@ -137,12 +138,46 @@ describe('参考图模型', () => { asset('asset-image', 'image/png', 'assets/a.png'), asset('asset-doc', 'text/markdown', 'assets/a.md', 'document'), asset('asset-audio', 'audio/mpeg', 'assets/a.mp3', 'sound-effect'), + // 原生按 `image::load_from_memory` 解码,SVG 读不出来:进了候选就是一次必然失败的付费提交。 + asset('asset-svg', 'image/svg+xml', 'assets/a.svg'), + asset('asset-svg-alias', 'image/svg', 'assets/b.svg'), asset('asset-hidden', 'image/png', '.agent/runtime/a.png'), asset('asset-remote-only', 'image/png', ' '), ]); expect(candidates.map((item) => item.id)).toEqual(['asset-image']); }); + test('陈旧引用判据把矢量图与缺失文件分开报,不做静默过滤', () => { + const assets = [ + asset('asset-svg', 'image/svg+xml', 'assets/a.svg'), + asset('asset-no-file', 'image/png', ' '), + ]; + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-svg', '矢量图')], + assets, + }), + ).toContain('矢量图'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-no-file', '没落盘')], + assets, + }), + ).toContain('没有本地文件'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-missing', '已删除')], + assets, + }), + ).toContain('已不在当前项目'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-image', '正常图片')], + assets: [asset('asset-image', 'image/webp', 'assets/a.webp')], + }), + ).toBeNull(); + }); + test('参考 ID 只取资源引用、按选择顺序去重', () => { expect( resourceCanvasAssetGenerationReferenceIds([ diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index c228db240..8f8a3c477 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -26,6 +26,11 @@ import { resourceCanvasBottomToolActions, } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasBottomToolbarView } from '../src/features/resource-canvas/ResourceCanvasBottomToolbarView'; +import { + generationPromptText, + typeGenerationPrompt, +} from './resourceGenerationPromptTestUtils'; +import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; afterEach(cleanup); @@ -367,9 +372,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { screen.queryByRole('button', { name: '图标规范比例 1:1' }), ).toBeNull(); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '像素月光厨房的统一视觉规范' }, - }); + await typeGenerationPrompt(panel, '像素月光厨房的统一视觉规范'); fireEvent.click(screen.getByRole('button', { name: '图标规范' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ @@ -378,6 +381,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: '图标规范', aspectRatio: '1:1', imageSize: '1K', + references: [], }), ); }); @@ -402,9 +406,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { fireEvent.click( screen.getByRole('button', { name: '生成 UI 设计图尺寸 2K' }), ); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '横屏单屏界面' }, - }); + await typeGenerationPrompt(document.body, '横屏单屏界面'); fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ @@ -413,11 +415,12 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: 'AI 生成 UI 设计图', aspectRatio: '9:16', imageSize: '2K', + references: [], }), ); }); - test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', () => { + test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', async () => { const onSubmit = vi.fn(); const onClose = vi.fn(); const action = assetActionOf('character', '生成角色形象'); @@ -431,9 +434,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { const submit = screen.getByRole('button', { name: '生成角色形象' }); expect((submit as HTMLButtonElement).disabled).toBe(true); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '披风猫骑士' }, - }); + await typeGenerationPrompt(document.body, '披风猫骑士'); fireEvent.click(submit); // 点击即关闭:面板不等受理结果,失败由宿主决定要不要把它带回来。 expect(onSubmit).toHaveBeenCalledTimes(1); @@ -446,6 +447,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: string; aspectRatio: string; imageSize: string; + references: ChatReference[]; }; render( { assetName: first.assetName, aspectRatio: first.aspectRatio, imageSize: first.imageSize, + references: first.references, }} error="图片比例不受支持:4:3" onSubmit={onSubmit} @@ -464,9 +467,10 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { expect(screen.getByRole('alert').textContent).toContain( '图片比例不受支持:4:3', ); - expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, - ).toBe('披风猫骑士'); + // 草稿会重新灌回 `@` 引用输入区;它由编辑器异步落到 DOM,等一次。 + await waitFor(() => + expect(generationPromptText(document.body)).toContain('披风猫骑士'), + ); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(onSubmit).toHaveBeenCalledTimes(2); expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx new file mode 100644 index 000000000..7e852ee5a --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +function asset( + id: string, + label: string, + mediaType = 'image/png', +): GameCreationAppAssetManifestEntry { + return { + id, + kind: 'image', + mediaType, + localPath: `assets/${label}.png`, + source: { kind: 'canvas' }, + }; +} + +/** 取面板里的引用输入区(含 `@` 触发器按钮),把操作局限在它自己身上。 */ +function referenceInputScope(ariaLabel: string) { + const root = screen.getByLabelText(ariaLabel).closest('.resource-reference-input'); + if (!root) { + throw new Error(`资源引用输入区不存在:${ariaLabel}`); + } + return within(root as HTMLElement); +} + +describe('生成浮层里的引用选择(真实组件,不做 props mock)', () => { + test('浮层不是模态,真实点击候选就能选中并随提交带走引用', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const assets = [ + asset('asset-a', '素材-a'), + asset('asset-b', '素材-b'), + ]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + /* + 浮层形态不是模态:没有全屏遮罩、没有 aria-modal。`ThemedModal` 的焦点陷阱正是 P1 的成因—— + 引用选择器 portal 到 body,被陷阱挡在外面时候选项点了不生效(真实浏览器复现过)。 + */ + expect(panel.getAttribute('aria-modal')).toBeNull(); + expect(document.querySelector('[aria-modal="true"]')).toBeNull(); + expect(document.querySelector('.fixed.inset-0')).toBeNull(); + + await typeGenerationPrompt(panel, '画一只猫'); + await user.click( + referenceInputScope('生成提示词').getByRole('button', { + name: '插入素材引用', + }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + await user.click( + within(picker).getByRole('option', { name: /素材-a/ }), + ); + expect(within(picker).getByText('已选择 1 个')).not.toBeNull(); + const insert = within(picker).getByRole('button', { name: '插入引用' }); + expect((insert as HTMLButtonElement).disabled).toBe(false); + await user.click(insert); + + // 选中的引用进了面板:计数可见、提交时随载荷带走。 + await waitFor(() => + expect(within(panel).getByText('参考图 1/5')).not.toBeNull(), + ); + await user.click(within(panel).getByRole('button', { name: '生成图片' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + const submitted = onSubmit.mock.calls[0]?.[0] as { + prompt: string; + references: { resourceId: string }[]; + }; + expect(submitted.references.map((reference) => reference.resourceId)).toEqual( + ['asset-a'], + ); + expect(submitted.prompt).toContain('画一只猫'); + }); + + test('搜索能收窄候选,键盘也能完成选择', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const assets = [ + asset('asset-a', '素材-a'), + asset('asset-b', '素材-b'), + ]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + await typeGenerationPrompt(panel, '画一只猫'); + await user.click( + referenceInputScope('生成提示词').getByRole('button', { + name: '插入素材引用', + }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + expect(within(picker).getAllByRole('option')).toHaveLength(2); + + // 搜索收窄:只剩「素材-b」这一条候选。 + await user.clear(screen.getByLabelText('搜索全部画布素材')); + await user.type(screen.getByLabelText('搜索全部画布素材'), '素材-b'); + await waitFor(() => + expect(within(picker).getAllByRole('option')).toHaveLength(1), + ); + + // 键盘路径:Tab 进候选列表,Enter 选中(不依赖鼠标点击)。 + const option = within(picker).getByRole('option', { name: /素材-b/ }); + option.focus(); + await user.keyboard('{Enter}'); + await waitFor(() => + expect(within(picker).getByText('已选择 1 个')).not.toBeNull(), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx new file mode 100644 index 000000000..bbae19c22 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx @@ -0,0 +1,340 @@ +// @vitest-environment jsdom +import { renderHook } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +// 指针事件与 DOM 尺寸的 jsdom 补丁统一由 appSurface harness 提供(`PointerEvent` 等), +// 与既有画布手势用例同一套测试环境;不在这里另写一份补丁。 +import { act, cleanup, fireEvent, render, screen } from './appSurface/harness'; + +import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { ResourceCanvasGenerationPlaceholderCardView } from '../src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView'; +import { + bindResourceCanvasGenerationPlaceholderTask, + failResourceCanvasGenerationPlaceholder, + moveResourceCanvasGenerationPlaceholder, + placeResourceCanvasGenerationPlaceholder, + removeResourceCanvasGenerationPlaceholder, + resolveResourceCanvasGenerationPanelStyle, + resourceCanvasGenerationPlaceholderByDraftId, + resourceCanvasGenerationPlaceholderByTaskId, + resourceCanvasGenerationPlaceholderSize, + resourceCanvasGenerationPlaceholdersForProject, + type ResourceCanvasGenerationPlaceholder, +} from '../src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; +import { useResourceCanvasGenerationPlaceholders } from '../src/features/resource-canvas/useResourceCanvasGenerationPlaceholders'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +function placeholderFixture( + overrides: Partial = {}, +): ResourceCanvasGenerationPlaceholder { + const size = resourceCanvasGenerationPlaceholderSize(); + return { + draftId: 'draft-1', + projectId: 'project-1', + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + x: 0, + y: 0, + width: size.width, + height: size.height, + taskId: null, + status: 'draft', + error: null, + ...overrides, + }; +} + +describe('生成占位模型', () => { + test('空栏目落在原点,已有内容时排到最下面一行之下(占位之间也不重叠)', () => { + const size = resourceCanvasGenerationPlaceholderSize(); + expect(placeResourceCanvasGenerationPlaceholder({ occupied: [] })).toEqual({ + x: 0, + y: 0, + }); + const first = placeResourceCanvasGenerationPlaceholder({ + occupied: [{ x: 0, y: 0, width: size.width, height: size.height }], + }); + expect(first.x).toBe(0); + expect(first.y).toBe(size.height + 16); + const second = placeResourceCanvasGenerationPlaceholder({ + occupied: [ + { x: 0, y: 0, width: size.width, height: size.height }, + { x: 0, y: first.y, width: size.width, height: size.height }, + ], + }); + expect(second.y).toBe(first.y + size.height + 16); + }); + + test('拖动只改坐标:有限数取整、越界收到坐标域、非法值保持原位', () => { + const base = placeholderFixture({ x: 10, y: 20 }); + expect(moveResourceCanvasGenerationPlaceholder(base, 33.6, -7.4)).toMatchObject( + { x: 34, y: -7 }, + ); + expect( + moveResourceCanvasGenerationPlaceholder(base, 1e9, -1e9), + ).toMatchObject({ + x: GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + y: -GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + }); + expect( + moveResourceCanvasGenerationPlaceholder(base, Number.NaN, Number.NaN), + ).toMatchObject({ x: 10, y: 20 }); + }); + + test('提交绑定任务、失败保留占位、删除只移除展示', () => { + const bound = bindResourceCanvasGenerationPlaceholderTask( + placeholderFixture(), + 'task-1', + ); + expect(bound).toMatchObject({ status: 'submitted', taskId: 'task-1' }); + const failed = failResourceCanvasGenerationPlaceholder(bound, '远端拒绝'); + expect(failed).toMatchObject({ + status: 'failed', + taskId: 'task-1', + error: '远端拒绝', + }); + // 失败不删占位:同一份输入与位置还要用来重试。 + expect(resourceCanvasGenerationPlaceholderByTaskId([failed], 'task-1')).toBe( + failed, + ); + expect( + resourceCanvasGenerationPlaceholderByDraftId([failed], 'draft-1'), + ).toBe(failed); + expect(resourceCanvasGenerationPlaceholderByTaskId([failed], 'task-2')).toBe( + null, + ); + expect(removeResourceCanvasGenerationPlaceholder([failed], 'draft-1')).toEqual( + [], + ); + }); + + test('归属按项目收口:切项目只留当前项目的占位', () => { + const placeholders = [ + placeholderFixture({ draftId: 'draft-a', projectId: 'project-1' }), + placeholderFixture({ draftId: 'draft-b', projectId: 'project-2' }), + ]; + expect( + resourceCanvasGenerationPlaceholdersForProject( + placeholders, + 'project-2', + ).map((item) => item.draftId), + ).toEqual(['draft-b']); + }); + + test('浮层锚点贴着占位下沿居中(与快速编辑同一条几何)', () => { + const style = resolveResourceCanvasGenerationPanelStyle({ + placeholder: placeholderFixture({ x: 100, y: 200 }), + viewport: { x: 0, y: 0, scale: 2 }, + canvasSize: { width: 800, height: 600 }, + }); + const size = resourceCanvasGenerationPlaceholderSize(); + expect(style?.left).toBe((100 + size.width / 2) * 2); + expect(style?.top).toBeGreaterThan((200 + size.height) * 2); + }); +}); + +describe('占位卡组件', () => { + test('呈现名称与状态,点卡开合浮层、点删除只删除自己', () => { + const onTogglePanel = vi.fn(); + const onRemove = vi.fn(); + render( + undefined} + onPointerMove={() => undefined} + onPointerUp={() => undefined} + onPointerCancel={() => undefined} + onTogglePanel={onTogglePanel} + onRemove={onRemove} + />, + ); + + const card = screen.getByRole('button', { + name: 'AI 生成图片(生成失败)', + }); + expect(card.getAttribute('data-resource-canvas-generation-placeholder')).toBe( + 'draft-1', + ); + fireEvent.click(card); + expect(onTogglePanel).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole('button', { name: '删除占位 AI 生成图片' })); + expect(onRemove).toHaveBeenCalledTimes(1); + // 删除按钮不能把点击透到卡片上(否则删完立刻又开一次浮层)。 + expect(onTogglePanel).toHaveBeenCalledTimes(1); + }); +}); + +describe('占位宿主 Hook', () => { + function renderPlaceholderHook(projectId = 'project-1') { + return renderHook( + ({ currentProjectId }: { currentProjectId: string }) => + useResourceCanvasGenerationPlaceholders({ + projectId: currentProjectId, + resolvePlacement: ({ placeholders: sameSection }) => ({ + x: 0, + y: sameSection.length * 100, + }), + viewportScale: () => 2, + }), + { initialProps: { currentProjectId: projectId } }, + ); + } + + test('创建占位、绑定任务与失败收口', () => { + const hook = renderPlaceholderHook(); + let draftId = ''; + act(() => { + const created = hook.result.current.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + draftId = created.draftId; + }); + expect(hook.result.current.placeholders).toHaveLength(1); + expect(hook.result.current.placeholders[0]).toMatchObject({ + projectId: 'project-1', + category: 'scene', + status: 'draft', + taskId: null, + }); + + act(() => hook.result.current.bindTask(draftId, 'task-1')); + expect(hook.result.current.placeholders[0]).toMatchObject({ + status: 'submitted', + taskId: 'task-1', + }); + + act(() => hook.result.current.failTask('task-1', '远端拒绝')); + expect(hook.result.current.placeholders[0]).toMatchObject({ + status: 'failed', + error: '远端拒绝', + }); + + act(() => hook.result.current.remove(draftId)); + expect(hook.result.current.placeholders).toEqual([]); + }); + + test('切项目清掉上一份会话的占位', () => { + const hook = renderPlaceholderHook(); + act(() => { + hook.result.current.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + }); + expect(hook.result.current.placeholders).toHaveLength(1); + + act(() => hook.rerender({ currentProjectId: 'project-2' })); + expect(hook.result.current.placeholders).toEqual([]); + }); + + test('拖动按视口缩放换算坐标,指针取消回到拖动前位置', () => { + function Harness() { + const [created, setCreated] = useState(false); + const placeholders = useResourceCanvasGenerationPlaceholders({ + projectId: 'project-1', + resolvePlacement: () => ({ x: 10, y: 20 }), + viewportScale: () => 2, + }); + useEffect(() => { + if (created) { + return; + } + placeholders.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + setCreated(true); + }, [created, placeholders]); + const placeholder = placeholders.placeholders[0]; + if (!placeholder) { + return null; + } + return ( + undefined} + onRemove={() => undefined} + /> + ); + } + + render(); + const card = screen.getByRole('button', { + name: 'AI 生成图片(待提交)', + }); + expect(card.style.left).toBe('10px'); + expect(card.style.top).toBe('20px'); + + fireEvent.pointerDown(card, { + pointerId: 1, + button: 0, + isPrimary: true, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 1, + clientX: 140, + clientY: 130, + }); + // 视口缩放 2:屏幕 40 / 30 px 对应画布 20 / 15 px。 + expect(card.style.left).toBe('30px'); + expect(card.style.top).toBe('35px'); + fireEvent.pointerUp(card, { pointerId: 1, clientX: 140, clientY: 130 }); + + // 取消手势:回到拖动前坐标,不留下半截位置。 + fireEvent.pointerDown(card, { + pointerId: 2, + button: 0, + isPrimary: true, + clientX: 200, + clientY: 200, + }); + fireEvent.pointerMove(card, { + pointerId: 2, + clientX: 260, + clientY: 260, + }); + expect(card.style.left).toBe('60px'); + fireEvent.pointerCancel(card, { pointerId: 2 }); + expect(card.style.left).toBe('30px'); + expect(card.style.top).toBe('35px'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts b/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts new file mode 100644 index 000000000..950fca4e5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts @@ -0,0 +1,40 @@ +import { act, within } from '@testing-library/react'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + type LexicalEditor, +} from 'lexical'; + +/** + * 往生成面板的提示词输入区写一段文本。 + * + * 提示词输入区与聊天、资源快速编辑共用同一个 `@` 引用输入区(Lexical contenteditable)。 + * jsdom 没有可用的 DOM Selection,Lexical 因此会忽略浏览器输入事件——`userEvent.type` 与 + * `beforeinput` 都不会落字,`fireEvent.change` 更不适用。所以这条链路只能在编辑器实例上做 + * 等价更新:仍然走 Lexical 的 `update()` → `OnChangePlugin` → 面板状态,断言的是面板真正收到 + * 的提示词与引用。浏览器里的真实输入路径由引用输入区自己的测试与实机验收覆盖。 + */ +export async function typeGenerationPrompt(scope: HTMLElement, text: string) { + const element = within(scope).getByLabelText('生成提示词') as HTMLElement & { + __lexicalEditor?: LexicalEditor; + }; + const editor = element.__lexicalEditor; + if (!editor) { + throw new Error('生成提示词输入区不是 Lexical 编辑器'); + } + await act(async () => { + editor.update(() => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(text)); + root.append(paragraph); + }); + }); +} + +/** 读回提示词输入区当前呈现的文本(contenteditable 没有 `value`)。 */ +export function generationPromptText(scope: HTMLElement) { + return within(scope).getByLabelText('生成提示词').textContent ?? ''; +}