统一主站画布共享视觉组件
新增共享画布按钮、工具栏、分组与分隔符及作用域样式 主站图片画布默认动作与底部工具栏改用共享实现 补齐 UI 对齐合同、决策记录和定向测试
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# AI 游戏创作项目开发工作台 PRD
|
||||
|
||||
更新时间:`2026-08-05`(实时 manifest、资源焦点状态机与客户端素材创作无限画布合同收口)
|
||||
更新时间:`2026-08-06`(主站 UI 对齐、实时 manifest、资源焦点状态机与客户端素材创作无限画布合同收口)
|
||||
|
||||
## 1. 产品定位
|
||||
|
||||
@@ -83,6 +83,16 @@
|
||||
- 未开放选项使用“视觉不可用但可点击说明原因”,不使用无法触发说明的原生 `disabled`。
|
||||
- 普通用户暂不开放 Agent.md 编辑和自定义 Skill 安装;后续必须先定义来源审核、版本、权限、沙箱和回滚合同。
|
||||
|
||||
### 3.7 主站 UI 对齐与共享视觉边界
|
||||
|
||||
- 项目工作台继续保留左侧平台导航、中央主视窗、右侧 Project Supervisor 和底部专业 Agent 状态栏四区结构;主站图片编辑器只作为视觉语言和共享画布组件的事实源,不把其素材库侧栏、账号业务或云端项目外壳整体搬入客户端。
|
||||
- 平台主题事实源固定为 `packages/shared/src/theme.css`。画布通用 chrome 固定落在 `@genarrative/image-canvas-react`,主站与 Tauri 必须直接 import 同一组件和作用域样式;客户端不得复制 `src/components/image-editor/`,也不得导入主站完整 `src/index.css`。
|
||||
- 第一批共享 chrome 固定覆盖画布动作按钮、工具栏、工具分组和分隔符。按钮的默认、悬停、键盘焦点、选中、禁用和主次色语义由共享层表达;宿主只提供图标、文案、事件与业务禁用条件。
|
||||
- 主站账号、钱包、服务端项目、云端素材库和生成面板仍留在网站宿主;客户端本地项目、manifest、Runtime、审批、草稿、生成与正式提交仍留在 Tauri 宿主。共享视觉组件不得读取这些业务事实。
|
||||
- 客户端 UI 对齐采用“同视觉、同组件、保留四区布局”,不复制主站整页布局。资源总览、运行视图和 Supervisor 对话继续是 Game Agent 工作台独有语义。
|
||||
- 客户端正式产品仍只按 `1280×800` 横屏合同交付;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。
|
||||
- 普通用户界面不默认展示内部错误码、开发者 API 配置或本机绝对路径;确需诊断的信息进入受控详情或开发模式,不与主要动作并列。
|
||||
|
||||
## 4. 工作台状态机
|
||||
|
||||
### 4.1 主视窗
|
||||
@@ -401,7 +411,7 @@ type ProjectAgentMudPointAttribution = {
|
||||
- 当前 run 专业状态与项目历史成果分离。
|
||||
- 默认三专业组,并可展开另外三组。
|
||||
- 严格审批有效;风险/无需审批可点击查看未开放原因。
|
||||
- 橙色低保真视觉与 `1280×800` 横屏边界。
|
||||
- 与主站图片编辑器一致的平台轻色主题、共享画布 chrome 与 `1280×800` 横屏边界。
|
||||
|
||||
### P1
|
||||
|
||||
@@ -487,6 +497,15 @@ type ProjectAgentMudPointAttribution = {
|
||||
7. 新增、精修、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 的完整验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。
|
||||
8. 自动定位必须分别证明资源已投影、dependency/type 两份布局都 settled 且存在目标位置、目标卡 DOM 已提交;搜索隐藏走显式清除/定位,任何 commit 最多自动聚焦一次。
|
||||
|
||||
### 7.7 主站 UI 对齐验收
|
||||
|
||||
1. `packages/image-canvas-react` 暴露共享画布动作按钮、工具栏、工具分组和分隔符;主站图片编辑器直接消费这些组件,旧网站组件只允许保留薄适配,不得继续维护另一份按钮可访问性或选中态实现。
|
||||
2. 共享样式全部使用 `.genarrative-image-canvas*` 作用域并消费 `--platform-*` / `--image-canvas-brand-*` token;Tauri 不导入主站完整 `src/index.css`,主站也不复制共享样式回业务 CSS。
|
||||
3. 后续客户端接入完成后,`1280×800` 下中央画布工具栏保持单行或受控横向收纳,中文动作不得逐字换行;聊天输入、主要保存动作和底部 Agent Dock 始终可见,document/body 不产生页面级溢出。
|
||||
4. 主站现有图片编辑器的按钮名称、tool selection、hover/focus/pressed/disabled 行为与工具栏位置不因共享抽取回归;网站账号、钱包、素材库、生成和导出逻辑不下沉共享层。
|
||||
5. 客户端生成、保存、取消、恢复、登录失效与余额不足只更换视觉承载,不改变 Host Port 调用、幂等身份、草稿 CAS、manifest/revision、Runtime 审批或钱包刷新语义。
|
||||
6. 阶段一、二只冻结合同并让主站消费共享 chrome,不修改客户端中央素材画布;客户端中央区、Supervisor 和 Agent Dock 的正式换肤在后续同一 UI 实施阶段完成。
|
||||
|
||||
## 8. 非目标
|
||||
|
||||
- 资源总览当前不实现资源卡手动拖动,也不实现通用聚焦工具栏/工具侧边栏、下一迭代版本创建入口、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,但当前只读取、校验和展示已有记录。
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-06 Game Agent 画布视觉以主站 token 与共享 chrome 为唯一来源
|
||||
|
||||
- 背景:网站与 Tauri 已经共同消费 `image-canvas-core/react` 的 viewport、selection、renderer 和 history,但客户端素材画布仍维护独立的文字工具栏、按钮和状态外观,`1280×800` 下会出现动作逐字换行、主次不清和工作台四区视觉漂移。直接复制主站 `src/components/image-editor` 或整包 `src/index.css` 会重新形成宿主分叉和隐式全局依赖。
|
||||
- 决策:保留 Game Agent 左导航、中央主视窗、右侧 Supervisor 和底部 Agent Dock 四区布局;平台颜色、边框、文本和 surface 继续以 `packages/shared/src/theme.css` 为事实源,画布动作按钮、工具栏、分组与分隔符进入现有 `@genarrative/image-canvas-react`。主站先改为直接消费共享 chrome,Tauri 在下一 UI 阶段接入同一组件。
|
||||
- 依赖边界:共享组件只接受 React props、图标节点、短文案和事件,不读取账号、钱包、项目、生成、Tauri 或 HTTP。主站 `EditorIconButton` 只保留 Lucide/特殊浮层薄适配,主站业务 CSS 只保留定位和专属状态;Tauri 禁止导入网站完整 CSS。内部错误码、开发者 API 配置和本机绝对路径不作为普通用户默认视觉内容。
|
||||
- 分期:阶段一、二只更新权威合同、抽取共享 chrome 并让主站消费,不修改客户端 `AssetCanvasSurface`;阶段三、四再统一中央素材画布、Supervisor 与 Agent Dock,阶段五验证与修复必须和客户端实施同一任务完成。
|
||||
- 验证:共享 React 组件与主站图片编辑器 7 个定向测试文件共 `92/92` 通过,覆盖可访问性、状态、透传、工具列表、选中态与 option hover/focus;主站与客户端 typecheck、编码检查和 `git diff --check` 通过。
|
||||
- 关联:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md`、`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`、`packages/image-canvas-react/`。
|
||||
|
||||
## 2026-08-05 无限画布阶段三以 Tauri 持久草稿和项目 mutation 事务形成独立图片闭环
|
||||
|
||||
- 决策:阶段三新增独立 `AssetCanvasSurface` 和 Tauri Host Adapter,直接消费共享 core/React/ports;前端状态固定为 `canvas.creating / editing / saving / recovering / failed`,generation 明确使用 unsupported mock。本阶段不接资源总览入口、保存后自动选中或真实 AI,避免提前修改工作台状态合同。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 客户端素材创作无限画布阶段一合同
|
||||
|
||||
更新时间:`2026-08-05`
|
||||
更新时间:`2026-08-06`
|
||||
|
||||
状态:阶段一产品与技术合同已冻结;当前提交只更新文档,不代表代码已经实现。
|
||||
状态:阶段一产品与技术合同已冻结;截至 2026-08-06,阶段一、二共享画布 chrome 与主站接入已实现,客户端中央画布换肤留待后续阶段。
|
||||
|
||||
本文是网站与 AI 游戏创作 Tauri 客户端共享图片画布能力的下一阶段编码依据。若本文与资源管理阶段七的“美术编辑暂缓”口径冲突,以本文对后续素材创作切片的更新决定为准;资源总览既有布局、依赖图和只读聚焦合同继续有效。
|
||||
|
||||
@@ -202,6 +202,28 @@ interface ImageCanvasHostPort {
|
||||
- Web adapter 把草稿、导入、生成、导出和提交映射到现有服务端 editor project、云端素材库及账户/钱包链路。
|
||||
- Tauri adapter 把草稿、导入、导出和提交映射到本文第 7 至 11 节的本地合同;生成通过 External Editor API,API Key 只从 Tauri 应用配置读取,不能进入项目 sidecar、manifest、事件或错误正文。
|
||||
|
||||
### 3.4 主站 UI 对齐与共享画布 chrome
|
||||
|
||||
实现状态(2026-08-06,阶段一、二):以下四个共享组件及作用域样式已经进入 `@genarrative/image-canvas-react`;主站默认 `EditorIconButton`、底部画布工具栏及画布内直接使用的常规图标/短标签动作已经消费共享按钮,网站专属 `surfaceFloating` / `darkMini` 仍保留薄适配。客户端 `AssetCanvasSurface` 尚未接入,按本节边界留给后续 UI 实施阶段。
|
||||
|
||||
第一批通用视觉组件固定由 `@genarrative/image-canvas-react` 暴露:
|
||||
|
||||
```ts
|
||||
CanvasChromeButton
|
||||
CanvasToolbar
|
||||
CanvasToolbarGroup
|
||||
CanvasToolbarDivider
|
||||
```
|
||||
|
||||
- `CanvasChromeButton` 统一原生 button 的可访问名称、tooltip、`aria-pressed`、`aria-expanded`、禁用态和画布 action 视觉;图标、短文案、业务事件和禁用条件由宿主传入。共享组件不得 import Lucide、平台账号 store、钱包 store 或宿主请求客户端。
|
||||
- `CanvasToolbar` 只负责 `role=toolbar`、可访问名称、plain/floating surface 和横向收纳;`CanvasToolbarGroup`、`CanvasToolbarDivider` 只表达动作分组,不知道具体工具 ID 或业务顺序。
|
||||
- 共享 CSS 继续位于 `packages/image-canvas-react/src/styles.css`,选择器必须以 `.genarrative-image-canvas` 前缀开头。颜色优先消费宿主继承的 `--image-canvas-brand-*`,再回退 `--platform-*`,最后才使用包内安全默认值。
|
||||
- 主站现役 `EditorIconButton` 可以保留为传入 Lucide icon 的薄 adapter,但默认画布按钮必须委托 `CanvasChromeButton`;特殊网站浮层或暗色媒体按钮仍可留在宿主,不能为了共享视觉把网站 portal、账号或媒体业务下沉。
|
||||
- 主站底部画布工具栏必须委托 `CanvasToolbar`、`CanvasToolbarGroup` 和 `CanvasToolbarDivider`,位置、引导态和工具 option hover 仍由网站宿主控制。共享抽取完成后,主站业务 CSS 只保留定位和主站专属状态,不重复维护通用按钮、浮动 surface、分组和分隔符样式。
|
||||
- Tauri 在后续 UI 实施阶段消费同一组件;本阶段不修改 `AssetCanvasSurface` 的 JSX/CSS,不把生成、保存、草稿、manifest 或 Runtime 逻辑带入共享包。
|
||||
- 主题事实源仍是 `packages/shared/src/theme.css`。Tauri 只能导入共享主题、`@genarrative/image-canvas-react/styles.css` 和自身宿主样式,禁止导入网站整包 `src/index.css`。
|
||||
- 共享组件测试必须覆盖名称、默认 `type=button`、pressed/expanded、禁用、带短标签动作、floating toolbar surface 和 ref/class/event 透传;主站测试继续锁定工具列表、选中态和 option tool 的 hover/focus 行为。
|
||||
|
||||
## 4. 图片首版能力边界
|
||||
|
||||
### 4.1 必须交付
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { createRef } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
CanvasChromeButton,
|
||||
CanvasToolbar,
|
||||
CanvasToolbarDivider,
|
||||
CanvasToolbarGroup,
|
||||
} from './CanvasChrome';
|
||||
|
||||
describe('shared canvas chrome', () => {
|
||||
it('forwards native button props and exposes shared accessible states', () => {
|
||||
const onClick = vi.fn();
|
||||
const ref = createRef<HTMLButtonElement>();
|
||||
|
||||
render(
|
||||
<CanvasChromeButton
|
||||
ref={ref}
|
||||
label="选择工具"
|
||||
title="选择"
|
||||
icon={<span data-testid="tool-icon" />}
|
||||
className="host-button"
|
||||
data-host="main-site"
|
||||
pressed
|
||||
expanded={false}
|
||||
onClick={onClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '选择工具' });
|
||||
expect(button.className).toContain(
|
||||
'genarrative-image-canvas__chrome-button',
|
||||
);
|
||||
expect(button.className).toContain('host-button');
|
||||
expect(button.getAttribute('type')).toBe('button');
|
||||
expect(button.getAttribute('title')).toBe('选择');
|
||||
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(button.getAttribute('data-host')).toBe('main-site');
|
||||
expect(ref.current).toBe(button);
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports a short visible label and native disabled behavior', () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<CanvasChromeButton
|
||||
label="改造素材"
|
||||
icon={<span />}
|
||||
disabled
|
||||
onClick={onClick}
|
||||
>
|
||||
改造
|
||||
</CanvasChromeButton>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '改造素材' });
|
||||
expect(button.className).toContain(
|
||||
'genarrative-image-canvas__chrome-button--with-label',
|
||||
);
|
||||
expect(button.getAttribute('title')).toBe('改造素材');
|
||||
expect(button).toHaveProperty('disabled', true);
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('provides a named toolbar with groups and decorative dividers', () => {
|
||||
render(
|
||||
<CanvasToolbar
|
||||
label="素材工具栏"
|
||||
surface="floating"
|
||||
className="host-toolbar"
|
||||
>
|
||||
<CanvasToolbarGroup data-testid="toolbar-group">
|
||||
<CanvasChromeButton label="下载" icon={<span />} />
|
||||
</CanvasToolbarGroup>
|
||||
<CanvasToolbarDivider data-testid="toolbar-divider" />
|
||||
</CanvasToolbar>,
|
||||
);
|
||||
|
||||
const toolbar = screen.getByRole('toolbar', { name: '素材工具栏' });
|
||||
expect(toolbar.className).toContain('genarrative-image-canvas__toolbar');
|
||||
expect(toolbar.className).toContain(
|
||||
'genarrative-image-canvas__toolbar--floating',
|
||||
);
|
||||
expect(toolbar.className).toContain('host-toolbar');
|
||||
expect(toolbar.getAttribute('aria-orientation')).toBe('horizontal');
|
||||
expect(screen.getByTestId('toolbar-group').className).toContain(
|
||||
'genarrative-image-canvas__toolbar-group',
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId('toolbar-divider').getAttribute('aria-hidden'),
|
||||
).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
function joinClassNames(...classNames: Array<string | undefined | false>) {
|
||||
return classNames.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export type CanvasChromeButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'aria-expanded' | 'aria-label' | 'aria-pressed' | 'children'
|
||||
> & {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
children?: ReactNode;
|
||||
pressed?: boolean;
|
||||
expanded?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 画布宿主共享的动作按钮,只承接 chrome、可访问性与原生按钮状态。
|
||||
*/
|
||||
export const CanvasChromeButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
CanvasChromeButtonProps
|
||||
>(function CanvasChromeButton(
|
||||
{
|
||||
label,
|
||||
icon,
|
||||
children,
|
||||
pressed,
|
||||
expanded,
|
||||
title = label,
|
||||
type = 'button',
|
||||
className,
|
||||
...buttonProps
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const hasVisibleLabel =
|
||||
children !== undefined && children !== null && children !== false;
|
||||
|
||||
return (
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={joinClassNames(
|
||||
'genarrative-image-canvas__chrome-button',
|
||||
hasVisibleLabel &&
|
||||
'genarrative-image-canvas__chrome-button--with-label',
|
||||
className,
|
||||
)}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
title={title}
|
||||
>
|
||||
<span className="genarrative-image-canvas__chrome-button-icon">
|
||||
{icon}
|
||||
</span>
|
||||
{hasVisibleLabel ? (
|
||||
<span className="genarrative-image-canvas__chrome-button-label">
|
||||
{children}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
export type CanvasToolbarProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'aria-label' | 'children' | 'role'
|
||||
> & {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
surface?: 'plain' | 'floating';
|
||||
};
|
||||
|
||||
/** 画布宿主共享的横向工具栏容器。 */
|
||||
export const CanvasToolbar = forwardRef<HTMLDivElement, CanvasToolbarProps>(
|
||||
function CanvasToolbar(
|
||||
{ label, children, surface = 'plain', className, ...toolbarProps },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
{...toolbarProps}
|
||||
ref={ref}
|
||||
role="toolbar"
|
||||
aria-label={label}
|
||||
aria-orientation="horizontal"
|
||||
className={joinClassNames(
|
||||
'genarrative-image-canvas__toolbar',
|
||||
`genarrative-image-canvas__toolbar--${surface}`,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type CanvasToolbarGroupProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/** 将同类画布动作保持为不可拆散的横向分组。 */
|
||||
export const CanvasToolbarGroup = forwardRef<
|
||||
HTMLSpanElement,
|
||||
CanvasToolbarGroupProps
|
||||
>(function CanvasToolbarGroup({ children, className, ...groupProps }, ref) {
|
||||
return (
|
||||
<span
|
||||
{...groupProps}
|
||||
ref={ref}
|
||||
className={joinClassNames(
|
||||
'genarrative-image-canvas__toolbar-group',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
export type CanvasToolbarDividerProps = Omit<
|
||||
HTMLAttributes<HTMLSpanElement>,
|
||||
'aria-hidden' | 'children'
|
||||
>;
|
||||
|
||||
/** 纯视觉工具分隔符,不进入无障碍树。 */
|
||||
export const CanvasToolbarDivider = forwardRef<
|
||||
HTMLSpanElement,
|
||||
CanvasToolbarDividerProps
|
||||
>(function CanvasToolbarDivider({ className, ...dividerProps }, ref) {
|
||||
return (
|
||||
<span
|
||||
{...dividerProps}
|
||||
ref={ref}
|
||||
aria-hidden="true"
|
||||
className={joinClassNames(
|
||||
'genarrative-image-canvas__toolbar-divider',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './CanvasChrome';
|
||||
export * from './CanvasPortal';
|
||||
export * from './CanvasViewport';
|
||||
export * from './CanvasWorld';
|
||||
|
||||
@@ -72,3 +72,129 @@
|
||||
position: relative;
|
||||
z-index: 70;
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__chrome-button {
|
||||
display: inline-flex;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid
|
||||
var(
|
||||
--image-canvas-brand-border-soft,
|
||||
var(--platform-icon-border, rgba(226, 203, 184, 0.82))
|
||||
);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--platform-icon-fill, #ffffff);
|
||||
padding: 0;
|
||||
color: var(--platform-icon-text, #334155);
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__chrome-button--with-label {
|
||||
width: auto;
|
||||
min-width: 2.25rem;
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__chrome-button-icon,
|
||||
.genarrative-image-canvas__chrome-button-label {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__chrome-button-label {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__chrome-button:not(:disabled):hover,
|
||||
.genarrative-image-canvas__chrome-button[aria-pressed='true'] {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(
|
||||
--image-canvas-brand-border-strong,
|
||||
var(--platform-nav-active-border, rgba(199, 101, 61, 0.64))
|
||||
);
|
||||
background: var(
|
||||
--image-canvas-brand-soft,
|
||||
var(--platform-nav-active-fill, rgba(234, 204, 179, 0.28))
|
||||
);
|
||||
color: var(
|
||||
--image-canvas-brand-accent-strong,
|
||||
var(--platform-text-strong, #6f2f21)
|
||||
);
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__chrome-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px
|
||||
var(--image-canvas-brand-focus-ring, rgba(204, 117, 76, 0.18));
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__chrome-button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__toolbar {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__toolbar--floating {
|
||||
border: 1px solid
|
||||
var(
|
||||
--image-canvas-brand-border-soft,
|
||||
var(--platform-surface-border, rgba(226, 203, 184, 0.82))
|
||||
);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--platform-panel-fill, #ffffff);
|
||||
padding: 0.35rem;
|
||||
box-shadow: var(
|
||||
--image-canvas-toolbar-shadow,
|
||||
0 18px 38px rgba(15, 23, 42, 0.16)
|
||||
);
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__toolbar-group {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.genarrative-image-canvas__toolbar-divider {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
align-self: center;
|
||||
border-radius: 999px;
|
||||
background: var(
|
||||
--image-canvas-brand-border-soft,
|
||||
var(--platform-line-soft, rgba(226, 203, 184, 0.82))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,17 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
const user = userEvent.setup();
|
||||
const { toolbar, switchTool } = renderToolbar();
|
||||
|
||||
expect(toolbar.className).toContain('genarrative-image-canvas__toolbar');
|
||||
expect(toolbar.className).toContain(
|
||||
'genarrative-image-canvas__toolbar--floating',
|
||||
);
|
||||
expect(
|
||||
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
|
||||
).toHaveLength(11);
|
||||
expect(
|
||||
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'),
|
||||
).toHaveLength(2);
|
||||
|
||||
const toolExpectations = [
|
||||
['选择工具', 'select'],
|
||||
['抓手工具', 'hand'],
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
CanvasToolbar,
|
||||
CanvasToolbarDivider,
|
||||
CanvasToolbarGroup,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
AppWindow,
|
||||
Clapperboard,
|
||||
@@ -77,27 +82,22 @@ export function ImageCanvasBottomToolbarView({
|
||||
onCloseToolOptions,
|
||||
}: ImageCanvasBottomToolbarViewProps) {
|
||||
return (
|
||||
<div
|
||||
<CanvasToolbar
|
||||
className={[
|
||||
'image-canvas-editor__bottom-toolbar',
|
||||
highlight ? 'image-canvas-editor__bottom-toolbar--guided' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="toolbar"
|
||||
aria-label="AI画布工具栏"
|
||||
label="AI画布工具栏"
|
||||
surface="floating"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canvasTools.map(({ id, label, icon: Icon, separatorBefore }) => {
|
||||
if (isToolbarOptionTool(id)) {
|
||||
return (
|
||||
<span key={id} className="image-canvas-editor__bottom-tool-group">
|
||||
{separatorBefore ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__bottom-toolbar-divider"
|
||||
/>
|
||||
) : null}
|
||||
<CanvasToolbarGroup key={id}>
|
||||
{separatorBefore ? <CanvasToolbarDivider /> : null}
|
||||
<span
|
||||
ref={
|
||||
id === 'spec'
|
||||
@@ -124,22 +124,19 @@ export function ImageCanvasBottomToolbarView({
|
||||
label={label}
|
||||
title={label}
|
||||
icon={Icon}
|
||||
pressed={hasPersistentPressedState(id) && effectiveTool === id}
|
||||
pressed={
|
||||
hasPersistentPressedState(id) && effectiveTool === id
|
||||
}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</CanvasToolbarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={id} className="image-canvas-editor__bottom-tool-group">
|
||||
{separatorBefore ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__bottom-toolbar-divider"
|
||||
/>
|
||||
) : null}
|
||||
<CanvasToolbarGroup key={id}>
|
||||
{separatorBefore ? <CanvasToolbarDivider /> : null}
|
||||
<EditorIconButton
|
||||
label={label}
|
||||
title={label}
|
||||
@@ -147,9 +144,9 @@ export function ImageCanvasBottomToolbarView({
|
||||
pressed={hasPersistentPressedState(id) && effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
</CanvasToolbarGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CanvasToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SidebarMediaItem,
|
||||
} from './ImageCanvasEditorPrimitives';
|
||||
|
||||
test('editor icon button delegates accessible icon chrome to the platform primitive', () => {
|
||||
test('editor icon button delegates accessible icon chrome to the shared canvas primitive', () => {
|
||||
render(
|
||||
<EditorIconButton
|
||||
label="素材选择模式"
|
||||
@@ -23,7 +23,7 @@ test('editor icon button delegates accessible icon chrome to the platform primit
|
||||
|
||||
const button = screen.getByRole('button', { name: '素材选择模式' });
|
||||
|
||||
expect(button.className).toContain('platform-icon-button');
|
||||
expect(button.className).toContain('genarrative-image-canvas__chrome-button');
|
||||
expect(button.className).toContain('image-canvas-editor__icon-button');
|
||||
expect(button.getAttribute('title')).toBe('选择');
|
||||
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
||||
import type {
|
||||
ComponentType,
|
||||
DragEventHandler,
|
||||
@@ -39,6 +40,24 @@ export function EditorIconButton({
|
||||
variant,
|
||||
onClick,
|
||||
}: EditorIconButtonProps) {
|
||||
const iconNode = <Icon className="h-4 w-4" />;
|
||||
|
||||
if (variant === undefined || variant === 'platformIcon') {
|
||||
return (
|
||||
<CanvasChromeButton
|
||||
type={type}
|
||||
className={className}
|
||||
label={label}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
pressed={pressed}
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
icon={iconNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformIconButton
|
||||
variant={variant}
|
||||
@@ -50,7 +69,7 @@ export function EditorIconButton({
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
onClick={onClick}
|
||||
icon={<Icon className="h-4 w-4" />}
|
||||
icon={iconNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2176,7 +2176,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
||||
expect(backgroundButton.className).toContain('platform-icon-button');
|
||||
expect(backgroundButton.className).toContain(
|
||||
'genarrative-image-canvas__chrome-button',
|
||||
);
|
||||
const minimapToggle = within(panelToolbar).getByRole('button', {
|
||||
name: '切换小地图',
|
||||
});
|
||||
|
||||
@@ -119,6 +119,11 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '画布背景色' })
|
||||
.getAttribute('aria-expanded'),
|
||||
).toBe('true');
|
||||
const spectrumButton = within(panel).getByRole('button', {
|
||||
name: '画布背景色盘',
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Minimap, ZoomControls } from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
CanvasChromeButton,
|
||||
Minimap,
|
||||
ZoomControls,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
ImagePlus,
|
||||
Layers,
|
||||
@@ -20,7 +24,6 @@ import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import {
|
||||
CANVAS_BACKGROUND_OPTIONS,
|
||||
@@ -394,10 +397,10 @@ export function ImageCanvasPanelDockView({
|
||||
)}
|
||||
</ZoomControls>
|
||||
<div className="image-canvas-editor__background-control">
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
label="画布背景色"
|
||||
title="画布背景色"
|
||||
aria-expanded={isBackgroundSettingsOpen}
|
||||
expanded={isBackgroundSettingsOpen}
|
||||
onClick={onToggleBackgroundSettings}
|
||||
icon={
|
||||
<span
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
Crop,
|
||||
Download,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
|
||||
@@ -57,7 +57,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
aria-label="素材工具栏"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
@@ -65,7 +65,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
@@ -91,7 +91,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
>
|
||||
{!isQuickEditUnsupportedAssetKind(selectedLayer) ? (
|
||||
<>
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="快速编辑"
|
||||
title="快速编辑"
|
||||
@@ -99,7 +99,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||
>
|
||||
<span>快速编辑</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
@@ -123,7 +123,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
</>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
@@ -157,10 +157,10 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
? '拆图中'
|
||||
: '拆分图集'}
|
||||
</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'ui-design' ? (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="提取素材"
|
||||
title="提取素材"
|
||||
@@ -168,10 +168,10 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onExtractUiDesignAssets(selectedLayer)}
|
||||
>
|
||||
<span>提取素材</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'character' ? (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="生成动画"
|
||||
title="生成动画"
|
||||
@@ -179,13 +179,13 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
|
||||
>
|
||||
<span>生成动画</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
@@ -193,7 +193,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
title="下载按钮"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -14,7 +15,6 @@ import { useState } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
@@ -314,9 +314,9 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
const Icon = option.icon;
|
||||
const isActive = state.tool === option.value;
|
||||
return (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
key={option.value}
|
||||
aria-pressed={isActive}
|
||||
pressed={isActive}
|
||||
className={
|
||||
isActive
|
||||
? 'image-canvas-editor__ui-extraction-tool--active'
|
||||
|
||||
+3
-39
@@ -4064,11 +4064,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.image-canvas-editor__icon-button,
|
||||
.image-canvas-editor__project-back-button,
|
||||
.image-canvas-editor__floating-toolbar button,
|
||||
.image-canvas-editor__bottom-toolbar button,
|
||||
.image-canvas-editor__reset-button {
|
||||
.image-canvas-editor__floating-toolbar button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -4082,23 +4079,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.image-canvas-editor__icon-button:hover,
|
||||
.image-canvas-editor__floating-toolbar button:hover,
|
||||
.image-canvas-editor__bottom-toolbar button:hover,
|
||||
.image-canvas-editor__reset-button:hover {
|
||||
.image-canvas-editor__floating-toolbar button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--image-canvas-brand-border-strong);
|
||||
background: var(--image-canvas-brand-soft);
|
||||
color: var(--image-canvas-brand-accent-strong);
|
||||
}
|
||||
|
||||
.image-canvas-editor__icon-button {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.45rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar-header-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
@@ -5011,21 +4998,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__floating-toolbar button,
|
||||
.image-canvas-editor__bottom-toolbar button,
|
||||
.image-canvas-editor__reset-button {
|
||||
.image-canvas-editor__floating-toolbar button {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.45rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-tool-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar-divider,
|
||||
.image-canvas-editor__floating-toolbar-divider {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
@@ -6578,15 +6556,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
left: 50%;
|
||||
bottom: 0.85rem;
|
||||
z-index: 18;
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
max-width: min(calc(100% - 6.6rem), 34rem);
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.35rem;
|
||||
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.16);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@@ -6638,12 +6608,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
text-shadow: 0 2px 7px rgba(127, 65, 25, 0.2);
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar button[aria-pressed='true'] {
|
||||
border-color: var(--image-canvas-brand-border-strong);
|
||||
background: var(--image-canvas-brand-soft);
|
||||
color: var(--image-canvas-brand-accent-strong);
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar-option-wrap,
|
||||
.image-canvas-editor__spec-tool-wrap {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user