生成占位可见性、落点与音频提交身份修复
打开生成浮层时把占位与浮层整块带进安全区(避开顶栏底栏),只做最小视口平移不改缩放 落点意图改为按草稿 ID 的 Map,避免并发完成互相覆盖;坐标在落点时刻取占位最新位置 落点 section 改用资源正式归类后的 category,修复图片落待归类、规范落文档时静默跳过 音频与背景音乐提交绑定占位与 operationId:在途时拒绝再次提交,失败保留输入并可同 operation 重试 宿主记录收起浮层时的草稿与音频提交身份,重开占位接着编辑而不是回到空表单 生成任务新增入口栏目 targetCategory 走原生可选入参,前端不据此伪分类 补可见性平移的定向测试
This commit is contained in:
+20
-4
@@ -94,7 +94,13 @@ export type ResourceCanvasAssetGenerationPanelViewProps = {
|
||||
* 面板自己不持有任何在途状态。
|
||||
*/
|
||||
onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* 收起浮层。
|
||||
*
|
||||
* 参数是当前草稿:宿主保存它,用户再点开占位卡时接着编辑(关闭 ≠ 丢弃输入,不是空表单)。
|
||||
* 提交后的关闭不带草稿——这次输入已经被任务接走,重试身份也在宿主的提交上下文里。
|
||||
*/
|
||||
onClose: (draft?: ResourceCanvasAssetGenerationPanelDraft) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -189,6 +195,15 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
setPrompt(next.text);
|
||||
setReferences(next.references);
|
||||
};
|
||||
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
|
||||
const closeWithDraft = () =>
|
||||
onClose({
|
||||
prompt,
|
||||
assetName,
|
||||
aspectRatio,
|
||||
imageSize,
|
||||
references,
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -215,6 +230,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
imageSize,
|
||||
references,
|
||||
});
|
||||
// 提交后的关闭不带草稿:这次输入已经被任务接走,重试身份在宿主的提交上下文里。
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -227,7 +243,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`关闭${action.label}`}
|
||||
onClick={onClose}
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -341,7 +357,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
onClick={onClose}
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
@@ -373,7 +389,7 @@ export function ResourceCanvasAssetGenerationPanelView({
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel={action.label}
|
||||
onClose={onClose}
|
||||
onClose={closeWithDraft}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
{panelBody}
|
||||
|
||||
+44
-7
@@ -45,8 +45,36 @@ export type ResourceCanvasGenerationPanelViewProps = {
|
||||
*/
|
||||
variant?: 'modal' | 'floating';
|
||||
style?: CSSProperties | null;
|
||||
/**
|
||||
* 初始草稿(音频 / 背景音乐入口用)。
|
||||
*
|
||||
* 用户收起浮层后草稿由宿主保存,再点开占位卡时从这里灌回来——**不是**空表单,
|
||||
* 用户不必重打一遍提示词。
|
||||
*/
|
||||
initialDraft?: {
|
||||
kind: ResourceCanvasGenerationKind;
|
||||
prompt: string;
|
||||
assetName: string;
|
||||
} | null;
|
||||
/**
|
||||
* 已绑定的提交身份。
|
||||
*
|
||||
* 同一次草稿的重试必须复用同一对 `operationId` / 幂等键:原生按 operation 记账,换一对就是
|
||||
* 一次**新的**付费生成。宿主把首次提交铸造的身份记在占位上,收起来再点开时灌回来。
|
||||
*/
|
||||
request?: ResourceEditRequestIdentity | null;
|
||||
onSubmit: (input: ResourceCanvasGenerationSubmitInput) => Promise<void>;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* 收起浮层。
|
||||
*
|
||||
* 参数是当前草稿:宿主把它存起来,用户再点开占位卡时能接着编辑(关闭 ≠ 丢弃输入)。
|
||||
* 提交成功后的关闭不带草稿(这次输入已经被任务接走)。
|
||||
*/
|
||||
onClose: (draft?: {
|
||||
kind: ResourceCanvasGenerationKind;
|
||||
prompt: string;
|
||||
assetName: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const RESOURCE_GENERATION_ALL_KIND_ITEMS =
|
||||
@@ -77,6 +105,8 @@ export function ResourceCanvasGenerationPanelView({
|
||||
initialKind,
|
||||
variant = 'modal',
|
||||
style,
|
||||
initialDraft,
|
||||
request: boundRequest,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: ResourceCanvasGenerationPanelViewProps) {
|
||||
@@ -94,16 +124,23 @@ export function ResourceCanvasGenerationPanelView({
|
||||
const option = resourceCanvasGenerationOption(kind);
|
||||
const panelTitle =
|
||||
allowedOptions.length === 1 ? option.generationLabel : '生成素材';
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [assetName, setAssetName] = useState(option.assetName);
|
||||
const [prompt, setPrompt] = useState(initialDraft?.prompt ?? '');
|
||||
const [assetName, setAssetName] = useState(
|
||||
initialDraft?.assetName ?? option.assetName,
|
||||
);
|
||||
const [attempted, setAttempted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 请求身份绑定到铸造时的那句提示词:失败重试命中同一 operation 账本,提示词变了就重铸
|
||||
// (Rust 的 request_fingerprint 含 prompt,复用旧身份会被拒)。面板在首次提交后锁定
|
||||
// 输入,正常路径下提示词不会漂移;这里按同一口径收口,不依赖「锁」这层间接保证。
|
||||
const requestRef = useRef<ResourceEditRequestIdentity | null>(null);
|
||||
const requestRef = useRef<ResourceEditRequestIdentity | null>(
|
||||
boundRequest ?? null,
|
||||
);
|
||||
const inputLocked = attempted || submitting;
|
||||
/** 收起浮层:把当前草稿交给宿主保存,用户再点开占位卡时接着编辑。 */
|
||||
const closeWithDraft = () =>
|
||||
onClose({ kind, prompt, assetName: assetName.trim() || option.assetName });
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -145,7 +182,7 @@ export function ResourceCanvasGenerationPanelView({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`关闭${panelTitle}`}
|
||||
onClick={onClose}
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -206,7 +243,7 @@ export function ResourceCanvasGenerationPanelView({
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
onClick={onClose}
|
||||
onClick={closeWithDraft}
|
||||
>
|
||||
{submitting ? '后台运行并关闭' : '取消'}
|
||||
</PlatformActionButton>
|
||||
@@ -250,7 +287,7 @@ export function ResourceCanvasGenerationPanelView({
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel={panelTitle}
|
||||
onClose={onClose}
|
||||
onClose={closeWithDraft}
|
||||
panelClassName="game-approval-dialog game-resource-generation-dialog"
|
||||
>
|
||||
{panelBody}
|
||||
|
||||
+4
@@ -154,6 +154,10 @@ export function createResourceCanvasAssetGenerationQueue(
|
||||
assetName: task.assetName,
|
||||
referenceAssetIds: task.referenceAssetIds,
|
||||
outputPath: task.outputPath,
|
||||
// 入口栏目:原生支持时按它登记归类;不支持时后端忽略,落点仍按正式归类走。
|
||||
...(task.targetCategory
|
||||
? { targetCategory: task.targetCategory }
|
||||
: {}),
|
||||
})) as LocalProjectAssetGenerationTaskRecord;
|
||||
let started: LocalProjectAssetGenerationTaskRecord;
|
||||
try {
|
||||
|
||||
+12
@@ -3,6 +3,7 @@ import {
|
||||
type ResourceCanvasAssetToolAction,
|
||||
resourceCanvasBottomToolActions,
|
||||
} from './resourceCanvasBottomToolbarModel';
|
||||
import type { ProjectResourceCanvasCategory } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
/** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */
|
||||
export type ResourceCanvasAssetGenerationTaskStatus =
|
||||
@@ -56,6 +57,14 @@ export type ResourceCanvasAssetGenerationTask = {
|
||||
* 没有这份本地草稿)按空列表读——账本不承诺回放当时的参考选择。
|
||||
*/
|
||||
referenceAssetIds: string[];
|
||||
/**
|
||||
* 这次的**入口栏目**:用户点工具时正在看的那个栏目。
|
||||
*
|
||||
* 原生侧按它把新素材直接登记进该栏目(`targetCategory`,可选入参);原生还没支持时它只是
|
||||
* 一条提示,落点仍按正式归类后的 section 走(见宿主落点 effect)。前端**不做**伪分类:
|
||||
* 归类真相只在 manifest 与原生命令里。
|
||||
*/
|
||||
targetCategory: ProjectResourceCanvasCategory | null;
|
||||
outputPath: string | null;
|
||||
projectId: string;
|
||||
/** 是否已经把这次提交交给后端。未派发的任务只活在本地队列里。 */
|
||||
@@ -165,6 +174,7 @@ export function createResourceCanvasAssetGenerationTask(input: {
|
||||
aspectRatio: string;
|
||||
imageSize: string;
|
||||
referenceAssetIds?: readonly string[];
|
||||
targetCategory?: ProjectResourceCanvasCategory | null;
|
||||
outputPath: string | null;
|
||||
projectId: string;
|
||||
nowMillis: number;
|
||||
@@ -188,6 +198,7 @@ export function createResourceCanvasAssetGenerationTask(input: {
|
||||
.filter((assetId) => assetId.length > 0),
|
||||
),
|
||||
],
|
||||
targetCategory: input.targetCategory ?? null,
|
||||
outputPath: input.outputPath,
|
||||
projectId: input.projectId,
|
||||
dispatched: false,
|
||||
@@ -220,6 +231,7 @@ export function restoreResourceCanvasAssetGenerationTask(
|
||||
aspectRatio: '',
|
||||
imageSize: '',
|
||||
referenceAssetIds: [],
|
||||
targetCategory: null,
|
||||
outputPath: null,
|
||||
projectId: record.projectId,
|
||||
dispatched: true,
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import type { CanvasViewport } from '../../../../../packages/image-canvas-core/src/types';
|
||||
import { normalizeResourceBookViewport } from '../../view/project-development/resourceBookViewport';
|
||||
|
||||
/**
|
||||
* 生成浮层/占位的可见性:把「占位卡 + 它下沿的浮层」整块带进画布安全区。
|
||||
*
|
||||
* 只做**最小平移**、不改缩放:用户的缩放预期不能被一次工具点击改掉。与既有
|
||||
* `ensureResourceBookContentVisible` / `centerResourceCanvasOnResource` 同一条口径,
|
||||
* 区别只在于这里要求内容**完整**落在安全区里、并且知道顶栏与底栏要留出来多少。
|
||||
*
|
||||
* 安全区 = 画布减去顶栏(栏目标题栏)与底栏(底部工具栏)后的区域:占位本来就可能被排到
|
||||
* 内容下方(真实浏览器 1280x720 上曾出现占位 y≈550、整块浮层落在可视区之外,用户只看到底栏)。
|
||||
*/
|
||||
export type ResourceCanvasGenerationSafeInsets = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
};
|
||||
|
||||
export function revealResourceCanvasGenerationContent({
|
||||
viewport,
|
||||
content,
|
||||
canvasSize,
|
||||
insets,
|
||||
padding = 12,
|
||||
}: {
|
||||
viewport: CanvasViewport;
|
||||
/** 需要完整可见的内容矩形(画布坐标):占位卡 + 浮层高度 + 两者之间的间隙。 */
|
||||
content: { x: number; y: number; width: number; height: number };
|
||||
canvasSize: { width: number; height: number };
|
||||
insets: ResourceCanvasGenerationSafeInsets;
|
||||
/** 安全区左右默认留白(顶/底由 insets 覆盖)。 */
|
||||
padding?: number;
|
||||
}): CanvasViewport {
|
||||
const current = normalizeResourceBookViewport(viewport);
|
||||
const finite =
|
||||
Number.isFinite(content.x) &&
|
||||
Number.isFinite(content.y) &&
|
||||
Number.isFinite(content.width) &&
|
||||
Number.isFinite(content.height) &&
|
||||
Number.isFinite(canvasSize.width) &&
|
||||
Number.isFinite(canvasSize.height) &&
|
||||
canvasSize.width > 0 &&
|
||||
canvasSize.height > 0;
|
||||
if (!finite || content.width <= 0 || content.height <= 0) {
|
||||
return current;
|
||||
}
|
||||
const safeLeft = Math.max(0, insets.left ?? padding);
|
||||
const safeTop = Math.max(0, insets.top);
|
||||
const safeRight = Math.max(
|
||||
safeLeft + 1,
|
||||
canvasSize.width - Math.max(0, insets.right ?? padding),
|
||||
);
|
||||
const safeBottom = Math.max(
|
||||
safeTop + 1,
|
||||
canvasSize.height - Math.max(0, insets.bottom),
|
||||
);
|
||||
const screenLeft = current.x + content.x * current.scale;
|
||||
const screenTop = current.y + content.y * current.scale;
|
||||
const screenWidth = Math.max(1, content.width * current.scale);
|
||||
const screenHeight = Math.max(1, content.height * current.scale);
|
||||
const screenRight = screenLeft + screenWidth;
|
||||
const screenBottom = screenTop + screenHeight;
|
||||
|
||||
// 水平:先按左边界对齐,右边越界再整体左推;内容比安全区还宽时以左边界为准。
|
||||
let dx = 0;
|
||||
if (screenRight > safeRight) {
|
||||
dx = safeRight - screenRight;
|
||||
}
|
||||
if (screenLeft + dx < safeLeft) {
|
||||
dx = safeLeft - screenLeft;
|
||||
}
|
||||
// 垂直:同理。占位被排到内容下方时要往上带,浮层顶到顶栏时要往下带。
|
||||
let dy = 0;
|
||||
if (screenBottom > safeBottom) {
|
||||
dy = safeBottom - screenBottom;
|
||||
}
|
||||
if (screenTop + dy < safeTop) {
|
||||
dy = safeTop - screenTop;
|
||||
}
|
||||
if (dx === 0 && dy === 0) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
scale: current.scale,
|
||||
x: current.x + dx,
|
||||
y: current.y + dy,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel';
|
||||
|
||||
const canvasSize = { width: 800, height: 600 };
|
||||
const insets = { top: 60, bottom: 90 };
|
||||
|
||||
describe('生成浮层与占位的可见性', () => {
|
||||
test('内容已经在安全区内时坐标不变(宿主据此跳过写回)', () => {
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
const next = revealResourceCanvasGenerationContent({
|
||||
viewport,
|
||||
content: { x: 40, y: 80, width: 180, height: 200 },
|
||||
canvasSize,
|
||||
insets,
|
||||
});
|
||||
expect(next).toEqual(viewport);
|
||||
});
|
||||
|
||||
test('内容落到视口下方时做最小上移,底边贴住底栏安全区', () => {
|
||||
const viewport = { x: 0, y: 0, scale: 1 };
|
||||
// 占位在 y=550、加上浮层后整块落到视口外——真实浏览器上复现过的那一档。
|
||||
const next = revealResourceCanvasGenerationContent({
|
||||
viewport,
|
||||
content: { x: 40, y: 550, width: 180, height: 320 },
|
||||
canvasSize,
|
||||
insets,
|
||||
});
|
||||
const safeBottom = canvasSize.height - insets.bottom;
|
||||
expect(viewport.y + (550 + 320)).toBeGreaterThan(safeBottom);
|
||||
expect(next.y + 550 + 320).toBe(safeBottom);
|
||||
expect(next.x).toBe(0);
|
||||
expect(next.scale).toBe(1);
|
||||
});
|
||||
|
||||
test('内容顶出顶栏时下移,内容越出左右边时做最小横移', () => {
|
||||
const next = revealResourceCanvasGenerationContent({
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
content: { x: -300, y: -40, width: 180, height: 120 },
|
||||
canvasSize,
|
||||
insets,
|
||||
});
|
||||
expect(next.y).toBe(insets.top + 40);
|
||||
expect(next.x).toBe(12 + 300);
|
||||
});
|
||||
|
||||
test('缩放参与换算:缩小后的越界按同一口径平移,比例保持不变', () => {
|
||||
const next = revealResourceCanvasGenerationContent({
|
||||
viewport: { x: 0, y: 0, scale: 0.5 },
|
||||
content: { x: 40, y: 900, width: 180, height: 320 },
|
||||
canvasSize,
|
||||
insets,
|
||||
});
|
||||
expect(next.scale).toBe(0.5);
|
||||
const safeBottom = canvasSize.height - insets.bottom;
|
||||
expect(next.y + (900 + 320) * 0.5).toBe(safeBottom);
|
||||
});
|
||||
|
||||
test('内容比安全区还大时以顶边 / 左边对齐,不来回抖', () => {
|
||||
const next = revealResourceCanvasGenerationContent({
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
content: { x: 0, y: 0, width: 2000, height: 1200 },
|
||||
canvasSize,
|
||||
insets,
|
||||
});
|
||||
expect(next).toEqual({ scale: 1, x: 12, y: insets.top });
|
||||
});
|
||||
|
||||
test('尺寸非法或画布未就绪时原样返回', () => {
|
||||
const viewport = { x: 5, y: 6, scale: 1 };
|
||||
expect(
|
||||
revealResourceCanvasGenerationContent({
|
||||
viewport,
|
||||
content: { x: 0, y: 0, width: 0, height: 0 },
|
||||
canvasSize,
|
||||
insets,
|
||||
}),
|
||||
).toEqual(viewport);
|
||||
expect(
|
||||
revealResourceCanvasGenerationContent({
|
||||
viewport,
|
||||
content: { x: 0, y: 0, width: 10, height: 10 },
|
||||
canvasSize: { width: 0, height: 0 },
|
||||
insets,
|
||||
}),
|
||||
).toEqual(viewport);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user