mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 09:24:24 +08:00
feat(i18n): implement internationalization framework and update UI components for language support
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user