替换面板改为非模态浮层,支持在画布上点选替换目标

- 共享选择组件新增 opt-in nonModal:不铺遮罩、不做焦点陷阱、限高 + 内部滚动,Esc 在 document 阶段截断后取消,关掉即卸载;网页端美术画布不传,弹窗行为逐字不变。
- 新增 initialSelectionRevision:面板开着时宿主换了目标才重同步当前选择,面板里的搜索词与分类筛选不被重置。
- AGC 侧把候选面板锚到画布右上角(任务开关下方),壳样式放共享样式表、宿主只负责锚定;面板开着时画布照常可点,点中合法候选落成面板当前选择,写入仍只由「确认」发起。
- 退役旧的「点选替换」按钮、画布提示条与 resourceReplacementPickMode:同一个功能不留两条 UI 路径;合法性判据仍复用 resolveResourceReplacementPick,写入仍是同一个 confirm 函数。
- 确认时用画布点选结果兜底,避免点完当帧按确认误报「请选择一个替换素材」。
- 测试:替换用例 4 条点选路径改写为非模态 + 面板点选(含四类非法目标在面板内报因、Esc 只收面板),新增「画布点选不清掉面板搜索/分类」;共享组件用例新增 nonModal 形态与浮层壳声明级契约。
- 文档:PRD 5.3 / 7.8 第 8 条改写、验收用例 S15a 改写、decision-log 新增 2026-09-21 条目、待办文档登记三项并勾掉对应需求。
This commit is contained in:
2026-09-21 03:48:40 +08:00
parent 9639a080c6
commit de502e9bc3
10 changed files with 511 additions and 302 deletions
@@ -1212,56 +1212,20 @@
}
/*
* 「点选替换」提示条:点选态下贴在资源画布顶端居中浮出。
* 「替换素材」面板的锚点:画布右上角、任务开关那一枚的下方。
*
* 与右下角缩放控件同一层语义(都在管理区之上,`position: absolute` 锚在
* `.game-resource-book-manager` 上,而不是挂在会随视口缩放 / 平移的场景里),
* 配色沿用工作台提示条那一套(`.game-resource-live-notice`)。
* 面板本体(`.image-canvas-editor__project-asset-picker--floating`)是**非模态浮层**:
* 面板开着的时候画布照常可点,在画布上点目标素材即落进面板的当前选择,写入只由面板「确认」发起。
* 所以这里只负责锚定,壳样式(边框 / 底色 / 阴影 / 头部与滚动区)在共享样式表里,宿主不抄第二份。
*
* 它是**会话级**提示:会话期间常驻,不跟随某张卡,所以层级高于场景(20)与缩放(40);
* 场景根是 `pointer-events: none`,这里必须显式收回指针事件,「取消」才点得动。
* 位置取舍:右上角是画布上唯一不被栏目工具栏(左下)与缩放 / 撤销 Dock(右下)占用的稳定空位;
* 顶部 3.5rem 起是任务开关,面板从 8.5rem 起排,两者同时开着也不重叠。
*/
.game-resource-canvas-pick-hint {
.game-resource-replacement-panel {
position: absolute;
top: 14px;
left: 50%;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 10px;
max-width: min(92vw, 34rem);
padding: 8px 8px 8px 12px;
border: 1px solid #edc7b5;
border-radius: 10px;
background: rgb(255 247 241 / 96%);
box-shadow: 0 6px 18px rgb(112 70 52 / 14%);
color: #8d5b45;
font-size: 11px;
line-height: 1.4;
transform: translateX(-50%);
pointer-events: auto;
}
.game-resource-canvas-pick-hint-text {
flex: 1 1 auto;
min-width: 0;
}
.game-resource-canvas-pick-hint-error {
flex: 0 1 auto;
min-width: 0;
color: #b3261e;
font-weight: 700;
}
.game-resource-canvas-pick-hint button {
flex: 0 0 auto;
border: 1px solid #dc9b7d;
border-radius: 8px;
background: #fff;
color: #9b5537;
font-size: 11px;
cursor: pointer;
top: 8.5rem;
right: 0.85rem;
z-index: 45;
}
/* 栏目画布底部工具栏:栏目页(view=child)左下角的画布 chrome。
@@ -2272,13 +2272,21 @@ export default function ProjectDevelopmentView({
useState<LocalProjectVersionReplacementCandidate[]>([]);
const [resourceReplacementOpen, setResourceReplacementOpen] = useState(false);
/**
* 「点选替换」:关掉候选弹窗(它的全屏遮罩会盖住画布),直接在资源画布上点目标素材。
* 在画布上点选出来的替换目标(manifest 资产 id)+ 这次点选的序号。
*
* 与弹窗互斥,但**不重置** `source` / `candidates`:点选时的合法性判据(
* `resolveResourceReplacementPick`)与写入载荷用的还是这一份冻结的源身份与权威候选。
* 替换面板是**非模态**的:面板开着的时候画布照常可点,点中的合法候选落成这里的「本次目标」,
* 面板里随之选中它;真正的写入仍然只由面板的「确认」发起。所以它既不是「点了就换」,
* 也不是第二份候选——合法性判据(`resolveResourceReplacementPick`)与写入载荷用的还是
* 那一份冻结的源身份与权威候选。
*
* `revision` 是给面板的「初值换了」信号:面板只在打开那一帧读 `selectedAssetIds`,
* 而数组本身每次渲染都会重建、不能当信号(见组件里的注释)。连续点同一张卡时序号照增,
* 保证每次都真的同步一次。
*/
const [resourceReplacementPickMode, setResourceReplacementPickMode] =
useState(false);
const [resourceReplacementPick, setResourceReplacementPick] = useState<{
assetId: string;
revision: number;
} | null>(null);
const [resourceReplacementLoading, setResourceReplacementLoading] =
useState(false);
const [resourceReplacementError, setResourceReplacementError] = useState<
@@ -5885,9 +5893,9 @@ export default function ProjectDevelopmentView({
if (event.button !== 0) {
return;
}
if (resourceReplacementPickMode) {
// 点选态下左键按下就是「选这个目标」:不起拖拽、不换选中,直接把这次点选交给
// 替换链路(合法即提交,非法把原因留在提示条上)。
if (resourceReplacementOpen) {
// 替换面板开着时左键按下就是「选这个目标」:不起拖拽、不换画布选中,直接把这次点选
// 交给替换链路(合法即记为本次目标,非法把原因留在面板里)。
pickResourceReplacementTargetRef.current(resource);
return;
}
@@ -5953,7 +5961,7 @@ export default function ProjectDevelopmentView({
resourceCategoryScopeKey,
resourceBookOpensAllResources,
resourceBookScenePlan,
resourceReplacementPickMode,
resourceReplacementOpen,
selectedResourceIds,
visibleResourceIds,
],
@@ -6156,16 +6164,6 @@ export default function ProjectDevelopmentView({
) {
return;
}
// 点选态下空白处的左键不起框选、也不清画布焦点/选中:点选只认资源卡,
// 点空白既不退出点选,也不把用户正在替换的选中清掉。中键 / 空格平移照常,
// 滚轮平移与缩放走 manager 上那条与指针无关的通路,不受影响。
if (
resourceReplacementPickMode &&
event.button === 0 &&
!resourceCanvasSpacePanRef.current
) {
return;
}
resourceBookTransitionControllerRef.current.settle();
// 右键平移,保留中键/空格抓手;空白处左键继续框选。
if (isPan) {
@@ -6201,7 +6199,6 @@ export default function ProjectDevelopmentView({
activePageCategory,
clearResourceCanvasFocus,
resourceBookOpensAllResources,
resourceReplacementPickMode,
],
);
@@ -7020,8 +7017,8 @@ export default function ProjectDevelopmentView({
setResourceReplacementError(null);
setResourceReplacementCandidates([]);
setResourceReplacementSource(source);
// 从工具条重新进入口时先退出上一次的点选会话(点选态下工具条仍可点)。
setResourceReplacementPickMode(false);
// 从工具条重新进入口时清掉上一次在画布上点出来的目标:面板里的选中必须与这次源素材一致。
setResourceReplacementPick(null);
setResourceReplacementLoading(true);
try {
const result = await readVersionResourceReplacementCandidates({
@@ -7043,29 +7040,21 @@ export default function ProjectDevelopmentView({
[activeVersionId, projectPath, projectVersions],
);
/**
* 结束一次替换会话:弹窗、点选态与冻结的源身份 / 候选一起清干净。
* 结束一次替换会话:面板、点选目标与冻结的源身份 / 候选一起清干净。
*
* 弹窗取消、点选态取消(Esc / 提示条「取消」)与替换成功都走这一处,避免出现
* "弹窗关了但点选态还挂着"或"点选退出了但候选还留着"的半截状态。
* 面板取消(× / Esc)、替换成功与切工作面都走这一处,避免出现「面板关了但目标还挂着」的
* 半截状态。
*/
const closeResourceVersionReplacement = useCallback(() => {
setResourceReplacementOpen(false);
setResourceReplacementPickMode(false);
setResourceReplacementPick(null);
setResourceReplacementSource(null);
setResourceReplacementCandidates([]);
setResourceReplacementError(null);
// 点选态给资源卡留的一次性点击抑制在这里收尾:不清干净的话,退出点选后下一次点
// 点选给资源卡留的一次性点击抑制在这里收尾:不清干净的话,关掉面板后下一次点
// 同一张卡会被吞掉一次(与拖拽结束后的收尾同一口径)。
clearSkippedResourceCardClick();
}, [clearSkippedResourceCardClick]);
/**
* 进入「点选替换」:关掉候选弹窗(它的全屏遮罩会盖住画布),冻结的源身份与权威候选原样留着。
*/
const enterResourceReplacementPickMode = useCallback(() => {
setResourceReplacementOpen(false);
setResourceReplacementError(null);
setResourceReplacementPickMode(true);
}, []);
/**
* 确认替换:直接改该版本的绑定(不建新版本),成功后重读 manifest。
*
@@ -7076,7 +7065,10 @@ export default function ProjectDevelopmentView({
const confirmResourceVersionReplacement = useCallback(
async (assetIds: string[]) => {
const source = resourceReplacementSource;
const replacementResourceId = assetIds[0];
// 点选出来的目标优先兜底:面板的当前选择来自 `initialSelectionRevision` 同步,
// 用户在点完画布那一帧立刻按确认(重渲染还没落地)时,不该给出「请选择一个替换素材」。
const replacementResourceId =
assetIds[0] ?? resourceReplacementPick?.assetId ?? null;
if (!source || !replacementResourceId) {
setResourceReplacementError('请选择一个替换素材');
return;
@@ -7130,6 +7122,7 @@ export default function ProjectDevelopmentView({
projectPath,
reloadManifestAfterAssetCommand,
resourceReplacementLoading,
resourceReplacementPick,
resourceReplacementSource,
],
);
@@ -7208,11 +7201,11 @@ export default function ProjectDevelopmentView({
}, [resourceReplacementOpen, sweepResourceReplacementPreviews]);
/**
* 点选态:把画布上点到的那张资源卡落成一次替换提交,或把「为什么不能替换」留在提示条上。
* 画布点选:把点到的那张资源卡落成**本次替换目标**,或把「为什么不能替换」留在面板里。
*
* 卡片的单击不参与选中:这里先登记一次性抑制(与卡片拖拽后抑制误点同一机制),随后那次
* `click` 会在 `handleResourceSelect` 里被吃掉 —— 只是这一下被消费,不是把单击全局关掉。
* 合法目标直接走既有 `confirmResourceVersionReplacement`,写入路径与弹窗确认**完全同一条**。
* 合法目标只记进面板(不写盘):写入路径与面板确认**完全同一条**,由用户按下确认才算数。
*/
const pickResourceReplacementTarget = useCallback(
(resource: ProjectResource) => {
@@ -7229,49 +7222,34 @@ export default function ProjectDevelopmentView({
return;
}
setResourceReplacementError(null);
void confirmResourceVersionReplacement([
resolution.replacementResourceId,
]);
setResourceReplacementPick((current) => ({
assetId: resolution.replacementResourceId,
revision: (current?.revision ?? 0) + 1,
}));
},
[
confirmResourceVersionReplacement,
resourceReplacementCandidates,
resourceReplacementSource,
],
[resourceReplacementCandidates, resourceReplacementSource],
);
pickResourceReplacementTargetRef.current = pickResourceReplacementTarget;
/**
* 点选态的退出:Esc、提示条「取消」、替换成功(`closeResourceVersionReplacement`)。
* 替换面板只在资源画布上成立:切运行态 / 进 UI 编辑器就结束这次会话。
*
* Esc 挂在 **document** 上并 `stopPropagation`:资源画布自己的 Esc 挂在 window 上(清画布
* 焦点 = 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到"Esc 只退出
* 点选、不连带清选中"(与画布浮层「浮层打开时 Escape 归浮层所有」同一口径)。
*
* 点选会话只在资源画布可见时存在:切运行态 / 进 UI 编辑器就结束它,否则会留下一个既看不到
* 提示条、也按不到 Esc 的会话。
* 非模态面板与画布是同一屏的两半,离开这块工作面它既点不到目标素材、也说不清自己在替换谁;
* Esc / × 关闭由面板自己管(见共享弹窗组件的 `nonModal`)。
*/
useEffect(() => {
if (!resourceReplacementPickMode) {
if (!resourceReplacementOpen) {
return undefined;
}
if (mode !== 'resources' || uiEditorRoute) {
closeResourceVersionReplacement();
return undefined;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') {
return;
}
event.stopPropagation();
closeResourceVersionReplacement();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
return undefined;
}, [
closeResourceVersionReplacement,
mode,
resourceReplacementPickMode,
resourceReplacementOpen,
uiEditorRoute,
]);
@@ -10331,27 +10309,6 @@ export default function ProjectDevelopmentView({
onPointerCancel={stopResourceCanvasPan}
onLostPointerCapture={handleResourceCanvasLostPointerCapture}
/>
{resourceReplacementPickMode ? (
<div className="game-resource-canvas-pick-hint">
<span className="game-resource-canvas-pick-hint-text">
在画布上点选要替换成的素材;点击空白处不会退出
</span>
{resourceReplacementError ? (
<span
className="game-resource-canvas-pick-hint-error"
role="alert"
>
{resourceReplacementError}
</span>
) : null}
<button
type="button"
onClick={closeResourceVersionReplacement}
>
取消
</button>
</div>
) : null}
{resourceCanvasBottomToolbarCategory ? (
<ResourceCanvasBottomToolbarView
category={resourceCanvasBottomToolbarCategory}
@@ -10822,6 +10779,57 @@ export default function ProjectDevelopmentView({
uiEditorRoute ? 'editor' : mode === 'run' ? 'run' : 'canvas'
}
/>
{/*
版本级资源替换(直接替换):复用美术画布的参考图选择组件,但走**非模态浮层**——
面板挂在画布右上角(在任务开关下方),面板开着的时候画布照常可点:在画布上点目标
素材即落进面板的当前选择,写入仍然只由面板的「确认」发起。
候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),组件只负责把
管线给的 `sourceUrl` 画出来;读不到时退回类型占位,与资源卡占位语义一致。
*/}
<div className="platform-theme platform-theme--light game-resource-replacement-panel">
<ImageCanvasProjectAssetPickerDialog
open={resourceReplacementOpen}
assets={resourceReplacementPickerEntries}
selectedAssetIds={
resourceReplacementPick ? [resourceReplacementPick.assetId] : []
}
initialSelectionRevision={resourceReplacementPick?.revision ?? 0}
singleSelect
nonModal
selectionNoun="替换素材"
assetBlockedReasons={resourceReplacementBlockedReasonMap}
assetHints={resourceReplacementHintMap}
errorMessage={resourceReplacementError}
renderAssetMedia={(asset) => {
const resource =
resourceByManifestAssetId.get(asset.id) ?? null;
const previewIdentity = resource
? (resourceCardPreviews.identityByResourceId.get(
resource.id,
) ?? null)
: null;
return (
<ResourcePreviewMedia
resource={resource}
previewIdentity={previewIdentity}
preview={
previewIdentity
? (resourceCardPreviews.previews.get(previewIdentity) ??
resourceCardPreviews.idlePreview)
: resourceCardPreviews.idlePreview
}
onObservePreview={resourceCardPreviews.observePreview}
onPreviewDecodeError={handleCardPreviewDecodeError}
fallbackMediaType={asset.mediaType}
/>
);
}}
onCancel={closeResourceVersionReplacement}
onConfirm={(assetIds) =>
void confirmResourceVersionReplacement(assetIds)
}
/>
</div>
</section>
{!uiEditorRoute ? (
@@ -11068,47 +11076,12 @@ export default function ProjectDevelopmentView({
/>
) : null}
{/*
版本级资源替换(直接替换):复用美术画布的参考图弹窗(单选 + 禁用原因 + 提示 + 失败原因)。
候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),弹窗只负责把
版本级资源替换(直接替换):复用美术画布的参考图选择组件,但走**非模态浮层**——
面板挂在画布右上角,面板开着的时候画布照常可点:在画布上点目标素材即落进面板的当前选择,
写入仍然只由面板的「确认」发起(`confirmResourceVersionReplacement` 与旧弹窗确认同一条)。
候选缩略图走**资源卡同一条预览管线**(身份 / 队列 / LRU 缓存都共用),组件只负责把
管线给的 `sourceUrl` 画出来;读不到时退回类型占位,与资源卡占位语义一致。
*/}
<ImageCanvasProjectAssetPickerDialog
open={resourceReplacementOpen}
assets={resourceReplacementPickerEntries}
selectedAssetIds={[]}
singleSelect
selectionNoun="替换素材"
assetBlockedReasons={resourceReplacementBlockedReasonMap}
assetHints={resourceReplacementHintMap}
errorMessage={resourceReplacementError}
renderAssetMedia={(asset) => {
const resource = resourceByManifestAssetId.get(asset.id) ?? null;
const previewIdentity = resource
? (resourceCardPreviews.identityByResourceId.get(resource.id) ??
null)
: null;
return (
<ResourcePreviewMedia
resource={resource}
previewIdentity={previewIdentity}
preview={
previewIdentity
? (resourceCardPreviews.previews.get(previewIdentity) ??
resourceCardPreviews.idlePreview)
: resourceCardPreviews.idlePreview
}
onObservePreview={resourceCardPreviews.observePreview}
onPreviewDecodeError={handleCardPreviewDecodeError}
fallbackMediaType={asset.mediaType}
/>
);
}}
onCancel={closeResourceVersionReplacement}
onPickFromCanvas={enterResourceReplacementPickMode}
onConfirm={(assetIds) =>
void confirmResourceVersionReplacement(assetIds)
}
/>
{resourceRecoveryPanelOpen ? (
<div
className="game-approval-backdrop"
@@ -335,6 +335,80 @@ describe('「选择替换素材」弹窗在 AGC 的面板底色', () => {
expect(overlay!.className).toMatch(/platform-theme--(light|dark)/u);
});
it('nonModal 形态:只渲染浮层壳,不铺遮罩也不算模态面板', () => {
render(
<ImageCanvasProjectAssetPickerDialog
open
assets={[]}
selectedAssetIds={[]}
singleSelect
nonModal
selectionNoun="替换素材"
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
const dialog = screen.getByRole('dialog', { name: '选择替换素材' });
expect(dialog.className).toContain(
'image-canvas-editor__project-asset-picker--floating',
);
expect(dialog.className).not.toContain(PLATFORM_MODAL_SHELL.slice(1));
// 非模态的核心判据:没有全屏遮罩——宿主画布必须保持可点(在画布上点选目标素材)。
expect(document.querySelector(PLATFORM_OVERLAY)).toBeNull();
// 关掉就是卸载:宿主把 open 置回 false 后不该在画布角落留半块面板。
cleanup();
render(
<ImageCanvasProjectAssetPickerDialog
open={false}
assets={[]}
selectedAssetIds={[]}
singleSelect
nonModal
selectionNoun="替换素材"
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
});
it('AGC 加载的样式表带非模态浮层的底色 / 边框 / 投影与滚动区', () => {
const shell = findClassRule(
loadedRules,
'.image-canvas-editor__project-asset-picker--floating',
);
expect(
shell,
'非模态浮层壳不在 AGC 加载的样式表里:面板会变透明',
).not.toBeNull();
expect(shell!.declarations.get('background')).toBe(
'var(--platform-modal-fill)',
);
expect(shell!.declarations.get('border')).toBe(
'1px solid var(--platform-modal-border)',
);
expect(shell!.declarations.get('box-shadow')).toBe(
'var(--platform-panel-shadow)',
);
const header = findClassRule(
loadedRules,
'.image-canvas-editor__project-asset-picker-header',
);
expect(header).not.toBeNull();
// 浮层自己限高 + 内部滚动:候选再多也不把画布整块盖住。
const scroll = findClassRule(
loadedRules,
'.image-canvas-editor__project-asset-picker-scroll',
);
expect(scroll!.declarations.get('overflow-y')).toBe('auto');
const actions = findClassRule(
loadedRules,
'.image-canvas-editor__project-asset-picker-actions',
);
expect(actions).not.toBeNull();
});
it('没有平行拷贝:外壳三条规则只在共享表里定义一次', () => {
/*
这里必须**先转绝对路径再判断存在**:`filter(existsSync)` 拿到的是仓库相对路径,`existsSync`
@@ -297,7 +297,8 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
}
if (command === 'update_local_project_resource_classification') {
const input = args?.input as
{ assetId?: string; category?: string; tags?: string[] } | undefined;
| { assetId?: string; category?: string; tags?: string[] }
| undefined;
return {
asset: {
id: input?.assetId,
@@ -425,15 +426,10 @@ function replacementWrites(invoke: { mock: { calls: unknown[][] } }) {
);
}
/** 点选态的提示条;不在点选态时为 `null`。 */
function pickHint() {
return document.querySelector<HTMLElement>('.game-resource-canvas-pick-hint');
}
/**
* 在点选态下点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。
* 在替换面板开着时点一张资源卡:按下(这一步就是点选)+ 真实浏览器里紧随其后的那一次 `click`。
*
* 之所以把 `click` 也补上:点选态要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown
* 之所以把 `click` 也补上:点选要求"卡片单击不参与选中",而那正是靠**消费掉紧随 pointerdown
* 的那一次 click** 实现的 —— 不补这一下,用例就漏掉了抑制残留这条最可能的回归。
*/
async function pickResourceCard(label: string) {
@@ -1121,12 +1117,10 @@ describe('版本级资源替换', () => {
});
/**
* 替换弹窗的「点选替换」入口(PRD §5.3):关掉弹窗、改在画布上点目标素材。
*
* 进入点选必须**卸载弹窗**:弹窗外壳是全屏遮罩,留着它画布上的卡根本点不到。
* 入口本身不改数据:候选读取是打开弹窗时的事,这里一次替换写入都不该有。
* 替换面板是**非模态浮层**(PRD §5.3):面板开着的时候画布照常可点,在画布上点目标素材即落进
* 面板里的当前选择;写入仍然只由面板的「确认」发起,所以点选本身一次写入都不该有。
*/
it('点选替换:入口关掉弹窗进入点选态,提示条写明退出方式且不写盘', async () => {
it('替换面板非模态:画布上点候选即落进面板的当前选择,零写入', async () => {
const { invoke } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
@@ -1134,27 +1128,74 @@ describe('版本级资源替换', () => {
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
// 非模态判据:没有全屏遮罩,面板自己是画布上的浮层(否则画布上的卡根本点不到)。
expect(dialog.className).toContain(
'image-canvas-editor__project-asset-picker--floating',
);
expect(document.querySelector('.platform-overlay')).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull();
const hint = pickHint();
if (!hint) throw new Error('点选提示条未渲染');
expect(hint.textContent).toContain('在画布上点选要替换成的素材');
// 空白处点击不退出这件事必须写在提示条上:用户按直觉点空白才不会以为点坏了。
expect(hint.textContent).toContain('点击空白处不会退出');
expect(within(hint).getByRole('button', { name: '取消' })).not.toBeNull();
const option = within(dialog).getByRole('option', {
name: '选择替换素材final.png',
});
await waitFor(() =>
expect(option.getAttribute('aria-selected')).toBe('true'),
);
// 点选只改面板里的当前选择:写入还是得等「确认」。
expect(replacementWrites(invoke)).toHaveLength(0);
// 点选那一下自带的 click 不得换画布选中:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
});
/**
* 点选态下合法目标直接提交:写入路径与弹窗确认**完全同一条**(同一个 `confirm` 函数),
* 载荷必须逐字一致,且成功后自动退出点选态。
* 在画布上点目标素材只同步「当前选择」这一项:面板里的搜索词与分类筛选不能被重置 —— 用户
* 常常是先筛到一半、才想起目标就在画布上(这正是这次的入口动机)。同步点由宿主的显式序号给
* (`initialSelectionRevision`),不是「props 变了就重置」。
*/
it('画布点选同步当前选择,但不清掉面板里的搜索与分类', async () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
const search = within(dialog).getByLabelText('搜索参考图素材');
fireEvent.change(search, { target: { value: 'final' } });
expect((search as HTMLInputElement).value).toBe('final');
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
await waitFor(() =>
expect(
within(dialog)
.getByRole('option', { name: '选择替换素材final.png' })
.getAttribute('aria-selected'),
).toBe('true'),
);
// 搜索词还在:同步选择没有顺手把面板筛回「全部 / 空关键词」。
expect(
(within(dialog).getByLabelText('搜索参考图素材') as HTMLInputElement)
.value,
).toBe('final');
});
/**
* 面板里按下「确认」才写入:写入路径与旧的弹窗确认**完全同一条**(同一个 `confirm` 函数),
* 载荷必须逐字一致,成功后关闭面板并标注血缘。
*
* 同时钉住"一次性抑制"的收尾:退出点选后点**同一张**刚被点选过的卡,单击语义必须完好
* 同时钉住"一次性抑制"的收尾:关闭面板后点**同一张**刚被点选过的卡,单击语义必须完好
* (换选中)。抑制残留就会在这一步被吞掉 —— 这是最容易被写错的一处。
*/
it('点选替换:点中合法候选提交一次且载荷一致,成功后自动退出点选态', async () => {
it('面板里确认才写入:载荷一致、成功后关闭面板并标注血缘', async () => {
const { invoke, onManifestChange } = renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
@@ -1162,12 +1203,22 @@ describe('版本级资源替换', () => {
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('final.png');
await waitFor(() =>
expect(
within(dialog)
.getByRole('option', { name: '选择替换素材final.png' })
.getAttribute('aria-selected'),
).toBe('true'),
);
fireEvent.click(
within(dialog).getByRole('button', { name: '确认选择替换素材' }),
);
await waitFor(() => expect(replacementWrites(invoke)).toHaveLength(1));
expect(replacementWrites(invoke)[0]?.[1]).toEqual({
@@ -1187,13 +1238,15 @@ describe('版本级资源替换', () => {
expect.objectContaining({ revision: 6, source: 'asset-command' }),
),
);
// 点选那一下自带的 click 不得换选中:源素材仍是选中的那一个。
// 写入成功即结束这次会话:面板收掉,画布选中不动。
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
);
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
await waitFor(() => expect(pickHint()).toBeNull());
// 退出点选后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
// 会话结束后单击语义不变:点同一张卡照常选中自己,抑制没有残留。
const finalCard = (await findResourceSelectButton('final.png')).closest(
'.game-resource-card',
);
@@ -1210,13 +1263,13 @@ describe('版本级资源替换', () => {
});
/**
* 点选态下点非法目标:说明原因、**留在**点选态、零写入,且点空白既不退出也不清选中。
* 非法目标:说明原因、**留在**面板里、零写入。
*
* 四种非法各点一次:源素材本身、不在权威候选里、分类不同的候选(跨栏目点)、
* manifest 里没有的身份(未登记附件)。判据全部来自同一条 `resolveResourceReplacementPick`,
* 文案与候选弹窗同源。
* 文案与候选列表同源。
*/
it('点选替换:非法目标不提交、留在点选态并说明原因,点空白不退出', async () => {
it('非法目标:面板里说明原因、面板留着、零写入', async () => {
const { invoke } = renderReplacementWorkbench({
attachments: [
{
@@ -1233,17 +1286,16 @@ describe('版本级资源替换', () => {
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
const reason = () => within(dialog).getByRole('alert').textContent;
// ① 点的就是源素材本身。
await pickResourceCard('legacy.png');
expect(screen.getByRole('alert').textContent).toBe('替换素材与源素材相同');
expect(pickHint()).not.toBeNull();
await waitFor(() => expect(reason()).toBe('替换素材与源素材相同'));
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
// 空白处左键:既不退出点选,也不清画布选中(清选中会连带收起选中工具条)。
// 空白处左键:面板不关(非模态面板不该被画布点击吃掉),也不写入。
const canvas = screen.getByLabelText('资源依赖视图');
// 指针捕获在 jsdom 里没有实现;这里补桩是为了让"这条按下到底走到哪一步"只由行为断言
// 判定,而不是被一个缺失的 DOM API 提前打断。
@@ -1258,31 +1310,23 @@ describe('版本级资源替换', () => {
clientX: 5,
clientY: 5,
});
expect(pickHint()).not.toBeNull();
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),
).toBe('true');
screen.queryByRole('dialog', { name: '选择替换素材' }),
).not.toBeNull();
// ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行
// (判据是"候选里有没有",不在前端另算一遍兼容性)。
await pickResourceCard('late.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材未登记或已被删除',
);
expect(pickHint()).not.toBeNull();
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
// ③ 分类不同的候选:目标在别的栏目,点选会话要跨栏目活着。
// ③ 分类不同的候选:目标在别的栏目,替换会话要跨栏目活着。
await openResourceBookCategory('场景与环境');
expect(pickHint()).not.toBeNull();
act(() => {
observer?.triggerVisible();
});
await pickResourceCard('scene.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材不兼容:分类不同',
);
expect(pickHint()).not.toBeNull();
// 非法目标同样不换选中:点选态里卡片单击只用来点选。
await waitFor(() => expect(reason()).toBe('替换素材不兼容:分类不同'));
// 非法目标同样不换选中:面板开着时卡片单击只用来点选。
expect(
getResourceSelectButton('scene.png').getAttribute('aria-pressed'),
).toBe('false');
@@ -1293,31 +1337,23 @@ describe('版本级资源替换', () => {
observer?.triggerVisible();
});
await pickResourceCard('草稿.png');
expect(screen.getByRole('alert').textContent).toBe(
'替换素材未登记或已被删除',
);
expect(pickHint()).not.toBeNull();
await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除'));
expect(replacementWrites(invoke)).toHaveLength(0);
});
/**
* 点选态的 Esc 只退出点选:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 +
* 收浮层),提示条这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的
* 选中一起丢。
* 面板开着时的 Esc 只收面板:资源画布自己的 Esc 挂在 window 上(清画布焦点 = 清选中 + 收浮层),
* 面板这条线必须在 document 上截断它 —— 否则用户按一次 Esc 会连正在替换的选中一起丢。
*
* 判据分两层:window 上的监听器收不到这次 Escape(截断生效),且选中工具条仍在。
* 变异验证:去掉 `event.stopPropagation()`,本用例必须失败。
*/
it('点选替换:Esc 退出点选态且不清画布选中', async () => {
it('替换面板:Esc 收面板且不清画布选中', async () => {
renderReplacementWorkbench();
const toolbar = await selectCardAndOpenToolbar('legacy.png');
fireEvent.click(toolbarAction(toolbar, '替换素材'));
const dialog = await screen.findByRole('dialog', {
name: '选择替换素材',
});
fireEvent.click(within(dialog).getByRole('button', { name: '点选替换' }));
expect(pickHint()).not.toBeNull();
await screen.findByRole('dialog', { name: '选择替换素材' });
const windowEsc = vi.fn();
window.addEventListener('keydown', windowEsc);
@@ -1328,7 +1364,9 @@ describe('版本级资源替换', () => {
}
expect(windowEsc).not.toHaveBeenCalled();
expect(pickHint()).toBeNull();
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(),
);
// 选中没有跟着被清掉:源素材仍是选中的那一个。
expect(
getResourceSelectButton('legacy.png').getAttribute('aria-pressed'),