替换面板改为非模态浮层,支持在画布上点选替换目标
- 共享选择组件新增 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:
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user