feat: 完善画布管理与快捷键体验

修改历史:

- 为画布编辑器补齐节点复制粘贴、全选、追加选择、缩放、删除、生成等快捷键,并新增快捷键说明弹窗

- 支持粘贴剪贴板文本/图片为画布节点、拖入图片上传,以及多选节点复制时保留内部连接

- 在 /canvas 画布管理页新增编辑和删除操作,可编辑标题与说明,并支持删除当前画布

- 为画布项目新增 description 字段,补充数据库兼容迁移、列表展示、API 保存以及导入导出保留说明

- 优化画布节点、提示词引用、配置节点尺寸、生成任务状态与相关服务逻辑
This commit is contained in:
2026-06-23 01:10:25 +08:00
parent 3b8ce48c56
commit 4ff348e808
38 changed files with 2069 additions and 310 deletions
+108 -26
View File
@@ -2,59 +2,126 @@ import type {
CanvasConnection,
CanvasImageNodeMetadata,
CanvasNode,
CanvasPromptNodeMetadata,
} from "@/lib/canvas/types";
export type PromptMentionSource = {
alias: string;
token: string;
nodeId: string;
kind: "image" | "prompt";
label: string;
description: string;
imageUrl: string;
imageUrl?: string;
prompt?: string;
index: number;
};
const mentionUrlPrefix = "canvas-image://";
const imageMentionUrlPrefix = "canvas-image://";
const promptMentionUrlPrefix = "canvas-prompt://";
export function getPromptMentionSources(
nodes: CanvasNode[],
connections: CanvasConnection[],
promptNodeId: string,
) {
const imageById = new Map(
): PromptMentionSource[] {
const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
const sources: PromptMentionSource[] = [];
let imageIndex = 0;
let promptIndex = 0;
for (const connection of connections) {
if (connection.toNodeId !== promptNodeId) continue;
const node = nodeById.get(connection.fromNodeId);
if (!node) continue;
if (node.type === "prompt") {
const metadata = node.metadata as CanvasPromptNodeMetadata;
const prompt = metadata.prompt?.trim() || "";
promptIndex += 1;
sources.push({
alias: `@文本${promptIndex}`,
token: createPromptMentionToken(`文本${promptIndex}`, node.id, "prompt"),
nodeId: node.id,
kind: "prompt",
label: `文本${promptIndex}`,
index: promptIndex,
description: prompt || node.title.trim() || `已连接文本 ${promptIndex}`,
prompt,
});
continue;
}
if (node.type === "image") {
const metadata = node.metadata as CanvasImageNodeMetadata;
if (!metadata.imageUrl) continue;
imageIndex += 1;
sources.push({
alias: `@图${imageIndex}`,
token: createPromptMentionToken(`${imageIndex}`, node.id, "image"),
nodeId: node.id,
kind: "image",
label: `${imageIndex}`,
index: imageIndex,
description:
metadata.prompt?.trim() || node.title.trim() || `已连接素材 ${imageIndex}`,
imageUrl: metadata.imageUrl,
});
}
}
return sources;
}
export function getConnectedPromptSources(
nodes: CanvasNode[],
connections: CanvasConnection[],
targetNodeId: string,
): PromptMentionSource[] {
const promptById = new Map(
nodes
.filter((node) => node.type === "image")
.filter((node) => node.type === "prompt")
.map((node) => [node.id, node] as const),
);
return connections
.filter((connection) => connection.toNodeId === promptNodeId)
.map((connection) => imageById.get(connection.fromNodeId))
.filter((connection) => connection.toNodeId === targetNodeId)
.map((connection) => promptById.get(connection.fromNodeId))
.filter((node): node is CanvasNode => Boolean(node))
.map((node, index) => {
const metadata = node.metadata as CanvasImageNodeMetadata;
const metadata = node.metadata as CanvasPromptNodeMetadata;
const prompt = metadata.prompt?.trim() || "";
return {
alias: `@${index + 1}`,
token: createPromptMentionToken(`${index + 1}`, node.id),
alias: `@文本${index + 1}`,
token: createPromptMentionToken(`文本${index + 1}`, node.id, "prompt"),
nodeId: node.id,
label: `${index + 1}`,
kind: "prompt",
label: `文本${index + 1}`,
index: index + 1,
description:
metadata.prompt?.trim() || node.title.trim() || `已连接素材 ${index + 1}`,
imageUrl: metadata.imageUrl,
description: prompt || node.title.trim() || `已连接文本 ${index + 1}`,
prompt,
} satisfies PromptMentionSource;
})
.filter((item) => Boolean(item.imageUrl));
});
}
export function createPromptMentionToken(label: string, nodeId: string) {
return `[${label}](${mentionUrlPrefix}${nodeId})`;
export function createPromptMentionToken(
label: string,
nodeId: string,
kind: PromptMentionSource["kind"] = "image",
) {
const prefix = kind === "prompt" ? promptMentionUrlPrefix : imageMentionUrlPrefix;
return `[${label}](${prefix}${nodeId})`;
}
export function parsePromptMentionUrl(value: string) {
return value.startsWith(mentionUrlPrefix)
? value.slice(mentionUrlPrefix.length)
: null;
if (value.startsWith(imageMentionUrlPrefix)) {
return value.slice(imageMentionUrlPrefix.length);
}
if (value.startsWith(promptMentionUrlPrefix)) {
return value.slice(promptMentionUrlPrefix.length);
}
return null;
}
export function resolvePromptMentions(
@@ -65,7 +132,10 @@ export function resolvePromptMentions(
let resolvedPrompt = prompt;
for (const mention of mentions) {
const replacement = `${mention.label}(第 ${mention.index} 张输入参考图)`;
const replacement =
mention.kind === "image"
? `${mention.label}(第 ${mention.index} 张输入参考图)`
: `${mention.label}(第 ${mention.index} 段输入文本)`;
const usedMarkdownToken = resolvedPrompt.includes(mention.token);
const usedAlias = resolvedPrompt.includes(mention.alias);
if (!usedMarkdownToken && !usedAlias) continue;
@@ -75,13 +145,25 @@ export function resolvePromptMentions(
}
if (mentions.length > 0) {
const referenceGuide = mentions
const imageGuide = mentions
.filter((mention) => mention.kind === "image")
.map(
(mention) =>
`${mention.label} = 第 ${mention.index} 张输入参考图:${mention.description}`,
)
.join("\n");
resolvedPrompt = `${resolvedPrompt.trim()}\n\n已连接参考图编号:\n${referenceGuide}`;
);
const promptGuide = mentions
.filter((mention) => mention.kind === "prompt")
.map(
(mention) =>
`${mention.label} = 第 ${mention.index} 段输入文本:${mention.description}`,
);
const guide = [
imageGuide.length ? `已连接参考图编号:\n${imageGuide.join("\n")}` : "",
promptGuide.length ? `已连接文本编号:\n${promptGuide.join("\n")}` : "",
]
.filter(Boolean)
.join("\n\n");
resolvedPrompt = `${resolvedPrompt.trim()}\n\n${guide}`;
}
return {