feat(assets): 文件夹主题 - 新增多主题皮肤并同步主线

This commit is contained in:
echoD886
2026-08-20 16:33:51 +08:00
93 changed files with 4081 additions and 1143 deletions

View File

@@ -2,11 +2,10 @@ import React, { useEffect, useMemo, useState } from "react";
import { Dropdown } from "antd";
import { FileAudio, FileText, MoreHorizontal, Pencil, Plus, SlidersHorizontal, Sparkles, Video } from "lucide-react";
import type { CanvasFolderStyle, CanvasNodeData } from "@/types/canvas";
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme, resolveCanvasFolderThemeCover } from "@/lib/canvas/canvas-folder-theme";
import type { CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType } from "@/types/canvas";
const DEFAULT_FOLDER_COVER = "/images/canvas/folder-default-cover.png";
const FOLDER_STYLE_OPTIONS: Array<{ key: CanvasFolderStyle; label: string }> = [
{ key: "glass", label: "流光玻璃" },
{ key: "stacked", label: "内容陈列" },
@@ -25,6 +24,7 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
onToggleCollapsed,
onTitleChange,
onStyleChange,
onThemeChange,
}: {
data: CanvasNodeData;
childNodes: CanvasNodeData[];
@@ -34,13 +34,34 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
onToggleCollapsed: (nodeId: string) => void;
onTitleChange: (nodeId: string, title: string) => void;
onStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
onThemeChange: (nodeId: string, theme: CanvasFolderTheme) => void;
}) {
const style = data.metadata?.folder?.style || "glass";
const theme = resolveCanvasFolderTheme(data.metadata?.folder?.theme);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(data.title);
const previewNodes = useMemo(() => childNodes.filter(canPreviewFolderNode).slice(0, 3), [childNodes]);
const themeCover = data.metadata?.folder?.themeCover || DEFAULT_FOLDER_COVER;
const themeCover = resolveCanvasFolderThemeCover(theme, data.metadata?.folder?.themeCover);
const showAdd = style === "glass" || childNodes.length === 0;
const folderMenu = {
selectedKeys: [`style:${style}`, `theme:${theme}`],
items: [
{
key: "styles",
label: "文件夹样式",
children: FOLDER_STYLE_OPTIONS.map((item) => ({ key: `style:${item.key}`, label: item.label })),
},
{
key: "themes",
label: "主题皮肤",
children: CANVAS_FOLDER_THEME_OPTIONS.map((item) => ({ key: `theme:${item.key}`, label: item.label })),
},
],
onClick: ({ key }: { key: string }) => {
if (key.startsWith("style:")) onStyleChange(data.id, key.slice(6) as CanvasFolderStyle);
if (key.startsWith("theme:")) onThemeChange(data.id, key.slice(6) as CanvasFolderTheme);
},
};
useEffect(() => setTitle(data.title), [data.title]);
@@ -130,17 +151,13 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
) : (
<Dropdown
trigger={["click"]}
menu={{
selectedKeys: [style],
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
}}
menu={folderMenu}
>
<button
type="button"
className="canvas-folder-action canvas-folder-options"
aria-label={`文件夹选项,当前 ${childNodes.length} 项`}
title="切换文件夹样式"
title="切换文件夹样式与主题"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
@@ -153,17 +170,13 @@ export const CanvasFolderPreview = React.memo(function CanvasFolderPreview({
{!readOnly && showAdd ? (
<Dropdown
trigger={["click"]}
menu={{
selectedKeys: [style],
items: FOLDER_STYLE_OPTIONS.map((item) => ({ key: item.key, label: item.label })),
onClick: ({ key }) => onStyleChange(data.id, key as CanvasFolderStyle),
}}
menu={folderMenu}
>
<button
type="button"
className="canvas-folder-style-trigger"
aria-label="切换文件夹样式"
title="切换文件夹样式"
aria-label="切换文件夹样式与主题"
title="切换文件夹样式与主题"
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>

View File

@@ -7,7 +7,7 @@ import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
import { FRAME_HEADER_HEIGHT, FRAME_PADDING, isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasNodeData, type Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type Position } from "@/types/canvas";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -22,6 +22,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
onResize,
onToggleCollapsed,
onFolderStyleChange,
onFolderThemeChange = () => undefined,
onTitleChange,
onContextMenu,
readOnly = false,
@@ -38,6 +39,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onToggleCollapsed: (nodeId: string) => void;
onFolderStyleChange: (nodeId: string, style: CanvasFolderStyle) => void;
onFolderThemeChange?: (nodeId: string, theme: CanvasFolderTheme) => void;
onTitleChange: (nodeId: string, title: string) => void;
onContextMenu: (event: ReactMouseEvent, nodeId: string) => void;
readOnly?: boolean;
@@ -194,6 +196,7 @@ export const CanvasFrameNode = React.memo(function CanvasFrameNode({
onToggleCollapsed={onToggleCollapsed}
onTitleChange={onTitleChange}
onStyleChange={onFolderStyleChange}
onThemeChange={onFolderThemeChange}
/>
) : (
<>

View File

@@ -255,6 +255,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
replaceEditableSelection(event.clipboardData.getData("text/plain"));
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
@@ -339,7 +340,16 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
syncMention(next, event.target.selectionStart);
reportContentSize(event.currentTarget);
}}
onCompositionStart={(event) => {
composingRef.current = true;
props.onCompositionStart?.(event);
}}
onCompositionEnd={(event) => {
composingRef.current = false;
props.onCompositionEnd?.(event);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();

View File

@@ -195,8 +195,8 @@ export function CanvasToolbar({
<AnimatePresence>
{appearanceOpen ? (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[224px] max-w-[calc(100vw-24px)] -translate-x-1/2" style={{ left: panelX || "50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item }} onWheel={(event) => event.stopPropagation()}>
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[224px] max-w-[calc(100vw-24px)]" style={{ left: panelX || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2.5 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.toolbar.item }} onWheel={(event) => event.stopPropagation()}>
<PanelHeading icon={<Palette className="size-4" />} title="画布外观" subtitle="调整整个创作空间" theme={theme} />
<div className="mt-3 text-[var(--fs-micro)] font-semibold uppercase opacity-45">主题模式</div>
<div className="mt-1 grid grid-cols-2 gap-1 rounded-[var(--dock-item-radius-labeled)] border p-1" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border }}>
@@ -234,8 +234,8 @@ function AddNodeMenu({ x, theme, commands }: {
commands: CanvasCreateCommand[];
}) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: aceternityMotion.duration.instant }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[260px] max-w-[calc(100vw-24px)] -translate-x-1/2" style={{ left: x || "50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ y: 6, scale: 0.97 }} animate={{ y: 0, scale: 1 }} exit={{ y: 4, scale: 0.98 }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[260px] max-w-[calc(100vw-24px)]" style={{ left: x || "50%", transformOrigin: "bottom center", x: "-50%" }}>
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
<CanvasCreateMenu commands={commands} />
</SpotlightSurface>
</motion.div>

View File

@@ -5,6 +5,7 @@ import { Button } from "antd";
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { useThemeStore } from "@/stores/use-theme-store";
import type { AiConfig } from "@/stores/use-config-store";
@@ -21,7 +22,12 @@ export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClass
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const summary = `${videoResolutionLabel(config.vquality)} · ${videoSizeLabel(config.size)} · ${videoSecondsLabel(config.videoSeconds)}`;
const resolutionSupported = Boolean(modelCapabilityConfigFor(config, config.model).video?.resolutions.length);
const summary = [
...(resolutionSupported ? [videoResolutionLabel(config.vquality)] : []),
videoSizeLabel(config.size),
videoSecondsLabel(config.videoSeconds),
].join(" · ");
useEffect(() => {
if (!open) return;