重构AGC资源画布菜单与卡片入口(#409)
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
主菜单保留前五项,其余操作通过向上展开的更多菜单访问 类型与信息入口下沉资源卡片,并复用共享画布角标 补充菜单键盘、滚轮归属、换选及资源操作链路回归 同步方案A规范与待人工验收记录
This commit is contained in:
@@ -49,7 +49,7 @@ export function isResourceCanvasPanTarget(
|
||||
return Boolean(
|
||||
target.closest('.game-resource-card') &&
|
||||
!target.closest(
|
||||
'button:not(.game-resource-card-select), input, textarea, select, a, audio, video',
|
||||
'button:not(.game-resource-card-select), [role="button"], input, textarea, select, a, audio, video',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7494,33 +7494,6 @@ iframe.preview-frame {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.game-resource-card-type-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
max-width: calc(100% - 16px);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(255 255 255 / 72%);
|
||||
border-radius: 999px;
|
||||
background: rgb(75 48 38 / 84%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 8px 18px rgb(96 62 47 / 20%);
|
||||
transform: scale(var(--genarrative-image-canvas-inverse-scale, 1));
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
/* 替换血缘标注(本次会话内有效):源素材卡「已被 … 替换」/ 替换素材卡「替换自 …」。
|
||||
左下角是卡片上唯一空闲的角(右上角标是类型、右下是媒体播放钮),用「当前版本」同一支橙色
|
||||
把这条关系与光环联系起来。 */
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SelectionOverlay,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import { save as saveNativeFileDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { CanvasCardCornerActions } from '@genarrative/shared/components';
|
||||
import {
|
||||
AtSign,
|
||||
Crosshair,
|
||||
@@ -42,7 +43,6 @@ import {
|
||||
Replace,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Shapes,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
@@ -695,6 +695,9 @@ const ResourceCard = memo(function ResourceCard({
|
||||
cardSize,
|
||||
activeMediaIdentity,
|
||||
onSelect,
|
||||
onShowInfo,
|
||||
onChangeType,
|
||||
infoPressed,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
@@ -724,6 +727,9 @@ const ResourceCard = memo(function ResourceCard({
|
||||
cardSize: ResourceCanvasCardSize;
|
||||
activeMediaIdentity: string | null;
|
||||
onSelect: (resourceId: string, options?: { append?: boolean }) => void;
|
||||
onShowInfo: (resource: ProjectResource) => void;
|
||||
onChangeType: (assetId: string) => void;
|
||||
infoPressed: boolean;
|
||||
onPointerDown: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
resource: ProjectResource,
|
||||
@@ -1072,13 +1078,19 @@ const ResourceCard = memo(function ResourceCard({
|
||||
<span className="game-resource-card-visual" aria-hidden="true">
|
||||
{visual}
|
||||
</span>
|
||||
<span
|
||||
className="game-resource-card-type-badge"
|
||||
data-resource-type={cardTypeLabel}
|
||||
title={cardTypeLabel}
|
||||
>
|
||||
{cardTypeLabel}
|
||||
</span>
|
||||
<CanvasCardCornerActions
|
||||
kindLabel={cardTypeLabel}
|
||||
kindAriaLabel={`素材类型:${resource.label}`}
|
||||
kindClassName="game-resource-card-type-badge"
|
||||
infoLabel={`查看${resource.label}资源信息`}
|
||||
infoPressed={infoPressed}
|
||||
onKindClick={
|
||||
resource.manifestAssetId
|
||||
? () => onChangeType(resource.manifestAssetId!)
|
||||
: undefined
|
||||
}
|
||||
onInfoClick={() => onShowInfo(resource)}
|
||||
/>
|
||||
{lineage ? (
|
||||
// 文字是给人看的关系,`data-resource-lineage` 是给端到端验收的稳定判据
|
||||
// (稳定 id 见卡上的 `data-resource-replaced-by` / `data-resource-replacement-of`)。
|
||||
@@ -1604,7 +1616,12 @@ export default function ProjectDevelopmentView({
|
||||
const [characterAnimationPanel, setCharacterAnimationPanel] =
|
||||
useState<CharacterAnimationPanelState | null>(null);
|
||||
/** 画布上的只读信息浮层(「信息」动作的落点),与运行页签的信息面板同源。 */
|
||||
const [resourceInfoPanelOpen, setResourceInfoPanelOpen] = useState(false);
|
||||
const [resourceInfoResourceId, setResourceInfoResourceId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const resourceInfoPanelOpen =
|
||||
resourceInfoResourceId !== null &&
|
||||
selectedResourceIds[0] === resourceInfoResourceId;
|
||||
const [resourceCanvasMarquee, setResourceCanvasMarquee] =
|
||||
useState<CanvasMarqueeState | null>(null);
|
||||
/** 资源卡组织操作历史:只回滚布局坐标,不回滚素材。 */
|
||||
@@ -1909,7 +1926,7 @@ export default function ProjectDevelopmentView({
|
||||
*/
|
||||
const clearResourceCanvasFocus = useCallback(() => {
|
||||
setSelectedResourceIds([]);
|
||||
setResourceInfoPanelOpen(false);
|
||||
setResourceInfoResourceId(null);
|
||||
if (!canDismissResourceCanvasQuickEdit(quickEditPanelRef.current)) {
|
||||
return;
|
||||
}
|
||||
@@ -2333,7 +2350,9 @@ export default function ProjectDevelopmentView({
|
||||
* 是因为选中本身有多个清空入口(清焦点、切视图、切项目)。
|
||||
*/
|
||||
useEffect(() => {
|
||||
setResourceInfoPanelOpen(false);
|
||||
setResourceInfoResourceId((current) =>
|
||||
current === selectedResourceId ? current : null,
|
||||
);
|
||||
}, [selectedResourceId]);
|
||||
const projectVersions = useMemo(
|
||||
() => manifest.versions ?? [],
|
||||
@@ -6262,6 +6281,16 @@ export default function ProjectDevelopmentView({
|
||||
|
||||
const showRunUnavailableHint = !runAvailable && !uiEditorRoute;
|
||||
|
||||
const showResourceCardInfo = useCallback(
|
||||
(resource: ProjectResource) => {
|
||||
handleResourceSelect(resource.id);
|
||||
setResourceInfoResourceId((current) =>
|
||||
current === resource.id ? null : resource.id,
|
||||
);
|
||||
},
|
||||
[handleResourceSelect],
|
||||
);
|
||||
|
||||
const renderResourceBookCard = useCallback(
|
||||
(
|
||||
resource: ProjectResource,
|
||||
@@ -6319,6 +6348,9 @@ export default function ProjectDevelopmentView({
|
||||
}
|
||||
activeMediaIdentity={activeCardMediaIdentity}
|
||||
onSelect={handleResourceSelect}
|
||||
onShowInfo={showResourceCardInfo}
|
||||
onChangeType={setResourceTypeAssetId}
|
||||
infoPressed={resourceInfoResourceId === resource.id}
|
||||
onPointerDown={(event) =>
|
||||
handleResourceCardPointerDown(
|
||||
event,
|
||||
@@ -6350,6 +6382,8 @@ export default function ProjectDevelopmentView({
|
||||
handleResourceCardPointerMove,
|
||||
handleResourceCardPointerUp,
|
||||
handleResourceSelect,
|
||||
showResourceCardInfo,
|
||||
resourceInfoResourceId,
|
||||
resourceCardDragPreview,
|
||||
resourceCardPreviews,
|
||||
resourceReplacementLineageBadgeMap,
|
||||
@@ -7976,6 +8010,7 @@ export default function ProjectDevelopmentView({
|
||||
) ||
|
||||
selectedResourceOpensUiEditor) ? (
|
||||
<ImageCanvasSelectedLayerToolbarView
|
||||
maxVisibleActions={5}
|
||||
selectedLayer={selectedResourceLayer}
|
||||
selectedToolbarStyle={selectedToolbarStyle}
|
||||
supportedActions={selectedToolbarActions}
|
||||
@@ -8046,20 +8081,6 @@ export default function ProjectDevelopmentView({
|
||||
<span>引用</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="信息"
|
||||
title="信息"
|
||||
pressed={resourceInfoPanelOpen}
|
||||
icon={<Info className="h-4 w-4" />}
|
||||
onClick={() =>
|
||||
setResourceInfoPanelOpen((open) => !open)
|
||||
}
|
||||
>
|
||||
<span>信息</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
@@ -8075,21 +8096,6 @@ export default function ProjectDevelopmentView({
|
||||
<span>编辑标签</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="素材类型"
|
||||
title="素材类型"
|
||||
icon={<Shapes className="h-4 w-4" />}
|
||||
onClick={() =>
|
||||
setResourceTypeAssetId(
|
||||
selectedResource.manifestAssetId,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>素材类型</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedResource?.manifestAssetId ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
@@ -8287,12 +8293,12 @@ export default function ProjectDevelopmentView({
|
||||
const assetId =
|
||||
selectedResource.manifestAssetId;
|
||||
if (!assetId) return;
|
||||
setResourceInfoPanelOpen(false);
|
||||
setResourceInfoResourceId(null);
|
||||
setResourceTypeAssetId(assetId);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={() => setResourceInfoPanelOpen(false)}
|
||||
onClose={() => setResourceInfoResourceId(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -338,7 +338,7 @@ export function registerClientHomeTests() {
|
||||
expect(await findResourceSelectButton('live-hero.png')).not.toBeNull();
|
||||
await openResourceBookCategory('项目版本');
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /版本 1/ }),
|
||||
await findResourceSelectButton('版本 1'),
|
||||
).not.toBeNull();
|
||||
expect(runButton.getAttribute('data-unavailable')).toBeNull();
|
||||
await waitFor(() => {
|
||||
@@ -509,7 +509,7 @@ export function registerClientHomeTests() {
|
||||
).not.toBeNull();
|
||||
await openResourceBookCategory('项目版本');
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /版本 1/ }),
|
||||
await findResourceSelectButton('版本 1'),
|
||||
).not.toBeNull();
|
||||
expect(runButton.getAttribute('data-unavailable')).toBeNull();
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -3817,21 +3817,10 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
];
|
||||
|
||||
await openResourceBookCategory('角色与对象');
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
const toolbarLabels = within(toolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label') ?? '');
|
||||
const infoButton = within(toolbar).getByRole('button', { name: '信息' });
|
||||
// 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。
|
||||
expect(toolbarLabels.indexOf('信息')).toBeGreaterThan(
|
||||
toolbarLabels.indexOf('引用资源 hero.png'),
|
||||
);
|
||||
expect(toolbarLabels.indexOf('信息')).toBeLessThan(
|
||||
toolbarLabels.indexOf('编辑标签'),
|
||||
);
|
||||
const infoButton = await screen.findByRole('button', { name: '查看hero.png资源信息' });
|
||||
expect(infoButton.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
// 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。
|
||||
fireEvent.click(infoButton);
|
||||
const canvasPanel = await screen.findByRole('dialog', {
|
||||
name: '资源信息',
|
||||
@@ -3863,11 +3852,11 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
|
||||
// Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
const reopenedToolbar = await screen.findByRole('toolbar', {
|
||||
await screen.findByRole('toolbar', {
|
||||
name: '图片工具栏',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(reopenedToolbar).getByRole('button', { name: '信息' }),
|
||||
screen.getByRole('button', { name: '查看hero.png资源信息' }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '资源信息' }),
|
||||
@@ -4075,10 +4064,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
),
|
||||
).toBe(false);
|
||||
// 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」),
|
||||
// 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口,
|
||||
// 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest
|
||||
// `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」
|
||||
// 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程)
|
||||
// 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。
|
||||
// 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调,
|
||||
// 不能再渲染成点了没反应的按钮。
|
||||
const audioToolbar = screen.getByRole('toolbar', {
|
||||
@@ -4093,9 +4079,7 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual([
|
||||
'引用资源 bgm.mp3',
|
||||
'信息',
|
||||
'编辑标签',
|
||||
'素材类型',
|
||||
'重命名',
|
||||
'导出',
|
||||
'删除素材',
|
||||
|
||||
@@ -1914,9 +1914,8 @@ describe('project resource live canvas integration', () => {
|
||||
await openResourceBookCategory('角色与对象');
|
||||
expect(await cardBadgeText('hero.png')).toBe('角色与对象');
|
||||
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
|
||||
// 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' }));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '设置素材类型',
|
||||
});
|
||||
@@ -1993,7 +1992,7 @@ describe('project resource live canvas integration', () => {
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
|
||||
// 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' }));
|
||||
await screen.findByRole('dialog', { name: '设置素材类型' });
|
||||
fireEvent.click(document.body);
|
||||
expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull();
|
||||
@@ -2073,7 +2072,7 @@ describe('project resource live canvas integration', () => {
|
||||
await openResourceBookCategory('角色与对象');
|
||||
fireEvent.click(await findResourceSelectButton('hero.png'));
|
||||
const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' });
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '信息' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看hero.png资源信息' }));
|
||||
const infoPanel = await screen.findByRole('dialog', { name: '资源信息' });
|
||||
|
||||
// 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('resourceCanvasFocusModel', () => {
|
||||
<div class="game-resource-card" id="card">
|
||||
<button class="game-resource-card-select" id="select"></button>
|
||||
<button class="game-resource-card-media-control" id="play"></button>
|
||||
<span role="button" id="corner">信息</span>
|
||||
<video id="video"></video><input id="input" />
|
||||
</div>
|
||||
<div contenteditable="true" id="editor"></div>
|
||||
@@ -60,6 +61,7 @@ describe('resourceCanvasFocusModel', () => {
|
||||
}
|
||||
for (const id of [
|
||||
'play',
|
||||
'corner',
|
||||
'video',
|
||||
'input',
|
||||
'editor',
|
||||
|
||||
@@ -375,6 +375,13 @@ function renderReplacementWorkbench(options: RenderOptions = {}) {
|
||||
return { invoke, onActiveVersionChange, onPlay, onManifestChange };
|
||||
}
|
||||
|
||||
function toolbarAction(toolbar: HTMLElement, name: string) {
|
||||
const visible = within(toolbar).queryByRole('button', { name });
|
||||
if (visible) return visible;
|
||||
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
|
||||
return within(screen.getByRole('group', { name: '更多操作' })).getByRole('button', { name });
|
||||
}
|
||||
|
||||
async function selectCardAndOpenToolbar(label: string) {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -538,13 +545,61 @@ function installResourceCardIntersectionObserver() {
|
||||
}
|
||||
|
||||
describe('版本级资源替换', () => {
|
||||
it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => {
|
||||
renderReplacementWorkbench();
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
|
||||
const menu = screen.getByRole('group', { name: '更多操作' });
|
||||
const viewport = () => document.querySelector('[data-resource-viewport]')
|
||||
?.getAttribute('data-resource-viewport');
|
||||
const before = viewport();
|
||||
expect(before).toBeTruthy();
|
||||
const wheel = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: 120 });
|
||||
act(() => { menu.dispatchEvent(wheel); });
|
||||
expect(wheel.defaultPrevented).toBe(false);
|
||||
expect(viewport()).toBe(before);
|
||||
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar);
|
||||
fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' }));
|
||||
fireEvent.click(await findResourceSelectButton('late.png'));
|
||||
expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
|
||||
|
||||
const scene = document.querySelector('.game-resource-book-scene')!;
|
||||
act(() => {
|
||||
scene.dispatchEvent(new WheelEvent('wheel', {
|
||||
bubbles: true, cancelable: true, deltaY: 120, clientX: 90, clientY: 70,
|
||||
}));
|
||||
});
|
||||
expect(viewport()).not.toBe(before);
|
||||
});
|
||||
|
||||
it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => {
|
||||
renderReplacementWorkbench();
|
||||
await selectCardAndOpenToolbar('legacy.png');
|
||||
const lateInfo = screen.getByRole('button', { name: '查看late.png资源信息' });
|
||||
fireEvent.pointerDown(lateInfo, { button: 0 });
|
||||
fireEvent.click(lateInfo);
|
||||
const panel = screen.getByRole('dialog', { name: '资源信息' });
|
||||
expect(within(panel).getByText('late.png')).toBeTruthy();
|
||||
expect(lateInfo.getAttribute('aria-pressed')).toBe('true');
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看legacy.png资源信息' }));
|
||||
const switched = screen.getByRole('dialog', { name: '资源信息' });
|
||||
expect(within(switched).getByText('legacy.png')).toBeTruthy();
|
||||
expect(within(switched).queryByText('late.png')).toBeNull();
|
||||
fireEvent.click(await findResourceSelectButton('late.png'));
|
||||
expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull();
|
||||
});
|
||||
|
||||
it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => {
|
||||
const { invoke } = renderReplacementWorkbench();
|
||||
|
||||
// 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。
|
||||
const lateToolbar = await selectCardAndOpenToolbar('late.png');
|
||||
fireEvent.mouseEnter(within(lateToolbar).getByRole('button', { name: '更多' }));
|
||||
expect(
|
||||
within(lateToolbar).queryByRole('button', { name: '替换素材' }),
|
||||
screen.queryByRole('button', { name: '替换素材' }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(lateToolbar).getByRole('button', { name: '快速编辑' }),
|
||||
@@ -560,7 +615,7 @@ describe('版本级资源替换', () => {
|
||||
// 被当前版本绑定的素材:入口出现。
|
||||
const sourceToolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
expect(
|
||||
within(sourceToolbar).getByRole('button', { name: '替换素材' }),
|
||||
toolbarAction(sourceToolbar, '替换素材'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -569,7 +624,7 @@ describe('版本级资源替换', () => {
|
||||
renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -675,7 +730,7 @@ describe('版本级资源替换', () => {
|
||||
});
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
@@ -709,7 +764,7 @@ describe('版本级资源替换', () => {
|
||||
});
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -735,7 +790,7 @@ describe('版本级资源替换', () => {
|
||||
const { invoke } = renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
@@ -787,7 +842,7 @@ describe('版本级资源替换', () => {
|
||||
).length;
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
@@ -837,8 +892,11 @@ describe('版本级资源替换', () => {
|
||||
});
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
const toolbarLabels = within(toolbar)
|
||||
.getAllByRole('button')
|
||||
const deleteButton = toolbarAction(toolbar, '删除素材');
|
||||
const toolbarLabels = [
|
||||
...within(toolbar).getAllByRole('button'),
|
||||
...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole('button'),
|
||||
]
|
||||
.map((button) => button.getAttribute('aria-label') ?? '');
|
||||
// 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。
|
||||
expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan(
|
||||
@@ -848,9 +906,6 @@ describe('版本级资源替换', () => {
|
||||
toolbarLabels.indexOf('导出'),
|
||||
);
|
||||
// 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。
|
||||
const deleteButton = within(toolbar).getByRole('button', {
|
||||
name: '删除素材',
|
||||
});
|
||||
const divider = deleteButton.previousElementSibling;
|
||||
expect(divider?.getAttribute('class')).toMatch(
|
||||
/(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/,
|
||||
@@ -914,7 +969,7 @@ describe('版本级资源替换', () => {
|
||||
});
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '删除素材'));
|
||||
const dialog = await screen.findByRole('dialog', { name: '确认删除资源' });
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('checkbox', {
|
||||
@@ -976,7 +1031,7 @@ describe('版本级资源替换', () => {
|
||||
).toBeNull();
|
||||
// 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。
|
||||
expect(
|
||||
within(toolbar).getByRole('button', { name: '信息' }),
|
||||
screen.getByRole('button', { name: '查看草稿.png资源信息' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -1056,7 +1111,7 @@ describe('版本级资源替换', () => {
|
||||
const { invoke } = renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
@@ -1084,7 +1139,7 @@ describe('版本级资源替换', () => {
|
||||
const { invoke, onManifestChange } = renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
@@ -1155,7 +1210,7 @@ describe('版本级资源替换', () => {
|
||||
});
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
@@ -1238,7 +1293,7 @@ describe('版本级资源替换', () => {
|
||||
renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
@@ -1274,7 +1329,7 @@ describe('版本级资源替换', () => {
|
||||
const { invoke } = renderReplacementWorkbench();
|
||||
|
||||
const toolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' }));
|
||||
fireEvent.click(toolbarAction(toolbar, '替换素材'));
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
});
|
||||
@@ -1357,7 +1412,7 @@ describe('版本级资源替换', () => {
|
||||
// 第一次:legacy → final。
|
||||
const legacyToolbar = await selectCardAndOpenToolbar('legacy.png');
|
||||
fireEvent.click(
|
||||
within(legacyToolbar).getByRole('button', { name: '替换素材' }),
|
||||
toolbarAction(legacyToolbar, '替换素材'),
|
||||
);
|
||||
let dialog = await screen.findByRole('dialog', {
|
||||
name: '选择替换素材',
|
||||
@@ -1377,7 +1432,7 @@ describe('版本级资源替换', () => {
|
||||
// 第二次:final → final.webp(同一个工作台会话内)。
|
||||
const finalToolbar = await selectCardAndOpenToolbar('final.png');
|
||||
fireEvent.click(
|
||||
within(finalToolbar).getByRole('button', { name: '替换素材' }),
|
||||
toolbarAction(finalToolbar, '替换素材'),
|
||||
);
|
||||
dialog = await screen.findByRole('dialog', { name: '选择替换素材' });
|
||||
fireEvent.click(
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# AGC 资源菜单收纳实施计划
|
||||
|
||||
对应:[里程碑](./【里程碑】AGC资源菜单收纳-2026-09-17.md),Issue #409,产品已确认方案 A。
|
||||
|
||||
1. 在 shared 扩展通用操作收纳及卡片角标控件;共用工具栏只给 AGC 开启 5 项限制,Web 卡片迁移共用角标而不改现有回调。
|
||||
2. AGC 卡片承接类型和信息,保留当前面板与命令链;信息使用资源身份防止换选竞态。
|
||||
3. 补工具栏/工作台定向回归,检查禁用、移入、Escape、换选和卡片事件边界。
|
||||
4. 并行执行定向 Vitest、AGC 类型检查、编码与文档索引检查,再自审整体调用链。
|
||||
|
||||
风险:portal 浮层点击外部判定、缩放角标与拖拽冲突、原测试依赖完整工具栏。回滚仅撤销本分支 UI 与文档修改;无数据迁移。首个检查点为组件用例通过,第二个为工作台集成与类型检查。真实客户端未测则明确保留待验收状态。
|
||||
@@ -0,0 +1,31 @@
|
||||
# AGC 资源菜单收纳
|
||||
|
||||
- Version: 1
|
||||
- Status: implemented,本地自动化通过,待真实客户端验收
|
||||
- Date: 2026-09-17
|
||||
- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
|
||||
|
||||
## 范围与评审
|
||||
|
||||
仅调整前端入口和临时浮层状态,不修改资源命令、权限、持久化、后端或 Web 默认菜单行为。主菜单保留前 5 项,其余悬停/点击向上展开;类型与信息下沉卡片。产品已选定方案 A,边界与既有资源操作合同无冲突,按单里程碑实施。对应 Issue #409,已获得创建 Issue 与本地实施授权;推送、PR 与飞书写入仍需单独确认。
|
||||
|
||||
## 验收
|
||||
|
||||
- 动作顺序、禁用状态和回调保持一致,少于等于 5 项不出现更多。
|
||||
- 更多支持鼠标移入浮层、点击、键盘、外部关闭与视口约束。
|
||||
- 卡片类型、信息入口不触发拖拽;未选中卡直接看信息,换选不残留旧信息。
|
||||
- 定向组件与工作台测试、类型检查、编码/文档索引/diff 检查通过;真实客户端视觉和触摸板手感单独验收。
|
||||
|
||||
## 产品结论与验收待办
|
||||
|
||||
正式实现采用方案 A;方案 B 不进入工作台,A/B 演示只留在忽略目录供本地参考。
|
||||
|
||||
完整工作台回归的 2 项失败已定位为新增信息按钮导致“版本 1”模糊匹配重复,改为精确查询资源选中按钮,完整重跑通过。
|
||||
|
||||
## 验收证据
|
||||
|
||||
- `appSurface.test.ts`:450 项通过、20 项跳过。
|
||||
- 收纳/卡片、Web 工具栏/卡片、资源类型实时链路、替换/重命名、浮层判据、动作可用性:178 项通过;追加真实工作台「更多滚轮不平移画布、Escape 仅收菜单、换选收起」与「跨卡片信息身份」2 项通过。
|
||||
- 根目录类型检查与 AGC 类型检查(含 skill-pack / config 检查)通过;编码、文档索引与 diff 检查通过。
|
||||
- 浏览器中真实组件预览已检查上方展开、执行回调后关闭、卡片信息入口;预览仅用演示数据,不替代真实 AGC 客户端。
|
||||
- 剩余:真实客户端、原生保存对话框、触摸板操作人工验收。未推送,未创建 PR,未更新飞书。
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
## 开发中
|
||||
|
||||
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。
|
||||
- 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。
|
||||
- UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
|
||||
- AGC 当前 Agent 与策划 Agent 的消息层级共用 `packages/shared` 的 `AgentMessageContent`:正文使用 `body`,思考、中间输出与工具调用使用 `process`;宿主不按 Agent 类型重新定义过程字号和颜色,错误状态保留语义色。
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
## 资源卡选中工具栏与导出
|
||||
|
||||
- AGC 选中工具栏按既有动作顺序最多直接显示前 5 项(不计分隔线),剩余动作进入「更多」。悬停、点击及键盘均可展开独立纵向浮层,优先向上展开,窗口顶边空间不足时向下避让;浮层限制在窗口内,超高时自行滚动,不带动画布。动作执行、点击外部、Escape 或换选资源后关闭;禁用状态和原处理链路保持不变。Web 美术画布默认不折叠。
|
||||
- 「素材类型」与「信息」不占工具栏名额,改为资源卡右上角的类型标签和信息圆钮,与 Web 美术画布共用卡片控件。未选中卡片可直接打开信息;类型入口仅对 manifest 资产可用。控件不触发卡片拖拽或多选,信息面板仍复用运行页签的字段。
|
||||
- 共享选中工具栏按实际显示的快速编辑、编辑动作、改造、导出与宿主动作组生成分隔线;空组不产生分隔线,不依赖宿主 CSS 隐藏重复线。
|
||||
- AGC 所有具有本地文件路径的素材都显示带文字的「导出」按钮,位于工具栏末组的「删除素材」之前,两者之间不插入分隔线;「重命名」继续保留在前面的常规动作组。工具栏宽度上限为 `min(92vw, 800px)`,窄屏仍可横向滚动。图片、视频、音频、动画、UI、文档及其它文件共用 `isResourceCanvasExportable`,不按媒体类型限制导出;无文件路径及虚拟项目版本不提供文件导出入口。
|
||||
- 导出继续复用 `saveProjectResourcesToDisk`:原生保存对话框选择路径,`save_local_project_asset_file` 复制原始文件字节,不转图片、不重编码、不另建 IPC。后端继续校验源文件、敏感路径和目标路径;取消不写文件,失败通过工作台提示。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import type { CSSProperties, Ref } from 'react';
|
||||
import { PlatformIconButton } from './PlatformIconButton';
|
||||
|
||||
/** 画布卡片共用的类型标签与信息入口,不承接资源业务状态。 */
|
||||
export function CanvasCardCornerActions({
|
||||
kindLabel,
|
||||
kindAriaLabel,
|
||||
kindClassName,
|
||||
infoLabel,
|
||||
style,
|
||||
kindRef,
|
||||
onKindClick,
|
||||
onInfoClick,
|
||||
infoPressed,
|
||||
}: {
|
||||
kindLabel?: string | null;
|
||||
kindAriaLabel?: string;
|
||||
kindClassName?: string;
|
||||
infoLabel: string;
|
||||
style?: CSSProperties;
|
||||
kindRef?: Ref<HTMLSpanElement>;
|
||||
onKindClick?: () => void;
|
||||
onInfoClick: () => void;
|
||||
infoPressed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="shared-canvas-card-corners"
|
||||
style={style}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{kindLabel ? (
|
||||
<span
|
||||
ref={kindRef}
|
||||
className={`shared-canvas-card-kind ${kindClassName ?? ''}`}
|
||||
data-resource-type={kindLabel}
|
||||
role={onKindClick ? 'button' : undefined}
|
||||
tabIndex={onKindClick ? 0 : undefined}
|
||||
aria-label={kindAriaLabel}
|
||||
title={kindLabel}
|
||||
onClick={onKindClick}
|
||||
onKeyDown={(event) => {
|
||||
if (onKindClick && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
onKindClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{kindLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<PlatformIconButton
|
||||
asChild="spanButton"
|
||||
variant="darkMini"
|
||||
className="shared-canvas-card-info"
|
||||
label={infoLabel}
|
||||
title="信息"
|
||||
aria-pressed={infoPressed}
|
||||
icon={<Info size={12} aria-hidden="true" />}
|
||||
onClick={onInfoClick}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { OverflowActions } from './OverflowActions';
|
||||
import { CanvasCardCornerActions } from './CanvasCardCornerActions';
|
||||
|
||||
afterEach(cleanup);
|
||||
describe('操作收纳与卡片角标', () => {
|
||||
it('具名入口可收纳全部动作,并优先向上展开', () => {
|
||||
render(
|
||||
<OverflowActions maxVisible={0} label="素材处理">
|
||||
<button>快速编辑</button>
|
||||
<button>改造</button>
|
||||
</OverflowActions>,
|
||||
);
|
||||
const trigger = screen.getByRole('button', { name: '素材处理' });
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1);
|
||||
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 200,
|
||||
right: 300,
|
||||
top: 350,
|
||||
bottom: 380,
|
||||
width: 100,
|
||||
height: 30,
|
||||
x: 200,
|
||||
y: 350,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const height = vi
|
||||
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
|
||||
.mockReturnValue(100);
|
||||
fireEvent.mouseEnter(trigger);
|
||||
const group = screen.getByRole('group', { name: '素材处理操作' });
|
||||
expect(parseFloat(group.style.top)).toBeLessThan(350);
|
||||
expect(
|
||||
within(group)
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(['快速编辑', '改造']);
|
||||
fireEvent.keyDown(document.body, { key: 'Escape' });
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
height.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('前五项可见,跳过分隔符和空 fragment,悬停展示剩余项并执行原回调', () => {
|
||||
const click = vi.fn();
|
||||
render(
|
||||
<OverflowActions maxVisible={5}>
|
||||
<>
|
||||
<button>一</button>
|
||||
<span aria-hidden="true" />
|
||||
<button>二</button>
|
||||
<></>
|
||||
<button>三</button>
|
||||
<button>四</button>
|
||||
<button>五</button>
|
||||
<span aria-hidden="true" />
|
||||
<button onClick={click}>六</button>
|
||||
<button disabled>七</button>
|
||||
</>
|
||||
</OverflowActions>,
|
||||
);
|
||||
expect(screen.getAllByRole('button')).toHaveLength(6);
|
||||
expect(screen.queryByText('六')).toBeNull();
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' }));
|
||||
const group = screen.getByRole('group', { name: '更多操作' });
|
||||
expect(
|
||||
within(group)
|
||||
.getAllByRole('button')
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(['六', '七']);
|
||||
expect((screen.getByText('七') as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(screen.getByText('六'));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
});
|
||||
it('悬停后点击仍展开、移入浮层不消失、外部关闭,Escape 恢复焦点', () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<OverflowActions maxVisible={1}>
|
||||
<button>一</button>
|
||||
<button>二</button>
|
||||
</OverflowActions>,
|
||||
);
|
||||
const more = screen.getByRole('button', { name: '更多' });
|
||||
fireEvent.mouseEnter(more);
|
||||
fireEvent.click(more);
|
||||
fireEvent.mouseLeave(more);
|
||||
fireEvent.mouseEnter(screen.getByRole('group'));
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(screen.getByText('二')).toBeTruthy();
|
||||
fireEvent.keyDown(screen.getByText('二'), { key: 'Escape' });
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
expect(document.activeElement).toBe(more);
|
||||
fireEvent.click(more);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it('不溢出不显示更多,默认保持 Web 原样', () => {
|
||||
const view = render(
|
||||
<OverflowActions maxVisible={2}>
|
||||
<button>一</button>
|
||||
<button>二</button>
|
||||
</OverflowActions>,
|
||||
);
|
||||
expect(screen.queryByText('更多')).toBeNull();
|
||||
view.rerender(
|
||||
<OverflowActions>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<button key={i}>{i}</button>
|
||||
))}
|
||||
</OverflowActions>,
|
||||
);
|
||||
expect(screen.getAllByRole('button')).toHaveLength(10);
|
||||
});
|
||||
it('动作减少至不溢出后关闭浮层,恢复动作不会自动重开', () => {
|
||||
const view = render(
|
||||
<OverflowActions maxVisible={1}>
|
||||
<button>一</button>
|
||||
<button>二</button>
|
||||
</OverflowActions>,
|
||||
);
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' }));
|
||||
expect(screen.getByRole('group')).toBeTruthy();
|
||||
view.rerender(
|
||||
<OverflowActions maxVisible={1}>
|
||||
<button>一</button>
|
||||
</OverflowActions>,
|
||||
);
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
view.rerender(
|
||||
<OverflowActions maxVisible={1}>
|
||||
<button>一</button>
|
||||
<button>二</button>
|
||||
</OverflowActions>,
|
||||
);
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
});
|
||||
it('浮层在窗口右下边界向上展开,方向键跳过禁用项', () => {
|
||||
render(
|
||||
<OverflowActions maxVisible={1}>
|
||||
<button>一</button>
|
||||
<button>二</button>
|
||||
<button disabled>三</button>
|
||||
<button>四</button>
|
||||
</OverflowActions>,
|
||||
);
|
||||
const more = screen.getByRole('button', { name: '更多' });
|
||||
vi.spyOn(more, 'getBoundingClientRect').mockReturnValue({
|
||||
left: window.innerWidth - 35,
|
||||
right: window.innerWidth,
|
||||
top: window.innerHeight - 40,
|
||||
bottom: window.innerHeight - 10,
|
||||
width: 35,
|
||||
height: 30,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
// jsdom 没有真实布局,提供浮层测量以验证向上定位。
|
||||
const height = vi
|
||||
.spyOn(HTMLElement.prototype, 'scrollHeight', 'get')
|
||||
.mockReturnValue(200);
|
||||
fireEvent.click(more);
|
||||
const panel = screen.getByRole('group');
|
||||
expect(parseFloat(panel.style.top)).toBeLessThan(window.innerHeight - 40);
|
||||
screen.getByText('二').focus();
|
||||
fireEvent.keyDown(screen.getByText('二'), { key: 'ArrowDown' });
|
||||
expect(document.activeElement).toBe(screen.getByText('四'));
|
||||
fireEvent.keyDown(screen.getByText('四'), { key: 'Home' });
|
||||
expect(document.activeElement).toBe(screen.getByText('二'));
|
||||
height.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('卡片角标阻断指针和键盘冒泡,类型及信息分别执行', () => {
|
||||
const parent = vi.fn(),
|
||||
kind = vi.fn(),
|
||||
info = vi.fn();
|
||||
render(
|
||||
<div onClick={parent} onPointerDown={parent} onKeyDown={parent}>
|
||||
<CanvasCardCornerActions
|
||||
kindLabel="角色"
|
||||
kindAriaLabel="素材类型"
|
||||
infoLabel="资源信息"
|
||||
onKindClick={kind}
|
||||
onInfoClick={info}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
const label = screen.getByRole('button', { name: '素材类型' });
|
||||
fireEvent.pointerDown(label);
|
||||
fireEvent.click(label);
|
||||
fireEvent.keyDown(label, { key: 'Enter' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '资源信息' }));
|
||||
expect(kind).toHaveBeenCalledTimes(2);
|
||||
expect(info).toHaveBeenCalledOnce();
|
||||
expect(parent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' };
|
||||
|
||||
function flatten(nodes: ReactNode, prefix = ''): ReactNode[] {
|
||||
return Children.toArray(nodes).flatMap((node, index) =>
|
||||
isValidElement<ActionProps>(node) && node.type === Fragment
|
||||
? flatten(node.props.children, `${prefix}${index}.`)
|
||||
: [
|
||||
isValidElement(node)
|
||||
? cloneElement(node, { key: `${prefix}${index}` })
|
||||
: node,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function isDivider(node: ReactNode) {
|
||||
return (
|
||||
isValidElement<ActionProps>(node) &&
|
||||
(node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true')
|
||||
);
|
||||
}
|
||||
|
||||
/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */
|
||||
export function OverflowActions({
|
||||
children,
|
||||
maxVisible = Infinity,
|
||||
label = '更多',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
maxVisible?: number;
|
||||
label?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 });
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const id = useId();
|
||||
const limit = Number.isFinite(maxVisible)
|
||||
? Math.max(0, Math.floor(maxVisible))
|
||||
: Infinity;
|
||||
const nodes = flatten(children);
|
||||
let count = 0;
|
||||
const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit);
|
||||
const primary = split < 0 ? nodes : nodes.slice(0, split);
|
||||
while (primary.length && isDivider(primary[primary.length - 1]))
|
||||
primary.pop();
|
||||
const overflow =
|
||||
split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node));
|
||||
useEffect(() => {
|
||||
if (overflow.length === 0) setOpen(false);
|
||||
}, [overflow.length]);
|
||||
const cancelClose = () => {
|
||||
if (timer.current !== null) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
};
|
||||
const show = () => {
|
||||
cancelClose();
|
||||
setOpen(true);
|
||||
};
|
||||
const scheduleClose = () => {
|
||||
cancelClose();
|
||||
timer.current = setTimeout(() => {
|
||||
if (!panel.current?.contains(document.activeElement)) setOpen(false);
|
||||
}, 150);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current !== null) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !overflow.length) return;
|
||||
const update = () => {
|
||||
const anchor = trigger.current?.getBoundingClientRect();
|
||||
if (!anchor) return;
|
||||
const width = panel.current?.getBoundingClientRect().width ?? 200;
|
||||
const height = panel.current?.scrollHeight ?? 320;
|
||||
const below = window.innerHeight - anchor.bottom - 12;
|
||||
const above = anchor.top - 12;
|
||||
// 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。
|
||||
const down = above < 80 && below > above;
|
||||
const maxHeight = Math.max(40, Math.min(360, down ? below : above));
|
||||
setPosition({
|
||||
left: Math.max(
|
||||
8,
|
||||
Math.min(anchor.right - width, window.innerWidth - width - 8),
|
||||
),
|
||||
top: down
|
||||
? anchor.bottom + 4
|
||||
: Math.max(8, anchor.top - Math.min(height, maxHeight) - 4),
|
||||
maxHeight,
|
||||
});
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
window.addEventListener('scroll', update, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', update);
|
||||
window.removeEventListener('scroll', update, true);
|
||||
};
|
||||
}, [open, overflow.length, children]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
!trigger.current?.contains(event.target) &&
|
||||
!panel.current?.contains(event.target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
};
|
||||
document.addEventListener('pointerdown', outside);
|
||||
document.addEventListener('keydown', escape);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', outside);
|
||||
document.removeEventListener('keydown', escape);
|
||||
};
|
||||
}, [open]);
|
||||
if (!overflow.length) return <>{children}</>;
|
||||
return (
|
||||
<>
|
||||
{primary}
|
||||
<button
|
||||
ref={trigger}
|
||||
type="button"
|
||||
className="shared-overflow-trigger image-canvas-editor__floating-toolbar-text-button"
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? id : undefined}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={scheduleClose}
|
||||
onClick={show}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
show();
|
||||
requestAnimationFrame(() =>
|
||||
panel.current
|
||||
?.querySelector<HTMLButtonElement>('button:not(:disabled)')
|
||||
?.focus(),
|
||||
);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label} <span aria-hidden="true">▴</span>
|
||||
</button>
|
||||
{open
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
id={id}
|
||||
role="group"
|
||||
aria-label={`${label}操作`}
|
||||
className="shared-overflow-panel image-canvas-editor__portal-menu"
|
||||
style={position}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if ((event.target as Element).closest('button:not(:disabled)'))
|
||||
setOpen(false);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (
|
||||
!event.currentTarget.contains(event.relatedTarget) &&
|
||||
event.relatedTarget !== trigger.current
|
||||
)
|
||||
setOpen(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
trigger.current?.focus();
|
||||
}
|
||||
if (
|
||||
['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
const buttons = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'button:not(:disabled)',
|
||||
),
|
||||
);
|
||||
const index = buttons.indexOf(
|
||||
document.activeElement as HTMLButtonElement,
|
||||
);
|
||||
const next =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? buttons.length - 1
|
||||
: (index +
|
||||
(event.key === 'ArrowDown' ? 1 : -1) +
|
||||
buttons.length) %
|
||||
buttons.length;
|
||||
buttons[next]?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{overflow}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -220,6 +220,8 @@ export { Textarea } from './ui/textarea';
|
||||
// existing application adapters while keeping the public API product-neutral.
|
||||
export type { AgentMessageTone } from './AgentMessageContent';
|
||||
export { AgentMessageContent } from './AgentMessageContent';
|
||||
export { CanvasCardCornerActions } from './CanvasCardCornerActions';
|
||||
export { OverflowActions } from './OverflowActions';
|
||||
export type {
|
||||
ButtonProps as PlatformButtonProps,
|
||||
SwitchProps as PlatformSwitchProps,
|
||||
|
||||
@@ -1310,3 +1310,126 @@ textarea.genarrative-ui-text-field__control {
|
||||
border-radius: 1.25rem 1.25rem 0 0;
|
||||
}
|
||||
}
|
||||
/* 画布卡片角标:缩放由宿主传入,两个入口保持一致的视觉尺寸。 */
|
||||
.shared-canvas-card-corners {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: calc(100% - 12px);
|
||||
transform: scale(
|
||||
var(
|
||||
--image-canvas-editor-inverse-scale,
|
||||
var(--genarrative-image-canvas-inverse-scale, 1)
|
||||
)
|
||||
);
|
||||
transform-origin: top right;
|
||||
}
|
||||
.shared-canvas-card-kind {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid rgb(255 255 255 / 72%);
|
||||
border-radius: 999px;
|
||||
background: rgb(199 101 61 / 92%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
}
|
||||
.shared-canvas-card-kind[role='button'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.shared-canvas-card-info {
|
||||
display: grid;
|
||||
flex: 0 0 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 42%);
|
||||
border-radius: 50%;
|
||||
background: rgb(199 101 61 / 92%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.shared-canvas-card-info:hover,
|
||||
.shared-canvas-card-kind[role='button']:hover {
|
||||
background: #b95d3a;
|
||||
}
|
||||
.shared-canvas-card-corners [role='button']:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.shared-overflow-trigger {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.shared-overflow-trigger:hover,
|
||||
.shared-overflow-trigger[aria-expanded='true'] {
|
||||
background: #f4e7df;
|
||||
}
|
||||
.shared-overflow-panel {
|
||||
position: fixed;
|
||||
z-index: 1500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
width: 200px;
|
||||
max-width: calc(100vw - 16px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 6px;
|
||||
border: 1px solid #e7d5c8;
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: #4b3026;
|
||||
box-shadow: 0 10px 28px rgb(52 30 22 / 18%);
|
||||
}
|
||||
.shared-overflow-panel > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.shared-overflow-panel > button:hover,
|
||||
.shared-overflow-panel > button:focus-visible {
|
||||
background: #f4e7df;
|
||||
}
|
||||
.shared-overflow-panel > button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.shared-overflow-panel
|
||||
> button.genarrative-image-canvas__chrome-button:not(
|
||||
.genarrative-image-canvas__chrome-button--with-label
|
||||
)::after {
|
||||
content: attr(aria-label);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,50 @@ function renderSelectedToolbar(
|
||||
}
|
||||
|
||||
describe('ImageCanvasSelectedLayerToolbarView', () => {
|
||||
it('AGC 收纳包含宿主末组,换选后关闭旧资源更多菜单', () => {
|
||||
const props = renderSelectedToolbar({
|
||||
maxVisibleActions: 5,
|
||||
supportedActions: new Set([
|
||||
'quick-edit',
|
||||
'character-animation',
|
||||
'download',
|
||||
]),
|
||||
downloadLabel: '导出',
|
||||
extraActions: (
|
||||
<>
|
||||
<button>引用</button>
|
||||
<button>编辑标签</button>
|
||||
<button>重命名</button>
|
||||
</>
|
||||
),
|
||||
endActions: <button>删除素材</button>,
|
||||
});
|
||||
const toolbar = screen.getByRole('toolbar');
|
||||
expect(
|
||||
within(toolbar)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.textContent),
|
||||
).toEqual(['快速编辑', '生成动画', '引用', '编辑标签', '重命名', '更多 ▴']);
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' }));
|
||||
const group = screen.getByRole('group', { name: '更多操作' });
|
||||
expect(
|
||||
within(group)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.textContent),
|
||||
).toEqual(['导出', '删除素材']);
|
||||
fireEvent.click(within(group).getByRole('button', { name: '导出' }));
|
||||
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
|
||||
cleanup();
|
||||
const view = render(<ImageCanvasSelectedLayerToolbarView {...props} />);
|
||||
fireEvent.mouseEnter(screen.getByRole('button', { name: '更多' }));
|
||||
view.rerender(
|
||||
<ImageCanvasSelectedLayerToolbarView
|
||||
{...props}
|
||||
selectedLayer={createLayer({ id: 'other' })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull();
|
||||
});
|
||||
it('常规动作与末组分别分隔,导出紧邻删除且位于删除之前', () => {
|
||||
const props = renderSelectedToolbar({
|
||||
supportedActions: new Set(['quick-edit', 'download']),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
||||
import { OverflowActions } from '@genarrative/shared/components';
|
||||
import {
|
||||
Crop,
|
||||
Download,
|
||||
@@ -38,6 +39,7 @@ export type ImageCanvasSelectedToolbarAction =
|
||||
| 'download';
|
||||
|
||||
type ImageCanvasSelectedLayerToolbarViewProps = {
|
||||
maxVisibleActions?: number;
|
||||
/**
|
||||
* 宿主显式声明的可用动作集合。
|
||||
*
|
||||
@@ -78,6 +80,7 @@ function hasToolbarActions(actions: ReactNode): boolean {
|
||||
}
|
||||
|
||||
export function ImageCanvasSelectedLayerToolbarView({
|
||||
maxVisibleActions,
|
||||
supportedActions = null,
|
||||
extraActions,
|
||||
endActions,
|
||||
@@ -148,22 +151,24 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
aria-label="素材工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canRedraw ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
icon={<WandSparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{canRedraw && hasExtraActions ? divider : null}
|
||||
{extraActions}
|
||||
{(canRedraw || hasExtraActions) && hasEndActions ? divider : null}
|
||||
{downloadAction}
|
||||
{endActions}
|
||||
<OverflowActions key={selectedLayer.id} maxVisible={maxVisibleActions}>
|
||||
{canRedraw ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
icon={<WandSparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{canRedraw && hasExtraActions ? divider : null}
|
||||
{extraActions}
|
||||
{(canRedraw || hasExtraActions) && hasEndActions ? divider : null}
|
||||
{downloadAction}
|
||||
{endActions}
|
||||
</OverflowActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -208,170 +213,172 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
aria-label="图片工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{showQuickEdit ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="快速编辑"
|
||||
title="快速编辑"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||
>
|
||||
<span>快速编辑</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{showQuickEdit && hasEditingActions ? divider : null}
|
||||
{showCropExpand ? (
|
||||
<EditorIconButton
|
||||
label="裁扩按钮"
|
||||
title="裁扩按钮"
|
||||
icon={Crop}
|
||||
onClick={() => onOpenCropExpandPanel(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{showRemoveBackground ? (
|
||||
<EditorIconButton
|
||||
label="去除背景按钮"
|
||||
title="去除背景按钮"
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{showPerfectPixel ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '完美像素结果待确认'
|
||||
: '完美像素'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '结果待确认,双击原占位继续核对或重试'
|
||||
: '自动识别并规整像素网格'
|
||||
}
|
||||
icon={
|
||||
isPersistingAssetKind || isPerfectPixelProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
|
||||
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
|
||||
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
|
||||
// 与相邻的拆分图集按钮保持同一套门禁。
|
||||
disabled={
|
||||
isPersistingAssetKind ||
|
||||
isPerfectPixelProcessing ||
|
||||
isPerfectPixelPendingConfirmation
|
||||
}
|
||||
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
|
||||
onClick={() => onPerfectPixel(selectedLayer)}
|
||||
>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isPerfectPixelProcessing
|
||||
? '处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '待确认'
|
||||
: '完美像素'}
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{showSplitIconSpritesheet ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '素材类型保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '素材类型保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'
|
||||
}
|
||||
icon={
|
||||
isPersistingAssetKind || isSplittingIconSpritesheet ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Scissors className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
disabled={isPersistingAssetKind || isSplittingIconSpritesheet}
|
||||
aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet}
|
||||
onClick={() => onSplitIconSpritesheet(selectedLayer)}
|
||||
>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'}
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{showExtractUiDesign ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="提取素材"
|
||||
title="提取素材"
|
||||
icon={<Scissors className="h-4 w-4" />}
|
||||
onClick={() => onExtractUiDesignAssets(selectedLayer)}
|
||||
>
|
||||
<span>提取素材</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{showCharacterAnimation ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="生成动画"
|
||||
title="生成动画"
|
||||
icon={<PersonStanding className="h-4 w-4" />}
|
||||
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
|
||||
>
|
||||
<span>生成动画</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{canRedraw ? (
|
||||
<>
|
||||
{showQuickEdit || hasEditingActions ? divider : null}
|
||||
<OverflowActions key={selectedLayer.id} maxVisible={maxVisibleActions}>
|
||||
{showQuickEdit ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
icon={<WandSparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
label="快速编辑"
|
||||
title="快速编辑"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
<span>快速编辑</span>
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
) : null}
|
||||
{(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions
|
||||
? divider
|
||||
: null}
|
||||
{extraActions}
|
||||
{(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) &&
|
||||
hasEndActions
|
||||
? divider
|
||||
: null}
|
||||
{downloadAction}
|
||||
{endActions}
|
||||
) : null}
|
||||
{showQuickEdit && hasEditingActions ? divider : null}
|
||||
{showCropExpand ? (
|
||||
<EditorIconButton
|
||||
label="裁扩按钮"
|
||||
title="裁扩按钮"
|
||||
icon={Crop}
|
||||
onClick={() => onOpenCropExpandPanel(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{showRemoveBackground ? (
|
||||
<EditorIconButton
|
||||
label="去除背景按钮"
|
||||
title="去除背景按钮"
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
) : null}
|
||||
{showPerfectPixel ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '完美像素结果待确认'
|
||||
: '完美像素'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '完美像素等待素材类型保存'
|
||||
: isPerfectPixelProcessing
|
||||
? '完美像素处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '结果待确认,双击原占位继续核对或重试'
|
||||
: '自动识别并规整像素网格'
|
||||
}
|
||||
icon={
|
||||
isPersistingAssetKind || isPerfectPixelProcessing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Grid2X2 className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
|
||||
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
|
||||
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
|
||||
// 与相邻的拆分图集按钮保持同一套门禁。
|
||||
disabled={
|
||||
isPersistingAssetKind ||
|
||||
isPerfectPixelProcessing ||
|
||||
isPerfectPixelPendingConfirmation
|
||||
}
|
||||
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
|
||||
onClick={() => onPerfectPixel(selectedLayer)}
|
||||
>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isPerfectPixelProcessing
|
||||
? '处理中'
|
||||
: isPerfectPixelPendingConfirmation
|
||||
? '待确认'
|
||||
: '完美像素'}
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{showSplitIconSpritesheet ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
? '素材类型保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'
|
||||
}
|
||||
title={
|
||||
isPersistingAssetKind
|
||||
? '素材类型保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'
|
||||
}
|
||||
icon={
|
||||
isPersistingAssetKind || isSplittingIconSpritesheet ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Scissors className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
disabled={isPersistingAssetKind || isSplittingIconSpritesheet}
|
||||
aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet}
|
||||
onClick={() => onSplitIconSpritesheet(selectedLayer)}
|
||||
>
|
||||
<span>
|
||||
{isPersistingAssetKind
|
||||
? '保存中'
|
||||
: isSplittingIconSpritesheet
|
||||
? '拆图中'
|
||||
: '拆分图集'}
|
||||
</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{showExtractUiDesign ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="提取素材"
|
||||
title="提取素材"
|
||||
icon={<Scissors className="h-4 w-4" />}
|
||||
onClick={() => onExtractUiDesignAssets(selectedLayer)}
|
||||
>
|
||||
<span>提取素材</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{showCharacterAnimation ? (
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="生成动画"
|
||||
title="生成动画"
|
||||
icon={<PersonStanding className="h-4 w-4" />}
|
||||
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
|
||||
>
|
||||
<span>生成动画</span>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{canRedraw ? (
|
||||
<>
|
||||
{showQuickEdit || hasEditingActions ? divider : null}
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
icon={<WandSparkles className="h-4 w-4" />}
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
) : null}
|
||||
{(showQuickEdit || hasEditingActions || canRedraw) && hasExtraActions
|
||||
? divider
|
||||
: null}
|
||||
{extraActions}
|
||||
{(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) &&
|
||||
hasEndActions
|
||||
? divider
|
||||
: null}
|
||||
{downloadAction}
|
||||
{endActions}
|
||||
</OverflowActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -814,7 +814,7 @@ describe('ImageCanvasWorldView', () => {
|
||||
|
||||
expect(
|
||||
(
|
||||
within(layerButton).getByText('角色') as HTMLElement
|
||||
within(layerButton).getByText('角色').parentElement as HTMLElement
|
||||
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
@@ -825,6 +825,7 @@ describe('ImageCanvasWorldView', () => {
|
||||
expect(
|
||||
within(layerButton)
|
||||
.getByRole('button', { name: '查看角色主图图片信息' })
|
||||
.parentElement!
|
||||
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
|
||||
).toBe(inverseScale);
|
||||
expect(
|
||||
@@ -1118,12 +1119,8 @@ describe('ImageCanvasWorldView', () => {
|
||||
name: '查看角色主图图片信息',
|
||||
});
|
||||
|
||||
expect(badge.className).toContain(
|
||||
'image-canvas-editor__kind-badge--beside-info',
|
||||
);
|
||||
expect(metadataButton.className).not.toContain(
|
||||
'image-canvas-editor__metadata-corner--beside-kind',
|
||||
);
|
||||
expect(badge.parentElement?.className).toBe('shared-canvas-card-corners');
|
||||
expect(badge.nextElementSibling).toBe(metadataButton);
|
||||
});
|
||||
|
||||
it('keeps the layer type menu scrollable and closes it when clicking outside', () => {
|
||||
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
LayerRenderer,
|
||||
SelectionOverlay,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import { CanvasCardCornerActions } from '@genarrative/shared/components';
|
||||
import {
|
||||
AppWindow,
|
||||
Clapperboard,
|
||||
ClipboardList,
|
||||
Grid2X2,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Megaphone,
|
||||
Mountain,
|
||||
Music,
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import {
|
||||
@@ -367,17 +366,6 @@ function handleLayerKeyboardActivation(event: ReactKeyboardEvent<HTMLElement>) {
|
||||
event.currentTarget.click();
|
||||
}
|
||||
|
||||
function handleLayerTagKeyboardActivation(
|
||||
event: ReactKeyboardEvent<HTMLElement>,
|
||||
) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.click();
|
||||
}
|
||||
|
||||
function stopMediaControlKeyPropagation(
|
||||
event: ReactKeyboardEvent<HTMLElement>,
|
||||
) {
|
||||
@@ -1012,38 +1000,19 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({
|
||||
mediaTransform={mediaTransform}
|
||||
/>
|
||||
)}
|
||||
{kindLabel ? (
|
||||
<span
|
||||
ref={isKindMenuOpen ? openLayerKindTriggerRef : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`素材标签:${kindLabel}`}
|
||||
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${kindTagMeta.className} image-canvas-editor__kind-badge--beside-info image-canvas-editor__kind-badge--editable`}
|
||||
style={inverseScaleStyle}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handlers.setOpenLayerKindMenuId((currentId) =>
|
||||
currentId === layer.id ? null : layer.id,
|
||||
);
|
||||
}}
|
||||
onKeyDown={handleLayerTagKeyboardActivation}
|
||||
>
|
||||
{kindLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<PlatformIconButton
|
||||
asChild="spanButton"
|
||||
variant="darkMini"
|
||||
className="image-canvas-editor__metadata-corner"
|
||||
label={`查看${layer.title}${metadataMediaLabel}信息`}
|
||||
icon={<Info className="h-3 w-3" />}
|
||||
<CanvasCardCornerActions
|
||||
kindLabel={kindLabel}
|
||||
kindAriaLabel={`素材标签:${kindLabel}`}
|
||||
kindRef={isKindMenuOpen ? openLayerKindTriggerRef : undefined}
|
||||
kindClassName={`image-canvas-editor__kind-badge--${kindTagMeta.className}`}
|
||||
infoLabel={`查看${layer.title}${metadataMediaLabel}信息`}
|
||||
style={inverseScaleStyle}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handlers.onOpenLayerMetadata(layer);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onKindClick={() =>
|
||||
handlers.setOpenLayerKindMenuId((currentId) =>
|
||||
currentId === layer.id ? null : layer.id,
|
||||
)
|
||||
}
|
||||
onInfoClick={() => handlers.onOpenLayerMetadata(layer)}
|
||||
/>
|
||||
{isHovered && layer.mediaType !== 'audio' ? (
|
||||
<PlatformPillBadge
|
||||
|
||||
@@ -13,6 +13,13 @@ const reactFileManagerPackageRoot = path.dirname(
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@genarrative\/shared\/components$/,
|
||||
replacement: path.resolve(
|
||||
__dirname,
|
||||
'packages/shared/src/components/index.ts',
|
||||
),
|
||||
},
|
||||
{
|
||||
find: /^@cubone\/react-file-manager$/,
|
||||
replacement: path.resolve(
|
||||
|
||||
Reference in New Issue
Block a user