Compare commits

..

No commits in common. "3a2ee064d06ab5a5ccbbebc4d3cc435efc2f84ff" and "d54180e11ddd7f30a6fa73e0bee850e43b057373" have entirely different histories.

419 changed files with 3185 additions and 14833 deletions

View File

@ -153,9 +153,8 @@ PRODUCT_PLANNING_DB_SSL=false
# Home Performance / Queues
# -----------------------------------------------------------------------------
# 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
CANVAS_IMAGE_TASK_CONCURRENCY=3
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
EXTERNAL_IMAGE_API_MAX_QUEUE=20
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000

View File

@ -1078,15 +1078,139 @@ 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: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
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>
}
>
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
<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>
</LocalAiSectionShell>
{/* 批量处理配置 */}

View File

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

View File

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

View File

@ -1,65 +0,0 @@
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,7 +3,6 @@ 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,
@ -120,8 +119,7 @@ export async function PUT(
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
}
const { title, description, canvasData } = body;
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
const { title, description, thumbnail, canvasData } = body;
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
const workspaceType =
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")

View File

@ -1,34 +0,0 @@
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,7 +74,6 @@ 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";
@ -88,7 +87,6 @@ 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 {
@ -251,6 +249,7 @@ import type {
AgentWorkflowUiConfig,
} from "@/types/agent";
import type {
AgentSlotNodeData,
CanvasEdge,
CanvasInitialProject,
CanvasNode,
@ -263,7 +262,7 @@ import type {
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
const CANVAS_SURFACE_BACKGROUND = "#f4f4f5";
const fetchCanvasSiteSettings = async (url: string) => {
const separator = url.includes("?") ? "&" : "?";
@ -1907,8 +1906,6 @@ export default function CanvasEditorLegacyPage({
[isVideoMode]
);
useCanvasImageArchives(id);
// Store state
const projectTitle = useCanvasStore((state) => state.projectTitle);
const viewport = useCanvasStore(canvasSelectors.viewport);
@ -3144,7 +3141,8 @@ 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)
@ -7517,7 +7515,7 @@ export default function CanvasEditorLegacyPage({
materialMimeType: image.mimeType || undefined,
originalHeight: dimensions.originalHeight,
originalWidth: dimensions.originalWidth,
source: image.sourceType === "upload" ? "local-upload" : "agent-workflow-material-search",
source: "agent-workflow-material-search",
},
},
connections: [],
@ -7529,7 +7527,7 @@ export default function CanvasEditorLegacyPage({
if (createdNodeIds.length > 0) {
selectNodes(createdNodeIds);
toast.success(`已添加 ${createdNodeIds.length} 张${validImages.every((image) => image.sourceType === "upload") ? "图片" : "白底图"}到画布`);
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
}
},
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
@ -7545,6 +7543,64 @@ 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");
@ -10256,11 +10312,7 @@ export default function CanvasEditorLegacyPage({
return (
<TooltipProvider>
<div
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"
)}
className="relative h-dvh w-full overflow-hidden overscroll-none"
style={
{
"--canvas-surface-background": canvasSurfaceBackground,
@ -10272,7 +10324,6 @@ 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
@ -10283,10 +10334,9 @@ export default function CanvasEditorLegacyPage({
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
: '点击顶部"添加"按钮开始创作'
}
occlusionRight={0}
occlusionRight={panelOcclusionRight}
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
showEmptyState={false}
showZoomControls={isVideoMode}
showEmptyState={!isVideoMode}
workspaceLabel={workspaceLabel}
workspaceVariant={isVideoMode ? "video" : "canvas"}
layerSplitEnabled={isLayerSplitEnabled}
@ -10975,27 +11025,14 @@ export default function CanvasEditorLegacyPage({
</div>
) : null}
{isVideoMode ? brandEntry : (
<CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode}
onHome={goHome}
onOpenFiles={() => {
setIsPanelOpen(true);
setPanelMode("fileList");
}}
onSave={() => void handleSave()}
projectTitle={projectTitle}
rightInset={panelOcclusionRight}
/>
)}
{brandEntry}
{agentDisabledNotice}
{/* 2. Floating Actions (Top Right) */}
{!isVideoMode && !isPanelOpen && (
<div
className="absolute z-20"
style={{ top: 46, right: 12 }}
style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}
>
<Button
className={cn(
@ -11027,7 +11064,7 @@ export default function CanvasEditorLegacyPage({
)}
data-canvas-right-panel="true"
style={{
top: 0,
top: DOCKED_PANEL_INSET,
right: DOCKED_PANEL_INSET,
bottom: DOCKED_PANEL_INSET,
width: resolvedPanelWidth,
@ -11058,7 +11095,7 @@ export default function CanvasEditorLegacyPage({
/>
</div>
) : null}
<div className="flex h-full w-full flex-col overflow-hidden border-l border-[#ededed] bg-white dark:bg-zinc-950">
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
{!areSecondaryPanelsReady ? (
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
) : panelMode === "chat" ? (
@ -11164,11 +11201,11 @@ export default function CanvasEditorLegacyPage({
className={cn(
isVideoMode
? VIDEO_CANVAS_TOOLBAR_CLASS
: "border border-[#e9e9e9] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.035)] dark:border-white/10 dark:bg-zinc-950",
: "border bg-white shadow-2xl 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-[14px] px-1.5 py-1.5")
: "flex items-center gap-1 rounded-[24px] px-2.5 py-1.5")
)}
style={
shouldDockFloatingToolbarToSide
@ -11186,7 +11223,7 @@ export default function CanvasEditorLegacyPage({
tool === "select" &&
(isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
: "bg-primary text-primary-foreground hover:bg-primary/90")
)}
onClick={() => setTool("select")}
size="icon"
@ -11196,8 +11233,6 @@ export default function CanvasEditorLegacyPage({
active={tool === "select"}
className="h-4 w-4"
fallback={MousePointer}
referenceName={isVideoMode ? undefined : "cursor"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"selectTool"
@ -11211,6 +11246,63 @@ 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
@ -11221,7 +11313,7 @@ export default function CanvasEditorLegacyPage({
tool === "pan" &&
(isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
: "bg-primary text-primary-foreground hover:bg-primary/90")
)}
onClick={() => setTool("pan")}
size="icon"
@ -11231,8 +11323,6 @@ 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>
@ -11353,7 +11443,7 @@ export default function CanvasEditorLegacyPage({
tool === "draw" &&
(isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
: "bg-primary text-primary-foreground hover:bg-primary/90")
)}
onClick={() => setTool("draw")}
size="icon"
@ -11394,7 +11484,7 @@ export default function CanvasEditorLegacyPage({
isAgentMode &&
(isVideoMode
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
: "bg-primary text-primary-foreground hover:bg-primary/90")
)}
onClick={handleStyleModeToolbarClick}
size="icon"
@ -11410,14 +11500,6 @@ export default function CanvasEditorLegacyPage({
fallback={
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
}
referenceName={
isVideoMode
? undefined
: shouldUseStyleModeButtonForStylePro
? "style-controls"
: "creativity"
}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"styleMode"
@ -11447,8 +11529,6 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
referenceName={isVideoMode ? undefined : "style-controls"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"stylePro"
@ -11478,8 +11558,6 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Layers}
referenceName={isVideoMode ? undefined : "layers"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
"batchProcessing"
@ -11511,8 +11589,6 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={ImagePlus}
referenceName={isVideoMode ? undefined : "image"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addImageInput" : "addImage"
@ -11545,8 +11621,6 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Type}
referenceName={isVideoMode ? undefined : "text"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "addScriptNode" : "addText"
@ -11704,8 +11778,6 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={PenTool}
referenceName={isVideoMode ? undefined : "draw"}
referenceSize={20}
item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"realtimeCanvas",
@ -11753,8 +11825,6 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
referenceName={isVideoMode ? undefined : "image-enhance"}
referenceSize={20}
item={resolveCanvasToolbarItemWithFallback(
canvasToolbarConfig,
"enhanceCanvas",
@ -11808,8 +11878,6 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Box}
referenceName={isVideoMode ? undefined : "model-auto"}
referenceSize={20}
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
/>
</Button>
@ -11899,8 +11967,6 @@ export default function CanvasEditorLegacyPage({
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Plus}
referenceName={isVideoMode ? undefined : "plus"}
referenceSize={20}
item={getCanvasToolbarItem(
canvasToolbarConfig,
isVideoMode ? "newVideoCanvas" : "newCanvas"
@ -11928,15 +11994,12 @@ export default function CanvasEditorLegacyPage({
type="file"
/>
{/* Compact zoom and history controls. */}
{/* 6. Bottom Left Compact Controls */}
<div
data-canvas-compact-controls="true"
className="absolute z-20"
style={{
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
...(isVideoMode
? { left: `${compactControlsLeft + 8}px` }
: { right: `${panelOcclusionRight + 16}px` }),
left: `${compactControlsLeft + 8}px`,
}}
>
<div

View File

@ -507,272 +507,3 @@
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, Upload, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
import { Button } from "@/components/ui/button";
import {
@ -21,8 +21,6 @@ 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,
@ -165,8 +163,6 @@ 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]);
@ -186,13 +182,12 @@ export function CanvasAddImageDialog({
const handleDialogOpenChange = useCallback(
(nextOpen: boolean) => {
if (!nextOpen && (isUploading || isAdding)) return;
if (!nextOpen) {
resetDialogState();
}
onOpenChange(nextOpen);
},
[isAdding, isUploading, onOpenChange, resetDialogState]
[onOpenChange, resetDialogState]
);
const closeDialog = useCallback(() => {
@ -268,51 +263,6 @@ 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">
@ -322,38 +272,15 @@ 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 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}
className="h-9 justify-start gap-2"
onClick={() => {
onCreateBlankImage();
closeDialog();
@ -428,13 +355,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 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">
<div 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}
@ -513,7 +440,6 @@ export function CanvasAddImageDialog({
<DialogFooter className="border-t px-5 py-3">
<Button
disabled={isUploading || isAdding}
onClick={closeDialog}
type="button"
variant="outline"
@ -522,7 +448,7 @@ export function CanvasAddImageDialog({
</Button>
<Button
className="gap-2"
disabled={!selectedImages.length || isAdding || isUploading}
disabled={!selectedImages.length || isAdding}
onClick={() => void handleAddSelected()}
type="button"
>

View File

@ -1,115 +0,0 @@
"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,11 +25,8 @@ 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, Images } 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 } 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,
@ -14733,9 +14730,7 @@ export function CanvasChatPanel({
: selectedSkillTag?.placeholder?.trim() ||
(selectedSkillTag
? `请输入「${selectedSkillTag.name}」相关需求`
: activeComposerMode === "image"
? "今天我们要创作什么"
: "请输入你的设计需求");
: "请输入你的设计需求");
const clawdEditablePresetPlaceholder =
isClawdMode && activeClawdSkillTag
? activeClawdSkillTag.placeholder?.trim() || ""
@ -15223,14 +15218,10 @@ 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",
!isWorkspacePresentation && !isVideoWorkspace && "rounded-none border-0 bg-transparent dark:bg-transparent"
: "border border-transparent bg-white dark:border-white/20 dark:bg-white"
)}
>
{!isWorkspacePresentation && !isVideoWorkspace ? (
<ModelIdentityIcon name={model.displayName} identity={`${model.id} ${model.providerType || ""}`} avatarUrl={model.avatarUrl ? buildPreviewImageUrl(model.avatarUrl, 40) : undefined} size={18} />
) : (
model.avatarUrl ? (
{model.avatarUrl ? (
<ModelAvatarImage
src={buildPreviewImageUrl(model.avatarUrl, 40)}
alt={model.displayName}
@ -15257,7 +15248,6 @@ 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">
@ -15410,7 +15400,6 @@ 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
@ -15448,7 +15437,7 @@ export function CanvasChatPanel({
</div>
)}
{!isWorkspacePresentation ? (
<div className="lovart-chat-header z-20 flex h-[40px] shrink-0 items-center justify-between gap-2 px-3 py-2">
<div className="z-20 flex 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(
@ -15880,8 +15869,8 @@ export function CanvasChatPanel({
) : null}
</div>
) : (
<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">
<div className="flex flex-1 flex-col px-5 pb-8 pt-8">
<div className="text-left">
<h1 className="text-[20px] font-semibold leading-tight text-[#050505] dark:text-zinc-50">
Hi,{" "}
{greetingUserName ? (
@ -15899,7 +15888,7 @@ export function CanvasChatPanel({
</div>
{canvasSkillTags.length > 0 ? (
<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">
<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">
<h3 className="text-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
{canvasSkillTagsTitle}
</h3>
@ -15971,38 +15960,40 @@ export function CanvasChatPanel({
) : null}
</div>
) : null}
<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("");
{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)
}
}}
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>
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}
<div
className={cn(
"mt-4 flex flex-wrap justify-center gap-1.5 transition-[max-height] duration-150 ease-out",
@ -16011,7 +16002,7 @@ export function CanvasChatPanel({
: "mt-6",
isCanvasSkillTagSearching || areCanvasSkillTagsExpanded
? "max-h-none overflow-visible"
: "max-h-[calc(4rem+0.375rem)] overflow-hidden"
: "max-h-[76px] overflow-hidden"
)}
>
{filteredCanvasSkillTags.map((tag) => {
@ -16974,53 +16965,37 @@ export function CanvasChatPanel({
<CanvasComposerUploadProgressDialog progress={composerUploadProgress} />
{!isWorkspacePresentation && shouldShowCanvasBanner ? (
!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");
}
<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);
}}
>
<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>
<X className="h-5 w-5" />
</button>
</div>
)
</div>
) : null}
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
@ -17028,16 +17003,15 @@ export function CanvasChatPanel({
className={cn(
isWorkspacePresentation
? "border-t bg-white/92 p-4 backdrop-blur-sm dark:bg-zinc-950/92"
: "lovart-chat-composer bg-white px-[8px] pb-[8px] pt-0 dark:bg-zinc-950"
: "bg-white px-2 pb-2 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-[120px] rounded-[24px]"
"min-h-[148px] rounded-[18px]"
)}
data-canvas-chat-input-anchor="true"
data-canvas-composer-mode={activeComposerMode}
>
{isWorkspacePresentation && !isDetailGenerationMode ? (
<div className="border-b px-5 py-3">
@ -17793,10 +17767,7 @@ export function CanvasChatPanel({
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
<div
className={cn(
"relative pb-0",
!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image"
? "px-0 pt-0"
: "px-3 pt-2.5",
"relative px-3 pb-0 pt-2.5",
isDetailGenerationMode
? "min-h-[96px]"
: activeComposerMode === "image"
@ -18175,17 +18146,16 @@ export function CanvasChatPanel({
</div>
) : null}
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? (
<div className="lovart-reference-upload mx-[8px] mt-[8px] flex items-center">
<div className="mx-4 mt-3 flex items-center">
<button
type="button"
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"
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"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
title={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
aria-label={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
>
<ReferenceIcon name="reference-image" size={16} />
<span>参考图</span>
<Plus className="h-4 w-4" />
{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">
@ -18247,7 +18217,6 @@ export function CanvasChatPanel({
) : null}
<div
className={cn(
"lovart-composer-text-region",
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
? "px-1"
: "px-2 pt-2"
@ -18256,11 +18225,6 @@ 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)]",
@ -18488,7 +18452,7 @@ export function CanvasChatPanel({
{/* Bottom Toolbar */}
<div
className={cn(
"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"
"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">
@ -18622,7 +18586,7 @@ export function CanvasChatPanel({
type="button"
variant="ghost"
className={cn(
"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",
"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"
@ -18648,8 +18612,6 @@ 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
@ -18659,9 +18621,7 @@ export function CanvasChatPanel({
/>
)}
<span className="min-w-0 truncate">{activeModeLabel}</span>
{!isWorkspacePresentation && !isVideoWorkspace ? (
<ReferenceIcon name="chevron-down" size={18} className={cn("ml-1.5", isComposerModeMenuOpen && "rotate-180")} />
) : isComposerModeMenuOpen ? (
{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" />
@ -18793,7 +18753,6 @@ export function CanvasChatPanel({
hideImageCount={false}
hideAdvancedOptions={true}
inlineTheme="minimal"
referencePresentation={!isWorkspacePresentation && !isVideoWorkspace}
showInlineDivider={false}
/>
) : null}
@ -18824,7 +18783,6 @@ 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]"
@ -18832,13 +18790,6 @@ 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",
@ -18883,13 +18834,12 @@ export function CanvasChatPanel({
</span>
)}
</span>
)}
</SelectTrigger>
{renderModelSelectContent()}
</Select>
) : null}
{estimatedPointsCost && (isWorkspacePresentation || isVideoWorkspace || activeComposerMode !== "image") ? (
{estimatedPointsCost ? (
<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]",
@ -18914,7 +18864,6 @@ 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",
@ -18945,8 +18894,6 @@ export function CanvasChatPanel({
? "停止处理并重新编辑"
: isWorkspacePresentation || isDetailGenerationMode
? getToolbarItem("chatStartGeneration").label
: !isVideoWorkspace && activeComposerMode === "image" && estimatedPointsCost
? `${getToolbarItem("chatSend").label} · ${estimatedPointsCost.title}`
: getToolbarItem("chatSend").label
}
>
@ -18980,11 +18927,6 @@ 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

@ -1,54 +0,0 @@
"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

@ -1,95 +0,0 @@
"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,10 +1,6 @@
"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";
@ -16,12 +12,13 @@ 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) {
@ -45,8 +42,6 @@ export function CanvasToolbarIcon({
className,
style,
active = false,
referenceName,
referenceSize = 18,
}: CanvasToolbarIconProps) {
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
@ -80,9 +75,7 @@ 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}
@ -91,15 +84,5 @@ export function CanvasToolbarIcon({
);
}
if (referenceName) {
return (
<ReferenceIcon
name={referenceName}
size={referenceSize}
className={className}
style={style}
/>
);
}
return <FallbackIcon className={className} style={style} />;
}

View File

@ -12,12 +12,8 @@ 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 {
@ -32,7 +28,6 @@ import {
LayoutTemplate,
LayoutGrid,
Lightbulb,
Loader2,
Minus,
Pin,
Plus,
@ -395,11 +390,6 @@ 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);
@ -737,6 +727,10 @@ export function CanvasWorkspace({
canvasToolbarConfig,
"imageLayoutReplace"
);
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
const selectedImageLocalEditEnabled = true;
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
const selectedImageLayoutReplaceEnabled = true;
// Store actions
const selectNode = useCanvasStore((state) => state.selectNode);
@ -1112,61 +1106,42 @@ export function CanvasWorkspace({
);
const fitAllImageNodesIntoViewport = useCallback(() => {
const fit = getCanvasFitViewport(
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
containerSize, occlusionRight
const imageNodes = nodes.filter(
(node) => node.type === "image" && Boolean((node.data as any)?.src)
);
if (fit) setViewport(fit);
}, [containerSize, nodes, occlusionRight, setViewport]);
if (imageNodes.length === 0) return;
if (containerSize.width <= 0 || containerSize.height <= 0) return;
const fitAllCanvasNodesIntoViewport = useCallback(() => {
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
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 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 contentWidth = Math.max(1, maxX - minX + padding * 2);
const contentHeight = Math.max(1, maxY - minY + padding * 2);
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 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)));
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 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]);
const getNodeInputAnchor = useCallback(
(node: CanvasNode): Position => ({
@ -1283,7 +1258,6 @@ export function CanvasWorkspace({
if (canvasImageSelectionSession) return;
if (hasBlockingDialogOpen()) return;
setAgentSlotContextMenu(null);
setBlankContextPosition(null);
if (!isEventFromCanvasContainer(e.target)) return;
if (suppressCanvasClickRef.current) {
suppressCanvasClickRef.current = false;
@ -1320,7 +1294,7 @@ export function CanvasWorkspace({
!isPointOnNodeElement(e.target);
if (
(tool === 'pan' && e.button === 0) ||
tool === 'pan' ||
e.button === 1 ||
(e.button === 0 && e.altKey) ||
shouldPanBlankWorkflowCanvas
@ -1683,105 +1657,6 @@ 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;
@ -1844,44 +1719,24 @@ export function CanvasWorkspace({
React.useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const activeElement = document.activeElement;
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
if (hasBlockingDialogOpen() || 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) && !fromContextMenu
!container.contains(activeElement)
) {
return;
}
const files = extractImageFilesFromDataTransfer(e.clipboardData);
const text = e.clipboardData?.getData("text/plain") || "";
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
if (files.length === 0) 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,
@ -1891,7 +1746,7 @@ export function CanvasWorkspace({
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]);
}, [hasBlockingDialogOpen, processDroppedFiles]);
const handleDownloadSelectedImages = useCallback(async () => {
if (selectedImageNodes.length === 0) {
@ -2414,47 +2269,12 @@ 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]" />
@ -2746,14 +2566,10 @@ export function CanvasWorkspace({
)}
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
<div
className={cn(
"absolute left-4 z-30 flex items-center gap-2",
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
)}
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
data-canvas-overlay
data-canvas-selection-actions="true"
>
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
size="sm"
@ -2775,7 +2591,7 @@ export function CanvasWorkspace({
{selectedImageLocalEditItem.label}
</Button>
) : null}
{isWorkflowCanvas &&
{selectedImageLayoutReplaceEnabled &&
selectedImageNodes.length >= 1 ? (
<Button
variant={isWorkflowCanvas ? "outline" : "secondary"}
@ -2804,48 +2620,27 @@ export function CanvasWorkspace({
{selectedImageLayoutReplaceItem.label}
</Button>
) : null}
<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>
<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>
</div>
)}

View File

@ -1,452 +0,0 @@
"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, usePathname, useRouter } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
@ -56,7 +56,6 @@ 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 {
@ -1308,8 +1307,6 @@ 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"
@ -1535,11 +1532,9 @@ export function ImageNode({
.map((action) => action.key)
);
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
useReferenceImageToolbar
? enabledSelectedImageToolbarActions.some((action) => action.key === key)
: selectedImageInlineActionKeys.has(key);
selectedImageInlineActionKeys.has(key);
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
!useReferenceImageToolbar && selectedImageMoreActionKeys.has(key);
selectedImageMoreActionKeys.has(key);
const hasHoverImageMorePrimaryActions =
imageRemoveBackgroundEnabled ||
imageRemoveWatermarkEnabled ||
@ -1565,7 +1560,6 @@ export function ImageNode({
const hasSelectedImageMorePrimaryActions =
selectedImageMoreActionKeys.size > 0;
const hasSelectedImageMoreMenu =
useReferenceImageToolbar ||
hasSelectedImageMorePrimaryActions ||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
(imageNodeMoreEnabled && imageDeleteEnabled);
@ -9646,8 +9640,6 @@ 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} />
@ -10200,12 +10192,8 @@ export function ImageNode({
typeof document !== "undefined" &&
createPortal(
<TooltipProvider delayDuration={200}>
<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}
<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"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
@ -10229,8 +10217,7 @@ 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}
data-tool-key="addToChat"
title={imageAddToChatItem.label}
title={imageAddToChatItem.label}
variant="ghost"
>
{isAddingToChat ? (
@ -10241,8 +10228,6 @@ 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>
@ -10257,7 +10242,6 @@ 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}
@ -10268,8 +10252,6 @@ 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>
@ -10277,7 +10259,6 @@ 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")}
@ -10288,8 +10269,6 @@ 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} />
@ -10298,7 +10277,6 @@ 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")}
@ -10309,8 +10287,6 @@ 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
@ -10322,7 +10298,6 @@ 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
@ -10332,7 +10307,6 @@ export function ImageNode({
disabled={isProcessing || isSubmittingAiWashQuick}
onClick={() => handleAction("ai-wash")}
title={aiWashQuickModeLabel}
data-reference-active={aiWashQuickPanelOpen ? "true" : undefined}
variant="ghost"
>
{isSubmittingAiWashQuick ? (
@ -10343,8 +10317,6 @@ 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}
@ -10357,7 +10329,6 @@ 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")}
@ -10368,8 +10339,6 @@ 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>
@ -10377,7 +10346,6 @@ 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")}
@ -10388,8 +10356,6 @@ 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} />
@ -10398,7 +10364,6 @@ 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")}
@ -10409,8 +10374,6 @@ 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
@ -10422,7 +10385,6 @@ 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")}
@ -10433,8 +10395,6 @@ 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
@ -10446,7 +10406,6 @@ 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")}
@ -10457,8 +10416,6 @@ 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
@ -10470,7 +10427,6 @@ 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")}
@ -10488,7 +10444,6 @@ 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")}
@ -10499,8 +10454,6 @@ 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
@ -10512,7 +10465,6 @@ 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")}
@ -10534,7 +10486,6 @@ 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")}
@ -10556,7 +10507,6 @@ 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")}
@ -10567,8 +10517,6 @@ 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>
@ -10576,7 +10524,6 @@ 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")}
@ -10587,8 +10534,6 @@ 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} />
@ -10597,7 +10542,6 @@ 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")}
@ -10608,8 +10552,6 @@ 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>
@ -10617,7 +10559,6 @@ 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")}
@ -10628,8 +10569,6 @@ 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>
@ -10637,7 +10576,6 @@ 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")}
@ -10648,8 +10586,6 @@ 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>
@ -10657,7 +10593,6 @@ 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")}
@ -10679,7 +10614,6 @@ 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")}
@ -10690,8 +10624,6 @@ 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>
@ -10716,14 +10648,12 @@ 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={cn("bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100", useReferenceImageToolbar && "lovart-image-action-menu")}
className="bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100"
side="bottom"
>
{isSelectedImageActionInMore("boxCutout") ? (
@ -10732,8 +10662,6 @@ 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} />
@ -10745,8 +10673,6 @@ 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} />
@ -10760,8 +10686,6 @@ 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} />
@ -10773,8 +10697,6 @@ 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
@ -10798,8 +10720,6 @@ 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
@ -10841,8 +10761,6 @@ 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>
@ -10853,8 +10771,6 @@ 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} />
@ -10866,8 +10782,6 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={Crop}
item={imageCropItem}
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
referenceSize={18}
/>
{imageCropItem.label}
</DropdownMenuItem>
@ -10878,8 +10792,6 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={SlidersHorizontal}
item={imageAdjustItem}
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
referenceSize={18}
/>
{imageAdjustItem.label}
</DropdownMenuItem>
@ -10890,8 +10802,6 @@ 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>
@ -10917,8 +10827,6 @@ export function ImageNode({
className="mr-2 h-4 w-4"
fallback={StickyNote}
item={imageNoteItem}
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
referenceSize={18}
/>
{imageNoteItem.label}
</DropdownMenuItem>
@ -10958,8 +10866,7 @@ export function ImageNode({
void handleDownload();
}}
size="icon"
data-tool-key="download"
title={imageDownloadItem.label}
title={imageDownloadItem.label}
variant="ghost"
>
{isDownloadingOriginalImage ? (
@ -10969,13 +10876,11 @@ export function ImageNode({
className="h-4 w-4"
fallback={Download}
item={imageDownloadItem}
referenceName={useReferenceImageToolbar ? "image-download" : undefined}
referenceSize={25}
/>
)}
</Button>
) : null}
</ReferenceImageToolbar>
</div>
</TooltipProvider>,
document.body
)}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,5 @@
"use client";
import "@/public/vendor/drawnix.css";
import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";

View File

@ -1,30 +0,0 @@
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,8 +16,6 @@ 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;
@ -459,7 +457,6 @@ 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="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]"
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]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">

View File

@ -1,70 +0,0 @@
"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

@ -1,778 +0,0 @@
"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

@ -1,94 +0,0 @@
"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

@ -1,46 +0,0 @@
"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

@ -1,280 +0,0 @@
/* 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

@ -1,318 +0,0 @@
"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

@ -1,111 +0,0 @@
"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

@ -1,28 +0,0 @@
"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

@ -1,650 +0,0 @@
"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

@ -1,45 +0,0 @@
"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

@ -1,384 +0,0 @@
"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

@ -1,139 +0,0 @@
"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,9 +18,6 @@ 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({
@ -44,7 +41,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(() => {
@ -55,8 +52,9 @@ export function XmindTopicPanel({
setAttachmentError("");
}, [topic?.id]);
useEffect(() => {
if (section === "links") linkRef.current?.focus();
if (section === "labels") labelRef.current?.focus();
({ notes: noteRef, links: linkRef, labels: labelRef })[
section
].current?.focus();
}, [section]);
const safeHref = getSafeTopicHref(linkDraft);
const invalidHref = Boolean(linkDraft.trim()) && !safeHref;
@ -98,7 +96,7 @@ export function XmindTopicPanel({
};
return (
<aside
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
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]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
@ -116,10 +114,20 @@ export function XmindTopicPanel({
<p className="truncate font-medium" title={getTopicTitle(topic)}>
{getTopicTitle(topic)}
</p>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium"><StickyNote size={14} />笔记</h3>
<XmindNotesEditor key={topic.id} value={getNoteDocument(topic)} onChange={(notesDocument) => onPatch({notesDocument})} />
</section>
<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">
<Tag size={14} />

View File

@ -1,9 +1,7 @@
"use client";
import { ReferenceIcon, type ReferenceIconName } from "@/components/lovart/reference-icon";
import dynamic from "next/dynamic";
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import useSWR from "swr";
@ -71,6 +69,7 @@ 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";
@ -421,15 +420,16 @@ function AccountMenuItem({
onClick: () => void;
}) {
return (
<DropdownMenuItem
onSelect={onClick}
<button
type="button"
onClick={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>
</DropdownMenuItem>
</button>
);
}
@ -1637,31 +1637,7 @@ export function LovartHome({
) => {
const item = getLovartSidebarItem(key);
const FallbackIcon = LOVART_HOME_SIDEBAR_FALLBACK_ICONS[key];
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} />;
}
const sizeClassName = options?.sizeClassName || "h-[18px] w-[18px]";
if (options?.filled) {
return (
@ -1727,261 +1703,8 @@ 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="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">
<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">
{shouldShowHomeBanner ? (
<LovartPromoBanner
actionText={homeBannerActionText}
@ -1991,12 +1714,236 @@ export function LovartHome({
text={homeBannerText}
/>
) : null}
<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>
<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>
<LovartSidebar
accountControls={homeAccountControls()}
agentEnabled={siteSettingsData?.agentEnabled !== false}
getItem={getLovartSidebarItem}
isEnabled={isLovartSidebarItemEnabled}
@ -2035,12 +1982,9 @@ export function LovartHome({
/>
<main
style={
{ "--home-banner-offset": `${shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}px` } as CSSProperties
}
className={cn(
"ml-[120px] mr-[60px] w-auto max-w-none pb-16 max-lg:mx-5",
shouldShowHomeBanner ? "pt-[124px]" : "pt-[76px]"
"ml-[92px] mr-10 w-auto max-w-none pb-16 max-lg:mx-5",
shouldShowHomeBanner ? "pt-[113px]" : "pt-[68px]"
)}
>
{!isProjectsOnlyView && (
@ -2069,15 +2013,25 @@ 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}
@ -2088,22 +2042,6 @@ 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
@ -2194,7 +2132,7 @@ export function LovartHome({
{loginDialogOpen ? (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
onClick={() => setLoginDialogOpen(false)}
>
<div

View File

@ -1,11 +1,8 @@
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[];
@ -19,11 +16,7 @@ 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;
}
@ -31,8 +24,6 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
}
export function LovartFeaturedGallery({
onOpenProjects,
onOpenMaterials,
activeCategory,
categories,
featuredItems,
@ -43,44 +34,21 @@ export function LovartFeaturedGallery({
setActiveCategory,
}: LovartFeaturedGalleryProps) {
return (
<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>
<section ref={featuredSectionRef} className="mt-14">
<h2 className="text-[16px] font-semibold leading-none text-[#1f1f1f] dark:text-white">
灵感发现
</h2>
<div className="flex items-center gap-x-5 overflow-x-auto border-b border-[#ededed] px-3 dark:border-white/10">
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
{categories.map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`relative h-9 shrink-0 border-b px-0 text-[13px] font-normal leading-none transition-colors duration-150 ${
className={`h-7 rounded-[7px] px-3 text-[12px] font-normal leading-none transition-colors duration-150 ${
activeCategory === category
? "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"
? "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]"
}`}
>
{category}
@ -90,19 +58,15 @@ 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-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
<div className="mt-[14px] columns-1 gap-[10px] md:columns-2 lg:columns-5">
{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
@ -114,17 +78,14 @@ 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-[12px] 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-[6px] 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">
@ -151,10 +112,7 @@ 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,8 +1,6 @@
import dynamic from "next/dynamic";
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 { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles, X } from "lucide-react";
import type { ChangeEvent } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
@ -54,19 +52,23 @@ 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,
@ -75,71 +77,63 @@ 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="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}
<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>
)}
<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]">
<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)]">
<Textarea
ref={textareaRef}
aria-label="描述你的设计需求"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={composerPlaceholder}
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"
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"
/>
{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"
@ -156,10 +150,8 @@ 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">
<ReferenceIcon name="plus" size={18} />
<Plus className="h-4 w-4" strokeWidth={1.9} />
<input
ref={uploadRef}
aria-label="上传参考图"
type="file"
accept="image/*"
multiple
@ -194,17 +186,7 @@ export function LovartHeroComposer({
title="选择模型"
>
<div className="flex h-8 w-8 items-center justify-center">
<ModelIdentityIcon
name={
modelOptions.find((model) => model.id === selectedModelId)
?.displayName || "auto"
}
avatarUrl={
modelOptions.find((model) => model.id === selectedModelId)
?.avatarUrl
}
size={18}
/>
<Box className="block h-4 w-4" strokeWidth={1.9} />
</div>
</SelectTrigger>
<SelectContent
@ -219,15 +201,18 @@ 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">
<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 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)
)}
</span>
<span className="min-w-0 flex-1 truncate">{model.displayName}</span>
</span>
</SelectItem>
))}
@ -235,63 +220,19 @@ 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" />
) : (
<ReferenceIcon name="arrow-up" size={18} />
<ArrowUp className="h-4 w-4" strokeWidth={1.8} />
)}
</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 = 48;
export const LOVART_PROMO_BANNER_HEIGHT = 45;
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-[48px] bg-[#e4ff58] text-[#11130d]">
<div className="fixed inset-x-0 top-0 z-[120] h-[45px] bg-[#ddff58] 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,7 +20,6 @@ type SidebarItem = {
};
type LovartSidebarProps = {
accountControls?: ReactNode;
agentEnabled: boolean;
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
@ -57,7 +56,6 @@ type LovartSidebarProps = {
};
export function LovartSidebar({
accountControls,
agentEnabled,
getItem,
isEnabled,
@ -90,9 +88,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-[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",
"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",
active
? "bg-[#e5e5e5] text-[#202020] dark:bg-white/10 dark:text-white"
? "bg-[#f5f5f5] text-[#202020] dark:bg-white/10 dark:text-white"
: "",
]
.filter(Boolean)
@ -114,7 +112,7 @@ export function LovartSidebar({
>
{renderIcon(key, {
active: options?.active,
sizeClassName: "h-[20px] w-[20px]",
sizeClassName: "h-4 w-4",
})}
</button>
</TooltipTrigger>
@ -135,7 +133,7 @@ export function LovartSidebar({
className={sidebarButtonClass()}
title={getItem(key).label}
>
{renderIcon(key, { sizeClassName: "h-[20px] w-[20px]" })}
{renderIcon(key, { sizeClassName: "h-4 w-4" })}
</button>
</HoverCardTrigger>
<HoverCardContent
@ -166,15 +164,15 @@ export function LovartSidebar({
return (
<aside
className={[
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[60px] flex-col items-center border-r lg:flex",
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[56px] flex-col items-center border-r lg:flex",
isDark
? "border-white/10 bg-black"
: "border-[#ededed] bg-[#f5f5f5]",
: "border-[#e9e9e9] bg-white",
].join(" ")}
style={{ top: topOffset }}
>
<TooltipProvider delayDuration={120}>
<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">
<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">
<Tooltip>
<TooltipTrigger asChild>
<button
@ -199,18 +197,18 @@ export function LovartSidebar({
<TooltipContent side="right">{siteName}</TooltipContent>
</Tooltip>
<div className="mt-[22px] flex w-full flex-col items-center gap-[2px]">
<div className="mt-5 flex w-full flex-col items-center gap-3.5">
{isEnabled("newProject") ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onNewCanvas}
className={`${sidebarButtonClass()} mb-[14px] border border-[#e5e5e5] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-white/5`}
className={sidebarButtonClass()}
title={getItem("newProject").label}
>
{renderIcon("newProject", {
sizeClassName: "h-[20px] w-[20px]",
sizeClassName: "h-4 w-4",
})}
</button>
</TooltipTrigger>
@ -230,7 +228,7 @@ export function LovartSidebar({
className={sidebarButtonClass()}
title={getItem("tutorial").label}
>
{renderIcon("tutorial", { sizeClassName: "h-[20px] w-[20px]" })}
{renderIcon("tutorial", { sizeClassName: "h-4 w-4" })}
</button>
</HoverCardTrigger>
<HoverCardContent
@ -283,6 +281,9 @@ 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"))}
@ -293,10 +294,9 @@ export function LovartSidebar({
{toolbox("toolbox2", toolbox2Links, toolbox2Enabled)}
</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 className="mt-auto flex w-full flex-col items-center gap-1 pt-4">
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
</div>
</div>
</TooltipProvider>
</aside>

View File

@ -1,6 +1,5 @@
"use client";
import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
@ -37,7 +36,6 @@ interface ImageParamsBarProps {
hideImageCount?: boolean; // 隐藏图片数量按钮(仅个别页面需要)
hideAdvancedOptions?: boolean; // 隐藏高级设置按钮
inlineTheme?: "light" | "dark" | "minimal";
referencePresentation?: boolean;
showInlineDivider?: boolean;
// 🆕 图片生成模型特性配置(从模型配置中读取)
imageGenConfig?: {
@ -135,7 +133,6 @@ export function ImageParamsBar({
hideImageCount = false,
hideAdvancedOptions = false,
inlineTheme = "light",
referencePresentation = false,
showInlineDivider = true,
imageGenConfig,
}: ImageParamsBarProps) {
@ -496,11 +493,11 @@ export function ImageParamsBar({
<PopoverTrigger asChild>
<Button
variant="ghost"
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")}
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]"
title="生成参数"
>
<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 ? (
{qualityLabel} · {selectedAspectLabel} · {selectedImageCount} img
{minimalParamsOpen ? (
<ChevronUp className="ml-1 h-3 w-3" />
) : (
<ChevronDown className="ml-1 h-3 w-3" />

View File

@ -1,44 +0,0 @@
"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

@ -1,125 +0,0 @@
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

@ -1,57 +0,0 @@
# 本机 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 / 演示基础 | 已有隐藏编辑工具、Escape 恢复和主题演示前后页;完整 Pitch 模板、分步动画、演示导出待补 |
| ZEN、演示/Pitch、分步展示 | 待实现 | 交互、快捷键、导出与演示行为 |
| 多工作表新建/切换/重命名/复制/删除 | 基础 | 跨工作表移动/链接、合并 XMind 文件 |
| 云文件、自动保存 | 基础 | 离线恢复、版本历史、异常恢复与冲突 |
| XMind 新版 JSON / 旧版 XML 导入 | 基础 | 资源、全部样式、结构和扩展内容;真实样本库 |
| MD、OPML、FreeMind 导入 | 基础 | 更多格式与大文件性能 |
| XMind 导出与往返兼容 | 基础,指定原生往返样例已核验 | 本机 26.04 实际打开网页导出文件、修改字号并保存回传,指定主题的字号/纯文本备注/附件字节保留;全结构、复杂样式与混合富文本的原生无损往返仍待核验 |
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | PNG 已支持;其余待核验 | 当前工作表全图 PNG,1/2/3 倍清晰度、画布背景或透明背景;选区、多页和其他格式待补 |
| XMind 导出与往返兼容 | 基础 | 已有多工作表 ZIP 导出与项目内往返测试;原生 XMind 打开和保存回传尚未核验,不能仅自读自写认定兼容 |
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | 待逐项核验 | 全图/选区/单页/多页、透明背景、纸张和清晰度 |
| 打印、密码、缓存、文件恢复 | 待实现 | 不得以浏览器存储代替完整文件保护 |
| 模板、主题库、手绘风格 | 待实现/部分底层能力 | 内置模板、自定义、导入与复用 |
| 协作、评论、分享、权限 | 待实现 | 项目已有登录不等于实时协作完成 |
@ -97,15 +97,3 @@
- 结构面板增加两枚独立 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

@ -1,105 +0,0 @@
"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,12 +350,14 @@ 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,13 +329,15 @@ 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,9 +119,6 @@ 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,9 +62,6 @@ 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,9 +430,6 @@ 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,9 +665,6 @@ 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,9 +177,6 @@ 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,12 +491,14 @@ 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,13 +269,15 @@ 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, deferArchive = true): Promise<{ thumbnail: string; original: string }> {
async function uploadImageToS3(imageData: string): Promise<{ thumbnail: string; original: string }> {
try {
if (deferArchive && /^(?:https?:\/\/|data:image\/)/i.test(imageData)) {
if (/^https?:\/\//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, false);
const uploaded = await uploadImageToS3(imageUrl);
const rewritten = await rewriteUploadPublicUrlForVolcano(uploaded.original);
processedImages.push(rewritten);
} else {

View File

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

View File

@ -1,74 +0,0 @@
"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,40 +1,192 @@
import "server-only";
import { normalizeCanvasImageTaskQueueConfig } from "./image-task-config";
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";
const stateKey = "__unrestricted_canvas_image_models__";
type ModelState = { running: Set<string>; durations: number[] };
function getModels() {
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 globalState = globalThis as typeof globalThis & {
[stateKey]?: Map<string, ModelState>;
[concurrencyStateKey]?: CanvasImageConcurrencyState;
};
return (globalState[stateKey] ??= new Map());
}
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);
if (!globalState[concurrencyStateKey]) {
globalState[concurrencyStateKey] = {
models: new Map<string, ModelConcurrencyState>(),
};
}
return state;
return globalState[concurrencyStateKey] as CanvasImageConcurrencyState;
}
function getOrCreateModelState(modelKey: string) {
const state = getGlobalConcurrencyState();
const existing = state.models.get(modelKey);
if (existing) {
return existing;
}
const created: ModelConcurrencyState = {
running: new Map<string, string>(),
waiters: [],
dispatchTimer: null,
delayedUntil: null,
recentExecutionDurationsMs: [],
};
state.models.set(modelKey, created);
return created;
}
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) {
return typeof value === "string" && value.trim() ? value.trim() : "__default__";
if (typeof value !== "string") {
return "__default__";
}
const normalized = value.trim();
return normalized || "__default__";
}
export async function getCanvasImageTaskQueueConfig() {
return normalizeCanvasImageTaskQueueConfig(undefined);
function normalizeCanvasImageOwnerKey(value: unknown) {
if (typeof value !== "string") {
return "__anonymous__";
}
const normalized = value.trim();
return normalized || "__anonymous__";
}
export async function getCanvasImageSingleModelConcurrency() {
return 0;
function getMaxConcurrentPerOwner(maxConcurrent: number) {
return Math.max(1, Math.ceil(Math.max(1, maxConcurrent) / 2));
}
export async function getCanvasImageBatchDispatchIntervalMs() {
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 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) {
return getModels().get(normalizeCanvasImageModelKey(modelKey))?.running.size || 0;
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
return getGlobalConcurrencyState().models.get(normalizedModelKey)?.running.size || 0;
}
export type CanvasImageModelQueueStatus = {
status: "pending" | "running" | "succeeded" | "failed";
queuePosition: number;
@ -45,27 +197,143 @@ export type CanvasImageModelQueueStatus = {
source: "model_queue";
};
export async function getCanvasImageModelQueueStatus(params: {
modelKey: string;
}): Promise<CanvasImageModelQueueStatus> {
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: "running",
queuePosition: 0,
sameModelPendingCount: 0,
sameModelRunningCount: getCanvasImageRunningCountForModel(params.modelKey),
concurrencyLimit: 0,
estimatedWaitMs: 0,
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 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();
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);
}
// Keep execution tracking/release semantics, without model or owner admission limits.
export async function acquireCanvasImageModelSlot(params: {
modelKey: string;
ownerKey?: string;
@ -73,15 +341,86 @@ export async function acquireCanvasImageModelSlot(params: {
debugMeta?: Record<string, unknown>;
onQueued?: (status: CanvasImageModelQueueStatus) => void | Promise<void>;
}) {
const state = getModel(params.modelKey);
const ticket = crypto.randomUUID();
state.running.add(ticket);
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,
});
}
return {
release: async () => {
state.running.delete(ticket);
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
if (!state) return;
state.running.delete(ticketId);
await drainModelWaiters(normalizedModelKey, "slot-release");
},
waitMs: 0,
maxConcurrent: 0,
batchDispatchIntervalMs: 0,
waitMs,
maxConcurrent,
batchDispatchIntervalMs,
};
}

View File

@ -1,21 +1,130 @@
export const CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY = "canvas_image_task_queue_config";
// 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_TASK_CONCURRENCY = 1;
export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 3;
export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 100;
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 = 0;
export const MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 60 * 1000;
export interface CanvasImageTaskQueueConfig {
concurrency: number;
batchDispatchIntervalMs: number;
}
export function normalizeCanvasImageTaskQueueConfig(
_value: unknown,
_options?: { envConcurrency?: unknown; envBatchDispatchIntervalMs?: unknown },
): CanvasImageTaskQueueConfig {
return { concurrency: 0, batchDispatchIntervalMs: 0 };
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 }
): 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,
};
}

View File

@ -1,11 +1,10 @@
import "server-only";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, lt, notInArray, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasImageTask } from "@/lib/db/schema";
import {
getCanvasImageBatchDispatchIntervalMs,
getCanvasImageRunningCountForModel,
getCanvasImageSingleModelConcurrency,
normalizeCanvasImageModelKey,
@ -71,6 +70,10 @@ 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;
}
@ -80,6 +83,9 @@ 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 {
@ -92,6 +98,10 @@ 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,
};
@ -174,6 +184,14 @@ 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({
@ -191,12 +209,27 @@ 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) {
@ -207,7 +240,7 @@ function releaseRunningTask(taskId: string) {
}
state.running.delete(taskId);
const { modelKey } = entry;
const { modelKey, userId } = entry;
const tasks = state.runningByModel.get(modelKey);
if (tasks) {
tasks.delete(taskId);
@ -216,9 +249,65 @@ 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
@ -245,14 +334,42 @@ async function enqueueLocalTask(
});
state.pendingSet.add(taskId);
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
if (
getRunningCountForModel(resolvedModelKey) >= maxConcurrentPerModel ||
getRunningCountForModelUser(resolvedModelKey, resolvedUserId) >=
getMaxConcurrentPerUser(maxConcurrentPerModel)
) {
markModelAwaitingNextBatch(resolvedModelKey);
}
}
async function claimNextPendingTask(): Promise<PendingTaskEntry | null> {
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> {
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;
@ -279,7 +396,15 @@ async function claimNextPendingTask(): Promise<PendingTaskEntry | null> {
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,
@ -428,7 +553,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
.update(canvasImageTask)
.set({
status: "succeeded",
result: await resolveArchivedImageUrls(result) as CanvasImageTaskResult,
result: result as CanvasImageTaskResult,
error: null,
completedAt: new Date(),
updatedAt: new Date(),
@ -450,7 +575,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
);
} finally {
releaseRunningTask(entry.taskId);
const modelKey = releaseRunningTask(entry.taskId);
let nextBatchTaskId: string | null = null;
if (completedBatchRequest?.batchId) {
try {
@ -467,6 +592,12 @@ async function runSingleTask(entry: PendingTaskEntry) {
});
}
}
if (modelKey) {
const batchDispatchIntervalMs = await getCanvasImageBatchDispatchIntervalMs();
if (getQueueState().awaitingNextBatch.has(modelKey)) {
scheduleModelNextBatch(modelKey, batchDispatchIntervalMs);
}
}
if (nextBatchTaskId) {
await enqueueLocalTask(nextBatchTaskId);
}
@ -481,8 +612,9 @@ export async function drainCanvasImageTaskQueue() {
state.draining = true;
try {
await markStaleDatabaseRunningTasksFailed();
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
while (true) {
const nextTask = await claimNextPendingTask();
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);
if (!nextTask) break;
if (state.running.has(nextTask.taskId)) continue;
markRunningTask(nextTask.taskId, nextTask.modelKey, nextTask.userId);
@ -490,11 +622,6 @@ 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();
}
}
}
@ -591,6 +718,58 @@ 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;
@ -610,13 +789,51 @@ 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: 0,
sameModelPendingCount: 0,
queuePosition,
sameModelPendingCount,
sameModelRunningCount,
concurrencyLimit,
estimatedWaitMs: 0,
estimatedWaitMs,
source: "model_queue" as const,
};
}

View File

@ -1,41 +0,0 @@
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,5 +1,4 @@
import "server-only";
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
import {
and,
@ -1591,7 +1590,6 @@ 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

@ -1,24 +0,0 @@
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

@ -1,137 +0,0 @@
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

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

View File

@ -1,290 +0,0 @@
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

@ -1,58 +0,0 @@
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

@ -1,268 +0,0 @@
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,4 +1,3 @@
import { getXmindMarker } from "./xmind-markers";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
@ -34,6 +33,15 @@ 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 {
@ -143,17 +151,6 @@ 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("主题内混合文字格式已按首段样式导出");
@ -163,7 +160,6 @@ 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);
@ -195,7 +191,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 = getXmindMarker(String(emoji.name))?.id;
const marker = markerIds[String(emoji.name)];
if (marker) markerSet.add(marker);
else warnings.add("部分自定义图标尚不能写入 XMind 标记");
}
@ -203,7 +199,7 @@ export async function exportXmind(
for (const marker of array(metadata.markers))
if (
typeof marker.markerId === "string" &&
!getXmindMarker(marker.markerId)
!Object.values(markerIds).includes(marker.markerId)
)
markerSet.add(marker.markerId);
if (markerSet.size)
@ -335,7 +331,6 @@ 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,5 +1,4 @@
import type JSZip from "jszip";
import { getXmindMarker, xmindMarkerName } from "./xmind-markers";
import { getTopicEntries } from "./topic-details";
import {
SPECIAL_MIND_LAYOUTS,
@ -234,13 +233,11 @@ 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,
inheritedBranchColor = ""
floatingRoot = false
): MindElement => {
if (++nodeCount > 10_000 || depth > 128)
throw new MindMapImportError(
@ -283,20 +280,16 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
...record(record(topic.style).properties),
};
const fill = color(properties["svg:fill"]);
if (properties["fill-pattern"] === "none") element.fill = "transparent";
else if (fill) element.fill = fill;
else if (properties["svg:fill"] === "inherited" && inheritedBranchColor) element.fill = inheritedBranchColor;
if (fill) element.fill = fill;
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"] || "")
);
@ -318,7 +311,6 @@ 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"]) }
: {}),
@ -339,20 +331,31 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
};
element.data.topic.children = [leaf];
const metadata: Data = {};
for (const key of ["notes", "href", "labels", "markers", "image", "extensions"])
for (const key of ["notes", "href", "labels", "markers", "image"])
if (topic[key] !== undefined) metadata[key] = topic[key];
if (Object.keys(metadata).length) element.xmind = metadata;
const emojis: string[] = [];
for (const marker of list(topic.markers)) {
const id = text(marker.markerId);
if (getXmindMarker(id)) emojis.push(xmindMarkerName(id));
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);
else warnings.add("部分图标已保留为数据,暂不显示");
}
if (emojis.length)
element.data.emojis = emojis.map((name) => ({ name }));
element.children = list(groups.attached).map((child,index) =>
build(child, depth + 1, false, false, depth === 0 && palette.length ? palette[index % palette.length] : inheritedBranchColor)
element.children = list(groups.attached).map((child) =>
build(child, depth + 1)
);
const attachedCount = element.children.length;
if (element.layout === MindLayoutType.standard) {
@ -443,22 +446,8 @@ 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

@ -1,42 +0,0 @@
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

@ -1,23 +0,0 @@
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,7 +3,6 @@ import "server-only";
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
import {
getImageArchiveTaskStats,
recoverStaleImageArchiveTasks,
getImageArchiveTasksToReplace,
getImageArchiveTasksToStore,
markImageArchiveTaskArchiving,
@ -27,16 +26,14 @@ 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;
const storeOne = async (task: (typeof storeTasks)[number]) => {
for (const task of storeTasks) {
try {
if (!(await markImageArchiveTaskArchiving(task.id))) return;
const metadata = task.metadata as { inlineSource?: string } | null;
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl);
await markImageArchiveTaskArchiving(task.id);
const archived = await archiveTemporaryImage(task.sourceUrl);
await markImageArchiveTaskStored({
id: task.id,
targetUrl: archived.url,
@ -57,13 +54,12 @@ export async function runImageArchiveJob() {
error: error instanceof Error ? error.message : String(error),
});
}
};
// 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 });
const remainingSlots = Math.max(1, batchSize - storeTasks.length);
const replaceTasks = await getImageArchiveTasksToReplace({
limit: remainingSlots,
});
let replaceSucceeded = 0;
let replaceFailed = 0;
@ -78,7 +74,7 @@ export async function runImageArchiveJob() {
}
try {
if (!(await markImageArchiveTaskReplacing(task.id))) continue;
await markImageArchiveTaskReplacing(task.id);
const result = await replaceImageUrlInDatabase(task.sourceUrl, task.targetUrl);
await markImageArchiveTaskCompleted({
id: task.id,

View File

@ -1,34 +1,30 @@
import "server-only";
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 { and, asc, eq, inArray, lt, lte, 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;
// Replace as soon as the durable object exists, regardless of legacy hold settings.
export function getImageArchiveDisplayHoldMs() {
return 0;
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));
}
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 function getImageArchiveDisplayHoldMs() {
return getDisplayHoldHours() * 60 * 60 * 1000;
}
function isArchivableHttpUrl(value: string) {
if (!/^https?:\/\//i.test(value)) return false;
if (/^(data|blob):/i.test(value)) return false;
return true;
}
export async function enqueueImageArchiveTask(params: {
@ -38,35 +34,30 @@ export async function enqueueImageArchiveTask(params: {
metadata?: Record<string, unknown> | null;
}) {
const sourceUrl = String(params.sourceUrl || "").trim();
if (!isGeneratedImageSource(sourceUrl)) return null;
if (!isArchivableHttpUrl(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: imageArchiveSourceKey(sourceUrl),
sourceUrl,
status: "pending",
archiveAfterAt,
replaceAfterAt,
metadata: sourceUrl.startsWith("data:")
? { ...params.metadata, inlineSource: sourceUrl }
: params.metadata || null,
metadata: params.metadata || null,
updatedAt: now,
})
.onConflictDoUpdate({
target: imageArchiveTask.sourceUrl,
set: {
replaceAfterAt,
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`,
metadata: params.metadata || undefined,
updatedAt: now,
},
})
.returning();
@ -80,8 +71,10 @@ export async function enqueueImageArchiveTasks(params: {
}) {
const urls = Array.from(
new Set(
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
),
(params.urls || [])
.map((url) => String(url || "").trim())
.filter(isArchivableHttpUrl)
)
);
const tasks: ImageArchiveTaskRecord[] = [];
@ -104,15 +97,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) {
const claimed = await db
await db
.update(imageArchiveTask)
.set({
status: "archiving",
@ -120,15 +113,7 @@ export async function markImageArchiveTaskArchiving(id: string) {
lastError: null,
updatedAt: new Date(),
})
.where(
and(
eq(imageArchiveTask.id, id),
inArray(imageArchiveTask.status, ["pending", "failed"]),
lte(imageArchiveTask.archiveAfterAt, new Date()),
),
)
.returning({ id: imageArchiveTask.id });
return claimed.length > 0;
.where(eq(imageArchiveTask.id, id));
}
export async function markImageArchiveTaskStored(params: {
@ -143,16 +128,18 @@ 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);
@ -173,35 +160,29 @@ export async function markImageArchiveTaskFailed(params: { id: string; error: un
.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(isArchiveReplacementDue(new Date()))
.where(
and(
eq(imageArchiveTask.status, "stored"),
lte(imageArchiveTask.replaceAfterAt, now)
)
)
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
.limit(Math.max(1, Math.min(50, params.limit)));
}
export async function markImageArchiveTaskReplacing(id: string) {
const claimed = await db
await db
.update(imageArchiveTask)
.set({ status: "replacing", updatedAt: new Date() })
.where(and(eq(imageArchiveTask.id, id), isArchiveReplacementDue(new Date())))
.returning({ id: imageArchiveTask.id });
return claimed.length > 0;
.set({
status: "replacing",
updatedAt: new Date(),
})
.where(eq(imageArchiveTask.id, id));
}
export async function markImageArchiveTaskCompleted(params: {
@ -214,20 +195,23 @@ export async function markImageArchiveTaskCompleted(params: {
status: "completed",
replacedAt: new Date(),
lastError: null,
// 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`,
metadata:
params.replacedCount === undefined
? undefined
: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || ${JSON.stringify({
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
@ -246,7 +230,6 @@ 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);
@ -254,11 +237,9 @@ 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) +
grouped.reduce((sum, item) => sum + (item.reconciliationCount || 0), 0);
const pendingReplace = grouped
.filter((item) => item.status === "stored")
.reduce((sum, item) => sum + (item.count || 0), 0);
return {
grouped,
@ -266,50 +247,3 @@ 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

@ -1,56 +0,0 @@
// 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

@ -1,66 +0,0 @@
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,13 +2,10 @@ 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";
@ -28,12 +25,16 @@ 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 true;
return process.env.AI_IMAGE_DEFERRED_ARCHIVE !== "false";
}
export function isImmediateImageArchiveEnabled() {
return false;
return process.env.AI_IMAGE_IMMEDIATE_ARCHIVE !== "false";
}
export async function registerGeneratedImageTemporaryUrls(params: {
@ -43,24 +44,24 @@ export async function registerGeneratedImageTemporaryUrls(params: {
if (!isDeferredImageArchiveEnabled()) return [];
const urls = Array.from(
new Set(
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
),
(params.urls || [])
.map((url) => String(url || "").trim())
.filter(isHttpUrl)
)
);
if (urls.length === 0) return [];
try {
const tasks = await enqueueImageArchiveTasks({
return await enqueueImageArchiveTasks({
urls,
metadata: {
...(params.metadata || {}),
displayHoldMs: getImageArchiveDisplayHoldMs(),
},
});
scheduleImageArchiveWorker();
return tasks;
} catch (error) {
logger.warn("enqueue temporary image archive failed", {
imageCount: urls.length,
urls,
error: error instanceof Error ? error.message : String(error),
});
return [];
@ -76,8 +77,10 @@ export async function formatDeferredGeneratedImageMarkdown(params: {
}) {
const urls = Array.from(
new Set(
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
),
(params.urls || [])
.map((url) => String(url || "").trim())
.filter(isHttpUrl)
)
);
if (urls.length === 0) {
throw new Error("AI 未返回可用图片地址");
@ -94,17 +97,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() || !isGeneratedImageSource(url)) return null;
if (!isDeferredImageArchiveEnabled() || !isHttpUrl(url)) return null;
const [persisted] = await persistGeneratedImageUrls({
urls: [url],
@ -121,20 +124,48 @@ async function persistGeneratedImageUrls(params: {
urls: string[];
metadata?: DeferredImageArchiveMetadata | null;
}) {
// 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 };
});
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,
};
}
})
);
}
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",
@ -147,11 +178,7 @@ 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;
}
@ -165,20 +192,14 @@ 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 }) {
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], ""),
};
}
export async function downloadTemporaryImage(params: {
sourceUrl: string;
retries?: number;
}) {
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
for (let attempt = 0; attempt <= retries; attempt += 1) {
@ -228,7 +249,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,7 +6,6 @@ import {
assetMemorySnapshot,
canvasDetailArchive,
canvasNode,
canvasImageTask,
canvasProject,
conversationWorkingState,
externalImageApiRequestLog,
@ -27,14 +26,18 @@ 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)) {
@ -67,7 +70,10 @@ 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 };
}
@ -100,8 +106,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
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);
@ -111,7 +117,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
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 (
@ -132,7 +139,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
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(),
@ -144,7 +152,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
.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(),
@ -161,8 +170,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
or(
eq(assetMemorySnapshot.normalizedUrl, sourceNormalizedUrl),
sql`${assetMemorySnapshot.metadata}::text like ${`%${sourceUrl}%`}`,
like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`),
),
like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`)
)
)
.limit(500);
@ -194,8 +203,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
.where(
or(
sql`${message.parts}::text like ${`%${sourceUrl}%`}`,
sql`${message.attachments}::text like ${`%${sourceUrl}%`}`,
),
sql`${message.attachments}::text like ${`%${sourceUrl}%`}`
)
)
.limit(500);
@ -234,67 +243,120 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
changed += 1;
}
const escapedSource = JSON.stringify(sourceUrl).slice(1, -1);
const escapedTarget = JSON.stringify(targetUrl).slice(1, -1);
const projects = await db
.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.
.select({
id: canvasProject.id,
canvasData: canvasProject.canvasData,
})
.where(
or(
sql`position(${escapedSource} in ${canvasProject.canvasData}::text) > 0`,
eq(canvasProject.thumbnail, sourceUrl),
),
)
.returning({ id: canvasProject.id });
changed += projects.length;
.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
.update(canvasNode)
.set({
data: sql`replace(${canvasNode.data}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
.select({
id: canvasNode.id,
data: canvasNode.data,
})
.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;
.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
.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(),
})
.select()
.from(conversationWorkingState)
.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`,
),
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}%`}`
)
)
.returning({ id: conversationWorkingState.id });
changed += workingStates.length;
.limit(500);
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 detailArchives = await db
.select({
@ -312,8 +374,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
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);
@ -354,8 +416,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
.where(
or(
like(model3dAsset.previewImageUrl, `%${sourceUrl}%`),
sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`,
),
sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`
)
)
.limit(500);
@ -367,7 +429,8 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
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(),
})
@ -381,14 +444,22 @@ export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: st
.where(
or(
sql`${externalImageApiRequestLog.responsePayload}::text like ${`%${sourceUrl}%`}`,
sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`,
),
sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`
)
)
.limit(500);
for (const item of apiLogs) {
const responsePayload = replaceDeepUrls(item.responsePayload, sourceUrl, targetUrl);
const generatedUrls = replaceDeepUrls(item.generatedImageUrls, sourceUrl, targetUrl);
const responsePayload = replaceDeepUrls(
item.responsePayload,
sourceUrl,
targetUrl
);
const generatedUrls = replaceDeepUrls(
item.generatedImageUrls,
sourceUrl,
targetUrl
);
if (!responsePayload.changed && !generatedUrls.changed) continue;
await db

View File

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

View File

@ -1,16 +0,0 @@
# 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

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 299 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 613 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 874 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 287 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 337 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 403 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 631 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 811 B

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