feat(i18n): implement internationalization framework and update UI components for language support

This commit is contained in:
HouYunFei
2026-08-05 14:08:03 +08:00
parent 9bccd0ff1a
commit 0f6809251a
116 changed files with 3269 additions and 1870 deletions
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Empty, Input, Modal, Pagination, Tag } from "antd";
import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
@@ -15,8 +16,9 @@ type Props = {
};
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
const { t } = useTranslation();
return (
<Modal title="选择资产" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<Modal title={t("canvas.assetPicker.title")} open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<MyAssetsTab onInsert={onInsert} />
</Modal>
);
@@ -24,14 +26,10 @@ export function AssetPickerModal({ open, onInsert, onClose }: Props) {
const PAGE_SIZE = 8;
const kindOptions = [
{ label: "全部", value: "all" },
{ label: "文本", value: "text" },
{ label: "图片", value: "image" },
{ label: "视频", value: "video" },
];
const kindOptions = ["all", "text", "image", "video"];
function PickerCard({ title, kind, cover, onClick }: { title: string; kind: string; cover: string; onClick: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
@@ -46,15 +44,16 @@ function PickerCard({ title, kind, cover, onClick }: { title: string; kind: stri
<div className="p-2.5">
<div className="flex items-center justify-between gap-2">
<span className="line-clamp-1 text-xs font-medium text-stone-800 dark:text-stone-200">{title}</span>
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : kind === "video" ? "视频" : "文本"}</Tag>
<Tag className="m-0 shrink-0 text-[10px]">{t(`assets.kinds.${kind}`)}</Tag>
</div>
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">{t("canvas.assetPicker.insert")}</div>
</button>
);
}
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
const { t } = useTranslation();
const assets = useAssetStore((state) => state.assets);
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState("all");
@@ -90,7 +89,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索资产"
placeholder={t("canvas.assetPicker.search")}
value={keyword}
allowClear
onChange={(e) => {
@@ -99,17 +98,17 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
}}
/>
<div className="flex gap-1.5">
{kindOptions.map((opt) => (
{kindOptions.map((option) => (
<Tag.CheckableTag
key={opt.value}
checked={kindFilter === opt.value}
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
key={option}
checked={kindFilter === option}
className={cn("prompt-filter-tag", kindFilter === option && "is-active")}
onChange={() => {
setPage(1);
setKindFilter(opt.value);
setKindFilter(option);
}}
>
{opt.label}
{option === "all" ? t("common.all") : t(`assets.kinds.${option}`)}
</Tag.CheckableTag>
))}
</div>
@@ -122,7 +121,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有资产" className="py-12" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("canvas.assetPicker.empty")} className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
@@ -2,7 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
import { Button, Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Video, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { NodeGenerationInput } from "./canvas-node-generation";
@@ -25,6 +27,7 @@ type MentionState = {
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
@@ -115,13 +118,13 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold"></div>
<div className="truncate text-[11px] opacity-55">@ </div>
<div className="shrink-0 text-xs font-semibold">{t("canvas.composer.title")}</div>
<div className="truncate text-[11px] opacity-55">{t("canvas.composer.description")}</div>
</div>
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
</div>
<div className="relative rounded-xl">
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}> @ </div> : null}
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>{t("canvas.composer.placeholder")}</div> : null}
<div
ref={editorRef}
contentEditable
@@ -173,7 +176,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
/>
{mention && candidates.length ? <MentionMenu inputs={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null}
</div>
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
{imagePreview ? <Image src={imagePreview} alt={t("canvas.composer.imagePreview")} style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
</div>
);
@@ -360,10 +363,7 @@ function parseComposerTokens(value: string): Token[] {
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
if (input.type === "image") return `图片${index + 1}`;
if (input.type === "video") return `视频${index + 1}`;
if (input.type === "audio") return `音频${index + 1}`;
return `文本${index + 1}`;
return i18n.t(`canvas.composer.resources.${input.type}`, { index: index + 1 });
}
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
@@ -1,6 +1,7 @@
import type { CSSProperties } from "react";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react";
import { Button, Segmented } from "antd";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -23,6 +24,7 @@ type CanvasConfigNodePanelProps = {
};
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onStop, onComposerToggle }: CanvasConfigNodePanelProps) {
const { t } = useTranslation();
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -36,7 +38,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
return (
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="shrink-0 text-sm font-semibold"></div>
<div className="shrink-0 text-sm font-semibold">{t("canvas.configNode.title")}</div>
<div className="cursor-default" onMouseDown={(event) => event.stopPropagation()}>
<Segmented
size="small"
@@ -49,7 +51,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
label: (
<span className="inline-flex items-center gap-1">
<ImageIcon className="size-3.5" />
{t("canvas.configNode.image")}
</span>
),
},
@@ -58,7 +60,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
label: (
<span className="inline-flex items-center gap-1">
<MessageSquare className="size-3.5" />
{t("canvas.configNode.text")}
</span>
),
},
@@ -67,7 +69,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
label: (
<span className="inline-flex items-center gap-1">
<Video className="size-3.5" />
{t("canvas.configNode.video")}
</span>
),
},
@@ -76,7 +78,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
label: (
<span className="inline-flex items-center gap-1">
<Music2 className="size-3.5" />
{t("canvas.configNode.audio")}
</span>
),
},
@@ -86,13 +88,13 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</div>
<div className="mb-2 flex flex-wrap gap-1.5">
<InputChip label="提示词" value={`${inputSummary.textCount}`} style={chipStyle} />
<InputChip label="参考图" value={`${inputSummary.imageCount}`} style={chipStyle} />
<InputChip label="参考视频" value={`${inputSummary.videoCount}`} style={chipStyle} />
<InputChip label="参考音频" value={`${inputSummary.audioCount}`} style={chipStyle} />
<InputChip label={t("canvas.configNode.prompt")} value={t("canvas.configNode.items", { count: inputSummary.textCount })} style={chipStyle} />
<InputChip label={t("canvas.configNode.references")} value={t("canvas.configNode.images", { count: inputSummary.imageCount })} style={chipStyle} />
<InputChip label={t("canvas.configNode.videoReferences")} value={t("canvas.configNode.items", { count: inputSummary.videoCount })} style={chipStyle} />
<InputChip label={t("canvas.configNode.audioReferences")} value={t("canvas.configNode.items", { count: inputSummary.audioCount })} style={chipStyle} />
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
<Settings2 className="size-3.5" />
{t("canvas.configNode.compose")}
</button>
</div>
@@ -122,12 +124,12 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
<>
<LoaderCircle className="size-4 animate-spin" />
<Square className="size-3.5 fill-current" />
<span></span>
<span>{t("canvas.configNode.stop")}</span>
</>
) : (
<>
<Play className="size-4" />
<span></span>
<span>{t("canvas.configNode.generate")}</span>
</>
)}
</span>
@@ -1,12 +1,14 @@
import { useEffect } from "react";
import type { ReactNode } from "react";
import { Plus, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ContextMenuState } from "@/types/canvas";
export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: { menu: ContextMenuState; onClose: () => void; onDuplicate: () => void; onDelete: () => void }) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
useEffect(() => {
@@ -25,8 +27,8 @@ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }:
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="复制" onClick={onDuplicate} /> : null}
<MenuButton icon={<Trash2 className="size-4" />} label="删除" onClick={onDelete} danger />
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label={t("canvas.controls.duplicate")} onClick={onDuplicate} /> : null}
<MenuButton icon={<Trash2 className="size-4" />} label={t("canvas.controls.delete")} onClick={onDelete} danger />
</div>
);
}
@@ -1,5 +1,6 @@
import { useEffect, useRef } from "react";
import { ImageIcon, List, Music2, Settings2, Video, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -21,6 +22,7 @@ export function ConnectionCreateMenu({
onClose: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
return (
<div
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
@@ -31,18 +33,18 @@ export function ConnectionCreateMenu({
>
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
{t("canvas.createMenu.fromNode")}
</span>
<button type="button" className="grid size-7 place-items-center rounded-lg text-base opacity-55 transition hover:bg-white/10 hover:opacity-100" onClick={onClose} aria-label="关闭">
<button type="button" className="grid size-7 place-items-center rounded-lg text-base opacity-55 transition hover:bg-white/10 hover:opacity-100" onClick={onClose} aria-label={t("canvas.createMenu.close")}>
×
</button>
</div>
<div className="grid gap-1">
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title={t("canvas.createMenu.text")} description={t("canvas.createMenu.textDescription")} onClick={() => onCreate(CanvasNodeType.Text)} />
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title={t("canvas.createMenu.image")} onClick={() => onCreate(CanvasNodeType.Image)} />
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title={t("canvas.createMenu.video")} onClick={() => onCreate(CanvasNodeType.Video)} />
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title={t("canvas.createMenu.audio")} onClick={() => onCreate(CanvasNodeType.Audio)} />
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title={t("canvas.createMenu.config")} description={t("canvas.createMenu.configDescription")} onClick={() => onCreate(CanvasNodeType.Config)} />
</div>
</div>
);
@@ -75,6 +77,7 @@ export function ConnectionCreateOption({ theme, icon, title, description, onClic
export function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
useNodeRegistryVersion();
const menuRef = useRef<HTMLDivElement>(null);
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
@@ -96,9 +99,9 @@ export function NodeCreateMenu({ position, onCreate, onClose }: { position: Posi
>
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
{t("canvas.createMenu.select")}
</span>
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭">
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label={t("canvas.createMenu.close")}>
<X className="size-4" />
</button>
</div>
@@ -1,10 +1,12 @@
import { Button, Modal } from "antd";
import { useTranslation } from "react-i18next";
import { useAssetStore } from "@/stores/use-asset-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
export function CanvasDeleteProjectsDialog() {
const { t } = useTranslation();
const ids = useCanvasUiStore((state) => state.deleteProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const removeSelectedIds = useCanvasUiStore((state) => state.removeSelectedProjectIds);
@@ -19,20 +21,20 @@ export function CanvasDeleteProjectsDialog() {
return (
<Modal
title="删除画布?"
title={t("canvas.project.deleteTitle")}
open={ids.length > 0}
centered
onCancel={() => setDeleteIds([])}
footer={
<>
<Button onClick={() => setDeleteIds([])}></Button>
<Button onClick={() => setDeleteIds([])}>{t("common.cancel")}</Button>
<Button danger type="primary" onClick={confirm}>
{t("common.delete")}
</Button>
</>
}
>
<p className="text-sm text-stone-500"> {ids.length} 线</p>
<p className="text-sm text-stone-500">{t("canvas.project.deleteDescription", { count: ids.length })}</p>
</Modal>
);
}
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { useTranslation } from "react-i18next";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -20,6 +21,7 @@ type CanvasImageSettingsPopoverProps = {
};
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
@@ -63,7 +65,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => updateOpen(!open)}>
<span className="truncate">
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count}
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {t("canvas.controls.images", { count })}
</span>
</Button>
</span>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Button, Card, Checkbox, Form, Modal, Space, Switch, Tag, Tooltip, Typography, theme as antdTheme } from "antd";
import { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ImageQuickToolId } from "./canvas-image-toolbar-tools";
@@ -48,6 +49,7 @@ export function ImageToolSettingsModal({
onCancel: () => void;
onSave: () => void;
}) {
const { t } = useTranslation();
const { token } = antdTheme.useToken();
const previewToolbarRef = useRef<HTMLDivElement>(null);
const scrollbarTrackRef = useRef<HTMLInputElement>(null);
@@ -56,7 +58,7 @@ export function ImageToolSettingsModal({
const selectedTools = tools.filter((tool) => selected.has(tool.id));
const previewTools: PreviewTool[] = [
...selectedTools,
{ id: "more", title: "配置快捷工具", label: "更多", icon: <Ellipsis className="size-4" />, active: true },
{ id: "more", title: t("canvas.imageTools.configure"), label: t("canvas.imageTools.more"), icon: <Ellipsis className="size-4" />, active: true },
];
const syncPreviewScroll = useCallback(() => {
@@ -121,7 +123,7 @@ export function ImageToolSettingsModal({
return (
<Modal
title="自定义工具栏"
title={t("canvas.imageTools.customize")}
open={open}
centered
width={760}
@@ -130,20 +132,20 @@ export function ImageToolSettingsModal({
footer={
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span></span>
<span>{t("canvas.imageTools.showLabels")}</span>
<Switch checked={showLabels} onChange={onShowLabelsChange} />
</div>
<Space>
<Button onClick={onCancel}></Button>
<Button onClick={onCancel}>{t("common.cancel")}</Button>
<Button type="primary" onClick={onSave}>
{t("common.save")}
</Button>
</Space>
</div>
}
>
<Typography.Paragraph type="secondary" className="!mb-4">
使
{t("canvas.imageTools.description")}
</Typography.Paragraph>
<Card
@@ -151,7 +153,7 @@ export function ImageToolSettingsModal({
title={
<Space size={6}>
<Settings2 className="size-4" />
{t("canvas.imageTools.preview")}
</Space>
}
className="mb-4"
@@ -172,7 +174,7 @@ export function ImageToolSettingsModal({
style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary, color: token.colorTextSecondary }}
>
<ImageIcon className="mb-2 size-8" />
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary">{t("canvas.imageTools.imageNode")}</Typography.Text>
</div>
<input
ref={scrollbarTrackRef}
@@ -194,7 +196,7 @@ export function ImageToolSettingsModal({
className="!mb-4"
label={
<Space size={8}>
<span></span>
<span>{t("canvas.imageTools.quickTools")}</span>
<Tag className="m-0">
{selectedTools.length}/{tools.length}
</Tag>
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react";
import type { CanvasNodeData } from "@/types/canvas";
import i18n from "@/i18n";
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
@@ -23,7 +24,6 @@ export type ImageToolHandlers = {
export type ImageToolDefinition = {
id: ImageNodeActionToolId;
defaultVisible: boolean;
panelLabel: string;
label: string | ((node: CanvasNodeData) => string);
title: string | ((node: CanvasNodeData) => string);
icon: (node: CanvasNodeData) => ReactNode;
@@ -44,36 +44,32 @@ export const imageToolDefinitions: ImageToolDefinition[] = [
{
id: "copyPrompt",
defaultVisible: true,
panelLabel: "复制提示词",
label: "复制提示词",
title: "复制生成该图片的提示词",
label: () => i18n.t("canvas.imageTools.copyPrompt"),
title: () => i18n.t("canvas.imageTools.copyPromptTitle"),
icon: () => <Copy className="size-4" />,
run: (node, handlers) => handlers.onCopyPrompt(node),
},
{
id: "reversePrompt",
defaultVisible: true,
panelLabel: "反推提示词",
label: "反推提示词",
title: "创建反推提示词的文本和配置节点",
label: () => i18n.t("canvas.imageTools.reversePrompt"),
title: () => i18n.t("canvas.imageTools.reversePromptTitle"),
icon: () => <FileText className="size-4" />,
run: (node, handlers) => handlers.onReversePrompt(node),
},
{
id: "replace",
defaultVisible: true,
panelLabel: "替换图片",
label: "替换图片",
title: "替换图片",
label: () => i18n.t("canvas.imageTools.replace"),
title: () => i18n.t("canvas.imageTools.replace"),
icon: () => <Upload className="size-4" />,
run: (node, handlers) => handlers.onUpload(node),
},
{
id: "resize",
defaultVisible: false,
panelLabel: "锁比例",
label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"),
title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"),
label: (node) => i18n.t(node.metadata?.freeResize ? "canvas.imageTools.free" : "canvas.imageTools.locked"),
title: (node) => i18n.t(node.metadata?.freeResize ? "canvas.imageTools.lockTitle" : "canvas.imageTools.freeTitle"),
icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />),
active: (node) => Boolean(node.metadata?.freeResize),
run: (node, handlers) => handlers.onToggleFreeResize(node),
@@ -81,63 +77,56 @@ export const imageToolDefinitions: ImageToolDefinition[] = [
{
id: "maskEdit",
defaultVisible: true,
panelLabel: "局部编辑",
label: "局部编辑",
title: "添加蒙版遮罩后局部修改",
label: () => i18n.t("canvas.imageTools.mask"),
title: () => i18n.t("canvas.imageTools.maskTitle"),
icon: () => <Brush className="size-4" />,
run: (node, handlers) => handlers.onMaskEdit(node),
},
{
id: "crop",
defaultVisible: true,
panelLabel: "裁剪",
label: "裁剪",
title: "裁剪并生成新节点",
label: () => i18n.t("canvas.imageTools.crop"),
title: () => i18n.t("canvas.imageTools.cropTitle"),
icon: () => <Scissors className="size-4" />,
run: (node, handlers) => handlers.onCrop(node),
},
{
id: "split",
defaultVisible: true,
panelLabel: "切图",
label: "切图",
title: "按行列切分图片",
label: () => i18n.t("canvas.imageTools.split"),
title: () => i18n.t("canvas.imageTools.splitTitle"),
icon: () => <Grid2x2 className="size-4" />,
run: (node, handlers) => handlers.onSplit(node),
},
{
id: "upscale",
defaultVisible: true,
panelLabel: "放大",
label: "放大",
title: "放大图片分辨率",
label: () => i18n.t("canvas.imageTools.upscale"),
title: () => i18n.t("canvas.imageTools.upscaleTitle"),
icon: () => <ZoomIn className="size-4" />,
run: (node, handlers) => handlers.onUpscale(node),
},
{
id: "superResolve",
defaultVisible: false,
panelLabel: "超分",
label: "超分",
title: "AI 超分",
label: () => i18n.t("canvas.imageTools.superResolve"),
title: () => i18n.t("canvas.imageTools.superResolveTitle"),
icon: () => <Sparkles className="size-4" />,
run: (node, handlers) => handlers.onSuperResolve(node),
},
{
id: "angle",
defaultVisible: false,
panelLabel: "多角度",
label: "多角度",
title: "生成角度",
label: () => i18n.t("canvas.imageTools.angle"),
title: () => i18n.t("canvas.imageTools.angleTitle"),
icon: () => <Camera className="size-4" />,
run: (node, handlers) => handlers.onAngle(node),
},
{
id: "view",
defaultVisible: true,
panelLabel: "查看大图",
label: "查看大图",
title: "查看图片详情",
label: () => i18n.t("canvas.imageTools.view"),
title: () => i18n.t("canvas.imageTools.viewTitle"),
icon: () => <Maximize2 className="size-4" />,
run: (node, handlers) => handlers.onViewImage(node),
},
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { Button, Modal, Segmented, Slider } from "antd";
import { RotateCcw, WandSparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
export type CanvasImageAngleParams = {
horizontalAngle: number;
@@ -17,6 +18,7 @@ const defaultParams: CanvasImageAngleParams = {
};
export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageAngleParams) => void }) {
const { t } = useTranslation();
const [params, setParams] = useState(defaultParams);
useEffect(() => {
@@ -29,8 +31,8 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { d
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={860} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold">AI </h2>
<p className="mt-1 text-sm opacity-60"></p>
<h2 className="text-xl font-semibold">{t("canvas.editors.angleTitle")}</h2>
<p className="mt-1 text-sm opacity-60">{t("canvas.editors.angleDescription")}</p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="flex min-h-[300px] flex-col justify-between rounded-xl border p-4">
@@ -41,21 +43,21 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { d
</div>
</div>
<Button className="w-fit" icon={<RotateCcw className="size-4" />} onClick={() => setParams(defaultParams)}>
{t("canvas.editors.reset")}
</Button>
</div>
<div className="space-y-6 py-2">
<AngleSlider label="左右角度" value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
<AngleSlider label="俯仰角度" value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
<AngleSlider label="镜头距离" value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
<AngleSlider label={t("canvas.editors.horizontal")} value={params.horizontalAngle} min={-60} max={60} step={1} suffix="deg" onChange={(value) => update("horizontalAngle", value)} />
<AngleSlider label={t("canvas.editors.pitch")} value={params.pitchAngle} min={-45} max={45} step={1} suffix="deg" onChange={(value) => update("pitchAngle", value)} />
<AngleSlider label={t("canvas.editors.distance")} value={params.cameraDistance} min={1} max={10} step={0.1} onChange={(value) => update("cameraDistance", value)} />
<div className="grid grid-cols-[88px_1fr_72px] items-center gap-4">
<span className="font-medium opacity-75">广</span>
<span className="font-medium opacity-75">{t("canvas.editors.lens")}</span>
<Segmented
className="w-fit"
value={params.wideAngle ? "wide" : "standard"}
options={[
{ label: "标准", value: "standard" },
{ label: "广角", value: "wide" },
{ label: t("canvas.editors.standard"), value: "standard" },
{ label: t("canvas.editors.wide"), value: "wide" },
]}
onChange={(value) => update("wideAngle", value === "wide")}
/>
@@ -64,7 +66,7 @@ export function CanvasNodeAngleDialog({ dataUrl, open, onClose, onConfirm }: { d
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<WandSparkles className="size-4" />} onClick={() => onConfirm(params)}>
AI
{t("canvas.editors.aiGenerate")}
</Button>
</div>
</div>
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Modal, Segmented, Tooltip } from "antd";
import { Check, X, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
import { readImageMeta } from "@/lib/image-utils";
@@ -18,17 +19,8 @@ type ResizeHandle = "n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw";
const handles: ResizeHandle[] = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
const minSize = 0.06;
const defaultCrop = { x: 0.12, y: 0.12, width: 0.76, height: 0.76 };
const ratioOptions = [
{ label: "自由", value: "free" },
{ label: "固定", value: "fixed" },
{ label: "原图", value: "original" },
{ label: "1:1", value: "1:1" },
{ label: "4:3", value: "4:3" },
{ label: "16:9", value: "16:9" },
{ label: "9:16", value: "9:16" },
];
export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (crop: CanvasImageCropRect) => void }) {
const { t } = useTranslation();
const [crop, setCrop] = useState<CanvasImageCropRect>(defaultCrop);
const [ratioPreset, setRatioPreset] = useState("free");
const [fixedRatio, setFixedRatio] = useState<number | null>(null);
@@ -77,7 +69,7 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
};
return (
<Modal title="裁剪图片" open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<Modal title={t("canvas.editors.cropTitle")} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-4">
<div
ref={viewport.viewportRef}
@@ -102,7 +94,7 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
className="absolute size-3 rounded-full border border-black bg-white"
style={handleStyle(handle)}
onPointerDown={(event) => startDrag("resize", event, handle)}
aria-label="调整裁剪框"
aria-label={t("canvas.editors.adjustCrop")}
/>
))}
</div>
@@ -111,31 +103,34 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
</div>
<div className="flex items-center justify-center gap-1">
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
<Tooltip title={t("canvas.editors.zoomOut")}>
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
<Tooltip title={t("canvas.editors.zoomIn")}>
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
</Tooltip>
<span className="ml-2 text-xs opacity-55"> · +</span>
<span className="ml-2 text-xs opacity-55">{t("canvas.editors.cropHint")}</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border px-3 py-2">
<div className="flex flex-wrap items-center gap-3 text-sm opacity-80">
<span> {cropSize ? `${cropSize.width} x ${cropSize.height}` : "未知"}</span>
<span> {cropSize ? formatRatio(cropSize.width, cropSize.height) : "未知"}</span>
<span>{t("canvas.editors.cropSize", { size: cropSize ? `${cropSize.width} x ${cropSize.height}` : t("canvas.editors.unknown") })}</span>
<span>{t("canvas.editors.ratio", { ratio: cropSize ? formatRatio(cropSize.width, cropSize.height) : t("canvas.editors.unknown") })}</span>
{image ? (
<span>
{image.width} x {image.height}
</span>
<span>{t("canvas.editors.original", { width: image.width, height: image.height })}</span>
) : null}
</div>
<Segmented
size="small"
options={ratioOptions}
options={[
{ label: t("canvas.editors.free"), value: "free" },
{ label: t("canvas.editors.fixed"), value: "fixed" },
{ label: t("canvas.editors.originalMode"), value: "original" },
...["1:1", "4:3", "16:9", "9:16"],
]}
value={ratioPreset}
onChange={(value) => {
const preset = String(value);
@@ -150,12 +145,12 @@ export function CanvasNodeCropDialog({ dataUrl, open, onClose, onConfirm }: { da
</div>
<div className="flex items-center justify-end gap-2">
<Button onClick={() => setCrop(defaultCrop)}></Button>
<Button onClick={() => setCrop(defaultCrop)}>{t("canvas.editors.reset")}</Button>
<Button icon={<X className="size-4" />} onClick={onClose}>
{t("canvas.editors.cancel")}
</Button>
<Button type="primary" icon={<Check className="size-4" />} onClick={() => onConfirm(crop)}>
{t("canvas.editors.confirmCrop")}
</Button>
</div>
</div>
@@ -1,4 +1,5 @@
import type { AiTextMessage } from "@/services/api/image";
import i18n from "@/i18n";
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { ReferenceImage } from "@/types/image";
@@ -154,7 +155,7 @@ function generationLabel(type: NodeGenerationInput["type"], index: number) {
if (type === "image") return imageReferenceLabel(index);
if (type === "video") return seedanceReferenceLabel("video", index);
if (type === "audio") return seedanceReferenceLabel("audio", index);
return `文本${index + 1}`;
return i18n.t("canvas.composer.resources.text", { index: index + 1 });
}
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Modal, Segmented, Tooltip } from "antd";
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -82,6 +84,7 @@ export function CanvasNodeHoverToolbar({
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
const { message } = App.useApp();
const { t } = useTranslation();
const copyText = useCopyText();
useEffect(() => {
@@ -120,10 +123,10 @@ export function CanvasNodeHoverToolbar({
const copyImagePrompt = (target: CanvasNodeData) => {
const prompt = target.metadata?.prompt?.trim();
if (!prompt) {
message.warning("暂无可复制的提示词");
message.warning(t("canvas.nodeToolbar.noPrompt"));
return;
}
copyText(prompt, "提示词已复制");
copyText(prompt, t("common.promptCopied"));
};
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
@@ -135,22 +138,22 @@ export function CanvasNodeHoverToolbar({
}
const baseToolbarTools: ToolbarTool[] = [
{ id: "info", title: "查看节点信息", label: "信息", icon: <Info className="size-4" />, onClick: () => onInfo(node) },
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
{ id: "info", title: t("canvas.nodeToolbar.infoTitle"), label: t("canvas.nodeToolbar.info"), icon: <Info className="size-4" />, onClick: () => onInfo(node) },
{ id: "delete", title: t("canvas.nodeToolbar.removeTitle"), label: t("common.delete"), icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
];
const nodeToolbarTools: ToolbarTool[] = [
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的资产", label: "存资产", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []),
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
...(canRetry ? [{ id: "retry", title: t("canvas.nodeToolbar.retryTitle"), label: t("canvas.node.retry"), icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: t("common.addToAssets"), label: t("canvas.nodeToolbar.saveAsset"), icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: t(hasAudio ? "canvas.nodeToolbar.downloadAudio" : hasVideo ? "canvas.nodeToolbar.downloadVideo" : "canvas.nodeToolbar.downloadImage"), label: t("common.download"), icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: t("common.edit"), label: t("common.edit"), icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "editText", title: t("canvas.nodeToolbar.editTextTitle"), label: t("canvas.nodeToolbar.editText"), icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
...(isText ? [{ id: "generateImage", title: t("canvas.node.generateImage"), label: t("canvas.node.generate"), icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
...(isConfig ? [{ id: "config", title: t("canvas.configNode.title"), label: t("canvas.configNode.title"), icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "decreaseFont", title: t("canvas.nodeToolbar.decreaseFont"), label: t("canvas.nodeToolbar.zoomOut"), icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
...(isText ? [{ id: "increaseFont", title: t("canvas.nodeToolbar.increaseFont"), label: t("canvas.nodeToolbar.zoomIn"), icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
...(isImage && !hasImage ? [{ id: "uploadImage", title: t("canvas.nodeToolbar.uploadImage"), label: t("canvas.nodeToolbar.uploadImage"), icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
...(isVideo ? [{ id: "uploadVideo", title: t(hasVideo ? "canvas.nodeToolbar.replaceVideo" : "canvas.nodeToolbar.uploadVideo"), label: t(hasVideo ? "canvas.nodeToolbar.replaceVideo" : "canvas.nodeToolbar.uploadVideo"), icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []),
...(isAudio ? [{ id: "uploadAudio", title: t(hasAudio ? "canvas.nodeToolbar.replaceAudio" : "canvas.nodeToolbar.uploadAudio"), label: t(hasAudio ? "canvas.nodeToolbar.replaceAudio" : "canvas.nodeToolbar.uploadAudio"), icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools, ...extraTools];
@@ -193,7 +196,7 @@ export function CanvasNodeHoverToolbar({
{toolbarTools.map((tool) => (
<ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} />
))}
{hasImage ? <ToolbarAction id="more" title="配置快捷工具" label="更多" icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
{hasImage ? <ToolbarAction id="more" title={t("canvas.imageTools.configure")} label={t("canvas.imageTools.more")} icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
</div>
{hasImage ? (
<ImageToolSettingsModal
@@ -213,6 +216,7 @@ export function CanvasNodeHoverToolbar({
export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeData | null; open: boolean; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const [view, setView] = useState<"info" | "json">("info");
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
@@ -236,13 +240,13 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
const title = (
<div className="flex items-center justify-between gap-4 pr-12">
<span></span>
<span>{t("canvas.nodeToolbar.nodeInfo")}</span>
<Segmented
size="small"
value={view}
onChange={(value) => setView(value as "info" | "json")}
options={[
{ label: "信息", value: "info" },
{ label: t("canvas.nodeToolbar.info"), value: "info" },
{ label: "JSON", value: "json" },
]}
/>
@@ -256,14 +260,14 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} />
<InfoRow label="名称" value={node.title || "未命名节点"} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : node.type === CanvasNodeType.Group ? "组" : "生成配置"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
{batchCount > 1 ? <InfoRow label="图片组" value={`${batchCount}`} /> : null}
{node.metadata?.prompt ? <InfoRow label="提示词" value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label="图片大小" value={formatBytes(imageBytes)} /> : null}
<InfoRow label={t("canvas.nodeToolbar.name")} value={node.title || t("canvas.node.untitled")} />
<InfoRow label={t("canvas.nodeToolbar.type")} value={node.type === CanvasNodeType.Group ? t("canvas.node.group") : node.type === CanvasNodeType.Config ? t("canvas.configNode.title") : [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(node.type as CanvasNodeType) ? t(`assets.kinds.${node.type}`) : getNodeDefinition(node.type)?.title || node.type} />
<InfoRow label={t("canvas.nodeToolbar.size")} value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label={t("canvas.nodeToolbar.position")} value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label={t("canvas.nodeToolbar.status")} value={node.metadata?.status || "idle"} />
{batchCount > 1 ? <InfoRow label={t("canvas.nodeToolbar.imageGroup")} value={t("canvas.configNode.images", { count: batchCount })} /> : null}
{node.metadata?.prompt ? <InfoRow label={t("canvas.configNode.prompt")} value={node.metadata.prompt} /> : null}
{imageBytes ? <InfoRow label={t("canvas.nodeToolbar.imageSize")} value={formatBytes(imageBytes)} /> : null}
{node.metadata?.errorDetails ? (
<div className="rounded-lg border p-3 text-red-400" style={{ borderColor: theme.node.stroke }}>
{node.metadata.errorDetails}
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPoi
import { createPortal } from "react-dom";
import { Button, Input, Modal, Slider, Tooltip } from "antd";
import { Brush, Eraser, Redo2, RotateCcw, Undo2, WandSparkles, X, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslation } from "react-i18next";
import { readImageMeta } from "@/lib/image-utils";
import { useImageEditorViewport } from "@/components/canvas/use-image-editor-viewport";
@@ -20,6 +21,7 @@ const defaultBrushSize = 100;
const maskFillColor = "rgba(37, 99, 235, .38)";
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
const { t } = useTranslation();
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const drawingRef = useRef<{ active: boolean; stroke: MaskStroke | null }>({ active: false, stroke: null });
@@ -201,9 +203,9 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
const submit = () => {
const nextPrompt = prompt.trim();
const canvas = maskCanvasRef.current;
if (!nextPrompt) return setError("请输入修改要求");
if (!nextPrompt) return setError(t("canvas.editors.maskPromptRequired"));
if (!canvas) return;
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
if (!canvasHasPaint(canvas)) return setError(t("canvas.editors.maskRequired"));
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) });
};
@@ -257,55 +259,55 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
<div className="flex min-h-[360px] flex-col gap-5">
<div>
<h2 className="text-xl font-semibold"></h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
<div className="mt-2 text-xs leading-5 opacity-55"> · + · Alt+/ · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </div>
<h2 className="text-xl font-semibold">{t("canvas.editors.maskTitle")}</h2>
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : t("canvas.editors.loading")}</div>
<div className="mt-2 text-xs leading-5 opacity-55">{t("canvas.editors.maskHint")}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
{t("canvas.editors.brush")}
</Button>
<Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
{t("canvas.editors.erase")}
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border border-black/10 px-2 py-1 dark:border-white/10">
<Tooltip title="撤回局部涂抹 (Ctrl/Cmd+Z)">
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回局部涂抹" onClick={undoMask} />
<Tooltip title={t("canvas.editors.undoMaskTitle")}>
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label={t("canvas.editors.undoMask")} onClick={undoMask} />
</Tooltip>
<Tooltip title="重做局部涂抹 (Ctrl/Cmd+Shift+Z)">
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做局部涂抹" onClick={redoMask} />
<Tooltip title={t("canvas.editors.redoMaskTitle")}>
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label={t("canvas.editors.redoMask")} onClick={redoMask} />
</Tooltip>
<div className="flex items-center gap-1">
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
<Tooltip title={t("canvas.editors.zoomOut")}>
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
<Tooltip title={t("canvas.editors.zoomIn")}>
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
</Tooltip>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium opacity-75"></span>
<span className="font-medium opacity-75">{t("canvas.editors.brushSize")}</span>
<span className="font-semibold">{brushSize}px</span>
</div>
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium opacity-75"></div>
<div className="text-sm font-medium opacity-75">{t("canvas.editors.editInstructions")}</div>
<Input.TextArea
rows={6}
value={prompt}
status={error && !prompt.trim() ? "error" : undefined}
placeholder="例如:把选中区域改成金属材质,保持原图光影"
placeholder={t("canvas.editors.maskPlaceholder")}
onChange={(event) => {
setPrompt(event.target.value);
setError("");
@@ -316,14 +318,14 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
<div className="mt-auto flex items-center justify-between gap-2">
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
{t("canvas.editors.reset")}
</Button>
<div className="flex items-center gap-2">
<Button icon={<X className="size-4" />} onClick={onClose}>
{t("canvas.editors.cancel")}
</Button>
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
AI
{t("canvas.editors.aiEdit")}
</Button>
</div>
</div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { ArrowUp, LoaderCircle, Square } from "lucide-react";
import { Button } from "antd";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, resolveModelForCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -30,6 +31,7 @@ type CanvasNodePromptPanelProps = {
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
const { t } = useTranslation();
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -73,7 +75,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
onSubmit={submit}
className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 text-sm leading-5 outline-none"
style={{ background: "transparent", color: theme.node.text }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
placeholder={t(`canvas.promptPanel.${mode === "image" && hasImageContent ? "editImage" : mode === "text" && hasTextContent ? "editText" : mode}`)}
/>
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
@@ -114,14 +116,14 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
danger={isRunning}
disabled={!isRunning && !prompt.trim()}
onClick={() => (isRunning ? onStop(node.id) : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
aria-label={t(isRunning ? "canvas.promptPanel.stopGeneration" : "canvas.promptPanel.generate")}
>
<span className="flex items-center gap-1.5">
{isRunning ? (
<>
<LoaderCircle className="size-4 animate-spin" />
<Square className="size-3.5 fill-current" />
<span className="text-xs font-medium"></span>
<span className="text-xs font-medium">{t("canvas.promptPanel.stop")}</span>
</>
) : (
<ArrowUp className="size-4" />
@@ -157,13 +159,6 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
};
}
function promptPlaceholder(mode: CanvasNodeGenerationMode, hasImageContent: boolean, hasTextContent: boolean) {
if (mode === "video") return "描述要生成的视频内容";
if (mode === "audio") return "描述要生成的音频内容";
if (mode === "image") return hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容";
return hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容";
}
function videoConfigPatch(key: keyof AiConfig, value: string) {
if (key === "videoSeconds") return { seconds: value };
if (key === "videoGenerateAudio") return { generateAudio: value };
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal, Tooltip } from "antd";
import { Grid2x2, ListRestart, PanelTop, Redo2, Rows3, Trash2, Undo2, ZoomIn, ZoomOut } from "lucide-react";
import { useTranslation } from "react-i18next";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
@@ -13,6 +14,7 @@ const maxGridSize = 12;
type ActiveLine = { axis: "horizontal" | "vertical"; index: number } | null;
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
const { t } = useTranslation();
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const [active, setActive] = useState<ActiveLine>(null);
@@ -149,9 +151,9 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden transitionName="" maskTransitionName="">
<div className="space-y-5" data-canvas-no-zoom>
<div>
<h2 className="text-xl font-semibold"></h2>
<p className="mt-1 text-sm opacity-60"> {total} </p>
<p className="mt-2 text-xs leading-5 opacity-55"> · + · Delete 线 · Ctrl/Cmd+Z · Ctrl/Cmd+Shift+Z </p>
<h2 className="text-xl font-semibold">{t("canvas.editors.splitTitle")}</h2>
<p className="mt-1 text-sm opacity-60">{t("canvas.editors.splitDescription", { count: total })}</p>
<p className="mt-2 text-xs leading-5 opacity-55">{t("canvas.editors.splitHint")}</p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
@@ -171,54 +173,54 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<Tooltip title="撤回切图调整 (Ctrl/Cmd+Z)">
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label="撤回切图调整" onClick={undoSplit} />
<Tooltip title={t("canvas.editors.undoSplitTitle")}>
<Button type="text" icon={<Undo2 className="size-4" />} disabled={!historySize} aria-label={t("canvas.editors.undoSplit")} onClick={undoSplit} />
</Tooltip>
<Tooltip title="重做切图调整 (Ctrl/Cmd+Shift+Z)">
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label="重做切图调整" onClick={redoSplit} />
<Tooltip title={t("canvas.editors.redoSplitTitle")}>
<Button type="text" icon={<Redo2 className="size-4" />} disabled={!redoSize} aria-label={t("canvas.editors.redoSplit")} onClick={redoSplit} />
</Tooltip>
<Tooltip title="缩小">
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label="缩小" onClick={viewport.zoomOut} />
<Tooltip title={t("canvas.editors.zoomOut")}>
<Button type="text" icon={<ZoomOut className="size-4" />} disabled={!viewport.canZoomOut} aria-label={t("canvas.editors.zoomOut")} onClick={viewport.zoomOut} />
</Tooltip>
<button type="button" className="min-w-14 text-center text-xs font-semibold tabular-nums opacity-70" onClick={viewport.resetZoom}>
{Math.round(viewport.zoom * 100)}%
</button>
<Tooltip title="放大">
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label="放大" onClick={viewport.zoomIn} />
<Tooltip title={t("canvas.editors.zoomIn")}>
<Button type="text" icon={<ZoomIn className="size-4" />} disabled={!viewport.canZoomIn} aria-label={t("canvas.editors.zoomIn")} onClick={viewport.zoomIn} />
</Tooltip>
</div>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : t("canvas.editors.loading")}</span>
</div>
</div>
<div className="space-y-5 py-2">
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} />
<NumberField label={t("canvas.editors.rows")} value={rows} onChange={(value) => update("rows", value)} />
<NumberField label={t("canvas.editors.columns")} value={columns} onChange={(value) => update("columns", value)} />
<div className="grid grid-cols-2 gap-2">
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>
线
{t("canvas.editors.horizontalLine")}
</Button>
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>
线
{t("canvas.editors.verticalLine")}
</Button>
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>
线
{t("canvas.editors.deleteLine")}
</Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>
线
{t("canvas.editors.resetLines")}
</Button>
</div>
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{total} </span>
<span className="opacity-60">{t("canvas.editors.pieceCount")}</span>
<span className="font-semibold">{t("canvas.editors.pieces", { count: total })}</span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
<span className="opacity-60">{t("canvas.editors.averageSize")}</span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : t("canvas.editors.unknown")}</span>
</div>
</div>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(confirmParams)}>
{t("canvas.editors.generateChildren")}
</Button>
</div>
</div>
@@ -1,17 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Modal, Segmented } from "antd";
import { ImagePlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { readImageMeta } from "@/lib/image-utils";
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/canvas-image-data";
export type CanvasImageUpscaleParams = ImageUpscaleParams;
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [
{ value: "high", title: "高清插值", description: "适合照片和细节图" },
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
];
const algorithms: ImageUpscaleAlgorithm[] = ["high", "bilinear", "nearest"];
const targetOptions = [
{ label: "1K", value: 1024 },
@@ -25,6 +22,7 @@ const defaultParams: CanvasImageUpscaleParams = {
};
export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) {
const { t } = useTranslation();
const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const sourceLongEdge = image ? Math.max(image.width, image.height) : 0;
@@ -53,7 +51,7 @@ export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: {
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold"></h2>
<h2 className="text-xl font-semibold">{t("canvas.editors.upscaleTitle")}</h2>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
<div className="rounded-xl border p-4">
@@ -61,32 +59,32 @@ export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: {
<img src={dataUrl} alt="" className="max-h-[320px] max-w-full rounded-lg object-contain shadow-xl" draggable={false} />
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60"></span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
<span className="opacity-60">{t("canvas.editors.source")}</span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : t("canvas.editors.loading")}</span>
</div>
</div>
<div className="space-y-6 py-2">
<div className="space-y-2">
<div className="font-medium opacity-75"></div>
<div className="font-medium opacity-75">{t("canvas.editors.targetPixels")}</div>
<Segmented
block
value={params.targetLongEdge}
options={targetOptions.map((option) => ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))}
onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))}
/>
{image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? "图片已达到 4K,无需放大" : "图片已达到当前目标像素,无需放大"}</div> : null}
{image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? t("canvas.editors.maxReached") : t("canvas.editors.targetReached")}</div> : null}
</div>
<div className="space-y-2">
<div className="font-medium opacity-75"></div>
<div className="font-medium opacity-75">{t("canvas.editors.algorithm")}</div>
<Segmented
block
value={params.algorithm}
options={algorithms.map((item) => ({
value: item.value,
options={algorithms.map((algorithm) => ({
value: algorithm,
label: (
<span className="flex min-h-12 flex-col justify-center text-left leading-5">
<span className="font-medium">{item.title}</span>
<span className="text-xs opacity-55">{item.description}</span>
<span className="font-medium">{t(`canvas.editors.${algorithm}`)}</span>
<span className="text-xs opacity-55">{t(`canvas.editors.${algorithm}Description`)}</span>
</span>
),
}))}
@@ -95,15 +93,15 @@ export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: {
</div>
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"}</span>
<span className="opacity-60">{t("canvas.editors.outputSize")}</span>
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : t("canvas.editors.unknown")}</span>
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}>
{t("canvas.editors.upscale")}
</Button>
</div>
</div>
+32 -21
View File
@@ -11,6 +11,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { useTranslation } from "react-i18next";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const selectionBlue = "#2f80ff";
@@ -120,6 +121,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onContextMenu,
}: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const definition = getNodeDefinition(data.type);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]);
@@ -167,11 +169,11 @@ export const CanvasNode = React.memo(function CanvasNode({
}, [isEditingTitle]);
const finishTitleEditing = useCallback(() => {
const title = titleDraft.trim() || data.title || "未命名节点";
const title = titleDraft.trim() || data.title || t("canvas.node.untitled");
setTitleDraft(title);
setIsEditingTitle(false);
if (title !== data.title) onTitleChange(data.id, title);
}, [data.id, data.title, onTitleChange, titleDraft]);
}, [data.id, data.title, onTitleChange, t, titleDraft]);
useEffect(() => {
if (!isEditingTitle) return;
@@ -342,13 +344,13 @@ export const CanvasNode = React.memo(function CanvasNode({
type="button"
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
style={{ color: theme.node.text }}
title="双击修改节点名称"
title={t("canvas.node.renameHint")}
onDoubleClick={(event) => {
event.stopPropagation();
setIsEditingTitle(true);
}}
>
{data.title || "未命名节点"}
{data.title || t("canvas.node.untitled")}
</button>
)}
</div>
@@ -466,15 +468,16 @@ const nodeContentRenderers = {
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) {
const { t } = useTranslation();
return (
<div className="pointer-events-none flex h-full w-full flex-col p-4">
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: theme.node.text }}>
<span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
<Group className="size-4" />
</span>
<span></span>
<span>{t("canvas.node.group")}</span>
<span className="ml-auto rounded-full px-2 py-1 text-[11px] font-medium" style={{ background: theme.node.fill, color: theme.node.muted }}>
{groupChildCount}
{t("canvas.node.nodeCount", { count: groupChildCount })}
</span>
</div>
<div className="mt-3 flex-1 rounded-2xl border border-dashed" style={{ borderColor: theme.node.stroke, background: `${theme.node.fill}55` }} />
@@ -483,18 +486,20 @@ function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererP
}
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
const { t } = useTranslation();
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
<div className="size-10 animate-spin rounded-full border-2" style={{ borderColor: theme.node.stroke, borderTopColor: theme.node.activeStroke }} />
<span className="text-[10px] tracking-[0.2em]"></span>
<span className="text-[10px] tracking-[0.2em]">{t("canvas.node.generating")}</span>
</div>
);
}
function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "node" | "theme" | "onRetry">) {
const { t } = useTranslation();
return (
<div className="flex max-w-[260px] flex-col items-center gap-3 px-5 text-center">
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || "生成失败"}</div>
<div className="text-xs leading-5 text-red-300">{node.metadata?.errorDetails || t("canvas.node.failed")}</div>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition hover:scale-[1.02]"
@@ -506,23 +511,25 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
onMouseDown={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" />
{t("canvas.node.retry")}
</button>
</div>
);
}
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
const { t } = useTranslation();
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
<Puzzle className="size-7 opacity-40" />
<span className="text-sm"></span>
<span className="text-[11px] opacity-70"> {type} </span>
<span className="text-sm">{t("canvas.node.missingPlugin")}</span>
<span className="text-[11px] opacity-70">{t("canvas.node.missingPluginDescription", { type })}</span>
</div>
);
}
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
const { t } = useTranslation();
const fontSize = node.metadata?.fontSize || 14;
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties;
@@ -538,11 +545,11 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
}}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
title="用文本生图"
aria-label="用文本生图"
title={t("canvas.node.generateImage")}
aria-label={t("canvas.node.generateImage")}
>
<ImageIcon className="size-3.5" />
{t("canvas.node.generate")}
</button>
{isEditingContent ? (
<CanvasResourceMentionTextarea
@@ -567,7 +574,7 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
style={textStyle}
onWheel={(event) => event.stopPropagation()}
>
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}></span>}
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>{t("canvas.node.editText")}</span>}
</div>
)}
</div>
@@ -607,12 +614,13 @@ function ImageNodeContent(props: NodeContentRendererProps) {
}
function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batchOpening, batchRecovering, onToggleBatch }: NodeContentRendererProps) {
const { t } = useTranslation();
const content = (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<div className="flex size-14 items-center justify-center rounded-2xl" style={{ background: theme.toolbar.activeBg }}>
<ImageIcon className="size-6 opacity-30" />
</div>
<span className="text-[10px] tracking-[0.18em] opacity-50"></span>
<span className="text-[10px] tracking-[0.18em] opacity-50">{t("canvas.node.emptyImage")}</span>
</div>
);
if (isBatchRoot)
@@ -625,29 +633,31 @@ function EmptyImageContent({ theme, isBatchRoot, batchCount, batchExpanded, batc
}
function VideoNodeContent({ node, theme }: NodeContentRendererProps) {
const { t } = useTranslation();
if (!node.metadata?.content)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.placeholder }}>
<Video className="size-7 opacity-35" />
<span className="text-sm"></span>
<span className="text-sm">{t("canvas.node.emptyVideo")}</span>
</div>
);
return <video src={node.metadata.content} controls className="h-full w-full rounded-[18px] bg-black object-contain" data-canvas-no-zoom />;
}
function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
const { t } = useTranslation();
if (!node.metadata?.content)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2" style={{ color: theme.node.placeholder }}>
<Music2 className="size-7 opacity-35" />
<span className="text-sm"></span>
<span className="text-sm">{t("canvas.node.emptyAudio")}</span>
</div>
);
return (
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
<Music2 className="size-4 shrink-0" />
<span className="truncate"></span>
<span className="truncate">{t("canvas.node.audio")}</span>
</div>
<audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom />
</div>
@@ -674,6 +684,7 @@ function ImageContent({
onSetBatchPrimary?: () => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { t } = useTranslation();
const isBatchChild = Boolean(node.metadata?.batchRootId);
return (
@@ -692,7 +703,7 @@ function ImageContent({
type="button"
className="absolute right-2.5 top-2.5 z-30 flex h-8 items-center justify-center gap-1 rounded-full border px-2.5 text-xs font-semibold shadow-[0_6px_18px_rgba(15,23,42,.10)] backdrop-blur-md transition hover:scale-[1.02]"
style={{ background: `${theme.toolbar.panel}d9`, borderColor: `${theme.toolbar.border}cc`, color: theme.node.text }}
aria-label={batchExpanded ? "图片组已展开" : "图片组已收起"}
aria-label={batchExpanded ? t("canvas.node.batchExpanded") : t("canvas.node.batchCollapsed")}
onClick={(event) => {
event.stopPropagation();
onToggleBatch?.();
@@ -717,7 +728,7 @@ function ImageContent({
onPointerDown={(event) => event.stopPropagation()}
>
<Star className="size-3.5 text-[#2f80ff]" />
{t("canvas.node.setPrimary")}
</button>
) : null}
</BatchFrame>
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Button, Input, Modal, Popconfirm, Switch, Tabs } from "antd";
import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme";
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
@@ -9,6 +10,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const { message } = App.useApp();
const plugins = usePluginStore((state) => state.plugins);
@@ -47,10 +49,10 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
setInstalling(true);
try {
const plugin = await installPluginFromUrl(target);
message.success(`已安装插件 ${plugin.name}`);
message.success(t("canvas.plugins.installedPlugin", { name: plugin.name }));
setUrl("");
} catch (error) {
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
message.error(t("canvas.plugins.installFailed", { error: error instanceof Error ? error.message : String(error) }));
} finally {
setInstalling(false);
}
@@ -60,9 +62,9 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
setBusyId(entry.id);
try {
const plugin = await installPluginFromUrl(entry.url, { official: true });
message.success(`已安装 ${plugin.name}`);
message.success(t("canvas.plugins.installed", { name: plugin.name }));
} catch (error) {
message.error(`安装失败:${error instanceof Error ? error.message : String(error)}`);
message.error(t("canvas.plugins.installFailed", { error: error instanceof Error ? error.message : String(error) }));
} finally {
setBusyId(null);
}
@@ -84,7 +86,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
// upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
const installedControls = (record: InstalledPlugin, upgradable = false) => (
<>
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} />
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), t(checked ? "canvas.plugins.enabled" : "canvas.plugins.disabled"))} />
{!record.local && (
<>
<Button
@@ -92,11 +94,11 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
size="small"
icon={<RefreshCw className="size-4" />}
loading={busyId === record.id}
title={upgradable ? "有新版本,点击升级" : "从来源更新"}
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")}
title={t(upgradable ? "canvas.plugins.upgradeAvailable" : "canvas.plugins.updateFromSource")}
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), t("canvas.plugins.updated"))}
/>
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
<Popconfirm title={t("canvas.plugins.uninstallTitle")} okText={t("canvas.plugins.uninstall")} cancelText={t("canvas.editors.cancel")} onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title={t("canvas.plugins.uninstall")} />
</Popconfirm>
</>
)}
@@ -108,7 +110,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
const withUpgradeDot = (icon: ReactNode) => (
<span className="relative inline-flex">
{icon}
<span className="absolute -right-1 -top-1 size-2 rounded-full" style={{ background: "#22c55e", boxShadow: `0 0 0 2px ${theme.node.fill}` }} title="有新版本可升级" />
<span className="absolute -right-1 -top-1 size-2 rounded-full" style={{ background: "#22c55e", boxShadow: `0 0 0 2px ${theme.node.fill}` }} title={t("canvas.plugins.newVersion")} />
</span>
);
@@ -149,20 +151,20 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-xs" style={{ color: theme.node.muted }}>
,
{t("canvas.plugins.officialDescription")}
</div>
<Button type="text" size="small" icon={<RefreshCw className={`size-4 ${loadingOfficial ? "animate-spin" : ""}`} />} onClick={loadOfficial} disabled={loadingOfficial}>
{t("canvas.plugins.refresh")}
</Button>
</div>
{officialError ? (
<div className="rounded-lg border px-3 py-2 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
{officialError}
{t("canvas.plugins.loadFailed", { error: officialError })}
</div>
) : loadingOfficial && official.length === 0 ? (
emptyHint("正在获取官方插件…")
emptyHint(t("canvas.plugins.loadingOfficial"))
) : official.length === 0 ? (
emptyHint("暂无官方插件")
emptyHint(t("canvas.plugins.noOfficial"))
) : (
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{official.map((entry) => {
@@ -181,7 +183,7 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
installedControls(record, upgradable)
) : (
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
{t("canvas.plugins.install")}
</Button>
),
);
@@ -196,27 +198,27 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
const thirdPartyTab = (
<div className="space-y-3">
<div className="flex gap-2">
<Input placeholder="输入插件 JS 文件 URL,例如 https://.../plugin.js" value={url} onChange={(event) => setUrl(event.target.value)} onPressEnter={handleInstallUrl} allowClear />
<Input placeholder={t("canvas.plugins.urlPlaceholder")} value={url} onChange={(event) => setUrl(event.target.value)} onPressEnter={handleInstallUrl} allowClear />
<Button type="primary" loading={installing} onClick={handleInstallUrl} icon={<Puzzle className="size-4" />}>
{t("canvas.plugins.install")}
</Button>
</div>
<div className="thin-scrollbar max-h-[42vh] space-y-2 overflow-auto">{thirdPartyPlugins.length === 0 ? emptyHint("还没有安装第三方插件") : thirdPartyPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>
<div className="thin-scrollbar max-h-[42vh] space-y-2 overflow-auto">{thirdPartyPlugins.length === 0 ? emptyHint(t("canvas.plugins.noThirdParty")) : thirdPartyPlugins.map((record) => row(record.id, <Puzzle className="size-4" />, record.name, record.version, record.description || record.url, installedControls(record)))}</div>
</div>
);
const tabs = [
{ key: "official", label: "官方插件", children: officialTab },
...(localPlugins.length > 0 ? [{ key: "local", label: "本地插件", children: localTab }] : []),
{ key: "third", label: "第三方插件", children: thirdPartyTab },
{ key: "official", label: t("canvas.plugins.official"), children: officialTab },
...(localPlugins.length > 0 ? [{ key: "local", label: t("canvas.plugins.local"), children: localTab }] : []),
{ key: "third", label: t("canvas.plugins.thirdParty"), children: thirdPartyTab },
];
return (
<Modal title="节点插件" open={open} onCancel={onClose} footer={null} centered width={640}>
<Modal title={t("canvas.plugins.title")} open={open} onCancel={onClose} footer={null} centered width={640}>
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-lg border px-3 py-2 text-xs leading-5" style={{ borderColor: "#f59e0b55", background: "#f59e0b14", color: theme.node.text }}>
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
<span>访 AI API Key</span>
<span>{t("canvas.plugins.warning")}</span>
</div>
<Tabs defaultActiveKey="official" items={tabs} />
</div>
@@ -1,12 +1,14 @@
import { Check, Download, Pencil, Trash2, X } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button, Input } from "antd";
import { useTranslation } from "react-i18next";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const { i18n, t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const renameProject = useCanvasStore((state) => state.renameProject);
@@ -35,7 +37,7 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
onClick={(event) => event.stopPropagation()}
onChange={(event) => toggleSelected(project.id, event.target.checked)}
className="mt-1 size-4 accent-stone-950 dark:accent-stone-100"
aria-label={`选择 ${project.title}`}
aria-label={t("canvas.project.select", { name: project.title })}
/>
{editing ? (
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
@@ -50,24 +52,24 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
>
<h2 className="truncate text-xl font-semibold">{project.title}</h2>
<p className="mt-3 text-sm leading-6 text-stone-600 dark:text-stone-400">
{project.nodes.length} · {project.connections.length} 线
{t("canvas.project.stats", { nodes: project.nodes.length, connections: project.connections.length })}
</p>
</button>
)}
</div>
<div className="mt-8 flex items-end justify-between gap-3">
<p className="text-xs text-stone-500"> {new Date(project.updatedAt).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}</p>
<p className="text-xs text-stone-500">{t("canvas.project.updated", { date: new Date(project.updatedAt).toLocaleString(i18n.resolvedLanguage, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) })}</p>
<div className="flex items-center gap-1" onClick={(event) => event.stopPropagation()}>
{editing ? (
<>
<Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label="保存名称" />
<Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label="取消重命名" />
<Button type="text" size="small" shape="circle" icon={<Check className="size-4" />} onClick={saveTitle} aria-label={t("canvas.project.saveName")} />
<Button type="text" size="small" shape="circle" icon={<X className="size-4" />} onClick={stopEditing} aria-label={t("canvas.project.cancelRename")} />
</>
) : (
<>
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects([project], project.title || "无限画布")} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" />
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects([project], project.title || t("canvas.title"))} aria-label={t("canvas.project.export")} />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label={t("canvas.project.rename")} />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label={t("canvas.project.delete")} />
</>
)}
</div>
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
import i18n from "@/i18n";
import { canvasThemes } from "@/lib/canvas-theme";
import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -192,7 +193,7 @@ export function CanvasPromptChipInput({ value, references, onChange, onSubmit, c
{mention && candidates.length ? (
<MentionMenu rect={mention.rect} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} />
) : null}
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
{imagePreview ? <Image src={imagePreview} alt={i18n.t("canvas.composer.imagePreview")} style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
</div>
);
}
@@ -1,25 +1,27 @@
import { useState } from "react";
import { Button, Tooltip } from "antd";
import { BookOpen } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
export function CanvasPromptLibrary({ onSelect }: { onSelect: (prompt: string) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
return (
<>
<Tooltip title="提示词库">
<Tooltip title={t("navigation.prompts")}>
<Button
type="text"
className="!h-8 !w-8 !min-w-8 shrink-0 !rounded-full !bg-transparent !p-0"
style={{ color: theme.node.text }}
icon={<BookOpen className="size-3.5" />}
onClick={() => setOpen(true)}
aria-label="提示词库"
aria-label={t("navigation.prompts")}
/>
</Tooltip>
<PromptSelectDialog open={open} onOpenChange={setOpen} onSelect={onSelect} />
+56 -56
View File
@@ -3,6 +3,7 @@ import { App, Empty, Input, Popconfirm, Select, Spin, Tag } from "antd";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Check, ChevronRight, Download, Eye, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react";
import { motion } from "motion/react";
import { useTranslation } from "react-i18next";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
@@ -50,6 +51,7 @@ const STATUS_COLOR: Record<string, string> = {
};
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, onInsertAsset }: Props) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [tab, setTab] = useState<PanelTab>("canvas");
const width = useCanvasSidePanelStore((state) => state.width);
@@ -98,9 +100,9 @@ export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreview
data-canvas-no-zoom
>
<div className="flex items-center gap-5 px-4 pt-3.5">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
<TabButton label="提示词库" active={tab === "prompts"} theme={theme} onClick={() => setTab("prompts")} />
<TabButton label={t("canvas.sidePanel.canvas")} active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label={t("canvas.sidePanel.assets")} active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
<TabButton label={t("canvas.sidePanel.prompts")} active={tab === "prompts"} theme={theme} onClick={() => setTab("prompts")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">
{tab === "canvas" ? (
@@ -111,7 +113,7 @@ export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onPreview
<CanvasPromptsTab onInsert={onInsertAsset} theme={theme} />
)}
</div>
<button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整左侧面板宽度" />
<button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label={t("canvas.sidePanel.resize")} />
</motion.aside>
</motion.div>
);
@@ -130,15 +132,7 @@ function TabButton({ label, active, theme, onClick }: { label: string; active: b
// 画布 Tab —— 列出节点,点击居中放大并选中
// ---------------------------------------------------------------------------
const NODE_FILTER_OPTIONS = [
{ label: "全部", value: "all" },
{ label: "图片", value: CanvasNodeType.Image },
{ label: "视频", value: CanvasNodeType.Video },
{ label: "文本", value: CanvasNodeType.Text },
{ label: "音频", value: CanvasNodeType.Audio },
{ label: "配置", value: CanvasNodeType.Config },
{ label: "分组", value: CanvasNodeType.Group },
];
const NODE_FILTER_VALUES = ["all", CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Text, CanvasNodeType.Audio, CanvasNodeType.Config, CanvasNodeType.Group];
function nodePreviewText(node: CanvasNodeData) {
if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || "";
@@ -147,6 +141,7 @@ function nodePreviewText(node: CanvasNodeData) {
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; onPreviewNode: (nodeId: string) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const { t } = useTranslation();
const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectMode, setSelectMode] = useState(false);
@@ -175,14 +170,14 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
const targets = nodes.filter((node) => checked.has(node.id));
if (!targets.length) return;
setExporting(true);
const hide = message.loading("正在导出选中元素…", 0);
const hide = message.loading(t("canvas.sidePanel.exporting"), 0);
try {
await exportCanvasNodes(targets, `画布元素-${targets.length}`);
message.success(`已导出 ${targets.length} 个元素`);
await exportCanvasNodes(targets, t("canvas.sidePanel.exportName", { count: targets.length }));
message.success(t("canvas.sidePanel.exported", { count: targets.length }));
exitSelect();
} catch (error) {
console.error(error);
message.error("导出失败,请重试");
message.error(t("canvas.sidePanel.exportFailed"));
} finally {
hide();
setExporting(false);
@@ -192,7 +187,7 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
<span className="text-xs font-medium opacity-60"></span>
<span className="text-xs font-medium opacity-60">{t("canvas.sidePanel.elements")}</span>
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
<button
type="button"
@@ -201,12 +196,12 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
style={selectMode ? { color: theme.toolbar.activeText, opacity: 1 } : undefined}
>
<ListChecks className="size-3.5" />
{selectMode ? "取消" : "选择"}
{selectMode ? t("common.cancel") : t("canvas.sidePanel.select")}
</button>
{selectMode ? null : <Select size="small" variant="borderless" className="w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />}
{selectMode ? null : <Select size="small" variant="borderless" className="w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_VALUES.map((value) => ({ value, label: value === "all" ? t("common.all") : t(`canvas.sidePanel.filter.${value}`) }))} />}
</div>
<div className="px-3 pb-2.5">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder={t("canvas.sidePanel.searchNodes")} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{filtered.length ? (
@@ -218,20 +213,20 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
return (
<div key={node.id} className={cn("group flex w-full items-center rounded-lg transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")} style={active ? { background: theme.toolbar.activeBg } : undefined}>
<button type="button" onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))} className="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left" title={selectMode ? undefined : "定位到节点"}>
<button type="button" onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))} className="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left" title={selectMode ? undefined : t("canvas.sidePanel.focusNode")}>
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || t("canvas.node.untitled")}</span>
<span className="block truncate text-xs leading-snug opacity-50">{nodePreviewText(node)}</span>
</span>
{node.metadata?.status && node.metadata.status !== "idle" ? <span className="size-1.5 shrink-0 rounded-full" style={{ background: STATUS_COLOR[node.metadata.status] || "transparent" }} /> : null}
</button>
{selectMode || !isImage ? null : (
<div className="flex shrink-0 flex-col items-center gap-0.5 pr-1.5">
<button type="button" onClick={() => onPreviewNode(node.id)} className="grid size-7 place-items-center rounded-md opacity-55 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label="放大预览" title="放大预览">
<button type="button" onClick={() => onPreviewNode(node.id)} className="grid size-7 place-items-center rounded-md opacity-55 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label={t("canvas.sidePanel.preview")} title={t("canvas.sidePanel.preview")}>
<Eye className="size-3.5" />
</button>
</div>
@@ -241,15 +236,15 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
})}
</div>
) : (
<div className="pt-16 text-center text-sm opacity-40"></div>
<div className="pt-16 text-center text-sm opacity-40">{t("canvas.sidePanel.noNodes")}</div>
)}
</div>
{selectMode ? (
<div className="flex items-center gap-2 border-t px-3 py-2.5" style={{ borderColor: theme.toolbar.border }}>
<button type="button" onClick={toggleAll} className="rounded-md px-2 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10">
{allChecked ? "取消全选" : "全选"}
{allChecked ? t("canvas.sidePanel.clearAll") : t("workbench.selectAll")}
</button>
<span className="text-xs opacity-45"> {checked.size}</span>
<span className="text-xs opacity-45">{t("canvas.sidePanel.selected", { count: checked.size })}</span>
<button
type="button"
onClick={() => void handleExport()}
@@ -258,7 +253,7 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, onPreviewNode, th
style={{ color: theme.node.text }}
>
<Download className="size-3.5" />
{t("canvas.exportSelected")}
</button>
</div>
) : null}
@@ -278,10 +273,10 @@ function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme })
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
// ---------------------------------------------------------------------------
const ASSET_GROUPS: { kind: AssetKind; label: string; icon: typeof Square }[] = [
{ kind: "image", label: "图片", icon: ImageIcon },
{ kind: "video", label: "视频", icon: Video },
{ kind: "text", label: "文本", icon: FileText },
const ASSET_GROUPS: { kind: AssetKind; icon: typeof Square }[] = [
{ kind: "image", icon: ImageIcon },
{ kind: "video", icon: Video },
{ kind: "text", icon: FileText },
];
function buildInsertPayload(asset: Asset): InsertAssetPayload {
@@ -292,6 +287,7 @@ function buildInsertPayload(asset: Asset): InsertAssetPayload {
const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const { t } = useTranslation();
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const removeAsset = useAssetStore((state) => state.removeAsset);
@@ -314,25 +310,25 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
const files = Array.from(fileList || []);
if (!files.length) return;
setUploading(true);
const hide = message.loading("正在添加资产…", 0);
const hide = message.loading(t("canvas.sidePanel.addingAssets"), 0);
let added = 0;
try {
for (const file of files) {
if (file.type.startsWith("image/")) {
const image = await uploadImage(file);
addAsset({ kind: "image", title: file.name || "图片", coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } });
addAsset({ kind: "image", title: file.name || t("assets.kinds.image"), coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } });
added += 1;
} else if (file.type.startsWith("video/")) {
const media = await uploadMediaFile(file, "video");
addAsset({ kind: "video", title: file.name || "视频", coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } });
addAsset({ kind: "video", title: file.name || t("assets.kinds.video"), coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } });
added += 1;
}
}
if (added) message.success(`已添加 ${added} 个资产`);
else message.warning("仅支持图片或视频文件");
if (added) message.success(t("canvas.sidePanel.addedAssets", { count: added }));
else message.warning(t("canvas.sidePanel.mediaOnly"));
} catch (error) {
console.error(error);
message.error("添加失败,请重试");
message.error(t("canvas.sidePanel.addFailed"));
} finally {
hide();
setUploading(false);
@@ -343,7 +339,7 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder={t("canvas.sidePanel.searchAssets")} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button
type="button"
disabled={uploading}
@@ -352,14 +348,14 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
style={{ color: theme.node.text }}
>
<Plus className="size-3.5" />
{t("canvas.sidePanel.add")}
</button>
<input ref={fileInputRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={(e) => void handleFiles(e.target.files)} />
</div>
{allTags.length ? (
<div className="flex flex-wrap gap-1.5 px-3 pb-2">
<Tag.CheckableTag checked={tagFilter === "all"} className={cn("prompt-filter-tag", tagFilter === "all" && "is-active")} onChange={() => setTagFilter("all")}>
{t("common.all")}
</Tag.CheckableTag>
{allTags.map((tag) => (
<Tag.CheckableTag key={tag} checked={tagFilter === tag} className={cn("prompt-filter-tag", tagFilter === tag && "is-active")} onChange={() => setTagFilter((prev) => (prev === tag ? "all" : tag))}>
@@ -382,13 +378,13 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
>
<ChevronRight className={cn("size-3.5 transition-transform", !isCollapsed && "rotate-90")} />
<group.icon className="size-3.5" />
<span>{group.label}</span>
<span>{t(`assets.kinds.${group.kind}`)}</span>
<span className="opacity-50">{group.items.length}</span>
</button>
{isCollapsed ? null : (
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
{group.items.map((asset) => (
<AssetCard key={asset.id} asset={asset} theme={theme} onInsert={() => onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success("资产已移除"))} />
<AssetCard key={asset.id} asset={asset} theme={theme} onInsert={() => onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success(t("canvas.sidePanel.assetRemoved")))} />
))}
</div>
)}
@@ -397,7 +393,7 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
})}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无资产" className="pt-16" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("canvas.sidePanel.noAssets")} className="pt-16" />
)}
</div>
</div>
@@ -405,6 +401,7 @@ const CanvasAssetsTab = memo(function CanvasAssetsTab({ onInsert, theme }: { onI
});
function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) {
const { t } = useTranslation();
return (
<div className="group relative aspect-square overflow-hidden rounded-xl border transition duration-200 hover:-translate-y-0.5 hover:shadow-lg" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
<AssetCover asset={asset} />
@@ -413,15 +410,15 @@ function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme:
type="button"
onClick={onInsert}
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-stone-900 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80"
aria-label="插入画布"
aria-label={t("canvas.sidePanel.inserted")}
>
<Plus className="size-4" />
</button>
<Popconfirm title="移除该资产?" okText="移除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onRemove}>
<Popconfirm title={t("canvas.sidePanel.removeAssetTitle")} okText={t("canvas.sidePanel.remove")} cancelText={t("common.cancel")} okButtonProps={{ danger: true }} onConfirm={onRemove}>
<button
type="button"
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-red-500 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80 dark:hover:text-red-400"
aria-label="移除资产"
aria-label={t("canvas.sidePanel.removeAsset")}
>
<Trash2 className="size-4" />
</button>
@@ -446,6 +443,7 @@ function AssetCover({ asset }: { asset: Asset }) {
const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const { t } = useTranslation();
const sources = usePromptSourceStore((state) => state.sources);
const enabledSources = useMemo(() => sources.filter((source) => source.enabled), [sources]);
const [keyword, setKeyword] = useState("");
@@ -455,16 +453,16 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
const copyPrompt = async (prompt: string) => {
try {
await navigator.clipboard.writeText(prompt);
message.success("已复制提示词");
message.success(t("canvas.sidePanel.promptCopied"));
} catch {
message.error("复制失败");
message.error(t("canvas.sidePanel.copyFailed"));
}
};
return (
<div className="flex h-full flex-col">
<div className="px-3 pb-2.5 pt-1">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索提示词" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder={t("canvas.sidePanel.searchPrompts")} value={keyword} onChange={(e) => setKeyword(e.target.value)} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
<div className="space-y-1">
@@ -480,7 +478,7 @@ const CanvasPromptsTab = memo(function CanvasPromptsTab({ onInsert, theme }: { o
onInsert={onInsert}
onView={setDetail}
/>
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" className="pt-12" />}
)) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("canvas.sidePanel.noPrompts")} className="pt-12" />}
</div>
</div>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => void copyPrompt(prompt)} />
@@ -507,6 +505,7 @@ function PromptSourceGroup({
onInsert: (payload: InsertAssetPayload) => void;
onView: (prompt: Prompt) => void;
}) {
const { t } = useTranslation();
// 展开过一次即缓存,避免收起后重复请求;搜索命中时也需要拿到数据来计数。
const showResults = open || !!keyword.trim();
const query = useQuery({ queryKey: ["side-panel-prompts", sourceId], queryFn: () => fetchSourcePrompts(sourceId), enabled: showResults, staleTime: 1000 * 60 * 60 });
@@ -536,7 +535,7 @@ function PromptSourceGroup({
</div>
) : query.isError ? (
<button type="button" onClick={() => void query.refetch()} className="block w-full py-4 text-center text-xs text-red-500 opacity-80 transition hover:opacity-100">
,
{t("canvas.sidePanel.loadFailedRetry")}
</button>
) : filtered.length ? (
<div className="space-y-1.5">
@@ -545,7 +544,7 @@ function PromptSourceGroup({
))}
</div>
) : (
<div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? "无匹配提示词" : "该来源暂无提示词"}</div>
<div className="py-4 text-center text-xs opacity-40">{keyword.trim() ? t("canvas.sidePanel.noMatchingPrompts") : t("canvas.sidePanel.sourceEmpty")}</div>
)}
</div>
) : null}
@@ -554,6 +553,7 @@ function PromptSourceGroup({
}
function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: CanvasTheme; onInsert: () => void; onView: () => void }) {
const { t } = useTranslation();
return (
<div className="group relative flex items-center gap-2.5 rounded-lg px-2 py-2 transition hover:bg-black/5 dark:hover:bg-white/5">
{item.coverUrl ? (
@@ -568,7 +568,7 @@ function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: Can
<div className="mt-0.5 truncate text-xs leading-snug opacity-50">{item.prompt}</div>
</button>
<div className="flex shrink-0 flex-col items-center gap-0.5">
<button type="button" onClick={onView} className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label="查看详情" title="查看详情">
<button type="button" onClick={onView} className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" aria-label={t("canvas.sidePanel.viewDetails")} title={t("canvas.sidePanel.viewDetails")}>
<Eye className="size-3.5" />
</button>
<button
@@ -576,8 +576,8 @@ function PromptRow({ item, theme, onInsert, onView }: { item: Prompt; theme: Can
onClick={onInsert}
className="grid size-6 place-items-center rounded-md opacity-60 transition hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
style={{ color: theme.toolbar.activeText }}
aria-label="插入画布"
title="插入画布"
aria-label={t("canvas.sidePanel.inserted")}
title={t("canvas.sidePanel.inserted")}
>
<Plus className="size-3.5" />
</button>
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Select } from "antd";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
@@ -12,6 +13,7 @@ type CanvasSizePickerProps = {
};
export function CanvasSizePicker({ value, className, onChange }: CanvasSizePickerProps) {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -42,7 +44,7 @@ export function CanvasSizePicker({ value, className, onChange }: CanvasSizePicke
className={cn("canvas-compact-control canvas-control-select h-full w-full")}
value={value || undefined}
searchValue={search}
placeholder="比例"
placeholder={t("canvas.controls.ratio")}
options={options}
popupMatchSelectWidth={false}
popupRender={(menu) => (
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button } from "antd";
import { useTranslation } from "react-i18next";
import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -16,6 +17,7 @@ type CanvasTextSettingsPopoverProps = {
};
export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
const { t } = useTranslation();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
@@ -47,7 +49,7 @@ export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassN
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate"> · {reasoningEffortLabel(config.reasoningEffort)}</span>
<span className="truncate">{t("canvas.controls.reasoning")} · {reasoningEffortLabel(config.reasoningEffort)}</span>
</Button>
</span>
{panel}
+46 -42
View File
@@ -7,6 +7,7 @@ import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type Ca
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { useTranslation } from "react-i18next";
export function CanvasToolbar({
selectedCount,
@@ -52,6 +53,7 @@ export function CanvasToolbar({
onShowImageInfoChange: (show: boolean) => void;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
@@ -68,7 +70,7 @@ export function CanvasToolbar({
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered) : "";
const tip = hovered ? toolLabel(hovered, t) : "";
// 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板
useEffect(() => {
@@ -87,38 +89,38 @@ export function CanvasToolbar({
<div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
<ToolbarButton id="tool-hand" label={t("canvas.toolbar.move")} active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
<Hand className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-undo" label="撤销" disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
<ToolbarButton id="tool-undo" label={t("canvas.undo")} disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
<Undo2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-redo" label="重做" disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}>
<ToolbarButton id="tool-redo" label={t("canvas.redo")} disabled={!canRedo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onRedo}>
<Redo2 className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton id="tool-text" label="文本" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}>
<ToolbarButton id="tool-text" label={t("canvas.toolbar.text")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddText}>
<Type className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-image" label="图片" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
<ToolbarButton id="tool-image" label={t("canvas.toolbar.image")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddImage}>
<ImageIcon className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-video" label="视频" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddVideo}>
<ToolbarButton id="tool-video" label={t("canvas.toolbar.video")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddVideo}>
<Video className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-audio" label="音频" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddAudio}>
<ToolbarButton id="tool-audio" label={t("canvas.toolbar.audio")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddAudio}>
<Music2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
<ToolbarButton id="tool-config" label={t("canvas.toolbar.config")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
<Settings2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
<ToolbarButton id="tool-group" label={t("canvas.toolbar.group")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
<Group className="size-4.5" />
</ToolbarButton>
{extensionDefs.length ? (
<ToolbarButton
id="tool-extensions"
label="扩展节点"
label={t("canvas.toolbar.extensions")}
active={extensionsOpen}
hovered={hovered}
activeStyle={activeStyle}
@@ -135,13 +137,13 @@ export function CanvasToolbar({
<Puzzle className="size-4.5" />
</ToolbarButton>
) : null}
<ToolbarButton id="tool-upload" label="上传资产" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<ToolbarButton id="tool-upload" label={t("canvas.toolbar.upload")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" />
</ToolbarButton>
<Divider theme={theme} />
<ToolbarButton
id="tool-style"
label="画布外观"
label={t("canvas.toolbar.appearance")}
active={appearanceOpen}
hovered={hovered}
activeStyle={activeStyle}
@@ -160,13 +162,13 @@ export function CanvasToolbar({
{selectedCount ? (
<>
<Divider theme={theme} />
<ToolbarButton id="tool-delete" label="删除选中" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger>
<ToolbarButton id="tool-delete" label={t("canvas.deleteSelected")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDelete} danger>
<Trash2 className="size-4.5" />
</ToolbarButton>
</>
) : null}
<Divider theme={theme} />
<ToolbarButton id="tool-clear" label="清空画布" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
<ToolbarButton id="tool-clear" label={t("canvas.toolbar.clear")} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onClear} danger>
<Eraser className="size-4.5" />
</ToolbarButton>
</div>
@@ -176,7 +178,7 @@ export function CanvasToolbar({
className="thin-scrollbar pointer-events-auto absolute bottom-[72px] z-30 max-h-[50vh] w-[240px] -translate-x-1/2 overflow-y-auto rounded-xl border p-2 shadow-xl backdrop-blur"
style={{ left: extPanelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
>
<div className="px-1.5 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="px-1.5 pb-1.5 text-[11px] font-medium opacity-50">{t("canvas.toolbar.extensions")}</div>
<div className="grid gap-0.5">
{extensionDefs.map((def) => (
<button
@@ -206,19 +208,19 @@ export function CanvasToolbar({
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
style={{ left: panelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
>
<div className="px-1 pb-2 text-sm font-medium opacity-65"></div>
<div className="px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="px-1 pb-2 text-sm font-medium opacity-65">{t("canvas.toolbar.appearance")}</div>
<div className="px-1 pb-1.5 text-[11px] font-medium opacity-50">{t("canvas.toolbar.themeMode")}</div>
<div className="grid grid-cols-2 gap-1 rounded-lg p-1" style={{ background: theme.toolbar.itemHover }}>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="light" onThemeChange={setTheme}>
<Sun className="size-4" />
{t("canvas.toolbar.light")}
</CanvasThemeButton>
<CanvasThemeButton colorTheme={colorTheme} targetTheme="dark" onThemeChange={setTheme}>
<Moon className="size-4" />
{t("canvas.toolbar.dark")}
</CanvasThemeButton>
</div>
<div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="mt-3 px-1 pb-1.5 text-[11px] font-medium opacity-50">{t("canvas.toolbar.gridStyle")}</div>
<Segmented
className="w-full !p-1 [&_.ant-segmented-group]:!flex [&_.ant-segmented-item]:!min-h-8 [&_.ant-segmented-item]:!flex-1 [&_.ant-segmented-item-label]:!min-h-8 [&_.ant-segmented-item-label]:!leading-8"
value={backgroundMode}
@@ -228,7 +230,7 @@ export function CanvasToolbar({
value: "dots",
label: (
<span className="inline-flex items-center gap-1.5">
<CircleDot className="size-4" />
<CircleDot className="size-4" />{t("canvas.toolbar.dots")}
</span>
),
},
@@ -236,7 +238,7 @@ export function CanvasToolbar({
value: "lines",
label: (
<span className="inline-flex items-center gap-1.5">
<Grid2x2 className="size-4" />线
<Grid2x2 className="size-4" />{t("canvas.toolbar.lines")}
</span>
),
},
@@ -245,7 +247,7 @@ export function CanvasToolbar({
label: (
<span className="inline-flex items-center gap-1.5">
<Square className="size-4" />
{t("canvas.toolbar.blank")}
</span>
),
},
@@ -254,7 +256,7 @@ export function CanvasToolbar({
<div className="mt-3 flex items-center justify-between gap-3 rounded-lg px-1.5 py-1">
<span className="inline-flex min-w-0 items-center gap-1.5 text-[11px] font-medium opacity-65">
<Info className="size-3.5" />
{t("canvas.toolbar.imageInfo")}
</span>
<Switch size="small" checked={showImageInfo} onChange={onShowImageInfoChange} />
</div>
@@ -321,6 +323,8 @@ function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }:
const theme = canvasThemes[colorTheme];
const active = colorTheme === targetTheme;
const activeStyle = colorTheme === "light" ? { background: "#111111", color: "#ffffff" } : { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const { t } = useTranslation();
const label = targetTheme === "dark" ? t("topNav.darkTheme") : t("topNav.lightTheme");
return (
<AnimatedThemeToggler
@@ -329,8 +333,8 @@ function CanvasThemeButton({ colorTheme, targetTheme, onThemeChange, children }:
onThemeChange={onThemeChange}
className="inline-flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-sm transition"
style={active ? activeStyle : { color: theme.toolbar.item }}
aria-label={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
title={`切换到${targetTheme === "dark" ? "深色" : "浅色"}主题`}
aria-label={label}
title={label}
>
{children}
</AnimatedThemeToggler>
@@ -345,21 +349,21 @@ function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasT
);
}
function toolLabel(id: string) {
if (id === "tool-hand") return "移动/选择";
if (id === "tool-undo") return "撤销";
if (id === "tool-redo") return "重做";
if (id === "tool-text") return "文本";
if (id === "tool-image") return "图片";
if (id === "tool-video") return "视频";
if (id === "tool-audio") return "音频";
if (id === "tool-config") return "生成配置";
if (id === "tool-group") return "组";
if (id === "tool-extensions") return "扩展节点";
if (id === "tool-upload") return "上传资产";
if (id === "tool-style") return "画布外观";
if (id === "tool-delete") return "删除选中";
if (id === "tool-clear") return "清空画布";
function toolLabel(id: string, t: (key: string) => string) {
if (id === "tool-hand") return t("canvas.toolbar.move");
if (id === "tool-undo") return t("canvas.undo");
if (id === "tool-redo") return t("canvas.redo");
if (id === "tool-text") return t("canvas.toolbar.text");
if (id === "tool-image") return t("canvas.toolbar.image");
if (id === "tool-video") return t("canvas.toolbar.video");
if (id === "tool-audio") return t("canvas.toolbar.audio");
if (id === "tool-config") return t("canvas.toolbar.config");
if (id === "tool-group") return t("canvas.toolbar.group");
if (id === "tool-extensions") return t("canvas.toolbar.extensions");
if (id === "tool-upload") return t("canvas.toolbar.upload");
if (id === "tool-style") return t("canvas.toolbar.appearance");
if (id === "tool-delete") return t("canvas.deleteSelected");
if (id === "tool-clear") return t("canvas.toolbar.clear");
return "";
}
+32 -29
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { BookOpen, Bot, Download, Home, Images, Menu, PanelLeftClose, PanelLeftOpen, Plus, Redo2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -54,6 +55,7 @@ export function CanvasTopBar({
onToggleAgent: () => void;
}) {
const colorTheme = useThemeStore((state) => state.theme);
const { t } = useTranslation();
const theme = canvasThemes[colorTheme];
const titleRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
@@ -73,11 +75,11 @@ export function CanvasTopBar({
<>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between pl-1 pr-4">
<div className="pointer-events-auto flex min-w-0 items-center gap-2">
<Tooltip title={sidePanelOpen ? "收起面板" : "展开面板"}>
<Tooltip title={sidePanelOpen ? t("canvas.collapsePanel") : t("canvas.expandPanel")}>
<button
type="button"
onClick={toggleSidePanel}
aria-label={sidePanelOpen ? "收起面板" : "展开面板"}
aria-label={sidePanelOpen ? t("canvas.collapsePanel") : t("canvas.expandPanel")}
className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
>
@@ -88,22 +90,22 @@ export function CanvasTopBar({
trigger={["click"]}
menu={{
items: [
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome },
{ key: "docs", icon: <BookOpen className="size-4" />, label: "文档", onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects },
{ key: "home", icon: <Home className="size-4" />, label: t("canvas.home"), onClick: onHome },
{ key: "docs", icon: <BookOpen className="size-4" />, label: t("canvas.docs"), onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
{ key: "projects", icon: <Images className="size-4" />, label: t("canvas.projects"), onClick: onProjects },
{ type: "divider" },
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ key: "new", icon: <Plus className="size-4" />, label: t("canvas.create"), onClick: onCreateProject },
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: t("canvas.deleteCurrent"), onClick: onDeleteProject },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
{ key: "export", icon: <Download className="size-4" />, label: "导出当前画布", onClick: onExportProject },
{ key: "import", icon: <Upload className="size-4" />, label: t("canvas.importAsset"), onClick: onImportImage },
{ key: "export", icon: <Download className="size-4" />, label: t("canvas.exportCurrent"), onClick: onExportProject },
{ type: "divider" },
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text={t("canvas.undo")} shortcut="⌘ Z" />, onClick: onUndo },
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text={t("canvas.redo")} shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
],
}}
>
<button type="button" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
<button type="button" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label={t("canvas.openMenu")}>
<Menu className="size-4" />
</button>
</Dropdown>
@@ -127,7 +129,7 @@ export function CanvasTopBar({
type="button"
className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current"
onDoubleClick={onStartTitleEditing}
title="双击修改画布名称"
title={t("canvas.renameHint")}
>
{title}
</button>
@@ -150,21 +152,21 @@ export function CanvasTopBar({
</Button>
</div>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut keys={["拖动画布"]} value="平移视图" />
<Shortcut keys={["滚轮"]} value="缩放画布" />
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
<Shortcut keys={["Ctrl / Cmd", "拖动"]} value="框选多个节点" />
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
<Shortcut keys={[t("canvas.shortcut.dragCanvas")]} value={t("canvas.shortcut.pan")} />
<Shortcut keys={[t("canvas.shortcut.wheel")]} value={t("canvas.shortcut.zoom")} />
<Shortcut keys={[t("canvas.shortcut.zoomSlider")]} value={t("canvas.shortcut.preciseZoom")} />
<Shortcut keys={["Ctrl / Cmd", t("canvas.shortcut.drag")]} value={t("canvas.shortcut.boxSelect")} />
<Shortcut keys={["Shift / Ctrl / Cmd", t("canvas.shortcut.click")]} value={t("canvas.shortcut.addSelection")} />
<Shortcut keys={["Ctrl / Cmd", "A"]} value={t("canvas.shortcut.selectAll")} />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value={t("canvas.shortcut.copyPaste")} />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value={t("canvas.undo")} />
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value={t("canvas.redo")} />
<Shortcut keys={["Ctrl / Cmd", "Y"]} value={t("canvas.redo")} />
<Shortcut keys={["Delete / Backspace"]} value={t("canvas.shortcut.delete")} />
<Shortcut keys={["Esc"]} value={t("canvas.shortcut.escape")} />
<Shortcut keys={[t("canvas.shortcut.dropMedia")]} value={t("canvas.shortcut.upload")} />
</div>
</Modal>
</>
@@ -183,10 +185,11 @@ function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接";
const { t } = useTranslation();
const label = status.connected ? t("canvas.agentConnected") : status.enabled ? t("canvas.agentConnecting", { activity: status.activity || t("canvas.connecting") }) : t("canvas.agentDisconnected");
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return (
<button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title="打开本地 Codex 面板">
<button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title={t("canvas.openAgent")}>
<span className="size-2 rounded-full" style={{ background: dotColor }} />
<span className="max-w-[140px] truncate">{label}</span>
</button>
@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { Compass, Focus, HelpCircle } from "lucide-react";
import { useState } from "react";
import { Button, Modal, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -16,6 +17,7 @@ type CanvasZoomControlsProps = {
export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpen, onToggleMiniMap }: CanvasZoomControlsProps) {
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const { t } = useTranslation();
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
@@ -24,20 +26,20 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
return (
<div className="absolute bottom-5 left-5 z-50" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<div className="flex h-14 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={dockStyle}>
<Tooltip title={isMiniMapOpen ? "关闭小地图" : "打开小地图"}>
<Tooltip title={isMiniMapOpen ? t("canvas.miniMapClose") : t("canvas.miniMapOpen")}>
<Button
type="text"
className="!h-8 !w-8 !min-w-8 !p-0"
style={isMiniMapOpen ? activeStyle : { color: theme.toolbar.item }}
icon={<Compass className="size-4" />}
onClick={onToggleMiniMap}
aria-label={isMiniMapOpen ? "关闭小地图" : "打开小地图"}
aria-label={isMiniMapOpen ? t("canvas.miniMapClose") : t("canvas.miniMapOpen")}
/>
</Tooltip>
<Tooltip title="重置视图">
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label="重置视图" />
<Tooltip title={t("canvas.resetView")}>
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={{ color: theme.toolbar.item }} icon={<Focus className="size-4" />} onClick={onReset} aria-label={t("canvas.resetView")} />
</Tooltip>
<Tooltip title="放大/缩小画布">
<Tooltip title={t("canvas.zoom")}>
<input
type="range"
min="5"
@@ -47,24 +49,24 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
className="w-24"
style={{ accentColor: theme.node.activeStroke }}
onChange={(event) => onScaleChange(Number(event.target.value) / 100)}
aria-label="放大/缩小画布"
aria-label={t("canvas.zoom")}
/>
</Tooltip>
<span className="w-10 text-right text-xs tabular-nums" style={{ color: theme.node.muted }}>
{Math.round(scale * 100)}%
</span>
<Tooltip title="快捷键">
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label="快捷键" />
<Tooltip title={t("canvas.shortcuts")}>
<Button type="text" className="!h-8 !w-8 !min-w-8 !p-0" style={shortcutsOpen ? activeStyle : { color: theme.toolbar.item }} icon={<HelpCircle className="size-4" />} onClick={() => setShortcutsOpen(true)} aria-label={t("canvas.shortcuts")} />
</Tooltip>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut label="拖动画布" value="平移视图" />
<Shortcut label="滚轮" value="缩放画布" />
<Shortcut label="Ctrl / Cmd + 拖动" value="框选多个节点" />
<Shortcut label="Shift / Ctrl / Cmd + 点击" value="追加选择节点" />
<Shortcut label="Ctrl / Cmd + C / V" value="复制 / 粘贴节点" />
<Shortcut label="Delete / Backspace" value="删除选中" />
<Shortcut label={t("canvas.shortcut.dragCanvas")} value={t("canvas.shortcut.pan")} />
<Shortcut label={t("canvas.shortcut.wheel")} value={t("canvas.shortcut.zoom")} />
<Shortcut label={`Ctrl / Cmd + ${t("canvas.shortcut.drag")}`} value={t("canvas.shortcut.boxSelect")} />
<Shortcut label={`Shift / Ctrl / Cmd + ${t("canvas.shortcut.click")}`} value={t("canvas.shortcut.addSelection")} />
<Shortcut label="Ctrl / Cmd + C / V" value={t("canvas.shortcut.copyPasteNodes")} />
<Shortcut label="Delete / Backspace" value={t("canvas.shortcut.delete")} />
</div>
</Modal>
</div>
@@ -1,5 +1,7 @@
import { FileText, Group, Image as ImageIcon, Music2, Settings2, Video } from "lucide-react";
import i18n from "@/i18n";
import { NODE_SPECS } from "@/constant/canvas";
import { registerNodeDefinitions } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -18,12 +20,12 @@ function builtinResource(node: CanvasNodeData): CanvasNodeResource | null {
const iconClass = "size-5";
const BUILTIN_DEFINITIONS: CanvasNodeDefinition[] = [
{ type: CanvasNodeType.Text, title: "文本", icon: <FileText className={iconClass} />, minimapColor: undefined, resource: builtinResource },
{ type: CanvasNodeType.Image, title: "图片", icon: <ImageIcon className={iconClass} />, minimapColor: "#10b981", keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, resource: builtinResource },
{ type: CanvasNodeType.Video, title: "视频", icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
{ type: CanvasNodeType.Audio, title: "音频", icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
{ type: CanvasNodeType.Config, title: "生成配置", icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
{ type: CanvasNodeType.Group, title: "组", icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
{ type: CanvasNodeType.Text, title: i18n.t("assets.kinds.text"), icon: <FileText className={iconClass} />, minimapColor: undefined, resource: builtinResource },
{ type: CanvasNodeType.Image, title: i18n.t("assets.kinds.image"), icon: <ImageIcon className={iconClass} />, minimapColor: "#10b981", keepAspectRatio: (node: CanvasNodeData) => !node.metadata?.freeResize, resource: builtinResource },
{ type: CanvasNodeType.Video, title: i18n.t("assets.kinds.video"), icon: <Video className={iconClass} />, minimapColor: "#f97316", keepAspectRatio: () => true, resource: builtinResource },
{ type: CanvasNodeType.Audio, title: i18n.t("assets.kinds.audio"), icon: <Music2 className={iconClass} />, minimapColor: "#a855f7", resource: builtinResource },
{ type: CanvasNodeType.Config, title: i18n.t("canvas.configNode.title"), icon: <Settings2 className={iconClass} />, minimapColor: "#60a5fa", hasSourceHandle: false },
{ type: CanvasNodeType.Group, title: i18n.t("canvas.node.group"), icon: <Group className={iconClass} />, minimapColor: "#94a3b8" },
].map((def) => {
const spec = NODE_SPECS[def.type];
return { ...def, title: spec.title, defaultSize: { width: spec.width, height: spec.height }, defaultMetadata: spec.metadata };