Compare commits

...

10 Commits

Author SHA1 Message Date
134
50569ca88a feat(canvas): deliver image previews before background archiving and remove generation caps 2026-10-12 08:03:58 +08:00
134
c3f5903a86 feat(flow-board): add rich notes, Gantt tasks and PNG export
Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
2026-10-06 18:06:29 +08:00
134
9611fafe0b feat(flow-board): add native XMind markers and bulk editing 2026-10-05 11:49:36 +08:00
134
9f98a68049 fix(flow-board): support image paste into mind map topics
Handle clipboard images while editing or selecting a topic and route Insert image through the same topic attachment behavior. Read image data locally in preview mode and retain the production upload path.

Add four browser regression cases for real clipboard paste, undo/redo, export resources, canvas images, and text input isolation. Record validation in the client comparison notes.
2026-10-05 10:56:37 +08:00
134
822c4cbfb9 fix(flow-board): align Xmind editor UI and restore marquee feedback
Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support.

Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
2026-10-04 18:57:02 +08:00
134
a492d082ed feat: recreate XMind editing and file workflows 2026-10-04 18:56:53 +08:00
134
4ada7eaf38 fix: polish canvas icons, context menu and panel layout 2026-10-04 12:13:47 +08:00
134
63e2f48192 fix: restore canvas skills and local uploads, simplify home and canvas headers 2026-10-04 10:31:53 +08:00
134
d376f5959f feat: refresh home and canvas UI with customizable image toolbar 2026-10-04 03:41:03 +08:00
134
2c8f306e74 docs(tvstar): record live production evidence and remaining gaps 2026-10-03 17:58:58 +08:00
445 changed files with 22298 additions and 2582 deletions

View File

@ -153,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues
# -----------------------------------------------------------------------------
CANVAS_IMAGE_TASK_CONCURRENCY=3
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
CANVAS_IMAGE_TASK_CONCURRENCY=0
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

View File

@ -1078,139 +1078,15 @@ export function LocalAiRuntimeSections({
</Button>
</LocalAiSectionShell>
{/* 全站生图单模型并发 */}
<LocalAiSectionShell
description={
<>
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
</>
}
description="生图任务直接发起,图片先显示,后台转存到存储桶。"
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
id="local-ai-section-canvas-image-task-queue"
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
summary={
<LocalAiSectionSummary
items={[
{
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
tone: "neutral",
},
{
label: `${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒补位`,
tone: "neutral",
},
{ label: "全站生图生效", tone: "enabled" },
]}
/>
}
title={
<span className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
全站生图单模型并发与批次间隔
</span>
}
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
<div className="font-medium">生效范围</div>
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
<p>
•
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
</p>
<p>
•
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
</p>
<p>
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
</p>
<p>
•
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
</p>
<p>
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
池继续细分。
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="canvasImageTaskQueueConcurrency">
单模型最大同时运行任务数
</Label>
<Input
id="canvasImageTaskQueueConcurrency"
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
concurrency: e.target.value,
})
)
}
type="number"
value={canvasImageTaskQueueConfig.concurrency}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
下一批排队任务发起间隔(秒)
</Label>
<Input
id="canvasImageTaskBatchDispatchIntervalSeconds"
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
onChange={(e) =>
setCanvasImageTaskQueueConfig(
normalizeCanvasImageTaskQueueConfig({
...canvasImageTaskQueueConfig,
batchDispatchIntervalMs: Math.round(
Number.parseFloat(e.target.value || "0") * 1000
),
})
)
}
step="0.5"
type="number"
value={String(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
)}
/>
<div className="text-muted-foreground text-xs">
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
{(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)}{" "}
秒,默认值为{" "}
{(
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
).toFixed(1)}{" "}
秒。
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
</Button>
</div>
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
</LocalAiSectionShell>
{/* 批量处理配置 */}

View File

@ -8906,9 +8906,7 @@ export function AdminSettingsClient() {
{
key: "realtime-canvas",
title: "实时与队列",
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${(
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
).toFixed(1)} 秒`,
description: "生图并发不限,任务即时发起",
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
icon: SlidersHorizontal,

View File

@ -3,10 +3,5 @@ export default function FlowBoardLayout({
}: {
children: React.ReactNode;
}) {
return (
<>
<link href="/vendor/drawnix.css" rel="stylesheet" />
{children}
</>
);
return <>{children}</>;
}

View File

@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { and, eq, inArray, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { canvasProject, imageArchiveTask, generatedImage, canvasImageTask } from "@/lib/db/schema";
import { imageArchiveSourceKey } from "@/lib/storage/image-archive-db";
import { isGeneratedImageSource } from "@/lib/storage/image-archive-urls";
import { scheduleImageArchiveWorker } from "@/lib/storage/image-archive-worker";
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
const { id } = await params;
const [project] = await db
.select({ id: canvasProject.id })
.from(canvasProject)
.where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
.limit(1);
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
const body = await request.json();
if (
!Array.isArray(body.urls) ||
body.urls.length > 100 ||
body.urls.some((url: unknown) => typeof url !== "string" || !isGeneratedImageSource(url))
) {
return NextResponse.json({ error: "图片地址格式错误" }, { status: 400 });
}
const urls: string[] = [...new Set<string>(body.urls)];
if (!urls.length) return NextResponse.json({ replacements: {}, pending: [] });
const source = imageArchiveTask.sourceUrl;
const rows = await db
.select({
sourceUrl: imageArchiveTask.sourceUrl,
targetUrl: imageArchiveTask.targetUrl,
status: imageArchiveTask.status,
})
.from(imageArchiveTask)
.where(
and(
inArray(imageArchiveTask.sourceUrl, urls.map(imageArchiveSourceKey)),
sql`(
exists (select 1 from ${generatedImage} where ${generatedImage.userId} = ${session.user.id}
and (${generatedImage.originalUrl} = ${source} or ${generatedImage.thumbnailUrl} = ${source}
or ${generatedImage.originalUrl} = ${imageArchiveTask.targetUrl} or ${generatedImage.thumbnailUrl} = ${imageArchiveTask.targetUrl}))
or exists (select 1 from ${canvasImageTask} where ${canvasImageTask.userId} = ${session.user.id}
and (position(${source} in ${canvasImageTask.result}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasImageTask.result}::text) > 0))
or exists (select 1 from ${canvasProject} where ${canvasProject.id} = ${id} and ${canvasProject.userId} = ${session.user.id}
and (position(${source} in ${canvasProject.canvasData}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasProject.canvasData}::text) > 0))
)`,
),
);
const byKey = new Map(rows.map((row) => [row.sourceUrl, row]));
const replacements: Record<string, string> = {};
const pending: string[] = [];
for (const url of urls) {
const row = byKey.get(imageArchiveSourceKey(url));
if (row?.targetUrl) replacements[url] = row.targetUrl;
else if (row && row.status !== "abandoned") pending.push(url);
}
if (rows.some((row) => row.status === "pending")) scheduleImageArchiveWorker(true);
return NextResponse.json(
{ replacements, pending },
{ headers: { "Cache-Control": "private, no-store" } },
);
}

View File

@ -3,6 +3,7 @@ import { auth } from '@/app/(auth)/auth';
import { db } from '@/lib/db';
import { canvasProject } from '@/lib/db/schema';
import { syncCanvasNodesForProject } from '@/lib/db/queries';
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
import { eq, and } from 'drizzle-orm';
import {
buildStaleCanvasSaveConflictPayload,
@ -122,7 +123,8 @@ export async function PUT(
);
}
const { title, description, thumbnail, canvasData } = body;
const { title, description, canvasData } = body;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const baseCanvasRevision =
body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType =

View File

@ -1,4 +1,8 @@
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
import type {
FlowBoardDraft,
FlowBoardSheetSnapshotData,
FlowBoardSnapshotData,
} from "@/lib/db/schema";
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@ -8,6 +12,73 @@ function normalizeRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
const DEFAULT_FLOW_BOARD_CANVAS = {
backgroundColor: "#f7f8fb",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
};
function normalizeCanvasColor(value: unknown, fallback: string) {
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)
? value.toLowerCase()
: fallback;
}
function normalizeFlowBoardCanvas(value: unknown): Record<string, unknown> {
const canvas = isRecord(value) ? value : {};
const requestedGridSize =
typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize)
? Math.round(canvas.gridSize)
: DEFAULT_FLOW_BOARD_CANVAS.gridSize;
return {
backgroundColor: normalizeCanvasColor(
canvas.backgroundColor,
DEFAULT_FLOW_BOARD_CANVAS.backgroundColor
),
gridColor: normalizeCanvasColor(
canvas.gridColor,
DEFAULT_FLOW_BOARD_CANVAS.gridColor
),
gridEnabled:
typeof canvas.gridEnabled === "boolean"
? canvas.gridEnabled
: DEFAULT_FLOW_BOARD_CANVAS.gridEnabled,
gridSize: Math.min(64, Math.max(8, requestedGridSize)),
};
}
function normalizeSheetId(value: unknown, index: number) {
const id = typeof value === "string" ? value.trim().slice(0, 128) : "";
return id || `sheet-${index + 1}`;
}
function normalizeSheetTitle(value: unknown, index: number) {
const title = typeof value === "string" ? value.trim().slice(0, 128) : "";
return title || `画布 ${index + 1}`;
}
function normalizeFlowBoardSheet(
value: unknown,
index: number
): FlowBoardSheetSnapshotData | null {
if (!isRecord(value)) {
return null;
}
return {
id: normalizeSheetId(value.id, index),
title: normalizeSheetTitle(value.title, index),
children: Array.isArray(value.children)
? value.children.filter(isRecord)
: [],
viewport: normalizeRecord(value.viewport),
theme: normalizeRecord(value.theme),
canvas: normalizeFlowBoardCanvas(value.canvas),
};
}
export function normalizeFlowBoardTitle(value: unknown) {
const title = String(value || "").trim();
return title.slice(0, 256) || "未命名流程白板";
@ -17,15 +88,67 @@ export function normalizeFlowBoardSnapshot(
value: unknown
): FlowBoardSnapshotData {
const raw = isRecord(value) ? value : {};
const legacyChildren = Array.isArray(raw.children)
? raw.children.filter(isRecord)
: [];
const normalizedSheets = Array.isArray(raw.sheets)
? raw.sheets
.map(normalizeFlowBoardSheet)
.filter(
(sheet): sheet is FlowBoardSheetSnapshotData => Boolean(sheet)
)
: [];
const sheets =
normalizedSheets.length > 0
? normalizedSheets
: [
{
id: "sheet-1",
title: "画布 1",
children: legacyChildren,
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
canvas: normalizeFlowBoardCanvas(raw.canvas),
},
];
const requestedActiveSheetId =
typeof raw.activeSheetId === "string" ? raw.activeSheetId.trim() : "";
const activeSheet =
sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0];
const activeChildren = Array.isArray(raw.children)
? legacyChildren
: activeSheet.children;
const activeViewport = isRecord(raw.viewport)
? raw.viewport
: activeSheet.viewport;
const activeTheme = isRecord(raw.theme) ? raw.theme : activeSheet.theme;
const activeCanvas = isRecord(raw.canvas)
? normalizeFlowBoardCanvas(raw.canvas)
: normalizeFlowBoardCanvas(activeSheet.canvas);
const nextSheets = sheets.map((sheet) =>
sheet.id === activeSheet.id
? {
...sheet,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
}
: sheet
);
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),
version: 2,
activeSheetId: activeSheet.id,
sheets: nextSheets,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
updatedAt,
};
}

View File

@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { imageArchiveTask } from "@/lib/db/schema";
// The SHA-256 URL is a capability, like the provider's signed image URL. It must be
// readable by image nodes and reference-image providers without a session cookie.
export async function GET(request: NextRequest, { params }: { params: Promise<{ key: string }> }) {
const { key } = await params;
if (!/^[a-f0-9]{64}$/.test(key)) return new NextResponse(null, { status: 404 });
const configured =
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
const origin = configured
? new URL(configured).origin
: `http://localhost:${process.env.PORT || "3000"}`;
const sourceUrl = `${origin}/api/generated-image-preview/${key}`;
const [task] = await db
.select({ targetUrl: imageArchiveTask.targetUrl, metadata: imageArchiveTask.metadata })
.from(imageArchiveTask)
.where(eq(imageArchiveTask.sourceUrl, sourceUrl))
.limit(1);
if (task?.targetUrl) return NextResponse.redirect(task.targetUrl, 307);
const inline = (task?.metadata as { inlineSource?: string } | null)?.inlineSource;
const match = inline?.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
if (!match) return new NextResponse(null, { status: 404 });
return new NextResponse(Buffer.from(match[2], "base64"), {
headers: {
"Content-Type": match[1],
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
},
});
}

View File

@ -74,6 +74,7 @@ import {
useState,
} from "react";
import { toast } from "sonner";
import { useCanvasImageArchives } from "@/hooks/use-canvas-image-archives";
import useSWR from "swr";
// 深色主题组件 V2 (新版布局)
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
@ -87,6 +88,7 @@ import {
type CanvasAddImageDialogImage,
} from "@/components/canvas/CanvasAddImageDialog";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasSurfaceHeader } from "@/components/canvas/CanvasSurfaceHeader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -249,7 +251,6 @@ import type {
AgentWorkflowUiConfig,
} from "@/types/agent";
import type {
AgentSlotNodeData,
CanvasEdge,
CanvasInitialProject,
CanvasNode,
@ -262,7 +263,7 @@ import type {
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
const CANVAS_SURFACE_BACKGROUND = "#f4f4f5";
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
const fetchCanvasSiteSettings = async (url: string) => {
const separator = url.includes("?") ? "&" : "?";
@ -1906,6 +1907,8 @@ export default function CanvasEditorLegacyPage({
[isVideoMode]
);
useCanvasImageArchives(id);
// Store state
const projectTitle = useCanvasStore((state) => state.projectTitle);
const viewport = useCanvasStore(canvasSelectors.viewport);
@ -3141,8 +3144,7 @@ export default function CanvasEditorLegacyPage({
const updateWidthFromClientX = (clientX: number) => {
const currentViewportWidth = window.innerWidth;
const nextWidth =
currentViewportWidth - DOCKED_PANEL_INSET - clientX;
const nextWidth = currentViewportWidth - DOCKED_PANEL_INSET - clientX;
setFloatingPanelWidth(
clampFloatingPanelWidth(nextWidth, currentViewportWidth)
@ -7515,7 +7517,7 @@ export default function CanvasEditorLegacyPage({
materialMimeType: image.mimeType || undefined,
originalHeight: dimensions.originalHeight,
originalWidth: dimensions.originalWidth,
source: "agent-workflow-material-search",
source: image.sourceType === "upload" ? "local-upload" : "agent-workflow-material-search",
},
},
connections: [],
@ -7527,7 +7529,7 @@ export default function CanvasEditorLegacyPage({
if (createdNodeIds.length > 0) {
selectNodes(createdNodeIds);
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
toast.success(`已添加 ${createdNodeIds.length} 张${validImages.every((image) => image.sourceType === "upload") ? "图片" : "白底图"}到画布`);
}
},
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
@ -7543,64 +7545,6 @@ export default function CanvasEditorLegacyPage({
setIsAddImageDialogOpen(true);
}, [handleAddWorkflowNode, isVideoMode]);
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
if (isVideoMode) return;
const slotDefaults: Record<
AgentSlotNodeData["slotType"],
{
label: string;
size: CanvasNode["size"];
aspectRatio: string;
}
> = {
image: {
label: "图片槽位",
size: { width: 320, height: 320 },
aspectRatio: "1:1",
},
video: {
label: "视频槽位",
size: { width: 360, height: 220 },
aspectRatio: "16:9",
},
"ui-screen": {
label: "UI 槽位",
size: { width: 360, height: 260 },
aspectRatio: "16:9",
},
};
const defaults = slotDefaults[slotType];
const origin = getVisibleCanvasOrigin();
createCanvasNode({
type: "agent-slot",
position: {
x: origin.x + 40,
y: origin.y + 40,
},
size: defaults.size,
data: {
slotType,
label: defaults.label,
status: "empty",
aspectRatio: defaults.aspectRatio,
fillPolicy: "replace-empty",
acceptedSources: ["text", "image", "selection", "annotation"],
filledNodeIds: [],
metadata: {
createdBy: "toolbar",
createdSlotType: slotType,
createdAt: new Date().toISOString(),
},
},
connections: [],
});
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
const handleAddAgentImageSlotNode = useCallback(() => {
handleAddAgentSlotNode("image");
}, [handleAddAgentSlotNode]);
const handleAddVideoNode = useCallback(() => {
if (isVideoMode) {
handleAddWorkflowNode("video-input");
@ -10312,7 +10256,11 @@ export default function CanvasEditorLegacyPage({
return (
<TooltipProvider>
<div
className="relative h-dvh w-full overflow-hidden overscroll-none"
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
className={cn(
"h-dvh w-full overflow-hidden overscroll-none",
isVideoMode ? "relative" : "lovart-canvas-surface fixed inset-0"
)}
style={
{
"--canvas-surface-background": canvasSurfaceBackground,
@ -10324,6 +10272,7 @@ export default function CanvasEditorLegacyPage({
<div
className="absolute inset-0 z-0"
data-canvas-stage="true"
style={isVideoMode ? undefined : { right: panelOcclusionRight, top: 40 }}
key={`workspace-${id}`}
>
<CanvasWorkspace
@ -10334,9 +10283,10 @@ export default function CanvasEditorLegacyPage({
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
: '点击顶部"添加"按钮开始创作'
}
occlusionRight={panelOcclusionRight}
occlusionRight={0}
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
showEmptyState={!isVideoMode}
showEmptyState={false}
showZoomControls={isVideoMode}
workspaceLabel={workspaceLabel}
workspaceVariant={isVideoMode ? "video" : "canvas"}
layerSplitEnabled={isLayerSplitEnabled}
@ -11025,14 +10975,27 @@ export default function CanvasEditorLegacyPage({
</div>
) : null}
{brandEntry}
{isVideoMode ? brandEntry : (
<CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode}
onHome={goHome}
onOpenFiles={() => {
setIsPanelOpen(true);
setPanelMode("fileList");
}}
onSave={() => void handleSave()}
projectTitle={projectTitle}
rightInset={panelOcclusionRight}
/>
)}
{agentDisabledNotice}
{/* 2. Floating Actions (Top Right) */}
{!isVideoMode && !isPanelOpen && (
<div
className="absolute z-20"
style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}
style={{ top: 46, right: 12 }}
>
<Button
className={cn(
@ -11064,7 +11027,7 @@ export default function CanvasEditorLegacyPage({
)}
data-canvas-right-panel="true"
style={{
top: DOCKED_PANEL_INSET,
top: 0,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: resolvedPanelWidth,
@ -11095,7 +11058,7 @@ export default function CanvasEditorLegacyPage({
/>
</div>
) : null}
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
<div className="flex h-full w-full flex-col overflow-hidden border-l border-[#ededed] bg-white dark:bg-zinc-950">
{!areSecondaryPanelsReady ? (
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
) : panelMode === "chat" ? (
@ -11201,11 +11164,11 @@ export default function CanvasEditorLegacyPage({
className={cn(
isVideoMode
? VIDEO_CANVAS_TOOLBAR_CLASS
: "border bg-white shadow-2xl dark:bg-zinc-950",
: "border border-[#e9e9e9] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.035)] dark:border-white/10 dark:bg-zinc-950",
!isVideoMode &&
(shouldDockFloatingToolbarToSide
? "flex flex-col items-center gap-1 rounded-[24px] px-1.5 py-2.5"
: "flex items-center gap-1 rounded-[24px] px-2.5 py-1.5")
: "flex items-center gap-1 rounded-[14px] px-1.5 py-1.5")
)}
style={
shouldDockFloatingToolbarToSide
@ -11223,7 +11186,7 @@ export default function CanvasEditorLegacyPage({
tool === "select" &&
(isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-primary text-primary-foreground hover:bg-primary/90")
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
)}
onClick={() => setTool("select")}
size="icon"
@ -11233,6 +11196,8 @@ export default function CanvasEditorLegacyPage({
active={tool === "select"}
className="h-4 w-4"
fallback={MousePointer}
referenceName={isVideoMode ? undefined : "cursor"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"selectTool"
@ -11246,63 +11211,6 @@ export default function CanvasEditorLegacyPage({
</TooltipContent>
</Tooltip>
{!isVideoMode &&
isCanvasToolbarItemEnabled(
canvasToolbarConfig,
"addAgentImageSlot"
) ? (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button className="h-9 w-9 rounded-xl" size="icon" variant="ghost">
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
)}
/>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side={floatingToolbarTooltipSide}>
{
getCanvasToolbarItem(
canvasToolbarConfig,
"addAgentImageSlot"
).label
}
</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="start"
side={floatingToolbarDropdownSide}
>
<DropdownMenuItem
onSelect={() => handleAddAgentImageSlotNode()}
>
<ImagePlus className="h-4 w-4" />
图片槽位
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleAddAgentSlotNode("video")}
>
<Video className="h-4 w-4" />
视频槽位
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleAddAgentSlotNode("ui-screen")}
>
<MonitorSmartphone className="h-4 w-4" />
UI 槽位
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -11313,7 +11221,7 @@ export default function CanvasEditorLegacyPage({
tool === "pan" &&
(isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-primary text-primary-foreground hover:bg-primary/90")
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
)}
onClick={() => setTool("pan")}
size="icon"
@ -11323,6 +11231,8 @@ export default function CanvasEditorLegacyPage({
active={tool === "pan"}
className="h-4 w-4"
fallback={Hand}
referenceName={isVideoMode ? undefined : "pan"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
/>
</Button>
@ -11443,7 +11353,7 @@ export default function CanvasEditorLegacyPage({
tool === "draw" &&
(isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-primary text-primary-foreground hover:bg-primary/90")
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
)}
onClick={() => setTool("draw")}
size="icon"
@ -11484,7 +11394,7 @@ export default function CanvasEditorLegacyPage({
isAgentMode &&
(isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-primary text-primary-foreground hover:bg-primary/90")
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
)}
onClick={handleStyleModeToolbarClick}
size="icon"
@ -11500,6 +11410,14 @@ export default function CanvasEditorLegacyPage({
fallback={
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
}
referenceName={
isVideoMode
? undefined
: shouldUseStyleModeButtonForStylePro
? "style-controls"
: "creativity"
}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"styleMode"
@ -11529,6 +11447,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
referenceName={isVideoMode ? undefined : "style-controls"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"stylePro"
@ -11558,6 +11478,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers}
referenceName={isVideoMode ? undefined : "layers"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"batchProcessing"
@ -11589,6 +11511,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
referenceName={isVideoMode ? undefined : "image"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addImageInput" : "addImage"
@ -11621,6 +11545,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Type}
referenceName={isVideoMode ? undefined : "text"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addScriptNode" : "addText"
@ -11778,6 +11704,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={PenTool}
referenceName={isVideoMode ? undefined : "draw"}
referenceSize={20}
item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"realtimeCanvas",
@ -11825,6 +11753,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
referenceName={isVideoMode ? undefined : "image-enhance"}
referenceSize={20}
item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"enhanceCanvas",
@ -11878,6 +11808,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Box}
referenceName={isVideoMode ? undefined : "model-auto"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
/>
</Button>
@ -11967,6 +11899,8 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Plus}
referenceName={isVideoMode ? undefined : "plus"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "newVideoCanvas" : "newCanvas"
@ -11994,12 +11928,15 @@ export default function CanvasEditorLegacyPage({
type="file"
/>
{/* 6. Bottom Left Compact Controls */}
{/* Compact zoom and history controls. */}
<div
data-canvas-compact-controls="true"
className="absolute z-20"
style={{
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
left: `${compactControlsLeft + 8}px`,
...(isVideoMode
? { left: `${compactControlsLeft + 8}px` }
: { right: `${panelOcclusionRight + 16}px` }),
}}
>
<div

View File

@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
export default function FlowBoardPreviewPage() {
if (process.env.NODE_ENV === "production") {
notFound();
}
return (
<FlowBoardClient
previewMode
siteLogo={null}
siteLogoDark={null}
siteName="SnapGen"
/>
);
}

View File

@ -519,3 +519,272 @@
padding: 0 12px 12px;
}
}
/* Lovart surface typography and controls, scoped to the home and image canvas. */
.lovart-home-surface,
.lovart-canvas-surface {
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
}
.lovart-suggestion {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 8px;
height: 32px;
padding: 0 14px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 13px;
white-space: nowrap;
transition: background-color 150ms, border-color 150ms;
}
.lovart-suggestion:hover { background: #f7f7f7; border-color: #d5d5d5; }
.lovart-suggestion:focus-visible,
.lovart-start-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
.lovart-start-action {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
gap: 9px;
min-width: 172px;
height: 40px;
padding: 0 16px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 14px;
font-weight: 400;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
transition: background-color 150ms, border-color 150ms;
}
.lovart-start-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-bottom: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer { padding: 0 8px 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] {
min-height: 120px;
border-color: #e5e5e5;
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
}
/* The promo backplate continues underneath the composer's rounded corners. */
.lovart-chat-promo-connected { padding: 0 8px; }
.lovart-chat-promo-surface { padding-bottom: 24px; border-radius: 24px 24px 0 0; background: #ebebeb; }
.lovart-chat-promo-row { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 10px 0 16px; }
.lovart-chat-promo-message { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0; border: 0; background: transparent; color: #202020; font-size: 14px; font-weight: 400; line-height: 20px; text-align: left; cursor: pointer; }
.lovart-chat-promo-message:disabled { cursor: default; opacity: 1; }
.lovart-chat-promo-message > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lovart-chat-promo-badge { flex-shrink: 0; }
.lovart-chat-promo-close { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; border: 0; border-radius: 50%; background: transparent; color: #262626; cursor: pointer; }
.lovart-chat-promo-close:hover { background: rgb(0 0 0 / 5%); }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-promo-connected + .lovart-chat-composer { position: relative; z-index: 1; margin-top: -24px; background: transparent; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor][data-canvas-composer-mode="image"] { min-height: 184px; border-radius: 24px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-upload { margin: 8px 8px 0; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { flex-direction: column; gap: 8px; width: 56px; height: 68px; border: 0; border-radius: 18px; background: #f5f5f5; color: #aaa; font-size: 12px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #ececec; color: #777; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile > svg { width: 14px; height: 14px; stroke-width: 1.5; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-text-region { padding: 12px 16px 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"] { min-height: 40px; font-size: 14px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"]:empty::before { color: #b0b0b0; }
.dark .lovart-chat-promo-surface { background: #27272a; }
.dark .lovart-chat-promo-message, .dark .lovart-chat-promo-close { color: #eee; }
.dark .lovart-chat-promo-close:hover { background: rgb(255 255 255 / 10%); }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { background: #27272a; color: #a1a1aa; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #3f3f46; color: #eee; }
.dark .lovart-suggestion,
.dark .lovart-start-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
.dark .lovart-suggestion:hover,
.dark .lovart-start-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-color: rgb(255 255 255 / 10%); }
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] { border-color: rgb(255 255 255 / 12%); }
@media (max-width: 640px) {
.lovart-home-surface main { padding-top: calc(72px + var(--home-banner-offset, 0px)); }
.lovart-hero { margin-top: 16px; }
.lovart-home-composer { border-radius: 20px; }
.lovart-suggestion { padding: 0 11px; font-size: 12px; }
.lovart-start-action { min-width: 160px; }
.lovart-chat-welcome h1 { font-size: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.lovart-home-surface *, .lovart-canvas-surface * { scroll-behavior: auto !important; }
}
.lovart-home-surface :is(button, input, textarea, select),
.lovart-canvas-surface :is(button, input, textarea, select) { font-family: inherit; }
@media (max-width: 640px) {
.lovart-canvas-surface[data-canvas-panel-open="true"] :is([data-canvas-topbar], [data-canvas-floating-toolbar], [data-canvas-compact-controls]) { display: none; }
}
/* Reference icon sizing is independent of the application's root rem scale. */
.lovart-suggestion { gap: 8px; padding: 0 12px; }
.lovart-start-action { min-width: 172.71875px; gap: 8px; padding: 0 12px; }
.lovart-canvas-surface [data-canvas-floating-toolbar] button { min-width: 32px; height: 32px; border-radius: 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector { width: 90px; height: 32px; padding: 0 8px; background: transparent; font-size: 14px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector > span { font-size: 13px; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector:hover,
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params:hover,
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector:hover { background: rgb(0 0 0 / 4%); }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params { width: 105.3828125px; min-width: 105.3828125px; height: 32px; padding: 0 4px 0 12px; background: transparent; font-size: 13px; gap: 4px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector { flex: 0 0 121px; width: 121px; height: 32px; padding: 0 4px 0 8px; gap: 0; border: 0; background: transparent; box-shadow: none; font-size: 13px; font-weight: 400; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 8px; }
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > span:not([data-reference-icon]) { max-width: 65px; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params) { color: #eee; }
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params, .lovart-composer-mode-selector):hover { background: rgb(255 255 255 / 8%); }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer { padding: 0 8px 8px; gap: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:first-child { flex: 0 0 90px; padding-right: 0; overflow: visible; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:last-child { flex: 1 1 0; min-width: 0; gap: 0; }
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-send { min-width: 32px; width: auto; height: 32px; padding: 0 8px; gap: 2px; font-size: 13px; font-weight: 400; }
/* The existing image toolbar is scaled to 80%; these dimensions render at
the reference's 44px bar, 32px buttons, and 20px icons. */
.lovart-image-action-bar {
height: 55px;
gap: 5px;
padding: 0 7.5px;
border: 1.25px solid rgba(0, 0, 0, 0.07);
border-radius: 17.5px;
background: #fff !important;
color: #000;
box-shadow: 0 1.25px 2.5px rgba(0, 0, 0, 0.04);
backdrop-filter: none;
}
.lovart-image-action-bar::before { display: none; }
.lovart-image-action-bar > button {
width: 40px;
min-width: 40px;
height: 40px;
margin: 0;
gap: 0;
padding: 0;
border-radius: 10px;
background: transparent;
color: #000;
font-size: 0;
font-weight: 400;
box-shadow: none;
}
.lovart-image-action-bar > button:hover,
.lovart-image-action-bar > button[data-state="open"],
.lovart-image-action-bar > button[data-reference-active="true"] {
background: rgba(0, 0, 0, 0.04);
color: #000;
}
.lovart-image-action-bar > button:disabled {
background: transparent;
color: rgba(0, 0, 0, 0.3);
}
.lovart-image-action-bar > button > svg,
.lovart-image-action-bar > button > [data-reference-icon],
.lovart-image-action-bar > button > span[aria-hidden="true"],
.lovart-image-action-bar > button > img {
width: 25px;
height: 25px;
margin: 0;
flex-shrink: 0;
}
.lovart-image-action-bar > button > img { filter: brightness(0); }
.lovart-image-action-bar > div.h-px,
.lovart-image-action-bar > div.w-px {
width: 1.25px;
height: 55px;
margin: 0 2.5px;
background: rgba(0, 0, 0, 0.07);
}
.lovart-image-action-menu [role="menuitem"] > svg,
.lovart-image-action-menu [role="menuitem"] > [data-reference-icon] {
width: 18px;
height: 18px;
}
.dark .lovart-image-action-bar {
border-color: rgba(255, 255, 255, 0.1);
background: #18191c !important;
color: #fff;
}
.dark .lovart-image-action-bar > button { color: #fff; }
.dark .lovart-image-action-bar > button:hover,
.dark .lovart-image-action-bar > button[data-state="open"],
.dark .lovart-image-action-bar > button[data-reference-active="true"] {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.dark .lovart-image-action-bar > button:disabled { color: rgba(255, 255, 255, 0.3); }
.dark .lovart-image-action-bar > button > img { filter: brightness(0) invert(1); }
.dark .lovart-image-action-bar > div.w-px { background: rgba(255, 255, 255, 0.1); }
.lovart-image-action-bar[data-show-tool-names="true"] > button[data-tool-key] { width: auto; gap: 7.5px; padding: 0 12.5px; font-size: 16.25px; }
.lovart-image-tool-label { white-space: nowrap; }
.lovart-image-tool-tooltip { z-index: 10030; border: 0; border-radius: 6px; background: #1b1b1b; padding: 6px 9px; color: #fff; font-size: 13px; line-height: 18px; box-shadow: 0 4px 10px #00000012; --tw-ring-shadow: 0 0 transparent; }
.lovart-image-custom-more { min-width: 172px; padding: 6px 12px; border: 1px solid #e8e8e8; border-radius: 12px; background: #fff; }
.lovart-image-custom-more [role="menuitem"] { min-height: 32px; gap: 10px; padding: 4px 0; font-size: 14px; font-weight: 400; }
.lovart-image-custom-more .lovart-customize-entry { justify-content: center; gap: 5px; }
.lovart-toolbar-customizer-overlay { z-index: 10010; background: #0006; }
.lovart-toolbar-customizer { z-index: 10020; display: flex; flex-direction: column; width: min(720px, calc(100vw - 32px)); max-width: 720px; max-height: calc(100dvh - 48px); gap: 0; padding: 20px 24px 24px; border: 1px solid #e5e5e5; border-radius: 16px; background: #fff; color: #242424; box-shadow: 0 8px 24px #00000012; }
.lovart-customizer-header { display: flex; flex-shrink: 0; justify-content: space-between; align-items: flex-start; gap: 16px; }
.lovart-customizer-header h2 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 600; }
.lovart-customizer-header p { margin-top: 2px; color: inherit; font-size: 14px; line-height: 22px; }
.lovart-customizer-header-actions { display: flex; gap: 4px; }
.lovart-customizer-header-actions button { display: flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 7px; }
.lovart-customizer-header-actions button:last-child { background: #f1f1f1; }
.lovart-customizer-header-actions button:hover { background: #e9e9e9; }
.lovart-customizer-body { min-height: 0; overflow-y: auto; margin: 24px 0 20px; }
.lovart-customizer-preview { display: flex; height: 180px; align-items: center; overflow: hidden; padding: 16px; border-radius: 10px; background: #e5edef url("/icons/lovart/toolbar-preview.png") center / cover; }
.lovart-customizer-preview-bar { display: flex; align-items: center; flex: 1; min-width: 0; gap: 4px; padding: 5px 8px; border: 1px solid #e5e5e5; border-radius: 14px; background: #fff; overflow-x: auto; scrollbar-width: thin; }
.lovart-preview-fixed,.lovart-preview-chip { display: flex; align-items: center; flex-shrink: 0; height: 32px; gap: 8px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
.lovart-preview-chip { position: relative; margin-top: 5px; margin-bottom: 5px; border-radius: 8px; background: #f4f4f4; cursor: grab; }
.lovart-preview-chip:active { cursor: grabbing; }
.lovart-preview-chip > button:last-child { position: absolute; top: -5px; right: -3px; display: flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid #e6e6e6; border-radius: 50%; background: #fff; color: #888; }
.lovart-customizer-tools { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; margin-top: 24px; }
.lovart-customizer-tools > button { display: flex; align-items: center; gap: 10px; height: 36px; min-width: 0; padding: 0 12px; border: 1px solid #e2e2e2; border-radius: 8px; background: #fff; font-size: 14px; text-align: left; }
.lovart-customizer-tools > button > span:not([aria-hidden]) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lovart-customizer-tools > button > svg:last-child { flex-shrink: 0; }
.lovart-customizer-tools > button:hover { background: #f7f7f7; }
.lovart-customizer-footer { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; gap: 16px; }
.lovart-customizer-footer > label,.lovart-customizer-footer > div { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.lovart-customizer-switch { width: 32px; height: 18px; padding: 2px; border-radius: 20px; background: #d5d5d5; }
.lovart-customizer-switch > span { display: block; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s; }
.lovart-customizer-switch[aria-checked="true"] { background: #2c2c2c; }
.lovart-customizer-switch[aria-checked="true"] > span { transform: translateX(14px); }
.lovart-customizer-footer > div > button { width: 80px; height: 36px; border: 1px solid #e3e3e3; border-radius: 8px; font-size: 14px; }
.lovart-customizer-footer .lovart-customizer-save { border-color: #2c2c2c; background: #2c2c2c; color: #fff; }
.lovart-customizer-footer > div > button:hover { opacity: .8; }
.lovart-toolbar-customizer button:focus-visible,.lovart-preview-chip:focus-visible { outline: 2px solid #397dff; outline-offset: 2px; }
.dark .lovart-toolbar-customizer,.dark .lovart-image-custom-more { background: #202124; border-color: #3d3e40; color: #f5f5f5; }
.dark .lovart-customizer-preview-bar,.dark .lovart-customizer-tools > button { background: #26272a; border-color: #46474a; }
.dark .lovart-preview-chip,.dark .lovart-customizer-header-actions button:last-child { background: #35363a; }
.dark .lovart-preview-chip > button:last-child { background: #26272a; border-color: #46474a; color: #ccc; }
.dark .lovart-customizer-tools > button:hover { background: #35363a; }
@media(max-width:600px) { .lovart-toolbar-customizer { padding: 18px; } .lovart-customizer-tools { grid-template-columns: repeat(2,minmax(0,1fr)); } .lovart-customizer-preview { height: 140px; } }
.lovart-preview-handle { display: flex; align-items: center; gap: 8px; white-space: nowrap; cursor: grab; }
.lovart-image-tool-tooltip { display: flex; align-items: center; gap: 8px; }
.lovart-image-tool-cost { margin-left: auto; padding: 0; background: transparent; color: #999; font-size: 12px; font-weight: 400; box-shadow: none; }
.lovart-image-tool-cost > svg { width: 12px; height: 12px; }
/* Preserve the canvas's own skills as the primary welcome content. */
.canvas-welcome-action {
display: inline-flex;
min-height: 36px;
min-width: 0;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 8px;
border: 1px solid #e5e5e5;
border-radius: 999px;
background: white;
color: #333;
font-size: 12px;
line-height: 16px;
transition: background-color 150ms, border-color 150ms;
}
.canvas-welcome-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
.canvas-welcome-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
.dark .canvas-welcome-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
.dark .canvas-welcome-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }

View File

@ -1,7 +1,7 @@
"use client";
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import {
@ -21,6 +21,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE } from "@/lib/image-upload-limits";
import { imageGenerationService } from "@/services/canvas";
import type {
AgentWorkflowMaterialImage,
AgentWorkflowMaterialQueryType,
@ -163,6 +165,8 @@ export function CanvasAddImageDialog({
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const localUploadInputRef = useRef<HTMLInputElement>(null);
const [errorText, setErrorText] = useState<string | null>(null);
const images = useMemo(() => normalizeImages(result), [result]);
@ -182,12 +186,13 @@ export function CanvasAddImageDialog({
const handleDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen && (isUploading || isAdding)) return;
if (!nextOpen) {
resetDialogState();
}
onOpenChange(nextOpen);
},
[onOpenChange, resetDialogState]
[isAdding, isUploading, onOpenChange, resetDialogState]
);
const closeDialog = useCallback(() => {
@ -263,6 +268,51 @@ export function CanvasAddImageDialog({
}
};
const handleLocalUpload = async (files: File[]) => {
if (!files.length || isUploading || isAdding) return;
if (files.some((file) => !file.type.startsWith("image/"))) {
setErrorText("请选择图片文件");
return;
}
if (files.some((file) => file.size > IMAGE_UPLOAD_MAX_BYTES)) {
setErrorText(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
return;
}
setIsUploading(true);
setErrorText(null);
try {
const uploadedImages: CanvasAddImageDialogImage[] = [];
const failures: string[] = [];
for (const [index, file] of files.entries()) {
const upload = await imageGenerationService.uploadImage(file);
if (!upload.success || !upload.data?.url) {
failures.push(`${file.name}:${upload.error || "图片上传失败"}`);
continue;
}
uploadedImages.push({
id: upload.data.assetId || crypto.randomUUID(),
key: upload.data.pathname || file.name,
label: file.name.replace(/\.[^.]+$/, "") || file.name,
fileName: file.name,
imageUrl: upload.data.url,
assetId: upload.data.assetId,
mimeType: file.type,
sourceType: "upload",
group: "other",
sortOrder: index,
});
}
if (uploadedImages.length) await onAddImages(uploadedImages);
if (failures.length) setErrorText(failures.join(";"));
else closeDialog();
} catch (error) {
setErrorText(error instanceof Error ? error.message : "图片上传失败,请重试");
} finally {
setIsUploading(false);
}
};
return (
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
@ -272,15 +322,38 @@ export function CanvasAddImageDialog({
添加图片
</DialogTitle>
<DialogDescription>
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
</DialogDescription>
</DialogHeader>
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
<div className="flex flex-col gap-2.5 sm:flex-row">
<input
accept="image/*"
className="hidden"
multiple
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void handleLocalUpload(files);
}}
ref={localUploadInputRef}
type="file"
/>
<Button
className="h-9 justify-start gap-2"
className="h-9 shrink-0 justify-start gap-2"
disabled={isUploading || isAdding}
onClick={() => localUploadInputRef.current?.click()}
type="button"
variant="secondary"
>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isUploading ? "上传中…" : "本地上传"}
</Button>
<Button
className="h-9 shrink-0 justify-start gap-2"
disabled={isUploading || isAdding}
onClick={() => {
onCreateBlankImage();
closeDialog();
@ -355,13 +428,13 @@ export function CanvasAddImageDialog({
</div>
{!materialSearchEnabled ? (
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
风格化功能或素材搜索步骤未开启,可从本地上传或添加空白图片。
</div>
) : null}
</div>
{errorText ? (
<div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
<div role="alert" className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
{errorText}
</div>
) : null}
@ -440,6 +513,7 @@ export function CanvasAddImageDialog({
<DialogFooter className="border-t px-5 py-3">
<Button
disabled={isUploading || isAdding}
onClick={closeDialog}
type="button"
variant="outline"
@ -448,7 +522,7 @@ export function CanvasAddImageDialog({
</Button>
<Button
className="gap-2"
disabled={!selectedImages.length || isAdding}
disabled={!selectedImages.length || isAdding || isUploading}
onClick={() => void handleAddSelected()}
type="button"
>

View File

@ -0,0 +1,115 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { Position } from "@/types/canvas";
type Props = {
position: Position | null;
isMac: boolean;
videoUploading: boolean;
onClose: () => void;
onUploadImage: () => void;
onUploadVideo: () => void;
onPaste: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onFitAll: () => void;
onResetZoom: () => void;
};
export function CanvasBlankContextMenu({
position,
isMac,
videoUploading,
onClose,
onUploadImage,
onUploadVideo,
onPaste,
onZoomIn,
onZoomOut,
onFitAll,
onResetZoom,
}: Props) {
const modifier = isMac ? "⌘" : "Ctrl+";
const itemClass =
"h-8 cursor-pointer justify-between whitespace-nowrap rounded-[5px] px-0 text-[14px] font-normal focus:bg-[#f5f5f5] dark:focus:bg-white/10";
const shortcut = (label: string) => (
<span className="ml-2 whitespace-nowrap text-[13px] text-[#aaa]">
{label}
</span>
);
return (
<DropdownMenu
modal={false}
open={position !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DropdownMenuTrigger asChild>
<button
aria-hidden="true"
tabIndex={-1}
data-canvas-no-drag="true"
className="pointer-events-none fixed h-px w-px opacity-0"
style={{ left: position?.x ?? 0, top: position?.y ?? 0 }}
type="button"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
aria-label="画布右键菜单"
align="start"
sideOffset={0}
collisionPadding={8}
data-canvas-blank-menu="true"
data-canvas-no-drag="true"
className="z-[10005] w-[200px] rounded-[12px] border-[#ededed] bg-white px-3 py-[5px] text-[#242424] shadow-[0_8px_20px_rgba(0,0,0,0.06)] ring-0 dark:border-white/10 dark:bg-zinc-900 dark:text-white"
style={{
fontFamily:
'Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
}}
onCloseAutoFocus={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DropdownMenuItem className={itemClass} onSelect={onUploadImage}>
上传图片
</DropdownMenuItem>
<DropdownMenuItem
className={itemClass}
disabled={videoUploading}
onSelect={onUploadVideo}
>
上传视频
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
<DropdownMenuItem className={itemClass} onSelect={onPaste}>
粘贴{shortcut(`${modifier}V`)}
</DropdownMenuItem>
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
<DropdownMenuItem className={itemClass} onSelect={onZoomIn}>
放大{shortcut(`${modifier}+`)}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onZoomOut}>
缩小{shortcut(`${modifier}-`)}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onFitAll}>
显示画布所有元素{shortcut(isMac ? "⇧1" : "Shift+1")}
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={onResetZoom}>
缩放至100%{shortcut(`${modifier}0`)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -25,8 +25,11 @@ import {
type UiSurfaceSnapshot,
} from "@/lib/ui-surface-protocol";
import useSWR, { useSWRConfig } from "swr";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, FileArchive, FolderUp, Paperclip, Copy, Square, Search, GripVertical, Volume2, LockKeyhole } from "lucide-react";
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, FileArchive, FolderUp, Paperclip, Copy, Square, Search, GripVertical, Volume2, LockKeyhole, Images } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CanvasComposerPromo } from "./CanvasComposerPromo";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { ModelIdentityIcon } from "@/components/lovart/model-identity-icon";
import {
Collapsible,
CollapsibleContent,
@ -14824,7 +14827,9 @@ export function CanvasChatPanel({
: selectedSkillTag?.placeholder?.trim() ||
(selectedSkillTag
? `请输入「${selectedSkillTag.name}」相关需求`
: "请输入你的设计需求");
: activeComposerMode === "image"
? "今天我们要创作什么"
: "请输入你的设计需求");
const clawdEditablePresetPlaceholder =
isClawdMode && activeClawdSkillTag
? activeClawdSkillTag.placeholder?.trim() || ""
@ -15312,10 +15317,14 @@ export function CanvasChatPanel({
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none",
isVideoWorkspace
? "border border-border/60 bg-background"
: "border border-transparent bg-white dark:border-white/20 dark:bg-white"
: "border border-transparent bg-white dark:border-white/20 dark:bg-white",
!isWorkspacePresentation && !isVideoWorkspace && "rounded-none border-0 bg-transparent dark:bg-transparent"
)}
>
{model.avatarUrl ? (
{!isWorkspacePresentation && !isVideoWorkspace ? (
<ModelIdentityIcon name={model.displayName} identity={`${model.id} ${model.providerType || ""}`} avatarUrl={model.avatarUrl ? buildPreviewImageUrl(model.avatarUrl, 40) : undefined} size={18} />
) : (
model.avatarUrl ? (
<ModelAvatarImage
src={buildPreviewImageUrl(model.avatarUrl, 40)}
alt={model.displayName}
@ -15342,6 +15351,7 @@ export function CanvasChatPanel({
>
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
</span>
)
)}
</span>
<span className="min-w-0 flex-1 truncate text-[13px] font-normal leading-5">
@ -15496,6 +15506,7 @@ export function CanvasChatPanel({
return (
<div
data-canvas-chat-panel="ready"
data-lovart-chat={!isWorkspacePresentation && !isVideoWorkspace ? "true" : undefined}
className={cn(
"relative flex h-full min-h-0 flex-col",
isWorkspacePresentation
@ -15533,7 +15544,7 @@ export function CanvasChatPanel({
</div>
)}
{!isWorkspacePresentation ? (
<div className="z-20 flex items-center justify-between gap-2 px-3 py-2">
<div className="lovart-chat-header z-20 flex h-[40px] shrink-0 items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0 flex flex-1 items-center gap-2 pr-2">
<span
className={cn(
@ -15983,8 +15994,8 @@ export function CanvasChatPanel({
) : null}
</div>
) : (
<div className="flex flex-1 flex-col px-5 pb-8 pt-8">
<div className="text-left">
<div className="lovart-chat-welcome flex min-h-0 flex-1 flex-col overflow-y-auto px-5 pb-6 pt-6">
<div className="shrink-0 text-left">
<h1 className="text-[20px] font-semibold leading-tight text-[#050505] dark:text-zinc-50">
Hi,{" "}
{greetingUserName ? (
@ -16002,7 +16013,7 @@ export function CanvasChatPanel({
</div>
{canvasSkillTags.length > 0 ? (
<div className="mt-6 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
<div data-canvas-skill-picker="true" className="mt-6 w-full shrink-0 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
<h3 className="text-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
{canvasSkillTagsTitle}
</h3>
@ -16074,40 +16085,38 @@ export function CanvasChatPanel({
) : null}
</div>
) : null}
{canvasSkillTags.length > 4 ? (
<div className="mx-auto mt-4 flex h-9 w-full max-w-[360px] items-center gap-2 rounded-lg border border-[#e5e7eb] bg-[#fafbfc] px-3 text-left transition-colors focus-within:border-[#aeb6c5] focus-within:bg-white dark:border-white/10 dark:bg-white/5 dark:focus-within:border-white/25 dark:focus-within:bg-white/[0.07]">
<Search className="h-4 w-4 shrink-0 text-[#9299a6] dark:text-zinc-500" />
<input
type="text"
value={canvasSkillTagSearchQuery}
onChange={(event) =>
setCanvasSkillTagSearchQuery(event.target.value)
<div className="mx-auto mt-4 flex h-9 w-full max-w-[360px] items-center gap-2 rounded-lg border border-[#e5e7eb] bg-[#fafbfc] px-3 text-left transition-colors focus-within:border-[#aeb6c5] focus-within:bg-white dark:border-white/10 dark:bg-white/5 dark:focus-within:border-white/25 dark:focus-within:bg-white/[0.07]">
<Search className="h-4 w-4 shrink-0 text-[#9299a6] dark:text-zinc-500" />
<input
type="text"
value={canvasSkillTagSearchQuery}
onChange={(event) =>
setCanvasSkillTagSearchQuery(event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Escape") {
setCanvasSkillTagSearchQuery("");
}
onKeyDown={(event) => {
if (event.key === "Escape") {
setCanvasSkillTagSearchQuery("");
}
}}
placeholder="搜索快捷技能"
aria-label="搜索快捷技能"
autoComplete="off"
enterKeyHint="search"
spellCheck={false}
className="h-full min-w-0 flex-1 bg-transparent text-[13px] text-[#1f2328] outline-none placeholder:text-[#9aa1ad] dark:text-zinc-100 dark:placeholder:text-zinc-500"
/>
{canvasSkillTagSearchQuery ? (
<button
type="button"
onClick={() => setCanvasSkillTagSearchQuery("")}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center text-[#9299a6] transition-colors hover:text-[#1f2328] dark:text-zinc-500 dark:hover:text-zinc-100"
title="清空搜索"
aria-label="清空快捷技能搜索"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
) : null}
}}
placeholder="搜索快捷技能"
aria-label="搜索快捷技能"
autoComplete="off"
enterKeyHint="search"
spellCheck={false}
className="h-full min-w-0 flex-1 bg-transparent text-[13px] text-[#1f2328] outline-none placeholder:text-[#9aa1ad] dark:text-zinc-100 dark:placeholder:text-zinc-500"
/>
{canvasSkillTagSearchQuery ? (
<button
type="button"
onClick={() => setCanvasSkillTagSearchQuery("")}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center text-[#9299a6] transition-colors hover:text-[#1f2328] dark:text-zinc-500 dark:hover:text-zinc-100"
title="清空搜索"
aria-label="清空快捷技能搜索"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div
className={cn(
"mt-4 flex flex-wrap justify-center gap-1.5 transition-[max-height] duration-150 ease-out",
@ -16116,7 +16125,7 @@ export function CanvasChatPanel({
: "mt-6",
isCanvasSkillTagSearching || areCanvasSkillTagsExpanded
? "max-h-none overflow-visible"
: "max-h-[76px] overflow-hidden"
: "max-h-[calc(4rem+0.375rem)] overflow-hidden"
)}
>
{filteredCanvasSkillTags.map((tag) => {
@ -17077,37 +17086,53 @@ export function CanvasChatPanel({
<CanvasComposerUploadProgressDialog progress={composerUploadProgress} />
{!isWorkspacePresentation && shouldShowCanvasBanner ? (
<div className="px-[10px] pb-0">
<div
className={cn(
"relative z-10 mb-0 flex h-[48px] items-center justify-between gap-2 rounded-[28px] bg-[#f4f4f5] px-4 py-0 text-[14px] font-medium leading-5 text-[#20242a] dark:bg-zinc-900 dark:text-zinc-100",
siteSettings?.bannerLink && "cursor-pointer transition-colors hover:bg-[#eeeeef] dark:hover:bg-zinc-800"
)}
onClick={() => {
if (siteSettings?.bannerLink) {
window.open(siteSettings.bannerLink, "_blank");
}
}}
>
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[9px] bg-[#e8ff38] text-[#111827]">
<Sparkles className="h-3.5 w-3.5 fill-current" />
</div>
<span className="min-w-0 truncate">{siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"}</span>
</div>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#20242a]/75 transition-colors hover:bg-black/5 hover:text-[#20242a] dark:text-zinc-100/75 dark:hover:bg-white/10 dark:hover:text-zinc-100"
aria-label="关闭优惠广告"
onClick={(event) => {
event.stopPropagation();
setIsCanvasBannerDismissed(true);
!isVideoWorkspace ? (
<CanvasComposerPromo
text={siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"}
onDismiss={() => setIsCanvasBannerDismissed(true)}
onActivate={
siteSettings?.bannerLink
? () => {
if (siteSettings.bannerLink) {
window.open(siteSettings.bannerLink, "_blank");
}
}
: undefined
}
/>
) : (
<div className="lovart-chat-promo px-[8px] pb-0">
<div
className={cn(
"relative z-10 mb-0 flex h-[36px] items-center justify-between gap-2 rounded-t-[24px] bg-[#ededed] px-4 py-0 text-[12px] font-medium leading-5 text-[#20242a] dark:bg-zinc-900 dark:text-zinc-100",
siteSettings?.bannerLink && "cursor-pointer transition-colors hover:bg-[#eeeeef] dark:hover:bg-zinc-800"
)}
onClick={() => {
if (siteSettings?.bannerLink) {
window.open(siteSettings.bannerLink, "_blank");
}
}}
>
<X className="h-5 w-5" />
</button>
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[9px] bg-[#e8ff38] text-[#111827]">
<Sparkles className="h-3.5 w-3.5 fill-current" />
</div>
<span className="min-w-0 truncate">{siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"}</span>
</div>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#20242a]/75 transition-colors hover:bg-black/5 hover:text-[#20242a] dark:text-zinc-100/75 dark:hover:bg-white/10 dark:hover:text-zinc-100"
aria-label="关闭优惠广告"
onClick={(event) => {
event.stopPropagation();
setIsCanvasBannerDismissed(true);
}}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
</div>
)
) : null}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
@ -17115,15 +17140,16 @@ export function CanvasChatPanel({
className={cn(
isWorkspacePresentation
? "border-t bg-white/92 p-4 backdrop-blur-sm dark:bg-zinc-950/92"
: "bg-white px-2 pb-2 pt-0 dark:bg-zinc-950"
: "lovart-chat-composer bg-white px-[8px] pb-[8px] pt-0 dark:bg-zinc-950"
)}
>
<div
className={cn(
"relative flex flex-col overflow-hidden border border-[#e2e5ea] bg-white shadow-[0_18px_36px_-28px_rgba(15,23,42,0.42)] transition-[border-color,box-shadow] duration-150 focus-within:border-[#cfd5de] focus-within:shadow-[0_20px_40px_-28px_rgba(15,23,42,0.5)] dark:border-white/12 dark:bg-zinc-950 dark:focus-within:border-white/25",
"min-h-[148px] rounded-[18px]"
"min-h-[120px] rounded-[24px]"
)}
data-canvas-chat-input-anchor="true"
data-canvas-composer-mode={activeComposerMode}
>
{isWorkspacePresentation && !isDetailGenerationMode ? (
<div className="border-b px-5 py-3">
@ -17879,7 +17905,10 @@ export function CanvasChatPanel({
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div
className={cn(
"relative px-3 pb-0 pt-2.5",
"relative pb-0",
!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image"
? "px-0 pt-0"
: "px-3 pt-2.5",
isDetailGenerationMode
? "min-h-[96px]"
: activeComposerMode === "image"
@ -18258,16 +18287,17 @@ export function CanvasChatPanel({
</div>
) : null}
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? (
<div className="mx-4 mt-3 flex items-center">
<div className="lovart-reference-upload mx-[8px] mt-[8px] flex items-center">
<button
type="button"
className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
className="lovart-reference-tile relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
aria-label={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
>
<Plus className="h-4 w-4" />
<ReferenceIcon name="reference-image" size={16} />
<span>参考图</span>
{composerReferenceUploadProgress ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-white backdrop-blur-[1px]">
<span className="text-[10px] font-semibold leading-none tabular-nums">
@ -18329,6 +18359,7 @@ export function CanvasChatPanel({
) : null}
<div
className={cn(
"lovart-composer-text-region",
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
? "px-1"
: "px-2 pt-2"
@ -18337,6 +18368,11 @@ export function CanvasChatPanel({
<div
ref={textareaRef as any}
contentEditable={!isSubmitting}
role="textbox"
tabIndex={isSubmitting ? -1 : 0}
aria-disabled={isSubmitting}
aria-label="描述你的设计需求"
aria-multiline="true"
suppressContentEditableWarning
className={cn(
"max-h-[128px] overflow-y-auto outline-none empty:before:content-[attr(data-placeholder)]",
@ -18564,7 +18600,7 @@ export function CanvasChatPanel({
{/* Bottom Toolbar */}
<div
className={cn(
"mt-auto flex min-h-10 items-center justify-between gap-1.5 px-2.5 pb-2 pt-1 dark:bg-zinc-950"
"lovart-composer-footer mt-auto flex min-h-10 items-center justify-between gap-1.5 px-2.5 pb-2 pt-1 dark:bg-zinc-950"
)}
>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto pr-1 scrollbar-hide">
@ -18698,7 +18734,7 @@ export function CanvasChatPanel({
type="button"
variant="ghost"
className={cn(
"relative h-8 max-w-[110px] shrink-0 cursor-pointer rounded-full px-2.5 text-[13px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
"lovart-composer-mode-selector relative h-8 max-w-[110px] shrink-0 cursor-pointer rounded-full px-2.5 text-[13px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
isVideoToolbarDark
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
: "bg-[#eeeeef] text-[#20242a] hover:bg-[#e7e7e8] dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14"
@ -18724,6 +18760,8 @@ export function CanvasChatPanel({
className="mr-1 h-4 w-4 shrink-0"
fallback={ImagePlus}
item={getToolbarItem("chatModeImageActive")}
referenceName={!isWorkspacePresentation && !isVideoWorkspace ? "image" : undefined}
referenceSize={20}
/>
) : (
<CanvasToolbarIcon
@ -18733,7 +18771,9 @@ export function CanvasChatPanel({
/>
)}
<span className="min-w-0 truncate">{activeModeLabel}</span>
{isComposerModeMenuOpen ? (
{!isWorkspacePresentation && !isVideoWorkspace ? (
<ReferenceIcon name="chevron-down" size={18} className={cn("ml-1.5", isComposerModeMenuOpen && "rotate-180")} />
) : isComposerModeMenuOpen ? (
<ChevronUp className="ml-1.5 h-3.5 w-3.5 shrink-0" />
) : (
<ChevronDown className="ml-1.5 h-3.5 w-3.5 shrink-0" />
@ -18865,6 +18905,7 @@ export function CanvasChatPanel({
hideImageCount={false}
hideAdvancedOptions={true}
inlineTheme="minimal"
referencePresentation={!isWorkspacePresentation && !isVideoWorkspace}
showInlineDivider={false}
/>
) : null}
@ -18895,6 +18936,7 @@ export function CanvasChatPanel({
>
<SelectTrigger
className={cn(
cn(!isVideoWorkspace && activeComposerMode === "image" && "lovart-model-selector"),
"h-8 w-8 shrink-0 justify-center rounded-full p-0 outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&>svg]:hidden",
isVideoToolbarDark
? "border border-white/12 bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
@ -18902,6 +18944,13 @@ export function CanvasChatPanel({
)}
title={getToolbarItem("chatModelSelector").label}
>
{!isVideoWorkspace && activeComposerMode === "image" ? (
<>
<ModelIdentityIcon name={selectDisplayModel?.displayName || "自动模型"} identity={`${selectDisplayModel?.id || ""} ${selectDisplayModel?.providerType || ""}`} avatarUrl={selectDisplayModel?.avatarUrl ? buildPreviewImageUrl(selectDisplayModel.avatarUrl, 64) : undefined} size={18} />
<span className="min-w-0 flex-1 truncate">{selectDisplayModel?.displayName || "自动模型"}</span>
<ReferenceIcon name="chevron-down" size={18} />
</>
) : (
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none ring-1",
@ -18946,12 +18995,13 @@ export function CanvasChatPanel({
</span>
)}
</span>
)}
</SelectTrigger>
{renderModelSelectContent()}
</Select>
) : null}
{estimatedPointsCost ? (
{estimatedPointsCost && (isWorkspacePresentation || isVideoWorkspace || activeComposerMode !== "image") ? (
<div
className={cn(
"ml-0 flex h-7 max-w-[60px] shrink items-center gap-1 rounded-full px-2 text-[10px] sm:ml-1 sm:max-w-none sm:gap-1 sm:px-2 sm:text-[11px]",
@ -18976,6 +19026,7 @@ export function CanvasChatPanel({
<Button
size={isWorkspacePresentation ? "default" : "icon"}
className={cn(
"lovart-composer-send",
isWorkspacePresentation
? "h-9 rounded-full px-3.5 transition-all duration-150 flex-shrink-0"
: "h-8 w-8 rounded-full transition-all duration-150 flex-shrink-0",
@ -19006,6 +19057,8 @@ export function CanvasChatPanel({
? "停止处理并重新编辑"
: isWorkspacePresentation || isDetailGenerationMode
? getToolbarItem("chatStartGeneration").label
: !isVideoWorkspace && activeComposerMode === "image" && estimatedPointsCost
? `${getToolbarItem("chatSend").label} · ${estimatedPointsCost.title}`
: getToolbarItem("chatSend").label
}
>
@ -19039,6 +19092,11 @@ export function CanvasChatPanel({
</span>
) : null}
</>
) : !isWorkspacePresentation && !isVideoWorkspace && activeComposerMode === "image" && estimatedPointsCost ? (
<>
<CanvasToolbarIcon fallback={Sparkles} item={getToolbarItem("chatPoints")} referenceName="points" referenceSize={14} />
<span>{estimatedPointsCost.cost}</span>
</>
) : (
<>
<CanvasToolbarIcon

View File

@ -0,0 +1,54 @@
"use client";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
type CanvasComposerPromoProps = {
text: string;
onDismiss: () => void;
onActivate?: () => void;
};
export function CanvasComposerPromo({
text,
onDismiss,
onActivate,
}: CanvasComposerPromoProps) {
return (
<div className="lovart-chat-promo lovart-chat-promo-connected">
<div className="lovart-chat-promo-surface">
<div className="lovart-chat-promo-row">
<button
type="button"
className="lovart-chat-promo-message"
onClick={onActivate}
disabled={!onActivate}
title={text}
>
<svg
aria-hidden="true"
width="16"
height="18"
viewBox="0 0 24 28"
className="lovart-chat-promo-badge"
>
<path d="M10 1.15a4 4 0 0 1 4 0l8 4.6a4 4 0 0 1 2 3.46v9.58a4 4 0 0 1-2 3.46l-8 4.6a4 4 0 0 1-4 0l-8-4.6A4 4 0 0 1 0 18.79V9.21a4 4 0 0 1 2-3.46Z" fill="#ddef65" />
<path
d="M12 5.5c1.2 5.3 2.2 6.3 7.5 7.5-5.3 1.2-6.3 2.2-7.5 7.5C10.8 15.2 9.8 14.2 4.5 13c5.3-1.2 6.3-2.2 7.5-7.5Z"
fill="#111"
/>
</svg>
<span>{text}</span>
</button>
<button
type="button"
className="lovart-chat-promo-close"
aria-label="关闭优惠广告"
onClick={onDismiss}
>
<ReferenceIcon name="close" size={18} />
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Loader2, Save } from "lucide-react";
type CanvasSurfaceHeaderProps = {
isSaving: boolean;
onAddImage: () => void;
onHome: () => void;
onOpenFiles: () => void;
onSave: () => void;
projectTitle: string;
rightInset: number;
};
const iconButtonClass =
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
export function CanvasSurfaceHeader({
isSaving,
onAddImage,
onHome,
onOpenFiles,
onSave,
projectTitle,
rightInset,
}: CanvasSurfaceHeaderProps) {
return (
<div
className="absolute left-0 top-0 z-20 flex h-[40px] items-center justify-between border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950"
data-canvas-topbar="true"
style={{ right: rightInset }}
>
<div className="flex min-w-0 items-center gap-2">
<button
aria-label="返回首页"
className={iconButtonClass}
onClick={onHome}
title="返回首页"
type="button"
>
<ReferenceIcon name="branding" size={16} />
</button>
<button
aria-label="添加参考素材"
className={iconButtonClass}
onClick={onAddImage}
title="添加参考素材"
type="button"
>
<ReferenceIcon name="reference" size={16} />
</button>
<button
aria-label="查看项目文件"
className={iconButtonClass}
onClick={onOpenFiles}
title="查看项目文件"
type="button"
>
<ReferenceIcon name="brand" size={16} />
</button>
<div className="mx-1 h-4 w-px bg-[#ededed] dark:bg-white/10" />
<span className="inline-flex h-7 min-w-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[13px] text-[#333333] dark:bg-white/10 dark:text-white">
<ReferenceIcon name="page" size={16} />
<span className="max-w-[160px] truncate">
{projectTitle || "页面 1"}
</span>
</span>
<button
aria-label="添加到画布"
className={iconButtonClass}
onClick={onAddImage}
title="添加到画布"
type="button"
>
<ReferenceIcon name="plus" size={16} />
</button>
</div>
<button
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
disabled={isSaving}
onClick={onSave}
type="button"
>
{isSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存
</button>
</div>
);
}

View File

@ -1,6 +1,10 @@
"use client";
import type { CSSProperties } from "react";
import {
ReferenceIcon,
type ReferenceIconName,
} from "@/components/lovart/reference-icon";
import type { LucideIcon } from "lucide-react";
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
import { cn } from "@/lib/utils";
@ -12,13 +16,12 @@ type CanvasToolbarIconProps = {
className?: string;
style?: CSSProperties;
active?: boolean;
referenceName?: ReferenceIconName;
referenceSize?: number;
};
function isSvgIconUrl(url: string) {
return (
/^data:image\/svg\+xml/i.test(url) ||
/\.svg(?:[?#].*)?$/i.test(url)
);
return /^data:image\/svg\+xml/i.test(url) || /\.svg(?:[?#].*)?$/i.test(url);
}
function stripImageFilterClasses(className?: string) {
@ -42,6 +45,8 @@ export function CanvasToolbarIcon({
className,
style,
active = false,
referenceName,
referenceSize = 18,
}: CanvasToolbarIconProps) {
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
@ -75,7 +80,9 @@ export function CanvasToolbarIcon({
aria-hidden="true"
className={cn(
"object-contain",
active ? "brightness-0 invert opacity-95" : "brightness-0 opacity-80 dark:invert",
active
? "brightness-0 invert opacity-95"
: "brightness-0 opacity-80 dark:invert",
className
)}
src={resolvedIconUrl}
@ -84,5 +91,15 @@ export function CanvasToolbarIcon({
);
}
if (referenceName) {
return (
<ReferenceIcon
name={referenceName}
size={referenceSize}
className={className}
style={style}
/>
);
}
return <FallbackIcon className={className} style={style} />;
}

View File

@ -12,8 +12,12 @@ import { UiScreenNode } from "./nodes/UiScreenNode";
import { VideoNode } from "./nodes/VideoNode";
import { WorkflowNode } from "./nodes/WorkflowNode";
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu";
import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { imageGenerationService } from "@/services/canvas";
import {
@ -28,6 +32,7 @@ import {
LayoutTemplate,
LayoutGrid,
Lightbulb,
Loader2,
Minus,
Pin,
Plus,
@ -390,6 +395,11 @@ export function CanvasWorkspace({
} | null;
const containerRef = useRef<HTMLDivElement>(null);
const contentLayerRef = useRef<HTMLDivElement>(null);
const contextImageInputRef = useRef<HTMLInputElement>(null);
const contextVideoInputRef = useRef<HTMLInputElement>(null);
const contextCanvasPointRef = useRef<Position>({ x: 0, y: 0 });
const [blankContextPosition, setBlankContextPosition] = useState<Position | null>(null);
const [isContextVideoUploading, setIsContextVideoUploading] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
@ -727,10 +737,6 @@ export function CanvasWorkspace({
canvasToolbarConfig,
"imageLayoutReplace"
);
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
const selectedImageLayoutReplaceEnabled = true;
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
@ -1106,42 +1112,61 @@ export function CanvasWorkspace({
);
const fitAllImageNodesIntoViewport = useCallback(() => {
const imageNodes = nodes.filter(
(node) => node.type === "image" && Boolean((node.data as any)?.src)
const fit = getCanvasFitViewport(
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
containerSize, occlusionRight
);
if (imageNodes.length === 0) return;
if (containerSize.width <= 0 || containerSize.height <= 0) return;
if (fit) setViewport(fit);
}, [containerSize, nodes, occlusionRight, setViewport]);
const padding = 80;
const minX = Math.min(...imageNodes.map((node) => node.position.x));
const minY = Math.min(...imageNodes.map((node) => node.position.y));
const maxX = Math.max(
...imageNodes.map((node) => node.position.x + node.size.width)
);
const maxY = Math.max(
...imageNodes.map((node) => node.position.y + node.size.height)
);
const fitAllCanvasNodesIntoViewport = useCallback(() => {
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
if (fit) setViewport(fit);
}, [containerSize, nodes, occlusionRight, setViewport]);
const contentWidth = Math.max(1, maxX - minX + padding * 2);
const contentHeight = Math.max(1, maxY - minY + padding * 2);
const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => {
if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return;
if (!(event.target instanceof Element) || event.target.closest(
'[data-canvas-node-id], [data-canvas-overlay], [data-canvas-no-drag="true"], button, input, textarea, select, a, [contenteditable="true"]'
)) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
contextCanvasPointRef.current = {
x: (event.clientX - rect.left - viewportRef.current.x) / viewportRef.current.zoom,
y: (event.clientY - rect.top - viewportRef.current.y) / viewportRef.current.zoom,
};
setBlankContextPosition({ x: event.clientX, y: event.clientY });
setAgentSlotContextMenu(null);
}, [canvasImageSelectionSession, hasBlockingDialogOpen, isWorkflowCanvas]);
const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight));
const fitZoomX = visibleWidth / contentWidth;
const fitZoomY = containerSize.height / contentHeight;
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
const zoomCanvasFromContextMenu = useCallback((factor: number) => {
const current = useCanvasStore.getState().viewport;
const zoom = Math.max(0.1, Math.min(4, current.zoom * factor));
const centerX = Math.max(1, containerSize.width - occlusionRight) / 2;
const centerY = containerSize.height / 2;
const ratio = zoom / current.zoom;
setViewport({ x: centerX - (centerX - current.x) * ratio, y: centerY - (centerY - current.y) * ratio, zoom });
}, [containerSize, occlusionRight, setViewport]);
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
const visibleCenterX = visibleWidth / 2;
const nextViewportX = visibleCenterX - centerX * targetZoom;
const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
setViewport({
x: nextViewportX,
y: nextViewportY,
zoom: targetZoom,
});
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
React.useEffect(() => {
if (isWorkflowCanvas) return;
const handleFitShortcut = (event: KeyboardEvent) => {
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return;
if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest(
'input, textarea, [contenteditable="true"], [role="textbox"]'
))) return;
const active = document.activeElement;
if (active && active !== document.body && !containerRef.current?.contains(active)
&& !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return;
event.preventDefault();
fitAllCanvasNodesIntoViewport();
setBlankContextPosition(null);
};
window.addEventListener("keydown", handleFitShortcut);
return () => window.removeEventListener("keydown", handleFitShortcut);
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
const getNodeInputAnchor = useCallback(
(node: CanvasNode): Position => ({
@ -1258,6 +1283,7 @@ export function CanvasWorkspace({
if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return;
setAgentSlotContextMenu(null);
setBlankContextPosition(null);
if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false;
@ -1294,7 +1320,7 @@ export function CanvasWorkspace({
!isPointOnNodeElement(e.target);
if (
tool === 'pan' ||
(tool === 'pan' && e.button === 0) ||
e.button === 1 ||
(e.button === 0 && e.altKey) ||
shouldPanBlankWorkflowCanvas
@ -1657,6 +1683,105 @@ export function CanvasWorkspace({
}
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
const getContextUploadClientPoint = useCallback(() => {
const rect = containerRef.current?.getBoundingClientRect();
const current = viewportRef.current;
return {
x: (rect?.left ?? 0) + current.x + contextCanvasPointRef.current.x * current.zoom,
y: (rect?.top ?? 0) + current.y + contextCanvasPointRef.current.y * current.zoom,
};
}, []);
const pasteCanvasText = useCallback((text: string, point: Position) => {
if (!text.trim()) return;
addNode({
type: "text", position: point, size: { width: 320, height: 160 },
data: { content: text, color: "#111827" }, connections: [],
});
}, [addNode]);
const uploadContextVideos = useCallback(async (files: File[]) => {
if (!files.length || isContextVideoUploading) return;
const position = { ...contextCanvasPointRef.current };
setIsContextVideoUploading(true);
try {
for (const [index, file] of files.entries()) {
if (!["video/mp4", "video/quicktime", "video/webm"].includes(file.type)) {
toast.error("请选择 MP4、MOV 或 WebM 视频");
continue;
}
if (file.size > 50 * 1024 * 1024) {
toast.error("视频文件不能超过 50MB");
continue;
}
const loadingToast = toast.loading(`正在上传 ${file.name}`);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "canvas-video-process");
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result.url) throw new Error(result.error || "视频上传失败");
addNode({
type: "video", position: { x: position.x + index * 24, y: position.y + index * 24 },
size: { width: 320, height: 180 },
data: { src: result.url, assetId: result.assetId, status: "completed", prompt: file.name,
metadata: { source: "local-upload", fileName: file.name, contentType: file.type } },
connections: [],
});
toast.success("视频已添加到画布");
} catch (error) {
toast.error(error instanceof Error ? error.message : "视频上传失败,请重试");
} finally {
toast.dismiss(loadingToast);
}
}
} finally {
setIsContextVideoUploading(false);
}
}, [addNode, isContextVideoUploading]);
const pasteFromContextMenu = useCallback(async () => {
const pastePoint = { ...contextCanvasPointRef.current };
const clientPoint = getContextUploadClientPoint();
try {
const files: File[] = [];
let text = "";
if (typeof navigator.clipboard?.read === "function") {
const items = await navigator.clipboard.read();
for (const [index, item] of items.entries()) {
const imageType = item.types.find((type) => type.startsWith("image/"));
if (imageType) {
const blob = await item.getType(imageType);
files.push(new File([blob], `粘贴图片-${Date.now()}-${index}.${imageType.split("/")[1]}`, { type: imageType }));
} else if (item.types.includes("text/plain")) {
text += await (await item.getType("text/plain")).text();
}
}
} else if (typeof navigator.clipboard?.readText === "function") {
text = await navigator.clipboard.readText();
} else {
throw new Error("Clipboard unavailable");
}
if (files.length) {
await processDroppedFiles(files, clientPoint.x, clientPoint.y);
} else if (text.trim()) {
pasteCanvasText(text, pastePoint);
} else {
toast.info("剪贴板中没有可粘贴的图片或文字");
}
} catch {
toast.info(`无法读取剪贴板,请使用 ${isMacPlatform ? "⌘V" : "Ctrl+V"} 粘贴`);
}
}, [getContextUploadClientPoint, isMacPlatform, pasteCanvasText, processDroppedFiles]);
React.useEffect(() => {
if (!blankContextPosition) return;
const close = () => setBlankContextPosition(null);
window.addEventListener("resize", close);
return () => window.removeEventListener("resize", close);
}, [blankContextPosition]);
React.useEffect(() => {
const handleWindowDrop = async (e: DragEvent) => {
if (!isFileDrag(e.dataTransfer)) return;
@ -1719,24 +1844,44 @@ export function CanvasWorkspace({
React.useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return;
const activeElement = document.activeElement;
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
const container = containerRef.current;
if (!container) return;
const activeElement = document.activeElement;
if (
activeElement instanceof HTMLElement &&
activeElement !== document.body &&
!container.contains(activeElement)
!container.contains(activeElement) && !fromContextMenu
) {
return;
}
const files = extractImageFilesFromDataTransfer(e.clipboardData);
if (files.length === 0) return;
const text = e.clipboardData?.getData("text/plain") || "";
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
e.preventDefault();
const rect = container.getBoundingClientRect();
if (fromContextMenu) {
setBlankContextPosition(null);
if (files.length) {
const point = getContextUploadClientPoint();
await processDroppedFiles(files, point.x, point.y);
} else {
pasteCanvasText(text, { ...contextCanvasPointRef.current });
}
return;
}
if (files.length === 0) {
const current = viewportRef.current;
pasteCanvasText(text, {
x: (rect.width / 2 - current.x) / current.zoom,
y: (rect.height / 2 - current.y) / current.zoom,
});
return;
}
await processDroppedFiles(
files,
rect.left + rect.width / 2,
@ -1746,7 +1891,7 @@ export function CanvasWorkspace({
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [hasBlockingDialogOpen, processDroppedFiles]);
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]);
const handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) {
@ -2269,12 +2414,47 @@ export function CanvasWorkspace({
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={handleBlankCanvasDoubleClick}
onContextMenu={handleBlankCanvasContextMenu}
onWheel={handleWheel}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{!isWorkflowCanvas && (
<>
<input
ref={contextImageInputRef} type="file" accept="image/*" multiple className="hidden"
aria-label="从右键菜单上传图片"
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
const point = getContextUploadClientPoint();
void processDroppedFiles(files, point.x, point.y);
}}
/>
<input
ref={contextVideoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" multiple className="hidden"
aria-label="从右键菜单上传视频"
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
void uploadContextVideos(files);
}}
/>
<CanvasBlankContextMenu
position={blankContextPosition} isMac={isMacPlatform} videoUploading={isContextVideoUploading}
onClose={() => setBlankContextPosition(null)}
onUploadImage={() => contextImageInputRef.current?.click()}
onUploadVideo={() => contextVideoInputRef.current?.click()}
onPaste={() => { void pasteFromContextMenu(); }}
onZoomIn={() => zoomCanvasFromContextMenu(1.2)}
onZoomOut={() => zoomCanvasFromContextMenu(1 / 1.2)}
onFitAll={fitAllCanvasNodesIntoViewport}
onResetZoom={() => setViewport({ x: 0, y: 0, zoom: 1 })}
/>
</>
)}
{isWorkflowCanvas ? (
<>
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
@ -2566,10 +2746,14 @@ export function CanvasWorkspace({
)}
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
<div
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
className={cn(
"absolute left-4 z-30 flex items-center gap-2",
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
)}
data-canvas-overlay
data-canvas-selection-actions="true"
>
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
@ -2591,7 +2775,7 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label}
</Button>
) : null}
{selectedImageLayoutReplaceEnabled &&
{isWorkflowCanvas &&
selectedImageNodes.length >= 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
@ -2620,27 +2804,48 @@ export function CanvasWorkspace({
{selectedImageLayoutReplaceItem.label}
</Button>
) : null}
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
className={cn(
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
isWorkflowCanvas &&
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
)}
onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting}
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
>
<CanvasToolbarIcon
className="h-3.5 w-3.5"
fallback={Download}
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
/>
{isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size={isWorkflowCanvas ? "sm" : "icon"}
className={cn(
isWorkflowCanvas
? "h-8 gap-1.5 rounded-full border border-[#dbe2ee] bg-white px-3 text-xs text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
: "h-[32px] w-[32px] rounded-[8px] border border-[#e9e9e9] bg-white p-0 text-[#333333] shadow-[0_1px_3px_rgba(0,0,0,0.035)] hover:bg-[#f5f5f5] dark:border-white/10 dark:bg-zinc-950 dark:text-white dark:hover:bg-white/10"
)}
onClick={handleDownloadSelectedImages}
disabled={isDownloadSubmitting}
aria-label={
isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
}
>
{!isWorkflowCanvas && isDownloadSubmitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<CanvasToolbarIcon
className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"}
fallback={Download}
referenceName={isWorkflowCanvas ? undefined : "image-download"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
/>
)}
{isWorkflowCanvas
? isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
: null}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{isDownloadSubmitting
? "打包中..."
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
</TooltipContent>
</Tooltip>
</div>
)}

View File

@ -0,0 +1,452 @@
"use client";
import { ChevronRight, X } from "lucide-react";
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useState,
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
} from "react";
import { toast } from "sonner";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
saveImageToolbarPreferences,
useImageToolbarPreferences,
type ImageToolbarPreferences,
} from "@/lib/canvas-image-toolbar-preferences";
type ToolProps = HTMLAttributes<HTMLButtonElement> & {
"data-tool-key"?: string;
disabled?: boolean;
children?: ReactNode;
};
type Tool = ReactElement<ToolProps>;
type Props = HTMLAttributes<HTMLDivElement> & {
referencePresentation: boolean;
defaultPinnedKeys: string[];
};
function toolLabel(tool: Tool) {
return tool.props.title || "";
}
function toolIcon(tool: Tool, size: number) {
const icon = Children.toArray(tool.props.children).find(isValidElement);
if (
!isValidElement<{
className?: string;
style?: object;
referenceSize?: number;
}>(icon)
)
return null;
return cloneElement(icon, {
className: "shrink-0",
style: { width: size, height: size },
...(icon.type === CanvasToolbarIcon ? { referenceSize: size } : {}),
});
}
function toolCredit(tool: Tool) {
return Children.toArray(tool.props.children).find(
(child) =>
isValidElement<{ compact?: boolean; label?: string }>(child) &&
child.props.compact &&
child.props.label
) as
| ReactElement<{ compact?: boolean; label?: string; className?: string }>
| undefined;
}
function tooltipButton(tool: Tool, showNames: boolean) {
const name = toolLabel(tool);
// Clone the existing action so its handler, busy state and administrator icon override remain intact.
const button = cloneElement(tool, {
title: undefined,
"aria-label": name,
children: (
<>
{toolIcon(tool, 25)}
{showNames && <span className="lovart-image-tool-label">{name}</span>}
</>
),
});
return (
<Tooltip key={tool.props["data-tool-key"] || name}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
className="lovart-image-tool-tooltip"
side="top"
sideOffset={8}
>
{name}
{toolCredit(tool) &&
cloneElement(toolCredit(tool)!, {
compact: false,
className: "lovart-image-tool-cost",
})}
</TooltipContent>
</Tooltip>
);
}
export const ReferenceImageToolbar = forwardRef<HTMLDivElement, Props>(
function ReferenceImageToolbar(
{ referencePresentation, defaultPinnedKeys, children, ...rootProps },
ref
) {
const saved = useImageToolbarPreferences();
const [draft, setDraft] = useState<ImageToolbarPreferences | null>(null);
const [draggedKey, setDraggedKey] = useState<string | null>(null);
const elements = Children.toArray(children).filter(
isValidElement
) as ReactElement<ToolProps>[];
const tools = elements.filter(
(element) =>
element.props["data-tool-key"] &&
!["addToChat", "download"].includes(element.props["data-tool-key"]!)
);
const toolMap = new Map(
tools.map((tool) => [tool.props["data-tool-key"]!, tool])
);
const preferences = saved || {
pinnedKeys: defaultPinnedKeys,
showNames: false,
};
const pinned = preferences.pinnedKeys.filter((key) => toolMap.has(key));
const moreTools = tools.filter(
(tool) => !pinned.includes(tool.props["data-tool-key"]!)
);
const addToChat = elements.find(
(element) => element.props["data-tool-key"] === "addToChat"
);
const download = elements.find(
(element) => element.props["data-tool-key"] === "download"
);
const originalMenu = elements.find(
(element) => element.type === DropdownMenu
);
const menuChildren = originalMenu
? (Children.toArray(originalMenu.props.children).filter(
isValidElement
) as ReactElement<{ children?: ReactNode }>[])
: [];
const menuTrigger = menuChildren.find(
(element) => element.type === DropdownMenuTrigger
);
const moreButton =
menuTrigger &&
(Children.toArray(menuTrigger.props.children).find(isValidElement) as
| Tool
| undefined);
const extras = menuChildren.find(
(element) => element.type === DropdownMenuContent
)?.props.children;
const openCustomizer = () =>
setDraft({
pinnedKeys: [...preferences.pinnedKeys],
showNames: preferences.showNames,
});
const toggle = (key: string) =>
setDraft((current) =>
current
? {
...current,
pinnedKeys: current.pinnedKeys.includes(key)
? current.pinnedKeys.filter((item) => item !== key)
: [...current.pinnedKeys, key],
}
: current
);
const move = (key: string, target: string) =>
setDraft((current) => {
if (!current || key === target) return current;
const next = [...current.pinnedKeys];
const from = next.indexOf(key),
to = next.indexOf(target);
if (from < 0 || to < 0) return current;
next.splice(from, 1);
next.splice(to, 0, key);
return { ...current, pinnedKeys: next };
});
if (!referencePresentation)
return (
<div {...rootProps} ref={ref}>
{children}
</div>
);
return (
<>
<div
{...rootProps}
ref={ref}
style={{ ...rootProps.style, overflowX: "auto" }}
data-show-tool-names={preferences.showNames ? "true" : "false"}
>
{addToChat && tooltipButton(addToChat, preferences.showNames)}
{addToChat && <div className="w-px" />}
{pinned.map((key) =>
tooltipButton(toolMap.get(key)!, preferences.showNames)
)}
<DropdownMenu>
{moreButton && (
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
{cloneElement(moreButton, {
title: undefined,
"aria-label": toolLabel(moreButton),
children: toolIcon(moreButton, 25),
})}
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent
className="lovart-image-tool-tooltip"
sideOffset={8}
>
{toolLabel(moreButton)}
</TooltipContent>
</Tooltip>
)}
<DropdownMenuContent
className="lovart-image-action-menu lovart-image-custom-more"
side="bottom"
align="end"
>
{moreTools.map((tool) => (
<DropdownMenuItem
key={tool.props["data-tool-key"]}
disabled={tool.props.disabled}
onClick={(event) =>
tool.props.onClick?.(
event as unknown as MouseEvent<HTMLButtonElement>
)
}
>
{toolIcon(tool, 18)}
<span>{toolLabel(tool)}</span>
{toolCredit(tool) &&
cloneElement(toolCredit(tool)!, {
compact: false,
className: "lovart-image-tool-cost",
})}
</DropdownMenuItem>
))}
{extras}
<DropdownMenuSeparator />
<DropdownMenuItem
className="lovart-customize-entry"
onSelect={openCustomizer}
>
自定义工具栏
<ChevronRight size={14} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{download && (
<>
<div className="w-px" />
{tooltipButton(download, preferences.showNames)}
</>
)}
</div>
<Dialog
open={Boolean(draft)}
onOpenChange={(open) => {
if (!open) setDraft(null);
}}
>
<DialogContent
hideCloseButton
className="lovart-toolbar-customizer"
overlayClassName="lovart-toolbar-customizer-overlay"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<header className="lovart-customizer-header">
<div>
<DialogTitle>自定义工具栏</DialogTitle>
<DialogDescription>
选择你想在编辑栏中使用的工具
</DialogDescription>
</div>
<div className="lovart-customizer-header-actions">
<button
type="button"
aria-label="恢复默认工具栏"
onClick={() =>
setDraft({
pinnedKeys: [...defaultPinnedKeys],
showNames: false,
})
}
>
<ReferenceIcon name="toolbar-reset" size={16} />
</button>
<button
type="button"
aria-label="关闭自定义工具栏"
onClick={() => setDraft(null)}
>
<ReferenceIcon name="toolbar-close" size={16} />
</button>
</div>
</header>
<div className="lovart-customizer-body">
<div
className="lovart-customizer-preview"
aria-label="工具栏预览"
>
<div className="lovart-customizer-preview-bar">
{addToChat && (
<span className="lovart-preview-fixed">
{toolIcon(addToChat, 18)}
{draft?.showNames && toolLabel(addToChat)}
</span>
)}
{draft?.pinnedKeys
.filter((key) => toolMap.has(key))
.map((key, index, keys) => {
const tool = toolMap.get(key)!;
return (
<div
className="lovart-preview-chip"
key={key}
draggable
onDragStart={(event) => {
setDraggedKey(key);
event.dataTransfer.setData("text/plain", key);
event.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setDraggedKey(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
if (draggedKey) move(draggedKey, key);
setDraggedKey(null);
}}
>
<button
type="button"
className="lovart-preview-handle"
aria-label={`${toolLabel(tool)},按左右方向键调整顺序`}
onKeyDown={(event) => {
const offset =
event.key === "ArrowLeft"
? -1
: event.key === "ArrowRight"
? 1
: 0;
if (offset && keys[index + offset]) {
event.preventDefault();
move(key, keys[index + offset]);
}
}}
>
{toolIcon(tool, 18)}
{draft.showNames && <span>{toolLabel(tool)}</span>}
</button>
<button
type="button"
aria-label={`移除${toolLabel(tool)}`}
onClick={() => toggle(key)}
>
<X size={12} />
</button>
</div>
);
})}
</div>
</div>
<div className="lovart-customizer-tools">
{tools.map((tool) => {
const key = tool.props["data-tool-key"]!;
const isPinned = Boolean(draft?.pinnedKeys.includes(key));
return (
<button
key={key}
type="button"
aria-label={`${isPinned ? "取消固定" : "固定"}${toolLabel(tool)}`}
aria-pressed={isPinned}
onClick={() => toggle(key)}
>
{toolIcon(tool, 18)}
<span>{toolLabel(tool)}</span>
<ReferenceIcon
name={
isPinned
? "toolbar-pin-filled"
: "toolbar-pin-outline"
}
size={13}
/>
</button>
);
})}
</div>
</div>
<footer className="lovart-customizer-footer">
<label>
<button
type="button"
role="switch"
aria-label="显示工具名称"
aria-checked={Boolean(draft?.showNames)}
className="lovart-customizer-switch"
onClick={() =>
setDraft((current) =>
current
? { ...current, showNames: !current.showNames }
: current
)
}
>
<span />
</button>
显示工具名称
</label>
<div>
<button type="button" onClick={() => setDraft(null)}>
取消
</button>
<button
type="button"
className="lovart-customizer-save"
onClick={() => {
if (draft && saveImageToolbarPreferences(draft))
setDraft(null);
else
toast.error("无法保存工具栏设置,请检查浏览器存储权限");
}}
>
保存
</button>
</div>
</footer>
</DialogContent>
</Dialog>
</>
);
}
);

View File

@ -42,7 +42,7 @@ import {
Zap,
} from "lucide-react";
import type React from "react";
import { useParams, useRouter } from "next/navigation";
import { useParams, usePathname, useRouter } from "next/navigation";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
@ -56,6 +56,7 @@ import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayout
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import {
@ -1307,6 +1308,8 @@ export function ImageNode({
}: ImageNodeProps) {
const router = useRouter();
const routeParams = useParams();
const pathname = usePathname();
const useReferenceImageToolbar = /^\/canvas\/[^/]+\/?$/.test(pathname);
const canvasId = Array.isArray(routeParams?.id)
? routeParams.id[0] || ""
: typeof routeParams?.id === "string"
@ -1532,9 +1535,11 @@ export function ImageNode({
.map((action) => action.key)
);
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
selectedImageInlineActionKeys.has(key);
useReferenceImageToolbar
? enabledSelectedImageToolbarActions.some((action) => action.key === key)
: selectedImageInlineActionKeys.has(key);
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
selectedImageMoreActionKeys.has(key);
!useReferenceImageToolbar && selectedImageMoreActionKeys.has(key);
const hasHoverImageMorePrimaryActions =
imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled ||
@ -1560,6 +1565,7 @@ export function ImageNode({
const hasSelectedImageMorePrimaryActions =
selectedImageMoreActionKeys.size > 0;
const hasSelectedImageMoreMenu =
useReferenceImageToolbar ||
hasSelectedImageMorePrimaryActions ||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
(imageNodeMoreEnabled && imageDeleteEnabled);
@ -9640,6 +9646,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/>
<span>{imageLayoutReplaceItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10192,8 +10200,12 @@ export function ImageNode({
typeof document !== "undefined" &&
createPortal(
<TooltipProvider delayDuration={200}>
<div
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62"
<ReferenceImageToolbar
referencePresentation={useReferenceImageToolbar}
defaultPinnedKeys={enabledSelectedImageToolbarActions.slice(0, selectedImageInlineActionCount).map((action) => action.key)}
className={cn("fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62", useReferenceImageToolbar && "lovart-image-action-bar")}
data-selected-image-toolbar="true"
data-reference-presentation={useReferenceImageToolbar ? "true" : undefined}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
@ -10217,7 +10229,8 @@ export function ImageNode({
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
disabled={isProcessing || isAddingToChat}
onClick={handleAddToChat}
title={imageAddToChatItem.label}
data-tool-key="addToChat"
title={imageAddToChatItem.label}
variant="ghost"
>
{isAddingToChat ? (
@ -10228,6 +10241,8 @@ export function ImageNode({
className="h-4 w-4 dark:invert-0"
fallback={MessageSquarePlus}
item={imageAddToChatItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={25}
/>
)}
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
@ -10242,6 +10257,7 @@ export function ImageNode({
{isSelectedImageActionInline("pickObject") ? (
<Button
data-tool-key="pickObject"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isPickerLoading || isThisNodePicking}
onClick={handleStartPicking}
@ -10252,6 +10268,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Crosshair}
item={imagePickObjectItem}
referenceName={useReferenceImageToolbar ? "image-move-object" : undefined}
referenceSize={25}
/>
{imagePickObjectItem.label}
</Button>
@ -10259,6 +10277,7 @@ export function ImageNode({
{isSelectedImageActionInline("enhance") ? (
<Button
data-tool-key="enhance"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("enhance")}
@ -10269,6 +10288,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
item={imageEnhanceItem}
referenceName={useReferenceImageToolbar ? "image-upscale" : undefined}
referenceSize={25}
/>
{imageEnhanceItem.label}
<ImageActionCreditBadge compact label={enhanceCostShortLabel} />
@ -10277,6 +10298,7 @@ export function ImageNode({
{isSelectedImageActionInline("removeBackground") ? (
<Button
data-tool-key="removeBackground"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("rmbg")}
@ -10287,6 +10309,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Scissors}
item={imageRemoveBackgroundItem}
referenceName={useReferenceImageToolbar ? "image-remove-background" : undefined}
referenceSize={25}
/>
{imageRemoveBackgroundItem.label}
<ImageActionCreditBadge
@ -10298,6 +10322,7 @@ export function ImageNode({
{isSelectedImageActionInline("aiWashQuick") ? (
<Button
data-tool-key="aiWashQuick"
className={cn(
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
aiWashQuickPanelOpen
@ -10307,6 +10332,7 @@ export function ImageNode({
disabled={isProcessing || isSubmittingAiWashQuick}
onClick={() => handleAction("ai-wash")}
title={aiWashQuickModeLabel}
data-reference-active={aiWashQuickPanelOpen ? "true" : undefined}
variant="ghost"
>
{isSubmittingAiWashQuick ? (
@ -10317,6 +10343,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
iconUrl={aiWashQuickConfig.modeIconUrl}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={25}
/>
)}
{aiWashQuickModeLabel}
@ -10329,6 +10357,7 @@ export function ImageNode({
{isSelectedImageActionInline("textEdit") ? (
<Button
data-tool-key="textEdit"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
onClick={() => handleAction("text-edit")}
@ -10339,6 +10368,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Type}
item={imageTextEditItem}
referenceName={useReferenceImageToolbar ? "image-edit-text" : undefined}
referenceSize={25}
/>
{imageTextEditItem.label}
</Button>
@ -10346,6 +10377,7 @@ export function ImageNode({
{isSelectedImageActionInline("boxCutout") ? (
<Button
data-tool-key="boxCutout"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("box-cutout")}
@ -10356,6 +10388,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Scan}
item={imageBoxCutoutItem}
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
referenceSize={25}
/>
{imageBoxCutoutItem.label}
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
@ -10364,6 +10398,7 @@ export function ImageNode({
{isSelectedImageActionInline("localEdit") ? (
<Button
data-tool-key="localEdit"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing || isSubmittingLocalEdit}
onClick={() => handleAction("local-edit")}
@ -10374,6 +10409,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Wand2}
item={imageLocalEditItem}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={25}
/>
{imageLocalEditItem.label}
<ImageActionCreditBadge
@ -10385,6 +10422,7 @@ export function ImageNode({
{isSelectedImageActionInline("layoutReplace") ? (
<Button
data-tool-key="layoutReplace"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("layout-replace")}
@ -10395,6 +10433,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/>
{imageLayoutReplaceItem.label}
<ImageActionCreditBadge
@ -10406,6 +10446,7 @@ export function ImageNode({
{isSelectedImageActionInline("removeWatermark") ? (
<Button
data-tool-key="removeWatermark"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("watermark")}
@ -10416,6 +10457,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
referenceSize={25}
/>
{imageRemoveWatermarkItem.label}
<ImageActionCreditBadge
@ -10427,6 +10470,7 @@ export function ImageNode({
{isSelectedImageActionInline("resize") ? (
<Button
data-tool-key="resize"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("resize")}
@ -10444,6 +10488,7 @@ export function ImageNode({
{isSelectedImageActionInline("multiAngle") ? (
<Button
data-tool-key="multiAngle"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("angle")}
@ -10454,6 +10499,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
referenceSize={25}
/>
{imageMultiAngleItem.label}
<ImageActionCreditBadge
@ -10465,6 +10512,7 @@ export function ImageNode({
{isSelectedImageActionInline("generate3dModel") ? (
<Button
data-tool-key="generate3dModel"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("generate-3d-model")}
@ -10486,6 +10534,7 @@ export function ImageNode({
{isSelectedImageActionInline("generate3dWorld") ? (
<Button
data-tool-key="generate3dWorld"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("generate-3d-world")}
@ -10507,6 +10556,7 @@ export function ImageNode({
{isSelectedImageActionInline("layerSplit") ? (
<Button
data-tool-key="layerSplit"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("layer-split")}
@ -10517,6 +10567,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Layers}
item={imageLayerSplitItem}
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
referenceSize={25}
/>
{imageLayerSplitItem.label}
</Button>
@ -10524,6 +10576,7 @@ export function ImageNode({
{isSelectedImageActionInline("extend") ? (
<Button
data-tool-key="extend"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("extend")}
@ -10534,6 +10587,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
referenceSize={25}
/>
{imageExtendItem.label}
<ImageActionCreditBadge compact label={extendCostShortLabel} />
@ -10542,6 +10597,7 @@ export function ImageNode({
{isSelectedImageActionInline("crop") ? (
<Button
data-tool-key="crop"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("crop")}
@ -10552,6 +10608,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={Crop}
item={imageCropItem}
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
referenceSize={25}
/>
{imageCropItem.label}
</Button>
@ -10559,6 +10617,7 @@ export function ImageNode({
{isSelectedImageActionInline("adjust") ? (
<Button
data-tool-key="adjust"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("adjust")}
@ -10569,6 +10628,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={SlidersHorizontal}
item={imageAdjustItem}
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
referenceSize={25}
/>
{imageAdjustItem.label}
</Button>
@ -10576,6 +10637,7 @@ export function ImageNode({
{isSelectedImageActionInline("flipRotate") ? (
<Button
data-tool-key="flipRotate"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("flip-rotate")}
@ -10586,6 +10648,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={FlipHorizontal2}
item={imageFlipRotateItem}
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
referenceSize={25}
/>
{imageFlipRotateItem.label}
</Button>
@ -10593,6 +10657,7 @@ export function ImageNode({
{isSelectedImageActionInline("promptRegenerate") ? (
<Button
data-tool-key="promptRegenerate"
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("prompt-regenerate")}
@ -10614,6 +10679,7 @@ export function ImageNode({
{isSelectedImageActionInline("note") ? (
<Button
data-tool-key="note"
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
disabled={isProcessing}
onClick={() => handleAction("note")}
@ -10624,6 +10690,8 @@ export function ImageNode({
className="mr-1.5 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={25}
/>
{imageNoteItem.label}
</Button>
@ -10648,12 +10716,14 @@ export function ImageNode({
className="h-4 w-4"
fallback={MoreHorizontal}
item={imageNodeMoreItem}
referenceName={useReferenceImageToolbar ? "image-more" : undefined}
referenceSize={25}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100"
className={cn("bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100", useReferenceImageToolbar && "lovart-image-action-menu")}
side="bottom"
>
{isSelectedImageActionInMore("boxCutout") ? (
@ -10662,6 +10732,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={Scan}
item={imageBoxCutoutItem}
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
referenceSize={18}
/>
<span>{imageBoxCutoutItem.label}</span>
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
@ -10673,6 +10745,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={Wand2}
item={imageLocalEditItem}
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
referenceSize={18}
/>
<span>{imageLocalEditItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10686,6 +10760,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={LayoutTemplate}
item={imageLayoutReplaceItem}
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
referenceSize={18}
/>
<span>{imageLayoutReplaceItem.label}</span>
<ImageActionCreditBadge label={localEditCostShortLabel} />
@ -10697,6 +10773,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={Eraser}
item={imageRemoveWatermarkItem}
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
referenceSize={18}
/>
<span>{imageRemoveWatermarkItem.label}</span>
<ImageActionCreditBadge
@ -10720,6 +10798,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={Box}
item={imageMultiAngleItem}
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
referenceSize={18}
/>
<span>{imageMultiAngleItem.label}</span>
<ImageActionCreditBadge
@ -10761,6 +10841,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={Layers}
item={imageLayerSplitItem}
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
referenceSize={18}
/>
{imageLayerSplitItem.label}
</DropdownMenuItem>
@ -10771,6 +10853,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={Expand}
item={imageExtendItem}
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
referenceSize={18}
/>
<span>{imageExtendItem.label}</span>
<ImageActionCreditBadge label={extendCostShortLabel} />
@ -10782,6 +10866,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={Crop}
item={imageCropItem}
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
referenceSize={18}
/>
{imageCropItem.label}
</DropdownMenuItem>
@ -10792,6 +10878,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={SlidersHorizontal}
item={imageAdjustItem}
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
referenceSize={18}
/>
{imageAdjustItem.label}
</DropdownMenuItem>
@ -10802,6 +10890,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={FlipHorizontal2}
item={imageFlipRotateItem}
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
referenceSize={18}
/>
{imageFlipRotateItem.label}
</DropdownMenuItem>
@ -10827,6 +10917,8 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={18}
/>
{imageNoteItem.label}
</DropdownMenuItem>
@ -10866,7 +10958,8 @@ export function ImageNode({
void handleDownload();
}}
size="icon"
title={imageDownloadItem.label}
data-tool-key="download"
title={imageDownloadItem.label}
variant="ghost"
>
{isDownloadingOriginalImage ? (
@ -10876,11 +10969,13 @@ export function ImageNode({
className="h-4 w-4"
fallback={Download}
item={imageDownloadItem}
referenceName={useReferenceImageToolbar ? "image-download" : undefined}
referenceSize={25}
/>
)}
</Button>
) : null}
</div>
</ReferenceImageToolbar>
</TooltipProvider>,
document.body
)}

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
"use client";
import "@/public/vendor/drawnix.css";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
@ -7,6 +9,7 @@ type FlowBoardClientProps = {
siteName: string;
siteLogo: string | null;
siteLogoDark: string | null;
previewMode?: boolean;
};
const DrawnixWorkspace = dynamic(

View File

@ -0,0 +1,206 @@
import {
addSelectedElement,
BoardTransforms,
clearSelectedElement,
PlaitBoard,
type PlaitElement,
PlaitHistoryBoard,
PlaitPointerType,
type Point,
Transforms,
} from "@plait/core";
import {
ArrowLineMarkerType,
ArrowLineShape,
BasicShapes,
createArrowLineElement,
createGeometryElementWithoutText,
} from "@plait/draw";
import { getRectangleByNode, MindElement } from "@plait/mind";
import {
type AnnotatedElement,
getBoundaryPoints,
getRelationshipPoints,
isMindAnnotation,
type MindAnnotation,
type TopicRectangle,
} from "@/lib/flow-board/mind-annotations";
function getTopics(board: PlaitBoard) {
const topics = new Map<
string,
{ element: MindElement; visible: MindElement }
>();
const visit = (elements: PlaitElement[], collapsedAncestor?: MindElement) => {
for (const element of elements) {
if (!MindElement.isMindElement(board, element)) continue;
topics.set(element.id, {
element,
visible: collapsedAncestor || element,
});
visit(
element.children || [],
collapsedAncestor || (element.isCollapsed ? element : undefined)
);
}
};
visit(board.children);
return topics;
}
function rectangle(element: MindElement): TopicRectangle | null {
const node = MindElement.getNode(element);
return node ? getRectangleByNode(node) : null;
}
function annotationPoints(
board: PlaitBoard,
annotation: MindAnnotation
): Point[] | null {
const topics = getTopics(board);
if (annotation.kind === "relationship") {
const source = topics.get(annotation.sourceId);
const target = topics.get(annotation.targetId);
if (!source || !target) return null;
const a = rectangle(source.visible);
const b = rectangle(target.visible);
return a && b ? getRelationshipPoints(a, b) : null;
}
const rectangles: TopicRectangle[] = [];
const seen = new Set<string>();
const collect = (element: MindElement) => {
if (seen.has(element.id)) return;
seen.add(element.id);
const rect = rectangle(element);
if (rect) rectangles.push(rect);
if (!element.isCollapsed) element.children?.forEach(collect);
};
for (const id of annotation.topicIds) {
const topic = topics.get(id);
if (topic) collect(topic.visible);
}
return getBoundaryPoints(rectangles, annotation.padding);
}
export function insertMindAnnotation(
board: PlaitBoard,
annotation: MindAnnotation
) {
const points = annotationPoints(board, annotation);
if (!points) return null;
const endpoints: [Point, Point] = [points[0], points[points.length - 1]];
const label = { type: "paragraph", children: [{ text: "联系" }] };
const shape =
annotation.kind === "relationship"
? createArrowLineElement(
ArrowLineShape.curve,
endpoints,
{ marker: ArrowLineMarkerType.none },
{ marker: ArrowLineMarkerType.arrow },
[{ position: 0.5, text: label }],
{ strokeColor: "#64748b", strokeWidth: 2 }
)
: createGeometryElementWithoutText(
BasicShapes.roundRectangle,
endpoints,
{
fill: "transparent",
strokeColor: "#8b9bb8",
strokeWidth: 2,
}
);
const element: AnnotatedElement = {
...shape,
points,
mindAnnotation: annotation,
};
PlaitHistoryBoard.withNewBatch(board, () => {
// Boundaries sit behind topics, so clicking within them still selects topics.
Transforms.insertNode(board, element, [
annotation.kind === "boundary" ? 0 : board.children.length,
]);
});
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
clearSelectedElement(board);
addSelectedElement(board, element);
return element;
}
const installed = new WeakSet<PlaitBoard>();
/** Keep native drawable shapes linked to topic IDs, using the existing undo stack. */
export function withMindAnnotations(board: PlaitBoard) {
if (installed.has(board)) return;
installed.add(board);
const { afterChange, undo, redo, apply } = board;
board.apply = (operation) => {
// Plait keeps a hover reference after deleting or collapsing a topic. Clear it
// while the node's DOM still exists, otherwise the next pointerLeave throws.
if (
operation.type === "remove_node" ||
(operation.type === "set_node" &&
("isCollapsed" in operation.newProperties ||
"children" in operation.newProperties))
) {
board.pointerLeave(new PointerEvent("pointerleave"));
}
apply(operation);
};
let pending = false;
let replaying = true;
const synchronize = () => {
pending = false;
const replay = replaying;
replaying = false;
const topics = getTopics(board);
const changes = board.children
.filter(isMindAnnotation)
.flatMap((element) => {
const annotation = element.mindAnnotation;
const missing =
annotation.kind === "relationship"
? !topics.has(annotation.sourceId) ||
!topics.has(annotation.targetId)
: !annotation.topicIds.some((id) => topics.has(id));
const points = missing ? null : annotationPoints(board, annotation);
if (
!missing &&
(!points || JSON.stringify(points) === JSON.stringify(element.points))
)
return [];
return [{ element, points, missing }];
});
if (!changes.length) return;
const update = () => {
for (const { element, points, missing } of changes) {
const index = board.children.findIndex(
(child) => child.id === element.id
);
if (index < 0) continue;
if (missing) Transforms.removeNode(board, [index]);
else if (points) Transforms.setNode(board, { points }, [index]);
}
};
if (replay) PlaitHistoryBoard.withoutSaving(board, update);
else PlaitHistoryBoard.withMerging(board, update);
};
board.undo = () => {
replaying = board.history.undos.length > 0;
undo();
};
board.redo = () => {
replaying = board.history.redos.length > 0;
redo();
};
board.afterChange = () => {
afterChange();
if (
!pending &&
board.operations.some((op) => op.type !== "set_selection")
) {
pending = true;
queueMicrotask(synchronize);
}
};
queueMicrotask(synchronize);
}

View File

@ -0,0 +1,30 @@
import type { PlaitBoard } from "@plait/core";
import type { withEmoji } from "@plait/mind";
import { getXmindMarker } from "@/lib/flow-board/xmind-markers";
/** Use the native marker artwork in Plait's existing emoji geometry and hit targets. */
export function withXmindMarkers(board: PlaitBoard) {
const markerBoard = board as ReturnType<typeof withEmoji>;
const renderEmoji = markerBoard.renderEmoji;
markerBoard.renderEmoji = (container, props) => {
const marker = getXmindMarker(props.emojiItem.name);
if (!marker) return renderEmoji(container, props);
const image = document.createElement("img");
image.className = "mind-node-emoji xmind-topic-marker";
image.draggable = false;
let currentProps = props;
const update = (next: Partial<typeof props>) => {
currentProps = { ...currentProps, ...next };
const current = getXmindMarker(currentProps.emojiItem.name);
image.src = current?.asset || marker.asset;
image.alt = current?.name || marker.name;
image.dataset.markerId = current?.id || marker.id;
image.style.width = `${currentProps.fontSize}px`;
image.style.height = `${currentProps.fontSize}px`;
};
update(props);
container.append(image);
return { update, destroy: () => image.remove() };
};
return board;
}

View File

@ -0,0 +1,506 @@
import type { PlaitBoard } from "@plait/core";
import {
MindElement,
MindNodeComponent,
PlaitMind,
PlaitMindComponent,
getBranchColorByMindElement,
getBranchWidthByMindElement,
type MindNode,
} from "@plait/mind";
import {
arrangeSpecialMind,
isSpecialMindLayout,
isSummaryNode,
specialNodeRect,
specialSubtreeBounds,
} from "@/lib/flow-board/mind-special-layout";
import { measureXmindTopics } from "@/lib/flow-board/xmind-typography";
const ns = "http://www.w3.org/2000/svg";
type TableCell = {
x: number;
y: number;
width: number;
height: number;
depth: number;
branch: number;
};
type TableGrid = {
kind: "spreadsheet";
x: number;
y: number;
width: number;
height: number;
columns: string[];
headerHeight: number;
columnHeaderHeight: number;
rowHeaderWidth: number;
columnWidth: number;
rows: { y: number; height: number; branch: number }[];
};
const tableLayouts = new Set(["treeTable", "treeTableSide", "spreadsheet"]);
const tableTopicState = new WeakMap<
SVGGElement,
{
foreignObject: SVGForeignObjectElement;
x: string | null;
y: string | null;
width: string | null;
height: string | null;
textStyle: string | null;
contentStyle: string | null;
shape: SVGElement | null;
shapeVisibility: string;
}
>();
function ensureTreeTableStyles() {
const id = "xmind-tree-table-topic-styles";
if (document.getElementById(id)) return;
const style = document.createElement("style");
style.id = id;
style.textContent = `
[data-special-table-topic] .plait-text-container {
align-items: center !important;
display: flex !important;
height: 100% !important;
justify-content: center !important;
overflow-wrap: normal !important;
text-align: center !important;
white-space: nowrap !important;
width: 100% !important;
}
[data-special-table-topic] .plait-text-container > [data-slate-node="element"] {
text-align: center !important;
white-space: nowrap !important;
width: 100% !important;
}
[data-special-table-topic="0"] .plait-text-container {
color: #fff !important;
}
`;
document.head.append(style);
}
function frameTreeTableTopic(
component: MindNodeComponent,
node: MindNode,
enabled: boolean
) {
const host = component.getElementG();
const saved = tableTopicState.get(host);
if (!enabled) {
delete host.dataset.specialTableTopic;
if (!saved) return;
for (const [name, value] of [
["x", saved.x],
["y", saved.y],
["width", saved.width],
["height", saved.height],
] as const) {
if (value === null) saved.foreignObject.removeAttribute(name);
else saved.foreignObject.setAttribute(name, value);
}
const text = saved.foreignObject.firstElementChild as HTMLElement | null;
if (text) {
if (saved.textStyle === null) text.removeAttribute("style");
else text.setAttribute("style", saved.textStyle);
const content = text.firstElementChild as HTMLElement | null;
if (content) {
if (saved.contentStyle === null) content.removeAttribute("style");
else content.setAttribute("style", saved.contentStyle);
}
}
if (saved.shape) saved.shape.style.visibility = saved.shapeVisibility;
tableTopicState.delete(host);
return;
}
const cell = (node as MindNode & { tableCell?: TableCell }).tableCell;
const foreignObject = host.querySelector<SVGForeignObjectElement>(
":scope > .text foreignObject"
);
if (!cell || !foreignObject) return;
ensureTreeTableStyles();
host.dataset.specialTableTopic = String(cell.depth);
const currentShape = (
component.nodeShapeGenerator as unknown as { g?: SVGGElement }
).g;
if (!saved) {
tableTopicState.set(host, {
foreignObject,
x: foreignObject.getAttribute("x"),
y: foreignObject.getAttribute("y"),
width: foreignObject.getAttribute("width"),
height: foreignObject.getAttribute("height"),
textStyle: foreignObject.firstElementChild?.getAttribute("style") ?? null,
contentStyle:
foreignObject.firstElementChild?.firstElementChild?.getAttribute(
"style"
) ?? null,
shape: currentShape || null,
shapeVisibility: currentShape?.style.visibility || "",
});
} else if (currentShape && currentShape !== saved.shape) {
saved.shape = currentShape;
saved.shapeVisibility = currentShape.style.visibility || "";
}
if (currentShape) currentShape.style.visibility = "hidden";
foreignObject.setAttribute("x", String(cell.x + 14));
foreignObject.setAttribute("y", String(cell.y + 8));
foreignObject.setAttribute("width", String(Math.max(1, cell.width - 28)));
foreignObject.setAttribute("height", String(Math.max(1, cell.height - 16)));
const text = foreignObject.firstElementChild as HTMLElement | null;
if (text) {
text.style.alignItems = "center";
text.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
text.style.display = "flex";
text.style.height = "100%";
text.style.justifyContent = "center";
text.style.overflowWrap = "normal";
text.style.textAlign = "center";
text.style.whiteSpace = "nowrap";
text.style.width = "100%";
const content = text.firstElementChild as HTMLElement | null;
if (content) {
content.style.alignItems = "center";
content.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
content.style.display = "flex";
content.style.height = "100%";
content.style.justifyContent = "center";
content.style.textAlign = "center";
content.style.whiteSpace = "nowrap";
content.style.width = "100%";
}
}
}
function specialLink(component: MindNodeComponent) {
const node = component.node;
let root: MindNode = node;
while (root.parent) root = root.parent;
const kind = root.origin.specialLayout;
const tableLayout = tableLayouts.has(String(kind));
frameTreeTableTopic(component, node, tableLayout);
if (tableLayout)
requestAnimationFrame(() => {
if (component.getElementG().isConnected)
frameTreeTableTopic(component, node, true);
});
if (!isSpecialMindLayout(kind) || isSummaryNode(node)) return false;
if (
kind !== "brace" &&
kind !== "verticalTimeline" &&
!tableLayout &&
node !== root &&
node.parent !== root
)
return false;
component.linkLineG?.remove();
const group = document.createElementNS(ns, "g");
group.dataset.specialStructure = kind;
const color =
getBranchColorByMindElement(component.board, component.element) ||
"#64748b";
const width =
getBranchWidthByMindElement(component.board, component.element) || 2;
const path = (d: string) => {
const shape = document.createElementNS(ns, "path");
shape.setAttribute("d", d);
shape.setAttribute("fill", "none");
shape.setAttribute("stroke", color);
shape.setAttribute("stroke-width", String(width));
group.append(shape);
};
const blendWithWhite = (value: string, amount: number) => {
const match = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (!match) return value;
const channels = match.slice(1).map((channel) => Number.parseInt(channel, 16));
return `#${channels
.map((channel) =>
Math.round(channel + (255 - channel) * amount)
.toString(16)
.padStart(2, "0")
)
.join("")}`;
};
const solidColor = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback;
const short = value.match(/^#([\da-f])([\da-f])([\da-f])$/i);
if (short) return `#${short.slice(1).map((channel) => channel + channel).join("")}`;
if (/^#[\da-f]{6}$/i.test(value)) return value;
const rgba = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (!rgba) return fallback;
const alpha = Number.parseInt(rgba[4], 16) / 255;
return `#${rgba
.slice(1, 4)
.map((channel) =>
Math.round(Number.parseInt(channel, 16) * alpha + 255 * (1 - alpha))
.toString(16)
.padStart(2, "0")
)
.join("")}`;
};
const rootRect = specialNodeRect(root);
const axisY = rootRect.y + rootRect.height / 2;
const branches = root.children.filter((child) => !isSummaryNode(child));
if (kind === "treeTable" || kind === "treeTableSide") {
if (node === root) {
group.setAttribute("pointer-events", "none");
const palette = ["#ffbc9f", "#d8ac8f", "#83c5be", "#f3c78e", "#8ab7d9"];
const tableNodes: MindNode[] = [];
const collect = (item: MindNode) => {
if (!isSummaryNode(item)) tableNodes.push(item);
item.children.forEach(collect);
};
collect(root);
const rootFill = solidColor(root.origin.fill, "#006d77");
tableNodes.forEach((item) => {
const cell = (item as typeof item & { tableCell?: TableCell }).tableCell;
if (!cell) return;
const rectangle = document.createElementNS(ns, "rect");
rectangle.dataset.specialTableCell = item.origin.id || "topic";
rectangle.dataset.depth = String(cell.depth);
rectangle.setAttribute("x", String(cell.x));
rectangle.setAttribute("y", String(cell.y));
rectangle.setAttribute("width", String(cell.width));
rectangle.setAttribute("height", String(cell.height));
const branchColor = palette[cell.branch % palette.length] || palette[0];
const explicit = solidColor(item.origin.fill, "");
rectangle.setAttribute(
"fill",
cell.depth === 0
? rootFill
: explicit ||
blendWithWhite(branchColor, cell.depth === 1 ? 0.08 : 0.72)
);
rectangle.setAttribute("stroke", rootFill);
rectangle.setAttribute("stroke-width", "2");
rectangle.setAttribute("vector-effect", "non-scaling-stroke");
group.append(rectangle);
});
group.dataset.specialTable = kind;
} else {
group.dataset.specialConnector = kind;
}
} else if (kind === "spreadsheet") {
if (node === root) {
group.setAttribute("pointer-events", "none");
const grid = (root as MindNode & { tableGrid?: TableGrid }).tableGrid;
if (grid) {
const palette = ["#ff9d8e", "#f6c75d", "#70c8af", "#68a7dd", "#9c8bd8"];
const rootFill = solidColor(root.origin.fill, "#1f2766");
const rectangle = (
x: number,
y: number,
width: number,
height: number,
fill: string,
dataset?: Record<string, string>
) => {
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(y));
rect.setAttribute("width", String(width));
rect.setAttribute("height", String(height));
rect.setAttribute("fill", fill);
rect.setAttribute("stroke", rootFill);
rect.setAttribute("stroke-width", "2");
rect.setAttribute("vector-effect", "non-scaling-stroke");
Object.assign(rect.dataset, dataset);
group.append(rect);
};
rectangle(grid.x, grid.y, grid.width, grid.headerHeight, rootFill, {
specialTableCell: root.origin.id || "topic",
depth: "0",
});
rectangle(
grid.x,
grid.y + grid.headerHeight,
grid.rowHeaderWidth,
grid.columnHeaderHeight,
rootFill
);
grid.columns.forEach((column, index) => {
const x = grid.x + grid.rowHeaderWidth + index * grid.columnWidth;
const y = grid.y + grid.headerHeight;
rectangle(x, y, grid.columnWidth, grid.columnHeaderHeight, rootFill, {
specialMatrixColumn: column,
});
const label = document.createElementNS(ns, "text");
label.dataset.specialMatrixColumnLabel = column;
label.setAttribute("x", String(x + grid.columnWidth / 2));
label.setAttribute("y", String(y + grid.columnHeaderHeight / 2 + 5));
label.setAttribute("fill", "#ffffff");
label.setAttribute("font-size", "14");
label.setAttribute("font-weight", "600");
label.setAttribute("text-anchor", "middle");
label.textContent = column;
group.append(label);
});
grid.rows.forEach((row, rowIndex) => {
const rowNode = branches[rowIndex];
const rowColor = solidColor(
rowNode?.origin.fill,
palette[rowIndex % palette.length]
);
rectangle(grid.x, row.y, grid.rowHeaderWidth, row.height, rowColor, {
specialTableCell: rowNode?.origin.id || `row-${rowIndex}`,
depth: "1",
});
grid.columns.forEach((column, columnIndex) =>
rectangle(
grid.x + grid.rowHeaderWidth + columnIndex * grid.columnWidth,
row.y,
grid.columnWidth,
row.height,
"#ffffff",
{ specialMatrixCell: `${rowIndex}:${columnIndex}` }
)
);
rowNode?.children
.filter((child) => !isSummaryNode(child))
.forEach((entry) => {
const cell = (entry as typeof entry & { tableCell?: TableCell }).tableCell;
if (!cell) return;
rectangle(cell.x, cell.y, cell.width, cell.height, blendWithWhite(rowColor, 0.2), {
specialMatrixEntry: entry.origin.id || "topic",
});
});
});
group.dataset.specialTable = kind;
}
} else group.dataset.specialConnector = kind;
} else if (kind === "verticalTimeline") {
const axisX = rootRect.x + rootRect.width / 2;
if (node === root) {
if (branches.length) {
const end = Math.max(
...branches.map((child) => {
const rect = specialNodeRect(child);
return rect.y + rect.height / 2;
})
);
path(`M${axisX} ${rootRect.y + rootRect.height}V${end}`);
group.dataset.specialAxis = kind;
}
} else {
const rect = specialNodeRect(node);
const right = rect.x > axisX;
const endX = right ? rect.x : rect.x + rect.width;
const endY = rect.y + rect.height / 2;
if (node.parent === root) {
path(`M${axisX} ${endY}H${endX}`);
} else if (node.parent) {
const parent = specialNodeRect(node.parent);
const startX = right ? parent.x + parent.width : parent.x;
const startY = parent.y + parent.height / 2;
const midX = (startX + endX) / 2;
path(
`M${startX} ${startY}C${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`
);
}
group.dataset.specialConnector = kind;
}
} else if (kind === "brace") {
const parent = node.parent;
const siblings = parent?.children.filter((child) => !isSummaryNode(child));
if (parent && siblings?.[0] === node) {
const owner = specialNodeRect(parent);
const rects = siblings.map(specialNodeRect);
const top = Math.min(...rects.map((rect) => rect.y + rect.height / 2));
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height / 2));
const middle = (top + bottom) / 2;
const right = Math.min(...rects.map((rect) => rect.x)) - 10;
const x = right - 18;
const left = Math.max(owner.x + owner.width + 6, x - 18);
const radius = Math.min(18, (bottom - top) / 4);
path(
top === bottom
? `M${left} ${top}H${right}`
: `M${right} ${top}Q${x} ${top} ${x} ${top + radius}V${middle - radius}Q${x} ${middle} ${left} ${middle}Q${x} ${middle} ${x} ${middle + radius}V${bottom - radius}Q${x} ${bottom} ${right} ${bottom}`
);
}
} else if (node === root) {
if (branches.length) {
const end =
Math.max(
...branches.map((child) => {
const rect = specialSubtreeBounds(child);
return rect.x + rect.width;
})
) + 32;
path(`M${rootRect.x + rootRect.width} ${axisY}H${end}`);
group.dataset.specialAxis = kind;
}
} else {
const rect = specialNodeRect(node);
const above = rect.y + rect.height / 2 < axisY;
const endX = rect.x + rect.width / 2;
const startX = endX - (kind === "fishbone" ? 48 : 0);
path(
`M${startX} ${axisY}L${endX} ${above ? rect.y + rect.height : rect.y}`
);
group.dataset.specialConnector = kind;
if (kind === "timeline") {
const circle = document.createElementNS(ns, "circle");
circle.setAttribute("cx", String(startX));
circle.setAttribute("cy", String(axisY));
circle.setAttribute("r", "4");
circle.setAttribute("fill", color);
group.append(circle);
}
}
component.linkLineG = group;
const host = component.getElementG();
if (tableLayout && node === root && host.parentElement)
host.parentElement.insertBefore(group, host.parentElement.firstChild);
else host.append(group);
return true;
}
class SpecialMindRoot extends PlaitMindComponent {
override updateMindLayout(element = this.element) {
measureXmindTopics(this.board, element);
super.updateMindLayout(element);
if (isSpecialMindLayout(element.specialLayout))
arrangeSpecialMind(this.root, element.specialLayout);
}
override drawLink() {
if (!specialLink(this)) {
this.linkLineG?.remove();
this.linkLineG = undefined;
super.drawLink();
}
}
}
class SpecialMindNode extends MindNodeComponent {
private lastSpecialLayout: unknown;
private rootLayout() {
let root = this.node;
while (root.parent) root = root.parent;
return root.origin.specialLayout;
}
override onContextChanged(
...args: Parameters<MindNodeComponent["onContextChanged"]>
) {
super.onContextChanged(...args);
// A structure switch can leave descendant positions and parent identity unchanged.
if (this.rootLayout() !== this.lastSpecialLayout) this.drawLink();
}
override drawLink() {
if (!specialLink(this)) super.drawLink();
this.lastSpecialLayout = this.rootLayout();
}
}
const installed = new WeakSet<PlaitBoard>();
export function withSpecialMindLayouts(board: PlaitBoard) {
if (installed.has(board)) return board;
installed.add(board);
const drawElement = board.drawElement;
board.drawElement = (context) => {
if (PlaitMind.isMind(context.element)) return SpecialMindRoot;
if (MindElement.isMindElement(board, context.element))
return SpecialMindNode;
return drawElement(context);
};
return board;
}

View File

@ -0,0 +1,129 @@
"use client";
import { Trash2, X } from "lucide-react";
import type { AnnotatedElement } from "@/lib/flow-board/mind-annotations";
export function XmindAnnotationPanel({
element,
onChange,
onDelete,
onClose,
}: {
element: AnnotatedElement;
onChange: (patch: Record<string, unknown>) => void;
onDelete: () => void;
onClose: () => void;
}) {
const relationship = element.mindAnnotation.kind === "relationship";
const label = (element.texts?.[0]?.text?.children || [])
.map((leaf: { text?: string }) => leaf.text || "")
.join("");
return (
<aside
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
<h2 className="text-sm font-semibold">
{relationship ? "联系格式" : "外框格式"}
</h2>
<button aria-label="关闭格式面板" onClick={onClose} type="button">
<X size={16} />
</button>
</div>
<div className="space-y-5 p-4 text-xs">
{relationship ? (
<label className="block space-y-2">
<span>联系文字</span>
<input
className="h-9 w-full rounded border border-[#dfe2e7] px-2 text-sm"
value={label}
onChange={(event) =>
onChange({
texts: event.target.value
? [
{
position: 0.5,
text: {
type: "paragraph",
children: [{ text: event.target.value }],
},
},
]
: [],
})
}
/>
</label>
) : (
<label className="flex items-center justify-between">
<span>外框留白</span>
<select
aria-label="外框留白"
className="h-8 rounded border border-[#dfe2e7] px-2"
value={
element.mindAnnotation.kind === "boundary"
? element.mindAnnotation.padding
: 18
}
onChange={(event) =>
onChange({
mindAnnotation: {
...element.mindAnnotation,
padding: Number(event.target.value),
},
})
}
>
{[12, 18, 24, 32].map((value) => (
<option key={value} value={value}>
{value} px
</option>
))}
</select>
</label>
)}
<label className="flex items-center justify-between">
<span>线条颜色</span>
<input
aria-label="联系或外框颜色"
className="h-7 w-12 cursor-pointer"
type="color"
value={element.strokeColor || "#64748b"}
onChange={(event) => onChange({ strokeColor: event.target.value })}
/>
</label>
<label className="flex items-center justify-between">
<span>线条粗细</span>
<select
aria-label="联系或外框粗细"
className="h-8 rounded border border-[#dfe2e7] px-2"
value={element.strokeWidth || 2}
onChange={(event) =>
onChange({ strokeWidth: Number(event.target.value) })
}
>
{[1, 2, 3, 4].map((value) => (
<option key={value} value={value}>
{value} pt
</option>
))}
</select>
</label>
<p className="leading-5 text-[#8b919c]">
{relationship
? "联系线会跟随主题移动。双击线上的文字也可以编辑。"
: "外框包含所选主题及其展开的子主题,随分支布局自动调整。"}
</p>
<button
className="flex h-9 w-full items-center justify-center gap-2 rounded border border-[#e5e7eb] text-[#b44343] hover:bg-red-50"
onClick={onDelete}
type="button"
>
<Trash2 size={14} />
删除{relationship ? "联系" : "外框"}
</button>
</div>
</aside>
);
}

View File

@ -0,0 +1,70 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { ChevronDown, type LucideIcon } from "lucide-react";
export type XmindCommand = {
label: string;
icon?: LucideIcon;
shortcut?: string;
disabled?: boolean;
checked?: boolean;
separator?: boolean;
run: () => void;
};
export function XmindCommandMenu({ label, children, commands, className = "", align = "left" }: {
label: string;
children: ReactNode;
commands: XmindCommand[];
className?: string;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const host = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
const dismiss = (event: PointerEvent) => {
if (!host.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [open]);
return (
<div className={`xmind-menu-host ${className}`} ref={host} data-flow-board-shell-control="true">
<button type="button" ref={trigger} aria-label={label} aria-haspopup="menu" aria-expanded={open}
className="xmind-menu-trigger" onClick={() => setOpen(value => !value)}
onKeyDown={event => {
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
requestAnimationFrame(() => host.current?.querySelector<HTMLButtonElement>("[role=menuitem]:not(:disabled)")?.focus());
}
}}>
{children}<ChevronDown size={12} />
</button>
{open ? <div role="menu" aria-label={label} className={`xmind-command-menu ${align === "right" ? "xmind-menu-right" : ""}`}
onKeyDown={event => {
if (event.key === "Escape") {
event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus();
}
const items = Array.from(host.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") || []);
const index = items.indexOf(document.activeElement as HTMLButtonElement);
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}
}}>
{commands.map(({ label: title, icon: Icon, shortcut, disabled, checked, separator, run }) => <div key={title}>
{separator ? <hr className="xmind-menu-separator" /> : null}
<button type="button" role="menuitem" disabled={disabled} className="xmind-command" onClick={() => { setOpen(false); run(); }}>
{Icon ? <Icon size={16} /> : <span className="xmind-command-check">{checked ? "✓" : ""}</span>}
<span>{title}</span>{shortcut ? <kbd>{shortcut}</kbd> : null}
</button>
</div>)}
</div> : null}
</div>
);
}

View File

@ -0,0 +1,470 @@
"use client";
import {
CalendarDays,
ChartGantt,
ArrowLeft,
ArrowUpRight,
Bold,
ChevronDown,
Copy,
FileText,
FolderOpen,
GitBranchPlus,
GitMerge,
Hand,
Image as ImageIcon,
Italic,
ListPlus,
Link2,
StickyNote,
Maximize,
Minus,
MousePointer2,
Network,
PanelRight,
Pencil,
Plus,
Redo2,
Save,
Shapes,
Tag,
Trash2,
Type,
Underline,
Undo2,
Upload,
Download,
X,
type LucideIcon,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Home, Smile, Scan, Search, MoreHorizontal, AlignLeft, AlignCenter, AlignRight, Presentation, SlidersHorizontal } from "lucide-react";
import { XmindCommandMenu } from "./xmind-command-menu";
export type FlowBoardToolId =
| "selection"
| "hand"
| "mind"
| "text"
| "arrow"
| "shape"
| "image";
export type FlowBoardSheetTab = {
id: string;
title: string;
};
export type FlowBoardLayoutOption = {
id: string;
label: string;
preview:
| "both"
| "right"
| "left"
| "down"
| "up"
| "indent"
| "fishbone"
| "timeline"
| "brace"
| "verticalTimeline"
| "treeTable"
| "treeTableSide"
| "spreadsheet";
};
export type FlowBoardThemeOption = {
id: string;
label: string;
colors: [string, string, string];
};
function LayoutPreview({
direction,
}: {
direction: FlowBoardLayoutOption["preview"];
}) {
const horizontal = direction === "right" || direction === "left";
const vertical = direction === "down" || direction === "up";
const leaf = (x: number, y: number) => (
<rect key={`${x}-${y}`} x={x - 3} y={y - 3} width="6" height="6" fill="white" />
);
const root = (x: number, y: number) => (
<rect x={x - 4.5} y={y - 4.5} width="9" height="9" fill="currentColor" stroke="none" />
);
return (
<svg aria-hidden="true" className="block h-9 w-12 shrink-0" data-layout-preview={direction}
viewBox="0 0 48 36" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
{direction === "both" ? (
<>
<path d="M19.5 18H14V8H8M14 18V28H8M28.5 18H34V8H40M34 18V28H40" />
{root(24, 18)}
{[leaf(5, 8), leaf(5, 28), leaf(43, 8), leaf(43, 28)]}
</>
) : null}
{horizontal ? (
<g transform={direction === "left" ? "translate(48 0) scale(-1 1)" : undefined}>
<path d="M13.5 18H24V8H35M24 18V28H35" />
{root(9, 18)}
{[leaf(38, 8), leaf(38, 28)]}
</g>
) : null}
{vertical ? (
<g transform={direction === "up" ? "translate(0 36) scale(1 -1)" : undefined}>
<path d="M24 10.5V19M7 25V19H41V25M24 19V25" />
{root(24, 6)}
{[leaf(7, 28), leaf(24, 28), leaf(41, 28)]}
</g>
) : null}
{direction === "indent" ? (
<>
<path d="M9 10.5V30H27M9 18H27" />
{root(9, 6)}
{[leaf(30, 18), leaf(30, 30)]}
<path d="M34 18H44M34 30H44" />
</>
) : null}
{direction === "fishbone" || direction === "timeline" ? <>
{root(6, 18)}<path d="M10.5 18H46" />
<path d={direction === "fishbone" ? "M17 18L23 7M29 18L35 29M34 18L40 7" : "M20 18V7M29 18V29M40 18V7"} />
{[leaf(direction === "fishbone" ? 23 : 20, 5), leaf(direction === "fishbone" ? 35 : 29, 31), leaf(40, 5)]}
</> : null}
{direction === "verticalTimeline" ? <>
{root(24, 5)}<path d="M24 9.5V32M24 15H35M24 23H13M24 31H35" />
{[leaf(38, 15), leaf(10, 23), leaf(38, 31)]}
</> : null}
{direction === "treeTable" ? <>
<rect x="7" y="4" width="34" height="8" fill="currentColor" stroke="none" />
<path d="M7 12H41V32H7ZM7 22H41M25 12V32" />
</> : null}
{direction === "treeTableSide" ? <>
<rect x="5" y="5" width="10" height="26" fill="currentColor" stroke="none" />
<path d="M15 5H43V31H15ZM15 14H43M15 23H43M29 5V31" />
</> : null}
{direction === "spreadsheet" ? <>
<rect x="5" y="4" width="38" height="8" fill="currentColor" stroke="none" />
<path d="M5 12H43V32H5ZM5 19H43M5 25.5H43M16 12V32M25 12V32M34 12V32" />
<rect x="6" y="20" width="9" height="5" fill="currentColor" stroke="none" opacity=".55" />
</> : null}
{direction === "brace" ? <>{root(8,18)}<path d="M30 5Q23 5 23 11V13Q23 18 17 18Q23 18 23 23V25Q23 31 30 31" />{[leaf(37,5),leaf(37,18),leaf(37,31)]}</> : null}
</svg>
);
}
export function XmindEditorTopBar({
activeTool, canEditMind, currentTitle, formatPanelOpen, markerPanelOpen, isSaving, onBack,
onImport, onExport, onExportImage, isExporting, onInsertChild, onInsertSibling,
onRelationship, onBoundary, relationshipPending, onOpenFiles, onRedo,
onSave, onSummary, onTopicDetails, onTitleChange, onTitleCommit,
onToggleFormat, onToggleMarkers, onToolChange, onUndo, siteName,
onSearch, onToggleZen, onToggleOutline, onPresentation, onTask, onGantt,
}: {
activeTool: FlowBoardToolId;
canEditMind: boolean;
currentTitle: string;
formatPanelOpen: boolean;
markerPanelOpen: boolean;
isSaving: boolean;
onBack: () => void;
onImport: () => void;
onExport: () => void;
onExportImage: () => void;
isExporting: boolean;
onInsertChild: () => void;
onInsertSibling: () => void;
onRelationship: () => void;
onBoundary: () => void;
relationshipPending: boolean;
onOpenFiles: () => void;
onRedo: () => void;
onSave: () => void;
onSummary: () => void;
onTopicDetails: (section: "notes" | "links" | "labels") => void;
onTitleChange: (value: string) => void;
onTitleCommit: () => void;
onToggleFormat: () => void;
onToggleMarkers: () => void;
onToolChange: (tool: FlowBoardToolId) => void;
onUndo: () => void;
siteLogo: string | null;
siteName: string;
onTask: () => void;
onGantt: () => void;
onSearch: () => void;
onToggleZen: () => void;
onToggleOutline: () => void;
onPresentation: () => void;
}) {
const nativeIcons: Record<string,string> = {"同级主题":"topic","子主题":"subtopic","联系":"relationship","概要":"summary","外框":"boundary","ZEN 模式":"zen","演示":"pitch","格式":"sidebar","标记":"marker","导出 XMind":"export"};
const action = (label: string, Icon: LucideIcon, run: () => void, disabled = false, active = false) => (
<button aria-label={label} title={label} type="button" disabled={disabled} aria-pressed={active}
className={`xmind-icon-button ${active ? "is-active" : ""}`} onClick={run}>{nativeIcons[label] ? <span aria-hidden="true" className="xmind-native-icon" style={{maskImage:`url(/icons/xmind/${nativeIcons[label]}.svg)`}} /> : <Icon size={20} strokeWidth={1.8} />}</button>
);
return <header className="xmind-topbar" data-flow-board-shell-control="true">
<div className="xmind-document-heading">
<button type="button" aria-label="返回首页" title={`返回 ${siteName}`} className="xmind-home-button" onClick={onBack}><Home size={21} strokeWidth={1.8} /></button>
<div className="xmind-document-name">
<input aria-label="白板标题" value={currentTitle} maxLength={256} onChange={e => onTitleChange(e.target.value)}
onBlur={onTitleCommit} onKeyDown={e => { if (e.key === "Enter") e.currentTarget.blur(); }} />
<span role="status">{isSaving ? "正在保存…" : "已编辑"}</span>
</div>
<XmindCommandMenu label="文件与编辑" className="xmind-document-menu" commands={[
{label:"保存",icon:Save,shortcut:"⌘S",run:onSave},
{label:"云文件",icon:FolderOpen,run:onOpenFiles},
{label:"导入",icon:Upload,run:onImport},
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
{label:"撤销",icon:Undo2,shortcut:"⌘Z",separator:true,run:onUndo},
{label:"重做",icon:Redo2,shortcut:"⇧⌘Z",run:onRedo},
{label:"甘特图",icon:ChartGantt,run:onGantt},
{label:"查找与替换",icon:Search,shortcut:"⌘F",separator:true,run:onSearch},
{label:"选择",icon:MousePointer2,checked:activeTool === "selection",separator:true,run:() => onToolChange("selection")},
{label:"画布",icon:Hand,checked:activeTool === "hand",run:() => onToolChange("hand")},
]}><MoreHorizontal size={17} /></XmindCommandMenu>
</div>
<nav className="xmind-pointer-toolbar" aria-label="画布工具">
<button type="button" aria-label="选择工具" title="选择 · V(空白处拖动框选)"
aria-pressed={activeTool === "selection"} className={`xmind-icon-button ${activeTool === "selection" ? "is-active" : ""}`} onClick={() => onToolChange("selection")}><MousePointer2 size={17} strokeWidth={1.8} /></button>
<button type="button" aria-label="拖动画布" title="拖动画布 · H(也可按住空格拖动)"
aria-pressed={activeTool === "hand"} className={`xmind-icon-button ${activeTool === "hand" ? "is-active" : ""}`} onClick={() => onToolChange("hand")}><Hand size={17} strokeWidth={1.8} /></button>
</nav>
<nav className="xmind-primary-tools" aria-label="主题工具">
<div className="xmind-toolbar-pill">
{action("同级主题", ListPlus, onInsertSibling, !canEditMind)}
{action("子主题", GitBranchPlus, onInsertChild, !canEditMind)}
</div>
<div className="xmind-toolbar-pill">
{action("联系", ArrowUpRight, onRelationship, !canEditMind && !relationshipPending, relationshipPending)}
{action("概要", GitMerge, onSummary, !canEditMind)}
{action("外框", Shapes, onBoundary, !canEditMind)}
<XmindCommandMenu label="插入" commands={[
{label:"主题",icon:Network,run:() => onToolChange("mind")},
{label:"笔记",icon:StickyNote,shortcut:"⇧⌘N",disabled:!canEditMind,separator:true,run:() => onTopicDetails("notes")},
{label:"任务",icon:CalendarDays,disabled:!canEditMind,run:onTask},
{label:"链接",icon:Link2,disabled:!canEditMind,run:() => onTopicDetails("links")},
{label:"标签",icon:Tag,disabled:!canEditMind,run:() => onTopicDetails("labels")},
{label:"图片",icon:ImageIcon,separator:true,run:() => onToolChange("image")},
{label:"文本",icon:Type,run:() => onToolChange("text")},
{label:"形状",icon:Shapes,run:() => onToolChange("shape")},
{label:"箭头",icon:ArrowUpRight,run:() => onToolChange("arrow")},
]}><Plus size={23} strokeWidth={1.7} /></XmindCommandMenu>
</div>
</nav>
<div className="xmind-utility-tools">
<div className="xmind-toolbar-pill">{action("导出 XMind", Download, onExport, isExporting)}<XmindCommandMenu label="更多导出选项" align="right" commands={[
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
]}><span className="sr-only">导出</span></XmindCommandMenu></div>
<div className="xmind-toolbar-pill">
{action("ZEN 模式", Scan, onToggleZen)}
{action("大纲视图", FileText, onToggleOutline)}
{action("演示", Presentation, onPresentation)}
</div>
<div className="xmind-toolbar-pill">
{action("标记", Smile, onToggleMarkers, false, markerPanelOpen)}
{action("格式", PanelRight, onToggleFormat, false, formatPanelOpen)}
</div>
</div>
</header>;
}
export function XmindFormatPanel({
activeLayoutId,
activeThemeId,
branchColor,
branchShape,
branchWidth,
canvasBackgroundColor,
canvasGridColor,
canvasGridEnabled,
canvasGridSize,
fontSize,
isBold,
isItalic,
isUnderlined,
layouts,
nodeFill,
nodeShape,
onBranchColorChange,
onBranchShapeChange,
onBranchWidthChange,
onCanvasBackgroundColorChange,
onCanvasGridColorChange,
onCanvasGridEnabledChange,
onCanvasGridSizeChange,
onClose,
onFontSizeChange,
onLayoutChange,
onNodeFillChange,
onNodeShapeChange,
onTextColorChange,
onThemeChange,
onToggleTextMark,
selectedCount,
textColor,
themes,
}: {
activeLayoutId: string;
activeThemeId: string;
branchColor: string;
branchShape: "bight" | "polyline";
branchWidth: number;
canvasBackgroundColor: string;
canvasGridColor: string;
canvasGridEnabled: boolean;
canvasGridSize: number;
fontSize: string;
isBold: boolean;
isItalic: boolean;
isUnderlined: boolean;
layouts: FlowBoardLayoutOption[];
nodeFill: string;
nodeShape: "round-rectangle" | "underline";
onBranchColorChange: (value: string) => void;
onBranchShapeChange: (value: "bight" | "polyline") => void;
onBranchWidthChange: (value: number) => void;
onCanvasBackgroundColorChange: (value: string) => void;
onCanvasGridColorChange: (value: string) => void;
onCanvasGridEnabledChange: (value: boolean) => void;
onCanvasGridSizeChange: (value: number) => void;
onClose: () => void;
onFontSizeChange: (value: string) => void;
onLayoutChange: (value: string) => void;
onNodeFillChange: (value: string) => void;
onNodeShapeChange: (value: "round-rectangle" | "underline") => void;
onTextColorChange: (value: string) => void;
onThemeChange: (value: string) => void;
onToggleTextMark: (mark: "bold" | "italic" | "underlined") => void;
selectedCount: number;
textColor: string;
themes: FlowBoardThemeOption[];
}) {
const [activePanel, setActivePanel] = useState<"format" | "canvas">("format");
const [structuresOpen, setStructuresOpen] = useState(false);
const activeLayout = layouts.find(layout => layout.id === activeLayoutId) || layouts[0];
const colorControl = (label: string, value: string, change: (value: string) => void, disabled = false) => (
<input type="color" aria-label={label} value={/^#[0-9a-f]{6}$/i.test(value) ? value : "#ffffff"}
disabled={disabled} onChange={e => change(e.target.value)} className="xmind-color-control" />
);
return <aside className="xmind-format-panel" aria-label="格式面板" data-flow-board-shell-control="true">
<div className="xmind-panel-heading"><span>格式</span><button type="button" aria-label="关闭格式面板" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-panel-tabs" role="tablist" aria-label="格式分类">
<button type="button" role="tab" aria-selected={activePanel === "format"} onClick={() => setActivePanel("format")}>样式</button>
<button type="button" role="tab" aria-selected={activePanel === "canvas"} onClick={() => setActivePanel("canvas")}>画布</button>
</div>
<div className="xmind-panel-content">
{activePanel === "format" ? <>
<div className="xmind-topic-preview"><span style={{ backgroundColor: nodeFill, color: textColor,
fontWeight: isBold ? 700 : 500, fontStyle: isItalic ? "italic" : "normal", borderRadius: nodeShape === "underline" ? 0 : 8 }}>{selectedCount ? "分支主题" : "中心主题"}</span><ChevronDown size={14} /></div>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />形状</h3>
<div className="xmind-segmented">
<button type="button" aria-label="圆角矩形节点" aria-pressed={nodeShape === "round-rectangle"} disabled={!selectedCount} onClick={() => onNodeShapeChange("round-rectangle")}><span className="xmind-shape-rectangle" /></button>
<button type="button" aria-label="下划线节点" aria-pressed={nodeShape === "underline"} disabled={!selectedCount} onClick={() => onNodeShapeChange("underline")}><span className="xmind-shape-line" /></button>
</div>
<label className="xmind-setting-row"><span>填充</span>{colorControl("节点填充颜色", nodeFill, onNodeFillChange, !selectedCount)}</label>
</section>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />文本</h3>
<label className="xmind-setting-row"><span>字号</span><select aria-label="节点字号" value={fontSize} disabled={!selectedCount} onChange={e => onFontSizeChange(e.target.value)}>
{Array.from(new Set([12,13,14,15,16,18,20,24,28,30,32,40,48,Number(fontSize)].filter(size => size > 0))).sort((a,b) => a-b).map(size => <option value={size} key={size}>{size}</option>)}
</select></label>
<label className="xmind-setting-row"><span>颜色</span>{colorControl("文字颜色", textColor, onTextColorChange, !selectedCount)}</label>
<div className="xmind-segmented xmind-text-marks">
{[{icon:Bold,id:"bold" as const,label:"粗体",active:isBold},{icon:Italic,id:"italic" as const,label:"斜体",active:isItalic},{icon:Underline,id:"underlined" as const,label:"下划线",active:isUnderlined}].map(({icon:Icon,id,label,active}) =>
<button type="button" key={id} aria-label={label} aria-pressed={active} disabled={!selectedCount} onClick={() => onToggleTextMark(id)}><Icon size={18} /></button>)}
</div>
</section>
<section className="xmind-format-section">
<div className="xmind-setting-row"><h3><ChevronDown size={13} />结构</h3><button type="button" aria-label="选择结构" aria-expanded={structuresOpen} className="xmind-structure-trigger" onClick={() => setStructuresOpen(value => !value)}><LayoutPreview direction={activeLayout.preview} /><ChevronDown size={12} /></button></div>
<div className="xmind-current-structure">{activeLayout.label}</div>
{structuresOpen ? <div className="xmind-structure-grid">
{layouts.map(layout => <button type="button" key={layout.id} aria-label={layout.label} aria-pressed={activeLayoutId === layout.id}
onClick={() => { onLayoutChange(layout.id); setStructuresOpen(false); }}><LayoutPreview direction={layout.preview} /><span>{layout.label}</span></button>)}
</div> : null}
</section>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />分支</h3>
<label className="xmind-setting-row"><span>线条</span><select aria-label="分支形状" value={branchShape} disabled={!selectedCount} onChange={e => onBranchShapeChange(e.target.value as "bight" | "polyline")}><option value="bight">曲线</option><option value="polyline">折线</option></select></label>
<label className="xmind-setting-row"><span>粗细</span><select aria-label="分支粗细" value={branchWidth} disabled={!selectedCount} onChange={e => onBranchWidthChange(Number(e.target.value))}>{[1,2,3,4,5,6,8].map(width => <option key={width} value={width}>{width} px</option>)}</select></label>
<label className="xmind-setting-row"><span>颜色</span>{colorControl("分支颜色", branchColor, onBranchColorChange, !selectedCount)}</label>
</section>
</> : <>
<section className="xmind-format-section">
<h3><ChevronDown size={13} />背景</h3>
<div className="xmind-background-presets">{["#ffffff","#f7f8fb","#f1f5f9","#fff8e7","#eef8f1","#f5f0ff"].map(color => <button type="button" key={color} aria-label={`画布背景 ${color}`} aria-pressed={color === canvasBackgroundColor} style={{backgroundColor:color}} onClick={() => onCanvasBackgroundColorChange(color)} />)}</div>
<label className="xmind-setting-row"><span>自定义颜色</span>{colorControl("画布背景颜色",canvasBackgroundColor,onCanvasBackgroundColorChange)}</label>
</section>
<section className="xmind-format-section"><h3><ChevronDown size={13} />彩虹色</h3><div className="xmind-theme-grid">{themes.map(theme => <button type="button" key={theme.id} aria-label={theme.label} aria-pressed={theme.id === activeThemeId} onClick={() => onThemeChange(theme.id)}><span>{theme.colors.map(color => <i key={color} style={{backgroundColor:color}} />)}</span>{theme.label}</button>)}</div></section>
<section className="xmind-format-section">
<div className="xmind-setting-row"><h3><ChevronDown size={13} />网格</h3><button type="button" role="switch" aria-label="显示画布网格" aria-checked={canvasGridEnabled} className="xmind-switch" onClick={() => onCanvasGridEnabledChange(!canvasGridEnabled)}><i /></button></div>
<label className="xmind-setting-row"><span>网格颜色</span>{colorControl("网格颜色",canvasGridColor,onCanvasGridColorChange,!canvasGridEnabled)}</label>
<label className="xmind-setting-row"><span>网格间距</span><select aria-label="网格间距" value={canvasGridSize} disabled={!canvasGridEnabled} onChange={e => onCanvasGridSizeChange(Number(e.target.value))}>{[8,16,24,32,40,48,64].map(size => <option key={size} value={size}>{size} px</option>)}</select></label>
<div aria-label="画布样式预览" className="xmind-canvas-preview" style={{backgroundColor:canvasBackgroundColor,backgroundImage:canvasGridEnabled ? `radial-gradient(${canvasGridColor} 1px, transparent 1px)` : "none",backgroundSize:`${canvasGridSize}px ${canvasGridSize}px`}} />
</section>
</>}
</div>
</aside>;
}
export function XmindSheetBar({
outlineOpen, onToggleOutline, activeSheetId, formatPanelOpen, onAddSheet,
onDeleteSheet, onDuplicateSheet, onFit, onRenameSheet, onSwitchSheet,
onZoomIn, onZoomOut, onZoomTo, sheets, zoomPercent, topicCount, selectedCount,
}: {
outlineOpen: boolean;
onToggleOutline: () => void;
activeSheetId: string;
formatPanelOpen: boolean;
onAddSheet: () => void;
onDeleteSheet: (id: string) => void;
onDuplicateSheet: (id: string) => void;
onFit: () => void;
onRenameSheet: (id: string, title: string) => void;
onSwitchSheet: (id: string) => void;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomTo: (value: number) => void;
sheets: FlowBoardSheetTab[];
zoomPercent: number;
topicCount: number;
selectedCount: number;
}) {
const [renamingSheetId, setRenamingSheetId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [contextId, setContextId] = useState<string | null>(null);
const activeSheet = sheets.find(sheet => sheet.id === activeSheetId);
const rename = (id: string) => { const sheet = sheets.find(item => item.id === id); if (sheet) { setRenamingSheetId(id); setRenameDraft(sheet.title); } };
const commitRename = () => { if (renamingSheetId && renameDraft.trim()) onRenameSheet(renamingSheetId,renameDraft.trim()); setRenamingSheetId(null); };
return <footer className="xmind-sheetbar" data-flow-board-shell-control="true">
<div className="xmind-sheet-tabs" role="tablist" aria-label="工作表">
{sheets.map(sheet => <div className="xmind-sheet-tab-host" key={sheet.id}>
{renamingSheetId === sheet.id ? <input aria-label="重命名画布" autoFocus value={renameDraft} maxLength={80} onChange={e => setRenameDraft(e.target.value)} onBlur={commitRename} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); commitRename(); } if (e.key === "Escape") { e.preventDefault(); setRenamingSheetId(null); } }} /> :
<button type="button" aria-label={sheet.title} aria-pressed={sheet.id === activeSheetId} onClick={() => onSwitchSheet(sheet.id)} onDoubleClick={() => rename(sheet.id)} onContextMenu={e => { e.preventDefault(); setContextId(contextId === sheet.id ? null : sheet.id); }}>{sheet.title}</button>}
{contextId === sheet.id ? <div className="xmind-sheet-context" data-flow-board-shell-control="true">
<button type="button" onClick={() => { rename(sheet.id); setContextId(null); }}>重命名</button>
<button type="button" onClick={() => { onDuplicateSheet(sheet.id); setContextId(null); }}>复制</button>
<button type="button" disabled={sheets.length <= 1} onClick={() => { onDeleteSheet(sheet.id); setContextId(null); }}>删除</button>
<button type="button" onClick={() => setContextId(null)}>关闭菜单</button>
</div> : null}
</div>)}
</div>
<span className="xmind-footer-divider" />
<button type="button" aria-label="新增画布" title="新增画布" className="xmind-footer-icon" onClick={onAddSheet}><Plus size={21} strokeWidth={1.5} /></button>
<XmindCommandMenu label="画布操作" className="xmind-sheet-actions" commands={[
{label:"重命名当前画布",icon:Pencil,disabled:!activeSheet,run:() => activeSheet && rename(activeSheet.id)},
{label:"复制当前画布",icon:Copy,disabled:!activeSheet,run:() => activeSheet && onDuplicateSheet(activeSheet.id)},
{label:"删除当前画布",icon:Trash2,disabled:sheets.length <= 1,run:() => activeSheet && onDeleteSheet(activeSheet.id)},
]}><MoreHorizontal size={17} /></XmindCommandMenu>
<div className="xmind-sheet-status"><span className="xmind-topic-count">主题: {selectedCount} / {topicCount}</span><span className="xmind-footer-divider" />
<XmindCommandMenu label="缩放" align="right" className="xmind-zoom-menu" commands={[
{label:"放大",icon:Plus,run:onZoomIn},{label:"缩小",icon:Minus,run:onZoomOut},
...[50,75,100,125,150,200].map(value => ({label:`${value}%`,checked:value === zoomPercent,separator:value === 50,run:() => onZoomTo(value)})),
{label:"适应画布",icon:Maximize,separator:true,run:onFit},
]}><span>{zoomPercent}%</span></XmindCommandMenu>
<span className="xmind-footer-divider" /><button type="button" aria-label={outlineOpen ? "切换到思维导图" : "切换到大纲"} aria-pressed={outlineOpen} className="xmind-outline-toggle" onClick={onToggleOutline}>{outlineOpen ? "思维导图" : "大纲"}</button>
</div>
</footer>;
}

View File

@ -0,0 +1,778 @@
"use client";
import {
CalendarDays,
ChevronDown,
ChevronRight,
Download,
Link2,
Minus,
Plus,
Printer,
Search,
UserRound,
X,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
addDays,
dateIndex,
getTaskRows,
indexDate,
todayDate,
type TopicTask,
} from "@/lib/flow-board/topic-tasks";
import { getTopicEntries } from "@/lib/flow-board/topic-details";
import {
activeWorkingCalendar,
getProjectCalendar,
isWorkingDate,
preserveNonWorkingDue,
taskDueDate,
workingDaysBetween,
} from "@/lib/flow-board/project-calendar";
const ROW = 38,
HEADER = 64;
export function XmindGantt({
elements,
title,
onClose,
onEdit,
onCalendar,
onChange,
onUndo,
onRedo,
}: {
elements: PlaitElement[];
title: string;
onClose: () => void;
onEdit: (id: string) => void;
onCalendar: () => void;
onChange: (id: string, task: TopicTask) => void;
onUndo: () => void;
onRedo: () => void;
}) {
const panelRef = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
panelRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, []);
const [scale, setScale] = useState(34),
[query, setQuery] = useState(""),
[assignee, setAssignee] = useState(""),
[status, setStatus] = useState("all");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<string[]>([]);
const [drag, setDrag] = useState<{
id: string;
x: number;
kind: "move" | "start" | "due";
task: TopicTask;
days: number;
} | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const rowScrollRef = useRef<HTMLDivElement>(null);
const projectCalendar = getProjectCalendar(elements);
const calendar = activeWorkingCalendar(projectCalendar);
const rows = getTaskRows(elements);
const visible = rows.filter(
(row) =>
row.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()) &&
(!assignee || row.task.assignees.includes(assignee)) &&
(status === "all" ||
(status === "done" && row.task.progress === 100) ||
(status === "open" && row.task.progress < 100)) &&
!rows.some(
(parent) =>
collapsed.has(parent.element.id) &&
parent.path.length < row.path.length &&
parent.path.every((n, i) => row.path[i] === n)
)
);
const candidates = getTopicEntries(elements).filter(
(e) => !rows.some((r) => r.element.id === e.element.id)
);
const [candidate, setCandidate] = useState("");
const [notice, setNotice] = useState("");
const today = dateIndex(todayDate());
const dated = rows.filter((r) => r.task.start && r.task.due);
const from =
Math.min(today, ...dated.map((r) => dateIndex(r.task.start))) - 3;
const last =
Math.max(today + 20, ...dated.map((r) => dateIndex(r.task.due))) + 4;
// Very long plans use a virtual day grid; no thousands of day DOM nodes.
const width = Math.max(800, (last - from + 1) * scale);
const [scrollX, setScrollX] = useState(0),
[viewportWidth, setViewportWidth] = useState(1000);
const tickStep = scale >= 24 ? 1 : scale >= 10 ? 7 : 30;
const monthBands = useMemo(() => {
const start = from + Math.max(0, Math.floor(scrollX / scale) - 31),
end = Math.min(
last,
from + Math.ceil((scrollX + viewportWidth) / scale) + 31
);
const bands: { date: string; offset: number; days: number }[] = [];
for (let day = start; day <= end; ) {
const date = indexDate(day),
next = dateIndex(`${date.slice(0, 7)}-01`);
const d = new Date(next * 86_400_000);
d.setUTCMonth(d.getUTCMonth() + 1);
const monthEnd = d.getTime() / 86_400_000;
const bandStart = Math.max(from, next),
bandEnd = Math.min(last + 1, monthEnd);
bands.push({
date: date.slice(0, 7),
offset: bandStart - from,
days: bandEnd - bandStart,
});
day = monthEnd;
}
return bands;
}, [from, last, scrollX, scale, viewportWidth]);
const ticks = useMemo(() => {
const result: number[] = [];
const begin = Math.max(
0,
Math.floor(scrollX / scale / tickStep) * tickStep - tickStep
);
const end = Math.min(
last - from,
Math.ceil((scrollX + viewportWidth) / scale) + tickStep
);
for (let i = begin; i <= end; i += tickStep) result.push(i);
return result;
}, [scrollX, scale, tickStep, viewportWidth, last, from]);
const moveTask = (task: TopicTask, days: number) => {
const start = addDays(task.start, days);
return {
...task,
start,
due: preserveNonWorkingDue(
addDays(task.due, days),
taskDueDate(start, task.duration, calendar),
calendar
),
};
};
const effective = (row: (typeof rows)[number]) => {
if (drag?.id !== row.element.id || !drag.days) return row.task;
const task = row.task;
if (drag.kind === "move") return moveTask(task, drag.days);
const start =
drag.kind === "start" ? addDays(task.start, drag.days) : task.start;
const due = drag.kind === "due" ? addDays(task.due, drag.days) : task.due;
return {
...task,
start,
due,
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
};
};
const goToday = () =>
scrollRef.current?.scrollTo({
left: Math.max(0, (today - from) * scale - 200),
behavior: "smooth",
});
const downloadCsv = () => {
const quote = (v: unknown) => `"${String(v ?? "").replaceAll('"', '""')}"`;
const content =
"\ufeff" +
[
[
"任务名称",
"开始日期",
"截止日期",
"时长(天)",
"进度(%)",
"优先级",
"负责人",
"前置任务",
],
...visible.map((r) => [
r.title,
r.task.start,
r.task.due,
r.task.duration,
r.task.progress,
r.task.priority == null ? "" : `P${r.task.priority}`,
r.task.assignees.join(", "),
r.task.dependencies
.map(
(d) =>
`${rows.find((t) => t.element.id === d.id)?.title || d.id} ${d.type}`
)
.join(", "),
]),
]
.map((row) => row.map(quote).join(","))
.join("\r\n");
const url = URL.createObjectURL(
new Blob([content], { type: "text/csv;charset=utf-8" })
);
const a = document.createElement("a");
a.href = url;
a.download = `${title}-甘特图.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const linkTasks = () => {
if (selected.length !== 2) return;
const target = rows.find((r) => r.element.id === selected[1]);
if (!target) return;
onChange(target.element.id, {
...target.task,
dependencies: [
...target.task.dependencies.filter((d) => d.id !== selected[0]),
{ id: selected[0], type: "FS", lag: 0 },
],
});
};
return (
<section
role="dialog"
aria-modal="true"
aria-label="甘特图"
className="xmind-gantt"
ref={panelRef}
data-flow-board-shell-control="true"
onKeyUp={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab") {
const controls = Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled),input,select,[tabindex="0"]'
) || []
);
const first = controls[0],
last = controls.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === "z" &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLSelectElement)
) {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) onRedo();
else onUndo();
}
}}
>
<header className="xmind-gantt-toolbar">
<div className="xmind-gantt-title">
<button type="button" aria-label="关闭甘特图" onClick={onClose}>
<X size={18} />
</button>
<h2>甘特图</h2>
<span>{title}</span>
</div>
<div className="xmind-gantt-actions">
<button
type="button"
aria-label="连接选中任务"
title="按选择顺序建立完成 → 开始依赖"
disabled={selected.length !== 2}
onClick={linkTasks}
>
<Link2 size={18} />
</button>
<button
type="button"
aria-label="项目工作日"
title="项目工作日"
onClick={onCalendar}
>
<CalendarDays size={18} />
</button>
<button
type="button"
aria-label="导出甘特图 CSV"
onClick={downloadCsv}
>
<Download size={18} />
</button>
<button
type="button"
aria-label="打印甘特图"
onClick={() => window.print()}
>
<Printer size={18} />
</button>
<button type="button" aria-label="定位今天" onClick={goToday}>
<CalendarDays size={18} />
</button>
<div className="xmind-gantt-zoom">
<button
type="button"
aria-label="缩小甘特图"
onClick={() => setScale((s) => Math.max(2, s - 4))}
>
<Minus size={16} />
</button>
<input
type="range"
aria-label="甘特图缩放"
min={2}
max={64}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
/>
<button
type="button"
aria-label="放大甘特图"
onClick={() => setScale((s) => Math.min(64, s + 4))}
>
<Plus size={16} />
</button>
</div>
</div>
</header>
<div className="xmind-gantt-filters">
<label>
<Search size={14} />
<input
aria-label="搜索甘特图任务"
placeholder="搜索任务"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<select
aria-label="按负责人筛选"
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
>
<option value="">全部负责人</option>
{[...new Set(rows.flatMap((r) => r.task.assignees))].map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
aria-label="按任务进度筛选"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="all">全部任务</option>
<option value="open">未完成</option>
<option value="done">已完成</option>
</select>
<select
aria-label="选择新增任务主题"
value={candidate}
onChange={(e) => setCandidate(e.target.value)}
>
<option value="">选择主题添加任务</option>
{candidates.map((e) => (
<option key={e.element.id} value={e.element.id}>
{e.title}
</option>
))}
</select>
<button
type="button"
disabled={!candidate}
onClick={() => {
onEdit(candidate);
setCandidate("");
}}
>
添加任务
</button>
<span>
{calendar ? `${calendar.name} · 跳过非工作日 · ` : ""}
{visible.length} 个任务
</span>
</div>
<div className="xmind-gantt-body">
<div className="xmind-gantt-table">
<div className="xmind-gantt-table-head">
<span>任务名称</span>
<span>开始日期</span>
<span>时长</span>
</div>
<div
className="xmind-gantt-row-scroll"
ref={rowScrollRef}
onScroll={(e) => {
const node = scrollRef.current;
if (node && node.scrollTop !== e.currentTarget.scrollTop)
node.scrollTop = e.currentTarget.scrollTop;
}}
data-gantt-row-scroll="true"
>
{visible.map((row) => (
<div
className={`xmind-gantt-row ${selected.includes(row.element.id) ? "is-selected" : ""}`}
key={row.element.id}
style={{ height: ROW }}
>
<div
className="xmind-gantt-row-name"
style={{ paddingLeft: 8 + Math.min(row.depth, 5) * 12 }}
>
<input
type="checkbox"
aria-label={`选择任务 ${row.title}`}
checked={selected.includes(row.element.id)}
onChange={(e) =>
setSelected((s) =>
e.target.checked
? [...s, row.element.id]
: s.filter((id) => id !== row.element.id)
)
}
/>
{row.summary ? (
<button
type="button"
aria-label={`折叠任务 ${row.title}`}
aria-expanded={!collapsed.has(row.element.id)}
onClick={() =>
setCollapsed((old) => {
const next = new Set(old);
if (next.has(row.element.id))
next.delete(row.element.id);
else next.add(row.element.id);
return next;
})
}
>
{collapsed.has(row.element.id) ? (
<ChevronRight size={12} />
) : (
<ChevronDown size={12} />
)}
</button>
) : null}
<button
type="button"
className="xmind-gantt-task-title"
title={row.title}
onClick={() => onEdit(row.element.id)}
>
{row.title}
</button>
</div>
<button
type="button"
className="xmind-gantt-date"
onClick={() => onEdit(row.element.id)}
>
{effective(row).start || "未排期"}
</button>
<span>{effective(row).duration} 天</span>
</div>
))}
{!visible.length ? (
<div className="xmind-gantt-empty">
<CalendarDays size={52} />
<strong>{rows.length ? "没有匹配的任务" : "没有任务"}</strong>
<p>
{rows.length
? "调整搜索或筛选条件。"
: "请选择一个主题以添加任务。"}
</p>
</div>
) : null}
</div>
</div>
<div
className="xmind-gantt-timeline"
ref={scrollRef}
onScroll={(e) => {
setScrollX(e.currentTarget.scrollLeft);
setViewportWidth(e.currentTarget.clientWidth);
const rowScroll = rowScrollRef.current;
if (rowScroll && rowScroll.scrollTop !== e.currentTarget.scrollTop)
rowScroll.scrollTop = e.currentTarget.scrollTop;
}}
>
<div
className="xmind-gantt-grid"
style={{
width,
height: Math.max(600, HEADER + visible.length * ROW),
backgroundSize: `${scale}px ${ROW}px`,
}}
>
{calendar
? Array.from(
{ length: Math.ceil(viewportWidth / scale) + 3 },
(_, index) =>
Math.max(0, Math.floor(scrollX / scale) - 1) + index
)
.filter(
(offset) =>
offset <= last - from &&
!isWorkingDate(indexDate(from + offset), calendar)
)
.map((offset) => (
<div
key={`off-${offset}`}
className="xmind-gantt-day-off"
data-gantt-day-off={indexDate(from + offset)}
style={{
left: offset * scale,
width: scale,
top: HEADER,
bottom: 0,
}}
/>
))
: null}
<div
className="xmind-gantt-calendar"
style={{ width, height: HEADER }}
>
{ticks.map((offset) => {
const date = indexDate(from + offset);
return (
<div
className={`xmind-gantt-day ${calendar && !isWorkingDate(date, calendar) ? "is-day-off" : ""}`}
key={offset}
style={{ left: offset * scale, width: tickStep * scale }}
>
<b>
{tickStep >= 30
? date.slice(5, 7) + "月"
: tickStep >= 7
? date.slice(5)
: String(Number(date.slice(8)))}
</b>
</div>
);
})}
{monthBands.map((band) => (
<div
key={band.date}
className="xmind-gantt-month"
style={{
left: band.offset * scale,
width: band.days * scale,
}}
>
{band.date.replace("-", "年")}月
</div>
))}
</div>
<div
className="xmind-gantt-today"
aria-label="今天"
style={{ left: (today - from) * scale, top: HEADER }}
/>
<svg
className="xmind-gantt-connections"
aria-label="任务依赖关系"
role="img"
width={width}
height={Math.max(600, HEADER + visible.length * ROW)}
>
<defs>
<marker
id="gantt-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8d94a1" />
</marker>
</defs>
{visible.flatMap((row, i) =>
row.task.dependencies.map((d) => {
const fromIndex = visible.findIndex(
(r) => r.element.id === d.id
);
if (fromIndex < 0) return null;
const before = effective(visible[fromIndex]),
after = effective(row);
if (!before.start || !after.start) return null;
const x1 =
(dateIndex(
d.type[0] === "F" ? before.due : before.start
) -
from +
(d.type[0] === "F" ? 1 : 0)) *
scale,
x2 =
(dateIndex(d.type[1] === "F" ? after.due : after.start) -
from +
(d.type[1] === "F" ? 1 : 0)) *
scale,
y1 = HEADER + fromIndex * ROW + ROW / 2,
y2 = HEADER + i * ROW + ROW / 2;
return (
<path
key={`${row.element.id}-${d.id}`}
data-gantt-dependency={`${d.id}:${row.element.id}`}
d={`M${x1},${y1} H${x1 + 9} V${y2} H${x2}`}
fill="none"
stroke="#8d94a1"
strokeWidth={1.3}
markerEnd="url(#gantt-arrow)"
/>
);
})
)}
</svg>
{visible.map((row, i) =>
selected.includes(row.element.id) ? (
<div
key={`selection-${row.element.id}`}
className="xmind-gantt-selection"
style={{ top: HEADER + i * ROW, height: ROW, width }}
/>
) : null
)}
{visible.map((row, i) => {
const t = effective(row);
if (!t.start || !t.due) return null;
const left = (dateIndex(t.start) - from) * scale,
barWidth = Math.max(
6,
(dateIndex(t.due) - dateIndex(t.start) + 1) * scale
);
return (
<div
key={row.element.id}
className={`xmind-gantt-bar ${row.summary ? "is-summary" : ""} ${t.progress === 100 ? "is-complete" : ""}`}
data-gantt-task-id={row.element.id}
style={{ left, top: HEADER + i * ROW + 6, width: barWidth }}
role="button"
tabIndex={0}
aria-label={`任务时间条 ${row.title}`}
aria-pressed={selected.includes(row.element.id)}
title={`${row.title}\n${t.start} — ${t.due}\n进度 ${t.progress}%`}
onDoubleClick={() => onEdit(row.element.id)}
onKeyDown={(e) => {
if (e.key === "Enter") onEdit(row.element.id);
if (
!row.summary &&
["ArrowLeft", "ArrowRight"].includes(e.key)
) {
e.preventDefault();
const days = e.key === "ArrowLeft" ? -1 : 1;
onChange(row.element.id, moveTask(t, days));
}
}}
onPointerDown={(e) => {
if (row.summary || e.button !== 0) return;
e.preventDefault();
setSelected((ids) =>
e.metaKey || e.ctrlKey
? ids.includes(row.element.id)
? ids.filter((id) => id !== row.element.id)
: [...ids, row.element.id]
: [row.element.id]
);
e.currentTarget.setPointerCapture(e.pointerId);
const kind =
(e.target as HTMLElement).dataset.resize === "start"
? "start"
: (e.target as HTMLElement).dataset.resize === "due"
? "due"
: "move";
setDrag({
id: row.element.id,
x: e.clientX,
kind,
task: row.task,
days: 0,
});
}}
onPointerMove={(e) => {
if (drag?.id !== row.element.id) return;
let days = Math.round((e.clientX - drag.x) / scale);
if (drag.kind === "start")
days = Math.min(
dateIndex(drag.task.due) - dateIndex(drag.task.start),
days
);
if (drag.kind === "due")
days = Math.max(
dateIndex(drag.task.start) - dateIndex(drag.task.due),
days
);
setDrag({ ...drag, days });
}}
onPointerUp={() => {
if (drag?.id !== row.element.id) return;
if (drag.days) onChange(row.element.id, t);
setDrag(null);
}}
onPointerCancel={() => setDrag(null)}
>
<div
className="xmind-gantt-progress"
style={{ width: `${t.progress}%` }}
/>
{!row.summary ? (
<>
<span
data-resize="start"
className="xmind-gantt-resize is-start"
/>
<span
data-resize="due"
className="xmind-gantt-resize is-due"
/>
</>
) : null}
<span className="xmind-gantt-bar-label">
<span
className="xmind-gantt-avatar"
title={t.assignees.join("、") || "未分配负责人"}
>
<UserRound size={12} />
</span>
<span>{row.title}</span>
</span>
</div>
);
})}
</div>
</div>
</div>
<footer className="xmind-gantt-footer">
<span>
{drag
? `调整 ${drag.days > 0 ? "+" : ""}${drag.days} 天`
: "拖动调整日期 · 拉伸修改工期 · 双击编辑任务"}
</span>
<span>{notice}</span>
<button
type="button"
onClick={() => {
setQuery("");
setAssignee("");
setStatus("all");
setNotice("");
}}
>
清除筛选
</button>
</footer>
</section>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { Download, Loader2, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ImageExportOptions } from "@/lib/flow-board/image-export";
export function XmindImageExport({
busy,
onExport,
onClose,
}: {
busy: boolean;
onExport: (
options: Pick<ImageExportOptions, "scale" | "transparent">
) => void;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [scale, setScale] = useState<1 | 2 | 3>(2);
const [transparent, setTransparent] = useState(false);
useEffect(() => {
const node = dialog.current;
node?.showModal();
return () => node?.close();
}, []);
return (
<dialog
ref={dialog}
className="xmind-image-export"
aria-labelledby="image-export-title"
data-flow-board-shell-control="true"
onKeyDown={(event) => event.stopPropagation()}
onCancel={(event) => {
event.preventDefault();
if (!busy) onClose();
}}
>
<header>
<h2 id="image-export-title">导出图片</h2>
<button
type="button"
aria-label="关闭图片导出"
disabled={busy}
onClick={onClose}
>
<X size={18} />
</button>
</header>
<p>导出当前工作表的完整内容为 PNG 图片。</p>
<label className="xmind-image-export-field">
清晰度
<select
aria-label="图片清晰度"
value={scale}
disabled={busy}
onChange={(event) =>
setScale(Number(event.target.value) as 1 | 2 | 3)
}
>
<option value={1}>标准 · 1 倍</option>
<option value={2}>高清 · 2 倍</option>
<option value={3}>超清 · 3 倍</option>
</select>
</label>
<label className="xmind-image-export-check">
<input
type="checkbox"
checked={transparent}
disabled={busy}
onChange={(event) => setTransparent(event.target.checked)}
/>
透明背景
</label>
<footer>
<button type="button" disabled={busy} onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
disabled={busy}
onClick={() => onExport({ scale, transparent })}
>
{busy ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
{busy ? "正在导出…" : "导出 PNG"}
</button>
</footer>
</dialog>
);
}

View File

@ -0,0 +1,46 @@
"use client";
import { useState } from "react";
import { ChevronDown, Search, X } from "lucide-react";
import { XMIND_MARKER_GROUPS, type XmindMarker } from "@/lib/flow-board/xmind-markers";
export function XmindMarkerPanel({ selectedCount, activeIds, mixedIds, onApply, onClear, onClose }: {
selectedCount: number;
activeIds: string[];
mixedIds: string[];
onApply: (marker: XmindMarker) => void;
onClear: () => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [showAll, setShowAll] = useState(false);
const [collapsed, setCollapsed] = useState<string[]>([]);
const needle = query.trim().toLowerCase();
const groups = XMIND_MARKER_GROUPS.filter(group => showAll || needle || !group.hidden).map(group => ({
...group, markers: group.markers.filter(marker => (showAll || needle || !marker.hidden) && (!needle || `${group.label} ${marker.name} ${marker.id}`.toLowerCase().includes(needle))),
})).filter(group => group.markers.length);
return <aside className="xmind-format-panel xmind-marker-panel" aria-label="标记面板" data-flow-board-shell-control="true"
onPointerDownCapture={event => event.stopPropagation()} onMouseDownCapture={event => event.stopPropagation()}>
<div className="xmind-panel-heading"><span>标记</span><button type="button" aria-label="关闭标记面板" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-marker-search"><Search size={14} /><input aria-label="搜索标记" placeholder="搜索标记" value={query} onChange={event => setQuery(event.target.value)} /></div>
<p className="xmind-marker-selection" role="status">{selectedCount ? `已选中 ${selectedCount} 个主题` : "先选中主题,再添加标记"}</p>
<div className="xmind-panel-content">
{groups.map(group => <section className="xmind-marker-group" key={group.id}>
<button type="button" className="xmind-marker-group-heading" aria-expanded={!collapsed.includes(group.id) || Boolean(needle)} onClick={() => setCollapsed(ids => ids.includes(group.id) ? ids.filter(id => id !== group.id) : [...ids, group.id])}>
<ChevronDown size={13} /><span>{group.label}</span><span>{group.markers.length}</span>
</button>
{!collapsed.includes(group.id) || needle ? <div className="xmind-marker-grid">
{group.markers.map(marker => <button type="button" key={marker.id} data-marker-id={marker.id}
aria-label={marker.id.startsWith("priority-") ? marker.name : `${group.label} ${marker.name}`}
title={`${group.label} · ${marker.name}`} aria-pressed={activeIds.includes(marker.id) ? true : mixedIds.includes(marker.id) ? "mixed" : false}
disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={() => onApply(marker)}>
<img src={marker.asset} alt="" draggable={false} loading="lazy" />
</button>)}
</div> : null}
</section>)}
{!groups.length ? <p className="xmind-marker-empty">没有匹配的标记</p> : null}
{!needle ? <button type="button" className="xmind-marker-more" aria-expanded={showAll} onClick={() => setShowAll(value => !value)}>{showAll ? "收起更多标记" : "更多标记"}</button> : null}
</div>
<div className="xmind-marker-footer"><button type="button" disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={onClear}>清空所选节点标记</button></div>
</aside>;
}

View File

@ -0,0 +1,280 @@
/* Xmind editing controls use the project's home/canvas typography and density. */
.xmind-workspace {
--xmind-panel-width: 240px;
--xmind-ink: #24282b;
--xmind-muted: #888b8e;
--xmind-panel: #f3f4f5;
--xmind-border: #e3e5e8;
color: var(--xmind-ink);
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
background: white;
font-size: 13px;
}
.xmind-workspace button { cursor: pointer; }
.xmind-workspace :is(button,input,textarea,select) { font-family: inherit; }
.xmind-workspace button:disabled { cursor: default; opacity: .3; }
.xmind-workspace button:focus-visible { outline: 2px solid #35b5ff; outline-offset: 2px; }
.xmind-topic-marker { display:inline-block; object-fit:contain; flex-shrink:0; vertical-align:middle; }
.xmind-marker-search { display:flex; align-items:center; gap:6px; margin:4px 12px; padding:6px 8px; border:1px solid var(--xmind-border); border-radius:7px; background:white; }
.xmind-marker-search input { width:100%; min-width:0; outline:none; background:transparent; font-size:12px; }
.xmind-marker-selection { margin:6px 12px 10px; font-size:11px; color:var(--xmind-muted); }
.xmind-marker-panel .xmind-panel-content { flex:1; overflow-x:hidden; }
.xmind-marker-group { margin-bottom:12px; }
.xmind-marker-group-heading { display:flex; align-items:center; gap:5px; width:100%; height:26px; font-size:12px; font-weight:600; }
.xmind-marker-group-heading > span:last-child { margin-left:auto; font-size:10px; font-weight:400; color:var(--xmind-muted); }
.xmind-marker-group-heading[aria-expanded=false] svg { transform:rotate(-90deg); }
.xmind-marker-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }
.xmind-marker-grid button { display:grid; place-items:center; width:100%; height:28px; border:1px solid transparent; border-radius:5px; background:transparent; }
.xmind-marker-grid button:hover:not(:disabled) { background:#e3e6ea; }
.xmind-marker-grid button[aria-pressed=true] { border-color:#387cff; background:#e9f0ff; }
.xmind-marker-grid button[aria-pressed=mixed] { border:1px dashed #387cff; background:#e9f0ff80; }
.xmind-marker-grid img { width:20px; height:20px; object-fit:contain; }
.xmind-marker-more { width:100%; padding:7px; border-radius:6px; color:#3569e8; background:#e9edf4; font-size:12px; }
.xmind-marker-empty { padding:24px 0; text-align:center; color:var(--xmind-muted); }
.xmind-marker-footer { padding:10px 12px; border-top:1px solid var(--xmind-border); }
.xmind-marker-footer button { width:100%; padding:7px; border:1px solid var(--xmind-border); border-radius:6px; background:white; font-size:12px; }
.xmind-topbar { position:absolute; top:0; left:0; right:var(--xmind-panel-offset,0px); height:56px; z-index:50; display:flex; align-items:flex-start; padding:12px 12px 0; gap:8px; pointer-events:none; }
.xmind-topbar > * { pointer-events:auto; }
.xmind-document-heading { display:flex; align-items:center; gap:8px; min-width:200px; }
.xmind-home-button { display:grid; place-items:center; width:32px; height:32px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; flex-shrink:0; }
.xmind-document-name { height:32px; display:flex; flex-direction:column; justify-content:center; gap:0; min-width:100px; }
.xmind-document-name input { background:transparent; border:1px solid transparent; border-radius:5px; font-size:13px; font-weight:600; width:140px; padding:0 3px; outline:none; }
.xmind-document-name input:focus { border-color:#35b5ff; }
.xmind-document-name span { font-size:11px; padding-left:4px; }
.xmind-document-menu { margin-left:-8px; opacity:0; transition:opacity .15s; }
.xmind-document-heading:hover .xmind-document-menu,.xmind-document-heading:focus-within .xmind-document-menu { opacity:1; }
.xmind-pointer-toolbar { position:absolute; top:64px; left:12px; display:flex; flex-direction:column; gap:2px; padding:3px; border:1px solid var(--xmind-border); border-radius:10px; background:#fffffff5; box-shadow:0 2px 8px #00000008; }
.xmind-pointer-toolbar .xmind-icon-button { width:28px; height:28px; }
.xmind-primary-tools { display:flex; align-items:center; gap:8px; margin:auto; margin-top:0; }
.xmind-toolbar-pill { display:flex; align-items:center; padding:2px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; backdrop-filter:blur(24px); }
.xmind-icon-button { width:30px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--xmind-ink); background:transparent; flex-shrink:0; }
.xmind-icon-button > svg { width:18px; height:18px; }
.xmind-icon-button:hover:not(:disabled),.xmind-home-button:hover,.xmind-menu-trigger:hover { background:#00000009; }
.xmind-icon-button.is-active { background:#363b3e; color:white; }
.xmind-utility-tools { display:flex; align-items:center; gap:8px; }
.xmind-menu-host { position:relative; }
.xmind-menu-trigger { display:flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:2px 5px; border-radius:24px; }
.xmind-document-menu .xmind-menu-trigger > svg:last-child,.xmind-sheet-actions .xmind-menu-trigger > svg:last-child { display:none; }
.xmind-command-menu { position:absolute; top:calc(100% + 10px); left:0; min-width:220px; max-height:min(70vh,540px); overflow-y:auto; padding:5px; border:1px solid #dedfe2; border-radius:10px; background:#fcfcfcf5; backdrop-filter:blur(24px); box-shadow:0 12px 32px #00000020,0 2px 6px #0000000a; z-index:70; }
.xmind-menu-right { left:auto; right:0; }
.xmind-command { width:100%; display:flex; align-items:center; gap:10px; border-radius:5px; padding:7px 9px; text-align:left; min-height:32px; color:#34393c; font-size:13px; }
.xmind-command:hover:not(:disabled),.xmind-command:focus-visible { background:#35a6fc; color:white; outline:none; }
.xmind-command kbd { font:inherit; opacity:.5; margin-left:auto; font-size:11px; }
.xmind-command-check { display:block; width:16px; }
.xmind-menu-separator { height:1px; background:#e1e2e4; margin:4px 6px; }
.xmind-format-panel { position:absolute; top:8px; right:8px; bottom:8px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid #e5e7e9; box-shadow:-5px 0 20px #00000005; display:flex; flex-direction:column; z-index:35; overflow:hidden; }
.xmind-panel-heading { display:flex; justify-content:space-between; align-items:center; min-height:36px; padding:0 12px; font-weight:600; }
.xmind-panel-heading button { border-radius:6px; padding:4px; color:#7a7e83; }
.xmind-panel-heading button:hover { background:#e2e4e6; }
.xmind-panel-tabs { display:flex; margin:4px 10px 10px; padding:3px; height:28px; border-radius:8px; background:#e7e8eb; flex-shrink:0; }
.xmind-panel-tabs button { flex:1; border-radius:6px; font-size:12px; color:#6d7276; }
.xmind-panel-tabs button[aria-selected=true] { background:#ffffffd9; color:#23282b; font-weight:600; }
.xmind-panel-content { overflow-y:auto; min-height:0; padding:0 12px 12px; scrollbar-width:thin; scrollbar-color:#b5b5b5 transparent; }
.xmind-topic-preview { min-height:48px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; display:flex; align-items:center; gap:8px; padding:8px; margin-bottom:12px; }
.xmind-topic-preview > span { flex:1; text-align:center; font-size:14px; padding:5px; }
.xmind-topic-preview svg { color:#5c6267; flex-shrink:0; }
.xmind-format-section { padding:12px 0; border-top:1px solid var(--xmind-border); }
.xmind-format-section h3 { display:flex; align-items:center; gap:5px; margin:0 0 10px; font-size:12px; font-weight:600; }
.xmind-setting-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; font-size:12px; }
.xmind-setting-row h3 { margin:0; }
.xmind-setting-row > span { flex-shrink:0; }
.xmind-setting-row select { height:25px; background:#fff; border:1px solid var(--xmind-border); border-radius:8px; min-width:74px; max-width:170px; padding:0 8px; font-size:12px; }
.xmind-color-control { height:25px; width:74px; padding:0; border:1px solid var(--xmind-border); border-radius:8px; overflow:hidden; background:white; cursor:pointer; }
.xmind-color-control::-webkit-color-swatch-wrapper { padding:0; }
.xmind-color-control::-webkit-color-swatch { border:0; border-radius:7px; }
.xmind-segmented { display:flex; border:1px solid var(--xmind-border); background:white; border-radius:8px; overflow:hidden; height:27px; }
.xmind-segmented button { flex:1; display:grid; place-items:center; margin:2px; border-radius:6px; }
.xmind-segmented button[aria-pressed=true] { background:#e4e6e8; }
.xmind-segmented button:hover:not(:disabled) { background:#f1f2f3; }
.xmind-shape-rectangle { width:18px; height:12px; border:1.5px solid currentColor; border-radius:3px; }
.xmind-shape-line { width:24px; border-bottom:1.5px solid currentColor; }
.xmind-text-marks { height:28px; margin-top:12px; }
.xmind-structure-trigger { display:flex; height:26px; align-items:center; padding:0 8px; background:white; border:1px solid var(--xmind-border); border-radius:8px; }
.xmind-structure-trigger svg[data-layout-preview] { width:35px; height:24px; }
.xmind-current-structure { padding:4px 10px; margin-top:14px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; font-size:12px; color:#7e8388; }
.xmind-structure-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; margin-top:12px; }
.xmind-structure-grid button { display:flex; flex-direction:column; justify-content:center; align-items:center; gap:4px; min-height:64px; border:1px solid #e0e3e7; border-radius:7px; font-size:9px; padding:4px 2px; background:white; }
.xmind-structure-grid button[aria-pressed=true] { border-color:#36b4ff; background:#edf8ff; color:#179cdc; }
.xmind-background-presets { display:grid; grid-template-columns:repeat(6,1fr); gap:6px; }
.xmind-background-presets button { height:28px; border:1px solid #d6d8dc; border-radius:6px; }
.xmind-background-presets button[aria-pressed=true] { outline:2px solid #35b5ff; }
.xmind-theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
.xmind-theme-grid button { background:white; border:1px solid #e1e3e6; border-radius:8px; font-size:11px; padding:8px; }
.xmind-theme-grid button[aria-pressed=true] { border-color:#35b5ff; }
.xmind-theme-grid button > span { display:flex; margin-bottom:8px; height:12px; overflow:hidden; border-radius:3px; }
.xmind-theme-grid i { flex:1; }
.xmind-switch { width:30px; height:18px; border-radius:12px; background:#bfc3c8; position:relative; }
.xmind-switch i { position:absolute; top:2px; left:2px; height:14px; width:14px; background:white; border-radius:50%; transition:transform .15s; }
.xmind-switch[aria-checked=true] { background:#35b5ff; }
.xmind-switch[aria-checked=true] i { transform:translateX(12px); }
.xmind-canvas-preview { height:96px; border:1px solid var(--xmind-border); border-radius:8px; margin-top:12px; }
.xmind-sheetbar { position:absolute; bottom:10px; left:16px; right:calc(var(--xmind-panel-offset,0px) + 16px); height:30px; z-index:40; display:flex; align-items:center; gap:8px; pointer-events:none; }
.xmind-sheetbar > * { pointer-events:auto; }
.xmind-sheet-tabs { display:flex; gap:4px; max-width:60%; overflow-x:auto; scrollbar-width:none; }
.xmind-sheet-tab-host { flex-shrink:0; }
.xmind-sheet-tab-host > button { border-radius:16px; padding:4px 18px; font-size:12px; font-weight:500; background:#ffffffd9; max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.xmind-sheet-tab-host > button[aria-pressed=true] { background:#e4e5e6; font-weight:600; }
.xmind-sheet-tab-host input { width:130px; height:25px; border-radius:6px; background:white; border:1px solid #35b5ff; padding:0 8px; }
.xmind-sheet-context { position:absolute; bottom:36px; min-width:140px; padding:5px; border:1px solid #e2e4e7; border-radius:10px; background:white; box-shadow:0 10px 25px #00000015; }
.xmind-sheet-context button { display:block; width:100%; text-align:left; padding:7px 10px; border-radius:6px; }
.xmind-sheet-context button:hover { background:#f0f3f5; }
.xmind-footer-divider { width:1px; height:13px; background:#dfe2e5; flex-shrink:0; }
.xmind-footer-icon { display:grid; place-items:center; width:26px; height:26px; border-radius:6px; }
.xmind-footer-icon:hover { background:#f2f3f5; }
.xmind-sheet-actions { opacity:0; }
.xmind-sheetbar:hover .xmind-sheet-actions,.xmind-sheet-actions:focus-within { opacity:1; }
.xmind-sheet-status { margin-left:auto; display:flex; gap:12px; align-items:center; background:#ffffffd9; }
.xmind-topic-count { font-size:11px; white-space:nowrap; }
.xmind-zoom-menu .xmind-command-menu,.xmind-sheet-actions .xmind-command-menu { top:auto; bottom:calc(100% + 8px); }
.xmind-zoom-menu .xmind-menu-trigger { font-size:12px; padding:0; min-width:50px; }
.xmind-outline-toggle { font-weight:600; font-size:13px; white-space:nowrap; }
.xmind-workspace .flow-board-canvas-shell { top:0; bottom:0; right:var(--xmind-panel-offset,0px); }
.xmind-workspace .board-host-svg { background:transparent !important; }
.xmind-workspace .board-active-svg { z-index:2; pointer-events:none; }
.xmind-workspace .active-host-g path[stroke="#6698FF"] { stroke:#387cff; stroke-width:2px; vector-effect:non-scaling-stroke; }
.xmind-workspace .active-host-g path[fill="#6698FF25"] { fill:rgba(56,124,255,.12); }
.xmind-outline-shell { position:absolute; inset:56px var(--xmind-panel-offset,0px) 48px 0; z-index:20; }
.xmind-workspace .xmind-aux-panel { top:8px; bottom:8px; right:8px; width:var(--xmind-panel-width); max-width:var(--xmind-panel-width); border-radius:12px; border:1px solid var(--xmind-border); background:var(--xmind-panel); }
.xmind-zen-exit { position:absolute; top:16px; right:16px; z-index:45; display:flex; gap:8px; align-items:center; padding:9px 14px; border-radius:24px; background:#fffffff0; box-shadow:0 4px 20px #0000000c; font-size:12px; }
.xmind-empty-canvas { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:20; border:1px solid #333; background:white; border-radius:8px; padding:8px 16px; font-size:16px; font-weight:600; }
.xmind-search-panel { position:absolute; top:64px; left:8px; bottom:48px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid var(--xmind-border); z-index:45; display:flex; flex-direction:column; box-shadow:5px 0 20px #00000008; }
.xmind-workspace:has(.xmind-search-panel) .xmind-pointer-toolbar { visibility:hidden; }
.xmind-search-panel .xmind-panel-content { padding:0 14px 14px; }
.xmind-search-box { display:flex; gap:8px; border-radius:8px; border:1px solid var(--xmind-border); background:white; padding:8px; align-items:center; margin:8px 0; }
.xmind-search-box input { outline:0; width:100%; background:transparent; font-size:13px; }
.xmind-search-options { display:flex; align-items:center; gap:12px; margin:10px 0; font-size:11px; }
.xmind-search-panel select { width:100%; height:30px; border-radius:8px; background:white; border:1px solid var(--xmind-border); padding:0 8px; font-size:12px; }
.xmind-search-navigation { display:flex; align-items:center; gap:8px; margin:12px 0; font-size:11px; color:#81868b; }
.xmind-search-navigation span { flex:1; }
.xmind-search-navigation button { padding:4px; border-radius:5px; }
.xmind-search-navigation button:hover { background:#e5e7e9; }
.xmind-search-result { display:block; width:100%; text-align:left; padding:10px; border-radius:8px; margin:4px 0; background:#ffffffa6; }
.xmind-search-result.is-active { outline:1px solid #35b5ff; background:#ecf8ff; }
.xmind-search-result > span { display:block; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.xmind-search-result small { font-size:10px; color:#8a8e91; }
.xmind-search-replace-buttons { display:flex; gap:8px; margin:10px 0 16px; }
.xmind-search-replace-buttons button { flex:1; border:1px solid var(--xmind-border); border-radius:6px; background:white; padding:5px; font-size:11px; }
.xmind-presentation { position:absolute; inset:0; z-index:60; background:#fff; display:flex; flex-direction:column; }
.xmind-presentation > header { display:flex; align-items:center; justify-content:space-between; padding:20px 28px; color:#8b9095; font-size:12px; }
.xmind-presentation > header button { display:flex; align-items:center; gap:6px; }
.xmind-presentation-slide { display:flex; flex:1; flex-direction:column; align-items:center; justify-content:center; padding:60px; overflow:auto; }
.xmind-presentation-slide h1 { font-size:clamp(28px,4vw,56px); font-weight:650; text-align:center; }
.xmind-presentation-slide ul { display:flex; flex-direction:column; gap:20px; margin-top:40px; font-size:clamp(18px,2vw,28px); }
.xmind-presentation-slide li { display:flex; align-items:center; gap:15px; }
.xmind-presentation-slide li::before { content:""; width:8px; height:8px; border-radius:50%; background:#ff7160; }
.xmind-presentation > footer { display:flex; justify-content:center; align-items:center; gap:20px; padding:20px; font-size:12px; color:#72787e; }
.xmind-presentation > footer button { padding:8px; border:1px solid #e6e7e9; border-radius:50%; }
@media (max-width:1100px) { .xmind-window-dots { display:none; } .xmind-document-heading { min-width:160px; gap:8px; } .xmind-document-name input { width:100px; } .xmind-utility-tools { gap:8px; } .xmind-primary-tools { gap:8px; } .xmind-topbar { gap:8px; padding-left:12px; } }
@media (max-width:850px) { .xmind-document-name { display:none; } .xmind-document-heading { min-width:auto; } .xmind-document-menu { opacity:1; } .xmind-utility-tools > .xmind-toolbar-pill:nth-child(2) { display:none; } .xmind-topbar { justify-content:space-between; } }
@media (max-width:640px) { .xmind-workspace { --xmind-panel-offset:0px !important; } .xmind-format-panel,.xmind-workspace .xmind-aux-panel { width:min(var(--xmind-panel-width),calc(100vw - 16px)); } .xmind-topbar { gap:4px; padding:12px 8px; } .xmind-home-button { width:32px; } .xmind-primary-tools { gap:4px; margin:0 auto; } .xmind-icon-button { width:28px; } .xmind-document-menu { display:none; } .xmind-topic-count { display:none; } .xmind-sheetbar { left:10px; right:10px; gap:5px; } .xmind-sheet-status { gap:6px; } .xmind-sheet-tabs { max-width:50%; } }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Regular.ttf') format('truetype'); font-weight:400; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Medium.ttf') format('truetype'); font-weight:500; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Bold.ttf') format('truetype'); font-weight:700; font-style:normal; font-display:swap; }
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Extrabold.ttf') format('truetype'); font-weight:800; font-style:normal; font-display:swap; }
@layer base {
.xmind-workspace .drawnix,.xmind-workspace .drawnix * { --ui-font:Arial,"PingFang SC",sans-serif; font-family:Arial,"PingFang SC",sans-serif !important; }
.xmind-workspace .drawnix .plait-text-container,.xmind-workspace .drawnix .plait-text-container * { font-family:NeverMind,Arial,"PingFang SC",sans-serif !important; }
}
.xmind-native-icon { display:block; width:18px; height:18px; background:currentColor; mask-size:contain; mask-position:center; mask-repeat:no-repeat; }
/* Topic notes follow the native compact formatting toolbar. */
.xmind-notes-editor { border:1px solid #e2e5eb; border-radius:12px; background:#fafbfc; overflow:hidden; }
.xmind-notes-toolbar { display:flex; justify-content:center; align-items:center; gap:2px; padding:7px; border-bottom:1px solid #eceef1; }
.xmind-notes-toolbar button { display:grid; place-items:center; width:28px; height:28px; border-radius:6px; }
.xmind-notes-toolbar button:hover,.xmind-notes-toolbar button[aria-pressed=true] { background:#e9edf5; color:#3569e8; }
.xmind-note-content { min-height:180px; max-height:380px; overflow:auto; padding:12px; outline:none; font-size:13px; line-height:1.7; color:#30343b; counter-reset:note-number; }
.xmind-note-content p { margin:0 0 4px; position:relative; }
.xmind-note-content p[data-note-list] { padding-left:20px; }
.xmind-note-content p[data-note-list=bullet]::before { content:"•"; position:absolute; left:3px; }
.xmind-note-content p[data-note-list=number] { counter-increment:note-number; }
.xmind-note-content p[data-note-list=number]::before { content:counter(note-number) "."; position:absolute; left:0; }
.xmind-note-content a { color:#3569e8; text-decoration:underline; }
.xmind-note-link-form { padding:8px; display:flex; flex-wrap:wrap; gap:6px; }
.xmind-note-link-form input { min-width:0; flex:1; border:1px solid #dfe2e7; padding:4px; border-radius:5px; }
.xmind-note-link-form p { width:100%; color:#c0392b; font-size:11px; }
.xmind-task-backdrop { position:absolute; inset:0; background:rgb(20 27 40 / .16); z-index:85; display:grid; place-items:center; padding:20px; }
.xmind-task-editor { width:min(460px,100%); max-height:calc(100vh - 40px); overflow:auto; border:1px solid #e0e3e8; border-radius:16px; background:#fafbfc; color:#343b48; padding:18px; box-shadow:0 16px 64px rgb(20 27 40 / .18); font-size:12px; }
.xmind-task-editor header,.xmind-task-editor header h2 { display:flex; align-items:center; gap:8px; }
.xmind-task-editor header { justify-content:space-between; }.xmind-task-editor h2 { font-size:14px; font-weight:600; }
.xmind-task-name { margin:14px 0; font-size:15px; font-weight:600; overflow-wrap:anywhere; }
.xmind-task-fields { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.xmind-task-fields label { display:flex; flex-direction:column; gap:5px; }
.xmind-task-editor input,.xmind-task-editor select { width:100%; min-width:0; height:32px; padding:0 8px; border:1px solid #dfe2e7; background:white; border-radius:6px; outline:none; }
.xmind-task-editor input:focus,.xmind-task-editor select:focus { border-color:#799ce8; }
.xmind-task-editor h3 { font-weight:600; margin:16px 0 8px; }
.xmind-task-dependencies>div { display:flex; justify-content:space-between; padding:6px 0; }.xmind-task-add-dependency { display:grid; grid-template-columns:1fr 1fr 50px 34px; gap:5px; }
.xmind-task-editor footer { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:20px; }.xmind-task-editor footer button { border-radius:6px; padding:7px 11px; }.xmind-task-editor footer button:first-child { margin-right:auto; }
.xmind-primary-button { background:#3569e8; color:white; }.xmind-danger,.xmind-task-error { color:#c23c36; }.xmind-task-error { margin-top:10px; }
.xmind-gantt { position:absolute; inset:0; z-index:75; display:flex; flex-direction:column; background:#edf0f3; color:#30343b; font-size:12px; }
.xmind-gantt-toolbar { display:flex; align-items:center; justify-content:space-between; min-height:58px; padding:8px 16px; gap:10px; }.xmind-gantt-title,.xmind-gantt-actions { display:flex; align-items:center; gap:12px; }.xmind-gantt-title h2 { font-size:15px; font-weight:600; white-space:nowrap; }.xmind-gantt-title>span { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#7d8592; }
.xmind-gantt-actions>button,.xmind-gantt-title>button { display:grid; place-items:center; width:32px; height:32px; background:#fafbfc; border-radius:12px; }.xmind-gantt button:disabled { opacity:.35; cursor:default; }.xmind-gantt-zoom { display:flex; align-items:center; gap:8px; padding:6px 10px; background:#fafbfc; border-radius:16px; }.xmind-gantt-zoom input { width:90px; }
.xmind-gantt-filters { display:flex; align-items:center; gap:8px; padding:0 16px 10px; flex-wrap:wrap; }.xmind-gantt-filters label { display:flex; align-items:center; gap:6px; background:#fafbfc; padding:0 8px; border:1px solid #e0e3e8; border-radius:6px; }.xmind-gantt-filters input { width:130px; height:28px; outline:none; background:transparent; }.xmind-gantt-filters select { max-width:210px; height:30px; border:1px solid #e0e3e8; background:#fafbfc; padding:0 8px; border-radius:6px; }.xmind-gantt-filters>span { margin-left:auto; color:#7d8592; }
.xmind-gantt-body { min-height:0; flex:1; display:grid; grid-template-columns:minmax(290px,34%) minmax(0,1fr); gap:12px; padding:0 12px; }.xmind-gantt-table,.xmind-gantt-timeline { border:1px solid #dfe3e9; border-radius:12px; background:#fafbfc; overflow:hidden; min-width:0; position:relative; }.xmind-gantt-table { display:flex; flex-direction:column; }.xmind-gantt-table-head,.xmind-gantt-row { display:grid; grid-template-columns:minmax(0,1fr) 104px 54px; align-items:center; }.xmind-gantt-table-head { height:64px; min-height:64px; font-weight:600; border-bottom:1px solid #e2e5eb; }.xmind-gantt-table-head>span { display:flex; align-items:center; height:100%; padding:0 12px; border-right:1px solid #e2e5eb; }.xmind-gantt-row-scroll { overflow:auto; scrollbar-width:none; flex:1; position:relative; }.xmind-gantt-row { border-bottom:1px solid #e2e5eb; }.xmind-gantt-row.is-selected { background:#edf3ff; }.xmind-gantt-row>span { padding:0 7px; }.xmind-gantt-row-name { display:flex; align-items:center; gap:5px; overflow:hidden; }.xmind-gantt-row-name input { flex-shrink:0; }.xmind-gantt-task-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }.xmind-gantt-date { font-size:11px; text-align:left; padding:0 8px; }
.xmind-gantt-timeline { overflow:auto; }.xmind-gantt-grid { position:relative; background-image:linear-gradient(to right,#e7e9ee 1px,transparent 1px),linear-gradient(to bottom,#e7e9ee 1px,transparent 1px); background-position:0 64px; }.xmind-gantt-calendar { position:sticky; top:0; z-index:5; background:#fafbfc; border-bottom:1px solid #e2e5eb; }.xmind-gantt-day { position:absolute; top:0; height:64px; display:flex; flex-direction:column; justify-content:space-around; align-items:center; border-right:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }.xmind-gantt-day span { font-size:10px; color:#8b929d; }.xmind-gantt-day b { font-weight:500; }.xmind-gantt-today { position:absolute; bottom:0; width:2px; background:#fa603b; pointer-events:none; z-index:2; }.xmind-gantt-connections { position:absolute; inset:0; pointer-events:none; z-index:2; }.xmind-gantt-bar { position:absolute; height:26px; border-radius:16px; background:#fac28a; cursor:grab; touch-action:none; z-index:3; overflow:hidden; min-width:6px; border:1px solid #f5b978; }.xmind-gantt-bar:focus { outline:2px solid #3569e8; outline-offset:2px; }.xmind-gantt-progress { position:absolute; inset:0 auto 0 0; background:#f3a660; }.xmind-gantt-bar.is-complete { background:#b8dcca; border-color:#68a986; }.xmind-gantt-bar.is-complete .xmind-gantt-progress { background:#86b99b; }.xmind-gantt-bar.is-summary { height:10px; margin-top:4px; background:#667387; border-color:#667387; cursor:default; }.xmind-gantt-bar-label { position:relative; display:flex; align-items:center; gap:8px; padding:0 6px; height:24px; color:#72491f; font-size:11px; pointer-events:none; white-space:nowrap; }
.xmind-gantt-bar-label>span:last-child { flex:1; overflow:hidden; text-overflow:ellipsis; text-align:center; }
.xmind-gantt-avatar { width:18px; height:18px; border-radius:50%; background:#eceff1; color:#939ba2; display:grid; place-items:center; flex-shrink:0; }
.xmind-gantt-selection { position:absolute; left:0; background:rgb(125 136 148 / .15); pointer-events:none; z-index:1; }
.xmind-gantt-bar[aria-pressed=true] { outline:2px solid #539efa; outline-offset:1px; }.xmind-gantt-bar.is-summary .xmind-gantt-bar-label { display:none; }.xmind-gantt-resize { position:absolute; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:4; }.xmind-gantt-resize.is-start { left:0; }.xmind-gantt-resize.is-due { right:0; }
.xmind-gantt-empty { min-height:300px; height:70%; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#a0a6af; gap:10px; }.xmind-gantt-empty strong { font-size:15px; color:#424852; }.xmind-gantt-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:32px; padding:0 16px; color:#7d8592; font-size:11px; }
@media(max-width:800px) { .xmind-gantt-title>span { display:none; }.xmind-gantt-body { grid-template-columns:260px minmax(0,1fr); gap:6px; padding:0 6px; }.xmind-gantt-table-head,.xmind-gantt-row { grid-template-columns:minmax(0,1fr) 84px 42px; }.xmind-gantt-actions { gap:5px; }.xmind-gantt-toolbar { padding:8px; }.xmind-gantt-zoom input { width:50px; } }
@media print { .xmind-workspace:has(.xmind-gantt)>:not(.xmind-gantt) { display:none!important; }.xmind-gantt { position:fixed; background:white; }.xmind-gantt-toolbar button,.xmind-gantt-actions,.xmind-gantt-filters,.xmind-gantt-footer { display:none; }.xmind-gantt-timeline,.xmind-gantt-row-scroll { overflow:visible; }.xmind-gantt-body { overflow:visible; }.xmind-gantt-table { overflow:visible; }.xmind-gantt-row-name input { display:none; } }
.xmind-gantt-grid { min-height:100%; }
.xmind-gantt-day { top:28px; height:36px; }
.xmind-gantt-month { position:absolute; top:0; height:28px; padding:6px 12px; text-align:center; color:#7d8592; border-right:1px solid #e2e5eb; border-bottom:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }
.xmind-topic-badges { position:absolute; inset:0; pointer-events:none; z-index:12; overflow:hidden; }
.xmind-topic-badge { position:absolute; display:flex; align-items:center; gap:2px; pointer-events:auto; }
.xmind-topic-badge button { display:grid; place-items:center; width:19px; height:19px; background:rgb(255 255 255 / .9); border-radius:4px; color:#8b929d; }
.xmind-topic-badge button:hover { background:#edf3ff; color:#3569e8; }
.xmind-note-content p:not([data-note-list=number]) { counter-reset:note-number; }
.xmind-gantt-day-off { position:absolute; background:rgb(111 124 139 / .09); pointer-events:none; z-index:0; }
.xmind-gantt-day.is-day-off { color:#929aa6; background:#eef0f3; }
.xmind-task-calendar-info { display:flex; justify-content:space-between; align-items:center; gap:10px; margin:12px 0; color:#7d8592; font-size:11px; }
.xmind-task-calendar-info button { padding:6px 10px; border:1px solid #dce1e8; border-radius:6px; background:#f7f8fa; color:#3b4452; }
.xmind-project-calendar-backdrop,.xmind-calendar-exception-backdrop { position:absolute; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; background:rgb(29 36 46 / .22); padding:16px; }
.xmind-calendar-exception-backdrop { position:fixed; z-index:110; }
.xmind-project-calendar,.xmind-calendar-exception { display:flex; flex-direction:column; width:740px; max-width:100%; max-height:calc(100svh - 32px); color:#343b48; font-size:12px; background:#f7f8fa; border:1px solid #dce1e8; border-radius:14px; box-shadow:0 18px 60px rgb(0 0 0 / .17); overflow:hidden; }
.xmind-project-calendar>header,.xmind-calendar-exception>header { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px; }
.xmind-project-calendar h2,.xmind-calendar-exception h2 { display:flex; align-items:center; gap:9px; font-size:16px; font-weight:600; }
.xmind-project-calendar>header p { margin-top:6px; font-size:11px; color:#8b929d; }
.xmind-project-calendar>header button,.xmind-calendar-exception>header button { padding:4px; border-radius:5px; }
.xmind-project-calendar-content { display:grid; grid-template-columns:290px minmax(0,1fr); gap:12px; padding:0 20px 18px; min-height:0; overflow:auto; }
.xmind-calendar-preview,.xmind-calendar-settings { padding:15px; border:1px solid #e0e4ea; border-radius:10px; background:#fff; }
.xmind-project-calendar label,.xmind-calendar-exception label { display:flex; flex-direction:column; gap:6px; font-size:11px; }
.xmind-project-calendar input:not([type=checkbox]),.xmind-project-calendar select,.xmind-calendar-exception input,.xmind-calendar-exception select { height:31px; padding:0 9px; border:1px solid #dce1e8; background:#fff; border-radius:6px; font-size:12px; outline:none; min-width:0; width:100%; }
.xmind-project-calendar input:focus,.xmind-project-calendar select:focus,.xmind-calendar-exception input:focus,.xmind-calendar-exception select:focus { border-color:#6592ef; box-shadow:0 0 0 2px rgb(53 105 232 / .1); }
.xmind-project-calendar h3 { font-size:12px; font-weight:600; }
.xmind-calendar-import { display:flex; align-items:flex-end; gap:8px; margin-bottom:18px; }.xmind-calendar-import label { flex:1; min-width:0; }.xmind-calendar-import>button { color:#3569e8; font-size:11px; margin-bottom:8px; }
.xmind-calendar-month-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:9px; }.xmind-calendar-month-header button { padding:5px; }.xmind-calendar-month-header strong { font-weight:500; }
.xmind-calendar-month-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }.xmind-calendar-month-grid>button,.xmind-calendar-weekday { height:29px; display:grid; place-items:center; border-radius:5px; font-size:11px; }
.xmind-calendar-weekday { color:#8b929d; }.xmind-calendar-month-grid .is-day-off { background:repeating-linear-gradient(135deg,#f1f3f6,#f1f3f6 3px,#e9ecf0 3px,#e9ecf0 4px); color:#89929e; }.xmind-calendar-month-grid .is-other-month { opacity:.4; }.xmind-calendar-month-grid>button[aria-pressed=true] { background:#edf3ff; box-shadow:inset 0 0 0 1px #6592ef; color:#3569e8; }
.xmind-calendar-hint { color:#929aa6; font-size:10px; line-height:1.6; margin-top:9px; }
.xmind-project-calendar .xmind-calendar-skip { flex-direction:row; align-items:center; gap:7px; margin:18px 0; font-size:12px; }.xmind-calendar-working-week { display:flex; justify-content:space-between; gap:5px; padding:12px 0 18px; border-bottom:1px solid #e9edf2; }.xmind-calendar-working-week label { align-items:center; }
.xmind-calendar-rule-heading { display:flex; align-items:center; justify-content:space-between; margin:16px 0 9px; }.xmind-calendar-rule-heading button { padding:5px; background:#f2f4f7; border-radius:5px; }.xmind-calendar-rules { max-height:225px; overflow:auto; }.xmind-calendar-rules>div { display:flex; justify-content:space-between; align-items:center; gap:7px; padding:9px 0; border-bottom:1px solid #eef0f4; }.xmind-calendar-rules>div>button:first-child { flex:1; text-align:left; min-width:0; }.xmind-calendar-rules strong,.xmind-calendar-rules span { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.xmind-calendar-rules strong { font-weight:500; }.xmind-calendar-rules span { font-size:10px; color:#8b929d; margin-top:5px; }.xmind-calendar-rules>div>button:last-child { padding:6px; color:#8b929d; }.xmind-calendar-rules>p { color:#929aa6; font-size:11px; padding:10px 0; }
.xmind-project-calendar>footer,.xmind-calendar-exception>footer { padding:13px 20px; display:flex; justify-content:space-between; gap:10px; border-top:1px solid #e2e6ec; background:#fff; }.xmind-project-calendar>footer>div { display:flex; gap:8px; }.xmind-project-calendar>footer button,.xmind-calendar-exception>footer button,.xmind-calendar-personal>button { padding:8px 12px; border:1px solid #dce1e8; border-radius:6px; font-size:11px; }.xmind-project-calendar>footer .xmind-primary-button,.xmind-calendar-exception>footer .xmind-primary-button { border-color:#3569e8; }
.xmind-calendar-personal { border-top:1px solid #e6e9ee; margin-top:14px; padding-top:12px; }.xmind-calendar-personal p { color:#929aa6; font-size:10px; margin:7px 0; }.xmind-calendar-personal>div { display:flex; justify-content:space-between; align-items:center; margin:9px 0; }.xmind-calendar-personal>label { margin:12px 0 8px; }
.xmind-calendar-exception { width:420px; }.xmind-calendar-exception-fields { display:flex; flex-direction:column; gap:12px; padding:0 20px 20px; overflow:auto; }.xmind-calendar-range { display:grid; grid-template-columns:1fr 1fr; gap:12px; }.xmind-calendar-exception>footer { justify-content:flex-end; }
@media(max-width:700px) { .xmind-project-calendar-content { grid-template-columns:1fr; }.xmind-calendar-preview { max-width:360px; width:100%; justify-self:center; }.xmind-project-calendar>footer { flex-wrap:wrap; }.xmind-project-calendar>footer>div:last-child { margin-left:auto; } }
.xmind-notes-popover { position:absolute; z-index:40; width:min(360px,calc(100vw - 32px)); padding:8px; background:#fafbfc; border:1px solid #e5e8ed; border-radius:18px; box-shadow:0 14px 44px rgb(0 0 0 / .14); color:#30343b; }
.xmind-notes-popover>header { display:flex; align-items:center; justify-content:space-between; padding:0 6px; height:24px; font-size:11px; color:#9198a3; }.xmind-notes-popover>header>span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.xmind-notes-popover>header>div { display:flex; align-items:center; gap:8px; }
.xmind-notes-popover .xmind-notes-editor { border:0; border-radius:12px; }.xmind-notes-popover .xmind-notes-toolbar { border-bottom:0; padding:4px; }.xmind-notes-popover .xmind-note-content { min-height:220px; max-height:280px; padding:8px; }.xmind-note-empty { min-height:220px; padding:16px; font-size:12px; color:#8b929d; }
.xmind-image-export { width:400px; max-width:calc(100vw - 32px); border:1px solid #e5e5e8; border-radius:14px; padding:24px; color:#303034; background:white; box-shadow:0 18px 60px #0002; font:14px NeverMind,Arial,"PingFang SC",sans-serif; }
.xmind-image-export::backdrop { background:#0004; }
.xmind-image-export header,.xmind-image-export footer { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.xmind-image-export h2 { margin:0; font-size:18px; font-weight:600; }
.xmind-image-export p { color:#737378; margin:18px 0 24px; line-height:1.6; }
.xmind-image-export button { display:inline-flex; gap:7px; align-items:center; justify-content:center; min-height:34px; border:0; border-radius:7px; padding:7px 12px; background:#f2f2f4; color:inherit; cursor:pointer; }
.xmind-image-export button:disabled { opacity:.55; cursor:wait; }
.xmind-image-export button.xmind-primary-button { background:#2463eb; color:white; }
.xmind-image-export-field { display:flex; align-items:center; justify-content:space-between; gap:20px; }
.xmind-image-export select { width:220px; height:36px; background:white; border:1px solid #dcdce2; border-radius:7px; padding:0 10px; color:inherit; }
.xmind-image-export-check { display:flex; align-items:center; gap:8px; margin:20px 0 28px; }
.xmind-image-export-check input { accent-color:#2463eb; }
.xmind-image-export footer { justify-content:flex-end; }

View File

@ -0,0 +1,318 @@
"use client";
import {
Bold,
Italic,
Underline,
Strikethrough,
List,
ListOrdered,
Link2,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
createEditor,
Editor,
Element,
Transforms,
type Descendant,
} from "slate";
import { withHistory } from "slate-history";
import {
Editable,
Slate,
withReact,
ReactEditor,
type RenderElementProps,
type RenderLeafProps,
} from "slate-react";
import {
type NoteDocument,
type NoteParagraph,
safeNoteLink,
serializeNoteDocument,
} from "@/lib/flow-board/topic-notes";
export function XmindNotesEditor({
value,
onChange,
}: {
value: NoteDocument;
onChange: (value: NoteDocument) => void;
}) {
const [editor] = useState(() => withHistory(withReact(createEditor())));
const sourceRef = useRef(value);
sourceRef.current = value;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const [revision, setRevision] = useState(0);
const [linkOpen, setLinkOpen] = useState(false),
[link, setLink] = useState("");
const [linkError, setLinkError] = useState("");
const savedSelection = useRef(editor.selection);
useEffect(() => {
if (
JSON.stringify(serializeNoteDocument(editor.children as NoteDocument)) ===
JSON.stringify(serializeNoteDocument(value))
)
return;
editor.children = value as Descendant[];
editor.selection = null;
editor.history = { undos: [], redos: [] };
editor.onChange();
}, [editor, value]);
const marks = Editor.marks(editor) as Record<string, unknown> | null;
// Toolbar presses can arrive before Slate's throttled selection listener.
const syncDOMSelection = () => {
const selection = window.getSelection();
if (
!selection?.anchorNode ||
!ReactEditor.hasDOMNode(editor, selection.anchorNode)
)
return;
const range = ReactEditor.toSlateRange(editor, selection, {
exactMatch: false,
suppressThrow: true,
});
if (range) Transforms.select(editor, range);
};
const toggleMark = (
mark: "bold" | "italic" | "underline" | "strikethrough"
) => {
syncDOMSelection();
const currentMarks = Editor.marks(editor) as Record<string, unknown> | null;
if (currentMarks?.[mark]) Editor.removeMark(editor, mark);
else Editor.addMark(editor, mark, true);
setRevision((v) => v + 1);
};
const toggleList = (list: "bullet" | "number") => {
syncDOMSelection();
const match = Array.from(
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
)[0];
Transforms.setNodes(
editor,
{
list: (match?.[0] as NoteParagraph)?.list === list ? undefined : list,
} as Partial<Descendant>,
{ match: (n) => Element.isElement(n) }
);
};
const applyLink = () => {
const href = link.trim() ? safeNoteLink(link.trim()) : "";
if (href === null) {
setLinkError("请输入完整的网页或邮件链接。");
return;
}
if (savedSelection.current)
Transforms.select(editor, savedSelection.current);
if (href) Editor.addMark(editor, "href", href);
else Editor.removeMark(editor, "href");
setLinkOpen(false);
editor.onChange();
};
const leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
const t = leaf as {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
href?: string;
};
const href = safeNoteLink(t.href || "");
return (
<span
{...attributes}
style={{
fontWeight: t.bold ? 700 : undefined,
fontStyle: t.italic ? "italic" : undefined,
textDecoration:
[
t.underline ? "underline" : "",
t.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
}}
>
{href ? (
<a
href={href}
title={href}
rel="noopener noreferrer"
onClick={(e) => {
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
}}
target="_blank"
>
{children}
</a>
) : (
children
)}
</span>
);
};
const element = ({ attributes, children, element }: RenderElementProps) => {
const p = element as NoteParagraph;
return (
<p
{...attributes}
data-note-list={p.list}
style={{ marginLeft: Math.max(0, Math.min(120, p.indent || 0)) }}
>
{children}
</p>
);
};
return (
<div className="xmind-notes-editor" data-editor-revision={revision}>
<Slate
editor={editor}
initialValue={value as Descendant[]}
onChange={(next) => {
setRevision((v) => v + 1);
if (editor.operations.every((o) => o.type === "set_selection"))
return;
const doc = next as NoteDocument;
if (
JSON.stringify(serializeNoteDocument(doc)) !==
JSON.stringify(serializeNoteDocument(sourceRef.current))
)
onChangeRef.current(doc);
}}
>
<div
className="xmind-notes-toolbar"
role="toolbar"
aria-label="笔记格式"
>
{(
[
["bold", "加粗", Bold],
["italic", "斜体", Italic],
["underline", "下划线", Underline],
["strikethrough", "删除线", Strikethrough],
] as const
).map(([mark, label, Icon]) => (
<button
key={mark}
type="button"
aria-label={`笔记${label}`}
aria-pressed={Boolean(marks?.[mark])}
onMouseDown={(e) => {
e.preventDefault();
toggleMark(mark);
}}
>
<Icon size={17} />
</button>
))}
<button
type="button"
aria-label="笔记无序列表"
onMouseDown={(e) => {
e.preventDefault();
toggleList("bullet");
}}
>
<List size={17} />
</button>
<button
type="button"
aria-label="笔记有序列表"
onMouseDown={(e) => {
e.preventDefault();
toggleList("number");
}}
>
<ListOrdered size={17} />
</button>
<button
type="button"
aria-label="笔记链接"
onMouseDown={(e) => {
e.preventDefault();
syncDOMSelection();
savedSelection.current = editor.selection;
setLink(String(marks?.href || ""));
setLinkError("");
setLinkOpen(!linkOpen);
}}
>
<Link2 size={17} />
</button>
</div>
{linkOpen ? (
<div className="xmind-note-link-form">
<input
aria-label="笔记链接地址"
placeholder="https://…"
value={link}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
<button type="button" onClick={applyLink}>
确定
</button>
<button
type="button"
aria-label="取消笔记链接"
onClick={() => setLinkOpen(false)}
>
<X size={14} />
</button>
{linkError ? <p role="alert">{linkError}</p> : null}
</div>
) : null}
<Editable
aria-label="主题备注"
className="xmind-note-content"
placeholder="为这个主题补充说明…"
renderLeaf={leaf}
renderElement={element}
onKeyDown={(e) => {
if (
(e.metaKey || e.ctrlKey) &&
["b", "i", "u"].includes(e.key.toLowerCase())
) {
e.preventDefault();
toggleMark(
(
{ b: "bold", i: "italic", u: "underline" } as Record<
string,
"bold" | "italic" | "underline"
>
)[e.key.toLowerCase()]
);
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
e.stopPropagation();
if (e.key === "Tab") {
e.preventDefault();
const match = Array.from(
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
)[0];
const indent = (match?.[0] as NoteParagraph)?.indent || 0;
Transforms.setNodes(
editor,
{
indent: Math.max(
0,
Math.min(120, indent + (e.shiftKey ? -20 : 20))
),
} as Partial<Descendant>,
{ match: (n) => Element.isElement(n) }
);
}
}}
/>
</Slate>
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import {
type PlaitBoard,
type PlaitElement,
toHostPointFromViewBoxPoint,
toScreenPointFromHostPoint,
} from "@plait/core";
import { getRectangleByNode, MindElement } from "@plait/mind";
import { MoreHorizontal, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getNoteDocument,
type NoteDocument,
} from "@/lib/flow-board/topic-notes";
import { getTopicTitle } from "@/lib/flow-board/topic-details";
import { XmindNotesEditor } from "./xmind-notes-editor";
export function XmindNotesPopover({
board,
topic,
viewport,
onChange,
onDetails,
onClose,
}: {
board: PlaitBoard | null;
topic: PlaitElement | null;
viewport: unknown;
onChange: (doc: NoteDocument) => void;
onDetails: () => void;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(null);
const [position, setPosition] = useState({ left: 400, top: 300 });
useEffect(() => {
if (!board || !topic) return;
const update = () => {
const node = MindElement.getNode(topic as MindElement);
if (!node) return;
const rect = getRectangleByNode(node),
point = toScreenPointFromHostPoint(
board,
toHostPointFromViewBoxPoint(board, [
rect.x + rect.width / 2,
rect.y + rect.height,
])
);
const host = ref.current?.parentElement?.getBoundingClientRect();
const width = Math.min(360, window.innerWidth - 32);
const right = window.innerWidth > 800 ? 240 : 0;
const left = Math.max(
12,
Math.min(window.innerWidth - right - width - 16, point[0] - width / 2)
);
const top = Math.max(
78,
Math.min(window.innerHeight - 340, point[1] + 12)
);
setPosition({ left: left - (host?.x || 0), top: top - (host?.y || 0) });
};
const frame = requestAnimationFrame(update);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
};
}, [board, topic?.id, topic?.data, viewport]);
return (
<aside
ref={ref}
className="xmind-notes-popover"
aria-label="主题笔记"
style={position}
data-flow-board-shell-control="true"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
}}
>
<header>
<span title={topic ? getTopicTitle(topic) : "笔记"}>
{topic ? getTopicTitle(topic) : "笔记"}
</span>
<div>
<button
type="button"
aria-label="主题详情"
title="主题详情"
onClick={onDetails}
>
<MoreHorizontal size={15} />
</button>
<button type="button" aria-label="关闭笔记" onClick={onClose}>
<X size={15} />
</button>
</div>
</header>
{topic ? (
<XmindNotesEditor
key={topic.id}
value={getNoteDocument(topic)}
onChange={onChange}
/>
) : (
<p className="xmind-note-empty">选择一个主题以编辑笔记。</p>
)}
</aside>
);
}

View File

@ -0,0 +1,310 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronRight,
GripVertical,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
getTopicEntries,
type TopicEntry,
} from "@/lib/flow-board/topic-details";
import type {
OutlineCommand,
OutlineDrop,
} from "@/lib/flow-board/mind-outline";
type Props = {
zoomPercent: number;
elements: PlaitElement[];
selectedId: string | null;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onToggle: (id: string) => void;
onCommand: (
id: string | null,
command: OutlineCommand | OutlineDrop
) => string | null;
onUndo: () => void;
onRedo: () => void;
};
function OutlineRow({
entry,
selected,
onSelect,
onRename,
onToggle,
onCommand,
onFocusTopic,
}: {
entry: TopicEntry;
selected: boolean;
onSelect: Props["onSelect"];
onRename: Props["onRename"];
onToggle: Props["onToggle"];
onCommand: Props["onCommand"];
onFocusTopic: (id: string | null, direction?: number) => void;
}) {
const { element, title, depth } = entry;
const [draft, setDraft] = useState(title);
const [drop, setDrop] = useState<OutlineDrop["placement"] | null>(null);
const ref = useRef<HTMLInputElement>(null);
const details = getTopicDetails(element);
useEffect(() => setDraft(title), [title]);
const commit = () => {
if (draft !== title) onRename(element.id, draft);
};
const act = (command: OutlineCommand) => {
commit();
onFocusTopic(onCommand(element.id, command));
};
return (
<div
data-outline-id={element.id}
data-depth={depth}
className={`group flex min-h-11 items-center gap-1 border-b border-[#f1f2f5] px-3 ${selected ? "bg-[#f0f5ff]" : "hover:bg-[#fafbfc]"} ${drop === "child" ? "ring-1 ring-inset ring-blue-400" : drop === "before" ? "border-t-2 border-t-blue-400" : drop === "after" ? "border-b-2 border-b-blue-400" : ""}`}
style={{ paddingLeft: 12 + Math.min(depth, 20) * 24 }}
onDragOver={(event) => {
event.preventDefault();
const box = event.currentTarget.getBoundingClientRect();
const y = (event.clientY - box.top) / box.height;
setDrop(y < 0.25 ? "before" : y > 0.75 ? "after" : "child");
}}
onDragLeave={() => setDrop(null)}
onDrop={(event) => {
event.preventDefault();
const source = event.dataTransfer.getData("application/x-mind-topic");
if (source && drop)
onFocusTopic(
onCommand(source, { targetId: element.id, placement: drop })
);
setDrop(null);
}}
>
<span
draggable
aria-label={`拖动 ${title}`}
className="cursor-grab text-[#9ca3af] opacity-30 group-hover:opacity-100"
onDragStart={(event) => {
event.dataTransfer.setData("application/x-mind-topic", element.id);
event.dataTransfer.effectAllowed = "move";
}}
>
<GripVertical size={13} />
</span>
{element.children?.length ? (
<button
type="button"
className="p-1 text-[#64748b]"
aria-label={`${element.isCollapsed ? "展开" : "折叠"} ${title}`}
onClick={() => onToggle(element.id)}
>
{element.isCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</button>
) : (
<span className="w-[22px] text-center text-[#a2a9b3]">·</span>
)}
<input
ref={ref}
aria-label={`大纲主题 ${title}`}
data-outline-input={element.id}
className={`min-w-0 flex-1 bg-transparent py-2 text-sm outline-none ${depth === 0 ? "font-semibold text-[#253046]" : "text-[#404958]"}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onFocus={() => onSelect(element.id)}
onBlur={commit}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter") {
event.preventDefault();
act(event.shiftKey || depth === 0 ? "child" : "sibling");
} else if (event.key === "Tab") {
event.preventDefault();
act(event.shiftKey ? "outdent" : "indent");
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
commit();
if (event.altKey) act(event.key === "ArrowUp" ? "up" : "down");
else onFocusTopic(element.id, event.key === "ArrowUp" ? -1 : 1);
} else if (event.key === "Escape") {
event.preventDefault();
setDraft(title);
} else if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z" &&
draft !== title
) {
event.preventDefault();
event.stopPropagation();
setDraft(title);
} else if (event.key === "Backspace" && !draft) {
event.preventDefault();
onFocusTopic(onCommand(element.id, "delete"));
}
}}
/>
{Number.isInteger(element.start) ? (
<span className="text-[10px] text-[#8d79b2]">概要</span>
) : null}
{details.notes ? (
<span title={details.notes} className="text-[10px] text-[#8a94a5]">
备注
</span>
) : null}
{details.labels.map((label) => (
<span
key={label}
className="hidden rounded bg-[#e8effc] px-1.5 py-0.5 text-[10px] text-[#5879b4] lg:inline"
>
{label}
</span>
))}
<button
type="button"
aria-label={`为 ${title} 添加子主题`}
className="p-1 text-[#8a94a5] opacity-0 focus:opacity-100 group-hover:opacity-100"
onClick={() => act("child")}
>
<Plus size={14} />
</button>
</div>
);
}
export function XmindOutline(props: Props) {
const { elements, selectedId, onCommand } = props;
const container = useRef<HTMLElement>(null);
const [focusId, setFocusId] = useState<string | null>(null);
const entries = getTopicEntries(elements);
const collapsedPaths = entries
.filter(({ element }) => element.isCollapsed)
.map((entry) => entry.path);
const visible = entries.filter(
(entry) =>
!collapsedPaths.some(
(path) =>
path.length < entry.path.length &&
path.every((index, i) => index === entry.path[i])
)
);
useEffect(() => {
if (!focusId) return;
const input = Array.from(
container.current?.querySelectorAll<HTMLInputElement>(
"[data-outline-input]"
) || []
).find((node) => node.dataset.outlineInput === focusId);
if (input) {
input.focus();
input.select();
setFocusId(null);
}
}, [focusId, elements]);
const focusTopic = (id: string | null, direction?: number) => {
if (!id) return;
const target = direction
? visible[
visible.findIndex((entry) => entry.element.id === id) + direction
]?.element.id
: id;
if (target) setFocusId(target);
};
const action = (command: OutlineCommand) =>
focusTopic(onCommand(selectedId, command));
return (
<section
ref={container}
aria-label="大纲视图"
className="h-full overflow-auto bg-white"
data-flow-board-shell-control="true"
onKeyDown={(event) => {
event.stopPropagation();
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z"
) {
event.preventDefault();
if (event.shiftKey) props.onRedo();
else props.onUndo();
}
}}
>
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-[#e5e8ee] bg-white px-5 py-3">
<h2 className="mr-2 text-sm font-semibold">大纲</h2>
<span className="mr-auto text-xs text-[#949ba7]">
{entries.length} 个主题
</span>
{(
[
{ command: "child", label: "大纲新增子主题", icon: Plus },
{ command: "indent", label: "大纲缩进", icon: IndentIncrease },
{ command: "outdent", label: "大纲提升层级", icon: IndentDecrease },
{ command: "up", label: "大纲上移", icon: ArrowUp },
{ command: "down", label: "大纲下移", icon: ArrowDown },
{ command: "delete", label: "大纲删除主题", icon: Trash2 },
] as const
).map(({ command, label, icon: Icon }) => (
<button
key={command}
type="button"
title={label}
aria-label={label}
className="rounded p-1.5 text-[#64748b] hover:bg-[#edf3ff] disabled:opacity-30"
disabled={
(!selectedId && entries.length > 0) ||
(!selectedId && command !== "child")
}
onClick={() => action(command)}
>
<Icon size={16} />
</button>
))}
</div>
<div
className="mx-auto max-w-4xl px-4 py-6"
style={{ zoom: props.zoomPercent / 100 }}
>
{!entries.length ? (
<button
type="button"
onClick={() => action("child")}
className="w-full rounded border border-dashed border-[#cbd5e1] p-8 text-sm text-[#64748b]"
>
添加中心主题
</button>
) : (
visible.map((entry) => (
<OutlineRow
key={entry.element.id}
entry={entry}
selected={entry.element.id === selectedId}
onSelect={props.onSelect}
onRename={props.onRename}
onToggle={props.onToggle}
onCommand={onCommand}
onFocusTopic={focusTopic}
/>
))
)}
<p className="mt-6 text-xs text-[#a1a8b4]">
Enter 新增同级 · Shift+Enter 新增子主题 · Tab 缩进 · Shift+Tab
提升层级 · Alt+↑↓ 调整顺序
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import { useEffect, useState } from "react";
import type { PlaitElement } from "@plait/core";
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
export function XmindPresentation({ elements, title, onClose }: { elements: PlaitElement[]; title: string; onClose: () => void }) {
const slides = getTopicEntries(elements).filter(({ element, depth }) => depth === 0 || element.children?.length);
const [index,setIndex] = useState(0);
const entry = slides[Math.min(index,slides.length - 1)];
const next = () => setIndex(value => Math.min(slides.length - 1,value + 1));
const previous = () => setIndex(value => Math.max(0,value - 1));
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); }
if (["ArrowRight","ArrowDown"," ","PageDown"].includes(event.key)) { event.preventDefault(); next(); }
if (["ArrowLeft","ArrowUp","PageUp"].includes(event.key)) { event.preventDefault(); previous(); }
};
window.addEventListener("keydown",onKey);
return () => window.removeEventListener("keydown",onKey);
}, [onClose,slides.length]);
return <section className="xmind-presentation" aria-label="演示视图" data-flow-board-shell-control="true">
<header><span>{title}</span><button type="button" aria-label="退出演示" onClick={onClose}><X size={16} />退出演示</button></header>
<div className="xmind-presentation-slide"><h1>{entry?.title || title}</h1><ul>{(entry?.element.children || []).map(child => <li key={child.id}>{getTopicTitle(child)}</li>)}</ul></div>
<footer><button type="button" aria-label="上一页" disabled={index === 0} onClick={previous}><ChevronLeft size={18} /></button><span>{Math.min(index + 1,slides.length)} / {slides.length}</span><button type="button" aria-label="下一页" disabled={index >= slides.length - 1} onClick={next}><ChevronRight size={18} /></button></footer>
</section>;
}

View File

@ -0,0 +1,650 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
X,
} from "lucide-react";
import {
CALENDAR_FREQUENCIES,
defaultWorkingCalendar,
isWorkingDate,
readWorkingCalendar,
validateWorkingCalendar,
type CalendarRule,
type ProjectCalendarConfig,
type ProjectWorkingCalendar,
} from "@/lib/flow-board/project-calendar";
import {
addDays,
dateIndex,
DAY,
indexDate,
todayDate,
} from "@/lib/flow-board/calendar-dates";
const STORAGE_KEY = "flow-board.personal-calendars.v1";
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
const FREQUENCY_LABELS: Record<CalendarRule["frequency"], string> = {
NONE: "不重复",
DAILY: "每天",
WEEKLY: "每周",
MONTHLY_BY_DATE: "每月(按日期)",
MONTHLY_BY_WEEKDAY: "每月(按星期)",
YEARLY: "每年",
};
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
function useCalendarDialogFocus(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
ref.current?.querySelector<HTMLElement>("input,button")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, [ref]);
}
function trapCalendarTab(
event: React.KeyboardEvent,
panel: HTMLElement | null
) {
if (event.key !== "Tab") return;
const controls = Array.from(
panel?.querySelectorAll<HTMLElement>(
"button:not(:disabled),input:not(:disabled),select:not(:disabled)"
) || []
);
const first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
function CalendarExceptionEditor({
rule,
onSave,
onClose,
}: {
rule: CalendarRule;
onSave: (rule: CalendarRule) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(() => copy(rule)),
[error, setError] = useState("");
const ref = useRef<HTMLElement>(null);
useCalendarDialogFocus(ref);
const patch = (changes: Partial<CalendarRule>) =>
setDraft((value) => ({ ...value, ...changes }));
const save = () => {
const problem = validateWorkingCalendar({
id: "validation",
name: "",
defaultWorkingDays: [1],
rules: [draft],
});
if (problem) {
setError(problem);
return;
}
const selected = draft.selectedDates[0];
if (
(draft.effectiveRange?.start && selected < draft.effectiveRange.start) ||
(draft.effectiveRange?.end && selected > draft.effectiveRange.end)
) {
setError("例外日期必须在生效范围内。");
return;
}
onSave({ ...draft, name: draft.name.trim() || "例外" });
onClose();
};
return (
<div
className="xmind-calendar-exception-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-calendar-exception"
role="dialog"
aria-modal="true"
aria-label="工作日例外"
>
<header>
<h2>工作日例外</h2>
<button type="button" aria-label="关闭工作日例外" onClick={onClose}>
<X size={17} />
</button>
</header>
<div className="xmind-calendar-exception-fields">
<label>
名称
<input
aria-label="例外名称"
value={draft.name}
onChange={(event) => patch({ name: event.target.value })}
/>
</label>
<label>
日期
<input
type="date"
aria-label="例外日期"
value={draft.selectedDates[0] || ""}
onChange={(event) =>
patch({ selectedDates: [event.target.value] })
}
/>
</label>
<label>
类型
<select
aria-label="例外类型"
value={draft.type}
onChange={(event) =>
patch({ type: event.target.value as CalendarRule["type"] })
}
>
<option value="NON_WORKING_DAY">休息日</option>
<option value="WORKING_DAY">工作日(补班)</option>
</select>
</label>
<label>
重复
<select
aria-label="例外重复规则"
value={draft.frequency}
onChange={(event) =>
patch({
frequency: event.target.value as CalendarRule["frequency"],
})
}
>
{CALENDAR_FREQUENCIES.map((frequency) => (
<option key={frequency} value={frequency}>
{FREQUENCY_LABELS[frequency]}
</option>
))}
</select>
</label>
{draft.frequency !== "NONE" ? (
<label>
每隔
<input
type="number"
min={1}
max={100}
aria-label="例外重复间隔"
value={draft.interval || 1}
onChange={(event) =>
patch({ interval: Number(event.target.value) })
}
/>
</label>
) : null}
<div className="xmind-calendar-range">
<label>
生效开始
<input
type="date"
aria-label="例外生效开始"
value={draft.effectiveRange?.start || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
start: event.target.value,
},
})
}
/>
</label>
<label>
生效结束
<input
type="date"
aria-label="例外生效结束"
value={draft.effectiveRange?.end || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
end: event.target.value,
},
})
}
/>
</label>
</div>
<p className="xmind-calendar-hint">
留空表示不限制生效日期。后添加的例外优先。
</p>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
</div>
<footer>
<button type="button" onClick={onClose}>
取消
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存例外
</button>
</footer>
</section>
</div>
);
}
export function XmindProjectCalendar({
config,
onSave,
onClose,
}: {
config: ProjectCalendarConfig;
onSave: (config: ProjectCalendarConfig) => boolean;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(null);
useCalendarDialogFocus(ref);
const [draft, setDraft] = useState(() =>
copy(config.settings || defaultWorkingCalendar())
);
const [skip, setSkip] = useState(config.settings ? config.skip : true),
[error, setError] = useState("");
const [selectedDate, setSelectedDate] = useState(todayDate()),
[month, setMonth] = useState(todayDate().slice(0, 7));
const [exception, setException] = useState<CalendarRule | null>(null),
[manage, setManage] = useState(false);
const [templates, setTemplates] = useState<ProjectWorkingCalendar[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(stored)
? stored
.map(readWorkingCalendar)
.filter((item): item is ProjectWorkingCalendar => Boolean(item))
: [];
} catch {
return [];
}
});
const [templateName, setTemplateName] = useState("");
const [message, setMessage] = useState("");
const storeTemplates = (value: ProjectWorkingCalendar[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
setTemplates(value);
return true;
} catch {
setError("个人日历保存失败,请检查浏览器存储空间。");
return false;
}
};
const save = () => {
const problem = validateWorkingCalendar(draft);
if (problem) {
setError(problem);
return;
}
if (
onSave({
settings: { ...draft, name: draft.name.trim() || "项目日历" },
skip,
})
)
onClose();
};
const saveTemplate = () => {
const problem = validateWorkingCalendar(draft);
if (problem) {
setError(problem);
return;
}
if (!templateName.trim()) {
setError("请输入个人日历名称。");
return;
}
const value = {
...copy(draft),
id: crypto.randomUUID(),
name: templateName.trim(),
};
if (storeTemplates([...templates, value])) {
setTemplateName("");
setError("");
setMessage(`已保存个人日历:${value.name}`);
}
};
const moveMonth = (step: number) => {
const date = new Date(`${month}-01T00:00:00Z`);
date.setUTCMonth(date.getUTCMonth() + step);
setMonth(date.toISOString().slice(0, 7));
};
const first = dateIndex(`${month}-01`),
offset = (new Date(first * DAY).getUTCDay() + 6) % 7;
const days = Array.from({ length: 42 }, (_, index) =>
indexDate(first - offset + index)
);
const addException = () =>
setException({
id: crypto.randomUUID(),
name: "",
type: isWorkingDate(selectedDate, draft)
? "NON_WORKING_DAY"
: "WORKING_DAY",
frequency: "NONE",
selectedDates: [selectedDate],
effectiveRange: { start: selectedDate, end: selectedDate },
});
return (
<div
className="xmind-project-calendar-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (exception) return;
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-project-calendar"
role="dialog"
aria-modal="true"
aria-label="项目工作日"
>
<header>
<div>
<h2>
<CalendarDays size={19} />
项目工作日
</h2>
<p>只应用于当前工作表,不影响其他工作表。</p>
</div>
<button type="button" aria-label="关闭项目工作日" onClick={onClose}>
<X size={18} />
</button>
</header>
<div className="xmind-project-calendar-content">
<div className="xmind-calendar-preview">
<div className="xmind-calendar-import">
<label>
导入日历
<select
aria-label="导入个人日历"
value=""
onChange={(event) => {
const value =
event.target.value === "basic"
? { ...defaultWorkingCalendar(), name: "基本日历" }
: templates.find(
(item) => item.id === event.target.value
);
if (value) {
setDraft(copy(value));
setError("");
}
}}
>
<option value="">当前项目:{draft.name}</option>
<option value="basic">基本日历(周一至周五)</option>
{templates.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setManage((value) => !value)}
>
管理
</button>
</div>
<div className="xmind-calendar-month-header">
<button
type="button"
aria-label="上个月"
onClick={() => moveMonth(-1)}
>
<ChevronLeft size={16} />
</button>
<strong>{month.replace("-", "年")}月</strong>
<button
type="button"
aria-label="下个月"
onClick={() => moveMonth(1)}
>
<ChevronRight size={16} />
</button>
</div>
<div className="xmind-calendar-month-grid" aria-label="工作日预览">
{WEEKDAYS.map((day) => (
<span className="xmind-calendar-weekday" key={day}>
{day}
</span>
))}
{days.map((date) => (
<button
type="button"
key={date}
data-calendar-date={date}
data-working={isWorkingDate(date, draft)}
aria-label={`${date} ${isWorkingDate(date, draft) ? "工作日" : "休息日"}`}
aria-pressed={selectedDate === date}
className={`${date.slice(0, 7) !== month ? "is-other-month" : ""} ${isWorkingDate(date, draft) ? "" : "is-day-off"}`}
onClick={() => setSelectedDate(date)}
>
{Number(date.slice(8))}
</button>
))}
</div>
<p className="xmind-calendar-hint">
阴影为休息日。选择日期后可添加例外。
</p>
{manage ? (
<div className="xmind-calendar-personal">
<h3>个人日历</h3>
<p>保存在当前浏览器,可用于其他工作表。</p>
{templates.length ? (
templates.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<button
type="button"
aria-label={`删除个人日历 ${item.name}`}
onClick={() =>
storeTemplates(
templates.filter((value) => value.id !== item.id)
)
}
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p>还没有个人日历。</p>
)}
<label>
名称
<input
aria-label="个人日历名称"
value={templateName}
onChange={(event) => setTemplateName(event.target.value)}
/>
</label>
<button type="button" onClick={saveTemplate}>
保存为个人日历
</button>
</div>
) : null}
</div>
<div className="xmind-calendar-settings">
<label>
日历名称
<input
aria-label="项目日历名称"
value={draft.name}
onChange={(event) =>
setDraft((value) => ({ ...value, name: event.target.value }))
}
/>
</label>
<label className="xmind-calendar-skip">
<input
type="checkbox"
aria-label="跳过非工作日"
checked={skip}
onChange={(event) => setSkip(event.target.checked)}
/>
跳过非工作日
</label>
<h3>每周工作日</h3>
<div className="xmind-calendar-working-week">
{WEEKDAYS.map((day, index) => (
<label key={day}>
<input
type="checkbox"
aria-label={`周${day}为工作日`}
checked={draft.defaultWorkingDays.includes(index + 1)}
onChange={(event) => {
const value = event.target.checked
? [...draft.defaultWorkingDays, index + 1].sort()
: draft.defaultWorkingDays.filter(
(value) => value !== index + 1
);
if (!value.length) {
setError("请至少保留一个每周工作日。");
return;
}
setDraft((settings) => ({
...settings,
defaultWorkingDays: value,
}));
setError("");
}}
/>
{day}
</label>
))}
</div>
<div className="xmind-calendar-rule-heading">
<h3>例外</h3>
<button
type="button"
aria-label="添加工作日例外"
onClick={addException}
>
<Plus size={16} />
</button>
</div>
<div className="xmind-calendar-rules">
{[...draft.rules].reverse().map((rule) => (
<div key={rule.id}>
<button
type="button"
aria-label={`编辑工作日例外 ${rule.name}`}
onClick={() => setException(copy(rule))}
>
<strong>{rule.name}</strong>
<span>
{rule.type === "WORKING_DAY" ? "补班" : "休息日"} ·{" "}
{rule.selectedDates.join("、")} ·{" "}
{FREQUENCY_LABELS[rule.frequency]}
{rule.interval && rule.interval > 1
? `(间隔 ${rule.interval})`
: ""}
</span>
</button>
<button
type="button"
aria-label={`删除工作日例外 ${rule.name}`}
onClick={() =>
setDraft((value) => ({
...value,
rules: value.rules.filter(
(item) => item.id !== rule.id
),
}))
}
>
<Trash2 size={14} />
</button>
</div>
))}
{!draft.rules.length ? (
<p>可添加节假日、调休或重复例外。</p>
) : null}
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
{message ? <p role="status">{message}</p> : null}
</div>
</div>
<footer>
<div>
{config.settings ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
if (onSave({ settings: null, skip: false })) onClose();
}}
>
移除项目日历
</button>
) : null}
<button type="button" onClick={() => setManage(true)}>
保存为日历
</button>
</div>
<div>
<button type="button" onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
onClick={save}
>
保存工作日
</button>
</div>
</footer>
</section>
{exception ? (
<CalendarExceptionEditor
rule={exception}
onSave={(rule) =>
setDraft((value) => ({
...value,
rules: value.rules.some((item) => item.id === rule.id)
? value.rules.map((item) => (item.id === rule.id ? rule : item))
: [...value.rules, rule],
}))
}
onClose={() => setException(null)}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,45 @@
"use client";
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { searchTopics, type SearchSheet, type TopicSearchResult } from "@/lib/flow-board/topic-search";
export function XmindSearchPanel({ sheets, activeSheetId, onJump, onReplace, onClose }: {
sheets: SearchSheet[];
activeSheetId: string;
onJump: (result: TopicSearchResult) => void;
onReplace: (results: TopicSearchResult[], query: string, replacement: string, caseSensitive: boolean) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [replacement, setReplacement] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [scope, setScope] = useState("current");
const [activeIndex, setActiveIndex] = useState(0);
const input = useRef<HTMLInputElement>(null);
const results = searchTopics(scope === "all" ? sheets : sheets.filter(sheet => sheet.id === activeSheetId),query,caseSensitive);
useEffect(() => { input.current?.focus(); }, []);
useEffect(() => { setActiveIndex(0); }, [query,caseSensitive,scope]);
const navigate = (step: number) => {
if (!results.length) return;
const next = (Math.min(activeIndex,results.length - 1) + step + results.length) % results.length;
setActiveIndex(next); onJump(results[next]);
};
return <aside className="xmind-search-panel" aria-label="查找与替换" data-flow-board-shell-control="true" onKeyDown={event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); }
}}>
<div className="xmind-panel-heading"><span>查找与替换</span><button type="button" aria-label="关闭查找" onClick={onClose}><X size={16} /></button></div>
<div className="xmind-panel-content">
<label className="xmind-search-box"><Search size={15} /><input ref={input} aria-label="查找主题" placeholder="查找主题" value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); navigate(e.shiftKey ? -1 : 1); } }} /></label>
<div className="xmind-search-options"><label><input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} /> 区分大小写</label></div>
<select aria-label="查找范围" value={scope} onChange={e => setScope(e.target.value)}><option value="current">当前画布</option><option value="all">所有画布</option></select>
<label className="xmind-search-box"><input aria-label="替换为" placeholder="替换为" value={replacement} onChange={e => setReplacement(e.target.value)} /></label>
<div className="xmind-search-replace-buttons">
<button type="button" disabled={!results.length} onClick={() => onReplace([results[Math.min(activeIndex,results.length - 1)]],query,replacement,caseSensitive)}>替换</button>
<button type="button" disabled={!results.length} onClick={() => onReplace(results,query,replacement,caseSensitive)}>全部替换</button>
</div>
<div className="xmind-search-navigation"><span role="status">{query ? `${results.length} 个匹配主题` : "输入内容开始查找"}</span><button type="button" aria-label="上一个结果" disabled={!results.length} onClick={() => navigate(-1)}><ChevronUp size={16} /></button><button type="button" aria-label="下一个结果" disabled={!results.length} onClick={() => navigate(1)}><ChevronDown size={16} /></button></div>
<div aria-label="查找结果">{results.map((result,index) => <button type="button" key={`${result.sheetId}:${result.id}`} aria-label={`定位 ${result.title}`} className={`xmind-search-result ${index === activeIndex ? "is-active" : ""}`} onClick={() => { setActiveIndex(index); onJump(result); }}><span>{result.title}</span><small>{result.sheetTitle} · {result.occurrences} 处匹配</small></button>)}</div>
</div>
</aside>;
}

View File

@ -0,0 +1,384 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CalendarDays, X } from "lucide-react";
import type { PlaitElement } from "@plait/core";
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
import {
activeWorkingCalendar,
getProjectCalendar,
taskDueDate,
workingDaysBetween,
} from "@/lib/flow-board/project-calendar";
import {
DAY,
getTopicTask,
newTopicTask,
validateTopicTask,
type TopicTask,
type TaskDependency,
} from "@/lib/flow-board/topic-tasks";
export function XmindTaskEditor({
topic,
elements,
onSave,
onClose,
onGantt,
onCalendar,
}: {
topic: PlaitElement;
elements: PlaitElement[];
onSave: (id: string, task: TopicTask | null) => void;
onClose: () => void;
onGantt: () => void;
onCalendar: () => void;
}) {
const dialogRef = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.querySelector<HTMLInputElement>("input")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, []);
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
const existing = getTopicTask(topic);
const [draft, setDraft] = useState<TopicTask>(
() => existing || newTopicTask()
);
const [error, setError] = useState("");
const [assigneesDraft, setAssigneesDraft] = useState(
(existing?.assignees || []).join(", ")
);
const [dependency, setDependency] = useState("");
const [dependencyType, setDependencyType] =
useState<TaskDependency["type"]>("FS");
const [lag, setLag] = useState("0");
const source = JSON.stringify(existing);
useEffect(() => {
setDraft(getTopicTask(topic) || newTopicTask());
setAssigneesDraft((getTopicTask(topic)?.assignees || []).join(", "));
setError("");
}, [topic.id, source]);
const tasks = getTopicEntries(elements).filter(
(e) => e.element.id !== topic.id && getTopicTask(e.element)
);
const patch = (p: Partial<TopicTask>) => setDraft((t) => ({ ...t, ...p }));
const withAssignees = () => ({
...draft,
assignees: [
...new Set(
assigneesDraft
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean)
),
],
});
const save = () => {
const task = withAssignees();
const problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
};
return (
<div
className="xmind-task-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab") {
const controls = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
"button:not(:disabled),input,select"
) || []
);
const first = controls[0],
last = controls.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="任务信息"
className="xmind-task-editor"
ref={dialogRef}
>
<header>
<h2>
<CalendarDays size={17} />
任务信息
</h2>
<button type="button" aria-label="关闭任务信息" onClick={onClose}>
<X size={17} />
</button>
</header>
<p className="xmind-task-name">{getTopicTitle(topic)}</p>
<div className="xmind-task-fields">
<label>
开始日期
<input
type="date"
aria-label="任务开始日期"
value={draft.start}
onChange={(e) => {
const start = e.target.value;
patch({
start,
due:
start && draft.duration >= 1
? taskDueDate(start, draft.duration, calendar)
: "",
});
}}
/>
</label>
<label>
截止日期
<input
type="date"
aria-label="任务截止日期"
value={draft.due}
onChange={(e) => {
const due = e.target.value;
patch({
due,
...(draft.start && due && due >= draft.start
? {
duration: Math.max(
1,
workingDaysBetween(draft.start, due, calendar)
),
}
: {}),
});
}}
/>
</label>
<label>
{calendar ? "时长(工作日)" : "时长(天)"}
<input
type="number"
aria-label="任务工期"
min={1}
max={36500}
value={draft.duration}
onChange={(e) => {
const duration = Number(e.target.value);
patch({
duration,
...(draft.start && duration >= 1 && duration <= 36500
? { due: taskDueDate(draft.start, duration, calendar) }
: {}),
});
}}
/>
</label>
<label>
进度(%)
<input
type="number"
aria-label="任务进度"
min={0}
max={100}
value={draft.progress}
onChange={(e) => patch({ progress: Number(e.target.value) })}
/>
</label>
<label>
优先级
<select
aria-label="任务优先级"
value={draft.priority ?? ""}
onChange={(e) =>
patch({
priority:
e.target.value === "" ? null : Number(e.target.value),
})
}
>
<option value="">无</option>
{Array.from({ length: 10 }, (_, i) => (
<option value={i} key={i}>
P{i}
</option>
))}
</select>
</label>
<label>
负责人
<input
aria-label="任务负责人"
placeholder="多人用逗号分隔"
value={assigneesDraft}
onChange={(e) => setAssigneesDraft(e.target.value)}
/>
</label>
</div>
<h3>前置任务</h3>
<div className="xmind-task-dependencies">
{draft.dependencies.map((dep, i) => (
<div key={`${dep.id}-${i}`}>
<span>
{tasks.find((e) => e.element.id === dep.id)?.title ||
"已删除的任务"}{" "}
· {dep.type}
{dep.lag ? ` · ${dep.lag / DAY} 天` : ""}
</span>
<button
type="button"
aria-label={`移除前置任务 ${tasks.find((e) => e.element.id === dep.id)?.title || dep.id}`}
onClick={() =>
patch({
dependencies: draft.dependencies.filter((_, n) => n !== i),
})
}
>
<X size={13} />
</button>
</div>
))}
</div>
<div className="xmind-task-add-dependency">
<select
aria-label="前置任务"
value={dependency}
onChange={(e) => setDependency(e.target.value)}
>
<option value="">选择任务</option>
{tasks.map((e) => (
<option value={e.element.id} key={e.element.id}>
{e.title}
</option>
))}
</select>
<select
aria-label="依赖类型"
value={dependencyType}
onChange={(e) =>
setDependencyType(e.target.value as TaskDependency["type"])
}
>
<option value="FS">完成 → 开始</option>
<option value="SS">开始 → 开始</option>
<option value="FF">完成 → 完成</option>
<option value="SF">开始 → 完成</option>
</select>
<input
type="number"
aria-label="依赖间隔天数"
value={lag}
onChange={(e) => setLag(e.target.value)}
title="间隔天数(负数为提前)"
/>
<button
type="button"
disabled={!dependency}
onClick={() => {
const next = {
...draft,
dependencies: [
...draft.dependencies.filter((d) => d.id !== dependency),
{
id: dependency,
type: dependencyType,
lag: Number(lag) * DAY,
},
],
};
const problem = validateTopicTask(topic.id, next, elements);
if (problem) setError(problem);
else {
setDraft(next);
setDependency("");
setError("");
}
}}
>
添加
</button>
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
<div className="xmind-task-calendar-info">
<span>
{calendar ? `已跳过非工作日 · ${calendar.name}` : "包含非工作日"}
</span>
<button
type="button"
onClick={() => {
const task = withAssignees(),
problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
onCalendar();
}}
>
项目工作日
</button>
</div>
<footer>
{existing ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
onSave(topic.id, null);
onClose();
}}
>
移除任务
</button>
) : (
<button type="button" onClick={onClose}>
取消
</button>
)}
<button
type="button"
onClick={() => {
const task = withAssignees();
const problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
onGantt();
}}
>
查看甘特图
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存任务
</button>
</footer>
</section>
</div>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import {
type PlaitBoard,
type PlaitElement,
toHostPointFromViewBoxPoint,
toScreenPointFromHostPoint,
} from "@plait/core";
import { MindElement, getRectangleByNode } from "@plait/mind";
import { FileText } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { getTopicDetails, getTopicTitle } from "@/lib/flow-board/topic-details";
import { getTopicTask } from "@/lib/flow-board/topic-tasks";
export function XmindTopicBadges({
board,
elements,
viewport,
onNotes,
onTask,
}: {
board: PlaitBoard | null;
elements: PlaitElement[];
viewport: unknown;
onNotes: (id: string) => void;
onTask: (id: string) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const [badges, setBadges] = useState<
{
id: string;
title: string;
notes: boolean;
progress: number | null;
left: number;
top: number;
}[]
>([]);
useEffect(() => {
if (!board) return;
const update = () => {
const host = hostRef.current;
if (!host) return;
const origin = host.getBoundingClientRect();
const result: typeof badges = [];
const visit = (children: PlaitElement[]) => {
for (const element of children) {
const notes = Boolean(getTopicDetails(element).notes.trim()),
task = getTopicTask(element);
if (notes || task) {
const node = MindElement.getNode(element as MindElement);
if (node) {
const rect = getRectangleByNode(node);
const point = toScreenPointFromHostPoint(
board,
toHostPointFromViewBoxPoint(board, [
rect.x + rect.width,
rect.y + rect.height / 2,
])
);
result.push({
id: element.id,
title: getTopicTitle(element),
notes,
progress: task?.progress ?? null,
left: point[0] - origin.x + 5,
top: point[1] - origin.y - 9,
});
}
}
if (!element.isCollapsed && element.children) visit(element.children);
}
};
visit(elements);
setBadges(result);
};
const frame = requestAnimationFrame(update);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
};
}, [board, elements, viewport]);
return (
<div ref={hostRef} className="xmind-topic-badges" aria-label="主题内容提示">
{badges.map((b) => (
<div
key={b.id}
className="xmind-topic-badge"
style={{ left: b.left, top: b.top }}
data-flow-board-shell-control="true"
>
{b.progress !== null ? (
<button
type="button"
aria-label={`编辑任务 ${b.title}`}
title={`任务进度 ${b.progress}%`}
onClick={() => onTask(b.id)}
>
<svg
aria-hidden="true"
width={16}
height={16}
viewBox="0 0 16 16"
>
<circle
cx={8}
cy={8}
r={6}
fill="#f8fafc"
stroke="#afb7c4"
strokeWidth={1.4}
/>
<circle
cx={8}
cy={8}
r={6}
fill="none"
stroke="#6b8ee4"
strokeWidth={3}
strokeDasharray={`${(b.progress * 37.7) / 100} 37.7`}
transform="rotate(-90 8 8)"
/>
</svg>
</button>
) : null}
{b.notes ? (
<button
type="button"
aria-label={`查看笔记 ${b.title}`}
title="查看笔记"
onClick={() => onNotes(b.id)}
>
<FileText size={15} />
</button>
) : null}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,321 @@
"use client";
import type { PlaitElement } from "@plait/core";
import {
ExternalLink,
FileDown,
Link2,
StickyNote,
Tag,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getSafeTopicHref,
getTopicDetails,
getTopicTitle,
type TopicDetails,
type TopicEntry,
} from "@/lib/flow-board/topic-details";
import { getNoteDocument } from "@/lib/flow-board/topic-notes";
import { XmindNotesEditor } from "./xmind-notes-editor";
export type TopicDetailSection = "notes" | "links" | "labels";
export function XmindTopicPanel({
topic,
topics,
section,
onPatch,
onJump,
onClose,
}: {
topic: PlaitElement | null;
topics: TopicEntry[];
section: TopicDetailSection;
onPatch: (patch: Partial<TopicDetails>) => void;
onJump: (id: string) => void;
onClose: () => void;
}) {
const details = getTopicDetails(topic);
const [labelDraft, setLabelDraft] = useState("");
const [linkDraft, setLinkDraft] = useState(details.href);
const [attachmentError, setAttachmentError] = useState("");
const [isReadingAttachment, setIsReadingAttachment] = useState(false);
const attachmentRef = useRef<HTMLInputElement>(null);
const linkRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLinkDraft(details.href);
}, [topic?.id, details.href]);
useEffect(() => {
setLabelDraft("");
setAttachmentError("");
}, [topic?.id]);
useEffect(() => {
if (section === "links") linkRef.current?.focus();
if (section === "labels") labelRef.current?.focus();
}, [section]);
const safeHref = getSafeTopicHref(linkDraft);
const invalidHref = Boolean(linkDraft.trim()) && !safeHref;
const addLabel = () => {
const value = labelDraft.trim();
if (value && !details.labels.includes(value))
onPatch({ labels: [...details.labels, value] });
setLabelDraft("");
};
const addAttachment = async (file: File) => {
setAttachmentError("");
if (file.size > 8 * 1024 * 1024) {
setAttachmentError("附件不能超过 8 MB。");
return;
}
setIsReadingAttachment(true);
try {
const url = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("读取失败"));
reader.onload = () => resolve(String(reader.result));
// Always download attachments as files, independent of their source type.
reader.readAsDataURL(
new Blob([file], { type: "application/octet-stream" })
);
});
onPatch({
attachment: {
name: file.name,
url,
mimeType: "application/octet-stream",
},
});
} catch {
setAttachmentError("无法读取附件,请重新选择。");
} finally {
setIsReadingAttachment(false);
}
};
return (
<aside
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
<h2 className="text-sm font-semibold">主题内容</h2>
<button type="button" aria-label="关闭主题内容" onClick={onClose}>
<X size={16} />
</button>
</div>
{!topic ? (
<p className="p-4 text-xs text-[#8b919c]">
选择一个主题,编辑它的备注、链接和标签。
</p>
) : (
<div className="space-y-6 p-4 text-xs">
<p className="truncate font-medium" title={getTopicTitle(topic)}>
{getTopicTitle(topic)}
</p>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium"><StickyNote size={14} />笔记</h3>
<XmindNotesEditor key={topic.id} value={getNoteDocument(topic)} onChange={(notesDocument) => onPatch({notesDocument})} />
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<Tag size={14} />
标签
</h3>
<div className="flex flex-wrap gap-1.5">
{details.labels.map((label) => (
<span
key={label}
className="flex items-center gap-1 rounded bg-[#edf3ff] px-2 py-1 text-[#3569e8]"
>
{label}
<button
type="button"
aria-label={`移除标签 ${label}`}
onClick={() =>
onPatch({
labels: details.labels.filter(
(value) => value !== label
),
})
}
>
<X size={12} />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
aria-label="新增主题标签"
ref={labelRef}
className="h-8 min-w-0 flex-1 rounded border border-[#dfe2e7] px-2"
placeholder="输入标签后回车"
value={labelDraft}
onChange={(event) => setLabelDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
addLabel();
}
}}
/>
<button
type="button"
className="rounded border border-[#dfe2e7] px-2"
onClick={addLabel}
>
添加
</button>
</div>
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<Link2 size={14} />
网页或邮件链接
</h3>
<input
aria-label="主题外部链接"
ref={linkRef}
aria-invalid={invalidHref}
className="h-8 w-full rounded border border-[#dfe2e7] px-2"
placeholder="https://…"
value={linkDraft}
onChange={(event) => setLinkDraft(event.target.value)}
onBlur={() => {
if (!invalidHref) onPatch({ href: linkDraft.trim() });
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !invalidHref) {
event.preventDefault();
onPatch({ href: linkDraft.trim() });
}
}}
/>
{invalidHref ? (
<p role="alert" className="text-red-600">
请输入完整的 http、https、mailto 或 tel 链接。
</p>
) : safeHref ? (
<a
className="inline-flex items-center gap-1 text-[#3569e8]"
href={safeHref}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink size={12} />
打开链接
</a>
) : null}
{details.href ? (
<button
type="button"
className="ml-3 text-[#8b919c]"
onClick={() => {
setLinkDraft("");
onPatch({ href: "" });
}}
>
移除外部链接
</button>
) : null}
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<FileDown size={14} />
附件
</h3>
{details.attachment ? (
<>
<a
className="block break-all text-[#3569e8]"
href={details.attachment.url}
download={details.attachment.name}
>
下载 {details.attachment.name}
</a>
<button
type="button"
className="text-[#8b919c]"
onClick={() => onPatch({ attachment: null })}
>
移除附件
</button>
</>
) : null}
<input
ref={attachmentRef}
type="file"
className="hidden"
aria-label="上传主题附件"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void addAttachment(file);
}}
/>
<button
type="button"
className="block text-[#3569e8] disabled:text-gray-400"
disabled={isReadingAttachment}
onClick={() => attachmentRef.current?.click()}
>
{isReadingAttachment
? "正在读取附件…"
: details.attachment
? "替换附件"
: "添加附件"}
</button>
{attachmentError ? (
<p role="alert" className="text-red-600">
{attachmentError}
</p>
) : null}
</section>
<label className="block space-y-2">
<span className="font-medium">链接到当前画布的主题</span>
<select
aria-label="主题链接目标"
className="h-8 w-full rounded border border-[#dfe2e7] px-2"
value={details.topicLinkId}
onChange={(event) => onPatch({ topicLinkId: event.target.value })}
>
<option value="">无主题链接</option>
{details.topicLinkId &&
!topics.some(
({ element }) => element.id === details.topicLinkId
) ? (
<option value={details.topicLinkId}>目标主题已不存在</option>
) : null}
{topics
.filter(({ element }) => element.id !== topic.id)
.map(({ element, title, depth }) => (
<option key={element.id} value={element.id}>
{" ".repeat(Math.min(depth, 5))}
{title}
</option>
))}
</select>
</label>
{details.topicLinkId ? (
<button
type="button"
disabled={
!topics.some(
({ element }) => element.id === details.topicLinkId
)
}
className="text-[#3569e8] disabled:text-gray-400"
onClick={() => onJump(details.topicLinkId)}
>
定位目标主题
</button>
) : null}
</div>
)}
</aside>
);
}

View File

@ -1,7 +1,9 @@
"use client";
import { ReferenceIcon, type ReferenceIconName } from "@/components/lovart/reference-icon";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
@ -69,7 +71,6 @@ import {
normalizeLovartHomeSidebarConfig,
type LovartHomeSidebarItemKey,
} from "@/lib/home/lovart-sidebar-config";
import { BrandTopBar } from "@/components/layout/brand-top-bar";
import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-gallery";
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
@ -420,16 +421,15 @@ function AccountMenuItem({
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
<DropdownMenuItem
onSelect={onClick}
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-1.5 text-left text-[13px] font-medium text-black/80 hover:bg-black/5 dark:text-white/85 dark:hover:bg-white/6"
>
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-black/5 text-black/70 dark:bg-white/8 dark:text-white/70">
<Icon className="h-[16px] w-[16px]" />
</span>
<span className="flex-1">{label}</span>
</button>
</DropdownMenuItem>
);
}
@ -1637,7 +1637,31 @@ export function LovartHome({
) => {
const item = getLovartSidebarItem(key);
const FallbackIcon = LOVART_HOME_SIDEBAR_FALLBACK_ICONS[key];
const sizeClassName = options?.sizeClassName || "h-[18px] w-[18px]";
const sizeClassName = options?.sizeClassName || "h-[20px] w-[20px]";
const referenceIcons: Record<LovartHomeSidebarItemKey, ReferenceIconName> = {
newProject: "plus",
home: "home",
projects: "folder",
profile: "profile",
gallery: "brand",
tutorial: "tutorial",
imageEditor: "image-local-edit",
aiCad: "model-auto",
model3d: "model-auto",
scene: "layers",
agent: "skill",
videoCanvas: "video",
flowBoard: "connections",
productPlanning: "page",
toolbox1: "toolbox",
toolbox2: "toolbox",
settings: "settings",
};
const reference = referenceIcons[key];
if (reference && !item.iconUrl) {
return <ReferenceIcon name={reference} size={20} className={options?.filled ? "text-white" : undefined} />;
}
if (options?.filled) {
return (
@ -1703,8 +1727,261 @@ export function LovartHome({
THEME_MODE_OPTIONS[0];
const SelectedThemeIcon = selectedThemeOption.icon;
const homeThemeControl = () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
title={`主题模式:${selectedThemeOption.label}`}
aria-label={`主题模式:${selectedThemeOption.label}`}
>
<SelectedThemeIcon className="h-[15px] w-[15px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={10}
className="z-[160] !min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<div className="flex items-center gap-1">
{THEME_MODE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
const isSelected = selectedThemeMode === option.value;
return (
<DropdownMenuItem
key={option.value}
aria-label={option.label}
className={cn(
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
isSelected
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
: "text-black/70 dark:text-white/70"
)}
onSelect={() => setTheme(option.value)}
title={option.label}
>
<OptionIcon className="h-[15px] w-[15px]" />
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
const homeUtilityControls = () => (
<>
<button
type="button"
aria-label="通知"
title="通知"
className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10"
>
<Bell className="h-3.5 w-3.5" />
</button>
{homeThemeControl()}
</>
);
const homeAccountControls = () => (
<div className="flex w-full flex-col items-center gap-[12px]">
{isMembershipEnabled ? (
<HoverCard openDelay={100} closeDelay={120}>
<HoverCardTrigger asChild>
<Button
variant="ghost"
onClick={() => setUpgradeDialogOpen(true)}
aria-label="积分与会员"
className="h-auto w-[36px] rounded-[8px] px-0 py-2 text-[#333] hover:bg-black/5 dark:text-white/75 dark:hover:bg-white/10"
>
<div className="flex flex-col items-center gap-[4px] text-[10px] leading-none">
<Zap className="h-[20px] w-[20px]" />
{membershipData?.summary?.currentCredits ?? 0}
</div>
</Button>
</HoverCardTrigger>
<HoverCardContent
side="right"
align="end"
sideOffset={12}
className="z-[150] w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<div className="p-4">
<div className="mb-4 flex items-start justify-between">
<div>
<div className="text-[22px] font-semibold leading-none text-black dark:text-white">
{currentPlan?.name || "Free"}
</div>
</div>
<Button
size="sm"
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
</div>
<div className="space-y-2.5 border-t border-black/10 pt-3 text-[14px] dark:border-white/10 dark:text-white/90">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
<Sparkles className="h-3.5 w-3.5" />
积分
</span>
<span>{membershipData?.summary?.currentCredits ?? 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
<Upload className="h-3.5 w-3.5" />
每日免费积分
</span>
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
</div>
</div>
<button
type="button"
className="mt-4 inline-flex items-center gap-1 text-[12px] font-medium text-black/80 hover:text-black dark:text-white/75 dark:hover:text-white"
onClick={() => setUpgradeDialogOpen(true)}
>
使用详情
<ChevronDown className="h-4 w-4 -rotate-90" />
</button>
</div>
</HoverCardContent>
</HoverCard>
) : null}
{isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[#d4551f] text-[11px] font-semibold text-white"
aria-label="账号菜单"
title="账号菜单"
>
{userInitials}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="end"
sideOffset={12}
className="z-[150] max-h-[calc(100svh-24px)] w-[300px] overflow-y-auto rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<div className="p-3.5">
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
<div className="flex justify-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{userInitials}
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-black dark:text-white">
{userDisplayName}
</div>
<div className="truncate text-[12px] text-black/45 dark:text-white/45">
{user?.email || ""}
</div>
</div>
{isMembershipEnabled ? (
<DropdownMenuItem asChild>
<Button
size="sm"
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
</DropdownMenuItem>
) : null}
</div>
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
<DropdownMenuItem asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5 dark:text-white/80 dark:hover:bg-white/6"
onClick={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(true);
}}
>
<span>积分</span>
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60 dark:text-white/55">
{membershipData?.summary?.currentCredits ?? 0}
<ArrowRight className="h-4 w-4" />
</span>
</button>
</DropdownMenuItem>
</div>
</div>
<div className="mt-3 space-y-1">
{isMembershipEnabled ? (
<AccountMenuItem
icon={Users}
label="创建团队"
onClick={() => toast.info("团队功能后续开放")}
/>
) : null}
<AccountMenuItem
icon={User}
label="账户管理"
onClick={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(true);
}}
/>
<AccountMenuItem
icon={Info}
label="使用指南"
onClick={() => toast.info("使用指南后续开放")}
/>
<AccountMenuItem
icon={Mail}
label="联系我们"
onClick={() => setContactDialogOpen(true)}
/>
<div className="flex items-center justify-between border-t border-black/10 px-2 py-2 dark:border-white/10">
<span className="text-[13px] text-black/65 dark:text-white/65">
通知与主题
</span>
<div className="flex items-center gap-2">
{homeUtilityControls()}
</div>
</div>
<AccountMenuItem
icon={LogOut}
label="退出登录"
onClick={() => {
void handleLogout();
}}
/>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{homeThemeControl()}
<button
type="button"
className="flex h-[32px] w-[36px] items-center justify-center rounded-full border border-black/15 bg-white text-[11px] font-medium text-black/80 hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
onClick={openLoginDialog}
>
登录
</button>
</>
)}
</div>
);
return (
<div className="min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
<div className="lovart-home-surface min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
{shouldShowHomeBanner ? (
<LovartPromoBanner
actionText={homeBannerActionText}
@ -1714,236 +1991,12 @@ export function LovartHome({
text={homeBannerText}
/>
) : null}
<BrandTopBar
contentClassName="pl-[80px]"
logo={displayLogo}
offsetTop={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
showBrand={false}
siteName={siteName || "Lovart"}
>
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10">
<Bell className="h-3.5 w-3.5" />
</button>
{isMembershipEnabled ? (
<HoverCard openDelay={100} closeDelay={120}>
<HoverCardTrigger asChild>
<Button
variant="ghost"
onClick={() => setUpgradeDialogOpen(true)}
className="h-8 rounded-[14px] border border-black/10 bg-white px-2 text-[14px] text-black/72 shadow-sm hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
>
<div className="inline-flex items-center gap-1 rounded-[9px] px-1.5 py-0.5 text-xs">
<Zap className="h-3 w-3 text-amber-500" />
{membershipData?.summary?.currentCredits ?? 0}
</div>
</Button>
</HoverCardTrigger>
<HoverCardContent
align="end"
sideOffset={10}
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<div className="p-4">
<div className="mb-4 flex items-start justify-between">
<div>
<div className="text-[22px] font-semibold leading-none text-black dark:text-white">
{currentPlan?.name || "Free"}
</div>
</div>
<Button
size="sm"
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
</div>
<div className="space-y-2.5 border-t border-black/10 pt-3 text-[14px] dark:border-white/10 dark:text-white/90">
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
<Sparkles className="h-3.5 w-3.5" />
积分
</span>
<span>{membershipData?.summary?.currentCredits ?? 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
<Upload className="h-3.5 w-3.5" />
每日免费积分
</span>
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
</div>
</div>
<button
type="button"
className="mt-4 inline-flex items-center gap-1 text-[12px] font-medium text-black/80 hover:text-black dark:text-white/75 dark:hover:text-white"
onClick={() => setUpgradeDialogOpen(true)}
>
使用详情
<ChevronDown className="h-4 w-4 -rotate-90" />
</button>
</div>
</HoverCardContent>
</HoverCard>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
title={`主题模式:${selectedThemeOption.label}`}
aria-label={`主题模式:${selectedThemeOption.label}`}
>
<SelectedThemeIcon className="h-[15px] w-[15px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={10}
className="!min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<div className="flex items-center gap-1">
{THEME_MODE_OPTIONS.map((option) => {
const OptionIcon = option.icon;
const isSelected = selectedThemeMode === option.value;
return (
<DropdownMenuItem
key={option.value}
aria-label={option.label}
className={cn(
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
isSelected
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
: "text-black/70 dark:text-white/70"
)}
onSelect={() => setTheme(option.value)}
title={option.label}
>
<OptionIcon className="h-[15px] w-[15px]" />
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
{isAuthenticated ? (
<HoverCard openDelay={80} closeDelay={120}>
<HoverCardTrigger asChild>
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white"
aria-label="Account menu"
>
{userInitials}
</button>
</HoverCardTrigger>
<HoverCardContent
align="end"
sideOffset={12}
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
>
<div className="p-3.5">
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
<div className="flex justify-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
{userInitials}
</div>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-[15px] font-semibold text-black dark:text-white">
{userDisplayName}
</div>
<div className="truncate text-[12px] text-black/45 dark:text-white/45">
{user?.email || ""}
</div>
</div>
{isMembershipEnabled ? (
<Button
size="sm"
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
onClick={() => setUpgradeDialogOpen(true)}
>
升级
</Button>
) : null}
</div>
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
<button
type="button"
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5 dark:text-white/80 dark:hover:bg-white/6"
onClick={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(true);
}}
>
<span>积分</span>
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60 dark:text-white/55">
{membershipData?.summary?.currentCredits ?? 0}
<ArrowRight className="h-4 w-4" />
</span>
</button>
</div>
</div>
<div className="mt-3 space-y-1">
{isMembershipEnabled ? (
<AccountMenuItem
icon={Users}
label="创建团队"
onClick={() => toast.info("团队功能后续开放")}
/>
) : null}
<AccountMenuItem
icon={User}
label="账户管理"
onClick={() => {
setAccountCenterTab("account");
setUsageFilter("all");
setAccountCenterOpen(true);
}}
/>
<AccountMenuItem
icon={Info}
label="使用指南"
onClick={() => toast.info("使用指南后续开放")}
/>
<AccountMenuItem
icon={Mail}
label="联系我们"
onClick={() => setContactDialogOpen(true)}
/>
<AccountMenuItem
icon={LogOut}
label="退出登录"
onClick={() => {
void handleLogout();
}}
/>
</div>
</div>
</HoverCardContent>
</HoverCard>
) : (
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
onClick={openLoginDialog}
>
登录
</button>
)}
</BrandTopBar>
<div className="fixed bottom-5 left-4 z-[100] flex w-[44px] items-center rounded-full bg-[#f5f5f5] py-2 shadow-sm lg:hidden dark:bg-[#141416]">
{homeAccountControls()}
</div>
<LovartSidebar
accountControls={homeAccountControls()}
agentEnabled={siteSettingsData?.agentEnabled !== false}
getItem={getLovartSidebarItem}
isEnabled={isLovartSidebarItemEnabled}
@ -1982,9 +2035,12 @@ export function LovartHome({
/>
<main
style={
{ "--home-banner-offset": `${shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}px` } as CSSProperties
}
className={cn(
"ml-[92px] mr-10 w-auto max-w-none pb-16 max-lg:mx-5",
shouldShowHomeBanner ? "pt-[113px]" : "pt-[68px]"
"ml-[120px] mr-[60px] w-auto max-w-none pb-16 max-lg:mx-5",
shouldShowHomeBanner ? "pt-[124px]" : "pt-[76px]"
)}
>
{!isProjectsOnlyView && (
@ -2013,25 +2069,15 @@ export function LovartHome({
setPrompt={setPrompt}
siteName={siteName}
selectedModelId={selectedComposerModel.id}
onOpenGallery={() => featuredSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}
/>
)}
<LovartProjectsSection
deletingProjectId={deletingProjectId}
hasMoreProjects={hasMoreProjects}
isProjectsOnlyView={isProjectsOnlyView}
onCreateProject={openNewCanvas}
onDeleteProject={handleDeleteProject}
onOpenProject={openCanvasProject}
onPrepareCreate={preloadCanvasWorkspace}
onPrepareProject={prepareCanvasProject}
onViewAllProjects={openAllProjectsInPage}
projects={recentProjects}
projectSectionRef={projectSectionRef}
/>
{!isProjectsOnlyView && (
<LovartFeaturedGallery
onOpenProjects={openAllProjectsInPage}
onOpenMaterials={handleOpenImageEditor}
activeCategory={activeCategory}
categories={discoveryTabs}
featuredItems={featuredItems}
@ -2042,6 +2088,22 @@ export function LovartHome({
setActiveCategory={setActiveCategory}
/>
)}
{(isProjectsOnlyView || recentProjects.length > 0) && (
<LovartProjectsSection
deletingProjectId={deletingProjectId}
hasMoreProjects={hasMoreProjects}
isProjectsOnlyView={isProjectsOnlyView}
onCreateProject={openNewCanvas}
onDeleteProject={handleDeleteProject}
onOpenProject={openCanvasProject}
onPrepareCreate={preloadCanvasWorkspace}
onPrepareProject={prepareCanvasProject}
onViewAllProjects={openAllProjectsInPage}
projects={recentProjects}
projectSectionRef={projectSectionRef}
/>
)}
</main>
<button
@ -2132,7 +2194,7 @@ export function LovartHome({
{loginDialogOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
onClick={() => setLoginDialogOpen(false)}
>
<div

View File

@ -1,8 +1,11 @@
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
import type { Ref } from "react";
import type { FeaturedImage } from "./types";
type LovartFeaturedGalleryProps = {
onOpenProjects: () => void;
onOpenMaterials: () => void;
activeCategory: string;
categories: string[];
featuredItems: FeaturedImage[];
@ -16,7 +19,11 @@ type LovartFeaturedGalleryProps = {
function getFeaturedPreviewUrl(item: FeaturedImage) {
const source = item.thumbnailUrl || item.originalUrl;
if (!source || !/^https:\/\/yybb\.top\//i.test(source) || source.includes("?")) {
if (
!source ||
!/^https:\/\/yybb\.top\//i.test(source) ||
source.includes("?")
) {
return source;
}
@ -24,6 +31,8 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
}
export function LovartFeaturedGallery({
onOpenProjects,
onOpenMaterials,
activeCategory,
categories,
featuredItems,
@ -34,21 +43,44 @@ export function LovartFeaturedGallery({
setActiveCategory,
}: LovartFeaturedGalleryProps) {
return (
<section ref={featuredSectionRef} className="mt-14">
<h2 className="text-[16px] font-semibold leading-none text-[#1f1f1f] dark:text-white">
灵感发现
</h2>
<section
ref={featuredSectionRef}
className="lovart-discovery mt-16 scroll-mt-28"
aria-label="灵感发现"
>
<div className="flex items-center gap-2 overflow-x-auto pb-2 text-[13px] text-[#858585] dark:text-white/50">
<span className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[#252525] dark:bg-white/10 dark:text-white">
<ReferenceIcon name="skill" size={16} />
灵感发现
</span>
<button
type="button"
onClick={onOpenProjects}
className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] px-3 hover:bg-black/5 dark:hover:bg-white/10"
>
<ReferenceIcon name="folder" size={16} />
我的项目
</button>
<button
type="button"
onClick={onOpenMaterials}
className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] px-3 hover:bg-black/5 dark:hover:bg-white/10"
>
<ReferenceIcon name="brand" size={16} />
我的素材
</button>
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
<div className="flex items-center gap-x-5 overflow-x-auto border-b border-[#ededed] px-3 dark:border-white/10">
{categories.map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`h-7 rounded-[7px] px-3 text-[12px] font-normal leading-none transition-colors duration-150 ${
className={`relative h-9 shrink-0 border-b px-0 text-[13px] font-normal leading-none transition-colors duration-150 ${
activeCategory === category
? "bg-[#f2f2f2] text-[#202020] dark:bg-[#24242a] dark:text-white"
: "text-[#5f5f5f] hover:bg-[#f7f7f7] hover:text-[#202020] dark:text-white/55 dark:hover:bg-[#17171b]"
? "border-[#303030] text-[#202020] dark:border-white dark:text-white"
: "border-transparent text-[#858585] hover:text-[#202020] dark:text-white/55 dark:hover:text-white"
}`}
>
{category}
@ -58,15 +90,19 @@ export function LovartFeaturedGallery({
{featuredItems.length === 0 ? (
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
{isLoadingFeatured
? "灵感内容加载中..."
: "当前没有可展示的灵感数据。"}
</div>
) : (
<div className="mt-[14px] columns-1 gap-[10px] md:columns-2 lg:columns-5">
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
{featuredItems.map((item, index) => (
<button
key={item.id}
type="button"
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
onClick={() =>
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
}
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
>
<img
@ -78,14 +114,17 @@ export function LovartFeaturedGallery({
const image = event.currentTarget;
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
if (!fallbackUrl || image.dataset.fallbackApplied === "true") {
if (
!fallbackUrl ||
image.dataset.fallbackApplied === "true"
) {
return;
}
image.dataset.fallbackApplied = "true";
image.src = fallbackUrl;
}}
className="block h-auto w-full rounded-[6px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
/>
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
<div className="flex min-w-0 items-center gap-[6px] pr-2">
@ -112,7 +151,10 @@ export function LovartFeaturedGallery({
))}
</div>
)}
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
<div
ref={featuredLoadMoreRef}
className="mt-3 flex h-10 items-center justify-center"
>
{isLoadingMoreFeatured ? (
<div className="inline-flex items-center gap-2 text-sm text-black/45 dark:text-white/45">
<Loader2 className="h-3.5 w-3.5 animate-spin" />

View File

@ -1,6 +1,8 @@
import dynamic from "next/dynamic";
import { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles, X } from "lucide-react";
import type { ChangeEvent } from "react";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { ModelIdentityIcon } from "@/components/lovart/model-identity-icon";
import { Lightbulb, Loader2, X } from "lucide-react";
import { useRef, type ChangeEvent } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
@ -52,23 +54,19 @@ type LovartHeroComposerProps = {
onModelChange: (modelId: string) => void;
onStartAgentMode: () => void;
selectedModelId: string;
onOpenGallery: () => void;
};
export function LovartHeroComposer({
composerPlaceholder,
displayLogo,
files,
handleAddFiles,
heroPromoActionText,
heroPromoText,
heroSubtitleText,
heroTitleText,
homeAnimationData,
isAgentModeActive,
isMembershipEnabled,
isSubmitting,
onSubmit,
openUpgradeDialog,
prompt,
removeFile,
setPrompt,
@ -77,63 +75,71 @@ export function LovartHeroComposer({
onModelChange,
onStartAgentMode,
selectedModelId,
onOpenGallery,
}: LovartHeroComposerProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const uploadRef = useRef<HTMLInputElement>(null);
const suggestions = [
{
label: "电商",
icon: "commerce" as const,
prompt: "为我的产品设计一张高转化的电商主图,突出产品卖点。",
},
{
label: "社交媒体",
icon: "social" as const,
prompt: "设计一张适合社交媒体发布的视觉海报。",
},
{
label: "品牌",
icon: "branding" as const,
prompt: "为我的品牌设计一套统一的视觉形象。",
},
{
label: "创意",
icon: "creativity" as const,
prompt: "创作一张富有想象力和视觉冲击力的创意作品。",
},
{
label: "网站",
icon: "website" as const,
prompt: "设计一个简洁、现代的网站首页视觉稿。",
},
];
return (
<section className="mt-6 text-center">
{isMembershipEnabled ? (
<button
type="button"
onClick={openUpgradeDialog}
className="inline-flex w-full max-w-[620px] items-center gap-1.5 rounded-[12px] bg-[#fde8bf] px-3 py-1.5 text-[11px] leading-5 text-black/80 transition-colors duration-150 hover:bg-[#f9dfac] sm:w-auto sm:rounded-full sm:text-[12px] dark:bg-[#3f3013] dark:text-[#f6dfaa] dark:hover:bg-[#493819]"
>
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
<span className="line-clamp-2 min-w-0 flex-1 text-left sm:line-clamp-1">
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
</span>
<span className="shrink-0 text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
</button>
) : (
<div className="inline-flex w-full max-w-[620px] items-center gap-1.5 rounded-[12px] bg-[#fde8bf] px-3 py-1.5 text-[11px] leading-5 text-black/80 sm:w-auto sm:rounded-full sm:text-[12px] dark:bg-[#3f3013] dark:text-[#f6dfaa]">
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
<span className="line-clamp-2 min-w-0 flex-1 text-left sm:line-clamp-1">
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
</span>
</div>
)}
<section className="lovart-hero mt-6 text-center" aria-label="开始设计">
<h1 className="text-[20px] font-medium leading-7 tracking-[-0.02em] dark:text-white">
{heroTitleText || "你想设计什么?"}
</h1>
{heroSubtitleText ? (
<p className="mt-2 text-[13px] text-black/45 dark:text-white/45">
{heroSubtitleText}
</p>
) : null}
<div className="mt-7 flex items-center justify-center gap-1.5">
{displayLogo ? (
<img
src={displayLogo}
alt={siteName}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
<Sparkles className="h-3.5 w-3.5" />
</div>
)}
<h1 className="text-[28px] font-semibold leading-tight sm:text-[30px] dark:text-white">
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
</h1>
</div>
<p className="mt-1.5 text-[12px] text-black/38 dark:text-white/48">
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
</p>
<div className="mx-auto mt-7 w-full max-w-[760px] rounded-[16px] border border-[#e1e4e8] bg-white px-[18px] pb-[10px] pt-[14px] shadow-[0_8px_22px_rgba(0,0,0,0.055)] transition-shadow duration-150 focus-within:shadow-[0_10px_26px_rgba(0,0,0,0.075)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_14px_38px_rgba(0,0,0,0.32)]">
<div className="lovart-home-composer mx-auto mt-7 w-full max-w-[760px] rounded-[24px] border border-[#e5e5e5] bg-white px-[16px] pb-[8px] pt-[14px] shadow-[0_2px_4px_rgba(0,0,0,0.04),0_8px_20px_rgba(0,0,0,0.025)] transition-shadow duration-150 focus-within:shadow-[0_3px_8px_rgba(0,0,0,0.07)] dark:border-white/12 dark:bg-[#141416]">
<Textarea
ref={textareaRef}
aria-label="描述你的设计需求"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={composerPlaceholder}
className="min-h-[60px] resize-none border-0 bg-transparent px-0 !text-[16px] leading-6 text-[#20242a] shadow-none placeholder:text-[#aeb3bb] focus-visible:ring-0 md:!text-[14px] dark:text-white dark:placeholder:text-white/35"
className="min-h-[54px] resize-none border-0 bg-transparent px-0 !text-[16px] leading-6 text-[#20242a] shadow-none placeholder:text-[#b7b7b7] focus-visible:ring-0 md:!text-[14px] dark:text-white dark:placeholder:text-white/35"
/>
{files.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2 px-1">
{files.map((item) => (
<div key={item.id} className="group relative h-11 w-11 overflow-hidden rounded-md border border-black/10 dark:border-white/10">
<img src={item.previewUrl} alt={item.file.name} className="h-full w-full object-cover" />
<div
key={item.id}
className="group relative h-11 w-11 overflow-hidden rounded-md border border-black/10 dark:border-white/10"
>
<img
src={item.previewUrl}
alt={item.file.name}
className="h-full w-full object-cover"
/>
<button
type="button"
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-white group-hover:flex"
@ -150,8 +156,10 @@ export function LovartHeroComposer({
<div className="flex min-h-[44px] items-center justify-between pt-2">
<div className="flex min-w-0 flex-1 items-center gap-4">
<label className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-[#20242a] transition-colors duration-150 hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8">
<Plus className="h-4 w-4" strokeWidth={1.9} />
<ReferenceIcon name="plus" size={18} />
<input
ref={uploadRef}
aria-label="上传参考图"
type="file"
accept="image/*"
multiple
@ -186,7 +194,17 @@ export function LovartHeroComposer({
title="选择模型"
>
<div className="flex h-8 w-8 items-center justify-center">
<Box className="block h-4 w-4" strokeWidth={1.9} />
<ModelIdentityIcon
name={
modelOptions.find((model) => model.id === selectedModelId)
?.displayName || "auto"
}
avatarUrl={
modelOptions.find((model) => model.id === selectedModelId)
?.avatarUrl
}
size={18}
/>
</div>
</SelectTrigger>
<SelectContent
@ -201,18 +219,15 @@ export function LovartHeroComposer({
className="h-8 rounded-[7px] text-[12px] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full"
>
<span className="flex min-w-0 items-center gap-2">
<span className="flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full border border-black/10 bg-white text-[10px] font-medium text-[#6b7280] dark:border-white/15 dark:bg-white/8 dark:text-white/70">
{model.avatarUrl ? (
<img
alt=""
className="h-full w-full object-cover"
src={model.avatarUrl}
/>
) : (
(model.displayName || "A").slice(0, 1)
)}
<ModelIdentityIcon
name={model.displayName}
identity={`${model.modelId || model.id} ${model.providerType || ""}`}
avatarUrl={model.avatarUrl}
size={18}
/>
<span className="min-w-0 flex-1 truncate">
{model.displayName}
</span>
<span className="min-w-0 flex-1 truncate">{model.displayName}</span>
</span>
</SelectItem>
))}
@ -220,19 +235,63 @@ export function LovartHeroComposer({
</Select>
<Button
onClick={onSubmit}
aria-label="开始生成"
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-9 w-9 rounded-full bg-[#eeeeef] p-0 text-[#c2c7cf] transition-colors duration-150 hover:bg-[#e5e6e8] hover:text-[#7f8794] dark:bg-white/10 dark:text-white/40 dark:hover:bg-white/16"
>
{isSubmitting ? (
<Loader2 className="h-4.5 w-4.5 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" strokeWidth={1.8} />
<ReferenceIcon name="arrow-up" size={18} />
)}
</Button>
</div>
</div>
</div>
<div className="mx-auto mt-4 w-full max-w-[760px] space-y-3 px-4 text-left">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 w-[96px] shrink-0 text-[13px] text-[#8b8b8b] dark:text-white/45">
{siteName} 帮你做:
</span>
{suggestions.map(({ label, icon, prompt: suggestion }) => (
<button
key={label}
type="button"
className="lovart-suggestion"
onClick={() => {
setPrompt(suggestion);
textareaRef.current?.focus();
}}
>
<ReferenceIcon name={icon} size={18} />
{label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 w-[96px] shrink-0 text-[13px] text-[#8b8b8b] dark:text-white/45">
连接/收集灵感:
</span>
<button
type="button"
className="lovart-suggestion"
onClick={() => uploadRef.current?.click()}
>
<ReferenceIcon name="connections" size={18} />
上传我的参考素材
</button>
<button
type="button"
className="lovart-suggestion"
onClick={onOpenGallery}
>
<ReferenceIcon name="clipper" size={18} />
浏览灵感,发现更多创意
</button>
</div>
</div>
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
<div className="mt-6 w-full">
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />

View File

@ -8,7 +8,7 @@ type LovartPromoBannerProps = {
text: string;
};
export const LOVART_PROMO_BANNER_HEIGHT = 45;
export const LOVART_PROMO_BANNER_HEIGHT = 48;
export function LovartPromoBanner({
actionText,
@ -20,7 +20,7 @@ export function LovartPromoBanner({
const showAction = Boolean(link?.trim() && actionText?.trim());
return (
<div className="fixed inset-x-0 top-0 z-[120] h-[45px] bg-[#ddff58] text-[#11130d]">
<div className="fixed inset-x-0 top-0 z-[120] h-[48px] bg-[#e4ff58] text-[#11130d]">
<div className="mx-auto flex h-full max-w-[1440px] items-center justify-center gap-3 px-11 sm:px-12">
<Sparkles className="hidden h-4 w-4 shrink-0 sm:block" />
<p className="min-w-0 truncate text-center text-[13px] font-normal sm:text-[15px]">

View File

@ -20,6 +20,7 @@ type SidebarItem = {
};
type LovartSidebarProps = {
accountControls?: ReactNode;
agentEnabled: boolean;
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
@ -56,6 +57,7 @@ type LovartSidebarProps = {
};
export function LovartSidebar({
accountControls,
agentEnabled,
getItem,
isEnabled,
@ -88,9 +90,9 @@ export function LovartSidebar({
"rounded-[12px] border border-black/8 bg-white/96 p-2 text-[#111827] shadow-[0_18px_44px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-[#141416]/96 dark:text-white dark:shadow-[0_18px_44px_rgba(0,0,0,0.4)]";
const sidebarButtonClass = (active = false) =>
[
"group flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[#202020] transition-colors duration-150 hover:bg-[#f5f5f5] dark:text-white dark:hover:bg-white/10",
"group flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-[8px] text-[#202020] transition-colors duration-150 hover:bg-[#f5f5f5] dark:text-white dark:hover:bg-white/10",
active
? "bg-[#f5f5f5] text-[#202020] dark:bg-white/10 dark:text-white"
? "bg-[#e5e5e5] text-[#202020] dark:bg-white/10 dark:text-white"
: "",
]
.filter(Boolean)
@ -112,7 +114,7 @@ export function LovartSidebar({
>
{renderIcon(key, {
active: options?.active,
sizeClassName: "h-4 w-4",
sizeClassName: "h-[20px] w-[20px]",
})}
</button>
</TooltipTrigger>
@ -133,7 +135,7 @@ export function LovartSidebar({
className={sidebarButtonClass()}
title={getItem(key).label}
>
{renderIcon(key, { sizeClassName: "h-4 w-4" })}
{renderIcon(key, { sizeClassName: "h-[20px] w-[20px]" })}
</button>
</HoverCardTrigger>
<HoverCardContent
@ -164,15 +166,15 @@ export function LovartSidebar({
return (
<aside
className={[
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[56px] flex-col items-center border-r lg:flex",
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[60px] flex-col items-center border-r lg:flex",
isDark
? "border-white/10 bg-black"
: "border-[#e9e9e9] bg-white",
: "border-[#ededed] bg-[#f5f5f5]",
].join(" ")}
style={{ top: topOffset }}
>
<TooltipProvider delayDuration={120}>
<div className="flex h-full w-full flex-col items-center overflow-y-auto overflow-x-hidden px-2 pb-3 pt-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="flex min-h-0 w-full flex-1 flex-col items-center overflow-y-auto overflow-x-hidden px-2 pt-[16px] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Tooltip>
<TooltipTrigger asChild>
<button
@ -197,18 +199,18 @@ export function LovartSidebar({
<TooltipContent side="right">{siteName}</TooltipContent>
</Tooltip>
<div className="mt-5 flex w-full flex-col items-center gap-3.5">
<div className="mt-[22px] flex w-full flex-col items-center gap-[2px]">
{isEnabled("newProject") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onNewCanvas}
className={sidebarButtonClass()}
className={`${sidebarButtonClass()} mb-[14px] border border-[#e5e5e5] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-white/5`}
title={getItem("newProject").label}
>
{renderIcon("newProject", {
sizeClassName: "h-4 w-4",
sizeClassName: "h-[20px] w-[20px]",
})}
</button>
</TooltipTrigger>
@ -228,7 +230,7 @@ export function LovartSidebar({
className={sidebarButtonClass()}
title={getItem("tutorial").label}
>
{renderIcon("tutorial", { sizeClassName: "h-4 w-4" })}
{renderIcon("tutorial", { sizeClassName: "h-[20px] w-[20px]" })}
</button>
</HoverCardTrigger>
<HoverCardContent
@ -281,9 +283,6 @@ export function LovartSidebar({
</HoverCard>
) : null}
{tooltipButton("imageEditor", onImageEditor)}
{tooltipButton("aiCad", () => onRoute("/ai-cad"))}
{tooltipButton("model3d", () => onRoute("/model3d"))}
{tooltipButton("scene", () => onRoute("/pascal-editor"))}
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
{tooltipButton("flowBoard", () => onRoute("/flow-board"))}
@ -294,9 +293,10 @@ export function LovartSidebar({
{toolbox("toolbox2", toolbox2Links, toolbox2Enabled)}
</div>
<div className="mt-auto flex w-full flex-col items-center gap-1 pt-4">
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
</div>
</div>
<div className="flex w-full shrink-0 flex-col items-center gap-[10px] px-2 pb-[22px] pt-[12px]">
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
{accountControls}
</div>
</TooltipProvider>
</aside>

View File

@ -1,5 +1,6 @@
"use client";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
@ -37,6 +38,7 @@ interface ImageParamsBarProps {
hideImageCount?: boolean; // 隐藏图片数量按钮(仅个别页面需要)
hideAdvancedOptions?: boolean; // 隐藏高级设置按钮
inlineTheme?: "light" | "dark" | "minimal";
referencePresentation?: boolean;
showInlineDivider?: boolean;
// 🆕 图片生成模型特性配置(从模型配置中读取)
imageGenConfig?: {
@ -135,6 +137,7 @@ export function ImageParamsBar({
hideImageCount = false,
hideAdvancedOptions = false,
inlineTheme = "light",
referencePresentation = false,
showInlineDivider = true,
imageGenConfig,
}: ImageParamsBarProps) {
@ -493,12 +496,12 @@ export function ImageParamsBar({
<PopoverTrigger asChild>
<Button
variant="ghost"
className="h-8 shrink-0 rounded-full bg-[#f1f1f2] px-2.5 text-[13px] font-normal text-[#20242a] hover:bg-[#e9eaec] focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"
className={cn("h-8 shrink-0 rounded-full bg-[#f1f1f2] px-2.5 text-[13px] font-normal text-[#20242a] hover:bg-[#e9eaec] focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]", referencePresentation && "lovart-image-params")}
title="生成参数"
aria-label={`比例:${params.aspectRatio},分辨率:${params.imageSize}`}
>
{qualityLabel} · {selectedAspectLabel} · {currentImageSize} · {selectedImageCount} img
{minimalParamsOpen ? (
<span className={referencePresentation ? "min-w-0 truncate text-[13px]" : undefined}>{qualityLabel} · {selectedAspectLabel} · {currentImageSize} · {selectedImageCount} {referencePresentation ? "张" : "img"}</span>
{referencePresentation ? <ReferenceIcon name="chevron-down" size={18} className={minimalParamsOpen ? "rotate-180" : undefined} /> : minimalParamsOpen ? (
<ChevronUp className="ml-1 h-3 w-3" />
) : (
<ChevronDown className="ml-1 h-3 w-3" />

View File

@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { getReferenceModelIcon, ReferenceIcon } from "./reference-icon";
type ModelIdentityIconProps = {
name: string;
identity?: string;
avatarUrl?: string | null;
size?: number;
className?: string;
};
export function ModelIdentityIcon({
name,
identity,
avatarUrl,
size = 18,
className,
}: ModelIdentityIconProps) {
const [failedUrl, setFailedUrl] = useState<string | null>(null);
const reference = getReferenceModelIcon(`${name} ${identity || ""}`);
if (reference || !avatarUrl || failedUrl === avatarUrl) {
return (
<ReferenceIcon
name={reference || "model-auto"}
size={size}
className={className}
/>
);
}
return (
<img
alt=""
aria-hidden="true"
className={className}
src={avatarUrl}
width={size}
height={size}
style={{ width: size, height: size, objectFit: "contain", flexShrink: 0 }}
onError={() => setFailedUrl(avatarUrl)}
/>
);
}

View File

@ -0,0 +1,125 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
export type ReferenceIconName =
| "home"
| "folder"
| "skill"
| "brand"
| "plus"
| "model-auto"
| "arrow-up"
| "commerce"
| "social"
| "branding"
| "creativity"
| "website"
| "connections"
| "clipper"
| "cursor"
| "pan"
| "text"
| "sparkles"
| "style-controls"
| "image-enhance"
| "tutorial"
| "video"
| "toolbox"
| "settings"
| "profile"
| "comment"
| "draw"
| "image"
| "layers"
| "history"
| "expand"
| "collapse"
| "reference"
| "skill-filled"
| "brand-filled"
| "pinterest"
| "close"
| "reference-image"
| "chevron-down"
| "points"
| "page"
| "model-banana"
| "model-gpt"
| "model-seedream"
| "model-luma"
| "model-flux"
| "model-midjourney"
| "model-ideogram"
| "model-recraft"
| "image-local-edit"
| "image-layout-replace"
| "image-mark"
| "image-upscale"
| "image-remove-background"
| "image-erase"
| "image-extract-layer"
| "image-edit-text"
| "image-angle"
| "image-motion"
| "image-more"
| "image-download"
| "image-move-object"
| "image-mockup"
| "image-outpaint"
| "image-adjust"
| "image-crop"
| "image-vector"
| "image-flip-rotate"
| "toolbar-pin-outline"
| "toolbar-pin-filled"
| "toolbar-reset"
| "toolbar-close";
type ReferenceIconProps = {
name: ReferenceIconName;
size?: number;
className?: string;
style?: CSSProperties;
};
export function ReferenceIcon({
name,
size = 18,
className,
style,
}: ReferenceIconProps) {
const source = `url("/icons/lovart/${name}.svg")`;
return (
<span
aria-hidden="true"
data-reference-icon={name}
className={cn("inline-block shrink-0 align-middle", className)}
style={{
width: size,
height: size,
backgroundColor: "currentColor",
WebkitMaskImage: source,
maskImage: source,
WebkitMaskRepeat: "no-repeat",
maskRepeat: "no-repeat",
WebkitMaskPosition: "center",
maskPosition: "center",
WebkitMaskSize: "contain",
maskSize: "contain",
...style,
}}
/>
);
}
export function getReferenceModelIcon(name: string): ReferenceIconName | null {
if (/nano[\s-]*banana|gemini|imagen/i.test(name)) return "model-banana";
if (/gpt|openai|dall[\s-]*e/i.test(name)) return "model-gpt";
if (/seedream|doubao/i.test(name)) return "model-seedream";
if (/flux/i.test(name)) return "model-flux";
if (/luma|uni[\s-]*1/i.test(name)) return "model-luma";
if (/midjourney/i.test(name)) return "model-midjourney";
if (/ideogram|p[\s-]*image/i.test(name)) return "model-ideogram";
if (/recraft/i.test(name)) return "model-recraft";
return null;
}

View File

@ -0,0 +1,57 @@
# 本机 Xmind 客户端源码与界面对照
本轮修改位于用户指定的“旧电脑桌面/第四部分/开发项目/ai-chatbot-main”。本机 `/Applications/Xmind.app` 为 26.04.01337,与此前的 26.05 参考包分别记录。
- 读取 `Contents/Resources/app.asar`,核对 `renderer/editor-frame.js`、`renderer/editor.js`、Vanakit 样式及浮动工具栏/工作表栏样式。完整第三方代码只放在临时目录。
- 浮动工具栏按客户端顶部 16px、组间距 12px、圆角 24px 调整。主题/子主题、联系、概要、外框、插入、导出、大纲、ZEN、演示、标记和格式入口接入现有画布。菜单支持方向键和 Escape。
- 格式侧栏包含样式/画布分类及可展开结构选择。底部胶囊工作表支持重命名取消及缩放菜单。图标、NeverMind 字体来源记录在各自 public 子目录。
- 修正全局等宽字体覆盖、字体测量与实际渲染不一致引发的标题裁切。导入保留无填充、零边框、分支配色,透明填充随 XMind 导出。
- 查找支持折叠后代、当前/全部工作表、跨工作表定位。跨富文本叶替换保持文字标记,替换中的 `$&` 按字面写入;批量替换可撤销/重做。
- 30 项数据测试、18 项浏览器测试、类型检查及新增组件/工具的 Biome 检查通过。浏览器测试覆盖安装包 9 个基础样例,并检查主主题字体和文字不裁切。
此轮完成上述基础能力和界面调整,尚未达到全功能及逐像素一致。格式控件、节点间距、左侧分支顺序、完整 Pitch、全部主题、任务/甘特图、云协作等仍有差距,不能用测试数量认定与原生完全对等。完整验收清单见 [功能对照](./XMind-复刻功能对照.md)。
## 2026-10-04 实机操作与项目 UI 调整
- 本机客户端实际打开网页导出的 XMind 文件、显示备注和标签、将主主题字号改为 24pt,并保存;网页再次导入后,字号、两行纯文本备注和附件字节保留。该指定样例通过,不代表所有样式和内容无损兼容。
- 实际打开原生查找、标记和甘特窗口。任务编辑及完整演示遇到授权界面;云端服务、所有导出类型、全部布局变体仍未完成逐项实机覆盖。
- 修改前的 17 个网页功能组操作中 14 个通过、3 个文字格式组异常。进一步读取可见文字片段,确认选中整个主题修改字号、文字颜色或标记时,格式落入空文字片段,已有标题未更新。该问题仍待修复。
- 相同窗口 1480×873、DPR=2、100% 下,原生样例主主题色块约 137×41,网页约 124×37;同侧分支间距约 130 与 63,左侧分支顺序不同。测量的是色块区域,不能视为逐像素一致。
- 根据用户最新要求,界面改为项目首页使用的 Arial/PingFang 字体与紧凑控件:取消模拟窗口按钮,工具栏圆角改为 8px,侧栏从 264px 改为 240px,减少预览卡片和分组留白。画布主题字体和实际缩放由文档保留。检查显示页面 CSS zoom=1、transform=none,新建画板 100%。
- 增加常驻选择/平移按钮、V/H 快捷键与当前模式反馈,初始化和工作表切换默认选择模式;切换选择/平移时重置创建模式。文字输入框的 V/H 不被快捷键拦截。
- 框选两分支、批量删除和撤销、平移后再次框选、按钮/快捷键切换和文本输入均通过浏览器验证;查找侧栏遮挡文件菜单的问题已修正。20 项浏览器用例全部通过(首轮 18 项通过,两个既有测试因新增工具占据原点击坐标而失败,调整为空白坐标后复测通过);TypeScript 检查通过。800px 窗口无页面横向溢出。
- 开发预览点击保存后刷新回空白,不是正式账号云端保存的验收结果。详细截图和 46 项能力对照保存在本机 `.local/xmind-audit-2026-10-04/index.html`,未修改用户原始 Xmind 文档。
## 2026-10-04 框选线可见性修复
- 原有框选数量测试未覆盖实际显示。实测发现预览页没有应用画板基础样式,活动 SVG 的 position=static,区域顶边位于屏幕 y=1309,因此选中数量变化但框线不可见。
- 将画板基础 CSS 移到共享客户端组件中打包加载,替代只在正式路由加载的外部样式链接。修复后活动 SVG 为 absolute,覆盖画布可视区域且 pointer-events=none,不拦截编辑操作。
- 框选边线为 2px 蓝色,拖动区域有浅蓝填充;松开后去掉拖动填充,保留被选主题和多选范围描边。
- 框选回归新增可视坐标、边框颜色/宽度、填充及松开后高亮检查,覆盖平移后再框选。实际截图保存在 `.local/xmind-audit-2026-10-04/web/22-marquee-drag-after.png`。
## 2026-10-05 主题图片粘贴修复
- 修复主题文字编辑时跳过图片粘贴的问题:选中一个主题或正在编辑该主题时,剪贴板图片作为主题图片插入,保留标题;同一主题再次插入替换原图。插入菜单的图片入口采用相同行为,空白画布保留独立图片插入。
- 图片按比例缩放,主题图片宽度上限 240px。异步读取/上传后重新查找目标主题,切换画布或目标已删除时不插入其他位置。文字、标题、备注等文本粘贴保留各自原有处理。
- 开发预览直接保存图片数据,避免依赖登录上传;正式页面继续使用原有上传接口。支持 JPEG、PNG、WebP,保留原有大小限制。
- 4 项浏览器用例通过,使用实际剪贴板和粘贴快捷键验证编辑状态、选中状态、空白画布及文字粘贴;同时覆盖图片撤销/重做、XMind 导出嵌入资源和标题/备注不拦截。TypeScript 与本轮文件 Biome 检查通过。正式账号上传接口未在此轮实测。
## 2026-10-05 原生标记面板与文件往返
- 原先采用少量系统 Emoji 代替原生标记,大部分标记不能导出;旧面板在 240px 侧栏中使用固定宽度网格,会溢出。现使用安装包的标记目录、原生 SVG/PNG 和标记入口图标,默认显示标签、优先级、任务、旗帜、星星、人像、符号 7 组 58 个标记。
- 提取客户端目录中的 219 份标记资源,包含 197 个不同 ID;共享 ID 按客户端目录顺序解析。搜索可查找隐藏标记,“更多标记”展示完整目录,并用于旧文档导入显示。资源来源和哈希记录在 `public/icons/xmind/markers/`。
- 选中主题后添加标记,同组替换,再次点击移除;多选显示混合状态,点击后为全部选中主题应用同一值。批量修改和清空各作为一次撤销操作。清空同时移除保留的未知原生标记,撤销可恢复;普通自定义 Emoji 不受影响。
- 标记在主题上以客户端图标呈现,支持 XMind 导入导出。未识别的 ID 继续作为元数据保留,不能宣称所有第三方自定义标记都可显示。贴纸、插画、自定义标记库及图例交互仍未实现;原先禁用的占位页签已移除。
- 3 项标记浏览器测试覆盖面板尺寸、实际图标加载、增删替换、清空、批量撤销/重做、搜索和更多标记;2 项数据测试覆盖全部目录资源存在、197 个 ID 文件往返及旧 Emoji 兼容。TypeScript 与本轮文件的 Biome 检查通过。
- 连续操作发现成功提示会盖住顶部插入菜单;将白板内的提示移至底部,并取消清空标记后的遮挡提示。图片粘贴用例等待画布字体加载后再操作,避免加载中的坐标变化影响双击。
## 2026-10-06 笔记与甘特图
已加入富文本笔记、主题笔记/任务提示、任务信息窗口和甘特图。甘特图参考客户端左右表格/时间网格、橙色胶囊时间条、任务名称与负责人提示、选中行高亮、圆角容器、缩放和今天线,按项目现有控件尺寸实现。日级任务可编辑日期/工期、进度、优先级、负责人及四种前置关系;拖动和拉伸支持联动排期及批次撤销。
文件格式按安装包核对:最新笔记使用 `realHTML.content`,任务使用 `org.xmind.ui.task` 扩展、0–1 进度和毫秒工期。笔记默认在主题旁的浮窗编辑,支持展开主题详情侧栏;甘特图高级工作日日历、小时级及里程碑、团队成员服务和原生全部导出方式尚未对等。
本机 XMind 已实际打开本轮导出文件,任务卡片和原生甘特图正确显示 2026 年 10 月 5–6 日(2 天)与 7–9 日(3 天)、负责人“小明”;原生甘特图的前置关系箭头正常显示;笔记浮窗显示“1. 交付说明”、加粗及蓝色链接。指定样例的原生读取已验证,不能据此认定全部复杂笔记和任务设置均无损。
本轮 9 项数据检查、15 项浏览器检查(分批运行)、TypeScript 和相关代码检查通过。浏览器覆盖笔记与甘特图,并回归标记、框选、图片粘贴、主题内容及文件往返;首次空白画布导出超时后的复测已通过。快捷键不再把笔记全选变成画布多选。

View File

@ -0,0 +1,41 @@
# XMind 26.05 安装包对照记录
用户提供:`/Users/a134/Downloads/Xmind-for-macOS-26.05.01107-202609082317.dmg`。
2026-09-18 只读挂载至 `/Volumes/Xmind`。从 Info.plist 实际读取版本 **26.05.01107**、构建 **2026090823170**、Bundle ID `net.xmind.vana.app`。本机已有版本为 26.04.01337。签名元数据标识为 XMind Ltd.,Team ID `4WV38P2X5K`。
## 可复用的核验入口
```sh
python3 scripts/flow-board/inspect-xmind-package.py /Volumes/Xmind/Xmind.app --samples-dir /tmp/xmind-26.05-reference
XMIND_NATIVE_FIXTURES=/tmp/xmind-26.05-reference pnpm exec playwright test --config playwright.flow-board-ui.config.ts
```
脚本读取安装包的版本和内置样例,输出文件大小、SHA-256、根布局类型。样例仅提取到本机临时目录用于兼容性验证,不作为项目的内置模板提交。
## 样例与布局
安装包含 36 个 `.xmind` 样例,其中基础样例 9 个;这 9 个文件的 SHA-256 与本机 26.04 同名文件相同。
| 样例 | 文件内结构类型 | 当前项目 |
|---|---|---|
| mind_map.xmind / mind_map_legacy.xmind | org.xmind.ui.map.clockwise | 基础思维图 |
| logic_chart.xmind | org.xmind.ui.logic.right | 向右逻辑 |
| org_chart.xmind | org.xmind.ui.org-chart.down | 向下组织 |
| brace_map.xmind | org.xmind.ui.brace.right | 基础括号布局,导出保留结构类型 |
| fishbone.xmind | org.xmind.ui.fishbone.leftHeaded | 基础鱼骨布局,导出保留结构类型 |
| timeline.xmind | org.xmind.ui.timeline.sided.horizontal | 基础水平时间轴,导出保留结构类型 |
| tree_chart.xmind | org.xmind.ui.timeline.vertical | 基础垂直时间轴,导出保留结构类型 |
| tree_table.xmind | org.xmind.ui.treetable.toptitle | 基础顶标题树形表格,导出保留结构类型 |
| coordinating_upcoming_release_1.0.xmind | org.xmind.ui.treetable | 基础侧标题树形表格,导出保留结构类型 |
| mood_tracker.xmind | org.xmind.ui.spreadsheet | 基础矩阵;读取并导出列扩展和单元格标签 |
实际浏览器测试导入这 9 个样例,随后导出,逐工作表比对所有主题标题和数量,全部通过。鱼骨图、水平/垂直时间轴、括号图和树形表格另检查实际特殊结构渲染及导出结构类型。测试不证明所有细节已与原生像素一致,也不证明原生应用打开兼容性。
2026-09-24 追加从安装包提取的结构样例核验:`mood_tracker.xmind`、`coordinating_upcoming_release_1.0.xmind`、`tree_table.xmind`、`film_production.xmind` 均通过导入后再导出的主题标题/数量比对。矩阵的列扩展和标签映射、侧标题表格的根表头与跨行几何另有合成文件浏览器测试。
## 新版功能线索
中文文案字典从 3096 项变为 3167 项:新增 84 项、移除 13 项、修改 1 项。新增内容涉及浮动工具栏显示策略、主题添加按钮、编辑器偏好、默认中日韩字体、默认缩放、一周起始日、快捷输入启动设置、专区和打印选项。
这些是安装包中的功能线索,仍需原生交互核对后才能认定具体行为或版本差异。原生应用界面连接当前超时,未完成界面实测;不以文案存在代替功能验收。

View File

@ -0,0 +1,111 @@
# XMind 复刻功能对照
目标:以用户提供的 XMind 26.05.01107 macOS 安装包为最新参照,结合此前 XMind 26.04 桌面体验,完成全部功能能力的逐项实现与验证。保留项目自有品牌。不以界面相似或少量测试通过宣称完整复刻。安装包核验见 [26.05 对照记录](./XMind-26.05-安装包核验.md)。
参考入口:[官方功能矩阵](https://xmind.app/pricing/_feature_matrix/)、[功能总览](https://xmind.com/features)、[结构](https://xmind.com/structure)、[文件管理](https://xmind.com/user-guide/working-with-files)、[大纲](https://xmind.com/user-guide/outliner-new)。在线版本会变化;原生 XMind 行为和实际文件仍需逐项对照。
状态:基础 = 已有可用实现但未达到完整对等;进行中 = 正在实现;待实现/核验 = 不得视作支持。
| 能力 | 当前状态 | 剩余验收 |
|---|---|---|
| 编辑器布局、结构图标、工作表栏 | 基础 | 逐屏视觉对照、窄屏/高 DPI/暗色模式 |
| 主题/子主题/同级主题、键盘编辑 | 基础 | 完整快捷键矩阵、混合选区、批量操作 |
| 选择、拖动、重排、自由主题 | 底层已有,部分核验 | 跨分支拖拽、拆分合并、对齐、吸附 |
| 撤销/重做 | 基础 | 所有新命令均需单独验证历史边界 |
| 思维图、左右逻辑、上下组织、目录 | 基础 | 子树混合布局、手动布局行为对照 |
| 鱼骨图、时间轴、树形表格、矩阵、括号图 | 部分基础 | 已有左头鱼骨图、双侧水平时间轴、垂直时间轴、顶标题/侧标题树形表格、矩阵和向右括号图;待补方向变体、穿越式时间轴、子树混合布局和更精细的原生视觉对照 |
| 分支/节点/文字格式、主题配色 | 基础 | 全形状、线型、彩虹、字体、富文本、全主题 |
| 联系线 | 基础 | 控制点编辑、箭头、虚线、复杂布局、双向联系 |
| 外框 | 基础 | 标题、全形状、嵌套、填充、范围调整 |
| 概要 | 基础 | 范围调整、多重概要、完整样式 |
| 图标/标记、贴纸 | 原生标记可用,贴纸/插画待实现 | 自定义标记、图例交互及贴纸/插画库 |
| 备注、外链、主题链接、标签 | 基础 | 已增加富文本笔记(加粗、斜体、下划线、删除线、列表、缩进、链接),写入客户端最新 realHTML 和 plain 格式并保留旧段落格式;支持笔记提示、撤销和项目内往返。标签和外链编辑、工作表切换已验证;待补备注图片、跨画布链接和真实云端保存重载 |
| 图片、附件、音频、公式 | 部分支持 | 新旧 XMind 内嵌 PNG/JPEG/GIF/WebP 导入;附件新增/替换/下载/移除/撤销。待补 SVG、音频、公式、资源导出和真实样本覆盖 |
| 任务、待办、进度、日期、负责人、甘特图 | 任务及甘特图基础已实现 | 任务开始/截止日期、日级工期、进度、优先级、多负责人、四种依赖;甘特图拖动/拉伸、联动排期、批次撤销、汇总任务、折叠、筛选、缩放、今天定位、CSV 和浏览器打印;原生任务扩展导入导出。待办模型、工作日日历、小时级/里程碑、原生全部导出与团队成员服务待补 |
| 查找替换、过滤、聚焦、导航 | 查找替换基础 | 已验证折叠后代、当前/全部画布范围、跨工作表定位、替换批量撤销/重做和跨富文本叶替换;过滤、分支聚焦待补 |
| 大纲视图 | 基础 | 已验证画布双向改名、新增/删除、缩进/提升、拖动重排、展开折叠、缩放和撤销;待补混合富文本、批量选区、分支聚焦、大纲专用导出和原生视觉对照 |
| 浮动工具栏、主题添加按钮 | 待新版对照 | 26.05 安装包有新增编辑体验文案,需按原生行为补齐 |
| 编辑器偏好设置 | 待实现 | 默认缩放、默认中日韩字体、一周起始日、快捷输入启动偏好 |
| ZEN、演示/Pitch、分步展示 | ZEN / 演示基础 | 已有隐藏编辑工具、Escape 恢复和主题演示前后页;完整 Pitch 模板、分步动画、演示导出待补 |
| 多工作表新建/切换/重命名/复制/删除 | 基础 | 跨工作表移动/链接、合并 XMind 文件 |
| 云文件、自动保存 | 基础 | 离线恢复、版本历史、异常恢复与冲突 |
| XMind 新版 JSON / 旧版 XML 导入 | 基础 | 资源、全部样式、结构和扩展内容;真实样本库 |
| MD、OPML、FreeMind 导入 | 基础 | 更多格式与大文件性能 |
| XMind 导出与往返兼容 | 基础,指定原生往返样例已核验 | 本机 26.04 实际打开网页导出文件、修改字号并保存回传,指定主题的字号/纯文本备注/附件字节保留;全结构、复杂样式与混合富文本的原生无损往返仍待核验 |
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | PNG 已支持;其余待核验 | 当前工作表全图 PNG,1/2/3 倍清晰度、画布背景或透明背景;选区、多页和其他格式待补 |
| 打印、密码、缓存、文件恢复 | 待实现 | 不得以浏览器存储代替完整文件保护 |
| 模板、主题库、手绘风格 | 待实现/部分底层能力 | 内置模板、自定义、导入与复用 |
| 协作、评论、分享、权限 | 待实现 | 项目已有登录不等于实时协作完成 |
| AI 生成、总结、重组、扩写 | 待实现 | 接入项目模型能力、编辑历史、可靠保存 |
| 无障碍、多语言、性能 | 待专项核验 | 键盘/读屏、长文、1 万主题、资源上限 |
## 已有验证入口
- `pnpm exec playwright test --config playwright.flow-board.config.ts`:快照兼容、工作表隔离、关系/外框几何与选区规则。
- `pnpm exec playwright test --config playwright.flow-board-ui.config.ts`:真实浏览器导入、编辑、撤销、工作表切换。
- `pnpm exec tsc --noEmit --pretty false`:类型检查。
- 开发预览:`/dev/flow-board-preview`,只在开发环境开放。预览内容不等同于云端持久化。
## 2026-09-18 主题内容验收
- 11 项数据测试、7 项浏览器测试、TypeScript 检查通过。
- 新版 JSON 和旧版 XML 的备注、标签、外链、内嵌图片已验证;附件下载内容与原文件一致。
- 修复折叠后代主题没有渲染路径导致链接跳转异常的问题:按数据树路径展开祖先后定位。
- 资源限制:XMind 文件 50 MB、单资源/新增附件 8 MB、导入内嵌资源累计 24 MB;缺失或不支持的图片会给出提示并保留主题。外部图片不自动下载。
- 当前回归使用生成的测试文件。原生 XMind 实际文件库、真实账号云端保存重载和导出往返仍待核验,不能据此认定完全兼容。
## 验收约束
每项至少核验:创建、编辑、删除、保存重载、撤销重做、工作表切换、导入导出兼容;适用时增加原生 XMind 对照。未提供证据的项保持未完成。所有目前实现仍是部分能力,完整目标持续有效。
## 2026-09-18 XMind 导出验收
- 工具栏新增“导出 XMind”。按现代 XMind 文件包写入 content.json、metadata.json、manifest.json 和内嵌资源;文件包结构参考官方模型以及本机 XMind 自带 mind_map.xmind 样本。
- 14 项数据测试、8 项浏览器测试、TypeScript 和本轮新增文件 lint 检查通过。项目内往返覆盖多工作表、当前编辑内容、显式字号/粗体、备注/标签、主题链接、概要、外框、联系线、自由主题、图片和附件。图片与附件字节一致。
- 导出会重新分配主题 ID 并同步引用,避免重复工作表产生 ID 冲突。当前工作表以实时画布内容为准。
- 尚未达到原生无损往返:原生界面连接超时,未完成 XMind 打开/再次保存的实机验证;自动主题配色、混合富文本、部分标记、特殊布局、任意画布元素和联系线控制点仍有差距。同一主题同时含附件和链接时,目前优先附件并明确提示。
- 不支持的内容会显示导出提示;无法读取的资源、没有主题的工作表会明确报错,不生成看似成功的空文件。
## 2026-09-18 大纲与原生样例验收
- 19 项数据测试、10 项浏览器测试通过(浏览器运行设置 `XMIND_NATIVE_FIXTURES=/tmp/xmind-26.05-reference`),另验证大纲缩放、空画布创建中心主题和 Escape 取消改名。类型检查通过。
- 大纲与画布共享主题、备注、历史和工作表数据;支持 Enter 新增同级、Shift+Enter 新增子主题、Tab/Shift+Tab 层级调整、Alt+上下移动,以及拖动到主题前/后/内部。
- 重排保留 ID、备注、图片和联系线引用;概要随保留的主题调整范围。会拆散概要范围的移动暂时明确拒绝,待补原生范围重排行为。
- 用户安装包中的 9 个基础样例均完成“导入 → 导出”主题标题/数量比对,没有缺少主题;此项不证明布局和样式相同。此阶段鱼骨图、括号图、时间轴、树形表格尚未完成;后续进展见下文。
- 原生界面连接两次超时,原生视觉与导出文件打开验证仍未通过;不影响继续开发其他差距。
## 2026-09-18 特殊布局验收
- 新增左头鱼骨图、双侧水平时间轴、向右括号图;结构面板使用独立 SVG 图标。整棵分支参与布局计算,为长标题和后代预留空间,根概要放在分支列之外。
- 新版 XMind 导入识别这三种根结构,导出保留对应 `structureClass`。通过 Drawnix 初始插件入口安装自定义渲染组件,导入首次显示即使用对应布局。
- 23 项数据测试、11 项浏览器测试全部通过,包括安装包 9 个基础样例;TypeScript、五个本轮新文件的 Biome 检查通过。特殊布局测试覆盖实际坐标、连接线、改名、新增子主题、联系线保留、切换、撤销/重做和导出结构。
- 修复新增主题未自动获得键盘焦点的问题,以及括号图切回普通结构时深层连线残留的问题。
- 仍是基础实现:方向变体、垂直时间轴、树形表格、矩阵、子树混合结构、复杂联系线避障和原生视觉精确对照待补;原生应用打开导出文件仍未验证。真实账号保存重载也需另行验收。
## 2026-09-24 垂直时间轴与树形表格验收
- 新增垂直时间轴:根轴向下延伸,一级主题左右交替,整棵子树随所属方向镜像;新增主题、结构切换、撤销、工作表切换和 XMind 往返均有浏览器测试。
- 新增顶标题树形表格:中心主题跨列表头,层级按列排列,父主题纵向跨越全部后代行;表格单元格位于主题内容下层,不拦截选择和编辑。原生 `tree_table.xmind` 的主题配色、表头和网格视觉已用于对照。
- 新版导入和导出保留 `org.xmind.ui.timeline.vertical` 与 `org.xmind.ui.treetable.toptitle`;结构面板新增对应 SVG 图标。
- 25 项数据测试通过;13 项浏览器用例中新增树形表格用例,并再次通过安装包 9 个基础样例。TypeScript 和本轮文件 Biome 检查通过。浏览器预览测试改用 Webpack,避开 Next 16 Turbopack 在迁移后的中文目录中出现的包解析崩溃。
- 该阶段尚未完成侧标题树形表格和矩阵;后续进展见下文。时间轴方向变体、子树混合结构、复杂主题样式和原生应用打开导出文件的实机验证仍待补齐。
## 2026-09-24 侧标题树形表格与矩阵验收
- 新增侧标题树形表格:中心主题成为跨全部叶子行的左侧表头,后续层级按列展开,父主题跨越其后代行。导入、导出保留 `org.xmind.ui.treetable`。
- 新增矩阵:读取 `org.xmind.ui.spreadsheet` 根扩展中的列名,并按子主题 `labels` 放入对应单元格;同一单元格的多个主题纵向堆叠。矩阵列定义和主题标签可随 XMind 导出、再次导入。
- 结构面板增加两枚独立 SVG 图标;表格主题使用整格几何参与命中和画布适配。修复 8 位 XMind 色值、原生圆角主题边框残留和新增长文本裁切。
- 27 项数据测试、15 项浏览器测试和 TypeScript 检查通过;全量浏览器回归再次覆盖安装包的 9 个基础样例。另用安装包中的 `mood_tracker.xmind`、`coordinating_upcoming_release_1.0.xmind`、`tree_table.xmind`、`film_production.xmind` 完成主题完整性导入导出验证。
- 当前矩阵支持 XMind 的列扩展和标签映射;列增删/改名的专用交互、复杂嵌套子树、原生应用打开导出文件和逐像素多主题对照仍待实现。
## 2026-10-06 笔记与甘特图补齐
- 标记功能已先提交到本地 Git:`e6a68b34`。
- 富文本笔记使用现有 Slate 编辑能力,支持选中文字或继续输入时的格式、列表、缩进及安全链接;画布笔记提示可直接打开编辑。读取现代 `realHTML`、旧 `html.content.paragraphs` 与纯文本笔记,导出同时写入 `realHTML`/`plain` 和旧格式。格式依据已安装客户端 `6996.js` 的 Converter 核对。
- 任务写入原生 `org.xmind.ui.task` 扩展;日期使用本地日历日边界,进度存为 0–1,工期和依赖间隔存为毫秒。导入/导出重映射依赖主题 ID,保留其他原生扩展。
- 甘特图与画布共用主题数据及历史。可添加/编辑/移除任务、按前置关系排期、拒绝循环或父子依赖、拖动/拉伸时间条、按任务名称/负责人/进度筛选,支持汇总任务范围/进度、缩放、定位今天、CSV 导出和浏览器打印。移除任务时清理其他任务的引用,一次撤销恢复全部受影响任务。
- 本轮 9 项笔记/任务/导出数据检查、15 项浏览器检查(分批运行)、TypeScript 和相关文件 Biome 检查通过。浏览器覆盖富文本选区/列表/链接/撤销、任务日期/依赖/拖动/拉伸、校验/CSV/工作表隔离,以及原有图片粘贴、标记、框选、主题内容和导出。首次整批运行的空白画布导出用例超时,关闭追踪录制后单独复测通过;检查日志保留在本地验证目录。正式云账号保存重载没有在本轮验证。
- 已在安装的 XMind 26.04 中打开网页导出文件:笔记浮窗显示有序列表、加粗和蓝色链接,原生甘特图显示两条时间条、日期/工期/负责人和前置关系箭头。网页时间条改为客户端的橙色胶囊外观,显示任务名与负责人提示,选中行和时间条同步高亮。
- 修复笔记快捷键被画布拦截及快速选区格式不同步的问题;笔记、侧栏输入和任务窗口的键盘/剪贴板事件各自处理,编辑时保留当前主题。
- 当前是可用的富文本笔记和日级甘特图能力,未宣称与客户端全部功能或像素完全一致。高级工作日日历、小时/里程碑、原生全部导出格式、团队成员服务及笔记图片仍需继续补齐。

View File

@ -6,14 +6,25 @@
| 发现的问题 | 实际修复 | 验收方式 |
| --- | --- | --- |
| TVstar 关键帧与成片固定横屏 720P | 从需求传递画幅、清晰度与声音;不支持的 2K/4K 自动成片直接说明限制 | 参数与模型兼容性回归;待真实生成检查成片 |
| TVstar 关键帧与成片固定横屏 720P | 从需求传递画幅、清晰度与声音;不支持的 2K/4K 自动成片直接说明限制 | 参数与模型兼容性回归;真实竖屏成片规格已通过;视觉审查发现首镜头分屏,继续定向修复 |
| 离散生成时长可能短于剪辑所需时长 | 按最终分镜分配时长,选择不短于需求的生成规格;指定模型不满足要求时拒绝,不能静默换模型 | 18 秒/双镜头生成规格测试;保留小数总时长;超过 300 秒拒绝 |
| 高光匹配低分也会继续交付 | 分数低于 60、错序、遗漏、重复或越界区间直接停止;错误指明镜头;分数是模型判断,不能当作客观质量保证 | 独立选片合同验证;待真实模型和素材验收 |
| 六帧预览不能充分描述运动 | 已支持的官方千问视频模型读取真实视频(2帧/秒),结合相邻镜头预览选取连续区间;最多同时分析3段;供应商失败不自动换模型或重发 | 请求格式回归;待真实官方 API 验收 |
| 高光匹配低分也会继续交付 | 分数低于 60、错序、遗漏、重复或越界区间直接停止;错误指明镜头;分数是模型判断,不能当作客观质量保证 | 独立选片合同验证;真实官方模型拦截已知分屏以及不足3秒的无缺陷区间 |
| 六帧预览不能充分描述运动 | 已支持的官方千问视频模型读取真实视频(2帧/秒),结合相邻镜头预览选取连续区间;最多同时分析3段;供应商失败不自动换模型或重发 | 请求格式回归;真实官方视频选片已执行,保存了起止时间与画面依据;模型曾漏报分屏,不能把分数当作作品验收 |
| 最终合成服务不可用时仍先启动上游付费生成 | 创建任务前检查成片/高清/转写/分离所需服务;错误明确为未启动;前端清除提交重试记录 | 临时故障进程的真实 HTTP、鉴权、数据库零任务验收 |
| 新检查可能破坏任务幂等重查 | 检查在数据库排重后、新任务插入前执行,已有请求不受当前服务故障影响 | 保留原排重和原请求冲突检查 |
| 多个角色视图各自凭文本生成 | 第一张实际生成图作为身份基准;其他视图等待并引用它;多视图必须选择支持参考图的模型 | 真实画布依赖检查;人物一致性仍需视觉审查 |
| 无声剪辑仍可能保留素材音轨 | 无声约束写入实际剪辑方案,静音所有视频片段并移除背景音乐 | 剪辑方案回归;待成片声音检查 |
| 多个角色视图各自凭文本生成 | 第一张实际生成图作为身份基准;其他视图等待并引用它;多视图必须选择支持参考图的模型 | 真实画布依赖检查;真实立绘与近景均已生成,近景引用立绘;本轮人工检查人物、发型与服装一致,不等于身份自动验收 |
| 无声剪辑仍可能保留素材音轨 | 无声约束写入实际剪辑方案,静音所有视频片段并移除背景音乐 | 剪辑方案回归;真实成片音轨检测峰值与均值 -91 dB,静音通过 |
## 真实测试额外发现
1. **供应商视频规格漂移导致生产 HTTP 400。** 默认 MiniMax H3 的公开线路使用 `landscape/portrait`、`480p/768p/1080p`,存储配置却仍是 `9:16` 和 `720p`。已按公开合同校正指定模型、转换原生画幅、选择不低于最终输出的原生规格,并在任何上游付费任务插入前核验公开线路。旧规格请求的真实 HTTP 验收返回 503,数据库零任务。
2. **整片时长被“每个镜头3秒”覆盖。** 6秒双镜头需求曾被错误理解为3秒整片,已经修复自然语言时长解析。
3. **全能创作重复请求规划。** 初版实际等待116秒后才提交制作。当前全能创作一次生成结构化分镜和规划,真实浏览器验收观察到一次 `story-script` 请求,并即时显示用户消息;先确认模式保存工作流且没有生成任务。
4. **画布连线把整个脚本注入每张关键帧。** 首次真实成片虽可播放且规格正确,逐秒抽帧发现前三秒上下分屏;AI却给出92分。根因是脚本连接携带整片两个镜头,单帧生成消费了全部内容。已按镜头ID投影上游脚本、约束单镜头构图,并要求分析返回具体画面缺陷;报告缺陷时即使高分也拒绝合成。模型未报告的错误仍需独立审片,不能宣称自动视觉验收已经完善。
5. **角色分析自动模型遗漏。** 初次目录未加载时,角色分析走另一条鉴权失效的默认线路,而脚本分析会选已配置的官方千问。已统一角色分析默认选择,并在角色出图前恢复读取模型目录。
6. **供应商图片 HTTP 409 被错误标为失败。** 同一幂等键仍在处理的409,现在仅用原键进行有限重查;超出重查或传输不确定时保留中断/待核对状态,禁止切换线路或模型再次付费。真实旧请求无法取得可核对的供应商任务ID,未盲目重发;模拟接口集成测试验证同一键、无模型切换、503不确定状态保留。不能因此宣称该旧任务已找回。
7. **视频分析返回格式不稳定。** 真实素材审查曾返回不能解析的JSON并阻止合成。按官方合同启用标准JSON输出,并检查普通与流式返回的截断标记;截断即停止且不自动重发。
## 服务现状
@ -29,10 +40,35 @@
6. **交付规格与视觉验收。** 分辨率、时长、文件可读性可检查;真实品牌字样、商品细节、运动合理性与跨镜头主体一致性仍要通过真实输出审查。模型自评不是独立审片。
7. **账户及发布入口。** 部分支持、发布或账户入口仍未接入真实服务;不可将这些禁用入口列为生产功能。
本轮具体依据更新了此前的[功能对照记录](video-canvas-liblib-parity-audit-2026-09-24.md)中的待验证项;较早日期的状态不能直接作为当前生产验收结论。
## 本轮验收结果
- 视频画布与 TVstar 定向回归:179 项通过。
- 本轮完整 helper 回归:574 项通过;新增覆盖指定镜头消费、分屏缺陷与高分冲突、公开视频规格、自然语言时长等风险。
- TypeScript 检查通过。
- 故障注入、生产构建、上线、真实生成结果:在本轮后续执行,结果追加于此;未完成前不写“全部通过”。
- 真实服务故障注入、鉴权、数据库验收:单任务与整组 503,零任务;公开供应商拒绝旧视频规格前,图片任务也未启动。
- 生产构建通过;最终代码提交 `c4a2753a` 已在服务器快进更新并上线,构建号 `6MscLfc7pGW5cYMZZmyY-`。旧构建保留用于回滚,公网 `/ping` 为200。
- 两段实际视频生成、官方原生视频高光分析、FFmpeg合成、浏览器播放、刷新后云端对话与成片恢复通过。首轮成片 6.000 秒、720×1280、30 FPS、音轨峰值 -91 dB。生成与合成耗时约679秒。这只证明链路及文件规格,**首轮作品存在分屏,不算视觉合格**。
- 角色两张真实生成任务均成功,第二张依赖第一张实际图像;初版近景含全身和脸部插图,人工审查未通过。拆开人物身份与视图构图后,仅重做近景并复用立绘,已得到单一脸部近景,人物、发型、服装经人工检查一致。立绘脚部入画完整性仍需另行验收。
官方视频输入与采样依据:[阿里云 OpenAI 兼容 Chat 文档](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions)。原生视频模式仍按指定 FPS 采样,不意味着模型逐帧检查全部内容。
## 交付证据与限制
- 默认图片与视频模型已真实调用,费用不以本地测试推算;本轮没有核算项目完整费用账本。
- 分屏反向测试使用真正生成的视频,并由官方视频模型分析后阻止合成。另一次发现镜头2仅约2.46秒干净画面,不能满足连续3秒,也正确阻止了合成。
- 修复样片通过本站真实视频处理接口裁切首段、截取镜头2干净区间并减速两倍,随后重新选片合成。处理复用了两段已生成素材,没有再次生成视频。裁切首段只有378×672,再放大输出720×1280,不能称为原生720P细节质量。这个定向处理由本轮开发验收安排,尚不是TVstar完整自动返修能力。
- 角色新版近景视觉截图保存在本轮忽略的QA目录,旧拼贴近景不作合格样例。
- 生产首次真实生成到成片约679秒,规划去除一次重复调用改善了提交阶段;模型生成时间仍取决于供应商,不能保证所有请求秒出。
## Git 与发布方式
本轮所有代码修复都有Git提交,服务器通过Git bundle快进拉取相同提交。原origin仓库返回404,未更改或绕过远端鉴权;Git远端推送仍未成功。服务器预存的`next-env.d.ts`修改保留,构建自动更改的`tsconfig.json`已恢复,没有覆盖用户数据。
## 最终已上线复验
标准JSON输出版真实官方视频分析已成功,后续实际合成任务成功,未重新生成图片或视频。最终文件为[修复验收样片](https://scqny.jiansun.vip/uploads/1791021293810-hu1l9w.mp4):720×1280、30 FPS、6.022秒,与6秒目标的误差低于一帧。浏览器实际播放时间前进,媒体无错误;刷新后云端对话中的成片恢复并可播放。模型输出评分只作为选片依据,另做人工抽帧视觉检查。
验收脚本曾误将媒体时长要求写成浮点数严格等于6;这是测试断言问题,改为一帧容差后只读取原成功任务和作品,没有再次提交生成或分析。其他已发现的测试定位问题也未据此重复下单。
最终作品逐秒抽帧人工检查:首段笔尖墨水近景、后段桌面全景均为单一画幅,抽帧未见分屏/拼贴,暖色色调一致;音轨峰值和均值均为-91dB,无声验收通过。这个样片的两段视频由真实模型生成,定向裁切和截取减速由验收人员通过本站处理接口发起,随后AI实际选片和系统合成;不能将其写成“自然语言发起后全程自动返修成功”。首轮原始生成的分屏输出和中间分析失败如实保留。

View File

@ -0,0 +1,105 @@
"use client";
import { useEffect } from "react";
import { useCanvasStore } from "@/store/canvas";
import {
collectImageArchiveSources,
type ArchivedImageUrlMap,
} from "@/lib/storage/image-archive-urls";
function preload(url: string, signal: AbortSignal): Promise<boolean> {
return new Promise((resolve) => {
const image = new Image();
let done = false;
const finish = (loaded: boolean) => {
if (done) return;
done = true;
clearTimeout(timer);
signal.removeEventListener("abort", cancel);
image.onload = image.onerror = null;
resolve(loaded);
};
const cancel = () => finish(false);
const timer = setTimeout(cancel, 15_000);
signal.addEventListener("abort", cancel, { once: true });
image.onload = () => finish(true);
image.onerror = cancel;
if (signal.aborted) cancel();
else image.src = url;
});
}
export function useCanvasImageArchives(projectId: string) {
useEffect(() => {
const controller = new AbortController();
const checked = new Set<string>();
const unknownChecks = new Map<string, number>();
let timer: ReturnType<typeof setTimeout> | undefined;
let polling = false;
const poll = async () => {
if (polling || controller.signal.aborted || document.visibilityState === "hidden") return;
const state = useCanvasStore.getState();
if (state.projectId !== projectId) return;
const urls = collectImageArchiveSources(state.nodes)
.filter((url) => !checked.has(url))
.slice(0, 100);
if (!urls.length) return;
polling = true;
try {
const response = await fetch(
`/api/canvas/${encodeURIComponent(projectId)}/image-archives`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls }),
signal: controller.signal,
},
);
if (!response.ok) return;
const result = (await response.json()) as {
replacements: ArchivedImageUrlMap;
pending: string[];
};
const pending = new Set(result.pending);
const ready: ArchivedImageUrlMap = {};
await Promise.all(
urls.map(async (url) => {
const target = result.replacements[url];
if (target) {
if (await preload(target, controller.signal)) {
ready[url] = target;
checked.add(url);
checked.add(target);
}
} else if (!pending.has(url)) {
// A direct generation can reach the browser before its first autosave.
// Allow ownership records to catch up before classifying a URL as untracked.
const checks = (unknownChecks.get(url) || 0) + 1;
unknownChecks.set(url, checks);
if (checks >= 10) checked.add(url);
}
}),
);
if (
!controller.signal.aborted &&
useCanvasStore.getState().projectId === projectId &&
Object.keys(ready).length
) {
useCanvasStore.getState().replaceArchivedImageUrls(ready);
}
} catch {
// Temporary network errors keep provider previews and are retried on the next poll.
} finally {
polling = false;
}
};
const tick = () => {
void poll();
timer = setTimeout(tick, 3_000);
};
timer = setTimeout(tick, 1_000);
return () => {
controller.abort();
if (timer) clearTimeout(timer);
};
}, [projectId]);
}

View File

@ -357,14 +357,12 @@ async function uploadImageBuffer(buffer: Buffer, mimeType: string) {
}
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "atlascloud" });
if (immediatePreview) return immediatePreview;
if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl);
return uploadImageBuffer(parsed.buffer, parsed.mimeType);
}
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "atlascloud",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",

View File

@ -329,15 +329,13 @@ async function uploadImageBuffer(
}
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "bananaimage" });
if (immediatePreview) return immediatePreview;
if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl);
return uploadImageBuffer(parsed.buffer, parsed.mimeType);
}
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "bananaimage",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, {
cache: "no-store",

View File

@ -119,6 +119,9 @@ async function inferImageAspectRatioFromUrl(url: string): Promise<string | null>
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> {
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "gemini-image" });
if (deferred) return deferred;
try {
// 解析 base64 数据
const buffer = Buffer.from(base64Data, "base64");

View File

@ -62,6 +62,9 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
*/
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> {
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "huanke-gemini" });
if (deferred) return deferred;
try {
// 解析 base64 数据
const buffer = Buffer.from(base64Data, "base64");

View File

@ -430,6 +430,9 @@ async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage>
}
async function uploadBase64ToS3(base64Data: string, mimeType: string) {
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "openai-image" });
if (deferred) return deferred;
const buffer = Buffer.from(base64Data, "base64");
const extension = detectImageExtension("", mimeType);
return uploadGeneratedBuffer(

View File

@ -665,6 +665,9 @@ function generateFilename(prefix: string, extension: string) {
}
async function uploadImageBuffer(buffer: Buffer, extension: string): Promise<UploadedImage> {
const mime = extension === "jpg" || extension === "jpeg" ? "image/jpeg" : `image/${extension}`;
const deferred = await createDeferredUploadedImage(`data:${mime};base64,${buffer.toString("base64")}`, { providerType: "snapgen-image" });
if (deferred) return deferred;
const thumbnailBuffer = await createThumbnail(buffer);
const originalFilename = generateFilename("snapgen-image-orig", extension);

View File

@ -177,6 +177,9 @@ async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage>
}
async function uploadBase64ToS3(base64Data: string, mimeType: string) {
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "stepfun-image" });
if (deferred) return deferred;
const buffer = Buffer.from(base64Data, "base64");
const extension = detectImageExtension("", mimeType);
return uploadGeneratedBuffer(

View File

@ -511,14 +511,12 @@ async function uploadBuffer(buffer: Buffer, mimeType: string) {
}
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "vip98k" });
if (immediatePreview) return immediatePreview;
if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl);
return uploadBuffer(parsed.buffer, parsed.mimeType);
}
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "vip98k",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, {
cache: "no-store",
redirect: "follow",

View File

@ -269,15 +269,13 @@ async function uploadImageBuffer(
}
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "visionary" });
if (immediatePreview) return immediatePreview;
if (imageUrl.startsWith("data:")) {
const parsed = parseDataUrl(imageUrl);
return uploadImageBuffer(parsed.buffer, parsed.mimeType);
}
const deferred = await createDeferredUploadedImage(imageUrl, {
providerType: "visionary",
});
if (deferred) return deferred;
const response = await fetch(imageUrl, {
cache: "no-store",

View File

@ -428,9 +428,9 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer
/**
* 将图片 URL 或 base64 上传到 S3,返回缩略图和原图的 URL
*/
async function uploadImageToS3(imageData: string): Promise<{ thumbnail: string; original: string }> {
async function uploadImageToS3(imageData: string, deferArchive = true): Promise<{ thumbnail: string; original: string }> {
try {
if (/^https?:\/\//i.test(imageData)) {
if (deferArchive && /^(?:https?:\/\/|data:image\/)/i.test(imageData)) {
const deferred = await createDeferredUploadedImage(imageData, {
providerType: "volcano-vision",
});
@ -653,7 +653,7 @@ async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt
if (imageUrl.startsWith('data:image')) {
// Base64格式:需要先上传到S3获取URL
logger.debug("[Volcano Vision] Found Base64 image, uploading to S3...");
const uploaded = await uploadImageToS3(imageUrl);
const uploaded = await uploadImageToS3(imageUrl, false);
const rewritten = await rewriteUploadPublicUrlForVolcano(uploaded.original);
processedImages.push(rewritten);
} else {

View File

@ -1,3 +1,4 @@
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema";
import {
@ -122,6 +123,7 @@ export async function saveGeneratedImages(
savedOriginalUrls: string[];
}> {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
imageMarkdown = await resolveArchivedImageUrls(imageMarkdown);
const matches = [...imageMarkdown.matchAll(imageRegex)];
let savedImageCount = 0;

View File

@ -0,0 +1,74 @@
"use client";
import { useSyncExternalStore } from "react";
export const IMAGE_TOOLBAR_STORAGE_KEY = "canvas.image-toolbar.v1";
const CHANGE_EVENT = "canvas-image-toolbar-preferences";
export type ImageToolbarPreferences = {
pinnedKeys: string[];
showNames: boolean;
};
export function parseImageToolbarPreferences(
raw: string | null
): ImageToolbarPreferences | null {
if (!raw) return null;
try {
const value = JSON.parse(raw);
if (
!value ||
!Array.isArray(value.pinnedKeys) ||
typeof value.showNames !== "boolean"
)
return null;
return {
pinnedKeys: [
...new Set<string>(
value.pinnedKeys.filter(
(key: unknown) => typeof key === "string" && key.length < 80
)
),
],
showNames: value.showNames,
};
} catch {
return null;
}
}
function snapshot() {
try {
return window.localStorage.getItem(IMAGE_TOOLBAR_STORAGE_KEY);
} catch {
return null;
}
}
function subscribe(listener: () => void) {
const onStorage = (event: StorageEvent) => {
if (!event.key || event.key === IMAGE_TOOLBAR_STORAGE_KEY) listener();
};
window.addEventListener("storage", onStorage);
window.addEventListener(CHANGE_EVENT, listener);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(CHANGE_EVENT, listener);
};
}
export function useImageToolbarPreferences() {
const raw = useSyncExternalStore(subscribe, snapshot, () => null);
return parseImageToolbarPreferences(raw);
}
export function saveImageToolbarPreferences(
preferences: ImageToolbarPreferences
) {
try {
window.localStorage.setItem(
IMAGE_TOOLBAR_STORAGE_KEY,
JSON.stringify(preferences)
);
window.dispatchEvent(new Event(CHANGE_EVENT));
return true;
} catch {
return false;
}
}

View File

@ -1,192 +1,40 @@
import "server-only";
import { getSystemSetting } from "@/lib/db/queries";
import {
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
type CanvasImageTaskQueueConfig,
normalizeCanvasImageTaskQueueConfig,
} from "./image-task-config";
import { normalizeCanvasImageTaskQueueConfig } from "./image-task-config";
const CONCURRENCY_CONFIG_CACHE_MS = 5 * 1000;
const RECENT_EXECUTION_SAMPLE_LIMIT = 24;
const DEFAULT_ESTIMATED_WAIT_MS = 45 * 1000;
const concurrencyStateKey = "__canvas_image_model_concurrency__";
interface ModelWaiter {
ticketId: string;
ownerKey: string;
resolve: () => void;
}
interface ModelConcurrencyState {
running: Map<string, string>;
waiters: ModelWaiter[];
dispatchTimer: ReturnType<typeof setTimeout> | null;
delayedUntil: number | null;
recentExecutionDurationsMs: number[];
}
interface CanvasImageConcurrencyState {
models: Map<string, ModelConcurrencyState>;
}
let cachedConfig:
| {
expiresAt: number;
value: CanvasImageTaskQueueConfig;
}
| null = null;
function getGlobalConcurrencyState(): CanvasImageConcurrencyState {
const stateKey = "__unrestricted_canvas_image_models__";
type ModelState = { running: Set<string>; durations: number[] };
function getModels() {
const globalState = globalThis as typeof globalThis & {
[concurrencyStateKey]?: CanvasImageConcurrencyState;
[stateKey]?: Map<string, ModelState>;
};
if (!globalState[concurrencyStateKey]) {
globalState[concurrencyStateKey] = {
models: new Map<string, ModelConcurrencyState>(),
};
}
return globalState[concurrencyStateKey] as CanvasImageConcurrencyState;
return (globalState[stateKey] ??= new Map());
}
function getOrCreateModelState(modelKey: string) {
const state = getGlobalConcurrencyState();
const existing = state.models.get(modelKey);
if (existing) {
return existing;
function getModel(key: string) {
const models = getModels();
const normalized = normalizeCanvasImageModelKey(key);
let state = models.get(normalized);
if (!state) {
state = { running: new Set(), durations: [] };
models.set(normalized, state);
}
const created: ModelConcurrencyState = {
running: new Map<string, string>(),
waiters: [],
dispatchTimer: null,
delayedUntil: null,
recentExecutionDurationsMs: [],
};
state.models.set(modelKey, created);
return created;
return state;
}
function clearModelDispatchTimer(modelState: ModelConcurrencyState) {
if (modelState.dispatchTimer) {
clearTimeout(modelState.dispatchTimer);
modelState.dispatchTimer = null;
}
modelState.delayedUntil = null;
}
function cleanupModelState(modelKey: string) {
const state = getGlobalConcurrencyState();
const modelState = state.models.get(modelKey);
if (!modelState) {
return;
}
if (
modelState.running.size === 0 &&
modelState.waiters.length === 0 &&
modelState.recentExecutionDurationsMs.length === 0
) {
clearModelDispatchTimer(modelState);
state.models.delete(modelKey);
}
}
export function normalizeCanvasImageModelKey(value: unknown) {
if (typeof value !== "string") {
return "__default__";
}
const normalized = value.trim();
return normalized || "__default__";
return typeof value === "string" && value.trim() ? value.trim() : "__default__";
}
function normalizeCanvasImageOwnerKey(value: unknown) {
if (typeof value !== "string") {
return "__anonymous__";
}
const normalized = value.trim();
return normalized || "__anonymous__";
export async function getCanvasImageTaskQueueConfig() {
return normalizeCanvasImageTaskQueueConfig(undefined);
}
function getMaxConcurrentPerOwner(maxConcurrent: number) {
return Math.max(1, Math.ceil(Math.max(1, maxConcurrent) / 2));
export async function getCanvasImageSingleModelConcurrency() {
return 0;
}
function getRunningCountForOwner(
modelState: ModelConcurrencyState,
ownerKey: string
) {
let count = 0;
for (const runningOwnerKey of modelState.running.values()) {
if (runningOwnerKey === ownerKey) {
count += 1;
}
}
return count;
export async function getCanvasImageBatchDispatchIntervalMs() {
return 0;
}
export async function getCanvasImageTaskQueueConfig(): Promise<CanvasImageTaskQueueConfig> {
const now = Date.now();
if (cachedConfig && cachedConfig.expiresAt > now) {
return cachedConfig.value;
}
const envConcurrency = process.env.CANVAS_IMAGE_TASK_CONCURRENCY;
const envBatchDispatchIntervalMs = process.env.CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS;
let config = normalizeCanvasImageTaskQueueConfig(undefined, {
envConcurrency,
envBatchDispatchIntervalMs,
});
try {
const setting = await getSystemSetting({
key: CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
});
if (setting?.value !== undefined && setting.value !== null) {
config = normalizeCanvasImageTaskQueueConfig(setting.value, {
envConcurrency,
envBatchDispatchIntervalMs,
});
}
} catch (error) {
console.warn("[Canvas Image Concurrency] Failed to load DB config, using fallback", {
error: error instanceof Error ? error.message : String(error),
});
}
cachedConfig = {
expiresAt: now + CONCURRENCY_CONFIG_CACHE_MS,
value: {
concurrency: config.concurrency || DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
batchDispatchIntervalMs: Number.isFinite(config.batchDispatchIntervalMs)
? config.batchDispatchIntervalMs
: DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
},
};
return cachedConfig.value;
}
export async function getCanvasImageSingleModelConcurrency(): Promise<number> {
return (await getCanvasImageTaskQueueConfig()).concurrency;
}
export async function getCanvasImageBatchDispatchIntervalMs(): Promise<number> {
return (await getCanvasImageTaskQueueConfig()).batchDispatchIntervalMs;
}
export function getCanvasImageRunningCountForModel(modelKey: string) {
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
return getGlobalConcurrencyState().models.get(normalizedModelKey)?.running.size || 0;
return getModels().get(normalizeCanvasImageModelKey(modelKey))?.running.size || 0;
}
export type CanvasImageModelQueueStatus = {
status: "pending" | "running" | "succeeded" | "failed";
queuePosition: number;
@ -197,143 +45,27 @@ export type CanvasImageModelQueueStatus = {
source: "model_queue";
};
function getRecentAverageExecutionMs(modelState: ModelConcurrencyState) {
const samples = modelState.recentExecutionDurationsMs.filter(
(value) => Number.isFinite(value) && value > 0
);
if (samples.length === 0) {
return DEFAULT_ESTIMATED_WAIT_MS;
}
const total = samples.reduce((sum, value) => sum + value, 0);
return Math.max(1000, Math.round(total / samples.length));
}
function buildModelQueueSnapshot(params: {
modelState: ModelConcurrencyState;
queuePosition: number;
sameModelPendingCount: number;
sameModelRunningCount: number;
concurrencyLimit: number;
}): CanvasImageModelQueueStatus {
const averageExecutionMs = getRecentAverageExecutionMs(params.modelState);
const estimatedWaitMs =
params.sameModelPendingCount <= 0
? 0
: Math.max(
averageExecutionMs,
Math.ceil((params.queuePosition + 1) / Math.max(1, params.concurrencyLimit)) *
averageExecutionMs
);
return {
status: "pending",
queuePosition: Math.max(0, params.queuePosition),
sameModelPendingCount: Math.max(0, params.sameModelPendingCount),
sameModelRunningCount: Math.max(0, params.sameModelRunningCount),
concurrencyLimit: Math.max(1, params.concurrencyLimit),
estimatedWaitMs,
source: "model_queue",
};
}
export async function getCanvasImageModelQueueStatus(params: {
modelKey: string;
}): Promise<CanvasImageModelQueueStatus> {
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey);
const { concurrency: maxConcurrent } = await getCanvasImageTaskQueueConfig();
const modelState =
getGlobalConcurrencyState().models.get(normalizedModelKey) || {
running: new Map<string, string>(),
waiters: [],
dispatchTimer: null,
delayedUntil: null,
recentExecutionDurationsMs: [],
};
return buildModelQueueSnapshot({
modelState,
queuePosition: modelState.waiters.length > 0 ? modelState.waiters.length - 1 : 0,
sameModelPendingCount: modelState.waiters.length,
sameModelRunningCount: modelState.running.size,
concurrencyLimit: maxConcurrent,
});
}
export function recordCanvasImageModelExecutionDuration(
modelKey: string,
durationMs: number
) {
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return;
}
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
const modelState = getOrCreateModelState(normalizedModelKey);
modelState.recentExecutionDurationsMs.push(durationMs);
if (modelState.recentExecutionDurationsMs.length > RECENT_EXECUTION_SAMPLE_LIMIT) {
modelState.recentExecutionDurationsMs.splice(
0,
modelState.recentExecutionDurationsMs.length - RECENT_EXECUTION_SAMPLE_LIMIT
);
}
}
async function drainModelWaiters(
modelKey: string,
trigger: "arrival" | "slot-release" | "timer"
) {
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
const modelState = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!modelState || modelState.waiters.length === 0) {
cleanupModelState(normalizedModelKey);
return;
}
const { concurrency: maxConcurrent, batchDispatchIntervalMs } =
await getCanvasImageTaskQueueConfig();
if (modelState.running.size >= maxConcurrent) {
return;
}
const now = Date.now();
if (modelState.delayedUntil && modelState.delayedUntil > now) {
return;
}
if (trigger === "slot-release" && batchDispatchIntervalMs > 0) {
if (!modelState.dispatchTimer) {
modelState.delayedUntil = now + batchDispatchIntervalMs;
modelState.dispatchTimer = setTimeout(() => {
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!state) return;
clearModelDispatchTimer(state);
void drainModelWaiters(normalizedModelKey, "timer");
}, batchDispatchIntervalMs);
}
return;
}
clearModelDispatchTimer(modelState);
while (modelState.running.size < maxConcurrent && modelState.waiters.length > 0) {
const maxConcurrentPerOwner = getMaxConcurrentPerOwner(maxConcurrent);
const waiterIndex = modelState.waiters.findIndex(
(item) =>
getRunningCountForOwner(modelState, item.ownerKey) < maxConcurrentPerOwner
);
if (waiterIndex < 0) {
break;
}
const [waiter] = modelState.waiters.splice(waiterIndex, 1);
if (!waiter) {
break;
}
modelState.running.set(waiter.ticketId, waiter.ownerKey);
waiter.resolve();
}
cleanupModelState(normalizedModelKey);
return {
status: "running",
queuePosition: 0,
sameModelPendingCount: 0,
sameModelRunningCount: getCanvasImageRunningCountForModel(params.modelKey),
concurrencyLimit: 0,
estimatedWaitMs: 0,
source: "model_queue",
};
}
export function recordCanvasImageModelExecutionDuration(modelKey: string, durationMs: number) {
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
const state = getModel(modelKey);
state.durations.push(durationMs);
if (state.durations.length > 24) state.durations.shift();
}
// Keep execution tracking/release semantics, without model or owner admission limits.
export async function acquireCanvasImageModelSlot(params: {
modelKey: string;
ownerKey?: string;
@ -341,86 +73,15 @@ export async function acquireCanvasImageModelSlot(params: {
debugMeta?: Record<string, unknown>;
onQueued?: (status: CanvasImageModelQueueStatus) => void | Promise<void>;
}) {
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey);
const normalizedOwnerKey = normalizeCanvasImageOwnerKey(params.ownerKey);
const {
concurrency: maxConcurrent,
batchDispatchIntervalMs,
} = await getCanvasImageTaskQueueConfig();
const modelState = getOrCreateModelState(normalizedModelKey);
const ticketId = `${normalizedModelKey}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`;
const waitingSince = Date.now();
const hasActiveDelay =
modelState.delayedUntil !== null && modelState.delayedUntil > waitingSince;
const maxConcurrentPerOwner = getMaxConcurrentPerOwner(maxConcurrent);
if (
modelState.waiters.length === 0 &&
!hasActiveDelay &&
modelState.running.size < maxConcurrent &&
getRunningCountForOwner(modelState, normalizedOwnerKey) < maxConcurrentPerOwner
) {
modelState.running.set(ticketId, normalizedOwnerKey);
return {
release: async () => {
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!state) return;
state.running.delete(ticketId);
await drainModelWaiters(normalizedModelKey, "slot-release");
},
waitMs: 0,
maxConcurrent,
batchDispatchIntervalMs,
};
}
await new Promise<void>((resolve) => {
const queuePosition = modelState.waiters.length;
modelState.waiters.push({
ticketId,
ownerKey: normalizedOwnerKey,
resolve,
});
if (typeof params.onQueued === "function") {
const queueStatus = buildModelQueueSnapshot({
modelState,
queuePosition,
sameModelPendingCount: modelState.waiters.length,
sameModelRunningCount: modelState.running.size,
concurrencyLimit: maxConcurrent,
});
Promise.resolve(params.onQueued(queueStatus)).catch((error) => {
console.warn("[Canvas Image Concurrency] onQueued callback failed:", {
debugLabel: params.debugLabel || "canvas-image",
modelKey: normalizedModelKey,
error: error instanceof Error ? error.message : String(error),
...params.debugMeta,
});
});
}
void drainModelWaiters(normalizedModelKey, "arrival");
});
const waitMs = Date.now() - waitingSince;
if (waitMs > 250) {
console.info("[Canvas Image Concurrency] Waited for model slot:", {
debugLabel: params.debugLabel || "canvas-image",
modelKey: normalizedModelKey,
waitMs,
batchDispatchIntervalMs,
...params.debugMeta,
});
}
const state = getModel(params.modelKey);
const ticket = crypto.randomUUID();
state.running.add(ticket);
return {
release: async () => {
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!state) return;
state.running.delete(ticketId);
await drainModelWaiters(normalizedModelKey, "slot-release");
state.running.delete(ticket);
},
waitMs,
maxConcurrent,
batchDispatchIntervalMs,
waitMs: 0,
maxConcurrent: 0,
batchDispatchIntervalMs: 0,
};
}

View File

@ -1,130 +1,21 @@
export const CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY = "canvas_image_task_queue_config";
export const MIN_CANVAS_IMAGE_TASK_CONCURRENCY = 1;
export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 3;
export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 100;
// Zero means unrestricted. Legacy DB/environment caps must not re-enable queueing.
export const MIN_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
export const MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
export const DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
export const MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 60 * 1000;
export const MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
export interface CanvasImageTaskQueueConfig {
concurrency: number;
batchDispatchIntervalMs: number;
}
function clampConcurrency(value: number) {
return Math.min(
MAX_CANVAS_IMAGE_TASK_CONCURRENCY,
Math.max(MIN_CANVAS_IMAGE_TASK_CONCURRENCY, Math.floor(value))
);
}
function clampBatchDispatchIntervalMs(value: number) {
return Math.min(
MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
Math.max(MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS, Math.floor(value))
);
}
function parseConcurrencyCandidate(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return clampConcurrency(value);
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value.trim(), 10);
if (Number.isFinite(parsed)) {
return clampConcurrency(parsed);
}
}
return null;
}
function parseBatchDispatchIntervalCandidate(
value: unknown,
options?: { treatAsSeconds?: boolean }
): number | null {
const normalize = (candidate: number) =>
clampBatchDispatchIntervalMs(
options?.treatAsSeconds ? candidate * 1000 : candidate
);
if (typeof value === "number" && Number.isFinite(value)) {
return normalize(value);
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseFloat(value.trim());
if (Number.isFinite(parsed)) {
return normalize(parsed);
}
}
return null;
}
export function normalizeCanvasImageTaskQueueConfig(
value: unknown,
options?: { envConcurrency?: unknown; envBatchDispatchIntervalMs?: unknown }
_value: unknown,
_options?: { envConcurrency?: unknown; envBatchDispatchIntervalMs?: unknown },
): CanvasImageTaskQueueConfig {
const raw = value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const concurrencyCandidates = [
raw.concurrency,
raw.maxConcurrency,
raw.maxConcurrent,
typeof value === "number" || typeof value === "string" ? value : undefined,
options?.envConcurrency,
];
let concurrency = DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY;
for (const candidate of concurrencyCandidates) {
const parsed = parseConcurrencyCandidate(candidate);
if (parsed !== null) {
concurrency = parsed;
break;
}
}
const intervalCandidates = [
raw.batchDispatchIntervalMs,
raw.dispatchIntervalMs,
raw.batchDispatchDelayMs,
raw.batchDispatchInterval,
raw.dispatchInterval,
options?.envBatchDispatchIntervalMs,
];
const intervalSecondsCandidates = [
raw.batchDispatchIntervalSeconds,
raw.dispatchIntervalSeconds,
];
let batchDispatchIntervalMs = DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS;
for (const candidate of intervalCandidates) {
const parsed = parseBatchDispatchIntervalCandidate(candidate);
if (parsed !== null) {
batchDispatchIntervalMs = parsed;
break;
}
}
if (batchDispatchIntervalMs === DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS) {
for (const candidate of intervalSecondsCandidates) {
const parsed = parseBatchDispatchIntervalCandidate(candidate, {
treatAsSeconds: true,
});
if (parsed !== null) {
batchDispatchIntervalMs = parsed;
break;
}
}
}
return {
concurrency,
batchDispatchIntervalMs,
};
return { concurrency: 0, batchDispatchIntervalMs: 0 };
}

View File

@ -1,10 +1,10 @@
import "server-only";
import { and, asc, desc, eq, lt, notInArray, or, sql } from "drizzle-orm";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import { and, asc, eq, lt, notInArray, or, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasImageTask } from "@/lib/db/schema";
import {
getCanvasImageBatchDispatchIntervalMs,
getCanvasImageRunningCountForModel,
getCanvasImageSingleModelConcurrency,
normalizeCanvasImageModelKey,
@ -78,10 +78,6 @@ interface QueueState {
pendingSet: Set<string>;
running: Map<string, RunningTaskEntry>;
runningByModel: Map<string, Set<string>>;
runningByModelUser: Map<string, Set<string>>;
nextBatchEligibleAt: Map<string, number>;
nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>;
awaitingNextBatch: Set<string>;
lastStaleRunningCleanupAt: number;
draining: boolean;
}
@ -91,9 +87,6 @@ const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 12 * 60 * 1000;
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000;
const PENDING_LOOKAHEAD_LIMIT = 100;
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
const DEFAULT_ESTIMATED_WAIT_MS = 45 * 1000;
const queueStateKey = "__canvas_image_task_queue_state__";
function getQueueState(): QueueState {
@ -106,10 +99,6 @@ function getQueueState(): QueueState {
pendingSet: new Set<string>(),
running: new Map<string, RunningTaskEntry>(),
runningByModel: new Map<string, Set<string>>(),
runningByModelUser: new Map<string, Set<string>>(),
nextBatchEligibleAt: new Map<string, number>(),
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
awaitingNextBatch: new Set<string>(),
lastStaleRunningCleanupAt: 0,
draining: false,
};
@ -193,14 +182,6 @@ function isStyleProTaskRequest(request: unknown): request is StyleProTaskRequest
);
}
function getModelUserKey(modelKey: string, userId: string) {
return `${modelKey}::${userId || "__anonymous__"}`;
}
function getMaxConcurrentPerUser(maxConcurrentPerModel: number) {
return Math.max(1, Math.ceil(Math.max(1, maxConcurrentPerModel) / 2));
}
async function getTaskQueueMeta(taskId: string) {
const rows = await db
.select({
@ -218,27 +199,12 @@ async function getTaskQueueMeta(taskId: string) {
};
}
function getRunningCountForModel(modelKey: string) {
const state = getQueueState();
return state.runningByModel.get(modelKey)?.size || 0;
}
function getRunningCountForModelUser(modelKey: string, userId: string) {
const state = getQueueState();
return state.runningByModelUser.get(getModelUserKey(modelKey, userId))?.size || 0;
}
function markRunningTask(taskId: string, modelKey: string, userId: string) {
const state = getQueueState();
state.running.set(taskId, { modelKey, userId });
const tasks = state.runningByModel.get(modelKey) || new Set<string>();
tasks.add(taskId);
state.runningByModel.set(modelKey, tasks);
const modelUserKey = getModelUserKey(modelKey, userId);
const userTasks = state.runningByModelUser.get(modelUserKey) || new Set<string>();
userTasks.add(taskId);
state.runningByModelUser.set(modelUserKey, userTasks);
}
function releaseRunningTask(taskId: string) {
@ -249,7 +215,7 @@ function releaseRunningTask(taskId: string) {
}
state.running.delete(taskId);
const { modelKey, userId } = entry;
const { modelKey } = entry;
const tasks = state.runningByModel.get(modelKey);
if (tasks) {
tasks.delete(taskId);
@ -258,65 +224,9 @@ function releaseRunningTask(taskId: string) {
}
}
const modelUserKey = getModelUserKey(modelKey, userId);
const userTasks = state.runningByModelUser.get(modelUserKey);
if (userTasks) {
userTasks.delete(taskId);
if (userTasks.size === 0) {
state.runningByModelUser.delete(modelUserKey);
}
}
return modelKey;
}
function isModelBatchDispatchDelayed(modelKey: string) {
const state = getQueueState();
const delayedUntil = state.nextBatchEligibleAt.get(modelKey);
if (!delayedUntil) {
return false;
}
if (delayedUntil <= Date.now()) {
state.nextBatchEligibleAt.delete(modelKey);
const timer = state.nextBatchTimers.get(modelKey);
if (timer) {
clearTimeout(timer);
state.nextBatchTimers.delete(modelKey);
}
return false;
}
return true;
}
function markModelAwaitingNextBatch(modelKey: string) {
getQueueState().awaitingNextBatch.add(modelKey);
}
function scheduleModelNextBatch(modelKey: string, delayMs: number) {
const state = getQueueState();
if (delayMs <= 0) {
state.awaitingNextBatch.delete(modelKey);
return false;
}
if (state.nextBatchTimers.has(modelKey)) {
return true;
}
state.awaitingNextBatch.delete(modelKey);
state.nextBatchEligibleAt.set(modelKey, Date.now() + delayMs);
const timer = setTimeout(() => {
const latestState = getQueueState();
latestState.nextBatchTimers.delete(modelKey);
latestState.nextBatchEligibleAt.delete(modelKey);
void drainCanvasImageTaskQueue();
}, delayMs);
state.nextBatchTimers.set(modelKey, timer);
return true;
}
async function enqueueLocalTask(
taskId: string,
meta?: { modelKey?: string; userId?: string } | string
@ -343,42 +253,14 @@ async function enqueueLocalTask(
});
state.pendingSet.add(taskId);
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
if (
getRunningCountForModel(resolvedModelKey) >= maxConcurrentPerModel ||
getRunningCountForModelUser(resolvedModelKey, resolvedUserId) >=
getMaxConcurrentPerUser(maxConcurrentPerModel)
) {
markModelAwaitingNextBatch(resolvedModelKey);
}
}
function hasModelCapacity(
modelKey: string,
userId: string,
maxConcurrentPerModel: number
) {
return (
getRunningCountForModel(modelKey) < maxConcurrentPerModel &&
getRunningCountForModelUser(modelKey, userId) <
getMaxConcurrentPerUser(maxConcurrentPerModel)
);
}
async function claimNextPendingTask(
maxConcurrentPerModel: number
): Promise<PendingTaskEntry | null> {
async function claimNextPendingTask(): Promise<PendingTaskEntry | null> {
const state = getQueueState();
for (let index = 0; index < state.pending.length; index += 1) {
const entry = state.pending[index];
if (!entry) continue;
if (state.running.has(entry.taskId)) continue;
if (isModelBatchDispatchDelayed(entry.modelKey)) continue;
if (!hasModelCapacity(entry.modelKey, entry.userId, maxConcurrentPerModel)) {
markModelAwaitingNextBatch(entry.modelKey);
continue;
}
state.pending.splice(index, 1);
state.pendingSet.delete(entry.taskId);
return entry;
@ -405,15 +287,7 @@ async function claimNextPendingTask(
if (state.pendingSet.has(row.id) || state.running.has(row.id)) continue;
const modelKey = normalizeTaskModelKey(row.request);
if (isModelBatchDispatchDelayed(modelKey)) {
continue;
}
const userId = String(row.userId || "__anonymous__");
if (!hasModelCapacity(modelKey, userId, maxConcurrentPerModel)) {
markModelAwaitingNextBatch(modelKey);
continue;
}
return {
taskId: row.id,
modelKey,
@ -585,7 +459,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
.update(canvasImageTask)
.set({
status: "succeeded",
result: result as CanvasImageTaskResult,
result: await resolveArchivedImageUrls(result) as CanvasImageTaskResult,
error: null,
completedAt: new Date(),
updatedAt: new Date(),
@ -611,7 +485,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
);
} finally {
const modelKey = releaseRunningTask(entry.taskId);
releaseRunningTask(entry.taskId);
let nextBatchTaskId: string | null = null;
if (completedBatchRequest?.batchId) {
try {
@ -628,12 +502,6 @@ async function runSingleTask(entry: PendingTaskEntry) {
});
}
}
if (modelKey) {
const batchDispatchIntervalMs = await getCanvasImageBatchDispatchIntervalMs();
if (getQueueState().awaitingNextBatch.has(modelKey)) {
scheduleModelNextBatch(modelKey, batchDispatchIntervalMs);
}
}
if (nextBatchTaskId) {
await enqueueLocalTask(nextBatchTaskId);
}
@ -648,9 +516,8 @@ export async function drainCanvasImageTaskQueue() {
state.draining = true;
try {
await markStaleDatabaseRunningTasksFailed();
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
while (true) {
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);
const nextTask = await claimNextPendingTask();
if (!nextTask) break;
if (state.running.has(nextTask.taskId)) continue;
markRunningTask(nextTask.taskId, nextTask.modelKey, nextTask.userId);
@ -658,6 +525,11 @@ export async function drainCanvasImageTaskQueue() {
}
} finally {
state.draining = false;
// An arrival can occur while the final database scan is in flight. Dispatch it
// immediately rather than relying on a later status poll to wake the queue.
if (state.pending.some((entry) => !state.running.has(entry.taskId))) {
void drainCanvasImageTaskQueue();
}
}
}
@ -811,58 +683,6 @@ export async function createCanvasImageTask(params: {
return existing;
}
async function estimateModelQueueWaitMs(params: {
modelKey: string;
sameModelRunningCount: number;
queuePosition: number;
concurrencyLimit: number;
}) {
const rows = await db
.select({
request: canvasImageTask.request,
startedAt: canvasImageTask.startedAt,
completedAt: canvasImageTask.completedAt,
})
.from(canvasImageTask)
.where(eq(canvasImageTask.status, "succeeded"))
.orderBy(desc(canvasImageTask.completedAt))
.limit(QUEUE_STATUS_RECENT_SAMPLE_LIMIT);
const durations = rows
.filter((row) => normalizeTaskModelKey(row.request) === params.modelKey)
.map((row) => {
if (!row.startedAt || !row.completedAt) return 0;
return row.completedAt.getTime() - row.startedAt.getTime();
})
.filter((value) => Number.isFinite(value) && value > 1000)
.slice(0, 12);
const averageDurationMs =
durations.length > 0
? Math.round(durations.reduce((sum, value) => sum + value, 0) / durations.length)
: DEFAULT_ESTIMATED_WAIT_MS;
const immediatelyStartable = Math.max(
params.concurrencyLimit - params.sameModelRunningCount,
0
);
if (params.queuePosition < immediatelyStartable) {
return 0;
}
const tasksAheadAfterImmediateSlots = Math.max(
0,
params.queuePosition - immediatelyStartable
);
const waveCount =
Math.floor(
tasksAheadAfterImmediateSlots / Math.max(1, params.concurrencyLimit)
) +
(params.sameModelRunningCount > 0 || immediatelyStartable === 0 ? 1 : 0);
return Math.max(0, waveCount * averageDurationMs);
}
async function buildCanvasImageTaskQueueStatus(task: {
id: string;
status: CanvasImageTaskStatus;
@ -882,51 +702,13 @@ async function buildCanvasImageTaskQueueStatus(task: {
getCanvasImageRunningCountForModel(modelKey)
);
if (task.status !== "pending") {
return {
status: task.status,
queuePosition: 0,
sameModelPendingCount: 0,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs: 0,
source: "model_queue" as const,
};
}
const pendingRows = await db
.select({
id: canvasImageTask.id,
request: canvasImageTask.request,
createdAt: canvasImageTask.createdAt,
})
.from(canvasImageTask)
.where(eq(canvasImageTask.status, "pending"))
.orderBy(asc(canvasImageTask.createdAt))
.limit(QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT);
const sameModelPendingRows = pendingRows.filter(
(row) => normalizeTaskModelKey(row.request) === modelKey
);
const queuePosition = Math.max(
sameModelPendingRows.findIndex((row) => row.id === task.id),
0
);
const sameModelPendingCount = sameModelPendingRows.length;
const estimatedWaitMs = await estimateModelQueueWaitMs({
modelKey,
sameModelRunningCount,
queuePosition,
concurrencyLimit,
});
return {
status: task.status,
queuePosition,
sameModelPendingCount,
queuePosition: 0,
sameModelPendingCount: 0,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs,
estimatedWaitMs: 0,
source: "model_queue" as const,
};
}

View File

@ -0,0 +1,41 @@
import type { CanvasNode, Viewport } from "@/types/canvas";
export function getCanvasFitViewport(
nodes: Pick<CanvasNode, "position" | "size">[],
container: { width: number; height: number },
occlusionRight = 0
): Viewport | null {
const visibleWidth = container.width - Math.max(0, occlusionRight);
if (visibleWidth <= 0 || container.height <= 0) return null;
const validNodes = nodes.filter(
(node) =>
[
node.position.x,
node.position.y,
node.size.width,
node.size.height,
].every(Number.isFinite) &&
node.size.width > 0 &&
node.size.height > 0
);
if (!validNodes.length) return null;
const minX = Math.min(...validNodes.map((node) => node.position.x));
const minY = Math.min(...validNodes.map((node) => node.position.y));
const maxX = Math.max(
...validNodes.map((node) => node.position.x + node.size.width)
);
const maxY = Math.max(
...validNodes.map((node) => node.position.y + node.size.height)
);
const padding = Math.min(80, visibleWidth / 4, container.height / 4);
const zoom = Math.min(
4,
visibleWidth / (maxX - minX + 2 * padding),
container.height / (maxY - minY + 2 * padding)
);
return {
x: visibleWidth / 2 - ((minX + maxX) / 2) * zoom,
y: container.height / 2 - ((minY + maxY) / 2) * zoom,
zoom,
};
}

View File

@ -1,4 +1,5 @@
import "server-only";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import {
and,
@ -1590,6 +1591,7 @@ export async function syncCanvasNodesForProject(params: {
viewport?: { x: number; y: number; zoom: number };
} | null | undefined;
}, executor: Pick<typeof db, "select" | "insert" | "delete"> = db) {
params = { ...params, canvasData: await resolveArchivedImageUrls(params.canvasData) };
const nodes = Array.isArray(params.canvasData?.nodes) ? params.canvasData?.nodes : [];
const assetUrls = Array.from(
new Set(

View File

@ -1343,10 +1343,23 @@ export const aiCadWorkspace = pgTable("AiCadWorkspace", {
export type AiCadWorkspace = InferSelectModel<typeof aiCadWorkspace>;
export type FlowBoardSnapshotData = {
export type FlowBoardSheetSnapshotData = {
id: string;
title: string;
children: Record<string, unknown>[];
viewport?: Record<string, unknown> | null;
theme?: Record<string, unknown> | null;
canvas?: Record<string, unknown> | null;
};
export type FlowBoardSnapshotData = {
version?: number;
activeSheetId?: string | null;
sheets?: FlowBoardSheetSnapshotData[];
children: Record<string, unknown>[];
viewport?: Record<string, unknown> | null;
theme?: Record<string, unknown> | null;
canvas?: Record<string, unknown> | null;
updatedAt?: string | null;
};

View File

@ -0,0 +1,24 @@
export const DAY = 86_400_000;
export function dateIndex(date: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return NaN;
const timestamp = Date.parse(`${date}T00:00:00Z`);
return Number.isFinite(timestamp) &&
new Date(timestamp).toISOString().slice(0, 10) === date
? timestamp / DAY
: NaN;
}
export const indexDate = (index: number) =>
new Date(index * DAY).toISOString().slice(0, 10);
export const addDays = (date: string, days: number) =>
indexDate(dateIndex(date) + days);
export function timestampDate(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) return "";
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return "";
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
export const todayDate = () => timestampDate(Date.now());
export const dateTimestamp = (date: string, end = false) =>
Number.isFinite(dateIndex(date))
? new Date(`${date}T${end ? "23:59:59.999" : "00:00:00"}`).getTime()
: null;

View File

@ -0,0 +1,137 @@
import { PlaitBoard } from "@plait/core";
export type ImageExportOptions = {
scale: 1 | 2 | 3;
transparent: boolean;
backgroundColor: string;
};
const textStyles = [
"display",
"box-sizing",
"margin",
"padding",
"width",
"height",
"font-family",
"font-size",
"font-weight",
"font-style",
"line-height",
"color",
"background-color",
"text-align",
"text-decoration",
"white-space",
"word-break",
"overflow-wrap",
"border-radius",
"vertical-align",
];
async function embedResource(url: string): Promise<string> {
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error("图片资源加载失败,请稍后重试");
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("图片资源读取失败"));
reader.readAsDataURL(blob);
});
}
/** Render the complete content layer in board coordinates, independent of pan/zoom. */
export async function exportBoardImage(
board: PlaitBoard,
options: ImageExportOptions
) {
if (!board.children.length) throw new Error("当前工作表没有可导出的内容");
await document.fonts.ready;
const host = PlaitBoard.getElementHost(board);
const bounds = host.getBBox();
const padding = 32;
const width = Math.ceil(bounds.width + padding * 2);
const height = Math.ceil(bounds.height + padding * 2);
const pixelWidth = width * options.scale;
const pixelHeight = height * options.scale;
if (!bounds.width || !bounds.height)
throw new Error("画布尚未准备好,请稍后重试");
if (
pixelWidth > 16_384 ||
pixelHeight > 16_384 ||
pixelWidth * pixelHeight > 64_000_000
) {
throw new Error("图片尺寸过大,请降低清晰度或拆分工作表后导出");
}
const clone = host.cloneNode(true) as SVGGElement;
clone.removeAttribute("transform");
const sources = host.querySelectorAll("foreignObject *");
const copies = clone.querySelectorAll("foreignObject *");
sources.forEach((source, index) => {
const copy = copies[index] as HTMLElement;
const computed = getComputedStyle(source);
for (const property of textStyles)
copy.style.setProperty(property, computed.getPropertyValue(property));
copy.removeAttribute("contenteditable");
});
const resources = new Map<string, Promise<string>>();
await Promise.all(
Array.from(clone.querySelectorAll("img, image")).map(async (node) => {
const attribute = node.tagName.toLowerCase() === "img" ? "src" : "href";
const url =
node.getAttribute(attribute) ||
node.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (!url || url.startsWith("data:")) return;
if (!resources.has(url)) resources.set(url, embedResource(url));
node.setAttribute(attribute, await resources.get(url)!);
node.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
})
);
const namespace = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(namespace, "svg");
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
svg.setAttribute(
"viewBox",
`${bounds.x - padding} ${bounds.y - padding} ${width} ${height}`
);
for (const defs of PlaitBoard.getHost(board).querySelectorAll(
":scope > defs"
))
svg.append(defs.cloneNode(true));
const style = document.createElementNS(namespace, "style");
const weights = [400, 500, 700, 800];
const names = ["Regular", "Medium", "Bold", "Extrabold"];
style.textContent = (
await Promise.all(
names.map(async (name, index) => {
const data = await embedResource(`/fonts/xmind/NeverMind-${name}.ttf`);
return `@font-face{font-family:NeverMind;src:url('${data}') format('truetype');font-weight:${weights[index]};}`;
})
)
).join("\n");
svg.append(style, clone);
const image = new Image();
const svgData = new XMLSerializer().serializeToString(svg);
// A self-contained data URI keeps foreignObject content safe to draw on canvas.
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = pixelWidth;
canvas.height = pixelHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器无法生成图片");
if (!options.transparent) {
context.fillStyle = options.backgroundColor;
context.fillRect(0, 0, pixelWidth, pixelHeight);
}
context.drawImage(image, 0, 0, pixelWidth, pixelHeight);
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(value) => (value ? resolve(value) : reject(new Error("图片生成失败"))),
"image/png"
)
);
return { blob, width: pixelWidth, height: pixelHeight };
}

View File

@ -0,0 +1,123 @@
import type { PlaitElement, Point } from "@plait/core";
export type MindAnnotation =
| { kind: "relationship"; sourceId: string; targetId: string }
| { kind: "boundary"; topicIds: string[]; padding: number };
export type AnnotatedElement = PlaitElement & {
mindAnnotation: MindAnnotation;
};
export type TopicRectangle = {
x: number;
y: number;
width: number;
height: number;
};
export function isMindAnnotation(
element: PlaitElement
): element is AnnotatedElement {
const value = element.mindAnnotation as MindAnnotation | undefined;
return (
(element.type === "arrow-line" &&
value?.kind === "relationship" &&
typeof value.sourceId === "string" &&
typeof value.targetId === "string") ||
(element.type === "geometry" &&
value?.kind === "boundary" &&
typeof value.padding === "number" &&
Number.isFinite(value.padding) &&
value.padding >= 0 &&
Array.isArray(value.topicIds) &&
value.topicIds.every((id) => typeof id === "string"))
);
}
/** XMind ranges must not silently include unselected, intervening siblings. */
export function validateTopicRange(
paths: number[][],
rightNodeCount?: number
): string | null {
if (paths.length === 0) return "请先选择主题";
const parent = paths[0].slice(0, -1).join("/");
if (paths.some((path) => path.slice(0, -1).join("/") !== parent)) {
return "请选择同一个父主题下的连续主题";
}
const indices = [...new Set(paths.map((path) => path[path.length - 1]))].sort(
(a, b) => a - b
);
if (indices[indices.length - 1] - indices[0] + 1 !== indices.length) {
return "请选择连续的主题,避免包含未选中的分支";
}
if (
rightNodeCount &&
indices[0] < rightNodeCount &&
indices[indices.length - 1] >= rightNodeCount
) {
return "请选择中心主题同一侧的分支";
}
return null;
}
export function getBoundaryPoints(
rectangles: TopicRectangle[],
padding = 18
): [Point, Point] | null {
if (!rectangles.length) return null;
return [
[
Math.min(...rectangles.map((r) => r.x)) - padding,
Math.min(...rectangles.map((r) => r.y)) - padding,
],
[
Math.max(...rectangles.map((r) => r.x + r.width)) + padding,
Math.max(...rectangles.map((r) => r.y + r.height)) + padding,
],
];
}
/** Bow aligned links around topics instead of drawing through intervening siblings. */
export function getRelationshipPoints(
source: TopicRectangle,
target: TopicRectangle
): Point[] {
const a: Point = [source.x + source.width / 2, source.y + source.height / 2];
const b: Point = [target.x + target.width / 2, target.y + target.height / 2];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (Math.abs(dx) < (source.width + target.width) / 2) {
return [
[source.x + source.width, a[1]],
[
Math.max(source.x + source.width, target.x + target.width) + 72,
(a[1] + b[1]) / 2,
],
[target.x + target.width, b[1]],
];
}
if (Math.abs(dy) < (source.height + target.height) / 2) {
return [
[a[0], source.y],
[(a[0] + b[0]) / 2, Math.min(source.y, target.y) - 72],
[b[0], target.y],
];
}
if (
Math.abs(dx) / Math.max(1, (source.width + target.width) / 2) >=
Math.abs(dy) / Math.max(1, (source.height + target.height) / 2)
) {
const sign = dx >= 0 ? 1 : -1;
return [
[a[0] + (sign * source.width) / 2, a[1]],
[(a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 48],
[b[0] - (sign * target.width) / 2, b[1]],
];
}
const sign = dy >= 0 ? 1 : -1;
return [
[a[0], a[1] + (sign * source.height) / 2],
[Math.max(a[0], b[0]) + 72, (a[1] + b[1]) / 2],
[b[0], b[1] - (sign * target.height) / 2],
];
}

View File

@ -1,6 +1,13 @@
import type { PlaitElement, Point } from "@plait/core";
import { MindLayoutType } from "@plait/layouts";
import { createMindElement, type MindElement, type PlaitMind } from "@plait/mind";
import {
createMindElement,
type MindElement,
type PlaitMind,
} from "@plait/mind";
import { importXmind, type ImportedMindMap } from "./xmind-import";
export { MindMapImportError } from "./xmind-import";
export const FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT =
".md,.markdown,.mdown,.mkd,.opml,.mm,.xmind,text/markdown,text/plain,text/xml,application/xml";
@ -13,7 +20,9 @@ type OutlineNode = {
};
function normalizeText(value: string | null | undefined) {
return String(value || "").replace(/\s+/g, " ").trim();
return String(value || "")
.replace(/\s+/g, " ")
.trim();
}
export function getMindMapImportTitle(fileName: string) {
@ -75,14 +84,6 @@ function getDirectChildrenByTag(element: Element, tagName: string) {
);
}
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 || "未命名节点", {});
@ -108,7 +109,9 @@ function parseOpmlOutlineNode(element: Element): OutlineNode {
return {
text,
children: getDirectChildrenByTag(element, "outline").map(parseOpmlOutlineNode),
children: getDirectChildrenByTag(element, "outline").map(
parseOpmlOutlineNode
),
};
}
@ -119,8 +122,12 @@ function parseOpmlToMind(xmlText: string, fallbackTitle: string): PlaitMind {
throw new Error("OPML body not found");
}
const headTitle = normalizeText(document.getElementsByTagName("title")[0]?.textContent);
const topOutlines = getDirectChildrenByTag(body, "outline").map(parseOpmlOutlineNode);
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");
}
@ -172,144 +179,7 @@ function parseFreeMindToMind(xmlText: string): PlaitMind {
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(
async function parseOutlineFileToMind(
file: File,
fallbackTitle = getMindMapImportTitle(file.name)
): Promise<PlaitElement & { points?: Point[] }> {
@ -318,11 +188,6 @@ export async function parseMindMapFileToDrawnixMind(
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()) {
@ -347,3 +212,25 @@ export async function parseMindMapFileToDrawnixMind(
return parseFreeMindToMind(fileText) as PlaitElement & { points?: Point[] };
}
/** Preserve workbook sheets rather than flattening them into the topic tree. */
export async function parseMindMapFileToDocument(
file: File,
fallbackTitle = getMindMapImportTitle(file.name)
): Promise<ImportedMindMap> {
if (detectMindMapImportFormat(file) === "xmind")
return importXmind(file, fallbackTitle);
const mind = await parseOutlineFileToMind(file, fallbackTitle);
return {
sheets: [
{
id: `import-${crypto.randomUUID()}`,
title: "画布 1",
children: [
{ ...mind, points: mind.points?.length ? mind.points : [[0, 0]] },
],
},
],
warnings: [],
};
}

View File

@ -0,0 +1,183 @@
import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
export type OutlineCommand =
| "child"
| "sibling"
| "delete"
| "indent"
| "outdent"
| "up"
| "down";
export type OutlineDrop = {
targetId: string;
placement: "before" | "after" | "child";
};
const isSummary = (node: PlaitElement) =>
Number.isInteger(node.start) && Number.isInteger(node.end);
const regular = (node: PlaitElement) =>
(node.children || []).filter((child) => !isSummary(child));
const newTopic = (root = false): PlaitElement => ({
id: crypto.randomUUID(),
type: root ? "mindmap" : "mind_child",
data: {
topic: {
type: "paragraph",
children: [{ text: root ? "中心主题" : "新主题" }],
},
},
children: [],
...(root
? { isRoot: true, layout: "standard", rightNodeCount: 0, points: [[0, 0]] }
: {}),
});
/** Work on the model, including collapsed nodes that have no renderer path. */
export function applyOutlineCommand(
elements: PlaitElement[],
topicId: string | null,
command: OutlineCommand | OutlineDrop
): { children: PlaitElement[]; selectedId: string } {
const children = structuredClone(elements);
if (!topicId) {
if (command !== "child" || getTopicEntries(children).length)
throw new Error("请先选择一个主题");
const root = newTopic(true);
children.push(root);
return { children, selectedId: root.id };
}
const entries = getTopicEntries(children);
const entry = entries.find(({ element }) => element.id === topicId);
if (!entry) throw new Error("主题已不存在,请重新选择");
const node = entry.element;
const parentOf = (target: PlaitElement) =>
entries.find(({ element }) => element.children?.includes(target))?.element;
const parent = parentOf(node);
const ranges = entries
.filter(({ element }) => isSummary(element))
.map(({ element }) => {
const owner = parentOf(element)!;
return {
element,
owner,
ids: regular(owner)
.slice(Number(element.start), Number(element.end) + 1)
.map((child) => child.id),
};
});
const remove = (target: PlaitElement) => {
const owner = parentOf(target);
const list = owner?.children || children;
const index = list.indexOf(target);
if (
owner?.layout === "standard" &&
!isSummary(target) &&
index < Number(owner.rightNodeCount)
)
owner.rightNodeCount = Number(owner.rightNodeCount) - 1;
list.splice(index, 1);
};
const insert = (owner: PlaitElement, target: PlaitElement, index: number) => {
owner.children ||= [];
const count = regular(owner).length;
const at = Math.min(index, count);
owner.children.splice(at, 0, target);
if (owner.layout === "standard" && at <= Number(owner.rightNodeCount || 0))
owner.rightNodeCount = Number(owner.rightNodeCount || 0) + 1;
owner.isCollapsed = false;
};
let selectedId = node.id;
if (command === "child" || command === "sibling") {
const next = newTopic();
if (command === "child") insert(node, next, regular(node).length);
else {
if (!parent || isSummary(node))
throw new Error("中心主题和概要请使用新增子主题");
insert(parent, next, parent.children!.indexOf(node) + 1);
}
selectedId = next.id;
} else if (command === "delete") {
const siblings = parent
? regular(parent)
: children.filter((item) =>
["mind", "mindmap"].includes(String(item.type))
);
const index = siblings.indexOf(node);
selectedId =
siblings[index - 1]?.id || siblings[index + 1]?.id || parent?.id || "";
remove(node);
} else {
if (!parent || isSummary(node))
throw new Error("中心主题和概要暂不支持此层级调整");
let target: PlaitElement | undefined;
let placement: OutlineDrop["placement"] = "child";
const siblings = regular(parent);
const index = siblings.indexOf(node);
if (typeof command === "object") {
target = entries.find(
({ element }) => element.id === command.targetId
)?.element;
placement = command.placement;
} else if (command === "indent") target = siblings[index - 1];
else if (command === "outdent") {
target = parent;
placement = "after";
} else {
target = siblings[index + (command === "up" ? -1 : 1)];
placement = command === "up" ? "before" : "after";
}
if (!target) throw new Error("此位置无法继续移动主题");
if (isSummary(target)) throw new Error("请移动到普通主题附近");
if (
getTopicEntries([node]).some(({ element }) => element.id === target!.id)
)
throw new Error("不能把主题移入自身或它的子主题");
const destination = placement === "child" ? target : parentOf(target);
if (!destination) throw new Error("一级主题不能再提升层级");
remove(node);
const at =
placement === "child"
? regular(destination).length
: destination.children!.indexOf(target) +
(placement === "after" ? 1 : 0);
insert(destination, node, at);
}
// Keep summaries attached to their surviving topic range, never unrelated gaps.
for (const range of ranges) {
if (!range.owner.children?.includes(range.element)) continue;
const indices = regular(range.owner).flatMap((child, index) =>
range.ids.includes(child.id) ? [index] : []
);
if (!indices.length) {
range.owner.children = range.owner.children.filter(
(child) => child !== range.element
);
continue;
}
const start = indices[0];
const end = indices[indices.length - 1];
const newlyInserted = command === "child" || command === "sibling";
if (!newlyInserted && end - start + 1 !== indices.length)
throw new Error("移动会让概要范围断开,请先调整概要");
range.element.start = start;
range.element.end = end;
}
return { children, selectedId };
}
export function renameOutlineTopic(element: PlaitElement, title: string) {
const data = element.data as Record<string, unknown>;
const topic = data.topic as {
children?: Record<string, unknown>[];
type?: string;
};
return {
...data,
topic: {
...topic,
children: [
{ ...(topic.children?.[0] || {}), text: title.trim() || "未命名主题" },
],
},
};
}

View File

@ -0,0 +1,394 @@
export const SPECIAL_MIND_LAYOUTS = {
fishbone: {
structure: "org.xmind.ui.fishbone.leftHeaded",
base: "right",
label: "鱼骨图",
preview: "fishbone",
},
timeline: {
structure: "org.xmind.ui.timeline.sided.horizontal",
base: "right",
label: "水平时间轴",
preview: "timeline",
},
verticalTimeline: {
structure: "org.xmind.ui.timeline.vertical",
base: "right",
label: "垂直时间轴",
preview: "verticalTimeline",
},
treeTable: {
structure: "org.xmind.ui.treetable.toptitle",
base: "right",
label: "树形表格",
preview: "treeTable",
},
treeTableSide: {
structure: "org.xmind.ui.treetable",
base: "right",
label: "树形表格(侧标题)",
preview: "treeTableSide",
},
spreadsheet: {
structure: "org.xmind.ui.spreadsheet",
base: "right",
label: "矩阵",
preview: "spreadsheet",
},
brace: {
structure: "org.xmind.ui.brace.right",
base: "right",
label: "括号图",
preview: "brace",
},
} as const;
export type SpecialMindLayout = keyof typeof SPECIAL_MIND_LAYOUTS;
export function isSpecialMindLayout(
value: unknown
): value is SpecialMindLayout {
return (
typeof value === "string" && Object.hasOwn(SPECIAL_MIND_LAYOUTS, value)
);
}
export function specialLayoutForStructure(structure: string) {
return (Object.keys(SPECIAL_MIND_LAYOUTS) as SpecialMindLayout[]).find(
(key) => SPECIAL_MIND_LAYOUTS[key].structure === structure
);
}
export type SpecialLayoutNode = {
x: number;
y: number;
width: number;
height: number;
hGap: number;
vGap: number;
origin: {
id?: string;
start?: number;
end?: number;
fill?: string;
spreadsheetColumns?: string[];
xmind?: { labels?: string[] };
};
children: SpecialLayoutNode[];
tableCell?: {
x: number;
y: number;
width: number;
height: number;
depth: number;
branch: number;
};
tableGrid?: {
kind: "spreadsheet";
x: number;
y: number;
width: number;
height: number;
columns: string[];
headerHeight: number;
columnHeaderHeight: number;
rowHeaderWidth: number;
columnWidth: number;
rows: { y: number; height: number; branch: number }[];
};
};
export const specialNodeRect = (node: SpecialLayoutNode) => ({
x: node.x + node.hGap,
y: node.y + node.vGap,
width: node.width - 2 * node.hGap,
height: node.height - 2 * node.vGap,
});
export const isSummaryNode = (node: SpecialLayoutNode) =>
Number.isInteger(node.origin.start) && Number.isInteger(node.origin.end);
export function specialSubtreeBounds(node: SpecialLayoutNode) {
const nodes: SpecialLayoutNode[] = [];
const visit = (item: SpecialLayoutNode) => {
nodes.push(item);
item.children.forEach(visit);
};
visit(node);
const rects = nodes.map(specialNodeRect);
const x = Math.min(...rects.map((rect) => rect.x));
const y = Math.min(...rects.map((rect) => rect.y));
return {
x,
y,
width: Math.max(...rects.map((rect) => rect.x + rect.width)) - x,
height: Math.max(...rects.map((rect) => rect.y + rect.height)) - y,
};
}
function translate(node: SpecialLayoutNode, dx: number, dy: number) {
node.x += dx;
node.y += dy;
node.children.forEach((child) => translate(child, dx, dy));
}
function mirrorHorizontally(node: SpecialLayoutNode, axis: number) {
node.x = 2 * axis - node.x - node.width;
node.children.forEach((child) => mirrorHorizontally(child, axis));
}
function placeInTableCell(
node: SpecialLayoutNode,
cell: NonNullable<SpecialLayoutNode["tableCell"]>
) {
node.x = cell.x - node.hGap;
node.y = cell.y - node.vGap;
node.width = cell.width + node.hGap * 2;
node.height = cell.height + node.vGap * 2;
node.tableCell = cell;
}
function arrangeTreeTable(root: SpecialLayoutNode, title: "top" | "side") {
const branches = root.children.filter((node) => !isSummaryNode(node));
if (!branches.length) return;
let maxDepth = 1;
const visit = (node: SpecialLayoutNode, depth: number) => {
maxDepth = Math.max(maxDepth, depth);
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
visit(child, depth + 1)
);
};
branches.forEach((node) => visit(node, 1));
const columnWidths = Array.from({ length: maxDepth }, () => 180);
const measure = (node: SpecialLayoutNode, depth: number) => {
const rect = specialNodeRect(node);
columnWidths[depth - 1] = Math.min(
360,
Math.max(columnWidths[depth - 1], rect.width + 48)
);
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
measure(child, depth + 1)
);
};
branches.forEach((node) => measure(node, 1));
const spans = new Map<SpecialLayoutNode, [number, number]>();
let nextRow = 0;
const assignRows = (node: SpecialLayoutNode): [number, number] => {
const children = node.children.filter((child) => !isSummaryNode(child));
if (!children.length) {
const row = nextRow++;
spans.set(node, [row, row]);
return [row, row];
}
const childSpans = children.map(assignRows);
const span: [number, number] = [
childSpans[0][0],
childSpans[childSpans.length - 1][1],
];
spans.set(node, span);
return span;
};
branches.forEach(assignRows);
const sourceRoot = specialNodeRect(root);
const rootWidth = Math.min(420, Math.max(220, sourceRoot.width + 48));
const branchWidth = columnWidths.reduce((sum, width) => sum + width, 0);
const tableWidth = branchWidth + (title === "side" ? rootWidth : 0);
const rowHeight = 78;
const headerHeight = title === "top" ? Math.max(96, sourceRoot.height + 36) : 0;
const tableX = sourceRoot.x;
const tableY = sourceRoot.y;
placeInTableCell(root, {
x: tableX,
y: tableY,
width: title === "top" ? tableWidth : rootWidth,
height: title === "top" ? headerHeight : Math.max(rowHeight, nextRow * rowHeight),
depth: 0,
branch: -1,
});
const position = (
node: SpecialLayoutNode,
depth: number,
branch: number
) => {
const [start, end] = spans.get(node)!;
const x =
tableX + (title === "side" ? rootWidth : 0) +
columnWidths.slice(0, depth - 1).reduce((sum, width) => sum + width, 0);
placeInTableCell(node, {
x,
y: tableY + headerHeight + start * rowHeight,
width: columnWidths[depth - 1],
height: (end - start + 1) * rowHeight,
depth,
branch,
});
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
position(child, depth + 1, branch)
);
};
branches.forEach((node, index) => position(node, 1, index));
}
function labelsOf(node: SpecialLayoutNode) {
return Array.isArray(node.origin.xmind?.labels)
? node.origin.xmind.labels.filter((label) => typeof label === "string")
: [];
}
function arrangeSpreadsheet(root: SpecialLayoutNode) {
const rows = root.children.filter((node) => !isSummaryNode(node));
if (!rows.length) return;
const configured = Array.isArray(root.origin.spreadsheetColumns)
? root.origin.spreadsheetColumns.filter(Boolean)
: [];
const derived = rows.flatMap((row) =>
row.children.flatMap((child) => labelsOf(child))
);
const columns = [...new Set(configured.length ? configured : derived)];
if (!columns.length) columns.push("列 1", "列 2", "列 3");
const sourceRoot = specialNodeRect(root);
const headerHeight = Math.max(86, sourceRoot.height + 30);
const columnHeaderHeight = 54;
const rowHeaderWidth = Math.min(
320,
Math.max(180, ...rows.map((row) => specialNodeRect(row).width + 42))
);
const entries = rows.flatMap((row) => row.children.filter((node) => !isSummaryNode(node)));
const columnWidth = Math.min(
300,
Math.max(170, ...entries.map((entry) => specialNodeRect(entry).width + 34))
);
const tableX = sourceRoot.x;
const tableY = sourceRoot.y;
const tableWidth = rowHeaderWidth + columns.length * columnWidth;
const rowGeometry: NonNullable<SpecialLayoutNode["tableGrid"]>["rows"] = [];
let y = tableY + headerHeight + columnHeaderHeight;
placeInTableCell(root, {
x: tableX,
y: tableY,
width: tableWidth,
height: headerHeight,
depth: 0,
branch: -1,
});
rows.forEach((row, branch) => {
const children = row.children.filter((node) => !isSummaryNode(node));
const buckets = columns.map(() => [] as SpecialLayoutNode[]);
let fallback = 0;
for (const child of children) {
const labels = labelsOf(child);
const labelled = labels
.map((label) => columns.indexOf(label))
.find((index) => index >= 0);
const column = labelled ?? fallback++ % columns.length;
buckets[column].push(child);
}
const count = Math.max(1, ...buckets.map((bucket) => bucket.length));
const rowHeight = Math.max(88, 18 + count * 42);
rowGeometry.push({ y, height: rowHeight, branch });
placeInTableCell(row, {
x: tableX,
y,
width: rowHeaderWidth,
height: rowHeight,
depth: 1,
branch,
});
buckets.forEach((bucket, column) => {
bucket.forEach((child, index) => {
placeInTableCell(child, {
x: tableX + rowHeaderWidth + column * columnWidth + 8,
y: y + 8 + index * 42,
width: columnWidth - 16,
height: 34,
depth: 2,
branch,
});
});
});
y += rowHeight;
});
root.tableGrid = {
kind: "spreadsheet",
x: tableX,
y: tableY,
width: tableWidth,
height: y - tableY,
columns,
headerHeight,
columnHeaderHeight,
rowHeaderWidth,
columnWidth,
rows: rowGeometry,
};
}
/** Pack whole subtrees in alternating rows, preserving their native local layout. */
export function arrangeSpecialMind(
root: SpecialLayoutNode,
kind: SpecialMindLayout
) {
if (kind === "brace") return;
if (kind === "treeTable" || kind === "treeTableSide") {
arrangeTreeTable(root, kind === "treeTable" ? "top" : "side");
return;
}
if (kind === "spreadsheet") {
arrangeSpreadsheet(root);
return;
}
const center = specialNodeRect(root);
const axisY = center.y + center.height / 2;
let columnX = center.x + center.width + 110;
const branches = root.children.filter((node) => !isSummaryNode(node));
if (kind === "verticalTimeline") {
const axisX = center.x + center.width / 2;
let rowY = center.y + center.height + 70;
branches.forEach((node, index) => {
const bounds = specialSubtreeBounds(node);
translate(node, axisX + 70 - bounds.x, rowY - bounds.y);
if (index % 2 === 1) mirrorHorizontally(node, axisX);
rowY += bounds.height + 40;
});
} else
for (let i = 0; i < branches.length; i += 2) {
const pair = branches.slice(i, i + 2);
let columnWidth = 0;
pair.forEach((node, side) => {
const bounds = specialSubtreeBounds(node);
const stagger =
side === 1
? kind === "fishbone"
? 48
: Math.max(80, bounds.width * 0.55)
: 0;
const y = side === 0 ? axisY - 76 - bounds.height : axisY + 76;
translate(node, columnX + stagger - bounds.x, y - bounds.y);
columnWidth = Math.max(columnWidth, bounds.width + stagger);
});
columnX += columnWidth + 100;
}
// Root-level summaries follow the full geometry of their covered branches.
let summaryX =
Math.max(
center.x + center.width,
...branches.map((node) => {
const rect = specialSubtreeBounds(node);
return rect.x + rect.width;
})
) + 80;
for (const node of root.children.filter(isSummaryNode)) {
const covered = branches
.slice(node.origin.start, Number(node.origin.end) + 1)
.map(specialSubtreeBounds);
if (!covered.length) continue;
const top = Math.min(...covered.map((rect) => rect.y));
const bottom = Math.max(...covered.map((rect) => rect.y + rect.height));
const rect = specialNodeRect(node);
translate(
node,
summaryX - rect.x,
(top + bottom - rect.height) / 2 - rect.y
);
summaryX += specialSubtreeBounds(node).width + 60;
}
}

View File

@ -0,0 +1,288 @@
import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
import { addDays, dateIndex, DAY, indexDate } from "./calendar-dates";
export const CALENDAR_PROVIDER = "org.xmind.ui.working-day-settings";
export const CALENDAR_FREQUENCIES = [
"NONE",
"DAILY",
"WEEKLY",
"MONTHLY_BY_DATE",
"MONTHLY_BY_WEEKDAY",
"YEARLY",
] as const;
export type CalendarRule = {
id: string;
name: string;
selectedDates: string[];
type: "WORKING_DAY" | "NON_WORKING_DAY";
frequency: (typeof CALENDAR_FREQUENCIES)[number];
interval?: number;
effectiveRange?: { start?: string; end?: string };
};
export type ProjectWorkingCalendar = {
id: string;
name: string;
defaultWorkingDays: number[];
rules: CalendarRule[];
effectiveRange?: { start?: string; end?: string };
};
export type ProjectCalendarConfig = {
settings: ProjectWorkingCalendar | null;
skip: boolean;
};
const record = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
export function defaultWorkingCalendar(): ProjectWorkingCalendar {
return {
id: crypto.randomUUID(),
name: "基本日历",
defaultWorkingDays: [1, 2, 3, 4, 5],
rules: [],
};
}
export function calendarRoot(elements: PlaitElement[]) {
return (
getTopicEntries(elements).find((entry) => entry.depth === 0)?.element ||
null
);
}
export function readWorkingCalendar(
value: unknown
): ProjectWorkingCalendar | null {
const source = record(value);
if (!Array.isArray(source.defaultWorkingDays) || !Array.isArray(source.rules))
return null;
return {
...source,
id: String(source.id || "project-calendar"),
name: String(source.name || "项目日历"),
defaultWorkingDays: source.defaultWorkingDays.filter(
(day): day is number => Number.isInteger(day) && day >= 1 && day <= 7
),
rules: source.rules.map((rule, index) => {
const item = record(rule);
return {
...item,
id: String(item.id || `calendar-rule-${index}`),
name: String(item.name || "例外"),
selectedDates: Array.isArray(item.selectedDates)
? item.selectedDates.filter(
(date): date is string =>
typeof date === "string" && Number.isFinite(dateIndex(date))
)
: [],
type: item.type === "WORKING_DAY" ? "WORKING_DAY" : "NON_WORKING_DAY",
frequency: CALENDAR_FREQUENCIES.includes(
item.frequency as CalendarRule["frequency"]
)
? (item.frequency as CalendarRule["frequency"])
: "NONE",
} as CalendarRule;
}),
} as ProjectWorkingCalendar;
}
export function getProjectCalendar(
elements: PlaitElement[]
): ProjectCalendarConfig {
const sheet = record(record(calendarRoot(elements)?.xmind).sheet);
const extension = Array.isArray(sheet.extensions)
? sheet.extensions
.map(record)
.find((item) => item.provider === CALENDAR_PROVIDER)
: null;
const settings = readWorkingCalendar(extension?.content);
return {
settings,
skip: Boolean(settings && sheet.nonWorkingDaysSkipping === "skip"),
};
}
export function patchProjectCalendar(
root: PlaitElement,
config: ProjectCalendarConfig
) {
const metadata = { ...record(root.xmind) },
sheet = { ...record(metadata.sheet) };
const extensions = Array.isArray(sheet.extensions)
? sheet.extensions.map(record)
: [];
const existing = extensions.find(
(item) => item.provider === CALENDAR_PROVIDER
);
sheet.extensions = [
...extensions.filter((item) => item.provider !== CALENDAR_PROVIDER),
...(config.settings
? [{ ...existing, provider: CALENDAR_PROVIDER, content: config.settings }]
: []),
];
sheet.nonWorkingDaysSkipping =
config.skip && config.settings ? "skip" : "include";
metadata.sheet = sheet;
return metadata;
}
export const activeWorkingCalendar = (config: ProjectCalendarConfig) =>
config.skip ? config.settings : null;
function inRange(date: string, range?: CalendarRule["effectiveRange"]) {
return (
(!range?.start || date >= range.start) && (!range?.end || date <= range.end)
);
}
function ruleMatches(date: string, rule: CalendarRule) {
if (!inRange(date, rule.effectiveRange)) return false;
const index = dateIndex(date),
target = new Date(index * DAY),
interval = rule.interval || 1;
return rule.selectedDates.some((selected) => {
const seedIndex = dateIndex(selected),
seed = new Date(seedIndex * DAY);
const difference = Math.abs(index - seedIndex),
months = Math.abs(
(target.getUTCFullYear() - seed.getUTCFullYear()) * 12 +
target.getUTCMonth() -
seed.getUTCMonth()
);
switch (rule.frequency) {
case "NONE":
return selected === date;
case "DAILY":
return difference % interval === 0;
case "WEEKLY":
return (
target.getUTCDay() === seed.getUTCDay() &&
Math.floor(difference / 7) % interval === 0
);
case "MONTHLY_BY_DATE":
return (
target.getUTCDate() === seed.getUTCDate() && months % interval === 0
);
case "MONTHLY_BY_WEEKDAY":
return (
target.getUTCDay() === seed.getUTCDay() &&
Math.ceil(target.getUTCDate() / 7) ===
Math.ceil(seed.getUTCDate() / 7) &&
months % interval === 0
);
case "YEARLY":
return (
target.getUTCMonth() === seed.getUTCMonth() &&
target.getUTCDate() === seed.getUTCDate() &&
Math.abs(target.getUTCFullYear() - seed.getUTCFullYear()) %
interval ===
0
);
}
});
}
export function isWorkingDate(
date: string,
settings: ProjectWorkingCalendar | null
) {
if (!settings || !inRange(date, settings.effectiveRange)) return true;
const weekday = new Date(dateIndex(date) * DAY).getUTCDay() || 7;
let result = settings.defaultWorkingDays.includes(weekday);
for (const rule of settings.rules)
if (ruleMatches(date, rule)) result = rule.type === "WORKING_DAY";
return result;
}
export function nextWorkingDate(
date: string,
settings: ProjectWorkingCalendar | null,
direction: 1 | -1 = 1
) {
for (
let day = dateIndex(date), attempt = 0;
attempt <= 36500;
day += direction, attempt++
) {
const candidate = indexDate(day);
if (isWorkingDate(candidate, settings)) return candidate;
}
throw new Error("日历中连续 100 年没有可用工作日,请调整例外规则。");
}
export function addWorkingDays(
date: string,
days: number,
settings: ProjectWorkingCalendar | null
) {
if (!settings) return addDays(date, days);
const direction = days < 0 ? -1 : 1;
let result = date;
for (let left = Math.abs(days); left > 0; left--)
result = nextWorkingDate(addDays(result, direction), settings, direction);
return result;
}
export function taskDueDate(
start: string,
duration: number,
settings: ProjectWorkingCalendar | null
) {
return addWorkingDays(
nextWorkingDate(start, settings),
duration - 1,
settings
);
}
export function taskStartDate(
due: string,
duration: number,
settings: ProjectWorkingCalendar | null
) {
return addWorkingDays(
nextWorkingDate(due, settings, -1),
1 - duration,
settings
);
}
export function workingDaysBetween(
start: string,
due: string,
settings: ProjectWorkingCalendar | null
) {
if (!settings) return dateIndex(due) - dateIndex(start) + 1;
let duration = 0;
for (let day = dateIndex(start), last = dateIndex(due); day <= last; day++)
if (isWorkingDate(indexDate(day), settings)) duration++;
return duration;
}
export function preserveNonWorkingDue(
due: string,
calculated: string,
settings: ProjectWorkingCalendar | null
) {
return settings &&
due &&
!isWorkingDate(due, settings) &&
nextWorkingDate(due, settings, -1) === calculated
? due
: calculated;
}
export function validateWorkingCalendar(
calendar: ProjectWorkingCalendar
): string | null {
if (!calendar.defaultWorkingDays.length) return "请至少保留一个每周工作日。";
const validateRange = (range?: CalendarRule["effectiveRange"]) =>
!range ||
((!range.start || Number.isFinite(dateIndex(range.start))) &&
(!range.end || Number.isFinite(dateIndex(range.end))) &&
(!range.start || !range.end || range.start <= range.end));
if (!validateRange(calendar.effectiveRange)) return "日历生效范围无效。";
for (const rule of calendar.rules) {
if (
!rule.selectedDates.length ||
rule.selectedDates.some((date) => !Number.isFinite(dateIndex(date)))
)
return "请选择有效的例外日期。";
if (!validateRange(rule.effectiveRange))
return "例外结束日期不能早于开始日期。";
if (
rule.interval !== undefined &&
(!Number.isInteger(rule.interval) ||
rule.interval < 1 ||
rule.interval > 100)
)
return "重复间隔必须是 1 至 100。";
}
return null;
}

View File

@ -0,0 +1,119 @@
import type { PlaitElement } from "@plait/core";
import { getNoteDocument, noteDocumentText, serializeNoteDocument, type NoteDocument } from "./topic-notes";
export type TopicAttachment = { name: string; url: string; mimeType: string };
export type TopicDetails = {
notes: string;
notesDocument?: NoteDocument;
labels: string[];
href: string;
topicLinkId: string;
attachment: TopicAttachment | null;
};
export type TopicEntry = {
element: PlaitElement;
title: string;
depth: number;
path: number[];
};
const record = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
export function getTopicTitle(element: PlaitElement): string {
const visit = (value: unknown): string => {
const item = record(value);
if (typeof item.text === "string") return item.text;
return Array.isArray(item.children)
? item.children.map(visit).join("")
: "";
};
return visit(record(element.data).topic) || "未命名主题";
}
export function getTopicEntries(
elements: PlaitElement[],
depth = 0,
parentPath: number[] = []
): TopicEntry[] {
return elements.flatMap((element, index) => {
if (!["mind", "mindmap", "mind_child"].includes(String(element.type)))
return [];
return [
{
element,
title: getTopicTitle(element),
depth,
path: [...parentPath, index],
},
...getTopicEntries(element.children || [], depth + 1, [
...parentPath,
index,
]),
];
});
}
export function getTopicDetails(element: PlaitElement | null): TopicDetails {
const data = record(element?.xmind);
const notes =
typeof data.notes === "string"
? data.notes
: record(record(data.notes).plain).content;
const labels = Array.isArray(data.labels)
? data.labels
: typeof data.labels === "string"
? [data.labels]
: [];
const htmlText = typeof notes === "string" ? "" : noteDocumentText(getNoteDocument(element));
const attachment = record(data.attachment);
const safeAttachment =
typeof attachment.url === "string" &&
/^data:[a-z0-9.+/-]+;base64,[a-z0-9+/=]*$/i.test(attachment.url) &&
typeof attachment.name === "string"
? (attachment as TopicAttachment)
: null;
return {
attachment: safeAttachment,
notes: typeof notes === "string" ? notes : htmlText,
labels: [
...new Set(
labels
.filter(
(label): label is string =>
typeof label === "string" && Boolean(label.trim())
)
.map((label) => label.trim())
),
],
href: typeof data.href === "string" ? data.href : "",
topicLinkId: typeof data.topicLinkId === "string" ? data.topicLinkId : "",
};
}
export function patchTopicDetails(
element: PlaitElement,
patch: Partial<TopicDetails>
) {
const data = { ...record(element.xmind) };
for (const [key, value] of Object.entries(patch)) {
if (key === "notesDocument") data.notes = serializeNoteDocument(value as NoteDocument);
else if (key === "notes")
data.notes = value ? { plain: { content: value } } : undefined;
else data[key] = value;
}
return data;
}
export function getSafeTopicHref(href: string): string | null {
if (!href.trim()) return null;
try {
const url = new URL(href.trim());
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol)
? url.href
: null;
} catch {
return null;
}
}

View File

@ -0,0 +1,290 @@
import type { PlaitElement } from "@plait/core";
export type NoteText = {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
href?: string;
properties?: Record<string, unknown>;
};
export type NoteParagraph = {
type: "paragraph";
list?: "bullet" | "number";
indent?: number;
properties?: Record<string, unknown>;
children: NoteText[];
};
export type NoteDocument = NoteParagraph[];
const record = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
export const safeNoteLink = (value: string) => {
try {
const url = new URL(value);
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol)
? url.href
: null;
} catch {
return null;
}
};
function parseNoteHTML(html: string): NoteDocument | null {
if (typeof DOMParser === "undefined") return null;
const dom = new DOMParser().parseFromString(html, "text/html");
const ignored = new Set([
"SCRIPT",
"STYLE",
"IFRAME",
"OBJECT",
"SVG",
"FORM",
"INPUT",
]);
const inline = (
node: Node,
inherited: Partial<NoteText> = {}
): NoteText[] => {
if (node.nodeType === 3)
return [{ ...inherited, text: node.textContent || "" }];
if (!(node instanceof Element) || ignored.has(node.tagName)) return [];
if (node.tagName === "BR") return [{ ...inherited, text: "\n" }];
const style = (node as HTMLElement).style;
const marks: Partial<NoteText> = { ...inherited };
if (
["B", "STRONG"].includes(node.tagName) ||
style.fontWeight === "bold" ||
Number(style.fontWeight) >= 600
)
marks.bold = true;
if (["I", "EM"].includes(node.tagName) || style.fontStyle === "italic")
marks.italic = true;
if (node.tagName === "U" || style.textDecoration.includes("underline"))
marks.underline = true;
if (
["S", "DEL", "STRIKE"].includes(node.tagName) ||
style.textDecoration.includes("line-through")
)
marks.strikethrough = true;
const href = safeNoteLink(node.getAttribute("href") || "");
if (href) marks.href = href;
return Array.from(node.childNodes).flatMap((child) => inline(child, marks));
};
const result: NoteDocument = [];
const visit = (node: Node, list?: "bullet" | "number") => {
if (node.nodeType === 3) {
if (node.textContent?.trim())
result.push({
type: "paragraph",
children: [{ text: node.textContent }],
});
return;
}
if (!(node instanceof Element) || ignored.has(node.tagName)) return;
if (["OL", "UL"].includes(node.tagName)) {
Array.from(node.children).forEach((child) =>
visit(child, node.tagName === "OL" ? "number" : "bullet")
);
return;
}
if (
[
"P",
"LI",
"DIV",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"BLOCKQUOTE",
"PRE",
].includes(node.tagName)
) {
const blocks = Array.from(node.children).some((child) =>
["P", "LI", "DIV", "OL", "UL"].includes(child.tagName)
);
if (blocks && node.tagName !== "LI") {
Array.from(node.childNodes).forEach((child) => visit(child, list));
return;
}
const leaves = Array.from(node.childNodes)
.filter(
(child) =>
!(child instanceof Element && ["OL", "UL"].includes(child.tagName))
)
.flatMap((child) => inline(child));
result.push({
type: "paragraph",
...(list ? { list } : {}),
indent: Number.parseFloat((node as HTMLElement).style.marginLeft) || 0,
children: leaves.length ? leaves : [{ text: "" }],
});
Array.from(node.children)
.filter((child) => ["OL", "UL"].includes(child.tagName))
.forEach((child) => visit(child));
return;
}
const leaves = inline(node);
if (leaves.length) result.push({ type: "paragraph", children: leaves });
};
Array.from(dom.body.childNodes).forEach((node) => visit(node));
return result.length ? result : null;
}
const escapeHTML = (value: string) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
function noteDocumentHTML(doc: NoteDocument) {
let html = "",
list: "bullet" | "number" | undefined;
for (const paragraph of doc) {
if (list !== paragraph.list) {
if (list) html += list === "number" ? "</ol>" : "</ul>";
list = paragraph.list;
if (list) html += list === "number" ? "<ol>" : "<ul>";
}
const tag = list ? "li" : "p";
let content = paragraph.children
.map((t) => {
let text = escapeHTML(t.text).replaceAll("\n", "<br>");
if (t.bold) text = `<strong>${text}</strong>`;
if (t.italic) text = `<em>${text}</em>`;
if (t.underline) text = `<u>${text}</u>`;
if (t.strikethrough) text = `<s>${text}</s>`;
const href = safeNoteLink(t.href || "");
return href ? `<a href="${escapeHTML(href)}">${text}</a>` : text;
})
.join("");
if (!content) content = "<br>";
html += `<${tag}${paragraph.indent ? ` style="margin-left:${Math.max(0, Math.min(120, paragraph.indent))}px"` : ""}>${content}</${tag}>`;
}
if (list) html += list === "number" ? "</ol>" : "</ul>";
return html;
}
export function getNoteDocument(element: PlaitElement | null): NoteDocument {
const notes = record(record(element?.xmind).notes);
const realHTML = record(notes.realHTML).content;
if (typeof realHTML === "string") {
const parsed = parseNoteHTML(realHTML);
if (parsed) return parsed;
}
const paragraphs = record(record(notes.html).content).paragraphs;
if (Array.isArray(paragraphs) && paragraphs.length) {
const readSpans = (
spans: unknown,
inherited: Record<string, unknown> = {},
href = ""
): NoteText[] =>
Array.isArray(spans)
? spans.flatMap((span) => {
const item = record(span),
props = {
...inherited,
...record(record(item.style).properties),
};
const link = typeof item.href === "string" ? item.href : href;
if (Array.isArray(item.spans))
return readSpans(item.spans, props, link);
if (typeof item.text !== "string") return [];
return [
{
text: item.text,
properties: props,
bold: ["bold", "700", "600", "800", "900"].includes(
String(props["fo:font-weight"] || props["font-weight"] || "")
),
italic:
(props["fo:font-style"] || props["font-style"]) === "italic",
underline: String(
props["fo:text-decoration"] || props["text-decoration"] || ""
).includes("underline"),
strikethrough: String(
props["fo:text-decoration"] || props["text-decoration"] || ""
).includes("line-through"),
...(safeNoteLink(link) ? { href: safeNoteLink(link)! } : {}),
},
];
})
: [];
return paragraphs.map((p) => {
const item = record(p),
props = record(record(item.style).properties);
const bullet = String(props["fo:text-bullet"] || "");
const children = readSpans(item.spans);
return {
type: "paragraph",
properties: props,
...(bullet === "number" || bullet === "decimal"
? { list: "number" as const }
: bullet && bullet !== "none"
? { list: "bullet" as const }
: {}),
indent: Number(props["fo:margin-left"]) || 0,
children: children.length ? children : [{ text: "" }],
};
});
}
const plain =
typeof record(element?.xmind).notes === "string"
? String(record(element?.xmind).notes)
: String(record(notes.plain).content || "");
return plain
.split("\n")
.map((text) => ({ type: "paragraph", children: [{ text }] }));
}
export const noteDocumentText = (doc: NoteDocument) =>
doc.map((p) => p.children.map((t) => t.text).join("")).join("\n");
export function serializeNoteDocument(doc: NoteDocument) {
const plain = noteDocumentText(doc);
if (!plain.trim()) return undefined;
return {
plain: { content: plain },
realHTML: { content: noteDocumentHTML(doc) },
html: {
content: {
paragraphs: doc.map((p) => ({
style: {
type: "paragraph",
properties: {
...p.properties,
"fo:text-bullet":
p.list === "number"
? "number"
: p.list === "bullet"
? "bullet"
: "none",
"fo:margin-left": String(p.indent || 0),
},
},
spans: p.children.map((t) => ({
text: t.text,
...(safeNoteLink(t.href || "")
? { href: safeNoteLink(t.href!) }
: {}),
style: {
type: "text",
properties: {
...t.properties,
"fo:font-weight": t.bold ? "bold" : "normal",
"fo:font-style": t.italic ? "italic" : "normal",
"fo:text-decoration":
[
t.underline ? "underline" : "",
t.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || "none",
},
},
})),
})),
},
},
};
}

View File

@ -0,0 +1,58 @@
import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
export type TopicSearchResult = { id: string; title: string; sheetId: string; sheetTitle: string; occurrences: number };
export type SearchSheet = { id: string; title: string; children: PlaitElement[] };
export function searchTopics(sheets: SearchSheet[], query: string, caseSensitive = false): TopicSearchResult[] {
if (!query) return [];
const needle = caseSensitive ? query : query.toLocaleLowerCase();
return sheets.flatMap(sheet => getTopicEntries(sheet.children).flatMap(({ element, title }) => {
const text = caseSensitive ? title : title.toLocaleLowerCase();
let count = 0;
let offset = 0;
for (let at = text.indexOf(needle, offset); at !== -1; at = text.indexOf(needle, offset)) {
count++;
offset = at + needle.length;
}
return count ? [{ id: element.id, title, sheetId: sheet.id, sheetTitle: sheet.title, occurrences: count }] : [];
}));
}
/** A match may cross styled leaves. Replacement inherits the first matched leaf's marks. */
export function replaceTopicText(element: PlaitElement, query: string, replacement: string, caseSensitive = false) {
const topic = element.data?.topic;
if (!query || !topic) return topic;
const leaves: { node: Record<string, unknown>; start: number; end: number }[] = [];
let source = "";
const collect = (node: Record<string, unknown>) => {
if (typeof node.text === "string") {
const start = source.length;
source += node.text;
leaves.push({ node, start, end: source.length });
} else if (Array.isArray(node.children)) {
node.children.forEach(child => collect(child as Record<string, unknown>));
}
};
collect(topic);
const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), caseSensitive ? "g" : "gi");
const matches = Array.from(source.matchAll(pattern), match => ({ start: match.index!, end: match.index! + match[0].length }));
const replacements = new Map<object, string>();
for (const { node, start, end } of leaves) {
let cursor = start;
let text = "";
for (const match of matches) {
if (match.end <= start || match.start >= end) continue;
text += source.slice(cursor, Math.max(cursor, match.start));
if (match.start >= start) text += replacement;
cursor = Math.min(end, match.end);
}
replacements.set(node, text + source.slice(cursor, end));
}
const visit = (node: Record<string, unknown>): Record<string, unknown> => {
if (typeof node.text === "string") return { ...node, text: replacements.get(node) ?? node.text };
if (Array.isArray(node.children)) return { ...node, children: node.children.map(child => visit(child as Record<string, unknown>)) };
return node;
};
return visit(topic);
}

View File

@ -0,0 +1,268 @@
import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
export const TASK_PROVIDER = "org.xmind.ui.task";
import {
DAY,
dateIndex,
indexDate,
addDays,
dateTimestamp,
timestampDate,
todayDate,
} from "./calendar-dates";
export {
DAY,
dateIndex,
indexDate,
addDays,
dateTimestamp,
timestampDate,
todayDate,
} from "./calendar-dates";
import {
activeWorkingCalendar,
getProjectCalendar,
taskDueDate,
taskStartDate,
preserveNonWorkingDue,
workingDaysBetween,
type ProjectCalendarConfig,
} from "./project-calendar";
export type TaskDependency = {
id: string;
type: "FS" | "SS" | "FF" | "SF";
lag: number;
};
export type TopicTask = {
start: string;
due: string;
duration: number;
progress: number;
priority: number | null;
assignees: string[];
dependencies: TaskDependency[];
creator?: string;
};
const record = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
export function getTaskContent(element: PlaitElement) {
const extensions = record(element.xmind).extensions;
if (!Array.isArray(extensions)) return null;
const ext = extensions.map(record).find((e) => e.provider === TASK_PROVIDER);
const content = record(ext?.content);
return typeof content.progress === "number" ? content : null;
}
export function getTopicTask(element: PlaitElement): TopicTask | null {
const c = getTaskContent(element);
if (!c) return null;
return {
start: timestampDate(c.start),
due: timestampDate(c.due),
duration: Math.max(1, Math.round(Number(c.duration || DAY) / DAY)),
progress: Math.max(0, Math.min(100, Math.round(Number(c.progress) * 100))),
priority: typeof c.priority === "number" ? c.priority : null,
assignees: Array.isArray(c.assignees)
? c.assignees.filter((s): s is string => typeof s === "string")
: [],
dependencies: Array.isArray(c.dependencies)
? c.dependencies.flatMap((d) => {
const item = record(d);
return typeof item.id === "string" &&
["FS", "SS", "FF", "SF"].includes(String(item.type))
? [
{
id: item.id,
type: item.type as TaskDependency["type"],
lag: Number(item.lag) || 0,
},
]
: [];
})
: [],
...(typeof c.creator === "string" ? { creator: c.creator } : {}),
};
}
export function newTopicTask(): TopicTask {
return {
start: todayDate(),
due: todayDate(),
duration: 1,
progress: 0,
priority: null,
assignees: [],
dependencies: [],
};
}
export function patchTopicTask(element: PlaitElement, task: TopicTask | null) {
const metadata = { ...record(element.xmind) };
const extensions = Array.isArray(metadata.extensions)
? metadata.extensions.map(record)
: [];
const existing = extensions.find((e) => e.provider === TASK_PROVIDER);
const others = extensions.filter((e) => e.provider !== TASK_PROVIDER);
if (task)
others.push({
...existing,
provider: TASK_PROVIDER,
content: {
...record(existing?.content),
...task,
start: dateTimestamp(task.start),
due: dateTimestamp(task.due, true),
duration: task.duration * DAY,
progress: task.progress / 100,
},
});
metadata.extensions = others;
return metadata;
}
export function validateTopicTask(
id: string,
task: TopicTask,
elements: PlaitElement[]
): string | null {
if (
(task.start && !Number.isFinite(dateIndex(task.start))) ||
(task.due && !Number.isFinite(dateIndex(task.due)))
)
return "请输入有效日期。";
if (task.start && task.due && task.due < task.start)
return "截止日期不能早于开始日期。";
if (
!Number.isInteger(task.duration) ||
task.duration < 1 ||
task.duration > 36500
)
return "工期必须是 1 至 36500 天。";
if (
!Number.isFinite(task.progress) ||
task.progress < 0 ||
task.progress > 100
)
return "进度必须在 0% 至 100% 之间。";
const entries = getTopicEntries(elements),
byId = new Map(entries.map((e) => [e.element.id, e]));
const me = byId.get(id);
for (const dep of task.dependencies) {
const target = byId.get(dep.id);
if (!target || !getTopicTask(target.element)) return "前置任务已不存在。";
if (!Number.isFinite(dep.lag)) return "间隔天数无效。";
if (
dep.id === id ||
(me &&
(target.path.every((n, i) => me.path[i] === n) ||
me.path.every((n, i) => target.path[i] === n)))
)
return "任务不能依赖自身、父任务或子任务。";
const visit = (current: string, seen = new Set<string>()): boolean => {
if (current === id) return true;
if (seen.has(current)) return false;
seen.add(current);
const item = byId.get(current);
return item
? (getTopicTask(item.element)?.dependencies || []).some((d) =>
visit(d.id, seen)
)
: false;
};
if (visit(dep.id)) return "前置任务形成循环,请调整依赖关系。";
}
return null;
}
export function getTaskRows(elements: PlaitElement[]) {
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
return getTopicEntries(elements).flatMap((entry) => {
const task = getTopicTask(entry.element);
if (!task) return [];
const descendants = getTopicEntries(entry.element.children || [])
.map((e) => getTopicTask(e.element))
.filter((t): t is TopicTask => Boolean(t?.start && t.due));
if (!descendants.length) return [{ ...entry, task, summary: false }];
const start = descendants.map((t) => t.start).sort()[0],
due = descendants
.map((t) => t.due)
.sort()
.at(-1)!;
const total = descendants.reduce((s, t) => s + t.duration, 0);
return [
{
...entry,
summary: true,
task: {
...task,
start,
due,
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
progress: Math.round(
descendants.reduce((s, t) => s + t.progress * t.duration, 0) / total
),
},
},
];
});
}
/** Propagate finish/start constraints in topological order; the caller commits one undo batch. */
export function scheduleTaskChanges(
elements: PlaitElement[],
id: string,
task: TopicTask,
config: ProjectCalendarConfig = getProjectCalendar(elements)
): Map<string, TopicTask> {
const error = validateTopicTask(id, task, elements);
if (error) throw new Error(error);
const calendar = activeWorkingCalendar(config);
const tasks = new Map(
getTopicEntries(elements).flatMap((e) => {
const t = getTopicTask(e.element);
return t ? [[e.element.id, t] as const] : [];
})
);
tasks.set(id, task);
const changed = new Map<string, TopicTask>([[id, task]]),
visiting = new Set<string>(),
done = new Set<string>();
const visit = (key: string) => {
if (done.has(key)) return;
if (visiting.has(key)) return;
visiting.add(key);
const t = tasks.get(key)!;
for (const d of t.dependencies) if (tasks.has(d.id)) visit(d.id);
if (t.start && t.due) {
let earliest = dateIndex(t.start);
for (const d of t.dependencies) {
const before = tasks.get(d.id);
if (!before?.start || !before.due) continue;
const lag = d.lag / DAY;
const anchor = d.type[0] === "F" ? before.due : before.start;
const endpoint = addDays(
anchor,
d.type[0] === "F" ? Math.ceil(lag) : Math.floor(lag)
);
const constraint =
d.type === "FS"
? addDays(endpoint, 1)
: d.type === "SS"
? endpoint
: taskStartDate(endpoint, t.duration, calendar);
earliest = Math.max(earliest, dateIndex(constraint));
}
const start = indexDate(earliest);
const due = preserveNonWorkingDue(
t.due,
taskDueDate(start, t.duration, calendar),
calendar
);
if (start !== t.start || (calendar && due !== t.due)) {
const next = { ...t, start, due };
tasks.set(key, next);
changed.set(key, next);
}
}
visiting.delete(key);
done.add(key);
};
tasks.forEach((_, key) => visit(key));
return changed;
}

View File

@ -0,0 +1,360 @@
import { getXmindMarker } from "./xmind-markers";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
getTopicEntries,
getTopicTitle,
} from "./topic-details";
import { isMindAnnotation } from "./mind-annotations";
import {
SPECIAL_MIND_LAYOUTS,
isSpecialMindLayout,
} from "./mind-special-layout";
type Data = Record<string, unknown>;
type Sheet = { id: string; title: string; children: PlaitElement[] };
export type XmindExportSnapshot = {
activeSheetId?: string;
children: PlaitElement[];
sheets?: Sheet[];
};
const record = (value: unknown): Data =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Data)
: {};
const array = (value: unknown): Data[] =>
Array.isArray(value) ? value.map(record) : [];
const id = () => crypto.randomUUID();
const layouts: Record<string, string> = {
standard: "org.xmind.ui.map.unbalanced",
right: "org.xmind.ui.logic.right",
left: "org.xmind.ui.logic.left",
downward: "org.xmind.ui.org-chart.down",
upward: "org.xmind.ui.org-chart.up",
"right-bottom-indented": "org.xmind.ui.tree.right",
"left-bottom-indented": "org.xmind.ui.tree.left",
};
const summary = (element: PlaitElement) =>
Number.isInteger(element.start) && Number.isInteger(element.end);
function textOf(value: unknown): string {
const item = record(value);
return typeof item.text === "string"
? item.text
: array(item.children).map(textOf).join("");
}
/** Modern XMind ZIP model: https://github.com/xmindltd/xmind-model/tree/master/schemas */
export async function exportXmind(
snapshot: XmindExportSnapshot,
title: string
) {
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
const warnings = new Set<string>();
const entries: Record<string, object> = {
"content.json": {},
"metadata.json": {},
};
let resourceNumber = 0;
const embed = async (url: string, name: string) => {
if (!/^(data:|https?:)/i.test(url))
throw new Error(`无法导出资源:${name}`);
const response = await fetch(url);
if (!response.ok)
throw new Error(`无法读取资源:${name},请确认图片仍可访问`);
const bytes = new Uint8Array(await response.arrayBuffer());
const safeName =
name.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_").slice(-120) || "resource";
const path = `resources/${++resourceNumber}/${safeName}`;
zip.file(path, bytes);
entries[path] = {};
return `xap:${path}`;
};
const sourceSheets = snapshot.sheets?.length
? snapshot.sheets
: [{ id: "sheet-1", title, children: snapshot.children }];
const sheets = [];
for (const source of sourceSheets) {
const elements =
source.id === (snapshot.activeSheetId || sourceSheets[0].id)
? snapshot.children
: source.children;
const topics = getTopicEntries(elements);
const ids = new Map(topics.map(({ element }) => [element.id, id()]));
const converted = new Map<string, Data>();
const parents = new Map<string, PlaitElement>();
const build = async (element: PlaitElement): Promise<Data> => {
const data = record(element.data);
const details = getTopicDetails(element);
const metadata = record(element.xmind);
const topic: Data = {
id: ids.get(element.id),
class: "topic",
title: getTopicTitle(element),
};
converted.set(element.id, topic);
if (element.isCollapsed) topic.branch = "folded";
if (element.layout) {
topic.structureClass = layouts[String(element.layout)] || layouts.right;
if (!layouts[String(element.layout)])
warnings.add("部分布局已转换为向右逻辑图");
}
if (isSpecialMindLayout(element.specialLayout))
topic.structureClass =
SPECIAL_MIND_LAYOUTS[element.specialLayout].structure;
if (element.specialLayout === "spreadsheet") {
const configured = Array.isArray(element.spreadsheetColumns)
? element.spreadsheetColumns.filter(
(column): column is string =>
typeof column === "string" && Boolean(column.trim())
)
: [];
const derived = (element.children || []).flatMap(
(child) => getTopicDetails(child).labels
);
const columns = [
...new Set(configured.length ? configured : derived),
];
if (!columns.length) columns.push("列 1", "列 2", "列 3");
topic.extensions = [
{
provider: "org.xmind.ui.spreadsheet",
content: [
{
name: "columns",
content: columns.map((column) => ({
name: "column",
content: column,
})),
},
],
},
];
}
if (
element.layout === "standard" &&
typeof element.rightNodeCount === "number"
)
topic.extensions = [
{
provider: topic.structureClass,
content: [
{ name: "right-number", content: String(element.rightNodeCount) },
],
},
];
// Merge native task extensions with structure extensions; remap every dependency to exported topic IDs.
const nativeExtensions = array(metadata.extensions).filter(ext => !["org.xmind.ui.spreadsheet", ...Object.values(layouts)].includes(String(ext.provider))).map(ext => {
if (ext.provider !== "org.xmind.ui.task") return ext;
const content = record(ext.content);
return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: array(content.dependencies).flatMap(dep => {
const target = ids.get(String(dep.id));
if (!target) {warnings.add("指向缺失主题的任务依赖未导出"); return [];}
return [{...dep, id: target}];
})} : {})}};
});
if (nativeExtensions.length) topic.extensions = [...array(topic.extensions), ...nativeExtensions];
const leaves = array(record(data.topic).children);
const leaf = leaves[0] || {};
if (leaves.length > 1) warnings.add("主题内混合文字格式已按首段样式导出");
const properties: Record<string, string> = {};
const copy = (key: string, value: unknown) => {
if (typeof value === "string" || typeof value === "number")
properties[key] = String(value);
};
copy("svg:fill", element.fill);
if (element.fill === "transparent") properties["fill-pattern"] = "none";
copy("border-line-color", element.strokeColor);
copy("border-line-width", element.strokeWidth);
copy("line-color", element.branchColor);
copy("line-width", element.branchWidth);
if (element.branchShape)
properties["line-class"] =
element.branchShape === "polyline"
? "org.xmind.branchConnection.elbow"
: "org.xmind.branchConnection.curve";
if (element.shape)
properties["shape-class"] =
element.shape === "underline"
? "org.xmind.topicShape.underline"
: "org.xmind.topicShape.roundedRect";
copy("fo:color", leaf.color);
copy("fo:font-size", leaf["font-size"]);
copy("fo:font-family", leaf["font-family"]);
if (leaf.bold) properties["fo:font-weight"] = "bold";
if (leaf.italic) properties["fo:font-style"] = "italic";
if (leaf.underlined || leaf.underline)
properties["fo:text-decoration"] = "underline";
if (Object.keys(properties).length)
topic.style = { id: id(), properties };
if (details.notes)
topic.notes =
typeof metadata.notes === "object"
? metadata.notes
: { plain: { content: details.notes } };
if (details.labels.length) topic.labels = details.labels;
const markerSet = new Set<string>();
for (const emoji of array(data.emojis)) {
const marker = getXmindMarker(String(emoji.name))?.id;
if (marker) markerSet.add(marker);
else warnings.add("部分自定义图标尚不能写入 XMind 标记");
}
// Retain imported markers that this editor cannot render, but honor removal of supported ones.
for (const marker of array(metadata.markers))
if (
typeof marker.markerId === "string" &&
!getXmindMarker(marker.markerId)
)
markerSet.add(marker.markerId);
if (markerSet.size)
topic.markers = [...markerSet].map((markerId) => ({ markerId }));
if (details.topicLinkId && ids.has(details.topicLinkId))
topic.href = `xmind:#${ids.get(details.topicLinkId)}`;
else if (details.href) topic.href = details.href;
if (details.attachment) {
if (topic.href)
warnings.add("同时含附件和链接的主题优先导出附件,其他链接暂不导出");
topic.href = await embed(
details.attachment.url,
details.attachment.name
);
}
const picture = record(data.image);
if (typeof picture.url === "string") {
const mime =
picture.url.match(/^data:image\/(png|jpeg|gif|webp)/)?.[1] || "png";
topic.image = {
src: await embed(picture.url, `image.${mime}`),
width: picture.width,
height: picture.height,
};
}
const attached = (element.children || []).filter(
(child) => !summary(child)
);
const summaries = (element.children || []).filter(summary);
const groups: Data = {};
for (const child of attached) parents.set(child.id, element);
if (attached.length)
groups.attached = await Promise.all(attached.map(build));
if (summaries.length) {
groups.summary = await Promise.all(summaries.map(build));
topic.summaries = summaries.map((child) => ({
id: id(),
topicId: ids.get(child.id),
range: `(${child.start},${child.end})`,
}));
}
if (Object.keys(groups).length) topic.children = groups;
return topic;
};
const roots = elements.filter((element) =>
["mind", "mindmap"].includes(String(element.type))
);
if (!roots.length)
throw new Error(`“${source.title}”没有可导出的思维导图主题`);
const root = await build(roots[0]);
if (roots.length > 1) {
const detached = [];
const origin = Array.isArray(roots[0].points)
? (roots[0].points[0] as number[])
: [0, 0];
for (const element of roots.slice(1)) {
const topic = await build(element);
const point = Array.isArray(element.points)
? (element.points[0] as number[])
: [500, 0];
topic.position = { x: point[0] - origin[0], y: point[1] - origin[1] };
detached.push(topic);
}
root.children = { ...record(root.children), detached };
}
const relationships = [];
for (const element of elements) {
if (roots.includes(element)) continue;
if (!isMindAnnotation(element)) {
warnings.add("自由画布中的图形、文本和独立图片暂不导出到 XMind");
continue;
}
const annotation = element.mindAnnotation;
const style = {
id: id(),
properties: {
"line-color": String(element.strokeColor || "#64748b"),
"line-width": String(element.strokeWidth || 2),
},
};
if (annotation.kind === "relationship") {
if (!ids.has(annotation.sourceId) || !ids.has(annotation.targetId)) {
warnings.add("目标缺失的联系线未导出");
continue;
}
relationships.push({
id: id(),
end1Id: ids.get(annotation.sourceId),
end2Id: ids.get(annotation.targetId),
title: array(element.texts)
.map((item) => textOf(item.text))
.join("\n"),
style,
controlPoints: {
"0": { angle: 0, amount: 0.1 },
"1": { angle: 0, amount: 0.1 },
},
});
} else {
const parent = parents.get(annotation.topicIds[0]);
const siblings =
parent?.children?.filter((child) => !summary(child)) || [];
const indices = annotation.topicIds
.map((topicId) => siblings.findIndex((child) => child.id === topicId))
.sort((a, b) => a - b);
if (
!parent ||
!indices.length ||
indices[0] < 0 ||
indices.at(-1)! - indices[0] + 1 !== indices.length
) {
warnings.add("非连续同级主题的外框暂不导出");
continue;
}
const owner = converted.get(parent.id)!;
owner.boundaries = [
...array(owner.boundaries),
{
id: id(),
range: `(${indices[0]},${indices.at(-1)})`,
title: String(record(element.xmind).title || ""),
style,
},
];
}
}
sheets.push({
id: id(),
class: "sheet",
title: source.title,
rootTopic: root,
...record(record(topics.find(entry => !parents.has(entry.element.id))?.element.xmind).sheet),
relationships,
});
}
zip.file("content.json", JSON.stringify(sheets));
zip.file(
"metadata.json",
JSON.stringify({
dataStructureVersion: "2",
creator: { name: "SnapGen", version: "1.0" },
layoutEngineVersion: "4",
})
);
zip.file("manifest.json", JSON.stringify({ "file-entries": entries }));
return {
blob: await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
mimeType: "application/vnd.xmind.workbook",
}),
warnings: [...warnings],
};
}

View File

@ -0,0 +1,668 @@
import type JSZip from "jszip";
import { getXmindMarker, xmindMarkerName } from "./xmind-markers";
import { getTopicEntries } from "./topic-details";
import {
SPECIAL_MIND_LAYOUTS,
specialLayoutForStructure,
} from "./mind-special-layout";
import type { PlaitElement } from "@plait/core";
import {
ArrowLineMarkerType,
ArrowLineShape,
BasicShapes,
createArrowLineElement,
createGeometryElementWithoutText,
} from "@plait/draw";
import { MindLayoutType } from "@plait/layouts";
import {
createMindElement,
BranchShape,
MindElementShape,
type MindElement,
type PlaitMind,
} from "@plait/mind";
export class MindMapImportError extends Error {}
export type ImportedMindMap = {
sheets: { id: string; title: string; children: PlaitElement[] }[];
warnings: string[];
};
type Data = Record<string, unknown>;
const record = (value: unknown): Data =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Data)
: {};
const list = (value: unknown): Data[] =>
Array.isArray(value)
? value.filter(
(item) => item && typeof item === "object" && !Array.isArray(item)
)
: [];
const text = (value: unknown, fallback = "") =>
typeof value === "string" ? value.trim() || fallback : fallback;
const children = (element: Element, name: string) =>
Array.from(element.children).filter((child) => child.localName === name);
const first = (element: Element, name: string) => children(element, name)[0];
const color = (value: unknown) =>
typeof value === "string" && /^#[\da-f]{3,8}$/i.test(value)
? value
: undefined;
function spreadsheetColumns(topic: Data) {
const extension = list(topic.extensions).find(
(item) => item.provider === "org.xmind.ui.spreadsheet"
);
const result: string[] = [];
const visit = (value: unknown) => {
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
const item = record(value);
if (item.name === "column") {
const value = text(item.content);
if (value) result.push(value);
return;
}
Object.values(item).forEach(visit);
};
visit(extension?.content);
return [...new Set(result)];
}
// XMind's public model: https://github.com/xmindltd/xmind-model/tree/master/schemas
const layouts: Record<string, MindLayoutType> = {
"org.xmind.ui.map.unbalanced": MindLayoutType.standard,
"org.xmind.ui.map.clockwise": MindLayoutType.standard,
"org.xmind.ui.logic.right": MindLayoutType.right,
"org.xmind.ui.logic.left": MindLayoutType.left,
"org.xmind.ui.org-chart.down": MindLayoutType.downward,
"org.xmind.ui.org-chart.up": MindLayoutType.upward,
"org.xmind.ui.tree.right": MindLayoutType.rightBottomIndented,
"org.xmind.ui.tree.left": MindLayoutType.leftBottomIndented,
};
function parseXml(source: string) {
const doc = new DOMParser().parseFromString(source, "application/xml");
if (doc.getElementsByTagName("parsererror").length)
throw new MindMapImportError("XMind 文件中的 XML 内容已损坏");
return doc;
}
function xmlSheets(source: string, stylesSource?: string): Data[] {
const styles = new Map<string, Data>();
if (stylesSource) {
for (const element of Array.from(
parseXml(stylesSource).getElementsByTagNameNS("*", "style")
)) {
const properties: Data = {};
for (const property of Array.from(element.children)) {
for (const attribute of Array.from(property.attributes))
properties[attribute.name] = attribute.value;
}
styles.set(element.getAttribute("id") || "", properties);
}
}
const style = (element: Element) => ({
properties: styles.get(element.getAttribute("style-id") || "") || {},
});
const convert = (element: Element, depth = 0): Data => {
if (depth > 128)
throw new MindMapImportError("主题层级超过 128 层,请拆分后导入");
const childGroups: Data = {};
const container = first(element, "children");
if (container)
for (const group of children(container, "topics")) {
const kind = group.getAttribute("type") || "attached";
childGroups[kind] = children(group, "topic").map((child) =>
convert(child, depth + 1)
);
}
const collection = (containerName: string, itemName: string) => {
const parent = first(element, containerName);
return parent
? children(parent, itemName).map((item) => ({
...Object.fromEntries(
Array.from(item.attributes).map((attr) => [attr.name, attr.value])
),
title: first(item, "title")?.textContent || "",
style: style(item),
topicId: item.getAttribute("topic-id"),
}))
: [];
};
const notes = first(element, "notes");
const plainNote = notes && first(notes, "plain");
const labels = first(element, "labels");
const image =
first(element, "image") ||
Array.from(element.children).find((child) => child.localName === "img");
const position = first(element, "position");
return {
id: element.getAttribute("id"),
title: first(element, "title")?.textContent,
structureClass: element.getAttribute("structure-class"),
branch: element.getAttribute("branch"),
children: childGroups,
style: style(element),
notes: (plainNote || notes)?.textContent?.trim() || undefined,
href:
element.getAttributeNS("http://www.w3.org/1999/xlink", "href") ||
undefined,
labels: labels
? children(labels, "label")
.map((label) => label.textContent || "")
.filter(Boolean)
: undefined,
image: image
? {
src:
image.getAttribute("src") ||
image.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
width: image.getAttribute("width"),
height: image.getAttribute("height"),
}
: undefined,
position: position
? {
x: Number(position.getAttribute("x")),
y: Number(position.getAttribute("y")),
}
: undefined,
summaries: collection("summaries", "summary"),
boundaries: collection("boundaries", "boundary"),
markers: collection("marker-refs", "marker-ref").map((marker) => ({
markerId: record(marker)["marker-id"],
})),
extensions: first(element, "extensions")
? Array.from(first(element, "extensions")!.children).map(
(extension) => {
const extensionItem = (item: Element): Data => ({
name: item.localName,
content: item.children.length
? Array.from(item.children).map(extensionItem)
: item.textContent,
});
return {
provider: extension.getAttribute("provider"),
content: Array.from(extension.children).map(extensionItem),
};
}
)
: [],
};
};
const doc = parseXml(source);
return Array.from(doc.getElementsByTagNameNS("*", "sheet")).map((sheet) => {
const root = first(sheet, "topic");
const relationships = first(sheet, "relationships");
return {
title: first(sheet, "title")?.textContent,
rootTopic: root ? convert(root) : undefined,
relationships: relationships
? children(relationships, "relationship").map((item) => ({
end1Id: item.getAttribute("end1"),
end2Id: item.getAttribute("end2"),
title: first(item, "title")?.textContent,
style: style(item),
}))
: [],
};
});
}
function parseRange(value: unknown, count: number) {
const match = text(value).match(/^\((\d+)\s*,\s*(\d+)\)$/);
if (!match) return null;
const start = Number(match[1]);
const end = Number(match[2]);
return start <= end && end < count ? { start, end } : null;
}
function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
const warnings = new Set<string>();
let nodeCount = 0;
if (
!sheets.length ||
sheets.some((sheet) => !Object.keys(record(sheet.rootTopic)).length)
) {
throw new MindMapImportError("XMind 文件没有有效的工作表或中心主题");
}
return {
sheets: sheets.map((sheet, index) => {
const topicIds = new Map<string, string>();
const floating: PlaitElement[] = [];
const annotations: PlaitElement[] = [];
const theme = record(sheet.theme);
const palette = text(record(record(theme.map).properties)["multi-line-colors"] || record(record(theme.map).properties)["color-list"]).split(/\s+/).map(color).filter((value): value is string => Boolean(value));
const build = (
topic: Data,
depth: number,
root = false,
floatingRoot = false,
inheritedBranchColor = ""
): MindElement => {
if (++nodeCount > 10_000 || depth > 128)
throw new MindMapImportError(
"文件超过 10000 个主题或 128 层,请拆分后导入"
);
const element = createMindElement(text(topic.title, "未命名主题"), {});
if (typeof topic.id === "string") topicIds.set(topic.id, element.id);
const groups = Array.isArray(topic.children)
? { attached: topic.children }
: record(topic.children);
const structure = text(topic.structureClass);
const specialLayout = specialLayoutForStructure(structure);
if (specialLayout) element.specialLayout = specialLayout;
if (root && specialLayout === "spreadsheet") {
const columns = spreadsheetColumns(topic);
if (columns.length) element.spreadsheetColumns = columns;
}
if (specialLayout && !root) warnings.add("子树的特殊结构暂未单独排布");
if (structure && !layouts[structure] && !specialLayout)
warnings.add("部分特殊结构已转换为逻辑图");
if (structure || root)
element.layout = specialLayout
? (SPECIAL_MIND_LAYOUTS[specialLayout].base as MindLayoutType)
: layouts[structure] || MindLayoutType.right;
element.isCollapsed = topic.branch === "folded";
const properties = {
...record(
record(
theme[
floatingRoot
? "floatingTopic"
: root
? "centralTopic"
: depth === 1
? "mainTopic"
: "subTopic"
]
).properties
),
...record(record(topic.style).properties),
};
const fill = color(properties["svg:fill"]);
if (properties["fill-pattern"] === "none") element.fill = "transparent";
else if (fill) element.fill = fill;
else if (properties["svg:fill"] === "inherited" && inheritedBranchColor) element.fill = inheritedBranchColor;
const stroke = color(properties["border-line-color"]);
if (stroke) element.strokeColor = stroke;
const branch = color(properties["line-color"]);
if (branch) element.branchColor = branch;
else if (inheritedBranchColor) element.branchColor = inheritedBranchColor;
const borderWidth = Number.parseFloat(
String(properties["border-line-width"] || "")
);
if (Number.isFinite(borderWidth))
element.strokeWidth = Math.max(0, Math.min(16, borderWidth));
if (borderWidth === 0 || properties["border-line-pattern"] === "none") element.strokeColor = "transparent";
const branchWidth = Number.parseFloat(
String(properties["line-width"] || "")
);
if (Number.isFinite(branchWidth))
element.branchWidth = Math.max(0, Math.min(16, branchWidth));
if (properties["line-class"] === "org.xmind.branchConnection.elbow")
element.branchShape = BranchShape.polyline;
else if (
properties["line-class"] === "org.xmind.branchConnection.curve"
)
element.branchShape = BranchShape.bight;
const shape = text(properties["shape-class"]);
if (shape.endsWith(".underline"))
element.shape = MindElementShape.underline;
else if (shape.endsWith(".roundedRect"))
element.shape = MindElementShape.roundRectangle;
const fontSize = Number.parseFloat(
String(properties["fo:font-size"] || "")
);
const leaf = {
text: text(topic.title, "未命名主题"),
...(properties["fo:color"] === "inherited" ? { color: "#000000" } : {}),
...(color(properties["fo:color"])
? { color: color(properties["fo:color"]) }
: {}),
...(Number.isFinite(fontSize)
? { "font-size": Math.max(8, Math.min(96, fontSize)) }
: {}),
...(properties["fo:font-weight"] === "bold" ||
Number(properties["fo:font-weight"]) >= 600
? { bold: true }
: {}),
...(properties["fo:font-style"] === "italic" ? { italic: true } : {}),
...(properties["fo:text-decoration"] === "underline"
? { underlined: true }
: {}),
...(typeof properties["fo:font-family"] === "string"
? { "font-family": properties["fo:font-family"] }
: {}),
};
element.data.topic.children = [leaf];
const metadata: Data = {};
for (const key of ["notes", "href", "labels", "markers", "image", "extensions"])
if (topic[key] !== undefined) metadata[key] = topic[key];
if (Object.keys(metadata).length) element.xmind = metadata;
const emojis: string[] = [];
for (const marker of list(topic.markers)) {
const id = text(marker.markerId);
if (getXmindMarker(id)) emojis.push(xmindMarkerName(id));
else warnings.add("部分图标已保留为数据,暂不显示");
}
if (emojis.length)
element.data.emojis = emojis.map((name) => ({ name }));
element.children = list(groups.attached).map((child,index) =>
build(child, depth + 1, false, false, depth === 0 && palette.length ? palette[index % palette.length] : inheritedBranchColor)
);
const attachedCount = element.children.length;
if (element.layout === MindLayoutType.standard) {
const extension = list(topic.extensions).find(
(item) => item.provider === structure
);
const right = list(extension?.content).find(
(item) => item.name === "right-number"
);
const n = Number(right?.content ?? Math.ceil(attachedCount / 2));
element.rightNodeCount = Number.isInteger(n)
? Math.max(0, Math.min(attachedCount, n))
: Math.ceil(attachedCount / 2);
}
const usedSummaries = new Set<string>();
for (const summary of list(topic.summaries)) {
const range = parseRange(summary.range, attachedCount);
const summaryTopic = list(groups.summary).find(
(item) => item.id === summary.topicId
);
if (range && summaryTopic) {
const child = build(summaryTopic, depth + 1);
child.start = range.start;
child.end = range.end;
element.children.push(child);
usedSummaries.add(text(summaryTopic.id));
} else warnings.add("部分概要范围无法还原");
}
for (const boundary of list(topic.boundaries)) {
const range = parseRange(boundary.range, attachedCount);
if (!range) {
warnings.add("部分外框范围无法还原");
continue;
}
const props = record(record(boundary.style).properties);
const shape = createGeometryElementWithoutText(
BasicShapes.roundRectangle,
[
[0, 0],
[1, 1],
],
{
fill: "transparent",
strokeColor: color(props["line-color"]) || "#8b9bb8",
strokeWidth: 2,
}
);
annotations.push({
...shape,
mindAnnotation: {
kind: "boundary",
topicIds: element.children
.slice(range.start, range.end + 1)
.map((child) => child.id),
padding: 18,
},
xmind: { title: boundary.title },
});
if (boundary.title) warnings.add("外框标题已保留为数据,暂不显示");
}
const extraTopics = [
...list(groups.detached),
...list(groups.callout),
...list(groups.summary).filter(
(item) => !usedSummaries.has(text(item.id))
),
];
if (list(groups.callout).length)
warnings.add("标注主题已转换为自由主题");
for (const extra of extraTopics) {
const detached = build(extra, depth + 1, true, true) as PlaitMind;
const pos = record(extra.position);
detached.points = [
[
typeof pos.x === "number" && Number.isFinite(pos.x) ? pos.x : 500,
typeof pos.y === "number" && Number.isFinite(pos.y)
? pos.y
: floating.length * 180,
],
];
floating.push(detached);
}
if (root) {
element.type = "mindmap";
element.isRoot = true;
element.points = [[0, 0]];
}
return element;
};
const root = build(record(sheet.rootTopic), 0, true);
const sheetMetadata: Data = {};
for (const key of ["extensions", "nonWorkingDaysSkipping", "topicTasksDisplay", "topicTasksCreatorVisibility"]) if (sheet[key] !== undefined) sheetMetadata[key] = sheet[key];
if (Object.keys(sheetMetadata).length) root.xmind = {...record(root.xmind), sheet: sheetMetadata};
for (const { element } of getTopicEntries([root, ...floating])) {
const metadata = record(element.xmind);
if (Array.isArray(metadata.extensions)) metadata.extensions = metadata.extensions.map(value => {
const ext = record(value);
if (ext.provider !== "org.xmind.ui.task") return ext;
const content = record(ext.content);
return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: content.dependencies.map(value => {
const dep = record(value), mapped = topicIds.get(text(dep.id));
if (!mapped) warnings.add("部分任务依赖指向缺失主题,已保留引用");
return {...dep, id: mapped || dep.id};
})} : {})}};
});
if (Object.keys(metadata).length) element.xmind = metadata;
if (
typeof metadata.href === "string" &&
metadata.href.startsWith("xmind:#")
) {
const targetId = topicIds.get(metadata.href.slice(7));
if (targetId)
element.xmind = { ...metadata, href: "", topicLinkId: targetId };
else warnings.add("部分主题链接的目标不在当前画布,暂未还原");
}
}
for (const relationship of list(sheet.relationships)) {
const sourceId = topicIds.get(text(relationship.end1Id));
const targetId = topicIds.get(text(relationship.end2Id));
if (!sourceId || !targetId) {
warnings.add("部分联系线的目标主题缺失,未能还原");
continue;
}
const label = {
type: "paragraph",
children: [{ text: text(relationship.title) }],
};
const props = record(record(relationship.style).properties);
const shape = createArrowLineElement(
ArrowLineShape.curve,
[
[0, 0],
[1, 1],
],
{ marker: ArrowLineMarkerType.none },
{ marker: ArrowLineMarkerType.arrow },
relationship.title ? [{ position: 0.5, text: label }] : [],
{
strokeColor: color(props["line-color"]) || "#64748b",
strokeWidth: 2,
}
);
annotations.push({
...shape,
mindAnnotation: { kind: "relationship", sourceId, targetId },
});
}
return {
id: `xmind-${crypto.randomUUID()}`,
title: text(
sheet.title,
sheets.length === 1 ? fallbackTitle : `画布 ${index + 1}`
),
children: [
...annotations.filter((item) => item.type === "geometry"),
root,
...floating,
...annotations.filter((item) => item.type !== "geometry"),
],
};
}),
warnings: [...warnings],
};
}
async function restoreResources(
result: ImportedMindMap,
zip: JSZip
): Promise<ImportedMindMap> {
const warnings = new Set(result.warnings);
const cache = new Map<string, { url: string; bytes: Uint8Array }>();
let totalBytes = 0;
const readResource = async (source: string) => {
const path = decodeURIComponent(
source.replace(/^xap:/, "").replace(/^\//, "")
);
if (/^[a-z]+:/i.test(path) || path.split("/").includes("..")) return null;
const file =
zip.file(path) ||
Object.values(zip.files).find(
(entry) => !entry.dir && entry.name.endsWith(`/${path}`)
);
if (!file) return null;
if (cache.has(file.name)) return cache.get(file.name)!;
const bytes = await file.async("uint8array");
totalBytes += bytes.length;
if (bytes.length > 8 * 1024 * 1024 || totalBytes > 24 * 1024 * 1024) {
warnings.add("部分内嵌资源超过大小限制,暂未导入");
return null;
}
const value = { bytes, url: await file.async("base64") };
cache.set(file.name, value);
return value;
};
for (const sheet of result.sheets)
for (const { element } of getTopicEntries(sheet.children)) {
const metadata = record(element.xmind);
const image = record(metadata.image);
if (typeof image.src === "string") {
try {
const resource = await readResource(image.src);
if (!resource) throw new Error("Missing image");
const b = resource.bytes;
const mime =
b[0] === 137 && b[1] === 80
? "image/png"
: b[0] === 255 && b[1] === 216
? "image/jpeg"
: String.fromCharCode(...b.slice(0, 3)) === "GIF"
? "image/gif"
: String.fromCharCode(...b.slice(0, 4)) === "RIFF" &&
String.fromCharCode(...b.slice(8, 12)) === "WEBP"
? "image/webp"
: null;
if (!mime) throw new Error("Unsupported image");
const bitmap = await createImageBitmap(
new Blob([new Uint8Array(b)], { type: mime })
);
const requested = Number(image.width);
const width = Math.min(
500,
requested > 0 ? requested : Math.min(320, bitmap.width)
);
const height = (width * bitmap.height) / bitmap.width;
bitmap.close();
element.data = {
...record(element.data),
image: {
url: `data:${mime};base64,${resource.url}`,
width,
height,
},
};
} catch {
warnings.add("部分图片缺失、损坏或格式暂不支持");
}
}
if (
typeof metadata.href === "string" &&
metadata.href.startsWith("xap:")
) {
try {
const resource = await readResource(metadata.href);
if (!resource) throw new Error("Missing attachment");
const name = decodeURIComponent(
metadata.href.split("/").pop() || "附件"
);
element.xmind = {
...metadata,
href: "",
attachment: {
name,
mimeType: "application/octet-stream",
url: `data:application/octet-stream;base64,${resource.url}`,
},
};
} catch {
warnings.add("部分附件缺失或超过大小限制,暂未导入");
}
}
}
return { ...result, warnings: [...warnings] };
}
export async function importXmind(
file: File,
fallbackTitle: string
): Promise<ImportedMindMap> {
if (file.size > 50 * 1024 * 1024)
throw new MindMapImportError("XMind 文件超过 50 MB,请拆分后导入");
const { default: JSZip } = await import("jszip");
let zip;
try {
zip = await JSZip.loadAsync(await file.arrayBuffer());
} catch {
throw new MindMapImportError("无法打开 XMind 文件,请确认文件完整且未加密");
}
const entry = (name: string) =>
zip.file(name) ||
Object.values(zip.files).find(
(item) => !item.dir && item.name.toLowerCase().endsWith(`/${name}`)
);
const json = entry("content.json");
const xml = entry("content.xml");
try {
if (json) {
const parsed: unknown = JSON.parse(await json.async("string"));
const sheets = Array.isArray(parsed)
? list(parsed)
: list(record(parsed).sheets);
return await restoreResources(convertSheets(sheets, fallbackTitle), zip);
}
if (xml)
return await restoreResources(
convertSheets(
xmlSheets(
await xml.async("string"),
await entry("styles.xml")?.async("string")
),
fallbackTitle
),
zip
);
} catch (error) {
if (error instanceof MindMapImportError) throw error;
throw new MindMapImportError(
"XMind 内容解析失败,请在 XMind 中重新保存后再导入"
);
}
throw new MindMapImportError(
"文件中未找到 XMind 内容,请选择有效的 .xmind 文件"
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
import catalog from "./xmind-marker-catalog.json" with { type: "json" };
export type XmindMarker = { id: string; name: string; asset: string; hidden: boolean };
export type XmindMarkerGroup = { id: string; label: string; hidden: boolean; markers: XmindMarker[] };
export const XMIND_MARKER_GROUPS: XmindMarkerGroup[] = catalog;
// The client resolves shared IDs to their first catalog entry.
const markers = new Map<string, XmindMarker & { groupId: string }>();
for (const group of catalog) {
for (const marker of group.markers) {
if (!markers.has(marker.id)) markers.set(marker.id, { ...marker, groupId: group.id });
}
}
const aliases: Record<string, string> = {
"🔴": "tag-red", "🟠": "tag-orange", "🟡": "tag-yellow", "🟢": "tag-green", "🔵": "tag-blue", "🟣": "tag-dark-purple", "⚪": "tag-grey",
"✅": "task-done", "▶️": "task-start", "◔": "task-quarter", "◑": "task-half", "◕": "task-3quar", "🚧": "task-5oct",
"🚩": "flag-red", "⭐": "star-red", "🌟": "star-yellow", "👤": "people-blue",
"❗": "c_simbol-exclam", "❓": "c_simbol-question", "💡": "symbol-idea", "❤️": "c_symbol_heart", "⚡": "symbol-lightning", "📌": "symbol-pin",
...Object.fromEntries(Array.from({ length: 9 }, (_, index) => [`${index + 1}️⃣`, `priority-${index + 1}`])),
};
export function getXmindMarker(name: string) {
return markers.get(name.startsWith("xmind:") ? name.slice(6) : aliases[name] || name);
}
export function xmindMarkerName(id: string) {
return `xmind:${id}`;
}
export function getXmindMarkerIds(emojis: { name: string }[] = []) {
return [...new Set(emojis.flatMap(emoji => {
const marker = getXmindMarker(emoji.name);
return marker ? [marker.id] : [];
}))];
}
/** Native markers are mutually exclusive within each group. Unrelated emoji stay intact. */
export function toggleXmindMarker(emojis: { name: string }[], markerId: string, remove: boolean) {
const marker = getXmindMarker(markerId);
if (!marker) return emojis;
const remaining = emojis.filter(emoji => getXmindMarker(emoji.name)?.groupId !== marker.groupId);
return remove ? remaining : [...remaining, { name: xmindMarkerName(markerId) }];
}

View File

@ -0,0 +1,23 @@
import { measureElement, updateElementSizeCache } from "@plait/common";
import type { PlaitBoard } from "@plait/core";
import { getDefaultFontSizeForMindElement, NodeSpace, type MindElement, type PlaitMindBoard } from "@plait/mind";
const measured = new WeakMap<object, number>();
/** Plait measures with a system font by default. Seed its cache with the canvas font. */
export function measureXmindTopics(board: PlaitBoard, element: MindElement) {
const topic = element.data?.topic;
const maxWidth = NodeSpace.getTopicMaxDynamicWidth(board as PlaitMindBoard, element);
if (topic && measured.get(topic) !== maxWidth) {
const size = measureElement(board, topic, {
fontSize: getDefaultFontSizeForMindElement(element),
fontFamily: 'NeverMind, Arial, "PingFang SC", sans-serif',
}, maxWidth);
updateElementSizeCache(board, topic, {
width: Math.ceil(size.width) + 2,
height: Math.ceil(size.height),
});
measured.set(topic, maxWidth);
}
for (const child of element.children || []) measureXmindTopics(board, child);
}

View File

@ -3,6 +3,7 @@ import "server-only";
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import {
getImageArchiveTaskStats,
recoverStaleImageArchiveTasks,
getImageArchiveTasksToReplace,
getImageArchiveTasksToStore,
markImageArchiveTaskArchiving,
@ -26,14 +27,16 @@ export async function runImageArchiveJob() {
const batchSize = getBatchSize();
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
await recoverStaleImageArchiveTasks();
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
let storeSucceeded = 0;
let storeFailed = 0;
for (const task of storeTasks) {
const storeOne = async (task: (typeof storeTasks)[number]) => {
try {
await markImageArchiveTaskArchiving(task.id);
const archived = await archiveTemporaryImage(task.sourceUrl);
if (!(await markImageArchiveTaskArchiving(task.id))) return;
const metadata = task.metadata as { inlineSource?: string } | null;
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl);
await markImageArchiveTaskStored({
id: task.id,
targetUrl: archived.url,
@ -54,12 +57,13 @@ export async function runImageArchiveJob() {
error: error instanceof Error ? error.message : String(error),
});
}
}
};
const remainingSlots = Math.max(1, batchSize - storeTasks.length);
const replaceTasks = await getImageArchiveTasksToReplace({
limit: remainingSlots,
});
// Bucket work has a small worker pool independent of unrestricted model generation.
for (let index = 0; index < storeTasks.length; index += 4) {
await Promise.all(storeTasks.slice(index, index + 4).map(storeOne));
}
const replaceTasks = await getImageArchiveTasksToReplace({ limit: 50 });
let replaceSucceeded = 0;
let replaceFailed = 0;
@ -74,7 +78,7 @@ export async function runImageArchiveJob() {
}
try {
await markImageArchiveTaskReplacing(task.id);
if (!(await markImageArchiveTaskReplacing(task.id))) continue;
const result = await replaceImageUrlInDatabase(task.sourceUrl, task.targetUrl);
await markImageArchiveTaskCompleted({
id: task.id,

View File

@ -1,30 +1,34 @@
import "server-only";
import { and, asc, eq, inArray, lt, lte, sql } from "drizzle-orm";
import { createHash } from "node:crypto";
import {
isGeneratedImageSource,
collectImageArchiveSources,
replaceArchivedImageUrls,
} from "./image-archive-urls";
import { and, asc, eq, inArray, lt, lte, or, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { imageArchiveTask, type ImageArchiveTask } from "@/lib/db/schema";
export type ImageArchiveTaskRecord = ImageArchiveTask;
const DEFAULT_DISPLAY_HOLD_HOURS = 1;
const MAX_IMAGE_ARCHIVE_ATTEMPTS = 12;
const ARCHIVE_LEASE_MS = 30 * 60 * 1000;
function getDisplayHoldHours() {
const raw = Number(
process.env.IMAGE_ARCHIVE_DISPLAY_HOLD_HOURS || DEFAULT_DISPLAY_HOLD_HOURS
);
if (!Number.isFinite(raw)) return DEFAULT_DISPLAY_HOLD_HOURS;
return Math.max(0, Math.min(24 * 30, raw));
}
// Replace as soon as the durable object exists, regardless of legacy hold settings.
export function getImageArchiveDisplayHoldMs() {
return getDisplayHoldHours() * 60 * 60 * 1000;
return 0;
}
function isArchivableHttpUrl(value: string) {
if (!/^https?:\/\//i.test(value)) return false;
if (/^(data|blob):/i.test(value)) return false;
return true;
export function imageArchiveSourceKey(source: string) {
if (!source.startsWith("data:")) return source;
const configured =
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
const origin = configured
? new URL(configured).origin
: `http://localhost:${process.env.PORT || "3000"}`;
const key = createHash("sha256").update(source).digest("hex");
return `${origin}/api/generated-image-preview/${key}`;
}
export async function enqueueImageArchiveTask(params: {
@ -34,30 +38,35 @@ export async function enqueueImageArchiveTask(params: {
metadata?: Record<string, unknown> | null;
}) {
const sourceUrl = String(params.sourceUrl || "").trim();
if (!isArchivableHttpUrl(sourceUrl)) return null;
if (!isGeneratedImageSource(sourceUrl)) return null;
const now = new Date();
const archiveAfterAt = params.archiveAfterAt || now;
const replaceAfterAt =
params.replaceAfterAt ||
new Date(now.getTime() + getImageArchiveDisplayHoldMs());
params.replaceAfterAt || new Date(now.getTime() + getImageArchiveDisplayHoldMs());
const [task] = await db
.insert(imageArchiveTask)
.values({
sourceUrl,
sourceUrl: imageArchiveSourceKey(sourceUrl),
status: "pending",
archiveAfterAt,
replaceAfterAt,
metadata: params.metadata || null,
metadata: sourceUrl.startsWith("data:")
? { ...params.metadata, inlineSource: sourceUrl }
: params.metadata || null,
updatedAt: now,
})
.onConflictDoUpdate({
target: imageArchiveTask.sourceUrl,
set: {
replaceAfterAt,
metadata: params.metadata || undefined,
updatedAt: now,
metadata: sql`case when ${imageArchiveTask.targetUrl} is not null then ${imageArchiveTask.metadata}
else coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || ${JSON.stringify(
sourceUrl.startsWith("data:")
? { ...params.metadata, inlineSource: sourceUrl }
: params.metadata || {},
)}::jsonb end`,
},
})
.returning();
@ -71,10 +80,8 @@ export async function enqueueImageArchiveTasks(params: {
}) {
const urls = Array.from(
new Set(
(params.urls || [])
.map((url) => String(url || "").trim())
.filter(isArchivableHttpUrl)
)
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
),
);
const tasks: ImageArchiveTaskRecord[] = [];
@ -97,15 +104,15 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) {
and(
inArray(imageArchiveTask.status, ["pending", "failed"]),
lte(imageArchiveTask.archiveAfterAt, now),
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS)
)
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
),
)
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(Math.max(1, Math.min(50, params.limit)));
}
export async function markImageArchiveTaskArchiving(id: string) {
await db
const claimed = await db
.update(imageArchiveTask)
.set({
status: "archiving",
@ -113,7 +120,15 @@ export async function markImageArchiveTaskArchiving(id: string) {
lastError: null,
updatedAt: new Date(),
})
.where(eq(imageArchiveTask.id, id));
.where(
and(
eq(imageArchiveTask.id, id),
inArray(imageArchiveTask.status, ["pending", "failed"]),
lte(imageArchiveTask.archiveAfterAt, new Date()),
),
)
.returning({ id: imageArchiveTask.id });
return claimed.length > 0;
}
export async function markImageArchiveTaskStored(params: {
@ -128,18 +143,16 @@ export async function markImageArchiveTaskStored(params: {
targetUrl: params.targetUrl,
targetStorageConfigId: params.targetStorageConfigId || null,
archivedAt: new Date(),
replaceAfterAt: new Date(),
lastError: null,
updatedAt: new Date(),
})
.where(eq(imageArchiveTask.id, params.id));
}
export async function markImageArchiveTaskFailed(params: {
id: string;
error: unknown;
}) {
export async function markImageArchiveTaskFailed(params: { id: string; error: unknown }) {
const message = String(
params.error instanceof Error ? params.error.message : params.error || "Unknown error"
params.error instanceof Error ? params.error.message : params.error || "Unknown error",
).slice(0, 2000);
const isPermanentSourceError = /\bHTTP (?:404|410)\b/i.test(message);
@ -160,29 +173,35 @@ export async function markImageArchiveTaskFailed(params: {
.where(eq(imageArchiveTask.id, params.id));
}
function isArchiveReplacementDue(now: Date) {
return and(
or(
eq(imageArchiveTask.status, "stored"),
and(
eq(imageArchiveTask.status, "completed"),
sql`${imageArchiveTask.metadata}->>'replacementPasses' = '1'`,
),
),
lte(imageArchiveTask.replaceAfterAt, now),
);
}
export async function getImageArchiveTasksToReplace(params: { limit: number }) {
const now = new Date();
return await db
.select()
.from(imageArchiveTask)
.where(
and(
eq(imageArchiveTask.status, "stored"),
lte(imageArchiveTask.replaceAfterAt, now)
)
)
.where(isArchiveReplacementDue(new Date()))
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(Math.max(1, Math.min(50, params.limit)));
}
export async function markImageArchiveTaskReplacing(id: string) {
await db
const claimed = await db
.update(imageArchiveTask)
.set({
status: "replacing",
updatedAt: new Date(),
})
.where(eq(imageArchiveTask.id, id));
.set({ status: "replacing", updatedAt: new Date() })
.where(and(eq(imageArchiveTask.id, id), isArchiveReplacementDue(new Date())))
.returning({ id: imageArchiveTask.id });
return claimed.length > 0;
}
export async function markImageArchiveTaskCompleted(params: {
@ -195,23 +214,20 @@ export async function markImageArchiveTaskCompleted(params: {
status: "completed",
replacedAt: new Date(),
lastError: null,
metadata:
params.replacedCount === undefined
? undefined
: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || ${JSON.stringify({
replacedCount: params.replacedCount,
})}::jsonb`,
// Reconcile once more after delivery has settled: asynchronous generation/task
// writes may arrive just after the first replacement. The live URL is already ready.
replaceAfterAt: new Date(Date.now() + 60_000),
metadata: sql`(coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) - 'inlineSource')
|| jsonb_build_object('replacementPasses', coalesce((${imageArchiveTask.metadata}->>'replacementPasses')::int, 0) + 1)
|| ${JSON.stringify(params.replacedCount === undefined ? {} : { replacedCount: params.replacedCount })}::jsonb`,
updatedAt: new Date(),
})
.where(eq(imageArchiveTask.id, params.id));
}
export async function markImageArchiveTaskReplaceFailed(params: {
id: string;
error: unknown;
}) {
export async function markImageArchiveTaskReplaceFailed(params: { id: string; error: unknown }) {
const message = String(
params.error instanceof Error ? params.error.message : params.error || "Unknown error"
params.error instanceof Error ? params.error.message : params.error || "Unknown error",
).slice(0, 2000);
await db
@ -230,6 +246,7 @@ export async function getImageArchiveTaskStats() {
.select({
status: imageArchiveTask.status,
count: sql<number>`count(*)::int`,
reconciliationCount: sql<number>`count(*) filter (where ${imageArchiveTask.status} = 'completed' and ${imageArchiveTask.metadata}->>'replacementPasses' = '1')::int`,
})
.from(imageArchiveTask)
.groupBy(imageArchiveTask.status);
@ -237,9 +254,11 @@ export async function getImageArchiveTaskStats() {
const pendingStore = grouped
.filter((item) => item.status === "pending" || item.status === "failed")
.reduce((sum, item) => sum + (item.count || 0), 0);
const pendingReplace = grouped
.filter((item) => item.status === "stored")
.reduce((sum, item) => sum + (item.count || 0), 0);
const pendingReplace =
grouped
.filter((item) => item.status === "stored")
.reduce((sum, item) => sum + (item.count || 0), 0) +
grouped.reduce((sum, item) => sum + (item.reconciliationCount || 0), 0);
return {
grouped,
@ -247,3 +266,50 @@ export async function getImageArchiveTaskStats() {
pendingReplace,
};
}
// Interrupted workers can be claimed again after a lease expires. Failed uploads keep
// their original source and retry time, so transient bucket errors cannot lose images.
export async function recoverStaleImageArchiveTasks() {
const staleBefore = new Date(Date.now() - ARCHIVE_LEASE_MS);
await db
.update(imageArchiveTask)
.set({
status: sql`case when ${imageArchiveTask.attemptCount} >= ${MAX_IMAGE_ARCHIVE_ATTEMPTS} then 'abandoned' else 'failed' end`,
archiveAfterAt: new Date(),
updatedAt: new Date(),
})
.where(
and(eq(imageArchiveTask.status, "archiving"), lt(imageArchiveTask.updatedAt, staleBefore)),
);
await db
.update(imageArchiveTask)
.set({ status: "stored", replaceAfterAt: new Date(), updatedAt: new Date() })
.where(
and(eq(imageArchiveTask.status, "replacing"), lt(imageArchiveTask.updatedAt, staleBefore)),
);
}
export async function getArchivedImageUrlMappings(urls: string[]) {
if (!urls.length) return {};
const sources = [...new Set(urls.filter(isGeneratedImageSource))];
const tasks = await db
.select({ sourceUrl: imageArchiveTask.sourceUrl, targetUrl: imageArchiveTask.targetUrl })
.from(imageArchiveTask)
.where(inArray(imageArchiveTask.sourceUrl, sources.map(imageArchiveSourceKey)));
const targets = new Map(
tasks.filter((task) => task.targetUrl).map((task) => [task.sourceUrl, task.targetUrl!]),
);
const replacements: Record<string, string> = {};
for (const source of sources) {
const target = targets.get(imageArchiveSourceKey(source));
if (target) replacements[source] = target;
}
return replacements;
}
// Normalize late writes too: an archive can finish before a task/canvas save arrives.
export async function resolveArchivedImageUrls<T>(value: T): Promise<T> {
const sources = collectImageArchiveSources(value);
if (!sources.length) return value;
return replaceArchivedImageUrls(value, await getArchivedImageUrlMappings(sources));
}

View File

@ -0,0 +1,56 @@
// Shared by the server and canvas; no storage or network work happens here.
export type ArchivedImageUrlMap = Record<string, string>;
export function isGeneratedImageSource(value: string) {
return (
/^https?:\/\//i.test(value) || /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(value)
);
}
export function collectImageArchiveSources(value: unknown): string[] {
const urls = new Set<string>();
const visit = (item: unknown) => {
if (typeof item === "string") {
if (isGeneratedImageSource(item)) urls.add(item);
else
for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
if (isGeneratedImageSource(match[1])) urls.add(match[1]);
}
} else if (Array.isArray(item)) item.forEach(visit);
else if (item && typeof item === "object" && !(item instanceof Date))
Object.values(item).forEach(visit);
};
visit(value);
return [...urls];
}
// Structural sharing preserves unaffected nodes and Date values; no undo step is added.
export function replaceArchivedImageUrls<T>(value: T, replacements: ArchivedImageUrlMap): T {
if (typeof value === "string") {
if (replacements[value]) return replacements[value] as T;
// URLs inside generated-image Markdown carry both preview and original URLs.
return value.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt: string, url: string) => {
const nextAlt = alt
.split("|||")
.map((part) => replacements[part] || part)
.join("|||");
return `![${nextAlt}](${replacements[url] || url})`;
}) as T;
}
if (!value || typeof value !== "object" || value instanceof Date) return value;
let changed = false;
if (Array.isArray(value)) {
const next = value.map((item) => {
const replaced = replaceArchivedImageUrls(item, replacements);
if (replaced !== item) changed = true;
return replaced;
});
return (changed ? next : value) as T;
}
const next: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
next[key] = replaceArchivedImageUrls(item, replacements);
if (next[key] !== item) changed = true;
}
return (changed ? next : value) as T;
}

View File

@ -0,0 +1,66 @@
import "server-only";
import { after } from "next/server";
const workerKey = "__generated_image_archive_worker__";
type Worker = {
running: boolean;
scheduled: boolean;
requested: boolean;
retryTimer?: ReturnType<typeof setTimeout>;
};
function getWorker(): Worker {
const root = globalThis as typeof globalThis & { [workerKey]?: Worker };
return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
}
export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
const worker = getWorker();
worker.requested = true;
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
worker.scheduled = true;
if (worker.retryTimer) {
clearTimeout(worker.retryTimer);
worker.retryTimer = undefined;
}
const run = async () => {
if (worker.running) return;
worker.scheduled = false;
worker.running = true;
let retryNeeded = false;
try {
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
do {
worker.requested = false;
const result = await runImageArchiveJob();
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0;
// Stop when only backoff tasks remain; do not spin while storage is down.
if (!worker.requested && result.store.total === 0 && result.replace.total === 0) break;
} while (true);
} catch (error) {
retryNeeded = true;
console.error("[ImageArchiveWorker] background archive failed", error);
} finally {
worker.running = false;
if (worker.requested) scheduleImageArchiveWorker();
else if (retryNeeded) {
worker.retryTimer = setTimeout(scheduleImageArchiveWorker, 60_000);
worker.retryTimer.unref?.();
}
}
};
// Detached generation tasks may inherit a request that has already closed. Keep
// a Node-server fallback so archiving never depends on the user leaving a tab open.
const deliveryFallback = setTimeout(() => {
if (worker.scheduled && !worker.running) void run();
}, 1_000);
deliveryFallback.unref?.();
try {
// Request handlers deliver first. Durable task rows also allow cron/restart recovery.
after(run);
} catch {
// Durable canvas tasks can run outside Next's request context on the Node server.
setImmediate(() => {
void run();
});
}
}

View File

@ -2,10 +2,13 @@ import "server-only";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { isGeneratedImageSource } from "./image-archive-urls";
import { scheduleImageArchiveWorker } from "./image-archive-worker";
import { createLogger } from "@/lib/observability/logger";
import {
enqueueImageArchiveTasks,
getImageArchiveDisplayHoldMs,
imageArchiveSourceKey,
} from "@/lib/storage/image-archive-db";
import { uploadFile } from "@/lib/storage/service";
@ -25,16 +28,12 @@ function getDownloadTimeoutMs() {
return Math.max(5_000, Math.min(5 * 60_000, Math.round(parsed)));
}
function isHttpUrl(value: string) {
return /^https?:\/\//i.test(String(value || "").trim());
}
export function isDeferredImageArchiveEnabled() {
return process.env.AI_IMAGE_DEFERRED_ARCHIVE !== "false";
return true;
}
export function isImmediateImageArchiveEnabled() {
return process.env.AI_IMAGE_IMMEDIATE_ARCHIVE !== "false";
return false;
}
export async function registerGeneratedImageTemporaryUrls(params: {
@ -44,24 +43,24 @@ export async function registerGeneratedImageTemporaryUrls(params: {
if (!isDeferredImageArchiveEnabled()) return [];
const urls = Array.from(
new Set(
(params.urls || [])
.map((url) => String(url || "").trim())
.filter(isHttpUrl)
)
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
),
);
if (urls.length === 0) return [];
try {
return await enqueueImageArchiveTasks({
const tasks = await enqueueImageArchiveTasks({
urls,
metadata: {
...(params.metadata || {}),
displayHoldMs: getImageArchiveDisplayHoldMs(),
},
});
scheduleImageArchiveWorker();
return tasks;
} catch (error) {
logger.warn("enqueue temporary image archive failed", {
urls,
imageCount: urls.length,
error: error instanceof Error ? error.message : String(error),
});
return [];
@ -77,10 +76,8 @@ export async function formatDeferredGeneratedImageMarkdown(params: {
}) {
const urls = Array.from(
new Set(
(params.urls || [])
.map((url) => String(url || "").trim())
.filter(isHttpUrl)
)
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
),
);
if (urls.length === 0) {
throw new Error("AI 未返回可用图片地址");
@ -97,17 +94,17 @@ export async function formatDeferredGeneratedImageMarkdown(params: {
return persisted
.map(
({ originalUrl, thumbnailUrl }) =>
`![thumb|||${originalUrl}|||${params.imageSize}](${thumbnailUrl})`
`![thumb|||${originalUrl}|||${params.imageSize}](${thumbnailUrl})`,
)
.join("\n\n");
}
export async function createDeferredUploadedImage(
sourceUrl: string,
metadata?: DeferredImageArchiveMetadata | null
metadata?: DeferredImageArchiveMetadata | null,
) {
const url = String(sourceUrl || "").trim();
if (!isDeferredImageArchiveEnabled() || !isHttpUrl(url)) return null;
if (!isDeferredImageArchiveEnabled() || !isGeneratedImageSource(url)) return null;
const [persisted] = await persistGeneratedImageUrls({
urls: [url],
@ -124,48 +121,20 @@ async function persistGeneratedImageUrls(params: {
urls: string[];
metadata?: DeferredImageArchiveMetadata | null;
}) {
if (!isImmediateImageArchiveEnabled()) {
await registerGeneratedImageTemporaryUrls(params);
return params.urls.map((url) => ({
originalUrl: url,
thumbnailUrl: url,
}));
}
return await Promise.all(
params.urls.map(async (url) => {
try {
const archived = await archiveTemporaryImage(url);
logger.info("generated image archived before response", {
providerType: params.metadata?.providerType,
modelId: params.metadata?.modelId,
bytes: archived.bytes,
});
return {
originalUrl: archived.url,
thumbnailUrl: archived.thumbnailUrl || archived.url,
};
} catch (error) {
logger.error("immediate generated image archive failed; queued for retry", {
providerType: params.metadata?.providerType,
modelId: params.metadata?.modelId,
error: error instanceof Error ? error.message : String(error),
});
await registerGeneratedImageTemporaryUrls({
urls: [url],
metadata: params.metadata || null,
});
return {
originalUrl: url,
thumbnailUrl: url,
};
}
})
);
// Only the durable queue insert is awaited. Downloading, thumbnailing and bucket
// uploads run after delivery and never extend the provider response time.
await registerGeneratedImageTemporaryUrls(params);
return params.urls.map((url) => {
const preview = imageArchiveSourceKey(url);
return { originalUrl: preview, thumbnailUrl: preview };
});
}
function getImageExtension(contentType: string | null, sourceUrl: string) {
const normalized = String(contentType || "").split(";")[0].trim().toLowerCase();
const normalized = String(contentType || "")
.split(";")[0]
.trim()
.toLowerCase();
const byType: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
@ -178,7 +147,11 @@ function getImageExtension(contentType: string | null, sourceUrl: string) {
try {
const pathname = new URL(sourceUrl).pathname.toLowerCase();
const ext = pathname.split(".").pop()?.replace(/[^a-z0-9]+/g, "") || "";
const ext =
pathname
.split(".")
.pop()
?.replace(/[^a-z0-9]+/g, "") || "";
if (["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(ext)) {
return ext === "jpeg" ? "jpg" : ext;
}
@ -192,14 +165,20 @@ function getImageExtension(contentType: string | null, sourceUrl: string) {
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
export async function downloadTemporaryImage(params: {
sourceUrl: string;
retries?: number;
}) {
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
if (params.sourceUrl.startsWith("data:")) {
const match = params.sourceUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
if (!match) throw new Error("Invalid inline generated image");
return {
buffer: Buffer.from(match[2], "base64"),
contentType: match[1],
extension: getImageExtension(match[1], ""),
};
}
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
for (let attempt = 0; attempt <= retries; attempt += 1) {
@ -249,7 +228,7 @@ export async function archiveTemporaryImage(sourceUrl: string) {
const thumbnailUploaded = thumbnailBuffer
? await uploadFile(
`ai-generated-archive-thumb-${objectSuffix}.webp`,
toArrayBuffer(thumbnailBuffer)
toArrayBuffer(thumbnailBuffer),
).catch((error) => {
logger.warn("generated image thumbnail upload failed", {
error: error instanceof Error ? error.message : String(error),

View File

@ -6,6 +6,7 @@ import {
assetMemorySnapshot,
canvasDetailArchive,
canvasNode,
canvasImageTask,
canvasProject,
conversationWorkingState,
externalImageApiRequestLog,
@ -26,18 +27,14 @@ function normalizeMediaUrl(value: string): string {
const parsed = new URL(canonical);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
} catch {
return canonical
.split("#")[0]
.split("?")[0]
.replace(/\/+$/, "")
.toLowerCase();
return canonical.split("#")[0].split("?")[0].replace(/\/+$/, "").toLowerCase();
}
}
export function replaceDeepUrls(
value: unknown,
sourceUrl: string,
targetUrl: string
targetUrl: string,
): { value: unknown; changed: boolean } {
if (typeof value === "string") {
if (!value.includes(sourceUrl)) {
@ -70,10 +67,7 @@ export function replaceDeepUrls(
return { value, changed: false };
}
export async function replaceImageUrlInDatabase(
sourceUrl: string,
targetUrl: string
) {
export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: string) {
if (!sourceUrl || !targetUrl || sourceUrl === targetUrl) {
return { changed: 0 };
}
@ -106,8 +100,8 @@ export async function replaceImageUrlInDatabase(
eq(mediaAsset.thumbnailUrl, sourceUrl),
eq(mediaAsset.normalizedUrl, sourceNormalizedUrl),
sql`${mediaAsset.metadata}::text like ${`%${sourceUrl}%`}`,
sql`${mediaAsset.summary}::text like ${`%${sourceUrl}%`}`
)
sql`${mediaAsset.summary}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
@ -117,8 +111,7 @@ export async function replaceImageUrlInDatabase(
const thumbnail = replaceDeepUrls(item.thumbnailUrl, sourceUrl, targetUrl);
const metadata = replaceDeepUrls(item.metadata, sourceUrl, targetUrl);
const summary = replaceDeepUrls(item.summary, sourceUrl, targetUrl);
const shouldNormalize =
item.normalizedUrl === sourceNormalizedUrl || original.changed;
const shouldNormalize = item.normalizedUrl === sourceNormalizedUrl || original.changed;
const nextOriginal = (original.value as string) || item.originalUrl;
if (
@ -139,8 +132,7 @@ export async function replaceImageUrlInDatabase(
originalUrl: nextOriginal,
normalizedUrl: shouldNormalize ? targetNormalizedUrl : item.normalizedUrl,
previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl,
thumbnailUrl:
(thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
metadata: (metadata.value ?? item.metadata) as any,
summary: (summary.value ?? item.summary) as any,
updatedAt: new Date(),
@ -152,8 +144,7 @@ export async function replaceImageUrlInDatabase(
.set({
originalUrl: nextOriginal,
previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl,
thumbnailUrl:
(thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
metadata: (metadata.value ?? item.metadata) as any,
summary: (summary.value ?? item.summary) as any,
updatedAt: new Date(),
@ -170,8 +161,8 @@ export async function replaceImageUrlInDatabase(
or(
eq(assetMemorySnapshot.normalizedUrl, sourceNormalizedUrl),
sql`${assetMemorySnapshot.metadata}::text like ${`%${sourceUrl}%`}`,
like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`)
)
like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`),
),
)
.limit(500);
@ -203,8 +194,8 @@ export async function replaceImageUrlInDatabase(
.where(
or(
sql`${message.parts}::text like ${`%${sourceUrl}%`}`,
sql`${message.attachments}::text like ${`%${sourceUrl}%`}`
)
sql`${message.attachments}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
@ -243,120 +234,67 @@ export async function replaceImageUrlInDatabase(
changed += 1;
}
const escapedSource = JSON.stringify(sourceUrl).slice(1, -1);
const escapedTarget = JSON.stringify(targetUrl).slice(1, -1);
const projects = await db
.select({
id: canvasProject.id,
canvasData: canvasProject.canvasData,
.update(canvasProject)
.set({
canvasData: sql`replace(${canvasProject.canvasData}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
thumbnail: sql`case when ${canvasProject.thumbnail} = ${sourceUrl} then ${targetUrl} else ${canvasProject.thumbnail} end`,
// URL maintenance is not a content revision; keep autosave conflict detection valid.
})
.from(canvasProject)
.where(sql`${canvasProject.canvasData}::text like ${`%${sourceUrl}%`}`)
.limit(500);
for (const item of projects) {
const replaced = replaceDeepUrls(item.canvasData, sourceUrl, targetUrl);
if (!replaced.changed) continue;
await db
.update(canvasProject)
.set({
canvasData: replaced.value as any,
updatedAt: new Date(),
})
.where(eq(canvasProject.id, item.id));
changed += 1;
}
const nodes = await db
.select({
id: canvasNode.id,
data: canvasNode.data,
})
.from(canvasNode)
.where(sql`${canvasNode.data}::text like ${`%${sourceUrl}%`}`)
.limit(500);
for (const item of nodes) {
const replaced = replaceDeepUrls(item.data, sourceUrl, targetUrl);
if (!replaced.changed) continue;
await db
.update(canvasNode)
.set({
data: replaced.value as any,
updatedAt: new Date(),
})
.where(eq(canvasNode.id, item.id));
changed += 1;
}
const workingStates = await db
.select()
.from(conversationWorkingState)
.where(
or(
sql`${conversationWorkingState.currentFocusImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.selectedImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastUploadedImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastGeneratedImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastReferencedImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastResolvedTargetImageUrls}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastCanvasSnapshot}::text like ${`%${sourceUrl}%`}`,
sql`${conversationWorkingState.lastTurnSummary}::text like ${`%${sourceUrl}%`}`
)
sql`position(${escapedSource} in ${canvasProject.canvasData}::text) > 0`,
eq(canvasProject.thumbnail, sourceUrl),
),
)
.limit(500);
.returning({ id: canvasProject.id });
changed += projects.length;
const nodes = await db
.update(canvasNode)
.set({
data: sql`replace(${canvasNode.data}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
})
.where(sql`position(${escapedSource} in ${canvasNode.data}::text) > 0`)
.returning({ id: canvasNode.id });
changed += nodes.length;
const tasks = await db
.update(canvasImageTask)
.set({
result: sql`replace(${canvasImageTask.result}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
})
.where(sql`position(${escapedSource} in ${canvasImageTask.result}::text) > 0`)
.returning({ id: canvasImageTask.id });
changed += tasks.length;
for (const item of workingStates) {
const currentFocus = replaceDeepUrls(
item.currentFocusImageUrls,
sourceUrl,
targetUrl
);
const selected = replaceDeepUrls(item.selectedImageUrls, sourceUrl, targetUrl);
const uploaded = replaceDeepUrls(item.lastUploadedImageUrls, sourceUrl, targetUrl);
const generated = replaceDeepUrls(item.lastGeneratedImageUrls, sourceUrl, targetUrl);
const referenced = replaceDeepUrls(item.lastReferencedImageUrls, sourceUrl, targetUrl);
const resolved = replaceDeepUrls(
item.lastResolvedTargetImageUrls,
sourceUrl,
targetUrl
);
const canvasSnapshot = replaceDeepUrls(
item.lastCanvasSnapshot,
sourceUrl,
targetUrl
);
const turnSummary = replaceDeepUrls(item.lastTurnSummary, sourceUrl, targetUrl);
if (
!currentFocus.changed &&
!selected.changed &&
!uploaded.changed &&
!generated.changed &&
!referenced.changed &&
!resolved.changed &&
!canvasSnapshot.changed &&
!turnSummary.changed
) {
continue;
}
await db
.update(conversationWorkingState)
.set({
currentFocusImageUrls: currentFocus.value as any,
selectedImageUrls: selected.value as any,
lastUploadedImageUrls: uploaded.value as any,
lastGeneratedImageUrls: generated.value as any,
lastReferencedImageUrls: referenced.value as any,
lastResolvedTargetImageUrls: resolved.value as any,
lastCanvasSnapshot: (canvasSnapshot.value ?? item.lastCanvasSnapshot) as any,
lastTurnSummary: (turnSummary.value ?? item.lastTurnSummary) as any,
updatedAt: new Date(),
})
.where(eq(conversationWorkingState.id, item.id));
changed += 1;
}
const workingStates = await db
.update(conversationWorkingState)
.set({
currentFocusImageUrls: sql`replace(${conversationWorkingState.currentFocusImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
selectedImageUrls: sql`replace(${conversationWorkingState.selectedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastUploadedImageUrls: sql`replace(${conversationWorkingState.lastUploadedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastGeneratedImageUrls: sql`replace(${conversationWorkingState.lastGeneratedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastReferencedImageUrls: sql`replace(${conversationWorkingState.lastReferencedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastResolvedTargetImageUrls: sql`replace(${conversationWorkingState.lastResolvedTargetImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastCanvasSnapshot: sql`replace(${conversationWorkingState.lastCanvasSnapshot}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
lastTurnSummary: sql`replace(${conversationWorkingState.lastTurnSummary}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
updatedAt: new Date(),
})
.where(
or(
sql`position(${escapedSource} in ${conversationWorkingState.currentFocusImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.selectedImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastUploadedImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastGeneratedImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastReferencedImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastResolvedTargetImageUrls}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastCanvasSnapshot}::text) > 0`,
sql`position(${escapedSource} in ${conversationWorkingState.lastTurnSummary}::text) > 0`,
),
)
.returning({ id: conversationWorkingState.id });
changed += workingStates.length;
const detailArchives = await db
.select({
@ -374,8 +312,8 @@ export async function replaceImageUrlInDatabase(
sql`${canvasDetailArchive.brief}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.slots}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.uiSurfaceSnapshot}::text like ${`%${sourceUrl}%`}`,
sql`${canvasDetailArchive.uiSurfaceLatestReview}::text like ${`%${sourceUrl}%`}`
)
sql`${canvasDetailArchive.uiSurfaceLatestReview}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
@ -416,8 +354,8 @@ export async function replaceImageUrlInDatabase(
.where(
or(
like(model3dAsset.previewImageUrl, `%${sourceUrl}%`),
sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`
)
sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
@ -429,8 +367,7 @@ export async function replaceImageUrlInDatabase(
await db
.update(model3dAsset)
.set({
previewImageUrl:
(preview.value as string | null | undefined) ?? item.previewImageUrl,
previewImageUrl: (preview.value as string | null | undefined) ?? item.previewImageUrl,
assetPayload: (payload.value ?? item.assetPayload) as any,
updatedAt: new Date(),
})
@ -444,22 +381,14 @@ export async function replaceImageUrlInDatabase(
.where(
or(
sql`${externalImageApiRequestLog.responsePayload}::text like ${`%${sourceUrl}%`}`,
sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`
)
sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`,
),
)
.limit(500);
for (const item of apiLogs) {
const responsePayload = replaceDeepUrls(
item.responsePayload,
sourceUrl,
targetUrl
);
const generatedUrls = replaceDeepUrls(
item.generatedImageUrls,
sourceUrl,
targetUrl
);
const responsePayload = replaceDeepUrls(item.responsePayload, sourceUrl, targetUrl);
const generatedUrls = replaceDeepUrls(item.generatedImageUrls, sourceUrl, targetUrl);
if (!responsePayload.changed && !generatedUrls.changed) continue;
await db

View File

@ -209,7 +209,14 @@
},
"packageManager": "pnpm@9.12.3",
"pnpm": {
"patchedDependencies": {
"@drawnix/drawnix@0.4.0-2": "patches/@drawnix__drawnix@0.4.0-2.patch",
"@plait-board/react-text@0.4.0-2": "patches/@plait-board__react-text@0.4.0-2.patch"
},
"overrides": {
"@plait-board/react-text>slate": "$slate",
"@plait-board/react-text>slate-react": "$slate-react",
"@plait-board/react-text>slate-dom": "$slate-dom",
"sharp": "^0.34.5",
"fast-xml-parser": "^5.5.6",
"protobufjs": "^7.5.6",

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,46 @@
diff --git a/index.js b/index.js
index 9031a9a3b1a614785657dd35b23fed97daabbfe9..839c71d5d54c468a9a1209da21fa1b319a602fed 100644
--- a/index.js
+++ b/index.js
@@ -1,4 +1,4 @@
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const i=require("react/jsx-runtime"),c=require("slate"),g=require("is-hotkey"),f=require("slate-react"),x=require("react"),h=require("slate-history"),u=require("@plait/text-plugins"),C=t=>{const n=t,{insertData:r}=n;return n.insertBreak=()=>{t.insertText(`
`)},n.insertSoftBreak=()=>{t.insertText(`
`)},n.insertData=o=>{let e=o.getData("text/plain");if(!o.getData("application/x-slate-fragment")&&e){e.endsWith(`
-`)&&(e=e.substring(0,e.length-1)),e=e.trim().replace(/\t+/g," "),n.insertText(e);return}r(o)},n},y=()=>i.jsx("span",{contentEditable:!1,style:{fontSize:0},children:String.fromCodePoint(160)}),D=({attributes:t,children:n,element:r})=>i.jsxs("a",{...t,style:{textDecoration:"none",cursor:"inherit"},"data-url":r.url,className:"plait-board-link",children:[i.jsx(y,{}),n,i.jsx(y,{})]}),T=t=>{const{insertData:n,insertText:r,isInline:o}=t;return t.isInline=e=>e.type&&["link"].includes(e.type)||o(e),t.insertText=e=>{e&&u.isUrl(e)?u.LinkEditor.wrapLink(t,e,e):r(e)},t.insertData=e=>{const l=e.getData("text/plain");l&&u.isUrl(l)?u.LinkEditor.wrapLink(t,l,l):n(e)},t},b=t=>{const{text:n,readonly:r,onChange:o,onComposition:e,afterInit:l}=t,d=x.useCallback(s=>i.jsx(w,{...s}),[]),j=[n],a=x.useMemo(()=>{const s=T(C(h.withHistory(f.withReact(c.createEditor()))));return l&&l(s),s},[]);x.useEffect(()=>{n!==a.children[0]&&(a.children=[n],a.onChange())},[n,a]);const k=s=>{const{selection:p}=a;if(p&&c.Range.isCollapsed(p)){const{nativeEvent:m}=s;if(g.isKeyHotkey("left",m)){s.preventDefault(),c.Transforms.move(a,{unit:"offset",reverse:!0});return}if(g.isKeyHotkey("right",m)){s.preventDefault(),c.Transforms.move(a,{unit:"offset"});return}}};return i.jsx(f.Slate,{editor:a,initialValue:j,onChange:s=>{o&&o({newText:a.children[0],operations:a.operations})},children:i.jsx(f.Editable,{className:"slate-editable-container plait-text-container",renderElement:s=>i.jsx(E,{...s}),renderLeaf:d,readOnly:r===void 0?!0:r,onCompositionStart:s=>{e&&e(s)},onCompositionUpdate:s=>{e&&e(s)},onCompositionEnd:s=>{e&&e(s)},onKeyDown:k})})},E=t=>{const{attributes:n,children:r,element:o}=t;switch(o.type){case"link":return i.jsx(D,{...t});default:return i.jsx(v,{...t})}},v=({attributes:t,children:n,element:r})=>{const o={textAlign:r.align};return i.jsx("div",{style:o,...t,children:n})},w=({children:t,leaf:n,attributes:r})=>(n.bold&&(t=i.jsx("strong",{children:t})),n.code&&(t=i.jsx("code",{children:t})),n.italic&&(t=i.jsx("em",{children:t})),n.underlined&&(t=i.jsx("u",{children:t})),i.jsx("span",{style:{color:n.color},...r,children:t}));exports.Text=b;
+`)&&(e=e.substring(0,e.length-1)),e=e.trim().replace(/\t+/g," "),n.insertText(e);return}r(o)},n},y=()=>i.jsx("span",{contentEditable:!1,style:{fontSize:0},children:String.fromCodePoint(160)}),D=({attributes:t,children:n,element:r})=>i.jsxs("a",{...t,style:{textDecoration:"none",cursor:"inherit"},"data-url":r.url,className:"plait-board-link",children:[i.jsx(y,{}),n,i.jsx(y,{})]}),T=t=>{const{insertData:n,insertText:r,isInline:o}=t;return t.isInline=e=>e.type&&["link"].includes(e.type)||o(e),t.insertText=e=>{e&&u.isUrl(e)?u.LinkEditor.wrapLink(t,e,e):r(e)},t.insertData=e=>{const l=e.getData("text/plain");l&&u.isUrl(l)?u.LinkEditor.wrapLink(t,l,l):n(e)},t},b=t=>{const{text:n,readonly:r,onChange:o,onComposition:e,afterInit:l}=t,d=x.useCallback(s=>i.jsx(w,{...s}),[]),j=[n],a=x.useMemo(()=>{const s=T(C(h.withHistory(f.withReact(c.createEditor()))));return l&&l(s),s},[]);x.useEffect(()=>{if(r!==false)return;const frame=requestAnimationFrame(()=>{if(!f.ReactEditor.isReadOnly(a))f.ReactEditor.focus(a)});return()=>cancelAnimationFrame(frame)},[r,a]);x.useEffect(()=>{n!==a.children[0]&&(a.children=[n],a.onChange())},[n,a]);const k=s=>{const{selection:p}=a;if(p&&c.Range.isCollapsed(p)){const{nativeEvent:m}=s;if(g.isKeyHotkey("left",m)){s.preventDefault(),c.Transforms.move(a,{unit:"offset",reverse:!0});return}if(g.isKeyHotkey("right",m)){s.preventDefault(),c.Transforms.move(a,{unit:"offset"});return}}};return i.jsx(f.Slate,{editor:a,initialValue:j,onChange:s=>{o&&o({newText:a.children[0],operations:a.operations})},children:i.jsx(f.Editable,{className:"slate-editable-container plait-text-container",renderElement:s=>i.jsx(E,{...s}),renderLeaf:d,readOnly:r===void 0?!0:r,onCompositionStart:s=>{e&&e(s)},onCompositionUpdate:s=>{e&&e(s)},onCompositionEnd:s=>{e&&e(s)},onKeyDown:k})})},E=t=>{const{attributes:n,children:r,element:o}=t;switch(o.type){case"link":return i.jsx(D,{...t});default:return i.jsx(v,{...t})}},v=({attributes:t,children:n,element:r})=>{const o={textAlign:r.align};return i.jsx("div",{style:o,...t,children:n})},w=({children:t,leaf:n,attributes:r})=>(n.bold&&(t=i.jsx("strong",{children:t})),n.code&&(t=i.jsx("code",{children:t})),n.italic&&(t=i.jsx("em",{children:t})),n.underlined&&(t=i.jsx("u",{children:t})),i.jsx("span",{style:{color:n.color,fontSize:n["font-size"]?`${n["font-size"]}px`:void 0,lineHeight:n["font-size"]?1.5:void 0},...r,children:t}));exports.Text=b;
diff --git a/index.mjs b/index.mjs
index 888a6de26b311566472d986d20a9c7c44277a152..ab92f1215250a90ff58cc91641e228c7a8d2a8fc 100644
--- a/index.mjs
+++ b/index.mjs
@@ -1,7 +1,7 @@
import { jsxs as D, jsx as o } from "react/jsx-runtime";
import { createEditor as h, Range as C, Transforms as u } from "slate";
import { isKeyHotkey as m } from "is-hotkey";
-import { withReact as k, Slate as w, Editable as E } from "slate-react";
+import { withReact as k, Slate as w, Editable as E, ReactEditor as FocusEditor } from "slate-react";
import { useCallback as T, useMemo as b, useEffect as v } from "react";
import { withHistory as L } from "slate-history";
import { isUrl as p, LinkEditor as g } from "@plait/text-plugins";
@@ -60,6 +60,13 @@ const I = (t) => {
);
return l && l(i), i;
}, []);
+ v(() => {
+ if (r !== false) return;
+ const frame = requestAnimationFrame(() => {
+ if (!FocusEditor.isReadOnly(a)) FocusEditor.focus(a);
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [r, a]);
return v(() => {
e !== a.children[0] && (a.children = [e], a.onChange());
}, [e, a]), /* @__PURE__ */ o(
@@ -127,7 +134,7 @@ const I = (t) => {
}) => {
const s = { textAlign: r.align };
return /* @__PURE__ */ o("div", { style: s, ...t, children: e });
-}, H = ({ children: t, leaf: e, attributes: r }) => (e.bold && (t = /* @__PURE__ */ o("strong", { children: t })), e.code && (t = /* @__PURE__ */ o("code", { children: t })), e.italic && (t = /* @__PURE__ */ o("em", { children: t })), e.underlined && (t = /* @__PURE__ */ o("u", { children: t })), /* @__PURE__ */ o("span", { style: { color: e.color }, ...r, children: t }));
+}, H = ({ children: t, leaf: e, attributes: r }) => (e.bold && (t = /* @__PURE__ */ o("strong", { children: t })), e.code && (t = /* @__PURE__ */ o("code", { children: t })), e.italic && (t = /* @__PURE__ */ o("em", { children: t })), e.underlined && (t = /* @__PURE__ */ o("u", { children: t })), /* @__PURE__ */ o("span", { style: { color: e.color, fontSize: e["font-size"] ? `${e["font-size"]}px` : undefined, lineHeight: e["font-size"] ? 1.5 : undefined }, ...r, children: t }));
export {
V as Text
};

35
patches/README.md Normal file
View File

@ -0,0 +1,35 @@
# Flow board dependency patches
## Text rendering and keyboard focus
`@plait-board/react-text@0.4.0-2` measures the Slate `font-size` mark but its
React leaf renderer ignores that mark. The patch applies the size and matching
line height to both ESM and CommonJS renderers.
New topics can mount directly in editable mode. The board renderer only focuses
an editor when it observes a readonly-to-editable transition, so these topics can
leave keyboard focus on the toolbar. The text component now focuses after an
editable render; the animation frame is cancelled on cleanup and readonly editors
are excluded.
The scoped Slate overrides in `package.json` align react-text with the editor's
existing Slate dependencies. Without them, react-text creates editors with
0.116 while react-board tries to focus/blur them with 0.124, causing a missing
DOM-node error when finishing text edits.
## Initial board plugins
`@drawnix/drawnix@0.4.0-2` now accepts an optional `plugins` prop, installed after
the built-in plugins and before the first board render. The special mind-map
layouts need custom root and node components from the first imported render.
Installing them in `afterInit` is too late: the original components already exist.
The patch updates declarations and both ESM/CommonJS builds.
Validate dependency upgrades with:
```
pnpm exec playwright test --config playwright.flow-board-ui.config.ts
```
The regression suite covers imported font sizes, topic editing and creation,
special structures on initial import, structure switches and undo/redo.

View File

@ -0,0 +1,23 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
outputDir: "test-results/flow-board-ui",
testDir: "./tests/flow-board",
testMatch: "*.spec.ts",
workers: 1,
retries: 0,
forbidOnly: !!process.env.CI,
timeout: 60_000,
use: {
baseURL: "http://127.0.0.1:3190",
viewport: { width: 1440, height: 900 },
trace: "retain-on-failure",
},
webServer: {
command:
"NEXT_DIST_DIR=.next/xmind-test pnpm exec next dev --webpack --port 3190",
url: "http://127.0.0.1:3190/dev/flow-board-preview",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View File

@ -0,0 +1,12 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
outputDir: "test-results/flow-board-data",
testDir: "./tests",
testMatch: /routes\/flow-board-.*\.test\.ts/,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 2,
reporter: "list",
});

Some files were not shown because too many files have changed in this diff Show More