统一主站画布共享视觉组件
Project CI / Repository checks (pull_request) Failing after 8s
Project CI / Backend tests (pull_request) Failing after 8s
Project CI / Frontend tests (pull_request) Successful in 2m55s
Project CI / Native shell tests (pull_request) Successful in 13m59s

新增共享画布按钮、工具栏、分组与分隔符及作用域样式
主站图片画布默认动作与底部工具栏改用共享实现
补齐 UI 对齐合同、决策记录和定向测试
This commit is contained in:
2026-08-06 12:54:20 +08:00
parent 050d06ddb5
commit f9847c8f15
17 changed files with 519 additions and 88 deletions
@@ -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-*` tokenTauri 不导入主站完整 `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 APIAPI 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
View File
@@ -1,3 +1,4 @@
export * from './CanvasChrome';
export * from './CanvasPortal';
export * from './CanvasViewport';
export * from './CanvasWorld';
+126
View File
@@ -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
View File
@@ -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;