Compare commits

...

8 Commits

Author SHA1 Message Date
134
3a2ee064d0 feat(canvas): deliver image previews before background archiving and remove generation caps 2026-10-12 08:03:22 +08:00
134
23ced0b760 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 17:38:14 +08:00
134
e6a68b345b feat(flow-board): add native XMind markers and bulk editing 2026-10-05 11:46:46 +08:00
134
ae7a0e7ba4 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:55:13 +08:00
134
79253245f3 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 13:32:54 +08:00
134
e083607917 fix: polish canvas icons, context menu and panel layout 2026-10-04 12:13:33 +08:00
134
aed5e1c977 fix: restore canvas skills and local uploads, simplify home and canvas headers 2026-10-04 10:31:32 +08:00
134
c1c7037f05 feat: refresh home and canvas UI with customizable image toolbar 2026-10-04 03:37:06 +08:00
419 changed files with 14833 additions and 3185 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

@ -8915,9 +8915,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,
@ -119,7 +120,8 @@ export async function PUT(
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
}
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 =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")

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

@ -507,3 +507,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,
@ -14730,7 +14733,9 @@ export function CanvasChatPanel({
: selectedSkillTag?.placeholder?.trim() ||
(selectedSkillTag
? `请输入「${selectedSkillTag.name}」相关需求`
: "请输入你的设计需求");
: activeComposerMode === "image"
? "今天我们要创作什么"
: "请输入你的设计需求");
const clawdEditablePresetPlaceholder =
isClawdMode && activeClawdSkillTag
? activeClawdSkillTag.placeholder?.trim() || ""
@ -15218,10 +15223,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}
@ -15248,6 +15257,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">
@ -15400,6 +15410,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
@ -15437,7 +15448,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(
@ -15869,8 +15880,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 ? (
@ -15888,7 +15899,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>
@ -15960,40 +15971,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",
@ -16002,7 +16011,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) => {
@ -16965,37 +16974,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}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
@ -17003,15 +17028,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">
@ -17767,7 +17793,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"
@ -18146,16 +18175,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">
@ -18217,6 +18247,7 @@ export function CanvasChatPanel({
) : null}
<div
className={cn(
"lovart-composer-text-region",
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
? "px-1"
: "px-2 pt-2"
@ -18225,6 +18256,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)]",
@ -18452,7 +18488,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">
@ -18586,7 +18622,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"
@ -18612,6 +18648,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
@ -18621,7 +18659,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" />
@ -18753,6 +18793,7 @@ export function CanvasChatPanel({
hideImageCount={false}
hideAdvancedOptions={true}
inlineTheme="minimal"
referencePresentation={!isWorkspacePresentation && !isVideoWorkspace}
showInlineDivider={false}
/>
) : null}
@ -18783,6 +18824,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]"
@ -18790,6 +18832,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",
@ -18834,12 +18883,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]",
@ -18864,6 +18914,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",
@ -18894,6 +18945,8 @@ export function CanvasChatPanel({
? "停止处理并重新编辑"
: isWorkspacePresentation || isDetailGenerationMode
? getToolbarItem("chatStartGeneration").label
: !isVideoWorkspace && activeComposerMode === "image" && estimatedPointsCost
? `${getToolbarItem("chatSend").label} · ${estimatedPointsCost.title}`
: getToolbarItem("chatSend").label
}
>
@ -18927,6 +18980,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";

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

@ -16,6 +16,8 @@ import {
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;
@ -457,6 +459,7 @@ function specialLink(component: MindNodeComponent) {
}
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);

View File

@ -20,7 +20,7 @@ export function XmindAnnotationPanel({
.join("");
return (
<aside
className="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]"
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">

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

File diff suppressed because it is too large Load Diff

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

@ -18,6 +18,9 @@ import {
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({
@ -41,7 +44,7 @@ export function XmindTopicPanel({
const [attachmentError, setAttachmentError] = useState("");
const [isReadingAttachment, setIsReadingAttachment] = useState(false);
const attachmentRef = useRef<HTMLInputElement>(null);
const noteRef = useRef<HTMLTextAreaElement>(null);
const linkRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -52,9 +55,8 @@ export function XmindTopicPanel({
setAttachmentError("");
}, [topic?.id]);
useEffect(() => {
({ notes: noteRef, links: linkRef, labels: labelRef })[
section
].current?.focus();
if (section === "links") linkRef.current?.focus();
if (section === "labels") labelRef.current?.focus();
}, [section]);
const safeHref = getSafeTopicHref(linkDraft);
const invalidHref = Boolean(linkDraft.trim()) && !safeHref;
@ -96,7 +98,7 @@ export function XmindTopicPanel({
};
return (
<aside
className="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]"
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">
@ -114,20 +116,10 @@ export function XmindTopicPanel({
<p className="truncate font-medium" title={getTopicTitle(topic)}>
{getTopicTitle(topic)}
</p>
<label className="block space-y-2">
<span className="flex items-center gap-2 font-medium">
<StickyNote size={14} />
备注
</span>
<textarea
aria-label="主题备注"
ref={noteRef}
className="min-h-40 w-full resize-y rounded border border-[#dfe2e7] p-2 text-sm leading-6 outline-none focus:border-blue-400"
placeholder="为这个主题补充说明…"
value={details.notes}
onChange={(event) => onPatch({ notes: event.target.value })}
/>
</label>
<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} />

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";
@ -36,6 +37,7 @@ interface ImageParamsBarProps {
hideImageCount?: boolean; // 隐藏图片数量按钮(仅个别页面需要)
hideAdvancedOptions?: boolean; // 隐藏高级设置按钮
inlineTheme?: "light" | "dark" | "minimal";
referencePresentation?: boolean;
showInlineDivider?: boolean;
// 🆕 图片生成模型特性配置(从模型配置中读取)
imageGenConfig?: {
@ -133,6 +135,7 @@ export function ImageParamsBar({
hideImageCount = false,
hideAdvancedOptions = false,
inlineTheme = "light",
referencePresentation = false,
showInlineDivider = true,
imageGenConfig,
}: ImageParamsBarProps) {
@ -493,11 +496,11 @@ 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="生成参数"
>
{qualityLabel} · {selectedAspectLabel} · {selectedImageCount} img
{minimalParamsOpen ? (
<span className={referencePresentation ? "min-w-0 truncate text-[13px]" : undefined}>{qualityLabel} · {selectedAspectLabel} · {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

@ -18,21 +18,21 @@
| 联系线 | 基础 | 控制点编辑、箭头、虚线、复杂布局、双向联系 |
| 外框 | 基础 | 标题、全形状、嵌套、填充、范围调整 |
| 概要 | 基础 | 范围调整、多重概要、完整样式 |
| 图标/标记、贴纸 | 部分标记 | 全标记映射、自定义图标、贴纸库 |
| 备注、外链、主题链接、标签 | 基础 | 已验证纯文本备注、标签和外链编辑、撤销、工作表切换、快照序列化;同画布链接可展开折叠分支定位。待补富文本备注、跨画布链接、画布内容标识、真实云端保存重载 |
| 图标/标记、贴纸 | 原生标记可用,贴纸/插画待实现 | 自定义标记、图例交互及贴纸/插画库 |
| 备注、外链、主题链接、标签 | 基础 | 已增加富文本笔记(加粗、斜体、下划线、删除线、列表、缩进、链接),写入客户端最新 realHTML 和 plain 格式并保留旧段落格式;支持笔记提示、撤销和项目内往返。标签和外链编辑、工作表切换已验证;待补备注图片、跨画布链接和真实云端保存重载 |
| 图片、附件、音频、公式 | 部分支持 | 新旧 XMind 内嵌 PNG/JPEG/GIF/WebP 导入;附件新增/替换/下载/移除/撤销。待补 SVG、音频、公式、资源导出和真实样本覆盖 |
| 任务、待办、进度、日期、负责人、甘特图 | 待实现 | 任务模型、视图和筛选,不能用普通图标替代 |
| 查找替换、过滤、聚焦、导航 | 待实现 | 折叠节点和多画布范围、替换撤销 |
| 任务、待办、进度、日期、负责人、甘特图 | 任务及甘特图基础已实现 | 任务开始/截止日期、日级工期、进度、优先级、多负责人、四种依赖;甘特图拖动/拉伸、联动排期、批次撤销、汇总任务、折叠、筛选、缩放、今天定位、CSV 和浏览器打印;原生任务扩展导入导出。待办模型、工作日日历、小时级/里程碑、原生全部导出与团队成员服务待补 |
| 查找替换、过滤、聚焦、导航 | 查找替换基础 | 已验证折叠后代、当前/全部画布范围、跨工作表定位、替换批量撤销/重做和跨富文本叶替换;过滤、分支聚焦待补 |
| 大纲视图 | 基础 | 已验证画布双向改名、新增/删除、缩进/提升、拖动重排、展开折叠、缩放和撤销;待补混合富文本、批量选区、分支聚焦、大纲专用导出和原生视觉对照 |
| 浮动工具栏、主题添加按钮 | 待新版对照 | 26.05 安装包有新增编辑体验文案,需按原生行为补齐 |
| 编辑器偏好设置 | 待实现 | 默认缩放、默认中日韩字体、一周起始日、快捷输入启动偏好 |
| ZEN、演示/Pitch、分步展示 | 待实现 | 交互、快捷键、导出与演示行为 |
| ZEN、演示/Pitch、分步展示 | ZEN / 演示基础 | 已有隐藏编辑工具、Escape 恢复和主题演示前后页;完整 Pitch 模板、分步动画、演示导出待补 |
| 多工作表新建/切换/重命名/复制/删除 | 基础 | 跨工作表移动/链接、合并 XMind 文件 |
| 云文件、自动保存 | 基础 | 离线恢复、版本历史、异常恢复与冲突 |
| XMind 新版 JSON / 旧版 XML 导入 | 基础 | 资源、全部样式、结构和扩展内容;真实样本库 |
| MD、OPML、FreeMind 导入 | 基础 | 更多格式与大文件性能 |
| XMind 导出与往返兼容 | 基础 | 已有多工作表 ZIP 导出与项目内往返测试;原生 XMind 打开和保存回传尚未核验,不能仅自读自写认定兼容 |
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | 待逐项核验 | 全图/选区/单页/多页、透明背景、纸张和清晰度 |
| XMind 导出与往返兼容 | 基础,指定原生往返样例已核验 | 本机 26.04 实际打开网页导出文件、修改字号并保存回传,指定主题的字号/纯文本备注/附件字节保留;全结构、复杂样式与混合富文本的原生无损往返仍待核验 |
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | PNG 已支持;其余待核验 | 当前工作表全图 PNG,1/2/3 倍清晰度、画布背景或透明背景;选区、多页和其他格式待补 |
| 打印、密码、缓存、文件恢复 | 待实现 | 不得以浏览器存储代替完整文件保护 |
| 模板、主题库、手绘风格 | 待实现/部分底层能力 | 内置模板、自定义、导入与复用 |
| 协作、评论、分享、权限 | 待实现 | 项目已有登录不等于实时协作完成 |
@ -97,3 +97,15 @@
- 结构面板增加两枚独立 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

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

@ -350,14 +350,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

@ -491,14 +491,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,11 @@
import "server-only";
import { and, asc, desc, eq, lt, notInArray, sql } from "drizzle-orm";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasImageTask } from "@/lib/db/schema";
import {
getCanvasImageBatchDispatchIntervalMs,
getCanvasImageRunningCountForModel,
getCanvasImageSingleModelConcurrency,
normalizeCanvasImageModelKey,
@ -70,10 +71,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;
}
@ -83,9 +80,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 {
@ -98,10 +92,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,
};
@ -184,14 +174,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({
@ -209,27 +191,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) {
@ -240,7 +207,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);
@ -249,65 +216,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
@ -334,42 +245,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;
@ -396,15 +279,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,
@ -553,7 +428,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(),
@ -575,7 +450,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 {
@ -592,12 +467,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);
}
@ -612,9 +481,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);
@ -622,6 +490,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();
}
}
}
@ -718,58 +591,6 @@ export async function createCanvasImageTask(params: {
return task;
}
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;
@ -789,51 +610,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;
}) {
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

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

@ -1,8 +1,10 @@
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;
@ -64,19 +66,7 @@ export function getTopicDetails(element: PlaitElement | null): TopicDetails {
: typeof data.labels === "string"
? [data.labels]
: [];
const htmlParagraphs = record(
record(record(data.notes).html).content
).paragraphs;
const htmlText = Array.isArray(htmlParagraphs)
? htmlParagraphs
.map((paragraph) => {
const spans = record(paragraph).spans;
return Array.isArray(spans)
? spans.map((span) => String(record(span).text || "")).join("")
: "";
})
.join("\n")
: "";
const htmlText = typeof notes === "string" ? "" : noteDocumentText(getNoteDocument(element));
const attachment = record(data.attachment);
const safeAttachment =
typeof attachment.url === "string" &&
@ -108,7 +98,8 @@ export function patchTopicDetails(
) {
const data = { ...record(element.xmind) };
for (const [key, value] of Object.entries(patch)) {
if (key === "notes")
if (key === "notesDocument") data.notes = serializeNoteDocument(value as NoteDocument);
else if (key === "notes")
data.notes = value ? { plain: { content: value } } : undefined;
else data[key] = value;
}

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

@ -1,3 +1,4 @@
import { getXmindMarker } from "./xmind-markers";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
@ -33,15 +34,6 @@ const layouts: Record<string, string> = {
"right-bottom-indented": "org.xmind.ui.tree.right",
"left-bottom-indented": "org.xmind.ui.tree.left",
};
const markerIds: Record<string, string> = {
"✅": "task-done",
"▶️": "task-start",
"🚩": "flag-red",
"⭐": "star-red",
...Object.fromEntries(
[1, 2, 3, 4, 5, 6, 7].map((n) => [`${n}️⃣`, `priority-${n}`])
),
};
const summary = (element: PlaitElement) =>
Number.isInteger(element.start) && Number.isInteger(element.end);
function textOf(value: unknown): string {
@ -151,6 +143,17 @@ export async function exportXmind(
],
},
];
// 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("主题内混合文字格式已按首段样式导出");
@ -160,6 +163,7 @@ export async function exportXmind(
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);
@ -191,7 +195,7 @@ export async function exportXmind(
if (details.labels.length) topic.labels = details.labels;
const markerSet = new Set<string>();
for (const emoji of array(data.emojis)) {
const marker = markerIds[String(emoji.name)];
const marker = getXmindMarker(String(emoji.name))?.id;
if (marker) markerSet.add(marker);
else warnings.add("部分自定义图标尚不能写入 XMind 标记");
}
@ -199,7 +203,7 @@ export async function exportXmind(
for (const marker of array(metadata.markers))
if (
typeof marker.markerId === "string" &&
!Object.values(markerIds).includes(marker.markerId)
!getXmindMarker(marker.markerId)
)
markerSet.add(marker.markerId);
if (markerSet.size)
@ -331,6 +335,7 @@ export async function exportXmind(
class: "sheet",
title: source.title,
rootTopic: root,
...record(record(topics.find(entry => !parents.has(entry.element.id))?.element.xmind).sheet),
relationships,
});
}

View File

@ -1,4 +1,5 @@
import type JSZip from "jszip";
import { getXmindMarker, xmindMarkerName } from "./xmind-markers";
import { getTopicEntries } from "./topic-details";
import {
SPECIAL_MIND_LAYOUTS,
@ -233,11 +234,13 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
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
floatingRoot = false,
inheritedBranchColor = ""
): MindElement => {
if (++nodeCount > 10_000 || depth > 128)
throw new MindMapImportError(
@ -280,16 +283,20 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
...record(record(topic.style).properties),
};
const fill = color(properties["svg:fill"]);
if (fill) element.fill = 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"] || "")
);
@ -311,6 +318,7 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
);
const leaf = {
text: text(topic.title, "未命名主题"),
...(properties["fo:color"] === "inherited" ? { color: "#000000" } : {}),
...(color(properties["fo:color"])
? { color: color(properties["fo:color"]) }
: {}),
@ -331,31 +339,20 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
};
element.data.topic.children = [leaf];
const metadata: Data = {};
for (const key of ["notes", "href", "labels", "markers", "image"])
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);
const priority = id.match(/^priority-([1-7])$/);
const mapped = priority
? `${priority[1]}️⃣`
: (
{
"task-done": "✅",
"task-start": "▶️",
"flag-red": "🚩",
"star-red": "⭐",
} as Record<string, string>
)[id];
if (mapped) emojis.push(mapped);
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) =>
build(child, depth + 1)
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) {
@ -446,8 +443,22 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
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:#")

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,5 @@
# Xmind 画布字体
来自本机 Xmind 26.04.01337 安装包 `/static/snowbird/resource/fonts/files/` 的 NeverMind 字体,按用户要求读取客户端资源用于本地界面对照。包含 Regular(400)、Medium(500)、Bold(700)、Extrabold(800)。
流程白板在导入前等待字体加载,并以画布字体计算主题文字尺寸,避免系统字体测量与实际渲染不一致导致标题换行裁切。

View File

@ -0,0 +1,16 @@
# UI reference icons
These vector shapes were captured from the rendered Lovart UI at the user-selected reference:
https://www.lovart.ai/canvas?projectId=2rqrq08o10cg
Only SVG drawing elements and local fragment references are retained. No scripts, event attributes, external references, account data or authentication data are included.
The model families are GPT/OpenAI, Nano Banana, Seedream, Flux, Luma, Midjourney, Ideogram and Recraft. Model IDs, parameter handling and provider calls remain in the existing application code. Other model names retain their configured avatar, with a neutral icon when an avatar is unavailable.
Selected-image toolbar and overflow-menu shapes were captured from the same reference with a temporary test image: annotation, HD upscale, background removal, eraser, layer extraction, text editing, angle editing, motion, more, download, object move, mockup, outpaint, adjustment, crop, vector and flip/rotate. The toolbar keeps the existing configured actions and custom icon overrides.
The local editing icon is an application-neutral selection/brush drawing rather than the reference service’s branded quick-edit logo. Toolbar customization uses local icons and stores only tool order, pinned tools and label visibility in the browser.
Application-specific actions use local neutral icons adapted from the existing Lucide geometry: pan, text, sparkle, tutorial, video, toolbox, profile, settings and layout replacement. Their 1.5-unit strokes match the reference icon set. Home navigation and the main image-canvas toolbar render at 20px, including configured icon overrides in the home navigation.
The style controls and image enhancement actions use local slider and framed-landscape drawings in place of sparkle symbols, with the same 24-unit viewBox, 1.5-unit strokes and 20px toolbar size.

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M11.25 20V5.81l-4.72 4.72a.75.75 0 1 1-1.06-1.06l6-6 .056-.052a.75.75 0 0 1 1.004.052l6 6a.75.75 0 1 1-1.06 1.06l-4.72-4.72V20a.75.75 0 0 1-1.5 0" /></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M13.621 13.23a4.11 4.11 0 1 1-3.007 1.31l1.067-.389a2.53 2.53 0 0 0 1.144-.844q.387-.075.796-.076M5.012 6.236c.345-.125.73-.057 1.011.18l5.454 4.574a1.028 1.028 0 0 1-.31 1.752l-6.688 2.435a1.027 1.027 0 0 1-1.364-1.144l1.237-7.01c.063-.36.315-.661.66-.787M13.364 2.837a1.03 1.03 0 0 1 .78-.103L20.1 4.33c.547.147.873.71.726 1.258l-1.595 5.955a1.03 1.03 0 0 1-1.26.726l-4.752-1.274a2.53 2.53 0 0 0-.778-1.154l-1.033-.866 1.479-5.514c.07-.263.242-.488.478-.624" /></svg>

After

Width:  |  Height:  |  Size: 613 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M13.234 13.236a4.11 4.11 0 0 1 3.897 4.104l-.005.211a4.11 4.11 0 0 1-8.209 0l-.005-.21a4.11 4.11 0 0 1 4.11-4.11zm-.212 1.495a2.61 2.61 0 1 0 0 5.219 2.61 2.61 0 0 0 0-5.219m-8.51-8.495c.345-.126.73-.057 1.012.179l5.453 4.575c.28.236.414.604.35.966l-.032.133a1.03 1.03 0 0 1-.628.654L3.98 15.177l-.131.038c-.265.059-.542.01-.77-.135l-.111-.081a1.03 1.03 0 0 1-.352-.966l1.237-7.01c.064-.361.315-.662.66-.787m-.277 7.25 5.149-1.874-4.198-3.523zm9.53-10.649a1.03 1.03 0 0 1 .78-.102L20.5 4.33c.548.147.873.71.727 1.257l-1.596 5.955-.031.096a1.03 1.03 0 0 1-.447.528l-.091.046a1.03 1.03 0 0 1-.69.056l-5.954-1.595a1.03 1.03 0 0 1-.726-1.258l1.595-5.954c.07-.264.243-.488.479-.625m-.503 6.51 5.041 1.35 1.35-5.041-5.04-1.35z" /></svg>

After

Width:  |  Height:  |  Size: 874 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 18 18"><path fill="currentColor" d="M11.588 2.25c.62 0 1.12 0 1.523.032.41.034.77.105 1.103.275.53.27.96.7 1.23 1.229.169.333.24.693.274 1.103.033.403.032.903.032 1.523v5.176c0 .62 0 1.12-.032 1.523-.034.41-.105.77-.275 1.103-.27.53-.7.96-1.229 1.23-.333.169-.693.24-1.103.274-.403.033-.903.032-1.523.032H6.412c-.62 0-1.12 0-1.523-.032-.41-.034-.77-.105-1.103-.275-.53-.27-.96-.7-1.23-1.229-.169-.333-.24-.693-.274-1.103-.033-.403-.032-.903-.032-1.523V6.412c0-.62 0-1.12.032-1.523.034-.41.105-.77.275-1.103.27-.53.7-.96 1.229-1.23.333-.169.693-.24 1.103-.274.403-.033.903-.032 1.523-.032zM9.563 14.625h2.025c.639 0 1.085 0 1.432-.029.34-.027.536-.08.684-.155.317-.162.575-.42.737-.737.076-.149.128-.344.155-.684.029-.347.029-.793.029-1.432v-.338H9.563zm-6.188-6.75v3.713c0 .639 0 1.085.029 1.432.027.34.08.536.155.684.162.317.42.575.737.737.149.076.344.128.684.155.347.029.793.029 1.432.029h2.026v-6.75zm6.188 2.25h5.062V6.412c0-.639 0-1.085-.029-1.432-.027-.34-.08-.535-.155-.684a1.7 1.7 0 0 0-.737-.737c-.149-.076-.344-.128-.684-.155-.347-.029-.793-.029-1.432-.029H9.563zm-3.15-6.75c-.64 0-1.086 0-1.433.029-.34.027-.535.08-.684.155-.317.162-.575.42-.737.737-.076.149-.128.344-.155.684-.029.347-.029.793-.029 1.432v.338h5.063V3.375z" /></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M15.47 9.47a.75.75 0 1 1 1.06 1.06l-2.94 2.94a2.25 2.25 0 0 1-3.18 0l-2.94-2.94a.75.75 0 1 1 1.06-1.06l2.94 2.94a.75.75 0 0 0 1.06 0z" /></svg>

After

Width:  |  Height:  |  Size: 287 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 18 18"><path fill="currentColor" d="M10.688 9.75a.562.562 0 1 1 0 1.125H7.312a.562.562 0 1 1 0-1.125z" /><path fill="currentColor" fill-rule="evenodd" d="M14.16 3.059a.563.563 0 0 1 .93.632L13.006 6.75h1.431c.311 0 .563.252.563.563v6.375a2.813 2.813 0 0 1-2.812 2.812H5.812A2.813 2.813 0 0 1 3 13.688V7.312c0-.31.252-.562.563-.562h8.093a.6.6 0 0 1 .053-.094zM4.126 13.687c0 .933.756 1.688 1.688 1.688h6.375c.931 0 1.687-.755 1.687-1.687V7.875h-9.75z" clip-rule="evenodd" /><path fill="currentColor" d="M5.356 2.786a.3.3 0 0 1 .537 0l.312.624a.3.3 0 0 0 .134.134l.624.312a.3.3 0 0 1 0 .537l-.358.179-.266.133a.3.3 0 0 0-.134.134l-.312.624-.023.039a.3.3 0 0 1-.49 0l-.024-.039-.312-.624a.3.3 0 0 0-.092-.109l-.042-.025-.624-.312a.3.3 0 0 1 0-.537l.624-.312a.3.3 0 0 0 .134-.134zM9.415 1.42a.375.375 0 0 1 .67 0l.36.718a.38.38 0 0 0 .167.168l.717.359a.375.375 0 0 1 0 .67l-.717.36a.38.38 0 0 0-.168.167l-.359.717a.375.375 0 0 1-.67 0l-.36-.717a.4.4 0 0 0-.115-.136l-.052-.032-.717-.359a.375.375 0 0 1 0-.67l.717-.36a.38.38 0 0 0 .168-.167z" /></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M15.005 7.934a.75.75 0 0 1 1.061 1.06L13.06 12l3.006 3.005a.75.75 0 0 1-1.06 1.061L12 13.06l-3.005 3.006a.75.75 0 0 1-1.06-1.06L10.938 12 7.934 8.995a.75.75 0 1 1 1.06-1.06L12 10.938z" /></svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M18 3.25A3.75 3.75 0 0 1 21.75 7v10A3.75 3.75 0 0 1 18 20.75H6A3.75 3.75 0 0 1 2.25 17V7A3.75 3.75 0 0 1 6 3.25zm-8.75 16H18A2.25 2.25 0 0 0 20.25 17V7A2.25 2.25 0 0 0 18 4.75H9.25zM6 4.75A2.25 2.25 0 0 0 3.75 7v10A2.25 2.25 0 0 0 6 19.25h1.75V4.75z" /></svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M20.5 12c0-4.38-3.766-8-8.5-8s-8.5 3.62-8.5 8c0 1.372.367 2.662 1.014 3.791.482.842.853 1.945.524 3.07q-.076.26-.161.517a16 16 0 0 0 1.241-.205c.922-.19 1.86-.072 2.7.248A9 9 0 0 0 12 20c4.734 0 8.5-3.62 8.5-8m1.5 0c0 5.285-4.517 9.5-10 9.5-1.31 0-2.564-.24-3.714-.677-.615-.233-1.262-.305-1.865-.18a18.5 18.5 0 0 1-2.597.34.75.75 0 0 1-.747-1.016c.196-.51.373-1.018.522-1.527l.027-.106c.116-.537-.047-1.157-.414-1.797A9.1 9.1 0 0 1 2 12c0-5.285 4.517-9.5 10-9.5S22 6.715 22 12" /></svg>

After

Width:  |  Height:  |  Size: 631 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 18 18"><path fill="currentColor" d="M13.652 2.25c1.27 0 2.383.852 2.715 2.078l.416 1.538a2.52 2.52 0 0 1-.846 2.629v4.443a2.813 2.813 0 0 1-2.813 2.812H5.25a2.81 2.81 0 0 1-2.812-2.812V8.495a2.52 2.52 0 0 1-.846-2.629l.416-1.538A2.81 2.81 0 0 1 4.722 2.25zm-.991 5.928c-.419.5-1.035.822-1.738.822a2.26 2.26 0 0 1-1.736-.821c-.418.5-1.035.82-1.737.821-.702 0-1.32-.322-1.738-.822a2.4 2.4 0 0 1-2.15.799v3.96c0 .933.755 1.688 1.687 1.688h7.875c.932 0 1.688-.756 1.688-1.687V8.977a2.394 2.394 0 0 1-2.151-.798M4.72 3.375c-.761 0-1.43.51-1.629 1.247l-.416 1.54v.002c-.243.885.4 1.711 1.212 1.711.713 0 1.263-.522 1.263-1.246v-.01a.563.563 0 0 1 1.125.006v.008c.002.708.548 1.242 1.174 1.242.627 0 1.174-.536 1.174-1.246v-.008a.563.563 0 0 1 1.125 0v.008c0 .71.547 1.246 1.174 1.246s1.174-.536 1.174-1.246v-.008a.563.563 0 0 1 1.125 0v.008c0 .724.55 1.246 1.263 1.246.811 0 1.456-.826 1.213-1.71v-.003l-.417-1.54a1.69 1.69 0 0 0-1.63-1.247z" /></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 18 18"><path fill="currentColor" d="M2.49 10.664a2.623 2.623 0 0 1 3.442 3.804 5.6 5.6 0 0 0 3.065.907q.542 0 1.055-.099a.562.562 0 0 1 .21 1.106q-.617.117-1.265.118a6.72 6.72 0 0 1-3.932-1.264 2.623 2.623 0 0 1-2.574-4.572m10.806-5.408a.563.563 0 0 1 .794.055 6.73 6.73 0 0 1 1.58 5.46 2.624 2.624 0 0 1-2.79 4.44 2.625 2.625 0 0 1 1.712-4.866q.032-.296.033-.599a5.6 5.6 0 0 0-1.383-3.696.563.563 0 0 1 .054-.794m-8.194 6.932a1.5 1.5 0 1 0-2.598 1.5 1.5 1.5 0 0 0 2.598-1.5m9.84-.55a1.5 1.5 0 1 0-1.5 2.6 1.5 1.5 0 0 0 1.5-2.6M9 1.314a2.625 2.625 0 1 1-2.51 3.391 5.65 5.65 0 0 0-2.843 3.282.563.563 0 1 1-1.068-.35 6.77 6.77 0 0 1 3.835-4.131A2.62 2.62 0 0 1 9 1.313m0 1.125a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3" /></svg>

After

Width:  |  Height:  |  Size: 811 B

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