feat(canvas): 创作体验 - 合并 PR #282 并优化引用、风格与素材文件夹
This commit is contained in:
23
web/src/lib/canvas/canvas-folder-theme.ts
Normal file
23
web/src/lib/canvas/canvas-folder-theme.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { CanvasFolderTheme } from "@/types/canvas";
|
||||
|
||||
export const CANVAS_FOLDER_THEME_OPTIONS: ReadonlyArray<{
|
||||
key: CanvasFolderTheme;
|
||||
label: string;
|
||||
cover: string;
|
||||
}> = [
|
||||
{ key: "aurora", label: "赤蓝流光", cover: "/images/canvas/folder-default-cover.png" },
|
||||
{ key: "obsidian", label: "曜石银蓝", cover: "/images/canvas/folder-theme-obsidian.png" },
|
||||
{ key: "ember", label: "熔金赤焰", cover: "/images/canvas/folder-theme-ember.png" },
|
||||
{ key: "pearl", label: "珍珠薄雾", cover: "/images/canvas/folder-theme-pearl.png" },
|
||||
];
|
||||
|
||||
export function resolveCanvasFolderTheme(value?: string): CanvasFolderTheme {
|
||||
return CANVAS_FOLDER_THEME_OPTIONS.some((theme) => theme.key === value) ? (value as CanvasFolderTheme) : "aurora";
|
||||
}
|
||||
|
||||
export function resolveCanvasFolderThemeCover(theme?: string, customCover?: string) {
|
||||
const custom = customCover?.trim();
|
||||
if (custom) return custom;
|
||||
const resolved = resolveCanvasFolderTheme(theme);
|
||||
return CANVAS_FOLDER_THEME_OPTIONS.find((item) => item.key === resolved)!.cover;
|
||||
}
|
||||
@@ -4,15 +4,29 @@ export const FRAME_HEADER_HEIGHT = 36;
|
||||
export const FRAME_PADDING = 24;
|
||||
export const FRAME_COLLAPSED_WIDTH = 240;
|
||||
export const FRAME_COLLAPSED_HEIGHT = 144;
|
||||
export const FOLDER_COLLAPSED_WIDTH = 360;
|
||||
export const FOLDER_COLLAPSED_HEIGHT = 280;
|
||||
|
||||
export function isFrameNode(node?: CanvasNodeData | null): node is CanvasNodeData & { type: CanvasNodeType.Frame } {
|
||||
return node?.type === CanvasNodeType.Frame;
|
||||
}
|
||||
|
||||
export function isCanvasFolderNode(node?: CanvasNodeData | null) {
|
||||
return isFrameNode(node) && Boolean(node.metadata?.folder);
|
||||
}
|
||||
|
||||
export function canFrameContain(node: CanvasNodeData) {
|
||||
return node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Video;
|
||||
}
|
||||
|
||||
export function canFolderContain(node: CanvasNodeData) {
|
||||
return node.type !== CanvasNodeType.Frame;
|
||||
}
|
||||
|
||||
export function canLinkedFolderArchive(node: CanvasNodeData) {
|
||||
return (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio || node.type === CanvasNodeType.Text) && Boolean(node.metadata?.assetId || node.metadata?.content?.trim());
|
||||
}
|
||||
|
||||
export function getFrameChildren(frameId: string, nodes: CanvasNodeData[]) {
|
||||
return nodes.filter((node) => node.parentId === frameId);
|
||||
}
|
||||
@@ -32,16 +46,18 @@ export function isNodeHiddenByCollapsedFrame(node: CanvasNodeData, nodes: Canvas
|
||||
}
|
||||
|
||||
export function findFrameDropTarget(nodes: CanvasNodeData[], draggedNodeIds: Set<string>) {
|
||||
const dragged = nodes.filter((node) => draggedNodeIds.has(node.id) && canFrameContain(node));
|
||||
const dragged = nodes.filter((node) => draggedNodeIds.has(node.id));
|
||||
if (!dragged.length) return null;
|
||||
|
||||
return (
|
||||
[...nodes]
|
||||
.reverse()
|
||||
.find((frame) => {
|
||||
if (!isFrameNode(frame) || frame.metadata?.frame?.collapsed || draggedNodeIds.has(frame.id)) return false;
|
||||
if (!isFrameNode(frame) || (frame.metadata?.frame?.collapsed && !isCanvasFolderNode(frame)) || draggedNodeIds.has(frame.id)) return false;
|
||||
const canContain = frame.metadata?.folder?.assetFolderId ? canLinkedFolderArchive : isCanvasFolderNode(frame) ? canFolderContain : canFrameContain;
|
||||
if (!dragged.every(canContain)) return false;
|
||||
const left = frame.position.x;
|
||||
const top = frame.position.y + FRAME_HEADER_HEIGHT;
|
||||
const top = frame.position.y + (isCanvasFolderNode(frame) && frame.metadata?.frame?.collapsed ? 0 : FRAME_HEADER_HEIGHT);
|
||||
const right = frame.position.x + frame.width;
|
||||
const bottom = frame.position.y + frame.height;
|
||||
return dragged.every((node) => {
|
||||
@@ -54,7 +70,13 @@ export function findFrameDropTarget(nodes: CanvasNodeData[], draggedNodeIds: Set
|
||||
}
|
||||
|
||||
export function applyFrameDrop(nodes: CanvasNodeData[], draggedNodeIds: Set<string>, frameId: string | null) {
|
||||
const next = nodes.map((node) => (draggedNodeIds.has(node.id) && canFrameContain(node) ? { ...node, parentId: frameId || undefined } : node));
|
||||
const target = frameId ? nodes.find((node) => node.id === frameId && isFrameNode(node)) : null;
|
||||
const canContain = target?.metadata?.folder?.assetFolderId ? canLinkedFolderArchive : target && isCanvasFolderNode(target) ? canFolderContain : canFrameContain;
|
||||
const next = nodes.map((node) => {
|
||||
if (!draggedNodeIds.has(node.id)) return node;
|
||||
if (!frameId) return node.parentId ? { ...node, parentId: undefined } : node;
|
||||
return target && canContain(node) ? { ...node, parentId: frameId } : node;
|
||||
});
|
||||
if (!frameId) return next;
|
||||
|
||||
const children = getFrameChildren(frameId, next);
|
||||
@@ -62,6 +84,10 @@ export function applyFrameDrop(nodes: CanvasNodeData[], draggedNodeIds: Set<stri
|
||||
const frame = next.find((node) => node.id === frameId);
|
||||
if (!frame || !isFrameNode(frame)) return next;
|
||||
|
||||
if (isCanvasFolderNode(frame) && frame.metadata?.frame?.collapsed) {
|
||||
return layoutCollapsedFolderChildren(next, frame);
|
||||
}
|
||||
|
||||
const left = Math.min(frame.position.x, ...children.map((node) => node.position.x - FRAME_PADDING));
|
||||
const top = Math.min(frame.position.y, ...children.map((node) => node.position.y - FRAME_HEADER_HEIGHT - FRAME_PADDING));
|
||||
const right = Math.max(frame.position.x + frame.width, ...children.map((node) => node.position.x + node.width + FRAME_PADDING));
|
||||
@@ -87,6 +113,43 @@ export function applyFrameDrop(nodes: CanvasNodeData[], draggedNodeIds: Set<stri
|
||||
);
|
||||
}
|
||||
|
||||
function layoutCollapsedFolderChildren(nodes: CanvasNodeData[], folder: CanvasNodeData) {
|
||||
const children = getFrameChildren(folder.id, nodes);
|
||||
if (!children.length) return nodes;
|
||||
const gap = 24;
|
||||
const padding = FRAME_PADDING;
|
||||
const columns = Math.min(3, Math.ceil(Math.sqrt(children.length)));
|
||||
const rows = Math.ceil(children.length / columns);
|
||||
const cellWidth = Math.max(...children.map((node) => node.width));
|
||||
const cellHeight = Math.max(...children.map((node) => node.height));
|
||||
const expandedWidth = padding * 2 + columns * cellWidth + Math.max(columns - 1, 0) * gap;
|
||||
const expandedHeight = FRAME_HEADER_HEIGHT + padding * 2 + rows * cellHeight + Math.max(rows - 1, 0) * gap;
|
||||
const indexById = new Map(children.map((node, index) => [node.id, index]));
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (node.id === folder.id) {
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
frame: { collapsed: true, expandedWidth, expandedHeight },
|
||||
},
|
||||
};
|
||||
}
|
||||
const index = indexById.get(node.id);
|
||||
if (index === undefined) return node;
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: folder.position.x + padding + column * (cellWidth + gap) + (cellWidth - node.width) / 2,
|
||||
y: folder.position.y + FRAME_HEADER_HEIGHT + padding + row * (cellHeight + gap) + (cellHeight - node.height) / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveFrameConnection(connection: CanvasConnection, nodes: CanvasNodeData[]) {
|
||||
const from = nodes.find((node) => node.id === connection.fromNodeId);
|
||||
const to = nodes.find((node) => node.id === connection.toNodeId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FolderOpen, Layers3, Palette, UploadCloud, UserRound } from "lucide-react";
|
||||
import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video } from "lucide-react";
|
||||
|
||||
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
|
||||
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
|
||||
@@ -17,6 +17,7 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
nodeCommand(CanvasNodeType.Drawing, { defaultOrder: 20, run: (ctx) => ctx.handlers.onAddDrawing() }),
|
||||
nodeCommand(CanvasNodeType.Script, { badge: "核心", defaultOrder: 30, run: (ctx) => ctx.handlers.onAddScript() }),
|
||||
nodeCommand(CanvasNodeType.Frame, { defaultOrder: 40, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddFrame() }),
|
||||
{ id: "folder", label: "文件夹", icon: <Folder />, badge: "6 款", section: "node", defaultOrder: 45, run: (ctx) => ctx.handlers.onAddFolder() },
|
||||
nodeCommand(CanvasNodeType.Image, { defaultOrder: 50, run: (ctx) => ctx.handlers.onAddImage() }),
|
||||
nodeCommand(CanvasNodeType.Video, { defaultOrder: 60, run: (ctx) => ctx.handlers.onAddVideo() }),
|
||||
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ToolbarHandlers = {
|
||||
onAddAudio: () => void;
|
||||
onAddScript: () => void;
|
||||
onAddFrame: () => void;
|
||||
onAddFolder: () => void;
|
||||
onAddDrawing: () => void;
|
||||
onChooseStyle: () => void;
|
||||
onOpenDirector: () => void;
|
||||
@@ -131,6 +132,7 @@ export type AddNodeMenuContext = {
|
||||
| "onAddAudio"
|
||||
| "onAddScript"
|
||||
| "onAddFrame"
|
||||
| "onAddFolder"
|
||||
| "onAddDrawing"
|
||||
| "onChooseStyle"
|
||||
| "onOpenDirector"
|
||||
|
||||
Reference in New Issue
Block a user