feat: update canvas and flow board features
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-11 22:28:18 +08:00
parent d88c28356a
commit e054dcabcc
19 changed files with 4215 additions and 670 deletions

View 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,
};
}

View File

@ -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,
},
}); });
} }

View 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),
});
}

View 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),
});
}

View File

@ -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>

View File

@ -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 (

View File

@ -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

View 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;
}

View 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");

View File

@ -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
} }
] ]
} }

View File

@ -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",

View File

@ -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()

View 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[] };
}

View File

@ -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",

View File

@ -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
View File

@ -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