替换面板改为非模态浮层,支持在画布上点选替换目标
- 共享选择组件新增 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:
@@ -1,9 +1,16 @@
|
||||
import { Check, ImageIcon, Music, Search, Video } from 'lucide-react';
|
||||
import { type ReactNode, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../../../packages/shared/src/components/PlatformActionButton';
|
||||
import { PlatformResourceFilterBar } from '../../../packages/shared/src/components/PlatformResourceFilterBar';
|
||||
import { PlatformStatusMessage } from '../../../packages/shared/src/components/PlatformStatusMessage';
|
||||
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import type { EditorAsset } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
@@ -59,13 +66,24 @@ type ImageCanvasProjectAssetPickerDialogProps = {
|
||||
*/
|
||||
errorMessage?: string | null;
|
||||
/**
|
||||
* 「点选替换」:由宿主关闭本弹窗,改在画布上直接点选目标素材。
|
||||
* 非模态浮层:不铺全屏遮罩、不做焦点陷阱,宿主画布保持可点。
|
||||
*
|
||||
* 默认 `undefined` → **不渲染该入口**,弹窗行为与现在逐字不变(网页端美术画布的参考图
|
||||
* 选择不传它,多选与确认流程原样保留)。传了才多出一个按钮,点它不做选择、不回传 id ——
|
||||
* 弹窗只负责把"用户要走点选"这件事告诉宿主,进入与退出点选态都由宿主自己的状态机管。
|
||||
* AGC 的「替换素材」用它:面板开着的时候直接在资源画布上点目标素材,点中的候选落进面板的
|
||||
* 当前选择,写入仍然只由面板的「确认」发起。默认 `false` → 网页端美术画布的弹窗行为逐字不变。
|
||||
*
|
||||
* 打开期间 Esc 仍等于「取消」(document 阶段截断,宿主画布的全局 Esc 不随之触发);
|
||||
* 点外部不关闭——非模态面板与画布是同一屏的两半,点画布是要选目标,不是要关面板。
|
||||
*/
|
||||
onPickFromCanvas?: () => void;
|
||||
nonModal?: boolean;
|
||||
/**
|
||||
* 「初值换了」的信号:宿主在面板**开着**的时候又给了新的 `selectedAssetIds`(AGC 里是在
|
||||
* 画布上点选目标素材),序号一变就按新初值重同步当前选择。
|
||||
*
|
||||
* 不能用 `selectedAssetIds` 的引用当信号:调用方每次渲染都会重建那个数组,把它放进依赖会
|
||||
* 让「父级任何一次重渲染」都清掉用户的选择(组件里原本就是这么写的)。所以同步点交给这个
|
||||
* 显式序号:只有宿主真的换了目标才变化。缺省 `0`(网页端美术画布不传,行为逐字不变)。
|
||||
*/
|
||||
initialSelectionRevision?: number;
|
||||
};
|
||||
|
||||
function assetIcon(category: ProjectAssetPickerCategory) {
|
||||
@@ -121,7 +139,8 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
renderAssetMedia,
|
||||
selectionNoun = '参考图',
|
||||
errorMessage,
|
||||
onPickFromCanvas,
|
||||
nonModal = false,
|
||||
initialSelectionRevision = 0,
|
||||
}: ImageCanvasProjectAssetPickerDialogProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ProjectAssetPickerCategory>('all');
|
||||
@@ -143,6 +162,18 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只在打开的那一帧重置
|
||||
}, [open]);
|
||||
|
||||
/**
|
||||
* 面板开着时宿主换了初值(AGC 在画布上点选目标素材):只同步这一项,别的不动。
|
||||
*
|
||||
* 搜索词与分类保持原样——用户在面板里筛到一半、又去画布上点一张,回来不该被重置成「全部」。
|
||||
* 选择直接落成新初值(单选场景就是那一项)。
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
setSelection([...selectedAssetIds]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 只认显式序号,不认数组引用
|
||||
}, [initialSelectionRevision, open]);
|
||||
|
||||
const visibleAssets = useMemo(
|
||||
() =>
|
||||
assets.filter((asset) =>
|
||||
@@ -171,59 +202,63 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title={`选择${selectionNoun}`}
|
||||
size="lg"
|
||||
portalTheme="light"
|
||||
closeLabel={`关闭选择${selectionNoun}`}
|
||||
onClose={onCancel}
|
||||
panelClassName="image-canvas-editor__project-asset-picker"
|
||||
bodyClassName="image-canvas-editor__project-asset-picker-body"
|
||||
footer={
|
||||
<>
|
||||
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
|
||||
已选 {selection.length} 个
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => setSelection([])}
|
||||
>
|
||||
清空
|
||||
</PlatformActionButton>
|
||||
{onPickFromCanvas ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onPickFromCanvas}
|
||||
>
|
||||
点选替换
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-label={`确认选择${selectionNoun}`}
|
||||
onClick={() => onConfirm(selection)}
|
||||
>
|
||||
确认
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
/**
|
||||
* 非模态下的 Esc = 取消。
|
||||
*
|
||||
* 挂在 **document** 并 `stopPropagation`:宿主画布的全局 Esc 挂在 window 上(清画布焦点 =
|
||||
* 清选中 + 收浮层),document 在冒泡路径上早于 window,这里截断才能做到「Esc 只收替换面板、
|
||||
* 不连带清画布选中」(与「浮层打开时 Escape 归浮层所有」同一口径)。
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!open || !nonModal) {
|
||||
return undefined;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
>
|
||||
event.stopPropagation();
|
||||
onCancel();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [nonModal, onCancel, open]);
|
||||
|
||||
const dialogLabel = `选择${selectionNoun}`;
|
||||
const pickerFooter = (
|
||||
<>
|
||||
<span className="mr-auto text-xs text-[var(--platform-text-base)]">
|
||||
已选 {selection.length} 个
|
||||
</span>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
disabled={selection.length === 0}
|
||||
onClick={() => setSelection([])}
|
||||
>
|
||||
清空
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-label={`确认选择${selectionNoun}`}
|
||||
onClick={() => onConfirm(selection)}
|
||||
>
|
||||
确认
|
||||
</PlatformActionButton>
|
||||
</>
|
||||
);
|
||||
const pickerBody = (
|
||||
<>
|
||||
{errorMessage ? (
|
||||
<PlatformStatusMessage
|
||||
tone="error"
|
||||
@@ -359,6 +394,57 @@ export function ImageCanvasProjectAssetPickerDialog({
|
||||
{visibleAssets.length} / {assets.length}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
/**
|
||||
* 非模态浮层:不铺遮罩、不抢焦点,宿主画布保持可点(AGC 的「替换素材」用它——面板开着时
|
||||
* 直接在画布上点目标素材,点中的候选落进这里的当前选择,写入仍然只由「确认」发起)。
|
||||
*/
|
||||
if (nonModal) {
|
||||
// 关掉就是卸载(模态那条路由 `UnifiedModal` 自己按 `open` 返回空):与弹窗同一判据,
|
||||
// 否则宿主把 `open` 置回 false 之后画布右上角还留着半块面板。
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="image-canvas-editor__project-asset-picker image-canvas-editor__project-asset-picker--floating"
|
||||
role="dialog"
|
||||
aria-label={dialogLabel}
|
||||
>
|
||||
<header className="image-canvas-editor__project-asset-picker-header">
|
||||
<strong>{dialogLabel}</strong>
|
||||
<PlatformModalCloseButton
|
||||
variant="platformIcon"
|
||||
placement="inline"
|
||||
label={`关闭选择${selectionNoun}`}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</header>
|
||||
<div className="image-canvas-editor__project-asset-picker-scroll">
|
||||
{pickerBody}
|
||||
</div>
|
||||
<footer className="image-canvas-editor__project-asset-picker-actions">
|
||||
{pickerFooter}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
open={open}
|
||||
title={dialogLabel}
|
||||
size="lg"
|
||||
portalTheme="light"
|
||||
closeLabel={`关闭选择${selectionNoun}`}
|
||||
onClose={onCancel}
|
||||
panelClassName="image-canvas-editor__project-asset-picker"
|
||||
bodyClassName="image-canvas-editor__project-asset-picker-body"
|
||||
footer={pickerFooter}
|
||||
>
|
||||
{pickerBody}
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user