补齐画布验收第一批:润色回包提示、未提交草稿恢复与派生名预检
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m57s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m44s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m21s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 7m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m22s
Project CI / Native shell tests (pull_request) Successful in 10m13s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m57s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m44s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m21s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m27s
Project CI / Frontend tests (pull_request) Successful in 7m4s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m22s
Project CI / Native shell tests (pull_request) Successful in 10m13s
AI 润色回包与原文相同时给出明确提示,不再落下点了等于没点的「恢复原文」假入口 润色「与原文相同」的判据改为规范化之后要写回宿主的文本,回包被长度上限截回原文同样算没变化 共享聊天输入区显示润色提示,并让提示随用户手改提示词失效 快速编辑未提交草稿改按资源路径归属,收起面板或被旁路重投影后都不再静默丢掉 恢复入口按路径与本会话草稿对号,并区分「本会话未提交」与原生账本条目 账本读取失败时入口仍报出本会话草稿条数,「继续编辑」重开面板回填提示词与 @ 引用 派生资源名预检接入快速编辑与角色动画两条提交路径,早于正规化写盘 名称预检自身抛错时回写面板失败态,不再留下「点了没反应」的提交按钮 浮层关闭判定改在捕获阶段冻结,避免浮层内按钮卸载自己时整块面板被自己人收掉 恢复入口动作按钮收进统一容器,补移动端纵向排布与禁用态样式 空草稿表改工厂函数,跨语言对表用例按模块路径解析 Rust 源码 补回归用例:旁路重投影后的草稿、原样回包提示、提交前名称拦截、草稿表读写与跨卡隔离 同步 decision-log 与画布验收待办的进展说明
This commit is contained in:
+24
-2
@@ -758,8 +758,15 @@ function ResourceReferenceEditor({
|
||||
const reminderDisabledRef = useRef(reminderDisabled);
|
||||
reminderDisabledRef.current = reminderDisabled;
|
||||
|
||||
/**
|
||||
* 我们自己回填进草稿的那一份文本,用于区分「润色回填」与「用户手改」:
|
||||
* 只有后者该把上一轮往返留下的提示(截断 / 与原文相同)收掉,
|
||||
* 否则刚显示出来的提示会被自己的回填立刻清掉。
|
||||
*/
|
||||
const appliedPromptRef = useRef<string | null>(null);
|
||||
const applyPromptText = useCallback(
|
||||
(text: string) => {
|
||||
appliedPromptRef.current = text;
|
||||
flushSync(() => {
|
||||
onChange({ text, references: liveDraftRef.current.references });
|
||||
});
|
||||
@@ -781,13 +788,25 @@ function ResourceReferenceEditor({
|
||||
const {
|
||||
polishing,
|
||||
error: polishError,
|
||||
notice: polishNotice,
|
||||
originalText: polishedOriginalText,
|
||||
polish: runPolish,
|
||||
restoreOriginal: restoreOriginalPrompt,
|
||||
clearError: clearPolishError,
|
||||
clearNotice: clearPolishNotice,
|
||||
reset: resetPromptPolish,
|
||||
} = promptPolishState;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
appliedPromptRef.current !== null &&
|
||||
value === appliedPromptRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
clearPolishNotice();
|
||||
}, [clearPolishNotice, value]);
|
||||
|
||||
const polishPrompt = useCallback(async () => {
|
||||
await runPolish();
|
||||
}, [runPolish]);
|
||||
@@ -1082,13 +1101,16 @@ function ResourceReferenceEditor({
|
||||
) : null}
|
||||
</div>
|
||||
{/* 提醒面板打开时错误提示只在面板里出现,输入区不重复显示。 */}
|
||||
{showPolishAction && !reminderOpen && (polishing || polishError) ? (
|
||||
{/* 「与原文相同 / 已截断」这类提示也要可见:只报失败会让「润色没变化」看起来像按钮坏了。 */}
|
||||
{showPolishAction &&
|
||||
!reminderOpen &&
|
||||
(polishing || polishError || polishNotice) ? (
|
||||
<span
|
||||
className="resource-reference-input-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{polishing ? '润色中…' : polishError}
|
||||
{polishing ? '润色中…' : (polishError ?? polishNotice)}
|
||||
</span>
|
||||
) : null}
|
||||
<LexicalTypeaheadMenuPlugin<ResourceMentionOption>
|
||||
|
||||
@@ -2,6 +2,15 @@ import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { requestChatPromptPolish } from './chatPromptPolish';
|
||||
|
||||
/**
|
||||
* 回包与原文一字不差时的提示语。
|
||||
*
|
||||
* 平台侧有时会把同一句话原样还回来(AGC-004:快速编辑里点「AI 润色」后文案毫无变化,
|
||||
* 用户以为按钮没反应或已经改过)。这种情况必须给一条说得清的提示,而且**不能**落下
|
||||
* 原文快照——没有可回退的变化,就不该冒出「恢复原文」这种假入口。
|
||||
*/
|
||||
export const PROMPT_POLISH_UNCHANGED_NOTICE = 'AI 润色结果与原文相同,未做修改';
|
||||
|
||||
/**
|
||||
* 润色回填前的规范化结果。
|
||||
*
|
||||
@@ -41,14 +50,19 @@ export type UsePromptPolishOptions = {
|
||||
export type UsePromptPolishResult = {
|
||||
polishing: boolean;
|
||||
error: string | null;
|
||||
/** 最近一次成功回填的截断提示;没有截断时为 null。 */
|
||||
/** 最近一次成功回包的提示:截断说明,或「与原文相同,未做修改」;两者都没有时为 null。 */
|
||||
notice: string | null;
|
||||
/** 首次成功润色时落下的原文快照;非空时宿主渲染「恢复原文」。 */
|
||||
/**
|
||||
* 首次**真的改动了文本**的润色时落下的原文快照;非空时宿主渲染「恢复原文」。
|
||||
* 回包与原文相同的那些次不落快照(没什么可恢复的)。
|
||||
*/
|
||||
originalText: string | null;
|
||||
/** 润色并在成功时回填,返回回填后的文本;失败返回 null 并保留原文。 */
|
||||
polish: (options?: PromptPolishRunOptions) => Promise<string | null>;
|
||||
restoreOriginal: () => void;
|
||||
clearError: () => void;
|
||||
/** 用户自己改了提示词:把上一轮往返留下的提示(截断 / 与原文相同)收掉。 */
|
||||
clearNotice: () => void;
|
||||
/** 清掉往返状态(草稿清空、面板换资源时用)。 */
|
||||
reset: () => void;
|
||||
};
|
||||
@@ -56,6 +70,10 @@ export type UsePromptPolishResult = {
|
||||
/**
|
||||
* 提示词润色的状态机:失败保留原文、首次成功落原文快照、反复润色只覆盖结果。
|
||||
*
|
||||
* 成功但回包与原文逐字相同(规范化前后都没变)时不算「润色出了新东西」:不落原文快照、
|
||||
* 不回填宿主状态,只给 {@link PROMPT_POLISH_UNCHANGED_NOTICE} 这条明确反馈,
|
||||
* 免得用户把「按钮点了没反应」误当成功能坏了、或者以为文案已经被改过。
|
||||
*
|
||||
* 聊天输入区与资源侧(生成素材 / 快速编辑)共用这一份;它与界面无关,也不碰共享组件,
|
||||
* 宿主自己决定文本存在哪里、怎么回填。Tauri 调用固定在 AGC 侧
|
||||
* (`requestChatPromptPolish`),共享 composer 只接一个可选的注入位。
|
||||
@@ -108,6 +126,18 @@ export function usePromptPolish({
|
||||
const normalized = normalizeResult
|
||||
? normalizeResult(polished)
|
||||
: { text: polished };
|
||||
if (normalized.text === prompt) {
|
||||
// 最终要写回宿主的文本与当前文本逐字相同:既没有新内容可回填,也没有可回退的
|
||||
// 变化,因此不落原文快照、不写宿主状态——`applyPrompt` 在宿主侧还有「提示词变了
|
||||
// 就重铸请求身份」这类副作用,回填一份没变的文本会平白作废一次请求身份。
|
||||
//
|
||||
// 判据必须是**规范化之后**的文本,不能只看回包:润色结果被按长度上限截回原文时
|
||||
// (`truncateResourceEditPrompt` 是切片不是 trim),回包与原文不同、写回去却一字
|
||||
// 未变,那会渲染出一枚点了等于没点的「恢复原文」,正是要消灭的那种假入口。
|
||||
// 截断提示优先——它解释的是「这次为什么没变」。
|
||||
setNotice(normalized.notice ?? PROMPT_POLISH_UNCHANGED_NOTICE);
|
||||
return normalized.text;
|
||||
}
|
||||
// 原文快照只在第一次成功润色时落下,因此「恢复原文」永远回到最初原文。
|
||||
setOriginalText((current) => current ?? prompt);
|
||||
setNotice(normalized.notice ?? null);
|
||||
@@ -150,6 +180,10 @@ export function usePromptPolish({
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const clearNotice = useCallback(() => {
|
||||
setNotice(null);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
// 作废在飞请求:宿主清空草稿 / 换资源后,迟到的润色结果不许再回填。
|
||||
requestIdRef.current += 1;
|
||||
@@ -166,6 +200,7 @@ export function usePromptPolish({
|
||||
polish,
|
||||
restoreOriginal,
|
||||
clearError,
|
||||
clearNotice,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
+26
-1
@@ -1,4 +1,5 @@
|
||||
import { Loader2, RotateCcw, Sparkles } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import {
|
||||
type LocalProjectResourceEditKind,
|
||||
@@ -30,14 +31,38 @@ export function ResourcePromptPolishSlot({
|
||||
disabled = false,
|
||||
applyPrompt,
|
||||
}: ResourcePromptPolishSlotProps) {
|
||||
/**
|
||||
* 我们自己写回去的那一份文本。用于区分「润色回填导致的 prop 变化」与「用户手改」:
|
||||
* 只有后者该把上一轮往返留下的提示收掉,否则刚显示出来的提示会被自己的回填清掉。
|
||||
*/
|
||||
const appliedPromptRef = useRef<string | null>(null);
|
||||
// 宿主回调存 ref:包一层只为记账,不该让这份包装每次渲染都换身份、
|
||||
// 进而把 `usePromptPolish` 的 `polish` 也跟着重造。
|
||||
const applyPromptRef = useRef(applyPrompt);
|
||||
applyPromptRef.current = applyPrompt;
|
||||
const applyPromptAndTrack = useCallback((text: string) => {
|
||||
appliedPromptRef.current = text;
|
||||
applyPromptRef.current(text);
|
||||
}, []);
|
||||
const polish = usePromptPolish({
|
||||
readPrompt: () => prompt,
|
||||
applyPrompt,
|
||||
applyPrompt: applyPromptAndTrack,
|
||||
canPolish: () => !disabled,
|
||||
resolveContext: () => resourceAssetPromptPolishContext(subject),
|
||||
normalizeResult: (polished) =>
|
||||
truncateResourceEditPrompt(polished, editKind),
|
||||
});
|
||||
const { clearNotice } = polish;
|
||||
useEffect(() => {
|
||||
if (
|
||||
appliedPromptRef.current !== null &&
|
||||
prompt === appliedPromptRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 手改过提示词:上一轮「与原文相同 / 已截断」的结论不再描述当前这段文本。
|
||||
clearNotice();
|
||||
}, [clearNotice, prompt]);
|
||||
const statusText = polish.polishing
|
||||
? '润色中…'
|
||||
: (polish.error ?? polish.notice);
|
||||
|
||||
+172
@@ -1,6 +1,7 @@
|
||||
import type { CanvasLayer } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
import type { QuickEditPanelState } from '../../../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
import { createQuickEditPanelDraft } from '../../../../../src/components/image-editor/ImageCanvasGenerationDialogModel';
|
||||
import type { ChatReference } from '../project-workspace/resourceReferences';
|
||||
|
||||
/**
|
||||
* AGC 资源卡快速编辑面板的初始草稿。
|
||||
@@ -13,3 +14,174 @@ export function createResourceQuickEditPanelDraft(
|
||||
): QuickEditPanelState {
|
||||
return createQuickEditPanelDraft(sourceLayer, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份还没提交的快速编辑草稿。
|
||||
*
|
||||
* 这是**宿主会话内存**里的一笔记录,不是正式业务状态:不落盘、不进资源编辑账本、
|
||||
* 不参与对账。它存在的唯一理由是面板被点外部 / Esc 收起、或用户切到另一张素材卡之后,
|
||||
* 用户刚写的提示词与 `@` 引用还能原样回到面板里;正式的可恢复事实仍然只由原生
|
||||
* `list_pending_local_project_resource_edits` / `resume_local_project_resource_edit`
|
||||
* 那条既有账本链路负责。
|
||||
*/
|
||||
export type ResourceQuickEditDraft = {
|
||||
/** 提示词原文(含 `@显示名` 字面量,与聊天同源)。 */
|
||||
readonly prompt: string;
|
||||
/** 与提示词配套的 `@` 引用,回填输入区 chip。 */
|
||||
readonly references: readonly ChatReference[];
|
||||
/** 最后一次改动时间(恢复列表按新→旧排序)。 */
|
||||
readonly updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源**路径** → 未提交草稿。
|
||||
*
|
||||
* 键必须是投影的稳定身份,也就是 `ProjectResource.path`:资源投影本来就按 `path` 去重
|
||||
* (`resourceProjectionModel` 的 `uniqueByPath`),而资源 ID 会变——任务产物被正规化登记成
|
||||
* 正式素材后,同一张卡会从 `task:<任务>:<路径>` 变成 `asset:<id>`。拿 ID 当键时,只要重投影
|
||||
* 不是「打开中这一笔快速编辑」自己触发(换素材卡收起面板、Agent 或外部编辑器把同一路径登记成
|
||||
* 资产),草稿就会挂在一个再也点不到的键上:恢复入口按 id 找不到而把它静默过滤,重开面板也按
|
||||
* 新卡 id 查不到,用户刚写的提示词凭空消失。
|
||||
*/
|
||||
export type ResourceQuickEditDraftStore = ReadonlyMap<
|
||||
string,
|
||||
ResourceQuickEditDraft
|
||||
>;
|
||||
|
||||
/**
|
||||
* 空草稿表:只在项目身份变化时用它整体清空。
|
||||
*
|
||||
* 走工厂而不是导出一个共享常量:这个表会直接交给 `useState`,一旦哪天有人改成原地
|
||||
* `set` / `delete`,模块级单例就会跨面板、跨项目串味。每次调用都拿到一张新表。
|
||||
*/
|
||||
export function createEmptyResourceQuickEditDraftStore(): ResourceQuickEditDraftStore {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
/** 提示词与引用都空才算「没有草稿」,不留一条空记录去占用恢复入口。 */
|
||||
export function isResourceQuickEditDraftEmpty(draft: {
|
||||
readonly prompt: string;
|
||||
readonly references: readonly unknown[];
|
||||
}): boolean {
|
||||
return draft.prompt.trim().length === 0 && draft.references.length === 0;
|
||||
}
|
||||
|
||||
export function readResourceQuickEditDraft(
|
||||
store: ResourceQuickEditDraftStore,
|
||||
key: string | null | undefined,
|
||||
): ResourceQuickEditDraft | undefined {
|
||||
return key ? store.get(key) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一笔草稿补丁。
|
||||
*
|
||||
* - 只给 `prompt` 或只给 `references` 时,另一头沿用该键下既有记录(输入区两条
|
||||
* 状态各自上报,谁都不该把对方清掉);
|
||||
* - 合并结果为空就不留记录,避免恢复入口里堆一条点开空面板的条目。
|
||||
*
|
||||
* 没有「换键」这一步:键是资源路径,正规化把卡片换成 `asset:…` 也不改路径,草稿不需要
|
||||
* 跟着投影搬家(换键逻辑本身正是「搬家搬丢了」的来源)。
|
||||
*/
|
||||
export function writeResourceQuickEditDraft(
|
||||
store: ResourceQuickEditDraftStore,
|
||||
input: {
|
||||
readonly key: string;
|
||||
readonly prompt?: string;
|
||||
readonly references?: readonly ChatReference[];
|
||||
readonly updatedAt: number;
|
||||
},
|
||||
): ResourceQuickEditDraftStore {
|
||||
const current = store.get(input.key);
|
||||
const next: ResourceQuickEditDraft = {
|
||||
prompt: input.prompt ?? current?.prompt ?? '',
|
||||
references: input.references ?? current?.references ?? [],
|
||||
updatedAt: input.updatedAt,
|
||||
};
|
||||
const result = new Map(store);
|
||||
if (isResourceQuickEditDraftEmpty(next)) {
|
||||
result.delete(input.key);
|
||||
return result;
|
||||
}
|
||||
result.set(input.key, next);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 恢复入口里的一行未提交草稿。 */
|
||||
export type ResourceQuickEditDraftEntry = {
|
||||
/** 资源路径——`writeResourceQuickEditDraft` 的键,也是回填 / 丢弃时要用的键。 */
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly prompt: string;
|
||||
readonly updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 恢复入口里的未提交草稿:只列**此刻还在当前投影里**的素材(按路径匹配,与卡片 id 无关),
|
||||
* 按最后改动时间从新到旧。
|
||||
*
|
||||
* 资源已经不在项目里的草稿没有可继续的落点,不该继续占着入口;但表里那份记录不在这里删,
|
||||
* 删了就分不清「资源暂时不可见」和「用户已经不要这笔草稿」——由「继续编辑」与用户显式
|
||||
* 丢弃两处负责清理。
|
||||
*/
|
||||
export function listResourceQuickEditDraftEntries(
|
||||
store: ResourceQuickEditDraftStore,
|
||||
resources: readonly {
|
||||
readonly path: string;
|
||||
readonly label: string;
|
||||
}[],
|
||||
): ResourceQuickEditDraftEntry[] {
|
||||
const labelByPath = new Map(
|
||||
resources.map((resource) => [resource.path, resource.label] as const),
|
||||
);
|
||||
const entries: ResourceQuickEditDraftEntry[] = [];
|
||||
for (const [key, draft] of store) {
|
||||
const label = labelByPath.get(key);
|
||||
if (label === undefined) {
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
key,
|
||||
label,
|
||||
prompt: draft.prompt,
|
||||
updatedAt: draft.updatedAt,
|
||||
});
|
||||
}
|
||||
return entries.sort((left, right) => right.updatedAt - left.updatedAt);
|
||||
}
|
||||
|
||||
/** 成功提交 / 用户显式丢弃后抹掉这笔草稿。 */
|
||||
export function dropResourceQuickEditDraft(
|
||||
store: ResourceQuickEditDraftStore,
|
||||
key: string | null | undefined,
|
||||
): ResourceQuickEditDraftStore {
|
||||
if (!key || !store.has(key)) {
|
||||
return store;
|
||||
}
|
||||
const result = new Map(store);
|
||||
result.delete(key);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把记录下来的草稿盖回刚造出来的面板草稿:模型 / 比例 / 尺寸等仍取当下的默认值,
|
||||
* 只有用户真正改过的提示词与引用回填,避免用一份过期面板把当前默认档位也一起还原。
|
||||
*/
|
||||
export function restoreResourceQuickEditPanelDraft(
|
||||
panelDraft: QuickEditPanelState,
|
||||
draft: ResourceQuickEditDraft | undefined,
|
||||
): QuickEditPanelState {
|
||||
return draft ? { ...panelDraft, prompt: draft.prompt } : panelDraft;
|
||||
}
|
||||
|
||||
/** 恢复入口里的提示词摘要:只做一行展示,不改变原草稿内容。 */
|
||||
export function resourceQuickEditDraftPromptExcerpt(
|
||||
prompt: string,
|
||||
limit = 48,
|
||||
): string {
|
||||
const text = prompt.trim().replace(/\s+/gu, ' ');
|
||||
if (!text) {
|
||||
return '(尚未填写提示词)';
|
||||
}
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
||||
}
|
||||
|
||||
@@ -9590,7 +9590,7 @@ iframe.preview-frame {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > div {
|
||||
.game-resource-recovery-item > div:first-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
@@ -9606,7 +9606,18 @@ iframe.preview-frame {
|
||||
color: #9a7d70;
|
||||
}
|
||||
|
||||
/*
|
||||
* 一行里的多枚动作收在一个容器里:条目是 `space-between` 的 flex,动作不收拢就会被
|
||||
* 摊到中间,和原生条目右对齐的单枚动作对不齐(移动端尤其明显)。
|
||||
*/
|
||||
.game-resource-recovery-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button,
|
||||
.game-resource-recovery-actions > button,
|
||||
.game-resource-recovery-error > button {
|
||||
min-height: 34px;
|
||||
padding: 0 13px;
|
||||
@@ -9618,6 +9629,7 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button:disabled,
|
||||
.game-resource-recovery-actions > button:disabled,
|
||||
.game-resource-recovery-error > button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.56;
|
||||
@@ -9652,9 +9664,14 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-resource-recovery-item > button,
|
||||
.game-resource-recovery-actions,
|
||||
.game-resource-recovery-error > button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.game-resource-recovery-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.game-approval-dialog > header button {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -208,8 +208,122 @@ export function resourceBaseName(
|
||||
return resource.label.replace(/\.[^.]+$/u, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生资源名上限:与 Rust `normalize_resource_edit_name`
|
||||
* (`src-tauri/src/project/resource_editor.rs:802-808`)逐值同口径。
|
||||
*/
|
||||
export const RESOURCE_EDIT_NAME_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* 名称不合法时给用户看的那一句:与 Rust 的返回文案逐字一致(同文件 :805)。
|
||||
*
|
||||
* 前端只做镜像、不另造一套中文语义:提交前拦住和提交后失败看到的是同一句话,
|
||||
* 用户不必靠比对两边措辞去猜这是不是同一个问题。
|
||||
*/
|
||||
export const RESOURCE_EDIT_NAME_INVALID_NOTICE =
|
||||
'派生资源名称必须在 1..=120 字符内且不能包含控制字符';
|
||||
|
||||
/**
|
||||
* 名称检查结果:只有 `error === null` 才允许进入生成请求。
|
||||
*
|
||||
* 不合法时 `name` 原样保留(已 trim),**绝不返回截断名**:超长名截到 120 会让两个不同的
|
||||
* 长源名塌成同一个名字(同前缀的两张图就是这种情况),那是把一次可见失败换成一次静默重名。
|
||||
*/
|
||||
export type ResourceEditNameCheck = {
|
||||
name: string;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按 Unicode 码点算长度:Rust 侧是 `value.chars().count()`(同文件 :804),
|
||||
* 不是 JS 的 UTF-16 `.length`——`'😀'.repeat(120)` 的 `.length` 是 240,码点数才是 120。
|
||||
* 用 `.length` 会把 Rust 明确接受的名字在前端误判成非法。
|
||||
*/
|
||||
function resourceNameLength(value: string) {
|
||||
return [...value].length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust `char::is_control()` 的口径:Unicode 通用类别 `Cc`,即 C0(U+0000–U+001F)、
|
||||
* DEL(U+007F)与 C1(U+0080–U+009F)。
|
||||
*
|
||||
* 用码点比较而不是控制字符正则:`Cc` 之外(例如 `Cf` 格式符)Rust 是放行的,前端也不额外
|
||||
* 收窄——这份实现的职责是镜像,不是借机加一条客户端自有规则。
|
||||
*/
|
||||
function isResourceNameControlCodePoint(codePoint: number) {
|
||||
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
}
|
||||
|
||||
function resourceNameHasControlCharacter(value: string) {
|
||||
for (const character of value) {
|
||||
if (isResourceNameControlCodePoint(character.codePointAt(0) ?? 0)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前按 Rust 的同一条规则检查派生资源名:先 `trim`,再要求非空、码点数 1..=120,
|
||||
* 且不含控制字符。首尾的换行 / 制表符会被 `trim` 掉,因此不算违规——与 Rust 逐条一致。
|
||||
*
|
||||
* 调用方拿到 `error` 非空时必须在发请求**之前**停下并把它显示给用户(不合法时 `name`
|
||||
* 只是 trim 后的原样名,不能拿它去提交)。
|
||||
*/
|
||||
export function resolveResourceEditNameCheck(
|
||||
name: string,
|
||||
): ResourceEditNameCheck {
|
||||
const trimmed = name.trim();
|
||||
if (
|
||||
trimmed.length === 0 ||
|
||||
resourceNameLength(trimmed) > RESOURCE_EDIT_NAME_MAX_LENGTH ||
|
||||
resourceNameHasControlCharacter(trimmed)
|
||||
) {
|
||||
return { name: trimmed, error: RESOURCE_EDIT_NAME_INVALID_NOTICE };
|
||||
}
|
||||
return { name: trimmed, error: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 现役两种派生资源名后缀:快速编辑产出「-编辑版」,角色动画产出「-角色动画」。
|
||||
*
|
||||
* 后缀表是唯一来源,默认名与提交前检查共用它,避免「-编辑版」与「-角色动画」两条路径
|
||||
* 再次各自演化出不同的拼接口径。
|
||||
*/
|
||||
export const DERIVED_RESOURCE_NAME_SUFFIXES = {
|
||||
edit: '-编辑版',
|
||||
'character-animation': '-角色动画',
|
||||
} as const;
|
||||
|
||||
export type DerivedResourceNameKind =
|
||||
keyof typeof DERIVED_RESOURCE_NAME_SUFFIXES;
|
||||
|
||||
/**
|
||||
* 派生资源名的默认值 + 校验:去扩展名的基名(空则兜底「资源」)拼上对应后缀再检查。
|
||||
*
|
||||
* 后缀本身只有 4~5 字,所以超限几乎总是源名太长(例如 Agent 回执的 label 直接取任务标题)
|
||||
* 或源名里带控制字符(任务标题里的换行就是)。
|
||||
*/
|
||||
export function resolveDerivedResourceNameCheck(
|
||||
resource: ProjectResource,
|
||||
kind: DerivedResourceNameKind,
|
||||
): ResourceEditNameCheck {
|
||||
const base = resourceBaseName(resource) || '资源';
|
||||
return resolveResourceEditNameCheck(
|
||||
`${base}${DERIVED_RESOURCE_NAME_SUFFIXES[kind]}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速编辑派生资源的默认名称。
|
||||
*
|
||||
* 只负责拼名字,不校验也不截断:名字是否能在 Rust 侧通过由
|
||||
* `resolveDerivedResourceNameCheck(resource, 'edit')` 回答,调用方必须在提交前用它拦下
|
||||
* 不可用的名字。这里保持返回不合法原名(而不是悄悄截到 120),是为了让上层的检查
|
||||
* 拿到真实长度并给出提示。
|
||||
*/
|
||||
export function defaultDerivedResourceName(resource: ProjectResource) {
|
||||
return `${resourceBaseName(resource) || '资源'}-编辑版`;
|
||||
return resolveDerivedResourceNameCheck(resource, 'edit').name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,11 +331,14 @@ export function defaultDerivedResourceName(resource: ProjectResource) {
|
||||
*
|
||||
* 与 `defaultDerivedResourceName` 同一条口径(去扩展名 + 兜底名),只换后缀:动作序列帧
|
||||
* 不是"编辑版",用「-角色动画」让新素材在资源卡上一眼认出来源与产出类型。
|
||||
*
|
||||
* 同样只是默认名,提交前必须走
|
||||
* `resolveDerivedResourceNameCheck(resource, 'character-animation')`。
|
||||
*/
|
||||
export function defaultCharacterAnimationResourceName(
|
||||
resource: ProjectResource,
|
||||
) {
|
||||
return `${resourceBaseName(resource) || '资源'}-角色动画`;
|
||||
return resolveDerivedResourceNameCheck(resource, 'character-animation').name;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -291,6 +291,22 @@ describe('聊天输入区 AI 润色与发送前提醒', () => {
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
});
|
||||
|
||||
test('回包与原文相同时聊天输入区也给提示,且不冒出「恢复原文」', async () => {
|
||||
const user = userEvent.setup();
|
||||
installPolishingInvoke('原本的需求');
|
||||
renderComposer({ initialText: '原本的需求' });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI 润色' }));
|
||||
|
||||
// 聊天输入区与资源侧共用 `usePromptPolish`:修好状态机还不够,共享输入区必须把
|
||||
// notice 渲染出来,否则这三个宿主里「点了没反应」看起来还是按钮坏了。
|
||||
expect(
|
||||
await screen.findByText('AI 润色结果与原文相同,未做修改'),
|
||||
).not.toBeNull();
|
||||
expect(await composerText()).toBe('原本的需求');
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
});
|
||||
|
||||
test('holds a long prompt behind the reminder panel and sends the original on demand', async () => {
|
||||
const onSubmitDraft = renderComposer({ initialText: LONG_PROMPT });
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../../../src/components/image-editor/ImageCanvasGenerationModel';
|
||||
import { resourceReferenceFromAsset } from '../src/features/project-workspace/resourceReferences';
|
||||
import ProjectDevelopmentView from '../src/view/project-development';
|
||||
import { RESOURCE_EDIT_NAME_INVALID_NOTICE } from '../src/view/project-development/resourceEditModel';
|
||||
import {
|
||||
act,
|
||||
createGameCreationAppManifest,
|
||||
@@ -259,9 +260,17 @@ function ClassificationWorkbench() {
|
||||
function DerivedWorkbench({
|
||||
includeArt = false,
|
||||
includeCharacter = false,
|
||||
artName = 'source-art.png',
|
||||
characterName = 'hero.png',
|
||||
}: {
|
||||
includeArt?: boolean;
|
||||
includeCharacter?: boolean;
|
||||
/**
|
||||
* 源文件名可替换:派生资源名按源资源 label 拼出,用长名夹具复现
|
||||
* `normalize_resource_edit_name` 的 120 码点门禁(AGC-025)。
|
||||
*/
|
||||
artName?: string;
|
||||
characterName?: string;
|
||||
}) {
|
||||
const initial = createGameCreationAppManifest(
|
||||
'live-canvas-project',
|
||||
@@ -281,7 +290,7 @@ function DerivedWorkbench({
|
||||
id: 'source-art',
|
||||
kind: 'art-image',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/source-art.png',
|
||||
localPath: `assets/${artName}`,
|
||||
source: { kind: 'generated', resourceId: 'art-resource' },
|
||||
},
|
||||
]
|
||||
@@ -293,7 +302,7 @@ function DerivedWorkbench({
|
||||
kind: 'character',
|
||||
category: 'character' as const,
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
localPath: `assets/${characterName}`,
|
||||
source: { kind: 'generated', resourceId: 'hero-resource' },
|
||||
},
|
||||
]
|
||||
@@ -840,6 +849,8 @@ describe('project resource live canvas integration', () => {
|
||||
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
|
||||
expect(deriveCalls[0]?.editKind).toBe('image-reference');
|
||||
expect(deriveCalls[0]?.generationMode).toBe('derive');
|
||||
// 名字合法时出站 assetName 就是提交前校验过的那个名字(默认名,逐字未改)。
|
||||
expect(deriveCalls[0]?.assetName).toBe('source-art-编辑版');
|
||||
expect(deriveCalls[0]).not.toHaveProperty('accessToken');
|
||||
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
|
||||
expect(deriveCalls[1]).not.toHaveProperty('accessToken');
|
||||
@@ -891,6 +902,71 @@ describe('project resource live canvas integration', () => {
|
||||
expect(deriveCalls[0]).not.toHaveProperty('apiKey');
|
||||
});
|
||||
|
||||
/**
|
||||
* AGC-025:派生资源名由源资源 label 拼出,Rust `normalize_resource_edit_name`
|
||||
* (`resource_editor.rs:802-808`)对「超 120 码点 / 含控制字符 / trim 后为空」的名字整条
|
||||
* 拒绝。前端必须在**任何正规化写盘与派生请求之前**用同一句中文回写面板失败态,否则用户得
|
||||
* 提交一次才知道名字不合法,项目里还可能先多出一条正规化素材。
|
||||
*/
|
||||
it('快速编辑:源名超 120 码点时在派生请求之前拦下,并回写面板错误态', async () => {
|
||||
const longName = `${'生成角色正面半身战斗姿态设定稿'.repeat(8)}.png`;
|
||||
const { invoke, deriveCalls } = installTauri();
|
||||
render(<DerivedWorkbench includeArt artName={longName} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开待归类' }));
|
||||
fireEvent.click(await findResourceSelectButton(longName));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
|
||||
const panel = await screen.findByRole('dialog', { name: '快速编辑图片' });
|
||||
await setComposerText(
|
||||
within(panel).getByLabelText('快速编辑提示词'),
|
||||
'把角色头发设定改为红色',
|
||||
);
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '修改' }));
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
);
|
||||
// 零写盘、零远程请求:派生请求一次没发,正规化也没有被调用。
|
||||
expect(deriveCalls).toHaveLength(0);
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'normalize_local_project_raster_resource',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('角色动画:源名超 120 码点时同样在派生请求之前拦下', async () => {
|
||||
const longName = `${'生成角色正面半身战斗姿态设定稿'.repeat(8)}.png`;
|
||||
const { invoke, deriveCalls } = installTauri();
|
||||
render(<DerivedWorkbench includeCharacter characterName={longName} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' }));
|
||||
fireEvent.click(await findResourceSelectButton(longName));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '生成动画' }));
|
||||
const panel = await screen.findByRole('dialog', {
|
||||
name: '角色动画生成面板',
|
||||
});
|
||||
await setComposerText(
|
||||
within(panel).getByLabelText('动画描述'),
|
||||
'角色挥手打招呼',
|
||||
);
|
||||
fireEvent.click(
|
||||
within(panel).getByRole('button', { name: /生成[\d.]+泥点/ }),
|
||||
);
|
||||
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
);
|
||||
expect(deriveCalls).toHaveLength(0);
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'normalize_local_project_raster_resource',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('快速编辑里润色提示词:带场景约束回填,提示词变了就换请求身份', async () => {
|
||||
const { deriveCalls, polishCalls } = installTauri({
|
||||
failFirstDerive: true,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { CanvasLayer } from '@genarrative/image-canvas-core';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { QuickEditPanelState } from '../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
@@ -407,6 +408,211 @@ describe('资源画布浮层关闭时机', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 「恢复原文」的形状:浮层里的按钮在**这一次点击**里把自己卸掉
|
||||
* (原文快照被消费 ⇒ 按钮不再渲染)。AGC-005 的验收视频里,这一下把整个快速编辑面板
|
||||
* 连同画布选中一起收掉了。
|
||||
*
|
||||
* 这里用 React 的 `onClick`(应用里的真实形状)复现。
|
||||
*/
|
||||
function SelfUnmountingOverlayHarness({
|
||||
onDismiss,
|
||||
}: {
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const [restoreVisible, setRestoreVisible] = useState(true);
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: panelOpen,
|
||||
boundaryRefs: [canvasRef],
|
||||
onDismiss: () => {
|
||||
onDismiss();
|
||||
setPanelOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} data-testid="self-unmount-canvas">
|
||||
{panelOpen ? (
|
||||
<div role="dialog" aria-label="快速编辑浮层面板">
|
||||
{restoreVisible ? (
|
||||
<button type="button" onClick={() => setRestoreVisible(false)}>
|
||||
恢复原文
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一种形状的**同步**版本:目标节点在自己的原生 click 监听里被 `remove()`。
|
||||
*
|
||||
* 边界判定必须在事件派发最早期完成——等冒泡到 `document` 再拿 `event.target` 现算,
|
||||
* 节点已经脱离 DOM,`element.contains(target)` 恒为 false,「点浮层内部」就被误判成
|
||||
* 「点外部」,面板被自己的按钮关掉。这条用例是 AGC-005 的回归判据:
|
||||
* 冻结判定接上之前它是红的。
|
||||
*/
|
||||
function NativeSelfRemovingOverlayHarness({
|
||||
onDismiss,
|
||||
}: {
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: panelOpen,
|
||||
boundaryRefs: [canvasRef],
|
||||
onDismiss: () => {
|
||||
onDismiss();
|
||||
setPanelOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const button = buttonRef.current;
|
||||
if (!button) {
|
||||
return undefined;
|
||||
}
|
||||
const handleClick = () => {
|
||||
button.remove();
|
||||
};
|
||||
button.addEventListener('click', handleClick);
|
||||
return () => button.removeEventListener('click', handleClick);
|
||||
}, [panelOpen]);
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} data-testid="native-self-unmount-canvas">
|
||||
{panelOpen ? (
|
||||
<div role="dialog" aria-label="快速编辑浮层面板">
|
||||
<button ref={buttonRef} type="button">
|
||||
恢复原文
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 宿主自己 portal 到 `body` 的浮层(`@` 资源选择器 / 候选菜单)由 `isInsideExtraOverlay`
|
||||
* 登记;菜单项点完即卸载时同样不能被判成点外部——portal 语义必须与边界 DOM 一视同仁。
|
||||
*/
|
||||
function ExtraOverlaySelfRemovalHarness({
|
||||
onDismiss,
|
||||
}: {
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
useImageCanvasFloatingOptionDismiss({
|
||||
isOpen: panelOpen,
|
||||
boundaryRefs: [canvasRef],
|
||||
onDismiss: () => {
|
||||
onDismiss();
|
||||
setPanelOpen(false);
|
||||
},
|
||||
isInsideExtraOverlay: (target) =>
|
||||
target instanceof Element &&
|
||||
target.closest('.resource-reference-picker') !== null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const item = itemRef.current;
|
||||
if (!item) {
|
||||
return undefined;
|
||||
}
|
||||
const handleClick = () => {
|
||||
item.remove();
|
||||
};
|
||||
item.addEventListener('click', handleClick);
|
||||
return () => item.removeEventListener('click', handleClick);
|
||||
}, [panelOpen]);
|
||||
|
||||
if (!panelOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={canvasRef} data-testid="registered-overlay-canvas">
|
||||
<div role="dialog" aria-label="快速编辑浮层面板" />
|
||||
</div>
|
||||
{createPortal(
|
||||
<div className="resource-reference-picker">
|
||||
<button ref={itemRef} type="button">
|
||||
引用素材
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('点外部关闭的边界判据', () => {
|
||||
test('点会自卸载的按钮不算点外部,面板不退出', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<SelfUnmountingOverlayHarness onDismiss={onDismiss} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复原文' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('按钮在自己这次点击里被同步卸载,也仍算点在浮层内部', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<NativeSelfRemovingOverlayHarness onDismiss={onDismiss} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '恢复原文' });
|
||||
fireEvent.click(button);
|
||||
|
||||
// 目标确实是在这次点击里卸载的——判定不能因此翻成「点外部」。
|
||||
expect(button.isConnected).toBe(false);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('登记的 portal 浮层项自己卸载也算内部,宿主面板不被误关', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<ExtraOverlaySelfRemovalHarness onDismiss={onDismiss} />);
|
||||
|
||||
const item = screen.getByRole('button', { name: '引用素材' });
|
||||
fireEvent.click(item);
|
||||
|
||||
expect(item.isConnected).toBe(false);
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '快速编辑浮层面板' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('点边界外照旧立即关闭(对照)', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<NativeSelfRemovingOverlayHarness onDismiss={onDismiss} />);
|
||||
|
||||
fireEvent.click(document.body);
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '快速编辑浮层面板' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('画布滚轮归属判据', () => {
|
||||
/**
|
||||
* 与「点外部关闭」同一份口径:portal 出去的东西 DOM 上不在边界里,但 React 的事件会沿
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CanvasLayer } from '../../../src/components/image-editor/ImageCanvasEditorTypes';
|
||||
import type { ChatReference } from '../src/features/project-workspace/resourceReferences';
|
||||
import {
|
||||
createEmptyResourceQuickEditDraftStore,
|
||||
createResourceQuickEditPanelDraft,
|
||||
dropResourceQuickEditDraft,
|
||||
isResourceQuickEditDraftEmpty,
|
||||
listResourceQuickEditDraftEntries,
|
||||
readResourceQuickEditDraft,
|
||||
resourceQuickEditDraftPromptExcerpt,
|
||||
restoreResourceQuickEditPanelDraft,
|
||||
writeResourceQuickEditDraft,
|
||||
} from '../src/features/resource-canvas/resourceCanvasQuickEditModel';
|
||||
|
||||
const SOURCE_LAYER: CanvasLayer = {
|
||||
id: 'asset:source-art',
|
||||
resourceId: 'asset:source-art',
|
||||
title: 'source-art.png',
|
||||
src: '/source-art.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 512,
|
||||
height: 512,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
zIndex: 1,
|
||||
sourceType: 'upload',
|
||||
};
|
||||
|
||||
const RULES_REFERENCE: ChatReference = {
|
||||
type: 'resource',
|
||||
resourceId: 'rules-resource',
|
||||
kind: 'game-rules',
|
||||
mediaType: 'text/markdown',
|
||||
label: 'rules',
|
||||
category: 'document',
|
||||
tags: [],
|
||||
source: 'asset-picker',
|
||||
};
|
||||
|
||||
describe('快速编辑草稿表', () => {
|
||||
it('提示词与引用各自上报时互相补齐,不改动原表', () => {
|
||||
const withPrompt = writeResourceQuickEditDraft(
|
||||
createEmptyResourceQuickEditDraftStore(),
|
||||
{ key: 'asset:source-art', prompt: '把夜色改成星空', updatedAt: 10 },
|
||||
);
|
||||
const withReferences = writeResourceQuickEditDraft(withPrompt, {
|
||||
key: 'asset:source-art',
|
||||
references: [RULES_REFERENCE],
|
||||
updatedAt: 20,
|
||||
});
|
||||
|
||||
// 只补引用那一次不能把提示词清掉,反之亦然:输入区两条状态是分别上报的。
|
||||
expect(
|
||||
readResourceQuickEditDraft(withReferences, 'asset:source-art'),
|
||||
).toEqual({
|
||||
prompt: '把夜色改成星空',
|
||||
references: [RULES_REFERENCE],
|
||||
updatedAt: 20,
|
||||
});
|
||||
expect(readResourceQuickEditDraft(withPrompt, 'asset:source-art')).toEqual({
|
||||
prompt: '把夜色改成星空',
|
||||
references: [],
|
||||
updatedAt: 10,
|
||||
});
|
||||
// 只读语义:写操作返回新表、源表不动;空表走工厂,每次都是独立的一张。
|
||||
expect(withPrompt.size).toBe(1);
|
||||
expect(createEmptyResourceQuickEditDraftStore().size).toBe(0);
|
||||
expect(createEmptyResourceQuickEditDraftStore()).not.toBe(
|
||||
createEmptyResourceQuickEditDraftStore(),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 键是资源**路径**,不是投影 id:正规化把卡片从 `task:…` 换成 `asset:…` 之后,
|
||||
* 同一张卡的草稿必须在恢复入口里照样对得上号,否则用户刚写的提示词会静默消失。
|
||||
*/
|
||||
it('草稿按路径归属,卡片换成 asset 投影后仍在入口里', () => {
|
||||
const store = writeResourceQuickEditDraft(
|
||||
createEmptyResourceQuickEditDraftStore(),
|
||||
{
|
||||
key: 'assets/generated/hero.png',
|
||||
prompt: '改成红色头发',
|
||||
references: [RULES_REFERENCE],
|
||||
updatedAt: 10,
|
||||
},
|
||||
);
|
||||
// 正规化前后是同一路径的两张卡;入口只认路径,落在哪一张上都该列出来。
|
||||
expect(
|
||||
listResourceQuickEditDraftEntries(store, [
|
||||
{ path: 'assets/generated/hero.png', label: 'hero.png(正式素材)' },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'assets/generated/hero.png',
|
||||
label: 'hero.png(正式素材)',
|
||||
prompt: '改成红色头发',
|
||||
updatedAt: 10,
|
||||
},
|
||||
]);
|
||||
// 资源真的不在项目里时(删掉了)才不入列表,记录本身不在这一步删。
|
||||
expect(listResourceQuickEditDraftEntries(store, [])).toEqual([]);
|
||||
expect(store.size).toBe(1);
|
||||
});
|
||||
|
||||
it('恢复入口按最后改动时间从新到旧', () => {
|
||||
const store = writeResourceQuickEditDraft(
|
||||
writeResourceQuickEditDraft(createEmptyResourceQuickEditDraftStore(), {
|
||||
key: 'assets/older.png',
|
||||
prompt: '早写的',
|
||||
updatedAt: 10,
|
||||
}),
|
||||
{ key: 'assets/newer.png', prompt: '后写的', updatedAt: 30 },
|
||||
);
|
||||
|
||||
expect(
|
||||
listResourceQuickEditDraftEntries(store, [
|
||||
{ path: 'assets/older.png', label: 'older.png' },
|
||||
{ path: 'assets/newer.png', label: 'newer.png' },
|
||||
]).map((entry) => entry.key),
|
||||
).toEqual(['assets/newer.png', 'assets/older.png']);
|
||||
});
|
||||
|
||||
it('提示词与引用都清空后不留条目', () => {
|
||||
const store = writeResourceQuickEditDraft(
|
||||
createEmptyResourceQuickEditDraftStore(),
|
||||
{
|
||||
key: 'asset:source-art',
|
||||
prompt: '把夜色改成星空',
|
||||
updatedAt: 10,
|
||||
},
|
||||
);
|
||||
const cleared = writeResourceQuickEditDraft(store, {
|
||||
key: 'asset:source-art',
|
||||
prompt: '',
|
||||
updatedAt: 20,
|
||||
});
|
||||
|
||||
expect(
|
||||
isResourceQuickEditDraftEmpty({ prompt: ' ', references: [] }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
readResourceQuickEditDraft(cleared, 'asset:source-art'),
|
||||
).toBeUndefined();
|
||||
expect(cleared.size).toBe(0);
|
||||
});
|
||||
|
||||
it('丢弃只删目标键,未记录过时原样返回', () => {
|
||||
const store = writeResourceQuickEditDraft(
|
||||
createEmptyResourceQuickEditDraftStore(),
|
||||
{
|
||||
key: 'asset:a',
|
||||
prompt: 'A 的草稿',
|
||||
updatedAt: 10,
|
||||
},
|
||||
);
|
||||
const withTwo = writeResourceQuickEditDraft(store, {
|
||||
key: 'asset:b',
|
||||
prompt: 'B 的草稿',
|
||||
updatedAt: 20,
|
||||
});
|
||||
const dropped = dropResourceQuickEditDraft(withTwo, 'asset:a');
|
||||
|
||||
expect(readResourceQuickEditDraft(dropped, 'asset:a')).toBeUndefined();
|
||||
expect(readResourceQuickEditDraft(dropped, 'asset:b')?.prompt).toBe(
|
||||
'B 的草稿',
|
||||
);
|
||||
expect(dropResourceQuickEditDraft(dropped, 'asset:missing')).toBe(dropped);
|
||||
expect(dropResourceQuickEditDraft(dropped, null)).toBe(dropped);
|
||||
expect(readResourceQuickEditDraft(dropped, null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('回填只覆盖提示词,模型与比例仍取当下默认值', () => {
|
||||
const panelDraft = createResourceQuickEditPanelDraft(SOURCE_LAYER);
|
||||
const restored = restoreResourceQuickEditPanelDraft(panelDraft, {
|
||||
prompt: '把夜色改成星空',
|
||||
references: [],
|
||||
updatedAt: 10,
|
||||
});
|
||||
|
||||
expect(restored.prompt).toBe('把夜色改成星空');
|
||||
expect(restored.model).toBe(panelDraft.model);
|
||||
expect(restored.aspectRatio).toBe(panelDraft.aspectRatio);
|
||||
expect(restored.size).toBe(panelDraft.size);
|
||||
expect(restored.status).toBe('idle');
|
||||
// 没有草稿时面板原样返回:默认面板不该被这一步改写。
|
||||
expect(restoreResourceQuickEditPanelDraft(panelDraft, undefined)).toBe(
|
||||
panelDraft,
|
||||
);
|
||||
});
|
||||
|
||||
it('摘要压缩空白并按上限截断', () => {
|
||||
expect(resourceQuickEditDraftPromptExcerpt('')).toBe('(尚未填写提示词)');
|
||||
expect(resourceQuickEditDraftPromptExcerpt(' \n ')).toBe(
|
||||
'(尚未填写提示词)',
|
||||
);
|
||||
expect(resourceQuickEditDraftPromptExcerpt('把夜色\n改成 星空')).toBe(
|
||||
'把夜色 改成 星空',
|
||||
);
|
||||
expect(resourceQuickEditDraftPromptExcerpt('一二三四五', 3)).toBe(
|
||||
'一二三…',
|
||||
);
|
||||
expect(resourceQuickEditDraftPromptExcerpt('一二三', 3)).toBe('一二三');
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,20 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
canonicalProjectedResourceMediaType,
|
||||
defaultCharacterAnimationResourceName,
|
||||
defaultDerivedResourceName,
|
||||
DERIVED_RESOURCE_NAME_SUFFIXES,
|
||||
resolveDerivedResourceNameCheck,
|
||||
resolveProjectCharacterAnimationCapability,
|
||||
resolveProjectResourceEditCapability,
|
||||
resolveResourceEditNameCheck,
|
||||
resolveResourceEditRequestIdentity,
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
RESOURCE_EDIT_NAME_MAX_LENGTH,
|
||||
RESOURCE_EDIT_PROMPT_TRUNCATED_NOTICE,
|
||||
resourceEditPromptMaxLength,
|
||||
truncateResourceEditPrompt,
|
||||
@@ -280,3 +290,217 @@ describe('全类型资源编辑能力模型', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Rust 源码路径按**本文件位置**解析,不按仓库根 cwd 解析:这个用例是跨语言对表,
|
||||
* 从别的工作目录单跑这一个文件时 `ENOENT` 会把「两边分叉」误报成一次环境问题。
|
||||
*/
|
||||
const RUST_RESOURCE_EDITOR_PATH = fileURLToPath(
|
||||
new URL('../src-tauri/src/project/resource_editor.rs', import.meta.url),
|
||||
);
|
||||
|
||||
/**
|
||||
* Rust `normalize_resource_edit_name` 的函数体原文。
|
||||
*
|
||||
* 派生资源名的门禁在 Rust 侧,前端这份只是镜像;一旦两边数字或文案分叉,用户就会先看到
|
||||
* 一句「提交前拦下」的话、再被后端换成另一句话,所以这里直接拿源码对表。
|
||||
*/
|
||||
function rustResourceEditNameNormalizer() {
|
||||
const source = readFileSync(RUST_RESOURCE_EDITOR_PATH, 'utf8');
|
||||
const start = source.indexOf('fn normalize_resource_edit_name(');
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const body = source.slice(start, source.indexOf('\n}\n', start));
|
||||
expect(body.length).toBeGreaterThan(0);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 只换 label 的 Agent 回执资源:AGC 侧 label 直接取任务标题,是最容易触发名称门禁的一类。 */
|
||||
function labelResource(label: string) {
|
||||
return resource({
|
||||
id: 'agent-result:design:message-1',
|
||||
category: 'document',
|
||||
path: '专业 Agent 文本回执',
|
||||
subtype: 'agent-result',
|
||||
producerTaskId: null,
|
||||
content: '回执',
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
/** 默认名函数与 kind 的对应关系,供「默认名 == 检查名」的断言复用。 */
|
||||
function defaultDerivedNameFor(
|
||||
target: ProjectResource,
|
||||
kind: 'edit' | 'character-animation',
|
||||
) {
|
||||
return kind === 'edit'
|
||||
? defaultDerivedResourceName(target)
|
||||
: defaultCharacterAnimationResourceName(target);
|
||||
}
|
||||
|
||||
describe('派生资源名称:提交前镜像 Rust 的 1..=120 与禁止控制字符', () => {
|
||||
/**
|
||||
* Rust 是权威:`value.trim()` → 非空 → `chars().count() > 120` → `char::is_control`,
|
||||
* 任一不满足就 `Err`,并且**不截断**(没有 `truncate` / `chars().take(...)`)。
|
||||
* 前端镜像必须逐条对齐,包括「宁可按原话拒绝、也不截成可能重名的名字」这条口径。
|
||||
*/
|
||||
it('上限与提示文案逐字取自 Rust 的 normalize_resource_edit_name', () => {
|
||||
const body = rustResourceEditNameNormalizer();
|
||||
|
||||
expect(body).toMatch(/\bvalue\.trim\(\)/u);
|
||||
expect(body).toMatch(/value\.is_empty\(\)/u);
|
||||
expect(body).toMatch(/char::is_control/u);
|
||||
expect(body).toMatch(/return Err\(/u);
|
||||
expect(body).not.toMatch(/truncate|\.take\(/u);
|
||||
|
||||
const limit = /chars\(\)\.count\(\)\s*>\s*(\d+)/u.exec(body);
|
||||
expect(limit?.[1]).toBe(String(RESOURCE_EDIT_NAME_MAX_LENGTH));
|
||||
|
||||
const notice = /"([^"]*派生资源名称[^"]*)"/u.exec(body);
|
||||
expect(notice?.[1]).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
|
||||
});
|
||||
|
||||
it('恰好 120 码点放行,多一个字就按 Rust 原话拦住且不截断', () => {
|
||||
const exactlyLimit = '字'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH);
|
||||
expect(resolveResourceEditNameCheck(exactlyLimit)).toEqual({
|
||||
name: exactlyLimit,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const overLimit = `${exactlyLimit}多`;
|
||||
const overCheck = resolveResourceEditNameCheck(overLimit);
|
||||
expect(overCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
|
||||
// 提示里报的长度上限必须是拦下它的那个数字,且原样返回(不返回截断名)。
|
||||
expect(RESOURCE_EDIT_NAME_INVALID_NOTICE).toContain(
|
||||
`1..=${RESOURCE_EDIT_NAME_MAX_LENGTH}`,
|
||||
);
|
||||
expect(overCheck.name).toBe(overLimit);
|
||||
});
|
||||
|
||||
it('先 trim 再判空:首尾空白不算违规,只有空白则拦下', () => {
|
||||
expect(resolveResourceEditNameCheck(' 小猫咪-编辑版 ')).toEqual({
|
||||
name: '小猫咪-编辑版',
|
||||
error: null,
|
||||
});
|
||||
expect(resolveResourceEditNameCheck('').error).toBe(
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
);
|
||||
expect(resolveResourceEditNameCheck(' ').error).toBe(
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
);
|
||||
expect(resolveResourceEditNameCheck('\n\t').error).toBe(
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
);
|
||||
});
|
||||
|
||||
it('拒绝 Cc 控制字符(含内嵌换行 / 制表符),但放行 Rust 也放行的 Cf 格式符', () => {
|
||||
for (const control of ['\u0000', '\u0007', '\u001f', '\u007f', '\u009f']) {
|
||||
expect(
|
||||
resolveResourceEditNameCheck(`角色${control}形象-编辑版`).error,
|
||||
`U+${control.codePointAt(0)!.toString(16)}`,
|
||||
).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
|
||||
}
|
||||
// 任务标题里的换行是真实成因:label 直接取任务标题,内嵌换行原样传下来。
|
||||
for (const embedded of ['\n', '\r', '\t']) {
|
||||
expect(
|
||||
resolveResourceEditNameCheck(`生成角色${embedded}正面形象-编辑版`)
|
||||
.error,
|
||||
).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
|
||||
}
|
||||
// 首尾换行被 trim 掉,Rust 同样放行——不能在这里多收窄一条客户端自有规则。
|
||||
expect(
|
||||
resolveResourceEditNameCheck('\n角色形象-编辑版\n').error,
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveResourceEditNameCheck('角色形象\u200b-编辑版').error,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('按码点计长(chars().count() 口径),不按 UTF-16 长度误判', () => {
|
||||
const emojiLimit = '😀'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH);
|
||||
// UTF-16 长度是 240,码点数是 120:Rust 接受,前端不许拦。
|
||||
expect(emojiLimit.length).toBe(RESOURCE_EDIT_NAME_MAX_LENGTH * 2);
|
||||
expect(resolveResourceEditNameCheck(emojiLimit).error).toBeNull();
|
||||
expect(
|
||||
resolveResourceEditNameCheck(
|
||||
`${'字'.repeat(RESOURCE_EDIT_NAME_MAX_LENGTH - 5)}${'😀'.repeat(5)}`,
|
||||
).error,
|
||||
).toBeNull();
|
||||
expect(resolveResourceEditNameCheck(`${emojiLimit}😀`).error).toBe(
|
||||
RESOURCE_EDIT_NAME_INVALID_NOTICE,
|
||||
);
|
||||
});
|
||||
|
||||
it('编辑版与角色动画共用同一张后缀表,默认名与检查名逐个相等', () => {
|
||||
expect(DERIVED_RESOURCE_NAME_SUFFIXES).toEqual({
|
||||
edit: '-编辑版',
|
||||
'character-animation': '-角色动画',
|
||||
});
|
||||
const hero = resource({
|
||||
id: 'asset:hero',
|
||||
category: 'unclassified',
|
||||
path: 'assets/hero.png',
|
||||
label: 'hero.png',
|
||||
mediaType: 'image/png',
|
||||
manifestAssetId: 'hero',
|
||||
});
|
||||
expect(resolveDerivedResourceNameCheck(hero, 'edit')).toEqual({
|
||||
name: 'hero-编辑版',
|
||||
error: null,
|
||||
});
|
||||
expect(
|
||||
resolveDerivedResourceNameCheck(hero, 'character-animation'),
|
||||
).toEqual({ name: 'hero-角色动画', error: null });
|
||||
// 默认名与检查名是同一份拼接结果,调用方不必各自拼一遍。
|
||||
expect(defaultDerivedResourceName(hero)).toBe('hero-编辑版');
|
||||
expect(defaultCharacterAnimationResourceName(hero)).toBe('hero-角色动画');
|
||||
// 去扩展名 + 空基名兜底的口径不变。
|
||||
expect(defaultDerivedResourceName(labelResource('.png'))).toBe(
|
||||
'资源-编辑版',
|
||||
);
|
||||
expect(defaultDerivedResourceName(labelResource('theme.mp3'))).toBe(
|
||||
'theme-编辑版',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 两个后缀都要处理:快速编辑的「-编辑版」和角色动画的「-角色动画」是同一个源名的两种
|
||||
* 派生。后缀只多 4~5 个字,所以超限的源名在两条路径上都必须被拦下,而不是只拦一条。
|
||||
*/
|
||||
it('超长源名在编辑版与角色动画两条路径上都被拦下,不截成可能重名的名字', () => {
|
||||
const base = '生成角色正面半身战斗姿态设定稿'.repeat(8);
|
||||
const first = labelResource(`${base}A.png`);
|
||||
const second = labelResource(`${base}B.png`);
|
||||
|
||||
for (const kind of ['edit', 'character-animation'] as const) {
|
||||
const firstCheck = resolveDerivedResourceNameCheck(first, kind);
|
||||
const secondCheck = resolveDerivedResourceNameCheck(second, kind);
|
||||
expect(firstCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
|
||||
expect(secondCheck.error).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
|
||||
// 不截断:检查结果仍是原样的长名(否则两个同前缀的长名会被截成同一个可用名字)。
|
||||
expect([...firstCheck.name].length).toBeGreaterThan(
|
||||
RESOURCE_EDIT_NAME_MAX_LENGTH,
|
||||
);
|
||||
expect(firstCheck.name).not.toBe(secondCheck.name);
|
||||
expect(firstCheck.name).toBe(defaultDerivedNameFor(first, kind));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 真实成因:`agent-result` 资源的 label 直接取任务标题(`resourceProjectionModel`),
|
||||
* 用户写的长标题或带换行的标题会原样进入派生名。
|
||||
*/
|
||||
it('任务标题型源名(超长 / 带换行)在派生前就给出同一句中文提示', () => {
|
||||
const longTitle = `${'把主角改成夜行侠并加上战斗姿态'.repeat(9)}.png`;
|
||||
expect(
|
||||
resolveDerivedResourceNameCheck(labelResource(longTitle), 'edit').error,
|
||||
).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
|
||||
|
||||
const multilineTitle = '生成角色\n正面形象.png';
|
||||
expect(
|
||||
resolveDerivedResourceNameCheck(
|
||||
labelResource(multilineTitle),
|
||||
'character-animation',
|
||||
).error,
|
||||
).toBe(RESOURCE_EDIT_NAME_INVALID_NOTICE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ResourcePromptPolishSlot } from '../src/features/resource-canvas/ResourcePromptPolishSlot';
|
||||
|
||||
type TauriInvoke = (
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* 平台侧润色的桩:`ResourcePromptPolishSlot` 走 `usePromptPolish` 的默认实现
|
||||
* (`polish_local_project_prompt`),所以这里和生成素材面板的既有用例一样,
|
||||
* 用 `window.__TAURI__` 打桩,不碰真实 Provider。
|
||||
*/
|
||||
function installPolishInvoke(result: string) {
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command !== 'polish_local_project_prompt') {
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
(
|
||||
window as unknown as { __TAURI__?: { core?: { invoke: TauriInvoke } } }
|
||||
).__TAURI__ = { core: { invoke } };
|
||||
return invoke;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
|
||||
/**
|
||||
* 宿主侧的最小接线:slot 本身是受控的,提示词文本存在宿主里
|
||||
* (与 AGC 快速编辑面板 / 生成素材弹窗同一形状),`output` 只是把宿主状态显示出来。
|
||||
*/
|
||||
function PolishSlotHarness({ prompt }: { prompt: string }) {
|
||||
const [text, setText] = useState(prompt);
|
||||
return (
|
||||
<>
|
||||
<ResourcePromptPolishSlot
|
||||
subject="图片素材的快速编辑提示词"
|
||||
editKind="image-reference"
|
||||
prompt={text}
|
||||
applyPrompt={setText}
|
||||
/>
|
||||
{/* 刻意用 span:`<output>` 自带 status 角色,会和 slot 的状态行撞名。 */}
|
||||
<span aria-label="宿主提示词">{text}</span>
|
||||
{/* 宿主输入框里的手改:与 AGC 面板里改提示词同一条路径(改的是宿主状态)。 */}
|
||||
<button type="button" onClick={() => setText('把角色头发改成蓝色')}>
|
||||
模拟用户手改
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ResourcePromptPolishSlot', () => {
|
||||
test('回包与原文相同:状态行说清「未做修改」,也不给「恢复原文」假入口', async () => {
|
||||
const invoke = installPolishInvoke('把角色头发改成红色');
|
||||
render(<PolishSlotHarness prompt="把角色头发改成红色" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 润色' }));
|
||||
|
||||
// 验收项 AGC-004 的用户可见面:必须有一条说得清的反馈,而不是静默什么都不发生。
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'AI 润色结果与原文相同,未做修改',
|
||||
),
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith('polish_local_project_prompt', {
|
||||
prompt: '把角色头发改成红色',
|
||||
context: expect.stringContaining('图片素材的快速编辑提示词'),
|
||||
});
|
||||
// 文本没变,也就没有可回退的变化:不渲染「恢复原文」。
|
||||
expect(screen.getByLabelText('宿主提示词').textContent).toBe(
|
||||
'把角色头发改成红色',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
});
|
||||
|
||||
test('用户接着手改提示词:上一轮的提示收起,宿主文本就是用户写的那份', async () => {
|
||||
installPolishInvoke('把角色头发改成红色');
|
||||
render(<PolishSlotHarness prompt="把角色头发改成红色" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 润色' }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('status').textContent).toBe(
|
||||
'AI 润色结果与原文相同,未做修改',
|
||||
),
|
||||
);
|
||||
|
||||
// 宿主文本被用户改写:上一轮「与原文相同」的结论不再描述当前这段话,提示收起,
|
||||
// 免得用户把上一轮的结论当成对现在这段文本的判断。
|
||||
fireEvent.click(screen.getByRole('button', { name: '模拟用户手改' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('status')).toBeNull());
|
||||
expect(screen.getByLabelText('宿主提示词').textContent).toBe(
|
||||
'把角色头发改成蓝色',
|
||||
);
|
||||
});
|
||||
|
||||
test('回包不同:「恢复原文」把原输入回填给宿主,并收起自己与提示', async () => {
|
||||
installPolishInvoke('把角色头发设定改为亮红色');
|
||||
render(<PolishSlotHarness prompt="把角色头发改成红色" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 润色' }));
|
||||
const restore = await screen.findByRole('button', { name: '恢复原文' });
|
||||
expect(screen.getByLabelText('宿主提示词').textContent).toBe(
|
||||
'把角色头发设定改为亮红色',
|
||||
);
|
||||
|
||||
fireEvent.click(restore);
|
||||
|
||||
// 验收项 AGC-005 的动作面:恢复原文 = 回填最初原文,快照被消费掉,按钮与提示一起收干净。
|
||||
expect(screen.getByLabelText('宿主提示词').textContent).toBe(
|
||||
'把角色头发改成红色',
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull();
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { usePromptPolish } from '../src/features/project-workspace/usePromptPolish';
|
||||
import {
|
||||
PROMPT_POLISH_UNCHANGED_NOTICE,
|
||||
usePromptPolish,
|
||||
} from '../src/features/project-workspace/usePromptPolish';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -10,6 +13,7 @@ function renderPolish({
|
||||
requestPolish,
|
||||
applyPrompt = vi.fn(),
|
||||
normalizeResult,
|
||||
readPrompt = () => '原始需求',
|
||||
}: {
|
||||
requestPolish: (
|
||||
prompt: string,
|
||||
@@ -20,10 +24,11 @@ function renderPolish({
|
||||
text: string;
|
||||
notice?: string | null;
|
||||
};
|
||||
readPrompt?: () => string;
|
||||
}) {
|
||||
return renderHook(() =>
|
||||
usePromptPolish({
|
||||
readPrompt: () => '原始需求',
|
||||
readPrompt,
|
||||
applyPrompt,
|
||||
requestPolish,
|
||||
normalizeResult,
|
||||
@@ -126,4 +131,120 @@ describe('usePromptPolish', () => {
|
||||
expect(applyPrompt).toHaveBeenCalledWith('润色后的需求');
|
||||
expect(result.current.originalText).toBe('原始需求');
|
||||
});
|
||||
|
||||
test('成功回包与当前 prompt 逐字相同:给「未做修改」提示,不落快照也不回填', async () => {
|
||||
const applyPrompt = vi.fn();
|
||||
const { result } = renderPolish({
|
||||
requestPolish: async () => '原始需求',
|
||||
applyPrompt,
|
||||
});
|
||||
|
||||
let returned: string | null = null;
|
||||
await act(async () => {
|
||||
returned = await result.current.polish();
|
||||
});
|
||||
|
||||
// 验收项 AGC-004:润色结果就是原文时必须说得清,不能让用户以为按钮没反应、
|
||||
// 或者以为文案已经被改过。
|
||||
expect(returned).toBe('原始需求');
|
||||
expect(result.current.notice).toBe(PROMPT_POLISH_UNCHANGED_NOTICE);
|
||||
expect(result.current.notice).toBe('AI 润色结果与原文相同,未做修改');
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.polishing).toBe(false);
|
||||
// 没有可回退的变化 ⇒ 不落原文快照,宿主也就不会渲染「恢复原文」这个假入口。
|
||||
expect(result.current.originalText).toBeNull();
|
||||
// 不回填:宿主侧的回填还牵着「提示词变了就重铸请求身份」这类副作用,这次什么都没变。
|
||||
expect(applyPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('回包被上限截回原文:算「与原文相同」,提示改说截断原因', async () => {
|
||||
const applyPrompt = vi.fn();
|
||||
const { result } = renderPolish({
|
||||
// 原文正好顶到长度上限、模型又在原文后面追加了内容:切片后与原文逐字相同。
|
||||
requestPolish: async () => '原始需求加上很长很长的尾巴',
|
||||
applyPrompt,
|
||||
normalizeResult: () => ({ text: '原始需求', notice: '已按长度上限截断' }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.polish();
|
||||
});
|
||||
|
||||
// 判据是「最终要写回宿主的文本」而不是回包字符串:写回去一字未变,就没有可回退的变化,
|
||||
// 不该为它落快照、渲染一枚点了等于没点的「恢复原文」;截断提示优先,说清这次为什么没变。
|
||||
expect(result.current.notice).toBe('已按长度上限截断');
|
||||
expect(result.current.notice).not.toBe(PROMPT_POLISH_UNCHANGED_NOTICE);
|
||||
expect(applyPrompt).not.toHaveBeenCalled();
|
||||
expect(result.current.originalText).toBeNull();
|
||||
});
|
||||
|
||||
test('规范化确实改动了内容:照旧回填并给规范化提示(对照)', async () => {
|
||||
const applyPrompt = vi.fn();
|
||||
const { result } = renderPolish({
|
||||
requestPolish: async () => '润色后的需求加上要被截掉的尾巴',
|
||||
applyPrompt,
|
||||
normalizeResult: () => ({
|
||||
text: '润色后的需求',
|
||||
notice: '已按长度上限截断',
|
||||
}),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.polish();
|
||||
});
|
||||
|
||||
expect(result.current.notice).toBe('已按长度上限截断');
|
||||
expect(applyPrompt).toHaveBeenCalledWith('润色后的需求');
|
||||
expect(result.current.originalText).toBe('原始需求');
|
||||
});
|
||||
|
||||
test('clearNotice 收掉上一轮提示,快照与错误不受影响', async () => {
|
||||
const { result } = renderPolish({
|
||||
requestPolish: async () => '润色后的需求',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.polish();
|
||||
});
|
||||
expect(result.current.originalText).toBe('原始需求');
|
||||
|
||||
await act(async () => {
|
||||
result.current.clearNotice();
|
||||
});
|
||||
|
||||
// 用户手改提示词只作废「上一轮的结论」,不该顺手把「恢复原文」也取消掉。
|
||||
expect(result.current.notice).toBeNull();
|
||||
expect(result.current.originalText).toBe('原始需求');
|
||||
});
|
||||
|
||||
test('先真润色再遇到「回包等于原文」:提示互不覆盖,快照与恢复入口都留着', async () => {
|
||||
let current = '原始需求';
|
||||
const applied: string[] = [];
|
||||
// 第二次回包等于第一次回填后的当前文本。
|
||||
const results = ['润色后的需求', '润色后的需求'];
|
||||
const { result } = renderHook(() =>
|
||||
usePromptPolish({
|
||||
readPrompt: () => current,
|
||||
applyPrompt: (text) => {
|
||||
current = text;
|
||||
applied.push(text);
|
||||
},
|
||||
requestPolish: async () => results.shift() ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.polish();
|
||||
});
|
||||
expect(applied).toEqual(['润色后的需求']);
|
||||
expect(result.current.originalText).toBe('原始需求');
|
||||
|
||||
// 第二次回包就是当前文本:只换提示,宿主状态与原文快照都不动。
|
||||
await act(async () => {
|
||||
await result.current.polish();
|
||||
});
|
||||
expect(result.current.notice).toBe('AI 润色结果与原文相同,未做修改');
|
||||
expect(result.current.originalText).toBe('原始需求');
|
||||
expect(applied).toEqual(['润色后的需求']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
> 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。
|
||||
> 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。
|
||||
|
||||
## 2026-09-18 AGC 未提交快速编辑草稿按资源路径归属,正式恢复账本不动
|
||||
|
||||
- 背景:画布验收项 AGC-006/023 要求「点外部 / Esc 收起面板、或换素材卡」不再无条件丢弃用户刚写的提示词与 `@` 引用,于是宿主内存里多了一份未提交草稿表(`resourceCanvasQuickEditModel.ts` 的 `ResourceQuickEditDraftStore`),与原生 `list_pending_local_project_resource_edits` / `resume_local_project_resource_edit` 那条正式可恢复账本**并存**。
|
||||
- 决策:草稿键是投影的稳定身份 `ProjectResource.path`,不是投影 id。资源投影本来就按 path 去重(`resourceProjectionModel` 的 `uniqueByPath`),而 id 会变——任务产物经 `normalize_local_project_raster_resource` 登记成正式素材后,同一张卡从 `task:<任务>:<路径>` 变成 `asset:<id>`。用 id 当键时,任何**不是打开中这一笔快速编辑自身触发**的重投影(换素材卡收起面板、Agent 或外部编辑器把同一路径登记成资产)都会让草稿落到再也点不到的键上:恢复入口按 id 过滤后静默丢弃、重开面板按新 id 查不到。按路径归属后不需要任何「跟着投影搬家」的换键逻辑,那条逻辑本身就是搬丢的来源。
|
||||
- 决策:本地草稿是**会话内存态**——不落盘、不进账本、不参与对账;共享入口「管理未完成编辑」同时列出来源不同的两种条目,草稿条目显式标注「本会话未提交,关闭客户端不保留」,账本读取失败时仍报出本会话草稿条数,避免用户把两者当成同一种持久事实。
|
||||
- 决策:`ResourcePromptPolishSlot` 与共享聊天输入区(`ResourceReferenceInput`)共用 `usePromptPolish`;「与原文相同」以**规范化之后要写回宿主的文本**为准(回包被长度上限截回原文同样算没变化),此时不落原文快照、不回填宿主,只给提示,避免出现点了等于没点的「恢复原文」假入口。
|
||||
- 边界:发送前提醒面板里「AI 润色并发送」遇到原样回包仍按用户意图直接提交,本轮按产品取舍保留(记录在 `docs/project-memory/todos/【待办】画布验收后续修复-2026-09-18.md`)。
|
||||
- 落地:`apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasQuickEditModel.ts`(工厂空表 / 按路径写入读取丢弃 / `listResourceQuickEditDraftEntries`)、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`(草稿接线与恢复入口)、`apps/ai-game-creator-shell/src/features/resource-canvas/ResourcePromptPolishSlot.tsx`、`apps/ai-game-creator-shell/src/features/project-workspace/{usePromptPolish.ts,ResourceReferenceInput.tsx}`;回归见 `tests/resourceCanvasQuickEditModel.test.ts`、`tests/resourceCanvasQuickEditDraft.test.tsx`(含「面板收起后旁路重投影」用例,改回 id 作键即红灯)、`tests/usePromptPolish.test.tsx`、`tests/resourcePromptPolishSlot.test.tsx`、`tests/chatPromptPolish.test.tsx`。
|
||||
|
||||
## 2026-09-18 派生资源名称在前端镜像 Rust 门禁,宁可按原话拦下也不截断
|
||||
|
||||
- 背景:快速编辑(`-编辑版`)与角色动画(`-角色动画`)的派生资源名由前端按源资源 label 拼出,直接进 `derive_local_project_resource` 的 `input.assetName`;Rust `normalize_resource_edit_name`(`resource_editor.rs:802-808`)要求 `trim` 后非空、码点数 1..=120 且不含控制字符,任一不满足即整条请求失败。源 label 取任务标题(Agent 回执)或长生成文件名时,用户必须提交一次才看到「派生资源名称必须在 1..=120 字符内且不能包含控制字符」。这是画布验收遗留项 AGC-025 的成因之一。
|
||||
- 决策:前端只做镜像,不另造中文语义——上限 120、`Cc` 控制字符判定(C0 / DEL / C1,`Cf` 继续放行)、`trim` 后判空、按 Unicode 码点计长(Rust `chars().count()`,不是 JS UTF-16 `.length`)四条与 Rust 逐条对齐,提示文案与 Rust 返回串逐字相同;两份判定由跨语言测试直接读 Rust 源码对表。
|
||||
- 决策:名称不合法时**阻止并提示**,不截断。截到 120 会让同前缀的两个长源名塌成同一个名字,把一次可见失败换成一次静默重名;默认名函数因此保持返回不合法原名,由检查结果里的 `error` 让调用方先停下。
|
||||
- 落地:`apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts` 的 `RESOURCE_EDIT_NAME_MAX_LENGTH` / `RESOURCE_EDIT_NAME_INVALID_NOTICE` / `resolveResourceEditNameCheck` / `resolveDerivedResourceNameCheck(resource, 'edit' | 'character-animation')`,纯函数与跨语言对表见 `tests/resourceEditModel.test.ts`;两种后缀共用同一张后缀表,避免「-编辑版」与「-角色动画」再次分叉。
|
||||
- 接线:`index.tsx` 的 `submitResourceQuickEdit` 与 `submitResourceCharacterAnimation` 都在**调 `resolveResourceDeriveSource` 之前**做检查(正规化写盘发生在那一步之后),不合法时只把 `status: 'failed'` 与 Rust 原话写进各自的浮层面板并 return,不发派生请求;`assetName` 用检查结果里的 `name`。回归见 `tests/projectResourceLiveIntegration.test.tsx` 的两条「源名超 120 码点」用例与既有的合法名校验 `source-art-编辑版` / `hero-角色动画`。
|
||||
- 边界:资源画布的图片类生成走另一条门禁(`commands.rs` 的 `LOCAL_PROJECT_ASSET_MAX_ASSET_NAME_CHARS`,文案「素材名称超出安全边界」),不在本条口径内;提示词上限仍是 `resourceEditPromptMaxLength` 那一份,本决策不动它。
|
||||
|
||||
## 2026-09-18 Provider 瞬态重试次数严格按设置执行(游戏开发 Agent 与策划 Agent 不再被档位收进区间)
|
||||
|
||||
- 背景:AGC 客户端此前把 `agentLlm.<agent>.maxRetries` 按运行档位重新收进固定区间——`autonomous-game-build` 档位(自主构建的游戏开发 Agent 及其继承档位的专业子 Agent)被抬到 12~16,`standard` 档位(含立项策划入口的策划 Agent 与普通 Agent 对话)被压到最多 3;瞬态分类里的上游 400 还在同一预算上再收窄到 2 次。现场把 `maxRetries` 设成 5 时,游戏开发与策划两条链路都不按设置执行。
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
- 标签面板无法查看更多标签;资源面板多选后卡死。
|
||||
- 未完成编辑缺少继续入口;快速编辑的素材名称校验报错。
|
||||
|
||||
## 进展
|
||||
|
||||
### 2026-09-20 第一批(已提交到本分支,待客户端验收)
|
||||
|
||||
已完成并在本机跑通:AI 润色原样回包提示与「恢复原文」不再丢稿(AGC-004/005)、快速编辑未提交草稿的保留与继续入口(AGC-006/023)、派生资源名称的前端镜像校验与提交前拦截(AGC-025)。
|
||||
|
||||
- 本机执行过:`npx vitest run` 定向 9 个文件(138 用例全绿)、`npm --prefix apps/ai-game-creator-shell run typecheck`、`npm run check:encoding`、`git diff --check`、改动文件的 prettier / eslint,以及 `appSurface` 等 20 个渲染工作台的用例文件。仅本地;远程 CI 与真实客户端验收都还没跑。
|
||||
- 本轮独立 review 的 4 条 P2 已处理:草稿身份改为资源路径(原先按投影 id,旁路重投影后草稿会静默消失)、润色判据改为「规范化后要写回的文本」、共享聊天输入区显示润色提示、本会话草稿与账本条目在入口上区分来源;P3 处理了名称预检的抛错兜底、恢复入口按钮对齐、提示随编辑失效、空表改工厂、跨语言用例按 `import.meta.url` 解析路径。
|
||||
- 明确保留未改:发送前提醒面板里「AI 润色并发送」遇到原样回包仍直接提交(按用户意图发出去,不做二次拦截)。
|
||||
- 仍未做:动画生成失败的真实复现与定位、文档全文入口、标签面板查看更多标签、多选卡死,以及卡片全类型重设计、画布内任务浮层、替换交互与聊天引用这批后续需求。
|
||||
|
||||
## 后续需求
|
||||
|
||||
- 资源卡顶部独立名称区与全类型统一样式,确认 assetName/用户素材名的正式来源。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type FloatingOptionBoundaryRef = {
|
||||
readonly current: HTMLElement | null;
|
||||
@@ -107,19 +107,48 @@ export function useImageCanvasFloatingOptionDismiss({
|
||||
restoreFocusRef,
|
||||
isInsideExtraOverlay,
|
||||
}: UseImageCanvasFloatingOptionDismissOptions) {
|
||||
/**
|
||||
* 本次点击「算不算点在浮层里」的冻结判定。
|
||||
*
|
||||
* 判定必须在事件派发的最早期做,不能在冒泡到 `document` 时才拿 `event.target` 现算:
|
||||
* 浮层里的按钮可能在**这一次点击**里把自己卸载掉(AGC 画布验收 AGC-005:快速编辑的
|
||||
* 「恢复原文」被点后原文快照清空、按钮不再渲染,验收视频里表现为整个编辑面板连同选中
|
||||
* 一起被收掉)。目标节点一旦脱离 DOM,`element.contains(target)` 一律为 false,
|
||||
* 「点浮层内部」就被误判成「点外部」,面板被自己的按钮关掉。
|
||||
*
|
||||
* 捕获阶段挂在 `document` 上是最早的一站(React 的委托监听在 root 容器上,位置更靠内),
|
||||
* 此刻 DOM 还是用户看到的那一份;判定口径本身不变,仍是
|
||||
* {@link isEventInsideFloatingOverlay}(边界 DOM + portal 到 body 的浮层登记)。
|
||||
* 真正触发关闭仍然留在冒泡阶段,宿主其它处理器看到的事件顺序与既有行为逐字一致。
|
||||
*/
|
||||
const clickVerdictRef = useRef<{
|
||||
event: MouseEvent;
|
||||
inside: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolveInside = (event: MouseEvent) =>
|
||||
isEventInsideFloatingOverlay(event.target, {
|
||||
boundaryRefs,
|
||||
isInsideExtraOverlay,
|
||||
});
|
||||
|
||||
const handleClickCapture = (event: MouseEvent) => {
|
||||
clickVerdictRef.current = { event, inside: resolveInside(event) };
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
// 中文注释:选项项点击后要保留浮层;父级面板其它区域点击才收起。
|
||||
if (
|
||||
isEventInsideFloatingOverlay(event.target, {
|
||||
boundaryRefs,
|
||||
isInsideExtraOverlay,
|
||||
})
|
||||
) {
|
||||
const frozen = clickVerdictRef.current;
|
||||
clickVerdictRef.current = null;
|
||||
// 捕获阶段没跑到(例如事件不是这条派发路径派出来的)时退回现算,行为与改动前一致。
|
||||
const isInside =
|
||||
frozen && frozen.event === event ? frozen.inside : resolveInside(event);
|
||||
if (isInside) {
|
||||
return;
|
||||
}
|
||||
onDismiss();
|
||||
@@ -144,9 +173,11 @@ export function useImageCanvasFloatingOptionDismiss({
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickCapture, true);
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickCapture, true);
|
||||
document.removeEventListener('click', handleClick);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user