feat: update canvas and flow board features
This commit is contained in:
parent
d88c28356a
commit
e054dcabcc
50
app/api/flow-board/_utils.ts
Normal file
50
app/api/flow-board/_utils.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
return isRecord(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFlowBoardTitle(value: unknown) {
|
||||||
|
const title = String(value || "").trim();
|
||||||
|
return title.slice(0, 256) || "未命名流程白板";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFlowBoardSnapshot(
|
||||||
|
value: unknown
|
||||||
|
): FlowBoardSnapshotData {
|
||||||
|
const raw = isRecord(value) ? value : {};
|
||||||
|
const updatedAt =
|
||||||
|
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
||||||
|
? raw.updatedAt
|
||||||
|
: new Date().toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
|
||||||
|
viewport: normalizeRecord(raw.viewport),
|
||||||
|
theme: normalizeRecord(raw.theme),
|
||||||
|
updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFlowBoardDraft(draft: FlowBoardDraft) {
|
||||||
|
return {
|
||||||
|
id: draft.id,
|
||||||
|
title: draft.title || "未命名流程白板",
|
||||||
|
snapshot: draft.snapshot ?? { children: [] },
|
||||||
|
createdAt: draft.createdAt?.toISOString() ?? null,
|
||||||
|
updatedAt: draft.updatedAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapFlowBoardDraftSummary(draft: FlowBoardDraft) {
|
||||||
|
return {
|
||||||
|
id: draft.id,
|
||||||
|
title: draft.title || "未命名流程白板",
|
||||||
|
createdAt: draft.createdAt?.toISOString() ?? null,
|
||||||
|
updatedAt: draft.updatedAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -4,34 +4,11 @@ import {
|
|||||||
getFlowBoardDraftByUserId,
|
getFlowBoardDraftByUserId,
|
||||||
upsertFlowBoardDraftByUserId,
|
upsertFlowBoardDraftByUserId,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import type { FlowBoardSnapshotData } from "@/lib/db/schema";
|
import { mapFlowBoardDraft, normalizeFlowBoardSnapshot } from "../_utils";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
export const revalidate = 0;
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRecord(value: unknown): Record<string, unknown> | null {
|
|
||||||
return isRecord(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFlowBoardSnapshot(value: unknown): FlowBoardSnapshotData {
|
|
||||||
const raw = isRecord(value) ? value : {};
|
|
||||||
const updatedAt =
|
|
||||||
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
|
|
||||||
? raw.updatedAt
|
|
||||||
: new Date().toISOString();
|
|
||||||
|
|
||||||
return {
|
|
||||||
children: Array.isArray(raw.children)
|
|
||||||
? raw.children.filter(isRecord)
|
|
||||||
: [],
|
|
||||||
viewport: normalizeRecord(raw.viewport),
|
|
||||||
theme: normalizeRecord(raw.theme),
|
|
||||||
updatedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@ -48,10 +25,15 @@ export async function GET() {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: draft
|
||||||
snapshot: draft?.snapshot ?? { children: [] },
|
? mapFlowBoardDraft(draft)
|
||||||
updatedAt: draft?.updatedAt?.toISOString() ?? null,
|
: {
|
||||||
},
|
id: null,
|
||||||
|
title: "流程白板",
|
||||||
|
snapshot: { children: [] },
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,9 +64,6 @@ export async function PUT(request: Request) {
|
|||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: mapFlowBoardDraft(savedDraft),
|
||||||
snapshot: savedDraft.snapshot,
|
|
||||||
updatedAt: savedDraft.updatedAt?.toISOString() ?? snapshot.updatedAt,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
116
app/api/flow-board/files/[id]/route.ts
Normal file
116
app/api/flow-board/files/[id]/route.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
deleteFlowBoardDraftById,
|
||||||
|
getFlowBoardDraftById,
|
||||||
|
updateFlowBoardDraftById,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
mapFlowBoardDraft,
|
||||||
|
normalizeFlowBoardSnapshot,
|
||||||
|
normalizeFlowBoardTitle,
|
||||||
|
} from "../../_utils";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
type RouteContext = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const draft = await getFlowBoardDraftById({
|
||||||
|
userId: session.user.id,
|
||||||
|
draftId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!draft) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "流程白板文件不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: mapFlowBoardDraft(draft),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request, { params }: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
title?: unknown;
|
||||||
|
snapshot?: unknown;
|
||||||
|
};
|
||||||
|
const draft = await updateFlowBoardDraftById({
|
||||||
|
userId: session.user.id,
|
||||||
|
draftId: id,
|
||||||
|
title:
|
||||||
|
body.title !== undefined
|
||||||
|
? normalizeFlowBoardTitle(body.title)
|
||||||
|
: undefined,
|
||||||
|
snapshot:
|
||||||
|
body.snapshot !== undefined
|
||||||
|
? normalizeFlowBoardSnapshot(body.snapshot)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!draft) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "流程白板文件不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: mapFlowBoardDraft(draft),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: RouteContext) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const draft = await deleteFlowBoardDraftById({
|
||||||
|
userId: session.user.id,
|
||||||
|
draftId: id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!draft) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "流程白板文件不存在" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: mapFlowBoardDraft(draft),
|
||||||
|
});
|
||||||
|
}
|
||||||
67
app/api/flow-board/files/route.ts
Normal file
67
app/api/flow-board/files/route.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import {
|
||||||
|
createFlowBoardDraft,
|
||||||
|
listFlowBoardDraftsByUserId,
|
||||||
|
} from "@/lib/db/queries";
|
||||||
|
import {
|
||||||
|
mapFlowBoardDraft,
|
||||||
|
mapFlowBoardDraftSummary,
|
||||||
|
normalizeFlowBoardSnapshot,
|
||||||
|
normalizeFlowBoardTitle,
|
||||||
|
} from "../_utils";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const drafts = await listFlowBoardDraftsByUserId({
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: drafts.map(mapFlowBoardDraftSummary),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "未登录" },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
title?: unknown;
|
||||||
|
snapshot?: unknown;
|
||||||
|
};
|
||||||
|
const draft = await createFlowBoardDraft({
|
||||||
|
userId: session.user.id,
|
||||||
|
title: normalizeFlowBoardTitle(body.title),
|
||||||
|
snapshot: normalizeFlowBoardSnapshot(body.snapshot),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!draft) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: "创建流程白板失败" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: mapFlowBoardDraft(draft),
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Bot,
|
Bot,
|
||||||
Box,
|
Box,
|
||||||
@ -9,6 +10,7 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clapperboard,
|
Clapperboard,
|
||||||
|
CheckCircle2,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
@ -16,11 +18,15 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Film,
|
Film,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Flag,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Grid3X3,
|
Grid3X3,
|
||||||
Hand,
|
Hand,
|
||||||
|
Heart,
|
||||||
|
HelpCircle,
|
||||||
ImagePlus,
|
ImagePlus,
|
||||||
Layers,
|
Layers,
|
||||||
|
Lightbulb,
|
||||||
Loader2,
|
Loader2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
@ -29,6 +35,7 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
Pencil,
|
Pencil,
|
||||||
PenTool,
|
PenTool,
|
||||||
|
Pin,
|
||||||
Plus,
|
Plus,
|
||||||
Redo2,
|
Redo2,
|
||||||
Save,
|
Save,
|
||||||
@ -36,13 +43,17 @@ import {
|
|||||||
Scissors,
|
Scissors,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Star,
|
||||||
Table2,
|
Table2,
|
||||||
|
Tag,
|
||||||
Trash2,
|
Trash2,
|
||||||
Type,
|
Type,
|
||||||
Undo2,
|
Undo2,
|
||||||
Upload,
|
Upload,
|
||||||
|
UserRound,
|
||||||
Video,
|
Video,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
Zap,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -146,6 +157,19 @@ import {
|
|||||||
import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config";
|
import { normalizeAgentPresetModeConfig } from "@/lib/agents/preset-mode-config";
|
||||||
import type { AgentPsdTemplateCategoryTagIndex } from "@/lib/agents/psd-template-category-tag-config";
|
import type { AgentPsdTemplateCategoryTagIndex } from "@/lib/agents/psd-template-category-tag-config";
|
||||||
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
|
import { normalizeAgentWorkflowBrandModelConfig } from "@/lib/agents/workflow-brand-model-config";
|
||||||
|
import {
|
||||||
|
compactVideoCanvasMarkers,
|
||||||
|
VIDEO_CANVAS_MARKER_COLORS,
|
||||||
|
VIDEO_CANVAS_MARKER_METADATA_KEY,
|
||||||
|
VIDEO_CANVAS_PRIORITY_MARKERS,
|
||||||
|
VIDEO_CANVAS_SYMBOL_MARKERS,
|
||||||
|
VIDEO_CANVAS_TASK_MARKERS,
|
||||||
|
normalizeVideoCanvasMarkers,
|
||||||
|
type VideoCanvasMarkerKey,
|
||||||
|
type VideoCanvasMarkerState,
|
||||||
|
type VideoCanvasMarkerValue,
|
||||||
|
type VideoCanvasSymbolMarkerId,
|
||||||
|
} from "@/lib/canvas/video-canvas-markers";
|
||||||
import {
|
import {
|
||||||
buildAgentWorkflowComposerContext,
|
buildAgentWorkflowComposerContext,
|
||||||
getAgentWorkflowProgress,
|
getAgentWorkflowProgress,
|
||||||
@ -293,6 +317,8 @@ const IMPORTABLE_CANVAS_TOOL_TYPES = new Set<ToolType>([
|
|||||||
"text",
|
"text",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_CANVAS_TEXT_COLOR = "#111827";
|
||||||
|
|
||||||
const VIDEO_CANVAS_INSERT_MENU_ITEMS: VideoCanvasInsertMenuItem[] = [
|
const VIDEO_CANVAS_INSERT_MENU_ITEMS: VideoCanvasInsertMenuItem[] = [
|
||||||
{ id: "image", label: "图片", icon: FileImage, templateType: "input-image" },
|
{ id: "image", label: "图片", icon: FileImage, templateType: "input-image" },
|
||||||
{ id: "text", label: "文字", icon: FileText, templateType: "text-node" },
|
{ id: "text", label: "文字", icon: FileText, templateType: "text-node" },
|
||||||
@ -792,6 +818,284 @@ const VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS =
|
|||||||
"border-[#d6def7] bg-[#eef2ff] text-[#3954db] shadow-[0_0_0_1px_rgba(91,118,254,0.14)] hover:bg-[#e7edff] dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900";
|
"border-[#d6def7] bg-[#eef2ff] text-[#3954db] shadow-[0_0_0_1px_rgba(91,118,254,0.14)] hover:bg-[#e7edff] dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-900";
|
||||||
const VIDEO_CANVAS_COMPACT_CONTROLS_CLASS =
|
const VIDEO_CANVAS_COMPACT_CONTROLS_CLASS =
|
||||||
"rounded-full border border-[#dbe2ee] bg-white px-2 py-1 shadow-[0_0_0_1px_rgba(219,226,238,0.36),0_12px_26px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950";
|
"rounded-full border border-[#dbe2ee] bg-white px-2 py-1 shadow-[0_0_0_1px_rgba(219,226,238,0.36),0_12px_26px_rgba(22,24,29,0.06)] dark:border-zinc-800 dark:bg-zinc-950";
|
||||||
|
const VIDEO_CANVAS_MARKER_PANEL_ICON_MAP: Record<
|
||||||
|
VideoCanvasSymbolMarkerId,
|
||||||
|
LucideIcon
|
||||||
|
> = {
|
||||||
|
idea: Lightbulb,
|
||||||
|
question: HelpCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
pin: Pin,
|
||||||
|
heart: Heart,
|
||||||
|
bolt: Zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface VideoCanvasMarkerPanelProps {
|
||||||
|
hasSelection: boolean;
|
||||||
|
markers: VideoCanvasMarkerState;
|
||||||
|
onApply: (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoCanvasMarkerPanel({
|
||||||
|
hasSelection,
|
||||||
|
markers,
|
||||||
|
onApply,
|
||||||
|
onClear,
|
||||||
|
onClose,
|
||||||
|
selectedLabel,
|
||||||
|
}: VideoCanvasMarkerPanelProps) {
|
||||||
|
const isActive = (key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) =>
|
||||||
|
(markers as Record<string, unknown>)[key] === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-[28px] border border-[#dbe2ee] bg-white text-[#16181d] shadow-[0_0_0_1px_rgba(219,226,238,0.38),0_28px_80px_rgba(22,24,29,0.14)] dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
|
||||||
|
<div className="flex items-center justify-between border-b border-[#eef1f7] px-4 py-3 dark:border-zinc-800">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-sm">标记</div>
|
||||||
|
<div className="mt-0.5 truncate text-[11px] text-[#8b94a6]">
|
||||||
|
{hasSelection ? selectedLabel : "先选中一个模块"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="h-8 w-8 rounded-full text-[#7a8497] hover:bg-[#f5f7fb]"
|
||||||
|
onClick={onClose}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-1 border-b border-[#eef1f7] p-2 dark:border-zinc-800">
|
||||||
|
{["标记", "贴纸", "插画"].map((tab) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded-[12px] text-[12px] font-medium transition-colors",
|
||||||
|
tab === "标记"
|
||||||
|
? "bg-[#16181d] text-white shadow-sm dark:bg-zinc-100 dark:text-zinc-950"
|
||||||
|
: "text-[#8b94a6] hover:bg-[#f7f9ff]"
|
||||||
|
)}
|
||||||
|
disabled={tab !== "标记"}
|
||||||
|
key={tab}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"space-y-5 p-4",
|
||||||
|
!hasSelection && "pointer-events-none opacity-45"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
标签
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||||||
|
<button
|
||||||
|
aria-label={`标签 ${entry.label}`}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||||
|
isActive("tagColor", entry.id) &&
|
||||||
|
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||||
|
)}
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => onApply("tagColor", entry.id)}
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
优先级
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{VIDEO_CANVAS_PRIORITY_MARKERS.map((entry) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-full border border-white font-semibold text-[13px] text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||||
|
isActive("priority", entry.value) &&
|
||||||
|
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||||
|
)}
|
||||||
|
key={entry.value}
|
||||||
|
onClick={() => onApply("priority", entry.value)}
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{entry.value}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
任务
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{VIDEO_CANVAS_TASK_MARKERS.map((entry) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 items-center justify-center rounded-full border border-white p-[3px] shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||||
|
isActive("task", entry.id) &&
|
||||||
|
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||||
|
)}
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => onApply("task", entry.id)}
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(#10b981 ${entry.progress * 3.6}deg, #dfe6f3 0deg)`,
|
||||||
|
}}
|
||||||
|
title={entry.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex h-full w-full items-center justify-center rounded-full bg-white text-[#10b981]">
|
||||||
|
{entry.progress >= 100 ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
旗帜
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||||
|
isActive("flagColor", entry.id) &&
|
||||||
|
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||||
|
)}
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => onApply("flagColor", entry.id)}
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
title={entry.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Flag className="h-4 w-4" fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
星星
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||||
|
isActive("starColor", entry.id) &&
|
||||||
|
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||||
|
)}
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => onApply("starColor", entry.id)}
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
title={entry.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Star className="h-4 w-4" fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
人像
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-2">
|
||||||
|
{VIDEO_CANVAS_MARKER_COLORS.map((entry) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-8 items-center justify-center rounded-full border border-white text-white shadow-[0_8px_18px_rgba(15,23,42,0.12)] transition-transform hover:scale-105",
|
||||||
|
isActive("avatarColor", entry.id) &&
|
||||||
|
"ring-2 ring-[#5b76fe] ring-offset-2"
|
||||||
|
)}
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => onApply("avatarColor", entry.id)}
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
title={entry.label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<UserRound className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold text-[#5f687a]">
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
符号
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{VIDEO_CANVAS_SYMBOL_MARKERS.map((entry) => {
|
||||||
|
const Icon = VIDEO_CANVAS_MARKER_PANEL_ICON_MAP[entry.id];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 rounded-[14px] border border-[#e5eaf4] bg-white px-3 py-2 text-left text-[12px] font-medium text-[#4a5365] shadow-[0_8px_18px_rgba(15,23,42,0.05)] transition-all hover:border-[#cfdcff] hover:bg-[#f7f9ff]",
|
||||||
|
isActive("symbol", entry.id) &&
|
||||||
|
"border-[#9aaeff] bg-[#f3f6ff] text-[#3954db] ring-2 ring-[#5b76fe]/20"
|
||||||
|
)}
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => onApply("symbol", entry.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-7 w-7 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: entry.soft, color: entry.color }}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
{entry.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div className="border-t border-[#eef1f7] p-3 dark:border-zinc-800">
|
||||||
|
<Button
|
||||||
|
className="h-10 w-full rounded-[16px] border border-[#e3e8f3] bg-white text-[#5f687a] shadow-none hover:bg-[#f7f9ff]"
|
||||||
|
disabled={!hasSelection}
|
||||||
|
onClick={onClear}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
清空当前模块标记
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") {
|
function buildScopedCanvasChatId(projectId: string, scope: "agent-mode") {
|
||||||
const normalized = String(projectId || "")
|
const normalized = String(projectId || "")
|
||||||
@ -1097,6 +1401,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
const addEdge = useCanvasStore((state) => state.addEdge);
|
const addEdge = useCanvasStore((state) => state.addEdge);
|
||||||
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
const selectNodes = useCanvasStore((state) => state.selectNodes);
|
||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||||
|
const pushHistory = useCanvasStore((state) => state.pushHistory);
|
||||||
const setGenerating = useCanvasStore((state) => state.setGenerating);
|
const setGenerating = useCanvasStore((state) => state.setGenerating);
|
||||||
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
|
const setPickerMode = useCanvasStore((state) => state.setPickerMode);
|
||||||
const requestImageNodeAction = useCanvasStore(
|
const requestImageNodeAction = useCanvasStore(
|
||||||
@ -1125,6 +1430,10 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
});
|
});
|
||||||
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
|
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
|
||||||
useState<VideoCanvasInsertMenuState | null>(null);
|
useState<VideoCanvasInsertMenuState | null>(null);
|
||||||
|
const [isVideoCanvasFileListOpen, setIsVideoCanvasFileListOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [isVideoCanvasMarkerPanelOpen, setIsVideoCanvasMarkerPanelOpen] =
|
||||||
|
useState(false);
|
||||||
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
|
||||||
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
||||||
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
|
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
|
||||||
@ -1150,6 +1459,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideoMode) {
|
if (!isVideoMode) {
|
||||||
setVideoCanvasInsertMenu(null);
|
setVideoCanvasInsertMenu(null);
|
||||||
|
setIsVideoCanvasFileListOpen(false);
|
||||||
|
setIsVideoCanvasMarkerPanelOpen(false);
|
||||||
}
|
}
|
||||||
}, [isVideoMode]);
|
}, [isVideoMode]);
|
||||||
|
|
||||||
@ -1360,6 +1671,26 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
: null,
|
: null,
|
||||||
[selectedVideoWorkflowType]
|
[selectedVideoWorkflowType]
|
||||||
);
|
);
|
||||||
|
const selectedVideoCanvasMarkers = useMemo(() => {
|
||||||
|
const metadata = (selectedVideoWorkflowNode?.data as
|
||||||
|
| { metadata?: unknown }
|
||||||
|
| undefined)?.metadata;
|
||||||
|
return normalizeVideoCanvasMarkers(
|
||||||
|
isPlainObject(metadata)
|
||||||
|
? metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}, [selectedVideoWorkflowNode]);
|
||||||
|
const selectedVideoCanvasMarkerLabel = useMemo(() => {
|
||||||
|
if (!selectedVideoWorkflowNode) return "未选择模块";
|
||||||
|
const data = selectedVideoWorkflowNode.data as {
|
||||||
|
content?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
};
|
||||||
|
const title = String(data.title || data.content || "").trim();
|
||||||
|
if (title) return title.slice(0, 36);
|
||||||
|
return selectedVideoWorkflowDefinition?.label || "当前模块";
|
||||||
|
}, [selectedVideoWorkflowDefinition?.label, selectedVideoWorkflowNode]);
|
||||||
const selectedVideoWorkflowQuickConnects = useMemo(
|
const selectedVideoWorkflowQuickConnects = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedVideoWorkflowType
|
selectedVideoWorkflowType
|
||||||
@ -1423,6 +1754,73 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
selectedVideoWorkflowDefinition?.label,
|
selectedVideoWorkflowDefinition?.label,
|
||||||
selectedVideoWorkflowNode,
|
selectedVideoWorkflowNode,
|
||||||
]);
|
]);
|
||||||
|
const handleApplyVideoCanvasMarker = useCallback(
|
||||||
|
(key: VideoCanvasMarkerKey, value: VideoCanvasMarkerValue) => {
|
||||||
|
if (!selectedVideoWorkflowNode) {
|
||||||
|
toast.info("请先选中一个模块");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentData = selectedVideoWorkflowNode.data;
|
||||||
|
const currentMetadata = (currentData as { metadata?: unknown }).metadata;
|
||||||
|
const metadata = isPlainObject(currentMetadata)
|
||||||
|
? { ...currentMetadata }
|
||||||
|
: {};
|
||||||
|
const currentMarkers = normalizeVideoCanvasMarkers(
|
||||||
|
metadata[VIDEO_CANVAS_MARKER_METADATA_KEY]
|
||||||
|
);
|
||||||
|
const nextMarkers: VideoCanvasMarkerState = { ...currentMarkers };
|
||||||
|
const nextMarkerRecord = nextMarkers as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (nextMarkerRecord[key] === value) {
|
||||||
|
delete nextMarkerRecord[key];
|
||||||
|
} else {
|
||||||
|
nextMarkerRecord[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactMarkers = compactVideoCanvasMarkers(nextMarkers);
|
||||||
|
if (Object.keys(compactMarkers).length > 0) {
|
||||||
|
metadata[VIDEO_CANVAS_MARKER_METADATA_KEY] = compactMarkers;
|
||||||
|
} else {
|
||||||
|
delete metadata[VIDEO_CANVAS_MARKER_METADATA_KEY];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNode(selectedVideoWorkflowNode.id, {
|
||||||
|
data: {
|
||||||
|
...currentData,
|
||||||
|
metadata,
|
||||||
|
} as CanvasNode["data"],
|
||||||
|
});
|
||||||
|
pushHistory();
|
||||||
|
},
|
||||||
|
[pushHistory, selectedVideoWorkflowNode, updateNode]
|
||||||
|
);
|
||||||
|
const handleClearVideoCanvasMarkers = useCallback(() => {
|
||||||
|
if (!selectedVideoWorkflowNode) {
|
||||||
|
toast.info("请先选中一个模块");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentData = selectedVideoWorkflowNode.data;
|
||||||
|
const currentMetadata = (currentData as { metadata?: unknown }).metadata;
|
||||||
|
const metadata = isPlainObject(currentMetadata)
|
||||||
|
? { ...currentMetadata }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (!(VIDEO_CANVAS_MARKER_METADATA_KEY in metadata)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete metadata[VIDEO_CANVAS_MARKER_METADATA_KEY];
|
||||||
|
updateNode(selectedVideoWorkflowNode.id, {
|
||||||
|
data: {
|
||||||
|
...currentData,
|
||||||
|
metadata,
|
||||||
|
} as CanvasNode["data"],
|
||||||
|
});
|
||||||
|
pushHistory();
|
||||||
|
toast.success("已清空当前模块标记");
|
||||||
|
}, [pushHistory, selectedVideoWorkflowNode, updateNode]);
|
||||||
const workflowGraph = useMemo(
|
const workflowGraph = useMemo(
|
||||||
() => createWorkflowGraphContext(nodes, edges),
|
() => createWorkflowGraphContext(nodes, edges),
|
||||||
[edges, nodes]
|
[edges, nodes]
|
||||||
@ -5613,6 +6011,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
||||||
size: { width: 200, height: 100 },
|
size: { width: 200, height: 100 },
|
||||||
data: {
|
data: {
|
||||||
|
color: DEFAULT_CANVAS_TEXT_COLOR,
|
||||||
content: "双击编辑文本",
|
content: "双击编辑文本",
|
||||||
},
|
},
|
||||||
connections: [],
|
connections: [],
|
||||||
@ -8898,6 +9297,51 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isVideoMode && isVideoCanvasFileListOpen ? (
|
||||||
|
<div
|
||||||
|
className="absolute z-30 overflow-hidden rounded-[28px] border border-border/70 bg-background/95 shadow-[0_24px_80px_rgba(15,23,42,0.18)] backdrop-blur-xl"
|
||||||
|
style={{
|
||||||
|
top: DOCKED_PANEL_INSET,
|
||||||
|
right: DOCKED_PANEL_INSET,
|
||||||
|
bottom: `${FLOATING_BOTTOM_INSET + 76}px`,
|
||||||
|
width: 380,
|
||||||
|
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET * 2}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CanvasFileListPanel
|
||||||
|
buildProjectUrl={buildWorkspaceProjectUrl}
|
||||||
|
createUrl={createNewCanvasUrl}
|
||||||
|
currentId={id}
|
||||||
|
onClose={() => setIsVideoCanvasFileListOpen(false)}
|
||||||
|
workspaceLabel={workspaceLabel}
|
||||||
|
workspaceType={activeCanvasWorkspaceType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isVideoMode && isVideoCanvasMarkerPanelOpen ? (
|
||||||
|
<div
|
||||||
|
className="absolute z-30"
|
||||||
|
data-canvas-overlay="true"
|
||||||
|
style={{
|
||||||
|
top: DOCKED_PANEL_INSET,
|
||||||
|
right: DOCKED_PANEL_INSET,
|
||||||
|
bottom: `${FLOATING_BOTTOM_INSET + 76}px`,
|
||||||
|
width: 324,
|
||||||
|
maxWidth: `calc(100vw - ${DOCKED_PANEL_INSET * 2}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VideoCanvasMarkerPanel
|
||||||
|
hasSelection={Boolean(selectedVideoWorkflowNode)}
|
||||||
|
markers={selectedVideoCanvasMarkers}
|
||||||
|
onApply={handleApplyVideoCanvasMarker}
|
||||||
|
onClear={handleClearVideoCanvasMarkers}
|
||||||
|
onClose={() => setIsVideoCanvasMarkerPanelOpen(false)}
|
||||||
|
selectedLabel={selectedVideoCanvasMarkerLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* 5. Floating Toolbar (Bottom) */}
|
{/* 5. Floating Toolbar (Bottom) */}
|
||||||
<div
|
<div
|
||||||
className="-translate-x-1/2 absolute left-1/2 z-20"
|
className="-translate-x-1/2 absolute left-1/2 z-20"
|
||||||
@ -8956,6 +9400,69 @@ export default function CanvasEditorPage({ params }: PageProps) {
|
|||||||
<TooltipContent side="top">平移 (H)</TooltipContent>
|
<TooltipContent side="top">平移 (H)</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{isVideoMode ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={handleSave}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">保存到云端</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isVideoMode ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className={VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS}
|
||||||
|
onClick={() => {
|
||||||
|
setIsVideoCanvasMarkerPanelOpen(false);
|
||||||
|
setIsVideoCanvasFileListOpen(true);
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">云文件列表</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isVideoMode ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
VIDEO_CANVAS_TOOLBAR_BUTTON_CLASS,
|
||||||
|
isVideoCanvasMarkerPanelOpen &&
|
||||||
|
VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setIsVideoCanvasFileListOpen(false);
|
||||||
|
setIsVideoCanvasMarkerPanelOpen((open) => !open);
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">标记</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isVideoMode ? (
|
{isVideoMode ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|||||||
@ -15,7 +15,145 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { imageGenerationService } from "@/services/canvas";
|
import { imageGenerationService } from "@/services/canvas";
|
||||||
import { Download, GitBranch, Minus, Plus } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Flag,
|
||||||
|
GitBranch,
|
||||||
|
Heart,
|
||||||
|
HelpCircle,
|
||||||
|
Lightbulb,
|
||||||
|
Minus,
|
||||||
|
Pin,
|
||||||
|
Plus,
|
||||||
|
Star,
|
||||||
|
Tag,
|
||||||
|
type LucideIcon,
|
||||||
|
UserRound,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
getVideoCanvasMarkerColor,
|
||||||
|
getVideoCanvasSymbolMarker,
|
||||||
|
getVideoCanvasTaskMarker,
|
||||||
|
normalizeVideoCanvasMarkers,
|
||||||
|
VIDEO_CANVAS_MARKER_METADATA_KEY,
|
||||||
|
VIDEO_CANVAS_PRIORITY_MARKERS,
|
||||||
|
} from "@/lib/canvas/video-canvas-markers";
|
||||||
|
|
||||||
|
const VIDEO_CANVAS_SYMBOL_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
idea: Lightbulb,
|
||||||
|
question: HelpCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
pin: Pin,
|
||||||
|
heart: Heart,
|
||||||
|
bolt: Zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getNodeVideoCanvasMarkers(node: CanvasNode) {
|
||||||
|
const metadata = (node.data as { metadata?: unknown } | undefined)?.metadata;
|
||||||
|
return normalizeVideoCanvasMarkers(
|
||||||
|
metadata && typeof metadata === "object" && !Array.isArray(metadata)
|
||||||
|
? (metadata as Record<string, unknown>)[VIDEO_CANVAS_MARKER_METADATA_KEY]
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoCanvasNodeMarkers({ node }: { node: CanvasNode }) {
|
||||||
|
const markers = getNodeVideoCanvasMarkers(node);
|
||||||
|
const tagColor = getVideoCanvasMarkerColor(markers.tagColor);
|
||||||
|
const flagColor = getVideoCanvasMarkerColor(markers.flagColor);
|
||||||
|
const starColor = getVideoCanvasMarkerColor(markers.starColor);
|
||||||
|
const avatarColor = getVideoCanvasMarkerColor(markers.avatarColor);
|
||||||
|
const task = getVideoCanvasTaskMarker(markers.task);
|
||||||
|
const priority = VIDEO_CANVAS_PRIORITY_MARKERS.find(
|
||||||
|
(entry) => entry.value === markers.priority
|
||||||
|
);
|
||||||
|
const symbol = getVideoCanvasSymbolMarker(markers.symbol);
|
||||||
|
const SymbolIcon = markers.symbol
|
||||||
|
? VIDEO_CANVAS_SYMBOL_ICON_MAP[markers.symbol]
|
||||||
|
: null;
|
||||||
|
const hasMarkers = Boolean(
|
||||||
|
tagColor || flagColor || starColor || avatarColor || task || priority || symbol
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasMarkers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute -top-4 left-4 z-30 flex items-center gap-1.5">
|
||||||
|
{tagColor ? (
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||||
|
style={{ backgroundColor: tagColor.color, color: "#ffffff" }}
|
||||||
|
title={`标签:${tagColor.label}`}
|
||||||
|
>
|
||||||
|
<Tag className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{priority ? (
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-white font-semibold text-[12px] text-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||||
|
style={{ backgroundColor: priority.color }}
|
||||||
|
title={`优先级:${priority.value}`}
|
||||||
|
>
|
||||||
|
{priority.value}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{task ? (
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-white p-[2px] shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(#10b981 ${task.progress * 3.6}deg, #dfe6f3 0deg)`,
|
||||||
|
}}
|
||||||
|
title={`任务:${task.label}`}
|
||||||
|
>
|
||||||
|
<span className="flex h-full w-full items-center justify-center rounded-full bg-white text-[#10b981]">
|
||||||
|
{task.progress >= 100 ? <Check className="h-3 w-3" /> : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{flagColor ? (
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||||
|
style={{ backgroundColor: flagColor.color, color: "#ffffff" }}
|
||||||
|
title={`旗帜:${flagColor.label}`}
|
||||||
|
>
|
||||||
|
<Flag className="h-3.5 w-3.5" fill="currentColor" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{starColor ? (
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||||
|
style={{ backgroundColor: starColor.color, color: "#ffffff" }}
|
||||||
|
title={`星标:${starColor.label}`}
|
||||||
|
>
|
||||||
|
<Star className="h-3.5 w-3.5" fill="currentColor" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{avatarColor ? (
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||||
|
style={{ backgroundColor: avatarColor.color, color: "#ffffff" }}
|
||||||
|
title={`人像:${avatarColor.label}`}
|
||||||
|
>
|
||||||
|
<UserRound className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{symbol && SymbolIcon ? (
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-white shadow-[0_8px_18px_rgba(15,23,42,0.14)]"
|
||||||
|
style={{ backgroundColor: symbol.soft, color: symbol.color }}
|
||||||
|
title={`符号:${symbol.label}`}
|
||||||
|
>
|
||||||
|
<SymbolIcon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 性能优化:判断节点是否在视口内
|
// 性能优化:判断节点是否在视口内
|
||||||
function isNodeInViewport(
|
function isNodeInViewport(
|
||||||
@ -505,36 +643,84 @@ export function CanvasWorkspace({
|
|||||||
return `M ${source.x} ${source.y} C ${source.x + controlOffset} ${source.y}, ${target.x - controlOffset} ${target.y}, ${target.x} ${target.y}`;
|
return `M ${source.x} ${source.y} C ${source.x + controlOffset} ${source.y}, ${target.x - controlOffset} ${target.y}, ${target.x} ${target.y}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const renderedEdges = useMemo(() => {
|
const { renderedEdges, renderedMergeTrunks } = useMemo(() => {
|
||||||
return edges
|
type RenderedWorkflowEdgeBase = {
|
||||||
|
edge: CanvasEdge;
|
||||||
|
sourcePoint: Position;
|
||||||
|
targetPoint: Position;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||||
|
const baseEdges = edges
|
||||||
.map((edge) => {
|
.map((edge) => {
|
||||||
const sourceNode = nodes.find((node) => node.id === edge.source);
|
const sourceNode = nodeById.get(edge.source);
|
||||||
const targetNode = nodes.find((node) => node.id === edge.target);
|
const targetNode = nodeById.get(edge.target);
|
||||||
if (!sourceNode || !targetNode) return null;
|
if (!sourceNode || !targetNode) return null;
|
||||||
const sourcePoint = toScreenPosition(getNodeOutputAnchor(sourceNode));
|
|
||||||
const targetPoint = toScreenPosition(getNodeInputAnchor(targetNode));
|
|
||||||
return {
|
return {
|
||||||
edge,
|
edge,
|
||||||
sourcePoint,
|
sourcePoint: toScreenPosition(getNodeOutputAnchor(sourceNode)),
|
||||||
targetPoint,
|
targetPoint: toScreenPosition(getNodeInputAnchor(targetNode)),
|
||||||
path: buildWorkflowPath(sourcePoint, targetPoint),
|
|
||||||
midPoint: {
|
|
||||||
x: (sourcePoint.x + targetPoint.x) / 2,
|
|
||||||
y: (sourcePoint.y + targetPoint.y) / 2,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(
|
.filter((item): item is RenderedWorkflowEdgeBase => Boolean(item));
|
||||||
(
|
|
||||||
item
|
const incomingEdges = new Map<string, RenderedWorkflowEdgeBase[]>();
|
||||||
): item is {
|
baseEdges.forEach((item) => {
|
||||||
edge: CanvasEdge;
|
const group = incomingEdges.get(item.edge.target) ?? [];
|
||||||
sourcePoint: Position;
|
group.push(item);
|
||||||
targetPoint: Position;
|
incomingEdges.set(item.edge.target, group);
|
||||||
path: string;
|
});
|
||||||
midPoint: Position;
|
|
||||||
} => Boolean(item)
|
const mergePointByTarget = new Map<string, Position>();
|
||||||
);
|
const renderedMergeTrunks: {
|
||||||
|
targetNodeId: string;
|
||||||
|
targetPoint: Position;
|
||||||
|
mergePoint: Position;
|
||||||
|
path: string;
|
||||||
|
edgeIds: string[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
incomingEdges.forEach((items, targetNodeId) => {
|
||||||
|
if (items.length <= 1) return;
|
||||||
|
|
||||||
|
const targetPoint = items[0].targetPoint;
|
||||||
|
const averageSourceX =
|
||||||
|
items.reduce((total, item) => total + item.sourcePoint.x, 0) / items.length;
|
||||||
|
const averageDeltaX =
|
||||||
|
items.reduce((total, item) => total + Math.abs(targetPoint.x - item.sourcePoint.x), 0) /
|
||||||
|
items.length;
|
||||||
|
const mergeDistance = Math.max(56, Math.min(180, averageDeltaX * 0.32));
|
||||||
|
const direction = averageSourceX <= targetPoint.x ? -1 : 1;
|
||||||
|
const mergePoint = {
|
||||||
|
x: targetPoint.x + direction * mergeDistance,
|
||||||
|
y: targetPoint.y,
|
||||||
|
};
|
||||||
|
|
||||||
|
mergePointByTarget.set(targetNodeId, mergePoint);
|
||||||
|
renderedMergeTrunks.push({
|
||||||
|
targetNodeId,
|
||||||
|
targetPoint,
|
||||||
|
mergePoint,
|
||||||
|
path: buildWorkflowPath(mergePoint, targetPoint),
|
||||||
|
edgeIds: items.map((item) => item.edge.id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderedEdges = baseEdges.map((item) => {
|
||||||
|
const mergePoint = mergePointByTarget.get(item.edge.target);
|
||||||
|
const visualTargetPoint = mergePoint ?? item.targetPoint;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
mergePoint,
|
||||||
|
path: buildWorkflowPath(item.sourcePoint, visualTargetPoint),
|
||||||
|
midPoint: {
|
||||||
|
x: (item.sourcePoint.x + visualTargetPoint.x) / 2,
|
||||||
|
y: (item.sourcePoint.y + visualTargetPoint.y) / 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { renderedEdges, renderedMergeTrunks };
|
||||||
}, [buildWorkflowPath, edges, getNodeInputAnchor, getNodeOutputAnchor, nodes, toScreenPosition]);
|
}, [buildWorkflowPath, edges, getNodeInputAnchor, getNodeOutputAnchor, nodes, toScreenPosition]);
|
||||||
|
|
||||||
// 处理画布点击(取消选择)
|
// 处理画布点击(取消选择)
|
||||||
@ -1300,6 +1486,7 @@ export function CanvasWorkspace({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isWorkflowCanvas ? <VideoCanvasNodeMarkers node={node} /> : null}
|
||||||
{node.type === 'image' && (
|
{node.type === 'image' && (
|
||||||
<ImageNode node={node} isSelected={isSelected} />
|
<ImageNode node={node} isSelected={isSelected} />
|
||||||
)}
|
)}
|
||||||
@ -1483,6 +1670,29 @@ export function CanvasWorkspace({
|
|||||||
|
|
||||||
{isWorkflowCanvas ? (
|
{isWorkflowCanvas ? (
|
||||||
<svg className="absolute inset-0 z-[1] h-full w-full overflow-visible">
|
<svg className="absolute inset-0 z-[1] h-full w-full overflow-visible">
|
||||||
|
{renderedMergeTrunks.map(({ targetNodeId, path, edgeIds }) => {
|
||||||
|
const isSelected = Boolean(selectedEdgeId && edgeIds.includes(selectedEdgeId));
|
||||||
|
return (
|
||||||
|
<g key={`merge-trunk-${targetNodeId}`} style={{ pointerEvents: "none" }}>
|
||||||
|
{isSelected ? (
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(59, 130, 246, 0.18)"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={10}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
stroke={isSelected ? WORKFLOW_EDGE_ACTIVE_COLOR : WORKFLOW_EDGE_COLOR}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={isSelected ? 4 : 3}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{renderedEdges.map(({ edge, path }) => {
|
{renderedEdges.map(({ edge, path }) => {
|
||||||
const isSelected = selectedEdgeId === edge.id;
|
const isSelected = selectedEdgeId === edge.id;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,24 +1,77 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import type { CanvasNode, TextNodeData } from "@/types/canvas";
|
import type { CanvasNode, TextNodeData } from "@/types/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface TextNodeProps {
|
interface TextNodeProps {
|
||||||
node: CanvasNode;
|
node: CanvasNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TEXT_NODE_COLOR = "#111827";
|
||||||
|
const PASTED_IMAGE_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
const PASTED_IMAGE_MAX_SIZE = 360;
|
||||||
|
const PASTED_IMAGE_MIN_SIZE = 140;
|
||||||
|
|
||||||
|
function getPastedImageDimensions(file: File): Promise<{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
originalWidth: number;
|
||||||
|
originalHeight: number;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
resolve({ width: 300, height: 240, originalWidth: 0, originalHeight: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const originalWidth = img.naturalWidth || 300;
|
||||||
|
const originalHeight = img.naturalHeight || 240;
|
||||||
|
let width = originalWidth;
|
||||||
|
let height = originalHeight;
|
||||||
|
|
||||||
|
if (width > PASTED_IMAGE_MAX_SIZE || height > PASTED_IMAGE_MAX_SIZE) {
|
||||||
|
const scale = Math.min(PASTED_IMAGE_MAX_SIZE / width, PASTED_IMAGE_MAX_SIZE / height);
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width < PASTED_IMAGE_MIN_SIZE && height < PASTED_IMAGE_MIN_SIZE) {
|
||||||
|
const scale = PASTED_IMAGE_MIN_SIZE / Math.max(1, Math.min(width, height));
|
||||||
|
width = Math.round(width * scale);
|
||||||
|
height = Math.round(height * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve({ width, height, originalWidth, originalHeight });
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve({ width: 300, height: 240, originalWidth: 0, originalHeight: 0 });
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function TextNode({ node, isSelected }: TextNodeProps) {
|
export function TextNode({ node, isSelected }: TextNodeProps) {
|
||||||
const data = node.data as TextNodeData;
|
const data = node.data as TextNodeData;
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editContent, setEditContent] = useState(data.content);
|
const [editContent, setEditContent] = useState(data.content);
|
||||||
|
const [isUploadingPastedImage, setIsUploadingPastedImage] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||||
|
const addNode = useCanvasStore((state) => state.addNode);
|
||||||
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
|
|
||||||
// 进入编辑模式时聚焦
|
// 进入编辑模式时聚焦
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -31,7 +84,11 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
|||||||
// 保存编辑
|
// 保存编辑
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
updateNode(node.id, {
|
updateNode(node.id, {
|
||||||
data: { ...data, content: editContent },
|
data: {
|
||||||
|
...data,
|
||||||
|
color: data.color || DEFAULT_TEXT_NODE_COLOR,
|
||||||
|
content: editContent,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
@ -48,6 +105,83 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePasteImages = useCallback(async (files: File[]) => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploadingPastedImage(true);
|
||||||
|
let addedCount = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let index = 0; index < files.length; index += 1) {
|
||||||
|
const file = files[index];
|
||||||
|
if (!file.type.startsWith("image/")) continue;
|
||||||
|
if (file.size > PASTED_IMAGE_MAX_BYTES) {
|
||||||
|
toast.error("图片大小不能超过 50MB");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.type.split("/")[1] || "png";
|
||||||
|
const fileName =
|
||||||
|
(file.name || "").trim() || `粘贴图片-${Date.now()}-${index + 1}.${ext}`;
|
||||||
|
const title = fileName.replace(/\.[^.]+$/, "") || "粘贴图片";
|
||||||
|
const dimensions = await getPastedImageDimensions(file);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchUploadWithRetry(formData);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("上传失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const imageUrl = typeof payload?.url === "string" ? payload.url : "";
|
||||||
|
if (!imageUrl) {
|
||||||
|
throw new Error("上传结果缺少图片地址");
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetId =
|
||||||
|
typeof payload?.assetId === "string" && payload.assetId.trim()
|
||||||
|
? payload.assetId.trim()
|
||||||
|
: undefined;
|
||||||
|
const newNodeId = addNode({
|
||||||
|
type: "image",
|
||||||
|
position: {
|
||||||
|
x: node.position.x + node.size.width + 32 + index * 24,
|
||||||
|
y: node.position.y + index * 24,
|
||||||
|
},
|
||||||
|
size: { width: dimensions.width, height: dimensions.height },
|
||||||
|
data: {
|
||||||
|
src: imageUrl,
|
||||||
|
assetId,
|
||||||
|
status: "completed",
|
||||||
|
title,
|
||||||
|
prompt: title,
|
||||||
|
metadata: {
|
||||||
|
source: "text-node-paste",
|
||||||
|
sourceTextNodeId: node.id,
|
||||||
|
originalWidth: dimensions.originalWidth,
|
||||||
|
originalHeight: dimensions.originalHeight,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
selectNode(newNodeId);
|
||||||
|
addedCount += 1;
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("图片上传失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
toast.success(addedCount === 1 ? "已粘贴图片到画布" : `已粘贴 ${addedCount} 张图片到画布`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsUploadingPastedImage(false);
|
||||||
|
}
|
||||||
|
}, [addNode, node.id, node.position.x, node.position.y, node.size.width, selectNode]);
|
||||||
|
|
||||||
const containerStyle = data.transparent
|
const containerStyle = data.transparent
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
@ -74,7 +208,7 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
|||||||
const textStyle = {
|
const textStyle = {
|
||||||
fontSize: data.fontSize || 14,
|
fontSize: data.fontSize || 14,
|
||||||
fontFamily: data.fontFamily || undefined,
|
fontFamily: data.fontFamily || undefined,
|
||||||
color: data.color || "inherit",
|
color: data.color || DEFAULT_TEXT_NODE_COLOR,
|
||||||
fontWeight: data.fontWeight || "normal",
|
fontWeight: data.fontWeight || "normal",
|
||||||
lineHeight: data.lineHeight || 1.35,
|
lineHeight: data.lineHeight || 1.35,
|
||||||
letterSpacing:
|
letterSpacing:
|
||||||
@ -100,6 +234,17 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
|||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={editContent}
|
value={editContent}
|
||||||
onChange={(e) => setEditContent(e.target.value)}
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const imageFiles = Array.from(e.clipboardData?.items || [])
|
||||||
|
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
.filter((file): file is File => Boolean(file));
|
||||||
|
|
||||||
|
if (imageFiles.length === 0) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
void handlePasteImages(imageFiles);
|
||||||
|
}}
|
||||||
onBlur={handleSave}
|
onBlur={handleSave}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
@ -120,6 +265,12 @@ export function TextNode({ node, isSelected }: TextNodeProps) {
|
|||||||
{data.content || '双击编辑文本'}
|
{data.content || '双击编辑文本'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isEditing && isUploadingPastedImage ? (
|
||||||
|
<div className="absolute bottom-2 left-2 rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-500 shadow-sm">
|
||||||
|
正在粘贴图片...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{!isEditing && isSelected ? (
|
{!isEditing && isSelected ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
177
lib/canvas/video-canvas-markers.ts
Normal file
177
lib/canvas/video-canvas-markers.ts
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
export const VIDEO_CANVAS_MARKER_METADATA_KEY = "videoCanvasMarkers";
|
||||||
|
|
||||||
|
export type VideoCanvasMarkerColorId =
|
||||||
|
| "red"
|
||||||
|
| "orange"
|
||||||
|
| "yellow"
|
||||||
|
| "green"
|
||||||
|
| "blue"
|
||||||
|
| "purple"
|
||||||
|
| "gray";
|
||||||
|
|
||||||
|
export type VideoCanvasTaskMarkerId =
|
||||||
|
| "todo"
|
||||||
|
| "quarter"
|
||||||
|
| "half"
|
||||||
|
| "mostly"
|
||||||
|
| "blocked"
|
||||||
|
| "done";
|
||||||
|
|
||||||
|
export type VideoCanvasSymbolMarkerId =
|
||||||
|
| "idea"
|
||||||
|
| "question"
|
||||||
|
| "warning"
|
||||||
|
| "pin"
|
||||||
|
| "heart"
|
||||||
|
| "bolt";
|
||||||
|
|
||||||
|
export type VideoCanvasMarkerKey =
|
||||||
|
| "tagColor"
|
||||||
|
| "priority"
|
||||||
|
| "task"
|
||||||
|
| "flagColor"
|
||||||
|
| "starColor"
|
||||||
|
| "avatarColor"
|
||||||
|
| "symbol";
|
||||||
|
|
||||||
|
export type VideoCanvasMarkerValue =
|
||||||
|
| VideoCanvasMarkerColorId
|
||||||
|
| VideoCanvasTaskMarkerId
|
||||||
|
| VideoCanvasSymbolMarkerId
|
||||||
|
| number;
|
||||||
|
|
||||||
|
export interface VideoCanvasMarkerState {
|
||||||
|
tagColor?: VideoCanvasMarkerColorId;
|
||||||
|
priority?: number;
|
||||||
|
task?: VideoCanvasTaskMarkerId;
|
||||||
|
flagColor?: VideoCanvasMarkerColorId;
|
||||||
|
starColor?: VideoCanvasMarkerColorId;
|
||||||
|
avatarColor?: VideoCanvasMarkerColorId;
|
||||||
|
symbol?: VideoCanvasSymbolMarkerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VIDEO_CANVAS_MARKER_COLORS: Array<{
|
||||||
|
id: VideoCanvasMarkerColorId;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
soft: string;
|
||||||
|
}> = [
|
||||||
|
{ id: "red", label: "红色", color: "#ef4444", soft: "#fee2e2" },
|
||||||
|
{ id: "orange", label: "橙色", color: "#f97316", soft: "#ffedd5" },
|
||||||
|
{ id: "yellow", label: "黄色", color: "#eab308", soft: "#fef9c3" },
|
||||||
|
{ id: "green", label: "绿色", color: "#10b981", soft: "#d1fae5" },
|
||||||
|
{ id: "blue", label: "蓝色", color: "#5b76fe", soft: "#e0e7ff" },
|
||||||
|
{ id: "purple", label: "紫色", color: "#7c3aed", soft: "#ede9fe" },
|
||||||
|
{ id: "gray", label: "灰色", color: "#9ca3af", soft: "#f3f4f6" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIDEO_CANVAS_PRIORITY_MARKERS = VIDEO_CANVAS_MARKER_COLORS.map(
|
||||||
|
(entry, index) => ({
|
||||||
|
...entry,
|
||||||
|
value: index + 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const VIDEO_CANVAS_TASK_MARKERS: Array<{
|
||||||
|
id: VideoCanvasTaskMarkerId;
|
||||||
|
label: string;
|
||||||
|
progress: number;
|
||||||
|
}> = [
|
||||||
|
{ id: "todo", label: "未开始", progress: 0 },
|
||||||
|
{ id: "quarter", label: "25%", progress: 25 },
|
||||||
|
{ id: "half", label: "50%", progress: 50 },
|
||||||
|
{ id: "mostly", label: "75%", progress: 75 },
|
||||||
|
{ id: "blocked", label: "暂停", progress: 88 },
|
||||||
|
{ id: "done", label: "完成", progress: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VIDEO_CANVAS_SYMBOL_MARKERS: Array<{
|
||||||
|
id: VideoCanvasSymbolMarkerId;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
soft: string;
|
||||||
|
}> = [
|
||||||
|
{ id: "idea", label: "想法", color: "#f59e0b", soft: "#fef3c7" },
|
||||||
|
{ id: "question", label: "疑问", color: "#3b82f6", soft: "#dbeafe" },
|
||||||
|
{ id: "warning", label: "注意", color: "#ef4444", soft: "#fee2e2" },
|
||||||
|
{ id: "pin", label: "固定", color: "#64748b", soft: "#f1f5f9" },
|
||||||
|
{ id: "heart", label: "喜欢", color: "#ec4899", soft: "#fce7f3" },
|
||||||
|
{ id: "bolt", label: "灵感", color: "#8b5cf6", soft: "#ede9fe" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARKER_COLOR_IDS = new Set(
|
||||||
|
VIDEO_CANVAS_MARKER_COLORS.map((entry) => entry.id)
|
||||||
|
);
|
||||||
|
const TASK_MARKER_IDS = new Set(
|
||||||
|
VIDEO_CANVAS_TASK_MARKERS.map((entry) => entry.id)
|
||||||
|
);
|
||||||
|
const SYMBOL_MARKER_IDS = new Set(
|
||||||
|
VIDEO_CANVAS_SYMBOL_MARKERS.map((entry) => entry.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMarkerColorId(value: unknown) {
|
||||||
|
const id = typeof value === "string" ? value.trim() : "";
|
||||||
|
return MARKER_COLOR_IDS.has(id as VideoCanvasMarkerColorId)
|
||||||
|
? (id as VideoCanvasMarkerColorId)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTaskMarkerId(value: unknown) {
|
||||||
|
const id = typeof value === "string" ? value.trim() : "";
|
||||||
|
return TASK_MARKER_IDS.has(id as VideoCanvasTaskMarkerId)
|
||||||
|
? (id as VideoCanvasTaskMarkerId)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSymbolMarkerId(value: unknown) {
|
||||||
|
const id = typeof value === "string" ? value.trim() : "";
|
||||||
|
return SYMBOL_MARKER_IDS.has(id as VideoCanvasSymbolMarkerId)
|
||||||
|
? (id as VideoCanvasSymbolMarkerId)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVideoCanvasMarkers(
|
||||||
|
value: unknown
|
||||||
|
): VideoCanvasMarkerState {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = Number(value.priority);
|
||||||
|
return {
|
||||||
|
tagColor: normalizeMarkerColorId(value.tagColor),
|
||||||
|
priority:
|
||||||
|
Number.isInteger(priority) && priority >= 1 && priority <= 7
|
||||||
|
? priority
|
||||||
|
: undefined,
|
||||||
|
task: normalizeTaskMarkerId(value.task),
|
||||||
|
flagColor: normalizeMarkerColorId(value.flagColor),
|
||||||
|
starColor: normalizeMarkerColorId(value.starColor),
|
||||||
|
avatarColor: normalizeMarkerColorId(value.avatarColor),
|
||||||
|
symbol: normalizeSymbolMarkerId(value.symbol),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compactVideoCanvasMarkers(
|
||||||
|
markers: VideoCanvasMarkerState
|
||||||
|
): VideoCanvasMarkerState {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(markers).filter(([, value]) => value !== undefined)
|
||||||
|
) as VideoCanvasMarkerState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoCanvasMarkerColor(id?: VideoCanvasMarkerColorId) {
|
||||||
|
return VIDEO_CANVAS_MARKER_COLORS.find((entry) => entry.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoCanvasTaskMarker(id?: VideoCanvasTaskMarkerId) {
|
||||||
|
return VIDEO_CANVAS_TASK_MARKERS.find((entry) => entry.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoCanvasSymbolMarker(id?: VideoCanvasSymbolMarkerId) {
|
||||||
|
return VIDEO_CANVAS_SYMBOL_MARKERS.find((entry) => entry.id === id) || null;
|
||||||
|
}
|
||||||
12
lib/db/migrations/0070_flow_board_cloud_files.sql
Normal file
12
lib/db/migrations/0070_flow_board_cloud_files.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE "FlowBoardDraft"
|
||||||
|
DROP CONSTRAINT IF EXISTS "FlowBoardDraft_userId_unique";
|
||||||
|
|
||||||
|
ALTER TABLE "FlowBoardDraft"
|
||||||
|
ADD COLUMN IF NOT EXISTS "title" varchar(256) DEFAULT '未命名流程白板' NOT NULL;
|
||||||
|
|
||||||
|
UPDATE "FlowBoardDraft"
|
||||||
|
SET "title" = '流程白板'
|
||||||
|
WHERE "title" = '未命名流程白板';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "FlowBoardDraft_user_title_idx"
|
||||||
|
ON "FlowBoardDraft"("userId", "title");
|
||||||
@ -393,6 +393,13 @@
|
|||||||
"when": 1775869200000,
|
"when": 1775869200000,
|
||||||
"tag": "0069_add_flow_board_drafts",
|
"tag": "0069_add_flow_board_drafts",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 56,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775908800000,
|
||||||
|
"tag": "0070_flow_board_cloud_files",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -907,6 +907,7 @@ export async function getFlowBoardDraftByUserId({
|
|||||||
.select()
|
.select()
|
||||||
.from(flowBoardDraft)
|
.from(flowBoardDraft)
|
||||||
.where(eq(flowBoardDraft.userId, userId))
|
.where(eq(flowBoardDraft.userId, userId))
|
||||||
|
.orderBy(desc(flowBoardDraft.updatedAt))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return result ?? null;
|
return result ?? null;
|
||||||
@ -918,8 +919,53 @@ export async function getFlowBoardDraftByUserId({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertFlowBoardDraftByUserId(params: {
|
export async function listFlowBoardDraftsByUserId({
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
}): Promise<FlowBoardDraft[]> {
|
||||||
|
try {
|
||||||
|
return await db
|
||||||
|
.select()
|
||||||
|
.from(flowBoardDraft)
|
||||||
|
.where(eq(flowBoardDraft.userId, userId))
|
||||||
|
.orderBy(desc(flowBoardDraft.updatedAt));
|
||||||
|
} catch (_error) {
|
||||||
|
throw new ChatSDKError(
|
||||||
|
"bad_request:database",
|
||||||
|
"Failed to list flow board drafts"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFlowBoardDraftById(params: {
|
||||||
|
userId: string;
|
||||||
|
draftId: string;
|
||||||
|
}): Promise<FlowBoardDraft | null> {
|
||||||
|
try {
|
||||||
|
const [result] = await db
|
||||||
|
.select()
|
||||||
|
.from(flowBoardDraft)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(flowBoardDraft.id, params.draftId),
|
||||||
|
eq(flowBoardDraft.userId, params.userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return result ?? null;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new ChatSDKError(
|
||||||
|
"bad_request:database",
|
||||||
|
"Failed to get flow board draft by id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFlowBoardDraft(params: {
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
snapshot: FlowBoardSnapshotData;
|
snapshot: FlowBoardSnapshotData;
|
||||||
}): Promise<FlowBoardDraft | null> {
|
}): Promise<FlowBoardDraft | null> {
|
||||||
try {
|
try {
|
||||||
@ -928,20 +974,104 @@ export async function upsertFlowBoardDraftByUserId(params: {
|
|||||||
.insert(flowBoardDraft)
|
.insert(flowBoardDraft)
|
||||||
.values({
|
.values({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
|
title: params.title,
|
||||||
snapshot: params.snapshot,
|
snapshot: params.snapshot,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: flowBoardDraft.userId,
|
|
||||||
set: {
|
|
||||||
snapshot: params.snapshot,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return result ?? null;
|
return result ?? null;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new ChatSDKError(
|
||||||
|
"bad_request:database",
|
||||||
|
"Failed to create flow board draft"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFlowBoardDraftById(params: {
|
||||||
|
userId: string;
|
||||||
|
draftId: string;
|
||||||
|
title?: string;
|
||||||
|
snapshot?: FlowBoardSnapshotData;
|
||||||
|
}): Promise<FlowBoardDraft | null> {
|
||||||
|
try {
|
||||||
|
const updateData: Partial<typeof flowBoardDraft.$inferInsert> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (params.title !== undefined) {
|
||||||
|
updateData.title = params.title;
|
||||||
|
}
|
||||||
|
if (params.snapshot !== undefined) {
|
||||||
|
updateData.snapshot = params.snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await db
|
||||||
|
.update(flowBoardDraft)
|
||||||
|
.set(updateData)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(flowBoardDraft.id, params.draftId),
|
||||||
|
eq(flowBoardDraft.userId, params.userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result ?? null;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new ChatSDKError(
|
||||||
|
"bad_request:database",
|
||||||
|
"Failed to update flow board draft"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFlowBoardDraftById(params: {
|
||||||
|
userId: string;
|
||||||
|
draftId: string;
|
||||||
|
}): Promise<FlowBoardDraft | null> {
|
||||||
|
try {
|
||||||
|
const [result] = await db
|
||||||
|
.delete(flowBoardDraft)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(flowBoardDraft.id, params.draftId),
|
||||||
|
eq(flowBoardDraft.userId, params.userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result ?? null;
|
||||||
|
} catch (_error) {
|
||||||
|
throw new ChatSDKError(
|
||||||
|
"bad_request:database",
|
||||||
|
"Failed to delete flow board draft"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertFlowBoardDraftByUserId(params: {
|
||||||
|
userId: string;
|
||||||
|
snapshot: FlowBoardSnapshotData;
|
||||||
|
}): Promise<FlowBoardDraft | null> {
|
||||||
|
try {
|
||||||
|
const existing = await getFlowBoardDraftByUserId({
|
||||||
|
userId: params.userId,
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return await updateFlowBoardDraftById({
|
||||||
|
userId: params.userId,
|
||||||
|
draftId: existing.id,
|
||||||
|
snapshot: params.snapshot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await createFlowBoardDraft({
|
||||||
|
userId: params.userId,
|
||||||
|
title: "流程白板",
|
||||||
|
snapshot: params.snapshot,
|
||||||
|
});
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
throw new ChatSDKError(
|
throw new ChatSDKError(
|
||||||
"bad_request:database",
|
"bad_request:database",
|
||||||
|
|||||||
@ -964,8 +964,10 @@ export const flowBoardDraft = pgTable("FlowBoardDraft", {
|
|||||||
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
id: uuid("id").primaryKey().notNull().defaultRandom(),
|
||||||
userId: uuid("userId")
|
userId: uuid("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" })
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
.unique(),
|
title: varchar("title", { length: 256 })
|
||||||
|
.notNull()
|
||||||
|
.default("未命名流程白板"),
|
||||||
snapshot: jsonb("snapshot")
|
snapshot: jsonb("snapshot")
|
||||||
.$type<FlowBoardSnapshotData>()
|
.$type<FlowBoardSnapshotData>()
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|||||||
349
lib/flow-board/mind-map-import.ts
Normal file
349
lib/flow-board/mind-map-import.ts
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
import type { PlaitElement, Point } from "@plait/core";
|
||||||
|
import { MindLayoutType } from "@plait/layouts";
|
||||||
|
import { createMindElement, type MindElement, type PlaitMind } from "@plait/mind";
|
||||||
|
|
||||||
|
export const FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT =
|
||||||
|
".md,.markdown,.mdown,.mkd,.opml,.mm,.xmind,text/markdown,text/plain,text/xml,application/xml";
|
||||||
|
|
||||||
|
type MindMapImportFormat = "markdown" | "opml" | "freemind" | "xmind";
|
||||||
|
|
||||||
|
type OutlineNode = {
|
||||||
|
text: string;
|
||||||
|
children: OutlineNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeText(value: string | null | undefined) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMindMapImportTitle(fileName: string) {
|
||||||
|
const title = fileName
|
||||||
|
.replace(/\.(md|markdown|mdown|mkd|opml|mm|xmind)$/i, "")
|
||||||
|
.trim();
|
||||||
|
return title || "导入思维导图";
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectMindMapImportFormat(file: File): MindMapImportFormat | null {
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
if (
|
||||||
|
fileName.endsWith(".md") ||
|
||||||
|
fileName.endsWith(".markdown") ||
|
||||||
|
fileName.endsWith(".mdown") ||
|
||||||
|
fileName.endsWith(".mkd")
|
||||||
|
) {
|
||||||
|
return "markdown";
|
||||||
|
}
|
||||||
|
if (fileName.endsWith(".opml")) {
|
||||||
|
return "opml";
|
||||||
|
}
|
||||||
|
if (fileName.endsWith(".mm")) {
|
||||||
|
return "freemind";
|
||||||
|
}
|
||||||
|
if (fileName.endsWith(".xmind")) {
|
||||||
|
return "xmind";
|
||||||
|
}
|
||||||
|
if (file.type === "text/markdown" || file.type === "text/plain") {
|
||||||
|
return "markdown";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedMindMapImportFile(file: File) {
|
||||||
|
return Boolean(detectMindMapImportFormat(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMarkdownHeading(markdownText: string) {
|
||||||
|
return /^\s{0,3}#{1,6}\s+\S+/m.test(markdownText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXmlDocument(xmlText: string, formatLabel: string) {
|
||||||
|
if (typeof DOMParser === "undefined") {
|
||||||
|
throw new Error(`${formatLabel} import requires DOMParser`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = new DOMParser().parseFromString(xmlText, "application/xml");
|
||||||
|
if (document.getElementsByTagName("parsererror").length > 0) {
|
||||||
|
throw new Error(`${formatLabel} XML parse failed`);
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectChildrenByTag(element: Element, tagName: string) {
|
||||||
|
const normalizedTagName = tagName.toLowerCase();
|
||||||
|
return Array.from(element.children).filter(
|
||||||
|
(child) => child.tagName.toLowerCase() === normalizedTagName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectChildrenByLocalName(element: Element, localName: string) {
|
||||||
|
const normalizedLocalName = localName.toLowerCase();
|
||||||
|
return Array.from(element.children).filter(
|
||||||
|
(child) =>
|
||||||
|
(child.localName || child.tagName).toLowerCase() === normalizedLocalName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMindFromOutline(root: OutlineNode): PlaitMind {
|
||||||
|
const build = (node: OutlineNode): MindElement => {
|
||||||
|
const element = createMindElement(node.text || "未命名节点", {});
|
||||||
|
element.children = node.children.map(build);
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mind = createMindElement(root.text || "中心主题", {
|
||||||
|
layout: MindLayoutType.right,
|
||||||
|
}) as PlaitMind;
|
||||||
|
mind.isRoot = true;
|
||||||
|
mind.type = "mindmap";
|
||||||
|
mind.points = [[0, 0]] as Point[];
|
||||||
|
mind.children = root.children.map(build);
|
||||||
|
return mind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpmlOutlineNode(element: Element): OutlineNode {
|
||||||
|
const text =
|
||||||
|
normalizeText(element.getAttribute("text")) ||
|
||||||
|
normalizeText(element.getAttribute("title")) ||
|
||||||
|
"未命名节点";
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
children: getDirectChildrenByTag(element, "outline").map(parseOpmlOutlineNode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOpmlToMind(xmlText: string, fallbackTitle: string): PlaitMind {
|
||||||
|
const document = parseXmlDocument(xmlText, "OPML");
|
||||||
|
const body = document.getElementsByTagName("body")[0];
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("OPML body not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headTitle = normalizeText(document.getElementsByTagName("title")[0]?.textContent);
|
||||||
|
const topOutlines = getDirectChildrenByTag(body, "outline").map(parseOpmlOutlineNode);
|
||||||
|
if (topOutlines.length === 0) {
|
||||||
|
throw new Error("OPML outline not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root =
|
||||||
|
topOutlines.length === 1
|
||||||
|
? topOutlines[0]
|
||||||
|
: {
|
||||||
|
text: headTitle || fallbackTitle || "OPML 思维导图",
|
||||||
|
children: topOutlines,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createMindFromOutline(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreeMindNodeText(element: Element) {
|
||||||
|
const attrText = normalizeText(element.getAttribute("TEXT"));
|
||||||
|
if (attrText) {
|
||||||
|
return attrText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const richContent = getDirectChildrenByTag(element, "richcontent").find(
|
||||||
|
(child) => (child.getAttribute("TYPE") || "").toUpperCase() === "NODE"
|
||||||
|
);
|
||||||
|
return normalizeText(richContent?.textContent) || "未命名节点";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFreeMindNode(element: Element): OutlineNode {
|
||||||
|
return {
|
||||||
|
text: getFreeMindNodeText(element),
|
||||||
|
children: getDirectChildrenByTag(element, "node").map(parseFreeMindNode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFreeMindToMind(xmlText: string): PlaitMind {
|
||||||
|
const document = parseXmlDocument(xmlText, "FreeMind");
|
||||||
|
const rootElement = document.documentElement;
|
||||||
|
const rootNode =
|
||||||
|
rootElement.tagName.toLowerCase() === "map"
|
||||||
|
? getDirectChildrenByTag(rootElement, "node")[0]
|
||||||
|
: rootElement.tagName.toLowerCase() === "node"
|
||||||
|
? rootElement
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!rootNode) {
|
||||||
|
throw new Error("FreeMind root node not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMindFromOutline(parseFreeMindNode(rootNode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXmindTopicTitle(topic: Record<string, unknown>) {
|
||||||
|
const title = normalizeText(String(topic.title || topic.text || ""));
|
||||||
|
return title || "未命名节点";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXmindJsonChildTopics(topic: Record<string, unknown>) {
|
||||||
|
const children = topic.children;
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
return children.filter(isRecord);
|
||||||
|
}
|
||||||
|
if (!isRecord(children)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(children).flatMap((value) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter(isRecord);
|
||||||
|
}
|
||||||
|
if (isRecord(value)) {
|
||||||
|
return [value];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXmindJsonTopic(topic: Record<string, unknown>): OutlineNode {
|
||||||
|
return {
|
||||||
|
text: getXmindTopicTitle(topic),
|
||||||
|
children: getXmindJsonChildTopics(topic).map(parseXmindJsonTopic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXmindJsonToMind(jsonText: string, fallbackTitle: string) {
|
||||||
|
const parsed = JSON.parse(jsonText) as unknown;
|
||||||
|
const sheets = Array.isArray(parsed) ? parsed.filter(isRecord) : [];
|
||||||
|
const sheetRoots = sheets
|
||||||
|
.map((sheet) => {
|
||||||
|
const rootTopic = sheet.rootTopic;
|
||||||
|
return isRecord(rootTopic) ? parseXmindJsonTopic(rootTopic) : null;
|
||||||
|
})
|
||||||
|
.filter((sheet): sheet is OutlineNode => Boolean(sheet));
|
||||||
|
|
||||||
|
if (sheetRoots.length === 0) {
|
||||||
|
throw new Error("XMind content.json rootTopic not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const root =
|
||||||
|
sheetRoots.length === 1
|
||||||
|
? sheetRoots[0]
|
||||||
|
: {
|
||||||
|
text: fallbackTitle || "XMind 思维导图",
|
||||||
|
children: sheetRoots,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createMindFromOutline(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectXmindXmlTitle(element: Element) {
|
||||||
|
return (
|
||||||
|
normalizeText(getDirectChildrenByLocalName(element, "title")[0]?.textContent) ||
|
||||||
|
normalizeText(element.getAttribute("title")) ||
|
||||||
|
normalizeText(element.getAttribute("TEXT")) ||
|
||||||
|
"未命名节点"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXmindXmlTopicChildren(element: Element) {
|
||||||
|
const childrenContainers = getDirectChildrenByLocalName(element, "children");
|
||||||
|
return childrenContainers.flatMap((childrenContainer) =>
|
||||||
|
getDirectChildrenByLocalName(childrenContainer, "topics").flatMap((topics) =>
|
||||||
|
getDirectChildrenByLocalName(topics, "topic")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXmindXmlTopic(element: Element): OutlineNode {
|
||||||
|
return {
|
||||||
|
text: getDirectXmindXmlTitle(element),
|
||||||
|
children: getXmindXmlTopicChildren(element).map(parseXmindXmlTopic),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXmindXmlToMind(xmlText: string, fallbackTitle: string) {
|
||||||
|
const document = parseXmlDocument(xmlText, "XMind");
|
||||||
|
const sheets = Array.from(document.getElementsByTagName("*")).filter(
|
||||||
|
(element) => (element.localName || element.tagName).toLowerCase() === "sheet"
|
||||||
|
);
|
||||||
|
const sheetRoots = sheets
|
||||||
|
.map((sheet) => getDirectChildrenByLocalName(sheet, "topic")[0])
|
||||||
|
.filter((topic): topic is Element => Boolean(topic))
|
||||||
|
.map(parseXmindXmlTopic);
|
||||||
|
|
||||||
|
if (sheetRoots.length === 0) {
|
||||||
|
const rootTopic = Array.from(document.getElementsByTagName("*")).find(
|
||||||
|
(element) => (element.localName || element.tagName).toLowerCase() === "topic"
|
||||||
|
);
|
||||||
|
if (!rootTopic) {
|
||||||
|
throw new Error("XMind content.xml topic not found");
|
||||||
|
}
|
||||||
|
return createMindFromOutline(parseXmindXmlTopic(rootTopic));
|
||||||
|
}
|
||||||
|
|
||||||
|
const root =
|
||||||
|
sheetRoots.length === 1
|
||||||
|
? sheetRoots[0]
|
||||||
|
: {
|
||||||
|
text: fallbackTitle || "XMind 思维导图",
|
||||||
|
children: sheetRoots,
|
||||||
|
};
|
||||||
|
|
||||||
|
return createMindFromOutline(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseXmindToMind(file: File, fallbackTitle: string) {
|
||||||
|
const { default: JSZip } = await import("jszip");
|
||||||
|
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||||
|
const contentJsonFile =
|
||||||
|
zip.file("content.json") ||
|
||||||
|
zip.file(/(^|\/)content\.json$/i).find((entry) => !entry.dir);
|
||||||
|
if (contentJsonFile) {
|
||||||
|
return parseXmindJsonToMind(await contentJsonFile.async("string"), fallbackTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentXmlFile =
|
||||||
|
zip.file("content.xml") ||
|
||||||
|
zip.file(/(^|\/)content\.xml$/i).find((entry) => !entry.dir);
|
||||||
|
if (contentXmlFile) {
|
||||||
|
return parseXmindXmlToMind(await contentXmlFile.async("string"), fallbackTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("XMind content file not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseMindMapFileToDrawnixMind(
|
||||||
|
file: File,
|
||||||
|
fallbackTitle = getMindMapImportTitle(file.name)
|
||||||
|
): Promise<PlaitElement & { points?: Point[] }> {
|
||||||
|
const format = detectMindMapImportFormat(file);
|
||||||
|
if (!format) {
|
||||||
|
throw new Error("Unsupported mind map import file");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "xmind") {
|
||||||
|
return parseXmindToMind(file, fallbackTitle) as Promise<
|
||||||
|
PlaitElement & { points?: Point[] }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileText = await file.text();
|
||||||
|
if (!fileText.trim()) {
|
||||||
|
throw new Error("Mind map import file is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "markdown") {
|
||||||
|
const { parseMarkdownToDrawnix } = await import(
|
||||||
|
"@plait-board/markdown-to-drawnix"
|
||||||
|
);
|
||||||
|
return parseMarkdownToDrawnix(
|
||||||
|
fileText,
|
||||||
|
hasMarkdownHeading(fileText) ? undefined : fallbackTitle
|
||||||
|
) as unknown as PlaitElement & { points?: Point[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === "opml") {
|
||||||
|
return parseOpmlToMind(fileText, fallbackTitle) as PlaitElement & {
|
||||||
|
points?: Point[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseFreeMindToMind(fileText) as PlaitElement & { points?: Point[] };
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import type { CanvasNode, NodeType, Position } from "@/types/canvas";
|
import type { CanvasNode, NodeType, Position } from "@/types/canvas";
|
||||||
|
|
||||||
|
const DEFAULT_VIDEO_WORKFLOW_TEXT_COLOR = "#111827";
|
||||||
|
|
||||||
export type VideoWorkflowSectionId =
|
export type VideoWorkflowSectionId =
|
||||||
| "input"
|
| "input"
|
||||||
| "analysis"
|
| "analysis"
|
||||||
@ -651,6 +653,7 @@ export function buildVideoWorkflowCanvasNode(
|
|||||||
? "在这里粘贴长篇剧情、人物关系、世界观设定或章节梗概"
|
? "在这里粘贴长篇剧情、人物关系、世界观设定或章节梗概"
|
||||||
: "输入脚本、镜头、画面意图或视频需求",
|
: "输入脚本、镜头、画面意图或视频需求",
|
||||||
fontSize: isNovelInput ? 16 : 15,
|
fontSize: isNovelInput ? 16 : 15,
|
||||||
|
color: DEFAULT_VIDEO_WORKFLOW_TEXT_COLOR,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "#d4d4d8",
|
borderColor: "#d4d4d8",
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
"@langchain/langgraph": "^1.1.3",
|
"@langchain/langgraph": "^1.1.3",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/api-logs": "^0.200.0",
|
"@opentelemetry/api-logs": "^0.200.0",
|
||||||
|
"@plait-board/markdown-to-drawnix": "0.0.7",
|
||||||
"@plait-board/react-board": "0.4.0-2",
|
"@plait-board/react-board": "0.4.0-2",
|
||||||
"@plait-board/react-text": "0.4.0-2",
|
"@plait-board/react-text": "0.4.0-2",
|
||||||
"@plait/common": "^0.92.1",
|
"@plait/common": "^0.92.1",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -65,6 +65,9 @@ importers:
|
|||||||
'@opentelemetry/api-logs':
|
'@opentelemetry/api-logs':
|
||||||
specifier: ^0.200.0
|
specifier: ^0.200.0
|
||||||
version: 0.200.0
|
version: 0.200.0
|
||||||
|
'@plait-board/markdown-to-drawnix':
|
||||||
|
specifier: 0.0.7
|
||||||
|
version: 0.0.7
|
||||||
'@plait-board/react-board':
|
'@plait-board/react-board':
|
||||||
specifier: 0.4.0-2
|
specifier: 0.4.0-2
|
||||||
version: 0.4.0-2(immer@11.1.3)(is-hotkey@0.2.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
version: 0.4.0-2(immer@11.1.3)(is-hotkey@0.2.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user