Compare commits
8 Commits
d54180e11d
...
3a2ee064d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a2ee064d0 | ||
|
|
23ced0b760 | ||
|
|
e6a68b345b | ||
|
|
ae7a0e7ba4 | ||
|
|
79253245f3 | ||
|
|
e083607917 | ||
|
|
aed5e1c977 | ||
|
|
c1c7037f05 |
@ -153,8 +153,9 @@ PRODUCT_PLANNING_DB_SSL=false
|
||||
# Home Performance / Queues
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
CANVAS_IMAGE_TASK_CONCURRENCY=3
|
||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=1000
|
||||
# Legacy image queue fields: 0 = unrestricted; model/user admission caps are disabled.
|
||||
CANVAS_IMAGE_TASK_CONCURRENCY=0
|
||||
CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS=0
|
||||
EXTERNAL_IMAGE_API_MAX_CONCURRENT=3
|
||||
EXTERNAL_IMAGE_API_MAX_QUEUE=20
|
||||
EXTERNAL_IMAGE_API_QUEUE_TIMEOUT_MS=120000
|
||||
|
||||
@ -1078,139 +1078,15 @@ export function LocalAiRuntimeSections({
|
||||
</Button>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
{/* 全站生图单模型并发 */}
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
控制全站生图链路中,同一个模型允许同时运行的任务数,以及排队后下一批任务的发起间隔。配置保存到数据库,异步队列和直调生成都会自动读取,无需重启服务。
|
||||
</>
|
||||
}
|
||||
description="生图任务直接发起,图片先显示,后台转存到存储桶。"
|
||||
expanded={isLocalAiSectionExpanded("canvas-image-task-queue")}
|
||||
id="local-ai-section-canvas-image-task-queue"
|
||||
onToggle={() => toggleLocalAiSection("canvas-image-task-queue")}
|
||||
summary={
|
||||
<LocalAiSectionSummary
|
||||
items={[
|
||||
{
|
||||
label: `${canvasImageTaskQueueConfig.concurrency} 并发/模型`,
|
||||
tone: "neutral",
|
||||
},
|
||||
{
|
||||
label: `${(
|
||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||
).toFixed(1)} 秒补位`,
|
||||
tone: "neutral",
|
||||
},
|
||||
{ label: "全站生图生效", tone: "enabled" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
全站生图单模型并发与批次间隔
|
||||
</span>
|
||||
}
|
||||
summary={<LocalAiSectionSummary items={[{ label: "并发不限", tone: "enabled" }, { label: "即时发起", tone: "enabled" }]} />}
|
||||
title={<span className="flex items-center gap-2"><ImageIcon className="h-5 w-5" />生图任务执行</span>}
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(220px,320px)]">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 text-sm">
|
||||
<div className="font-medium">生效范围</div>
|
||||
<div className="mt-2 space-y-1 text-amber-800 text-xs leading-5">
|
||||
<p>
|
||||
•
|
||||
影响画布异步生图、智能体直调生图、视频分镜高清预览等全站生图入口。
|
||||
</p>
|
||||
<p>
|
||||
•
|
||||
多用户共享同一套并发限制,但会按模型分别计数,不同模型之间不再互相阻塞。
|
||||
</p>
|
||||
<p>
|
||||
• 当前配置的是单模型并发,不是全站总并发,也不是单用户并发。
|
||||
</p>
|
||||
<p>
|
||||
•
|
||||
批次间隔只控制排队补位的“下一批”发起节奏,不影响首批任务即时启动。
|
||||
</p>
|
||||
<p>
|
||||
• 数值过高可能触发上游模型 429、超时或 504,建议配合模型 Key
|
||||
池继续细分。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="canvasImageTaskQueueConcurrency">
|
||||
单模型最大同时运行任务数
|
||||
</Label>
|
||||
<Input
|
||||
id="canvasImageTaskQueueConcurrency"
|
||||
max={MAX_CANVAS_IMAGE_TASK_CONCURRENCY}
|
||||
min={MIN_CANVAS_IMAGE_TASK_CONCURRENCY}
|
||||
onChange={(e) =>
|
||||
setCanvasImageTaskQueueConfig(
|
||||
normalizeCanvasImageTaskQueueConfig({
|
||||
...canvasImageTaskQueueConfig,
|
||||
concurrency: e.target.value,
|
||||
})
|
||||
)
|
||||
}
|
||||
type="number"
|
||||
value={canvasImageTaskQueueConfig.concurrency}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可配置范围 {MIN_CANVAS_IMAGE_TASK_CONCURRENCY}-
|
||||
{MAX_CANVAS_IMAGE_TASK_CONCURRENCY},当前为{" "}
|
||||
{canvasImageTaskQueueConfig.concurrency} 并发,默认值为{" "}
|
||||
{DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY}。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="canvasImageTaskBatchDispatchIntervalSeconds">
|
||||
下一批排队任务发起间隔(秒)
|
||||
</Label>
|
||||
<Input
|
||||
id="canvasImageTaskBatchDispatchIntervalSeconds"
|
||||
max={MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
|
||||
min={MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}
|
||||
onChange={(e) =>
|
||||
setCanvasImageTaskQueueConfig(
|
||||
normalizeCanvasImageTaskQueueConfig({
|
||||
...canvasImageTaskQueueConfig,
|
||||
batchDispatchIntervalMs: Math.round(
|
||||
Number.parseFloat(e.target.value || "0") * 1000
|
||||
),
|
||||
})
|
||||
)
|
||||
}
|
||||
step="0.5"
|
||||
type="number"
|
||||
value={String(
|
||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||
)}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
可配置范围 {MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000}-
|
||||
{MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000} 秒,当前为{" "}
|
||||
{(
|
||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||
).toFixed(1)}{" "}
|
||||
秒,默认值为{" "}
|
||||
{(
|
||||
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS / 1000
|
||||
).toFixed(1)}{" "}
|
||||
秒。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveCanvasImageTaskQueueConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存并发与间隔
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">已取消模型及单用户并发限制,任务无需等待其他图片生成完成。任务状态、超时处理和失败重试继续保留。</p>
|
||||
</LocalAiSectionShell>
|
||||
|
||||
{/* 批量处理配置 */}
|
||||
|
||||
@ -8915,9 +8915,7 @@ export function AdminSettingsClient() {
|
||||
{
|
||||
key: "realtime-canvas",
|
||||
title: "实时与队列",
|
||||
description: `单模型并发 ${canvasImageTaskQueueConfig.concurrency},批次间隔 ${(
|
||||
canvasImageTaskQueueConfig.batchDispatchIntervalMs / 1000
|
||||
).toFixed(1)} 秒`,
|
||||
description: "生图并发不限,任务即时发起",
|
||||
status: realtimeCanvasConfig.enabled ? "实时已启用" : "实时已关闭",
|
||||
tone: realtimeCanvasConfig.enabled ? "enabled" : "neutral",
|
||||
icon: SlidersHorizontal,
|
||||
|
||||
@ -3,10 +3,5 @@ export default function FlowBoardLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<link href="/vendor/drawnix.css" rel="stylesheet" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
65
app/api/canvas/[id]/image-archives/route.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasProject, imageArchiveTask, generatedImage, canvasImageTask } from "@/lib/db/schema";
|
||||
import { imageArchiveSourceKey } from "@/lib/storage/image-archive-db";
|
||||
import { isGeneratedImageSource } from "@/lib/storage/image-archive-urls";
|
||||
import { scheduleImageArchiveWorker } from "@/lib/storage/image-archive-worker";
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const [project] = await db
|
||||
.select({ id: canvasProject.id })
|
||||
.from(canvasProject)
|
||||
.where(and(eq(canvasProject.id, id), eq(canvasProject.userId, session.user.id)))
|
||||
.limit(1);
|
||||
if (!project) return NextResponse.json({ error: "画布不存在" }, { status: 404 });
|
||||
const body = await request.json();
|
||||
if (
|
||||
!Array.isArray(body.urls) ||
|
||||
body.urls.length > 100 ||
|
||||
body.urls.some((url: unknown) => typeof url !== "string" || !isGeneratedImageSource(url))
|
||||
) {
|
||||
return NextResponse.json({ error: "图片地址格式错误" }, { status: 400 });
|
||||
}
|
||||
const urls: string[] = [...new Set<string>(body.urls)];
|
||||
if (!urls.length) return NextResponse.json({ replacements: {}, pending: [] });
|
||||
const source = imageArchiveTask.sourceUrl;
|
||||
const rows = await db
|
||||
.select({
|
||||
sourceUrl: imageArchiveTask.sourceUrl,
|
||||
targetUrl: imageArchiveTask.targetUrl,
|
||||
status: imageArchiveTask.status,
|
||||
})
|
||||
.from(imageArchiveTask)
|
||||
.where(
|
||||
and(
|
||||
inArray(imageArchiveTask.sourceUrl, urls.map(imageArchiveSourceKey)),
|
||||
sql`(
|
||||
exists (select 1 from ${generatedImage} where ${generatedImage.userId} = ${session.user.id}
|
||||
and (${generatedImage.originalUrl} = ${source} or ${generatedImage.thumbnailUrl} = ${source}
|
||||
or ${generatedImage.originalUrl} = ${imageArchiveTask.targetUrl} or ${generatedImage.thumbnailUrl} = ${imageArchiveTask.targetUrl}))
|
||||
or exists (select 1 from ${canvasImageTask} where ${canvasImageTask.userId} = ${session.user.id}
|
||||
and (position(${source} in ${canvasImageTask.result}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasImageTask.result}::text) > 0))
|
||||
or exists (select 1 from ${canvasProject} where ${canvasProject.id} = ${id} and ${canvasProject.userId} = ${session.user.id}
|
||||
and (position(${source} in ${canvasProject.canvasData}::text) > 0 or position(${imageArchiveTask.targetUrl} in ${canvasProject.canvasData}::text) > 0))
|
||||
)`,
|
||||
),
|
||||
);
|
||||
const byKey = new Map(rows.map((row) => [row.sourceUrl, row]));
|
||||
const replacements: Record<string, string> = {};
|
||||
const pending: string[] = [];
|
||||
for (const url of urls) {
|
||||
const row = byKey.get(imageArchiveSourceKey(url));
|
||||
if (row?.targetUrl) replacements[url] = row.targetUrl;
|
||||
else if (row && row.status !== "abandoned") pending.push(url);
|
||||
}
|
||||
if (rows.some((row) => row.status === "pending")) scheduleImageArchiveWorker(true);
|
||||
return NextResponse.json(
|
||||
{ replacements, pending },
|
||||
{ headers: { "Cache-Control": "private, no-store" } },
|
||||
);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import { auth } from '@/app/(auth)/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { canvasProject } from '@/lib/db/schema';
|
||||
import { syncCanvasNodesForProject } from '@/lib/db/queries';
|
||||
import { resolveArchivedImageUrls } from '@/lib/storage/image-archive-db';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import {
|
||||
buildStaleCanvasSaveConflictPayload,
|
||||
@ -119,7 +120,8 @@ export async function PUT(
|
||||
return NextResponse.json({ success: false, error: '未登录' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { title, description, thumbnail, canvasData } = body;
|
||||
const { title, description, canvasData } = body;
|
||||
const thumbnail = await resolveArchivedImageUrls(body.thumbnail);
|
||||
const baseCanvasRevision = body?.baseCanvasUpdatedAt || body?.baseCanvasRevision;
|
||||
const workspaceType =
|
||||
body && Object.prototype.hasOwnProperty.call(body, "workspaceType")
|
||||
|
||||
34
app/api/generated-image-preview/[key]/route.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { imageArchiveTask } from "@/lib/db/schema";
|
||||
|
||||
// The SHA-256 URL is a capability, like the provider's signed image URL. It must be
|
||||
// readable by image nodes and reference-image providers without a session cookie.
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ key: string }> }) {
|
||||
const { key } = await params;
|
||||
if (!/^[a-f0-9]{64}$/.test(key)) return new NextResponse(null, { status: 404 });
|
||||
const configured =
|
||||
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
||||
const origin = configured
|
||||
? new URL(configured).origin
|
||||
: `http://localhost:${process.env.PORT || "3000"}`;
|
||||
const sourceUrl = `${origin}/api/generated-image-preview/${key}`;
|
||||
const [task] = await db
|
||||
.select({ targetUrl: imageArchiveTask.targetUrl, metadata: imageArchiveTask.metadata })
|
||||
.from(imageArchiveTask)
|
||||
.where(eq(imageArchiveTask.sourceUrl, sourceUrl))
|
||||
.limit(1);
|
||||
if (task?.targetUrl) return NextResponse.redirect(task.targetUrl, 307);
|
||||
const inline = (task?.metadata as { inlineSource?: string } | null)?.inlineSource;
|
||||
const match = inline?.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
||||
if (!match) return new NextResponse(null, { status: 404 });
|
||||
return new NextResponse(Buffer.from(match[2], "base64"), {
|
||||
headers: {
|
||||
"Content-Type": match[1],
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Security-Policy": "default-src 'none'; sandbox",
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -74,6 +74,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCanvasImageArchives } from "@/hooks/use-canvas-image-archives";
|
||||
import useSWR from "swr";
|
||||
// 深色主题组件 V2 (新版布局)
|
||||
import type { AgentGenerationWorkspaceDarkV2Props } from "@/components/agents/AgentGenerationWorkspaceDarkV2";
|
||||
@ -87,6 +88,7 @@ import {
|
||||
type CanvasAddImageDialogImage,
|
||||
} from "@/components/canvas/CanvasAddImageDialog";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { CanvasSurfaceHeader } from "@/components/canvas/CanvasSurfaceHeader";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -249,7 +251,6 @@ import type {
|
||||
AgentWorkflowUiConfig,
|
||||
} from "@/types/agent";
|
||||
import type {
|
||||
AgentSlotNodeData,
|
||||
CanvasEdge,
|
||||
CanvasInitialProject,
|
||||
CanvasNode,
|
||||
@ -262,7 +263,7 @@ import type {
|
||||
|
||||
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
|
||||
const CANVAS_SITE_SETTINGS_TIMEOUT_MS = 10000;
|
||||
const CANVAS_SURFACE_BACKGROUND = "#f4f4f5";
|
||||
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
|
||||
|
||||
const fetchCanvasSiteSettings = async (url: string) => {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
@ -1906,6 +1907,8 @@ export default function CanvasEditorLegacyPage({
|
||||
[isVideoMode]
|
||||
);
|
||||
|
||||
useCanvasImageArchives(id);
|
||||
|
||||
// Store state
|
||||
const projectTitle = useCanvasStore((state) => state.projectTitle);
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
@ -3141,8 +3144,7 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
const updateWidthFromClientX = (clientX: number) => {
|
||||
const currentViewportWidth = window.innerWidth;
|
||||
const nextWidth =
|
||||
currentViewportWidth - DOCKED_PANEL_INSET - clientX;
|
||||
const nextWidth = currentViewportWidth - DOCKED_PANEL_INSET - clientX;
|
||||
|
||||
setFloatingPanelWidth(
|
||||
clampFloatingPanelWidth(nextWidth, currentViewportWidth)
|
||||
@ -7515,7 +7517,7 @@ export default function CanvasEditorLegacyPage({
|
||||
materialMimeType: image.mimeType || undefined,
|
||||
originalHeight: dimensions.originalHeight,
|
||||
originalWidth: dimensions.originalWidth,
|
||||
source: "agent-workflow-material-search",
|
||||
source: image.sourceType === "upload" ? "local-upload" : "agent-workflow-material-search",
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
@ -7527,7 +7529,7 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
if (createdNodeIds.length > 0) {
|
||||
selectNodes(createdNodeIds);
|
||||
toast.success(`已添加 ${createdNodeIds.length} 张白底图到画布`);
|
||||
toast.success(`已添加 ${createdNodeIds.length} 张${validImages.every((image) => image.sourceType === "upload") ? "图片" : "白底图"}到画布`);
|
||||
}
|
||||
},
|
||||
[createCanvasNode, getVisibleCanvasOrigin, selectNodes]
|
||||
@ -7543,64 +7545,6 @@ export default function CanvasEditorLegacyPage({
|
||||
setIsAddImageDialogOpen(true);
|
||||
}, [handleAddWorkflowNode, isVideoMode]);
|
||||
|
||||
const handleAddAgentSlotNode = useCallback((slotType: AgentSlotNodeData["slotType"]) => {
|
||||
if (isVideoMode) return;
|
||||
|
||||
const slotDefaults: Record<
|
||||
AgentSlotNodeData["slotType"],
|
||||
{
|
||||
label: string;
|
||||
size: CanvasNode["size"];
|
||||
aspectRatio: string;
|
||||
}
|
||||
> = {
|
||||
image: {
|
||||
label: "图片槽位",
|
||||
size: { width: 320, height: 320 },
|
||||
aspectRatio: "1:1",
|
||||
},
|
||||
video: {
|
||||
label: "视频槽位",
|
||||
size: { width: 360, height: 220 },
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
"ui-screen": {
|
||||
label: "UI 槽位",
|
||||
size: { width: 360, height: 260 },
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
};
|
||||
const defaults = slotDefaults[slotType];
|
||||
const origin = getVisibleCanvasOrigin();
|
||||
createCanvasNode({
|
||||
type: "agent-slot",
|
||||
position: {
|
||||
x: origin.x + 40,
|
||||
y: origin.y + 40,
|
||||
},
|
||||
size: defaults.size,
|
||||
data: {
|
||||
slotType,
|
||||
label: defaults.label,
|
||||
status: "empty",
|
||||
aspectRatio: defaults.aspectRatio,
|
||||
fillPolicy: "replace-empty",
|
||||
acceptedSources: ["text", "image", "selection", "annotation"],
|
||||
filledNodeIds: [],
|
||||
metadata: {
|
||||
createdBy: "toolbar",
|
||||
createdSlotType: slotType,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
connections: [],
|
||||
});
|
||||
}, [createCanvasNode, getVisibleCanvasOrigin, isVideoMode]);
|
||||
|
||||
const handleAddAgentImageSlotNode = useCallback(() => {
|
||||
handleAddAgentSlotNode("image");
|
||||
}, [handleAddAgentSlotNode]);
|
||||
|
||||
const handleAddVideoNode = useCallback(() => {
|
||||
if (isVideoMode) {
|
||||
handleAddWorkflowNode("video-input");
|
||||
@ -10312,7 +10256,11 @@ export default function CanvasEditorLegacyPage({
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className="relative h-dvh w-full overflow-hidden overscroll-none"
|
||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||
className={cn(
|
||||
"h-dvh w-full overflow-hidden overscroll-none",
|
||||
isVideoMode ? "relative" : "lovart-canvas-surface fixed inset-0"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--canvas-surface-background": canvasSurfaceBackground,
|
||||
@ -10324,6 +10272,7 @@ export default function CanvasEditorLegacyPage({
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
data-canvas-stage="true"
|
||||
style={isVideoMode ? undefined : { right: panelOcclusionRight, top: 40 }}
|
||||
key={`workspace-${id}`}
|
||||
>
|
||||
<CanvasWorkspace
|
||||
@ -10334,9 +10283,10 @@ export default function CanvasEditorLegacyPage({
|
||||
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
|
||||
: '点击顶部"添加"按钮开始创作'
|
||||
}
|
||||
occlusionRight={panelOcclusionRight}
|
||||
occlusionRight={0}
|
||||
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
|
||||
showEmptyState={!isVideoMode}
|
||||
showEmptyState={false}
|
||||
showZoomControls={isVideoMode}
|
||||
workspaceLabel={workspaceLabel}
|
||||
workspaceVariant={isVideoMode ? "video" : "canvas"}
|
||||
layerSplitEnabled={isLayerSplitEnabled}
|
||||
@ -11025,14 +10975,27 @@ export default function CanvasEditorLegacyPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{brandEntry}
|
||||
{isVideoMode ? brandEntry : (
|
||||
<CanvasSurfaceHeader
|
||||
isSaving={isSaving}
|
||||
onAddImage={handleAddImageNode}
|
||||
onHome={goHome}
|
||||
onOpenFiles={() => {
|
||||
setIsPanelOpen(true);
|
||||
setPanelMode("fileList");
|
||||
}}
|
||||
onSave={() => void handleSave()}
|
||||
projectTitle={projectTitle}
|
||||
rightInset={panelOcclusionRight}
|
||||
/>
|
||||
)}
|
||||
{agentDisabledNotice}
|
||||
|
||||
{/* 2. Floating Actions (Top Right) */}
|
||||
{!isVideoMode && !isPanelOpen && (
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={{ top: FLOATING_PANEL_INSET, right: FLOATING_PANEL_INSET }}
|
||||
style={{ top: 46, right: 12 }}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
@ -11064,7 +11027,7 @@ export default function CanvasEditorLegacyPage({
|
||||
)}
|
||||
data-canvas-right-panel="true"
|
||||
style={{
|
||||
top: DOCKED_PANEL_INSET,
|
||||
top: 0,
|
||||
right: DOCKED_PANEL_INSET,
|
||||
bottom: DOCKED_PANEL_INSET,
|
||||
width: resolvedPanelWidth,
|
||||
@ -11095,7 +11058,7 @@ export default function CanvasEditorLegacyPage({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden border-l border-[#ededed] bg-white dark:bg-zinc-950">
|
||||
{!areSecondaryPanelsReady ? (
|
||||
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
||||
) : panelMode === "chat" ? (
|
||||
@ -11201,11 +11164,11 @@ export default function CanvasEditorLegacyPage({
|
||||
className={cn(
|
||||
isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_CLASS
|
||||
: "border bg-white shadow-2xl dark:bg-zinc-950",
|
||||
: "border border-[#e9e9e9] bg-white shadow-[0_1px_3px_rgba(0,0,0,0.035)] dark:border-white/10 dark:bg-zinc-950",
|
||||
!isVideoMode &&
|
||||
(shouldDockFloatingToolbarToSide
|
||||
? "flex flex-col items-center gap-1 rounded-[24px] px-1.5 py-2.5"
|
||||
: "flex items-center gap-1 rounded-[24px] px-2.5 py-1.5")
|
||||
: "flex items-center gap-1 rounded-[14px] px-1.5 py-1.5")
|
||||
)}
|
||||
style={
|
||||
shouldDockFloatingToolbarToSide
|
||||
@ -11223,7 +11186,7 @@ export default function CanvasEditorLegacyPage({
|
||||
tool === "select" &&
|
||||
(isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||||
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
||||
)}
|
||||
onClick={() => setTool("select")}
|
||||
size="icon"
|
||||
@ -11233,6 +11196,8 @@ export default function CanvasEditorLegacyPage({
|
||||
active={tool === "select"}
|
||||
className="h-4 w-4"
|
||||
fallback={MousePointer}
|
||||
referenceName={isVideoMode ? undefined : "cursor"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"selectTool"
|
||||
@ -11246,63 +11211,6 @@ export default function CanvasEditorLegacyPage({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!isVideoMode &&
|
||||
isCanvasToolbarItemEnabled(
|
||||
canvasToolbarConfig,
|
||||
"addAgentImageSlot"
|
||||
) ? (
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="h-9 w-9 rounded-xl" size="icon" variant="ghost">
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={ImagePlus}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"addAgentImageSlot"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={floatingToolbarTooltipSide}>
|
||||
{
|
||||
getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"addAgentImageSlot"
|
||||
).label
|
||||
}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side={floatingToolbarDropdownSide}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleAddAgentImageSlotNode()}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
图片槽位
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleAddAgentSlotNode("video")}
|
||||
>
|
||||
<Video className="h-4 w-4" />
|
||||
视频槽位
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleAddAgentSlotNode("ui-screen")}
|
||||
>
|
||||
<MonitorSmartphone className="h-4 w-4" />
|
||||
UI 槽位
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@ -11313,7 +11221,7 @@ export default function CanvasEditorLegacyPage({
|
||||
tool === "pan" &&
|
||||
(isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||||
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
||||
)}
|
||||
onClick={() => setTool("pan")}
|
||||
size="icon"
|
||||
@ -11323,6 +11231,8 @@ export default function CanvasEditorLegacyPage({
|
||||
active={tool === "pan"}
|
||||
className="h-4 w-4"
|
||||
fallback={Hand}
|
||||
referenceName={isVideoMode ? undefined : "pan"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
|
||||
/>
|
||||
</Button>
|
||||
@ -11443,7 +11353,7 @@ export default function CanvasEditorLegacyPage({
|
||||
tool === "draw" &&
|
||||
(isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||||
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
||||
)}
|
||||
onClick={() => setTool("draw")}
|
||||
size="icon"
|
||||
@ -11484,7 +11394,7 @@ export default function CanvasEditorLegacyPage({
|
||||
isAgentMode &&
|
||||
(isVideoMode
|
||||
? VIDEO_CANVAS_TOOLBAR_ACTIVE_BUTTON_CLASS
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90")
|
||||
: "bg-[#ededed] text-[#333333] hover:bg-[#e5e5e5] dark:bg-white/12 dark:text-white")
|
||||
)}
|
||||
onClick={handleStyleModeToolbarClick}
|
||||
size="icon"
|
||||
@ -11500,6 +11410,14 @@ export default function CanvasEditorLegacyPage({
|
||||
fallback={
|
||||
shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2
|
||||
}
|
||||
referenceName={
|
||||
isVideoMode
|
||||
? undefined
|
||||
: shouldUseStyleModeButtonForStylePro
|
||||
? "style-controls"
|
||||
: "creativity"
|
||||
}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"styleMode"
|
||||
@ -11529,6 +11447,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
referenceName={isVideoMode ? undefined : "style-controls"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"stylePro"
|
||||
@ -11558,6 +11478,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Layers}
|
||||
referenceName={isVideoMode ? undefined : "layers"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"batchProcessing"
|
||||
@ -11589,6 +11511,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={ImagePlus}
|
||||
referenceName={isVideoMode ? undefined : "image"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
isVideoMode ? "addImageInput" : "addImage"
|
||||
@ -11621,6 +11545,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Type}
|
||||
referenceName={isVideoMode ? undefined : "text"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
isVideoMode ? "addScriptNode" : "addText"
|
||||
@ -11778,6 +11704,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={PenTool}
|
||||
referenceName={isVideoMode ? undefined : "draw"}
|
||||
referenceSize={20}
|
||||
item={resolveCanvasToolbarItemWithFallback(
|
||||
canvasToolbarConfig,
|
||||
"realtimeCanvas",
|
||||
@ -11825,6 +11753,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
referenceName={isVideoMode ? undefined : "image-enhance"}
|
||||
referenceSize={20}
|
||||
item={resolveCanvasToolbarItemWithFallback(
|
||||
canvasToolbarConfig,
|
||||
"enhanceCanvas",
|
||||
@ -11878,6 +11808,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Box}
|
||||
referenceName={isVideoMode ? undefined : "model-auto"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
|
||||
/>
|
||||
</Button>
|
||||
@ -11967,6 +11899,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Plus}
|
||||
referenceName={isVideoMode ? undefined : "plus"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
isVideoMode ? "newVideoCanvas" : "newCanvas"
|
||||
@ -11994,12 +11928,15 @@ export default function CanvasEditorLegacyPage({
|
||||
type="file"
|
||||
/>
|
||||
|
||||
{/* 6. Bottom Left Compact Controls */}
|
||||
{/* Compact zoom and history controls. */}
|
||||
<div
|
||||
data-canvas-compact-controls="true"
|
||||
className="absolute z-20"
|
||||
style={{
|
||||
bottom: `${FLOATING_BOTTOM_INSET + 4}px`,
|
||||
left: `${compactControlsLeft + 8}px`,
|
||||
...(isVideoMode
|
||||
? { left: `${compactControlsLeft + 8}px` }
|
||||
: { right: `${panelOcclusionRight + 16}px` }),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
269
app/globals.css
@ -507,3 +507,272 @@
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lovart surface typography and controls, scoped to the home and image canvas. */
|
||||
.lovart-home-surface,
|
||||
.lovart-canvas-surface {
|
||||
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.lovart-suggestion {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
}
|
||||
.lovart-suggestion:hover { background: #f7f7f7; border-color: #d5d5d5; }
|
||||
.lovart-suggestion:focus-visible,
|
||||
.lovart-start-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
|
||||
|
||||
.lovart-start-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
gap: 9px;
|
||||
min-width: 172px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
}
|
||||
.lovart-start-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-bottom: 0; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer { padding: 0 8px 8px; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] {
|
||||
min-height: 120px;
|
||||
border-color: #e5e5e5;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 5%);
|
||||
}
|
||||
/* The promo backplate continues underneath the composer's rounded corners. */
|
||||
.lovart-chat-promo-connected { padding: 0 8px; }
|
||||
.lovart-chat-promo-surface { padding-bottom: 24px; border-radius: 24px 24px 0 0; background: #ebebeb; }
|
||||
.lovart-chat-promo-row { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 10px 0 16px; }
|
||||
.lovart-chat-promo-message { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; padding: 0; border: 0; background: transparent; color: #202020; font-size: 14px; font-weight: 400; line-height: 20px; text-align: left; cursor: pointer; }
|
||||
.lovart-chat-promo-message:disabled { cursor: default; opacity: 1; }
|
||||
.lovart-chat-promo-message > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lovart-chat-promo-badge { flex-shrink: 0; }
|
||||
.lovart-chat-promo-close { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; border: 0; border-radius: 50%; background: transparent; color: #262626; cursor: pointer; }
|
||||
.lovart-chat-promo-close:hover { background: rgb(0 0 0 / 5%); }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-promo-connected + .lovart-chat-composer { position: relative; z-index: 1; margin-top: -24px; background: transparent; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor][data-canvas-composer-mode="image"] { min-height: 184px; border-radius: 24px; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-upload { margin: 8px 8px 0; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { flex-direction: column; gap: 8px; width: 56px; height: 68px; border: 0; border-radius: 18px; background: #f5f5f5; color: #aaa; font-size: 12px; font-weight: 400; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #ececec; color: #777; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile > svg { width: 14px; height: 14px; stroke-width: 1.5; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-text-region { padding: 12px 16px 0; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"] { min-height: 40px; font-size: 14px; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] [role="textbox"]:empty::before { color: #b0b0b0; }
|
||||
.dark .lovart-chat-promo-surface { background: #27272a; }
|
||||
.dark .lovart-chat-promo-message, .dark .lovart-chat-promo-close { color: #eee; }
|
||||
.dark .lovart-chat-promo-close:hover { background: rgb(255 255 255 / 10%); }
|
||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile { background: #27272a; color: #a1a1aa; }
|
||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-reference-tile:hover { background: #3f3f46; color: #eee; }
|
||||
.dark .lovart-suggestion,
|
||||
.dark .lovart-start-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
|
||||
.dark .lovart-suggestion:hover,
|
||||
.dark .lovart-start-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
|
||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-color: rgb(255 255 255 / 10%); }
|
||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] { border-color: rgb(255 255 255 / 12%); }
|
||||
@media (max-width: 640px) {
|
||||
.lovart-home-surface main { padding-top: calc(72px + var(--home-banner-offset, 0px)); }
|
||||
.lovart-hero { margin-top: 16px; }
|
||||
.lovart-home-composer { border-radius: 20px; }
|
||||
.lovart-suggestion { padding: 0 11px; font-size: 12px; }
|
||||
.lovart-start-action { min-width: 160px; }
|
||||
.lovart-chat-welcome h1 { font-size: 15px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lovart-home-surface *, .lovart-canvas-surface * { scroll-behavior: auto !important; }
|
||||
}
|
||||
|
||||
.lovart-home-surface :is(button, input, textarea, select),
|
||||
.lovart-canvas-surface :is(button, input, textarea, select) { font-family: inherit; }
|
||||
@media (max-width: 640px) {
|
||||
.lovart-canvas-surface[data-canvas-panel-open="true"] :is([data-canvas-topbar], [data-canvas-floating-toolbar], [data-canvas-compact-controls]) { display: none; }
|
||||
}
|
||||
|
||||
/* Reference icon sizing is independent of the application's root rem scale. */
|
||||
.lovart-suggestion { gap: 8px; padding: 0 12px; }
|
||||
.lovart-start-action { min-width: 172.71875px; gap: 8px; padding: 0 12px; }
|
||||
.lovart-canvas-surface [data-canvas-floating-toolbar] button { min-width: 32px; height: 32px; border-radius: 8px; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector { width: 90px; height: 32px; padding: 0 8px; background: transparent; font-size: 14px; font-weight: 400; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector > span { font-size: 13px; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-mode-selector:hover,
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params:hover,
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector:hover { background: rgb(0 0 0 / 4%); }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-image-params { width: 105.3828125px; min-width: 105.3828125px; height: 32px; padding: 0 4px 0 12px; background: transparent; font-size: 13px; gap: 4px; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector { flex: 0 0 121px; width: 121px; height: 32px; padding: 0 4px 0 8px; gap: 0; border: 0; background: transparent; box-shadow: none; font-size: 13px; font-weight: 400; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > [data-reference-icon]:first-child { margin-right: 8px; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] .lovart-model-selector > span:not([data-reference-icon]) { max-width: 65px; }
|
||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params) { color: #eee; }
|
||||
.dark .lovart-canvas-surface [data-lovart-chat="true"] :is(.lovart-model-selector, .lovart-image-params, .lovart-composer-mode-selector):hover { background: rgb(255 255 255 / 8%); }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer { padding: 0 8px 8px; gap: 0; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:first-child { flex: 0 0 90px; padding-right: 0; overflow: visible; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-footer > div:last-child { flex: 1 1 0; min-width: 0; gap: 0; }
|
||||
.lovart-canvas-surface [data-lovart-chat="true"] [data-canvas-composer-mode="image"] .lovart-composer-send { min-width: 32px; width: auto; height: 32px; padding: 0 8px; gap: 2px; font-size: 13px; font-weight: 400; }
|
||||
|
||||
/* The existing image toolbar is scaled to 80%; these dimensions render at
|
||||
the reference's 44px bar, 32px buttons, and 20px icons. */
|
||||
.lovart-image-action-bar {
|
||||
height: 55px;
|
||||
gap: 5px;
|
||||
padding: 0 7.5px;
|
||||
border: 1.25px solid rgba(0, 0, 0, 0.07);
|
||||
border-radius: 17.5px;
|
||||
background: #fff !important;
|
||||
color: #000;
|
||||
box-shadow: 0 1.25px 2.5px rgba(0, 0, 0, 0.04);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.lovart-image-action-bar::before { display: none; }
|
||||
.lovart-image-action-bar > button {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #000;
|
||||
font-size: 0;
|
||||
font-weight: 400;
|
||||
box-shadow: none;
|
||||
}
|
||||
.lovart-image-action-bar > button:hover,
|
||||
.lovart-image-action-bar > button[data-state="open"],
|
||||
.lovart-image-action-bar > button[data-reference-active="true"] {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #000;
|
||||
}
|
||||
.lovart-image-action-bar > button:disabled {
|
||||
background: transparent;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.lovart-image-action-bar > button > svg,
|
||||
.lovart-image-action-bar > button > [data-reference-icon],
|
||||
.lovart-image-action-bar > button > span[aria-hidden="true"],
|
||||
.lovart-image-action-bar > button > img {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.lovart-image-action-bar > button > img { filter: brightness(0); }
|
||||
.lovart-image-action-bar > div.h-px,
|
||||
.lovart-image-action-bar > div.w-px {
|
||||
width: 1.25px;
|
||||
height: 55px;
|
||||
margin: 0 2.5px;
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
.lovart-image-action-menu [role="menuitem"] > svg,
|
||||
.lovart-image-action-menu [role="menuitem"] > [data-reference-icon] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.dark .lovart-image-action-bar {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: #18191c !important;
|
||||
color: #fff;
|
||||
}
|
||||
.dark .lovart-image-action-bar > button { color: #fff; }
|
||||
.dark .lovart-image-action-bar > button:hover,
|
||||
.dark .lovart-image-action-bar > button[data-state="open"],
|
||||
.dark .lovart-image-action-bar > button[data-reference-active="true"] {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
.dark .lovart-image-action-bar > button:disabled { color: rgba(255, 255, 255, 0.3); }
|
||||
.dark .lovart-image-action-bar > button > img { filter: brightness(0) invert(1); }
|
||||
.dark .lovart-image-action-bar > div.w-px { background: rgba(255, 255, 255, 0.1); }
|
||||
|
||||
.lovart-image-action-bar[data-show-tool-names="true"] > button[data-tool-key] { width: auto; gap: 7.5px; padding: 0 12.5px; font-size: 16.25px; }
|
||||
.lovart-image-tool-label { white-space: nowrap; }
|
||||
.lovart-image-tool-tooltip { z-index: 10030; border: 0; border-radius: 6px; background: #1b1b1b; padding: 6px 9px; color: #fff; font-size: 13px; line-height: 18px; box-shadow: 0 4px 10px #00000012; --tw-ring-shadow: 0 0 transparent; }
|
||||
.lovart-image-custom-more { min-width: 172px; padding: 6px 12px; border: 1px solid #e8e8e8; border-radius: 12px; background: #fff; }
|
||||
.lovart-image-custom-more [role="menuitem"] { min-height: 32px; gap: 10px; padding: 4px 0; font-size: 14px; font-weight: 400; }
|
||||
.lovart-image-custom-more .lovart-customize-entry { justify-content: center; gap: 5px; }
|
||||
.lovart-toolbar-customizer-overlay { z-index: 10010; background: #0006; }
|
||||
.lovart-toolbar-customizer { z-index: 10020; display: flex; flex-direction: column; width: min(720px, calc(100vw - 32px)); max-width: 720px; max-height: calc(100dvh - 48px); gap: 0; padding: 20px 24px 24px; border: 1px solid #e5e5e5; border-radius: 16px; background: #fff; color: #242424; box-shadow: 0 8px 24px #00000012; }
|
||||
.lovart-customizer-header { display: flex; flex-shrink: 0; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.lovart-customizer-header h2 { margin: 0; font-size: 18px; line-height: 24px; font-weight: 600; }
|
||||
.lovart-customizer-header p { margin-top: 2px; color: inherit; font-size: 14px; line-height: 22px; }
|
||||
.lovart-customizer-header-actions { display: flex; gap: 4px; }
|
||||
.lovart-customizer-header-actions button { display: flex; width: 28px; height: 28px; align-items: center; justify-content: center; border-radius: 7px; }
|
||||
.lovart-customizer-header-actions button:last-child { background: #f1f1f1; }
|
||||
.lovart-customizer-header-actions button:hover { background: #e9e9e9; }
|
||||
.lovart-customizer-body { min-height: 0; overflow-y: auto; margin: 24px 0 20px; }
|
||||
.lovart-customizer-preview { display: flex; height: 180px; align-items: center; overflow: hidden; padding: 16px; border-radius: 10px; background: #e5edef url("/icons/lovart/toolbar-preview.png") center / cover; }
|
||||
.lovart-customizer-preview-bar { display: flex; align-items: center; flex: 1; min-width: 0; gap: 4px; padding: 5px 8px; border: 1px solid #e5e5e5; border-radius: 14px; background: #fff; overflow-x: auto; scrollbar-width: thin; }
|
||||
.lovart-preview-fixed,.lovart-preview-chip { display: flex; align-items: center; flex-shrink: 0; height: 32px; gap: 8px; padding: 0 10px; font-size: 13px; white-space: nowrap; }
|
||||
.lovart-preview-chip { position: relative; margin-top: 5px; margin-bottom: 5px; border-radius: 8px; background: #f4f4f4; cursor: grab; }
|
||||
.lovart-preview-chip:active { cursor: grabbing; }
|
||||
.lovart-preview-chip > button:last-child { position: absolute; top: -5px; right: -3px; display: flex; width: 16px; height: 16px; align-items: center; justify-content: center; border: 1px solid #e6e6e6; border-radius: 50%; background: #fff; color: #888; }
|
||||
.lovart-customizer-tools { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 8px; margin-top: 24px; }
|
||||
.lovart-customizer-tools > button { display: flex; align-items: center; gap: 10px; height: 36px; min-width: 0; padding: 0 12px; border: 1px solid #e2e2e2; border-radius: 8px; background: #fff; font-size: 14px; text-align: left; }
|
||||
.lovart-customizer-tools > button > span:not([aria-hidden]) { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lovart-customizer-tools > button > svg:last-child { flex-shrink: 0; }
|
||||
.lovart-customizer-tools > button:hover { background: #f7f7f7; }
|
||||
.lovart-customizer-footer { display: flex; flex-shrink: 0; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.lovart-customizer-footer > label,.lovart-customizer-footer > div { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
.lovart-customizer-switch { width: 32px; height: 18px; padding: 2px; border-radius: 20px; background: #d5d5d5; }
|
||||
.lovart-customizer-switch > span { display: block; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
||||
.lovart-customizer-switch[aria-checked="true"] { background: #2c2c2c; }
|
||||
.lovart-customizer-switch[aria-checked="true"] > span { transform: translateX(14px); }
|
||||
.lovart-customizer-footer > div > button { width: 80px; height: 36px; border: 1px solid #e3e3e3; border-radius: 8px; font-size: 14px; }
|
||||
.lovart-customizer-footer .lovart-customizer-save { border-color: #2c2c2c; background: #2c2c2c; color: #fff; }
|
||||
.lovart-customizer-footer > div > button:hover { opacity: .8; }
|
||||
.lovart-toolbar-customizer button:focus-visible,.lovart-preview-chip:focus-visible { outline: 2px solid #397dff; outline-offset: 2px; }
|
||||
.dark .lovart-toolbar-customizer,.dark .lovart-image-custom-more { background: #202124; border-color: #3d3e40; color: #f5f5f5; }
|
||||
.dark .lovart-customizer-preview-bar,.dark .lovart-customizer-tools > button { background: #26272a; border-color: #46474a; }
|
||||
.dark .lovart-preview-chip,.dark .lovart-customizer-header-actions button:last-child { background: #35363a; }
|
||||
.dark .lovart-preview-chip > button:last-child { background: #26272a; border-color: #46474a; color: #ccc; }
|
||||
.dark .lovart-customizer-tools > button:hover { background: #35363a; }
|
||||
@media(max-width:600px) { .lovart-toolbar-customizer { padding: 18px; } .lovart-customizer-tools { grid-template-columns: repeat(2,minmax(0,1fr)); } .lovart-customizer-preview { height: 140px; } }
|
||||
|
||||
.lovart-preview-handle { display: flex; align-items: center; gap: 8px; white-space: nowrap; cursor: grab; }
|
||||
|
||||
.lovart-image-tool-tooltip { display: flex; align-items: center; gap: 8px; }
|
||||
.lovart-image-tool-cost { margin-left: auto; padding: 0; background: transparent; color: #999; font-size: 12px; font-weight: 400; box-shadow: none; }
|
||||
.lovart-image-tool-cost > svg { width: 12px; height: 12px; }
|
||||
|
||||
/* Preserve the canvas's own skills as the primary welcome content. */
|
||||
.canvas-welcome-action {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 999px;
|
||||
background: white;
|
||||
color: #333;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
transition: background-color 150ms, border-color 150ms;
|
||||
}
|
||||
.canvas-welcome-action:hover { background: #f7f7f7; border-color: #d4d4d4; }
|
||||
.canvas-welcome-action:focus-visible { outline: 2px solid #8c8c8c; outline-offset: 3px; }
|
||||
.dark .canvas-welcome-action { border-color: rgb(255 255 255 / 12%); background: #18181b; color: #eee; }
|
||||
.dark .canvas-welcome-action:hover { background: #27272a; border-color: rgb(255 255 255 / 22%); }
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Check, ImageIcon, ImagePlus, Loader2, Search, Upload, X } from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { AgentPreviewImage } from "@/components/agents/AgentPreviewImage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -21,6 +21,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE } from "@/lib/image-upload-limits";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import type {
|
||||
AgentWorkflowMaterialImage,
|
||||
AgentWorkflowMaterialQueryType,
|
||||
@ -163,6 +165,8 @@ export function CanvasAddImageDialog({
|
||||
const [selectedImageIds, setSelectedImageIds] = useState<string[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const localUploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
|
||||
const images = useMemo(() => normalizeImages(result), [result]);
|
||||
@ -182,12 +186,13 @@ export function CanvasAddImageDialog({
|
||||
|
||||
const handleDialogOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (!nextOpen && (isUploading || isAdding)) return;
|
||||
if (!nextOpen) {
|
||||
resetDialogState();
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
},
|
||||
[onOpenChange, resetDialogState]
|
||||
[isAdding, isUploading, onOpenChange, resetDialogState]
|
||||
);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
@ -263,6 +268,51 @@ export function CanvasAddImageDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalUpload = async (files: File[]) => {
|
||||
if (!files.length || isUploading || isAdding) return;
|
||||
if (files.some((file) => !file.type.startsWith("image/"))) {
|
||||
setErrorText("请选择图片文件");
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => file.size > IMAGE_UPLOAD_MAX_BYTES)) {
|
||||
setErrorText(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setErrorText(null);
|
||||
try {
|
||||
const uploadedImages: CanvasAddImageDialogImage[] = [];
|
||||
const failures: string[] = [];
|
||||
for (const [index, file] of files.entries()) {
|
||||
const upload = await imageGenerationService.uploadImage(file);
|
||||
if (!upload.success || !upload.data?.url) {
|
||||
failures.push(`${file.name}:${upload.error || "图片上传失败"}`);
|
||||
continue;
|
||||
}
|
||||
uploadedImages.push({
|
||||
id: upload.data.assetId || crypto.randomUUID(),
|
||||
key: upload.data.pathname || file.name,
|
||||
label: file.name.replace(/\.[^.]+$/, "") || file.name,
|
||||
fileName: file.name,
|
||||
imageUrl: upload.data.url,
|
||||
assetId: upload.data.assetId,
|
||||
mimeType: file.type,
|
||||
sourceType: "upload",
|
||||
group: "other",
|
||||
sortOrder: index,
|
||||
});
|
||||
}
|
||||
if (uploadedImages.length) await onAddImages(uploadedImages);
|
||||
if (failures.length) setErrorText(failures.join(";"));
|
||||
else closeDialog();
|
||||
} catch (error) {
|
||||
setErrorText(error instanceof Error ? error.message : "图片上传失败,请重试");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleDialogOpenChange} open={open}>
|
||||
<DialogContent className="max-h-[86vh] max-w-4xl overflow-hidden p-0">
|
||||
@ -272,15 +322,38 @@ export function CanvasAddImageDialog({
|
||||
添加图片
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
添加空白图片节点,或从风格化素材库检索 SPU / SKU 白底图。
|
||||
从本地上传图片、添加空白图片节点,或检索 SPU / SKU 白底图。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto px-5 py-4">
|
||||
<div className="rounded-[var(--ui-radius-panel)] border bg-muted/20 p-3">
|
||||
<div className="flex flex-col gap-2.5 sm:flex-row">
|
||||
<input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
void handleLocalUpload(files);
|
||||
}}
|
||||
ref={localUploadInputRef}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
className="h-9 justify-start gap-2"
|
||||
className="h-9 shrink-0 justify-start gap-2"
|
||||
disabled={isUploading || isAdding}
|
||||
onClick={() => localUploadInputRef.current?.click()}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||
{isUploading ? "上传中…" : "本地上传"}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-9 shrink-0 justify-start gap-2"
|
||||
disabled={isUploading || isAdding}
|
||||
onClick={() => {
|
||||
onCreateBlankImage();
|
||||
closeDialog();
|
||||
@ -355,13 +428,13 @@ export function CanvasAddImageDialog({
|
||||
</div>
|
||||
{!materialSearchEnabled ? (
|
||||
<div className="mt-2.5 rounded-[var(--ui-radius-control)] border border-dashed bg-background/70 px-3 py-2 text-[12px] text-muted-foreground">
|
||||
风格化功能或素材搜索步骤未开启,当前只显示空白图片入口。
|
||||
风格化功能或素材搜索步骤未开启,可从本地上传或添加空白图片。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{errorText ? (
|
||||
<div className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
||||
<div role="alert" className="mt-3 rounded-[var(--ui-radius-control)] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[13px] text-destructive">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
@ -440,6 +513,7 @@ export function CanvasAddImageDialog({
|
||||
|
||||
<DialogFooter className="border-t px-5 py-3">
|
||||
<Button
|
||||
disabled={isUploading || isAdding}
|
||||
onClick={closeDialog}
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -448,7 +522,7 @@ export function CanvasAddImageDialog({
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!selectedImages.length || isAdding}
|
||||
disabled={!selectedImages.length || isAdding || isUploading}
|
||||
onClick={() => void handleAddSelected()}
|
||||
type="button"
|
||||
>
|
||||
|
||||
115
components/canvas/CanvasBlankContextMenu.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Position } from "@/types/canvas";
|
||||
|
||||
type Props = {
|
||||
position: Position | null;
|
||||
isMac: boolean;
|
||||
videoUploading: boolean;
|
||||
onClose: () => void;
|
||||
onUploadImage: () => void;
|
||||
onUploadVideo: () => void;
|
||||
onPaste: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onFitAll: () => void;
|
||||
onResetZoom: () => void;
|
||||
};
|
||||
|
||||
export function CanvasBlankContextMenu({
|
||||
position,
|
||||
isMac,
|
||||
videoUploading,
|
||||
onClose,
|
||||
onUploadImage,
|
||||
onUploadVideo,
|
||||
onPaste,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onFitAll,
|
||||
onResetZoom,
|
||||
}: Props) {
|
||||
const modifier = isMac ? "⌘" : "Ctrl+";
|
||||
const itemClass =
|
||||
"h-8 cursor-pointer justify-between whitespace-nowrap rounded-[5px] px-0 text-[14px] font-normal focus:bg-[#f5f5f5] dark:focus:bg-white/10";
|
||||
const shortcut = (label: string) => (
|
||||
<span className="ml-2 whitespace-nowrap text-[13px] text-[#aaa]">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={position !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
data-canvas-no-drag="true"
|
||||
className="pointer-events-none fixed h-px w-px opacity-0"
|
||||
style={{ left: position?.x ?? 0, top: position?.y ?? 0 }}
|
||||
type="button"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
aria-label="画布右键菜单"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
collisionPadding={8}
|
||||
data-canvas-blank-menu="true"
|
||||
data-canvas-no-drag="true"
|
||||
className="z-[10005] w-[200px] rounded-[12px] border-[#ededed] bg-white px-3 py-[5px] text-[#242424] shadow-[0_8px_20px_rgba(0,0,0,0.06)] ring-0 dark:border-white/10 dark:bg-zinc-900 dark:text-white"
|
||||
style={{
|
||||
fontFamily:
|
||||
'Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
|
||||
}}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem className={itemClass} onSelect={onUploadImage}>
|
||||
上传图片
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={videoUploading}
|
||||
onSelect={onUploadVideo}
|
||||
>
|
||||
上传视频
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
|
||||
<DropdownMenuItem className={itemClass} onSelect={onPaste}>
|
||||
粘贴{shortcut(`${modifier}V`)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
|
||||
<DropdownMenuItem className={itemClass} onSelect={onZoomIn}>
|
||||
放大{shortcut(`${modifier}+`)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={onZoomOut}>
|
||||
缩小{shortcut(`${modifier}-`)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={onFitAll}>
|
||||
显示画布所有元素{shortcut(isMac ? "⇧1" : "Shift+1")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={onResetZoom}>
|
||||
缩放至100%{shortcut(`${modifier}0`)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@ -25,8 +25,11 @@ import {
|
||||
type UiSurfaceSnapshot,
|
||||
} from "@/lib/ui-surface-protocol";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, FileArchive, FolderUp, Paperclip, Copy, Square, Search, GripVertical, Volume2, LockKeyhole } from "lucide-react";
|
||||
import { Loader2, ImagePlus, X, Plus, History, LayoutGrid, ArrowRightToLine, RefreshCw, Globe, ArrowUp, Video, Sparkles, Check, ChevronDown, ChevronUp, AlertCircle, CheckCircle2, Brain, ThumbsUp, ThumbsDown, Eye, Trash2, Download, MessageCircle, Clapperboard, Bot, FileSpreadsheet, FileArchive, FolderUp, Paperclip, Copy, Square, Search, GripVertical, Volume2, LockKeyhole, Images } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CanvasComposerPromo } from "./CanvasComposerPromo";
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
import { ModelIdentityIcon } from "@/components/lovart/model-identity-icon";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@ -14730,7 +14733,9 @@ export function CanvasChatPanel({
|
||||
: selectedSkillTag?.placeholder?.trim() ||
|
||||
(selectedSkillTag
|
||||
? `请输入「${selectedSkillTag.name}」相关需求`
|
||||
: "请输入你的设计需求");
|
||||
: activeComposerMode === "image"
|
||||
? "今天我们要创作什么"
|
||||
: "请输入你的设计需求");
|
||||
const clawdEditablePresetPlaceholder =
|
||||
isClawdMode && activeClawdSkillTag
|
||||
? activeClawdSkillTag.placeholder?.trim() || ""
|
||||
@ -15218,10 +15223,14 @@ export function CanvasChatPanel({
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none",
|
||||
isVideoWorkspace
|
||||
? "border border-border/60 bg-background"
|
||||
: "border border-transparent bg-white dark:border-white/20 dark:bg-white"
|
||||
: "border border-transparent bg-white dark:border-white/20 dark:bg-white",
|
||||
!isWorkspacePresentation && !isVideoWorkspace && "rounded-none border-0 bg-transparent dark:bg-transparent"
|
||||
)}
|
||||
>
|
||||
{model.avatarUrl ? (
|
||||
{!isWorkspacePresentation && !isVideoWorkspace ? (
|
||||
<ModelIdentityIcon name={model.displayName} identity={`${model.id} ${model.providerType || ""}`} avatarUrl={model.avatarUrl ? buildPreviewImageUrl(model.avatarUrl, 40) : undefined} size={18} />
|
||||
) : (
|
||||
model.avatarUrl ? (
|
||||
<ModelAvatarImage
|
||||
src={buildPreviewImageUrl(model.avatarUrl, 40)}
|
||||
alt={model.displayName}
|
||||
@ -15248,6 +15257,7 @@ export function CanvasChatPanel({
|
||||
>
|
||||
{model.id === "auto" ? "A" : model.displayName.slice(0, 1)}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-normal leading-5">
|
||||
@ -15400,6 +15410,7 @@ export function CanvasChatPanel({
|
||||
return (
|
||||
<div
|
||||
data-canvas-chat-panel="ready"
|
||||
data-lovart-chat={!isWorkspacePresentation && !isVideoWorkspace ? "true" : undefined}
|
||||
className={cn(
|
||||
"relative flex h-full min-h-0 flex-col",
|
||||
isWorkspacePresentation
|
||||
@ -15437,7 +15448,7 @@ export function CanvasChatPanel({
|
||||
</div>
|
||||
)}
|
||||
{!isWorkspacePresentation ? (
|
||||
<div className="z-20 flex items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="lovart-chat-header z-20 flex h-[40px] shrink-0 items-center justify-between gap-2 px-3 py-2">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2 pr-2">
|
||||
<span
|
||||
className={cn(
|
||||
@ -15869,8 +15880,8 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col px-5 pb-8 pt-8">
|
||||
<div className="text-left">
|
||||
<div className="lovart-chat-welcome flex min-h-0 flex-1 flex-col overflow-y-auto px-5 pb-6 pt-6">
|
||||
<div className="shrink-0 text-left">
|
||||
<h1 className="text-[20px] font-semibold leading-tight text-[#050505] dark:text-zinc-50">
|
||||
Hi,{" "}
|
||||
{greetingUserName ? (
|
||||
@ -15888,7 +15899,7 @@ export function CanvasChatPanel({
|
||||
</div>
|
||||
|
||||
{canvasSkillTags.length > 0 ? (
|
||||
<div className="mt-6 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
|
||||
<div data-canvas-skill-picker="true" className="mt-6 w-full shrink-0 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
|
||||
<h3 className="text-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
|
||||
{canvasSkillTagsTitle}
|
||||
</h3>
|
||||
@ -15960,40 +15971,38 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{canvasSkillTags.length > 4 ? (
|
||||
<div className="mx-auto mt-4 flex h-9 w-full max-w-[360px] items-center gap-2 rounded-lg border border-[#e5e7eb] bg-[#fafbfc] px-3 text-left transition-colors focus-within:border-[#aeb6c5] focus-within:bg-white dark:border-white/10 dark:bg-white/5 dark:focus-within:border-white/25 dark:focus-within:bg-white/[0.07]">
|
||||
<Search className="h-4 w-4 shrink-0 text-[#9299a6] dark:text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={canvasSkillTagSearchQuery}
|
||||
onChange={(event) =>
|
||||
setCanvasSkillTagSearchQuery(event.target.value)
|
||||
<div className="mx-auto mt-4 flex h-9 w-full max-w-[360px] items-center gap-2 rounded-lg border border-[#e5e7eb] bg-[#fafbfc] px-3 text-left transition-colors focus-within:border-[#aeb6c5] focus-within:bg-white dark:border-white/10 dark:bg-white/5 dark:focus-within:border-white/25 dark:focus-within:bg-white/[0.07]">
|
||||
<Search className="h-4 w-4 shrink-0 text-[#9299a6] dark:text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={canvasSkillTagSearchQuery}
|
||||
onChange={(event) =>
|
||||
setCanvasSkillTagSearchQuery(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setCanvasSkillTagSearchQuery("");
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setCanvasSkillTagSearchQuery("");
|
||||
}
|
||||
}}
|
||||
placeholder="搜索快捷技能"
|
||||
aria-label="搜索快捷技能"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
spellCheck={false}
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-[13px] text-[#1f2328] outline-none placeholder:text-[#9aa1ad] dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
{canvasSkillTagSearchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCanvasSkillTagSearchQuery("")}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center text-[#9299a6] transition-colors hover:text-[#1f2328] dark:text-zinc-500 dark:hover:text-zinc-100"
|
||||
title="清空搜索"
|
||||
aria-label="清空快捷技能搜索"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
}}
|
||||
placeholder="搜索快捷技能"
|
||||
aria-label="搜索快捷技能"
|
||||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
spellCheck={false}
|
||||
className="h-full min-w-0 flex-1 bg-transparent text-[13px] text-[#1f2328] outline-none placeholder:text-[#9aa1ad] dark:text-zinc-100 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
{canvasSkillTagSearchQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCanvasSkillTagSearchQuery("")}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center text-[#9299a6] transition-colors hover:text-[#1f2328] dark:text-zinc-500 dark:hover:text-zinc-100"
|
||||
title="清空搜索"
|
||||
aria-label="清空快捷技能搜索"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex flex-wrap justify-center gap-1.5 transition-[max-height] duration-150 ease-out",
|
||||
@ -16002,7 +16011,7 @@ export function CanvasChatPanel({
|
||||
: "mt-6",
|
||||
isCanvasSkillTagSearching || areCanvasSkillTagsExpanded
|
||||
? "max-h-none overflow-visible"
|
||||
: "max-h-[76px] overflow-hidden"
|
||||
: "max-h-[calc(4rem+0.375rem)] overflow-hidden"
|
||||
)}
|
||||
>
|
||||
{filteredCanvasSkillTags.map((tag) => {
|
||||
@ -16965,37 +16974,53 @@ export function CanvasChatPanel({
|
||||
<CanvasComposerUploadProgressDialog progress={composerUploadProgress} />
|
||||
|
||||
{!isWorkspacePresentation && shouldShowCanvasBanner ? (
|
||||
<div className="px-[10px] pb-0">
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 mb-0 flex h-[48px] items-center justify-between gap-2 rounded-[28px] bg-[#f4f4f5] px-4 py-0 text-[14px] font-medium leading-5 text-[#20242a] dark:bg-zinc-900 dark:text-zinc-100",
|
||||
siteSettings?.bannerLink && "cursor-pointer transition-colors hover:bg-[#eeeeef] dark:hover:bg-zinc-800"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (siteSettings?.bannerLink) {
|
||||
window.open(siteSettings.bannerLink, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[9px] bg-[#e8ff38] text-[#111827]">
|
||||
<Sparkles className="h-3.5 w-3.5 fill-current" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate">{siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#20242a]/75 transition-colors hover:bg-black/5 hover:text-[#20242a] dark:text-zinc-100/75 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||||
aria-label="关闭优惠广告"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setIsCanvasBannerDismissed(true);
|
||||
!isVideoWorkspace ? (
|
||||
<CanvasComposerPromo
|
||||
text={siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"}
|
||||
onDismiss={() => setIsCanvasBannerDismissed(true)}
|
||||
onActivate={
|
||||
siteSettings?.bannerLink
|
||||
? () => {
|
||||
if (siteSettings.bannerLink) {
|
||||
window.open(siteSettings.bannerLink, "_blank");
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="lovart-chat-promo px-[8px] pb-0">
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 mb-0 flex h-[36px] items-center justify-between gap-2 rounded-t-[24px] bg-[#ededed] px-4 py-0 text-[12px] font-medium leading-5 text-[#20242a] dark:bg-zinc-900 dark:text-zinc-100",
|
||||
siteSettings?.bannerLink && "cursor-pointer transition-colors hover:bg-[#eeeeef] dark:hover:bg-zinc-800"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (siteSettings?.bannerLink) {
|
||||
window.open(siteSettings.bannerLink, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[9px] bg-[#e8ff38] text-[#111827]">
|
||||
<Sparkles className="h-3.5 w-3.5 fill-current" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate">{siteSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!"}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#20242a]/75 transition-colors hover:bg-black/5 hover:text-[#20242a] dark:text-zinc-100/75 dark:hover:bg-white/10 dark:hover:text-zinc-100"
|
||||
aria-label="关闭优惠广告"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setIsCanvasBannerDismissed(true);
|
||||
}}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* 底部输入区域 - 混合输入(图片标签+文字可自由混合) */}
|
||||
@ -17003,15 +17028,16 @@ export function CanvasChatPanel({
|
||||
className={cn(
|
||||
isWorkspacePresentation
|
||||
? "border-t bg-white/92 p-4 backdrop-blur-sm dark:bg-zinc-950/92"
|
||||
: "bg-white px-2 pb-2 pt-0 dark:bg-zinc-950"
|
||||
: "lovart-chat-composer bg-white px-[8px] pb-[8px] pt-0 dark:bg-zinc-950"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col overflow-hidden border border-[#e2e5ea] bg-white shadow-[0_18px_36px_-28px_rgba(15,23,42,0.42)] transition-[border-color,box-shadow] duration-150 focus-within:border-[#cfd5de] focus-within:shadow-[0_20px_40px_-28px_rgba(15,23,42,0.5)] dark:border-white/12 dark:bg-zinc-950 dark:focus-within:border-white/25",
|
||||
"min-h-[148px] rounded-[18px]"
|
||||
"min-h-[120px] rounded-[24px]"
|
||||
)}
|
||||
data-canvas-chat-input-anchor="true"
|
||||
data-canvas-composer-mode={activeComposerMode}
|
||||
>
|
||||
{isWorkspacePresentation && !isDetailGenerationMode ? (
|
||||
<div className="border-b px-5 py-3">
|
||||
@ -17767,7 +17793,10 @@ export function CanvasChatPanel({
|
||||
{/* 混合输入区域:使用 contenteditable 实现真正的混合编辑 */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative px-3 pb-0 pt-2.5",
|
||||
"relative pb-0",
|
||||
!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image"
|
||||
? "px-0 pt-0"
|
||||
: "px-3 pt-2.5",
|
||||
isDetailGenerationMode
|
||||
? "min-h-[96px]"
|
||||
: activeComposerMode === "image"
|
||||
@ -18146,16 +18175,17 @@ export function CanvasChatPanel({
|
||||
</div>
|
||||
) : null}
|
||||
{!isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && activeComposerMode === "image" ? (
|
||||
<div className="mx-4 mt-3 flex items-center">
|
||||
<div className="lovart-reference-upload mx-[8px] mt-[8px] flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
|
||||
className="lovart-reference-tile relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isSubmitting}
|
||||
title={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
|
||||
aria-label={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<ReferenceIcon name="reference-image" size={16} />
|
||||
<span>参考图</span>
|
||||
{composerReferenceUploadProgress ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-white backdrop-blur-[1px]">
|
||||
<span className="text-[10px] font-semibold leading-none tabular-nums">
|
||||
@ -18217,6 +18247,7 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"lovart-composer-text-region",
|
||||
isWorkspacePresentation || isDetailGenerationMode || isVideoWorkspace
|
||||
? "px-1"
|
||||
: "px-2 pt-2"
|
||||
@ -18225,6 +18256,11 @@ export function CanvasChatPanel({
|
||||
<div
|
||||
ref={textareaRef as any}
|
||||
contentEditable={!isSubmitting}
|
||||
role="textbox"
|
||||
tabIndex={isSubmitting ? -1 : 0}
|
||||
aria-disabled={isSubmitting}
|
||||
aria-label="描述你的设计需求"
|
||||
aria-multiline="true"
|
||||
suppressContentEditableWarning
|
||||
className={cn(
|
||||
"max-h-[128px] overflow-y-auto outline-none empty:before:content-[attr(data-placeholder)]",
|
||||
@ -18452,7 +18488,7 @@ export function CanvasChatPanel({
|
||||
{/* Bottom Toolbar */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-auto flex min-h-10 items-center justify-between gap-1.5 px-2.5 pb-2 pt-1 dark:bg-zinc-950"
|
||||
"lovart-composer-footer mt-auto flex min-h-10 items-center justify-between gap-1.5 px-2.5 pb-2 pt-1 dark:bg-zinc-950"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto pr-1 scrollbar-hide">
|
||||
@ -18586,7 +18622,7 @@ export function CanvasChatPanel({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"relative h-8 max-w-[110px] shrink-0 cursor-pointer rounded-full px-2.5 text-[13px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
"lovart-composer-mode-selector relative h-8 max-w-[110px] shrink-0 cursor-pointer rounded-full px-2.5 text-[13px] font-medium outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
isVideoToolbarDark
|
||||
? "bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
|
||||
: "bg-[#eeeeef] text-[#20242a] hover:bg-[#e7e7e8] dark:bg-white/10 dark:text-zinc-100 dark:hover:bg-white/14"
|
||||
@ -18612,6 +18648,8 @@ export function CanvasChatPanel({
|
||||
className="mr-1 h-4 w-4 shrink-0"
|
||||
fallback={ImagePlus}
|
||||
item={getToolbarItem("chatModeImageActive")}
|
||||
referenceName={!isWorkspacePresentation && !isVideoWorkspace ? "image" : undefined}
|
||||
referenceSize={20}
|
||||
/>
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
@ -18621,7 +18659,9 @@ export function CanvasChatPanel({
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{activeModeLabel}</span>
|
||||
{isComposerModeMenuOpen ? (
|
||||
{!isWorkspacePresentation && !isVideoWorkspace ? (
|
||||
<ReferenceIcon name="chevron-down" size={18} className={cn("ml-1.5", isComposerModeMenuOpen && "rotate-180")} />
|
||||
) : isComposerModeMenuOpen ? (
|
||||
<ChevronUp className="ml-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="ml-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
@ -18753,6 +18793,7 @@ export function CanvasChatPanel({
|
||||
hideImageCount={false}
|
||||
hideAdvancedOptions={true}
|
||||
inlineTheme="minimal"
|
||||
referencePresentation={!isWorkspacePresentation && !isVideoWorkspace}
|
||||
showInlineDivider={false}
|
||||
/>
|
||||
) : null}
|
||||
@ -18783,6 +18824,7 @@ export function CanvasChatPanel({
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
cn(!isVideoWorkspace && activeComposerMode === "image" && "lovart-model-selector"),
|
||||
"h-8 w-8 shrink-0 justify-center rounded-full p-0 outline-none focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&>svg]:hidden",
|
||||
isVideoToolbarDark
|
||||
? "border border-white/12 bg-[#242529] text-zinc-100 hover:bg-[#2c2d33]"
|
||||
@ -18790,6 +18832,13 @@ export function CanvasChatPanel({
|
||||
)}
|
||||
title={getToolbarItem("chatModelSelector").label}
|
||||
>
|
||||
{!isVideoWorkspace && activeComposerMode === "image" ? (
|
||||
<>
|
||||
<ModelIdentityIcon name={selectDisplayModel?.displayName || "自动模型"} identity={`${selectDisplayModel?.id || ""} ${selectDisplayModel?.providerType || ""}`} avatarUrl={selectDisplayModel?.avatarUrl ? buildPreviewImageUrl(selectDisplayModel.avatarUrl, 64) : undefined} size={18} />
|
||||
<span className="min-w-0 flex-1 truncate">{selectDisplayModel?.displayName || "自动模型"}</span>
|
||||
<ReferenceIcon name="chevron-down" size={18} />
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full leading-none ring-1",
|
||||
@ -18834,12 +18883,13 @@ export function CanvasChatPanel({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
{renderModelSelectContent()}
|
||||
</Select>
|
||||
) : null}
|
||||
|
||||
{estimatedPointsCost ? (
|
||||
{estimatedPointsCost && (isWorkspacePresentation || isVideoWorkspace || activeComposerMode !== "image") ? (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-0 flex h-7 max-w-[60px] shrink items-center gap-1 rounded-full px-2 text-[10px] sm:ml-1 sm:max-w-none sm:gap-1 sm:px-2 sm:text-[11px]",
|
||||
@ -18864,6 +18914,7 @@ export function CanvasChatPanel({
|
||||
<Button
|
||||
size={isWorkspacePresentation ? "default" : "icon"}
|
||||
className={cn(
|
||||
"lovart-composer-send",
|
||||
isWorkspacePresentation
|
||||
? "h-9 rounded-full px-3.5 transition-all duration-150 flex-shrink-0"
|
||||
: "h-8 w-8 rounded-full transition-all duration-150 flex-shrink-0",
|
||||
@ -18894,6 +18945,8 @@ export function CanvasChatPanel({
|
||||
? "停止处理并重新编辑"
|
||||
: isWorkspacePresentation || isDetailGenerationMode
|
||||
? getToolbarItem("chatStartGeneration").label
|
||||
: !isVideoWorkspace && activeComposerMode === "image" && estimatedPointsCost
|
||||
? `${getToolbarItem("chatSend").label} · ${estimatedPointsCost.title}`
|
||||
: getToolbarItem("chatSend").label
|
||||
}
|
||||
>
|
||||
@ -18927,6 +18980,11 @@ export function CanvasChatPanel({
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : !isWorkspacePresentation && !isVideoWorkspace && activeComposerMode === "image" && estimatedPointsCost ? (
|
||||
<>
|
||||
<CanvasToolbarIcon fallback={Sparkles} item={getToolbarItem("chatPoints")} referenceName="points" referenceSize={14} />
|
||||
<span>{estimatedPointsCost.cost}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CanvasToolbarIcon
|
||||
|
||||
54
components/canvas/CanvasComposerPromo.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
|
||||
type CanvasComposerPromoProps = {
|
||||
text: string;
|
||||
onDismiss: () => void;
|
||||
onActivate?: () => void;
|
||||
};
|
||||
|
||||
export function CanvasComposerPromo({
|
||||
text,
|
||||
onDismiss,
|
||||
onActivate,
|
||||
}: CanvasComposerPromoProps) {
|
||||
return (
|
||||
<div className="lovart-chat-promo lovart-chat-promo-connected">
|
||||
<div className="lovart-chat-promo-surface">
|
||||
<div className="lovart-chat-promo-row">
|
||||
<button
|
||||
type="button"
|
||||
className="lovart-chat-promo-message"
|
||||
onClick={onActivate}
|
||||
disabled={!onActivate}
|
||||
title={text}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="16"
|
||||
height="18"
|
||||
viewBox="0 0 24 28"
|
||||
className="lovart-chat-promo-badge"
|
||||
>
|
||||
<path d="M10 1.15a4 4 0 0 1 4 0l8 4.6a4 4 0 0 1 2 3.46v9.58a4 4 0 0 1-2 3.46l-8 4.6a4 4 0 0 1-4 0l-8-4.6A4 4 0 0 1 0 18.79V9.21a4 4 0 0 1 2-3.46Z" fill="#ddef65" />
|
||||
<path
|
||||
d="M12 5.5c1.2 5.3 2.2 6.3 7.5 7.5-5.3 1.2-6.3 2.2-7.5 7.5C10.8 15.2 9.8 14.2 4.5 13c5.3-1.2 6.3-2.2 7.5-7.5Z"
|
||||
fill="#111"
|
||||
/>
|
||||
</svg>
|
||||
<span>{text}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="lovart-chat-promo-close"
|
||||
aria-label="关闭优惠广告"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
<ReferenceIcon name="close" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
components/canvas/CanvasSurfaceHeader.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
type CanvasSurfaceHeaderProps = {
|
||||
isSaving: boolean;
|
||||
onAddImage: () => void;
|
||||
onHome: () => void;
|
||||
onOpenFiles: () => void;
|
||||
onSave: () => void;
|
||||
projectTitle: string;
|
||||
rightInset: number;
|
||||
};
|
||||
|
||||
const iconButtonClass =
|
||||
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
||||
|
||||
export function CanvasSurfaceHeader({
|
||||
isSaving,
|
||||
onAddImage,
|
||||
onHome,
|
||||
onOpenFiles,
|
||||
onSave,
|
||||
projectTitle,
|
||||
rightInset,
|
||||
}: CanvasSurfaceHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 z-20 flex h-[40px] items-center justify-between border-b border-[#ededed] bg-white px-3 dark:border-white/10 dark:bg-zinc-950"
|
||||
data-canvas-topbar="true"
|
||||
style={{ right: rightInset }}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
aria-label="返回首页"
|
||||
className={iconButtonClass}
|
||||
onClick={onHome}
|
||||
title="返回首页"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="branding" size={16} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="添加参考素材"
|
||||
className={iconButtonClass}
|
||||
onClick={onAddImage}
|
||||
title="添加参考素材"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="reference" size={16} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="查看项目文件"
|
||||
className={iconButtonClass}
|
||||
onClick={onOpenFiles}
|
||||
title="查看项目文件"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="brand" size={16} />
|
||||
</button>
|
||||
<div className="mx-1 h-4 w-px bg-[#ededed] dark:bg-white/10" />
|
||||
<span className="inline-flex h-7 min-w-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[13px] text-[#333333] dark:bg-white/10 dark:text-white">
|
||||
<ReferenceIcon name="page" size={16} />
|
||||
<span className="max-w-[160px] truncate">
|
||||
{projectTitle || "页面 1"}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
aria-label="添加到画布"
|
||||
className={iconButtonClass}
|
||||
onClick={onAddImage}
|
||||
title="添加到画布"
|
||||
type="button"
|
||||
>
|
||||
<ReferenceIcon name="plus" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import {
|
||||
ReferenceIcon,
|
||||
type ReferenceIconName,
|
||||
} from "@/components/lovart/reference-icon";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { CanvasToolbarItemConfig } from "@/lib/canvas-toolbar-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -12,13 +16,12 @@ type CanvasToolbarIconProps = {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
active?: boolean;
|
||||
referenceName?: ReferenceIconName;
|
||||
referenceSize?: number;
|
||||
};
|
||||
|
||||
function isSvgIconUrl(url: string) {
|
||||
return (
|
||||
/^data:image\/svg\+xml/i.test(url) ||
|
||||
/\.svg(?:[?#].*)?$/i.test(url)
|
||||
);
|
||||
return /^data:image\/svg\+xml/i.test(url) || /\.svg(?:[?#].*)?$/i.test(url);
|
||||
}
|
||||
|
||||
function stripImageFilterClasses(className?: string) {
|
||||
@ -42,6 +45,8 @@ export function CanvasToolbarIcon({
|
||||
className,
|
||||
style,
|
||||
active = false,
|
||||
referenceName,
|
||||
referenceSize = 18,
|
||||
}: CanvasToolbarIconProps) {
|
||||
const resolvedIconUrl = String(iconUrl || item?.iconUrl || "").trim();
|
||||
|
||||
@ -75,7 +80,9 @@ export function CanvasToolbarIcon({
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"object-contain",
|
||||
active ? "brightness-0 invert opacity-95" : "brightness-0 opacity-80 dark:invert",
|
||||
active
|
||||
? "brightness-0 invert opacity-95"
|
||||
: "brightness-0 opacity-80 dark:invert",
|
||||
className
|
||||
)}
|
||||
src={resolvedIconUrl}
|
||||
@ -84,5 +91,15 @@ export function CanvasToolbarIcon({
|
||||
);
|
||||
}
|
||||
|
||||
if (referenceName) {
|
||||
return (
|
||||
<ReferenceIcon
|
||||
name={referenceName}
|
||||
size={referenceSize}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <FallbackIcon className={className} style={style} />;
|
||||
}
|
||||
|
||||
@ -12,8 +12,12 @@ import { UiScreenNode } from "./nodes/UiScreenNode";
|
||||
import { VideoNode } from "./nodes/VideoNode";
|
||||
import { WorkflowNode } from "./nodes/WorkflowNode";
|
||||
import { CanvasToolbarIcon } from "./CanvasToolbarIcon";
|
||||
import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu";
|
||||
import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { toast } from "sonner";
|
||||
import { imageGenerationService } from "@/services/canvas";
|
||||
import {
|
||||
@ -28,6 +32,7 @@ import {
|
||||
LayoutTemplate,
|
||||
LayoutGrid,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Minus,
|
||||
Pin,
|
||||
Plus,
|
||||
@ -390,6 +395,11 @@ export function CanvasWorkspace({
|
||||
} | null;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentLayerRef = useRef<HTMLDivElement>(null);
|
||||
const contextImageInputRef = useRef<HTMLInputElement>(null);
|
||||
const contextVideoInputRef = useRef<HTMLInputElement>(null);
|
||||
const contextCanvasPointRef = useRef<Position>({ x: 0, y: 0 });
|
||||
const [blankContextPosition, setBlankContextPosition] = useState<Position | null>(null);
|
||||
const [isContextVideoUploading, setIsContextVideoUploading] = useState(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||||
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
||||
@ -727,10 +737,6 @@ export function CanvasWorkspace({
|
||||
canvasToolbarConfig,
|
||||
"imageLayoutReplace"
|
||||
);
|
||||
// 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLocalEditEnabled = true;
|
||||
// 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。
|
||||
const selectedImageLayoutReplaceEnabled = true;
|
||||
|
||||
// Store actions
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
@ -1106,42 +1112,61 @@ export function CanvasWorkspace({
|
||||
);
|
||||
|
||||
const fitAllImageNodesIntoViewport = useCallback(() => {
|
||||
const imageNodes = nodes.filter(
|
||||
(node) => node.type === "image" && Boolean((node.data as any)?.src)
|
||||
const fit = getCanvasFitViewport(
|
||||
nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)),
|
||||
containerSize, occlusionRight
|
||||
);
|
||||
if (imageNodes.length === 0) return;
|
||||
if (containerSize.width <= 0 || containerSize.height <= 0) return;
|
||||
if (fit) setViewport(fit);
|
||||
}, [containerSize, nodes, occlusionRight, setViewport]);
|
||||
|
||||
const padding = 80;
|
||||
const minX = Math.min(...imageNodes.map((node) => node.position.x));
|
||||
const minY = Math.min(...imageNodes.map((node) => node.position.y));
|
||||
const maxX = Math.max(
|
||||
...imageNodes.map((node) => node.position.x + node.size.width)
|
||||
);
|
||||
const maxY = Math.max(
|
||||
...imageNodes.map((node) => node.position.y + node.size.height)
|
||||
);
|
||||
const fitAllCanvasNodesIntoViewport = useCallback(() => {
|
||||
const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight);
|
||||
if (fit) setViewport(fit);
|
||||
}, [containerSize, nodes, occlusionRight, setViewport]);
|
||||
|
||||
const contentWidth = Math.max(1, maxX - minX + padding * 2);
|
||||
const contentHeight = Math.max(1, maxY - minY + padding * 2);
|
||||
const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => {
|
||||
if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return;
|
||||
if (!(event.target instanceof Element) || event.target.closest(
|
||||
'[data-canvas-node-id], [data-canvas-overlay], [data-canvas-no-drag="true"], button, input, textarea, select, a, [contenteditable="true"]'
|
||||
)) return;
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
contextCanvasPointRef.current = {
|
||||
x: (event.clientX - rect.left - viewportRef.current.x) / viewportRef.current.zoom,
|
||||
y: (event.clientY - rect.top - viewportRef.current.y) / viewportRef.current.zoom,
|
||||
};
|
||||
setBlankContextPosition({ x: event.clientX, y: event.clientY });
|
||||
setAgentSlotContextMenu(null);
|
||||
}, [canvasImageSelectionSession, hasBlockingDialogOpen, isWorkflowCanvas]);
|
||||
|
||||
const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight));
|
||||
const fitZoomX = visibleWidth / contentWidth;
|
||||
const fitZoomY = containerSize.height / contentHeight;
|
||||
const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY)));
|
||||
const zoomCanvasFromContextMenu = useCallback((factor: number) => {
|
||||
const current = useCanvasStore.getState().viewport;
|
||||
const zoom = Math.max(0.1, Math.min(4, current.zoom * factor));
|
||||
const centerX = Math.max(1, containerSize.width - occlusionRight) / 2;
|
||||
const centerY = containerSize.height / 2;
|
||||
const ratio = zoom / current.zoom;
|
||||
setViewport({ x: centerX - (centerX - current.x) * ratio, y: centerY - (centerY - current.y) * ratio, zoom });
|
||||
}, [containerSize, occlusionRight, setViewport]);
|
||||
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
const visibleCenterX = visibleWidth / 2;
|
||||
const nextViewportX = visibleCenterX - centerX * targetZoom;
|
||||
const nextViewportY = containerSize.height / 2 - centerY * targetZoom;
|
||||
|
||||
setViewport({
|
||||
x: nextViewportX,
|
||||
y: nextViewportY,
|
||||
zoom: targetZoom,
|
||||
});
|
||||
}, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]);
|
||||
React.useEffect(() => {
|
||||
if (isWorkflowCanvas) return;
|
||||
const handleFitShortcut = (event: KeyboardEvent) => {
|
||||
if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return;
|
||||
if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest(
|
||||
'input, textarea, [contenteditable="true"], [role="textbox"]'
|
||||
))) return;
|
||||
const active = document.activeElement;
|
||||
if (active && active !== document.body && !containerRef.current?.contains(active)
|
||||
&& !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return;
|
||||
event.preventDefault();
|
||||
fitAllCanvasNodesIntoViewport();
|
||||
setBlankContextPosition(null);
|
||||
};
|
||||
window.addEventListener("keydown", handleFitShortcut);
|
||||
return () => window.removeEventListener("keydown", handleFitShortcut);
|
||||
}, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]);
|
||||
|
||||
const getNodeInputAnchor = useCallback(
|
||||
(node: CanvasNode): Position => ({
|
||||
@ -1258,6 +1283,7 @@ export function CanvasWorkspace({
|
||||
if (canvasImageSelectionSession) return;
|
||||
if (hasBlockingDialogOpen()) return;
|
||||
setAgentSlotContextMenu(null);
|
||||
setBlankContextPosition(null);
|
||||
if (!isEventFromCanvasContainer(e.target)) return;
|
||||
if (suppressCanvasClickRef.current) {
|
||||
suppressCanvasClickRef.current = false;
|
||||
@ -1294,7 +1320,7 @@ export function CanvasWorkspace({
|
||||
!isPointOnNodeElement(e.target);
|
||||
|
||||
if (
|
||||
tool === 'pan' ||
|
||||
(tool === 'pan' && e.button === 0) ||
|
||||
e.button === 1 ||
|
||||
(e.button === 0 && e.altKey) ||
|
||||
shouldPanBlankWorkflowCanvas
|
||||
@ -1657,6 +1683,105 @@ export function CanvasWorkspace({
|
||||
}
|
||||
}, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]);
|
||||
|
||||
const getContextUploadClientPoint = useCallback(() => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const current = viewportRef.current;
|
||||
return {
|
||||
x: (rect?.left ?? 0) + current.x + contextCanvasPointRef.current.x * current.zoom,
|
||||
y: (rect?.top ?? 0) + current.y + contextCanvasPointRef.current.y * current.zoom,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const pasteCanvasText = useCallback((text: string, point: Position) => {
|
||||
if (!text.trim()) return;
|
||||
addNode({
|
||||
type: "text", position: point, size: { width: 320, height: 160 },
|
||||
data: { content: text, color: "#111827" }, connections: [],
|
||||
});
|
||||
}, [addNode]);
|
||||
|
||||
const uploadContextVideos = useCallback(async (files: File[]) => {
|
||||
if (!files.length || isContextVideoUploading) return;
|
||||
const position = { ...contextCanvasPointRef.current };
|
||||
setIsContextVideoUploading(true);
|
||||
try {
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (!["video/mp4", "video/quicktime", "video/webm"].includes(file.type)) {
|
||||
toast.error("请选择 MP4、MOV 或 WebM 视频");
|
||||
continue;
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
toast.error("视频文件不能超过 50MB");
|
||||
continue;
|
||||
}
|
||||
const loadingToast = toast.loading(`正在上传 ${file.name}`);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("purpose", "canvas-video-process");
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.url) throw new Error(result.error || "视频上传失败");
|
||||
addNode({
|
||||
type: "video", position: { x: position.x + index * 24, y: position.y + index * 24 },
|
||||
size: { width: 320, height: 180 },
|
||||
data: { src: result.url, assetId: result.assetId, status: "completed", prompt: file.name,
|
||||
metadata: { source: "local-upload", fileName: file.name, contentType: file.type } },
|
||||
connections: [],
|
||||
});
|
||||
toast.success("视频已添加到画布");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "视频上传失败,请重试");
|
||||
} finally {
|
||||
toast.dismiss(loadingToast);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsContextVideoUploading(false);
|
||||
}
|
||||
}, [addNode, isContextVideoUploading]);
|
||||
|
||||
const pasteFromContextMenu = useCallback(async () => {
|
||||
const pastePoint = { ...contextCanvasPointRef.current };
|
||||
const clientPoint = getContextUploadClientPoint();
|
||||
try {
|
||||
const files: File[] = [];
|
||||
let text = "";
|
||||
if (typeof navigator.clipboard?.read === "function") {
|
||||
const items = await navigator.clipboard.read();
|
||||
for (const [index, item] of items.entries()) {
|
||||
const imageType = item.types.find((type) => type.startsWith("image/"));
|
||||
if (imageType) {
|
||||
const blob = await item.getType(imageType);
|
||||
files.push(new File([blob], `粘贴图片-${Date.now()}-${index}.${imageType.split("/")[1]}`, { type: imageType }));
|
||||
} else if (item.types.includes("text/plain")) {
|
||||
text += await (await item.getType("text/plain")).text();
|
||||
}
|
||||
}
|
||||
} else if (typeof navigator.clipboard?.readText === "function") {
|
||||
text = await navigator.clipboard.readText();
|
||||
} else {
|
||||
throw new Error("Clipboard unavailable");
|
||||
}
|
||||
if (files.length) {
|
||||
await processDroppedFiles(files, clientPoint.x, clientPoint.y);
|
||||
} else if (text.trim()) {
|
||||
pasteCanvasText(text, pastePoint);
|
||||
} else {
|
||||
toast.info("剪贴板中没有可粘贴的图片或文字");
|
||||
}
|
||||
} catch {
|
||||
toast.info(`无法读取剪贴板,请使用 ${isMacPlatform ? "⌘V" : "Ctrl+V"} 粘贴`);
|
||||
}
|
||||
}, [getContextUploadClientPoint, isMacPlatform, pasteCanvasText, processDroppedFiles]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!blankContextPosition) return;
|
||||
const close = () => setBlankContextPosition(null);
|
||||
window.addEventListener("resize", close);
|
||||
return () => window.removeEventListener("resize", close);
|
||||
}, [blankContextPosition]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleWindowDrop = async (e: DragEvent) => {
|
||||
if (!isFileDrag(e.dataTransfer)) return;
|
||||
@ -1719,24 +1844,44 @@ export function CanvasWorkspace({
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return;
|
||||
const activeElement = document.activeElement;
|
||||
const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]'));
|
||||
if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
activeElement instanceof HTMLElement &&
|
||||
activeElement !== document.body &&
|
||||
!container.contains(activeElement)
|
||||
!container.contains(activeElement) && !fromContextMenu
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = extractImageFilesFromDataTransfer(e.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
const text = e.clipboardData?.getData("text/plain") || "";
|
||||
if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return;
|
||||
|
||||
e.preventDefault();
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (fromContextMenu) {
|
||||
setBlankContextPosition(null);
|
||||
if (files.length) {
|
||||
const point = getContextUploadClientPoint();
|
||||
await processDroppedFiles(files, point.x, point.y);
|
||||
} else {
|
||||
pasteCanvasText(text, { ...contextCanvasPointRef.current });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (files.length === 0) {
|
||||
const current = viewportRef.current;
|
||||
pasteCanvasText(text, {
|
||||
x: (rect.width / 2 - current.x) / current.zoom,
|
||||
y: (rect.height / 2 - current.y) / current.zoom,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await processDroppedFiles(
|
||||
files,
|
||||
rect.left + rect.width / 2,
|
||||
@ -1746,7 +1891,7 @@ export function CanvasWorkspace({
|
||||
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [hasBlockingDialogOpen, processDroppedFiles]);
|
||||
}, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]);
|
||||
|
||||
const handleDownloadSelectedImages = useCallback(async () => {
|
||||
if (selectedImageNodes.length === 0) {
|
||||
@ -2269,12 +2414,47 @@ export function CanvasWorkspace({
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onDoubleClick={handleBlankCanvasDoubleClick}
|
||||
onContextMenu={handleBlankCanvasContextMenu}
|
||||
onWheel={handleWheel}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{!isWorkflowCanvas && (
|
||||
<>
|
||||
<input
|
||||
ref={contextImageInputRef} type="file" accept="image/*" multiple className="hidden"
|
||||
aria-label="从右键菜单上传图片"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
const point = getContextUploadClientPoint();
|
||||
void processDroppedFiles(files, point.x, point.y);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={contextVideoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" multiple className="hidden"
|
||||
aria-label="从右键菜单上传视频"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
void uploadContextVideos(files);
|
||||
}}
|
||||
/>
|
||||
<CanvasBlankContextMenu
|
||||
position={blankContextPosition} isMac={isMacPlatform} videoUploading={isContextVideoUploading}
|
||||
onClose={() => setBlankContextPosition(null)}
|
||||
onUploadImage={() => contextImageInputRef.current?.click()}
|
||||
onUploadVideo={() => contextVideoInputRef.current?.click()}
|
||||
onPaste={() => { void pasteFromContextMenu(); }}
|
||||
onZoomIn={() => zoomCanvasFromContextMenu(1.2)}
|
||||
onZoomOut={() => zoomCanvasFromContextMenu(1 / 1.2)}
|
||||
onFitAll={fitAllCanvasNodesIntoViewport}
|
||||
onResetZoom={() => setViewport({ x: 0, y: 0, zoom: 1 })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isWorkflowCanvas ? (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
|
||||
@ -2566,10 +2746,14 @@ export function CanvasWorkspace({
|
||||
)}
|
||||
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
|
||||
<div
|
||||
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
|
||||
className={cn(
|
||||
"absolute left-4 z-30 flex items-center gap-2",
|
||||
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
|
||||
)}
|
||||
data-canvas-overlay
|
||||
data-canvas-selection-actions="true"
|
||||
>
|
||||
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
|
||||
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
@ -2591,7 +2775,7 @@ export function CanvasWorkspace({
|
||||
{selectedImageLocalEditItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedImageLayoutReplaceEnabled &&
|
||||
{isWorkflowCanvas &&
|
||||
selectedImageNodes.length >= 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
@ -2620,27 +2804,48 @@ export function CanvasWorkspace({
|
||||
{selectedImageLayoutReplaceItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
|
||||
isWorkflowCanvas &&
|
||||
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||
)}
|
||||
onClick={handleDownloadSelectedImages}
|
||||
disabled={isDownloadSubmitting}
|
||||
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-3.5 w-3.5"
|
||||
fallback={Download}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||
/>
|
||||
{isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size={isWorkflowCanvas ? "sm" : "icon"}
|
||||
className={cn(
|
||||
isWorkflowCanvas
|
||||
? "h-8 gap-1.5 rounded-full border border-[#dbe2ee] bg-white px-3 text-xs text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||
: "h-[32px] w-[32px] rounded-[8px] border border-[#e9e9e9] bg-white p-0 text-[#333333] shadow-[0_1px_3px_rgba(0,0,0,0.035)] hover:bg-[#f5f5f5] dark:border-white/10 dark:bg-zinc-950 dark:text-white dark:hover:bg-white/10"
|
||||
)}
|
||||
onClick={handleDownloadSelectedImages}
|
||||
disabled={isDownloadSubmitting}
|
||||
aria-label={
|
||||
isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
||||
}
|
||||
>
|
||||
{!isWorkflowCanvas && isDownloadSubmitting ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"}
|
||||
fallback={Download}
|
||||
referenceName={isWorkflowCanvas ? undefined : "image-download"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||
/>
|
||||
)}
|
||||
{isWorkflowCanvas
|
||||
? isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
||||
: null}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
452
components/canvas/ReferenceImageToolbar.tsx
Normal file
@ -0,0 +1,452 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, X } from "lucide-react";
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
saveImageToolbarPreferences,
|
||||
useImageToolbarPreferences,
|
||||
type ImageToolbarPreferences,
|
||||
} from "@/lib/canvas-image-toolbar-preferences";
|
||||
|
||||
type ToolProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
"data-tool-key"?: string;
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
type Tool = ReactElement<ToolProps>;
|
||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||
referencePresentation: boolean;
|
||||
defaultPinnedKeys: string[];
|
||||
};
|
||||
|
||||
function toolLabel(tool: Tool) {
|
||||
return tool.props.title || "";
|
||||
}
|
||||
function toolIcon(tool: Tool, size: number) {
|
||||
const icon = Children.toArray(tool.props.children).find(isValidElement);
|
||||
if (
|
||||
!isValidElement<{
|
||||
className?: string;
|
||||
style?: object;
|
||||
referenceSize?: number;
|
||||
}>(icon)
|
||||
)
|
||||
return null;
|
||||
return cloneElement(icon, {
|
||||
className: "shrink-0",
|
||||
style: { width: size, height: size },
|
||||
...(icon.type === CanvasToolbarIcon ? { referenceSize: size } : {}),
|
||||
});
|
||||
}
|
||||
function toolCredit(tool: Tool) {
|
||||
return Children.toArray(tool.props.children).find(
|
||||
(child) =>
|
||||
isValidElement<{ compact?: boolean; label?: string }>(child) &&
|
||||
child.props.compact &&
|
||||
child.props.label
|
||||
) as
|
||||
| ReactElement<{ compact?: boolean; label?: string; className?: string }>
|
||||
| undefined;
|
||||
}
|
||||
function tooltipButton(tool: Tool, showNames: boolean) {
|
||||
const name = toolLabel(tool);
|
||||
// Clone the existing action so its handler, busy state and administrator icon override remain intact.
|
||||
const button = cloneElement(tool, {
|
||||
title: undefined,
|
||||
"aria-label": name,
|
||||
children: (
|
||||
<>
|
||||
{toolIcon(tool, 25)}
|
||||
{showNames && <span className="lovart-image-tool-label">{name}</span>}
|
||||
</>
|
||||
),
|
||||
});
|
||||
return (
|
||||
<Tooltip key={tool.props["data-tool-key"] || name}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="lovart-image-tool-tooltip"
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
{name}
|
||||
{toolCredit(tool) &&
|
||||
cloneElement(toolCredit(tool)!, {
|
||||
compact: false,
|
||||
className: "lovart-image-tool-cost",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const ReferenceImageToolbar = forwardRef<HTMLDivElement, Props>(
|
||||
function ReferenceImageToolbar(
|
||||
{ referencePresentation, defaultPinnedKeys, children, ...rootProps },
|
||||
ref
|
||||
) {
|
||||
const saved = useImageToolbarPreferences();
|
||||
const [draft, setDraft] = useState<ImageToolbarPreferences | null>(null);
|
||||
const [draggedKey, setDraggedKey] = useState<string | null>(null);
|
||||
const elements = Children.toArray(children).filter(
|
||||
isValidElement
|
||||
) as ReactElement<ToolProps>[];
|
||||
const tools = elements.filter(
|
||||
(element) =>
|
||||
element.props["data-tool-key"] &&
|
||||
!["addToChat", "download"].includes(element.props["data-tool-key"]!)
|
||||
);
|
||||
const toolMap = new Map(
|
||||
tools.map((tool) => [tool.props["data-tool-key"]!, tool])
|
||||
);
|
||||
const preferences = saved || {
|
||||
pinnedKeys: defaultPinnedKeys,
|
||||
showNames: false,
|
||||
};
|
||||
const pinned = preferences.pinnedKeys.filter((key) => toolMap.has(key));
|
||||
const moreTools = tools.filter(
|
||||
(tool) => !pinned.includes(tool.props["data-tool-key"]!)
|
||||
);
|
||||
const addToChat = elements.find(
|
||||
(element) => element.props["data-tool-key"] === "addToChat"
|
||||
);
|
||||
const download = elements.find(
|
||||
(element) => element.props["data-tool-key"] === "download"
|
||||
);
|
||||
const originalMenu = elements.find(
|
||||
(element) => element.type === DropdownMenu
|
||||
);
|
||||
const menuChildren = originalMenu
|
||||
? (Children.toArray(originalMenu.props.children).filter(
|
||||
isValidElement
|
||||
) as ReactElement<{ children?: ReactNode }>[])
|
||||
: [];
|
||||
const menuTrigger = menuChildren.find(
|
||||
(element) => element.type === DropdownMenuTrigger
|
||||
);
|
||||
const moreButton =
|
||||
menuTrigger &&
|
||||
(Children.toArray(menuTrigger.props.children).find(isValidElement) as
|
||||
| Tool
|
||||
| undefined);
|
||||
const extras = menuChildren.find(
|
||||
(element) => element.type === DropdownMenuContent
|
||||
)?.props.children;
|
||||
const openCustomizer = () =>
|
||||
setDraft({
|
||||
pinnedKeys: [...preferences.pinnedKeys],
|
||||
showNames: preferences.showNames,
|
||||
});
|
||||
const toggle = (key: string) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
pinnedKeys: current.pinnedKeys.includes(key)
|
||||
? current.pinnedKeys.filter((item) => item !== key)
|
||||
: [...current.pinnedKeys, key],
|
||||
}
|
||||
: current
|
||||
);
|
||||
const move = (key: string, target: string) =>
|
||||
setDraft((current) => {
|
||||
if (!current || key === target) return current;
|
||||
const next = [...current.pinnedKeys];
|
||||
const from = next.indexOf(key),
|
||||
to = next.indexOf(target);
|
||||
if (from < 0 || to < 0) return current;
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, key);
|
||||
return { ...current, pinnedKeys: next };
|
||||
});
|
||||
if (!referencePresentation)
|
||||
return (
|
||||
<div {...rootProps} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
{...rootProps}
|
||||
ref={ref}
|
||||
style={{ ...rootProps.style, overflowX: "auto" }}
|
||||
data-show-tool-names={preferences.showNames ? "true" : "false"}
|
||||
>
|
||||
{addToChat && tooltipButton(addToChat, preferences.showNames)}
|
||||
{addToChat && <div className="w-px" />}
|
||||
{pinned.map((key) =>
|
||||
tooltipButton(toolMap.get(key)!, preferences.showNames)
|
||||
)}
|
||||
<DropdownMenu>
|
||||
{moreButton && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{cloneElement(moreButton, {
|
||||
title: undefined,
|
||||
"aria-label": toolLabel(moreButton),
|
||||
children: toolIcon(moreButton, 25),
|
||||
})}
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="lovart-image-tool-tooltip"
|
||||
sideOffset={8}
|
||||
>
|
||||
{toolLabel(moreButton)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<DropdownMenuContent
|
||||
className="lovart-image-action-menu lovart-image-custom-more"
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
{moreTools.map((tool) => (
|
||||
<DropdownMenuItem
|
||||
key={tool.props["data-tool-key"]}
|
||||
disabled={tool.props.disabled}
|
||||
onClick={(event) =>
|
||||
tool.props.onClick?.(
|
||||
event as unknown as MouseEvent<HTMLButtonElement>
|
||||
)
|
||||
}
|
||||
>
|
||||
{toolIcon(tool, 18)}
|
||||
<span>{toolLabel(tool)}</span>
|
||||
{toolCredit(tool) &&
|
||||
cloneElement(toolCredit(tool)!, {
|
||||
compact: false,
|
||||
className: "lovart-image-tool-cost",
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{extras}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="lovart-customize-entry"
|
||||
onSelect={openCustomizer}
|
||||
>
|
||||
自定义工具栏
|
||||
<ChevronRight size={14} />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{download && (
|
||||
<>
|
||||
<div className="w-px" />
|
||||
{tooltipButton(download, preferences.showNames)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
open={Boolean(draft)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDraft(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="lovart-toolbar-customizer"
|
||||
overlayClassName="lovart-toolbar-customizer-overlay"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="lovart-customizer-header">
|
||||
<div>
|
||||
<DialogTitle>自定义工具栏</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择你想在编辑栏中使用的工具
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="lovart-customizer-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="恢复默认工具栏"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
pinnedKeys: [...defaultPinnedKeys],
|
||||
showNames: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ReferenceIcon name="toolbar-reset" size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭自定义工具栏"
|
||||
onClick={() => setDraft(null)}
|
||||
>
|
||||
<ReferenceIcon name="toolbar-close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="lovart-customizer-body">
|
||||
<div
|
||||
className="lovart-customizer-preview"
|
||||
aria-label="工具栏预览"
|
||||
>
|
||||
<div className="lovart-customizer-preview-bar">
|
||||
{addToChat && (
|
||||
<span className="lovart-preview-fixed">
|
||||
{toolIcon(addToChat, 18)}
|
||||
{draft?.showNames && toolLabel(addToChat)}
|
||||
</span>
|
||||
)}
|
||||
{draft?.pinnedKeys
|
||||
.filter((key) => toolMap.has(key))
|
||||
.map((key, index, keys) => {
|
||||
const tool = toolMap.get(key)!;
|
||||
return (
|
||||
<div
|
||||
className="lovart-preview-chip"
|
||||
key={key}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setDraggedKey(key);
|
||||
event.dataTransfer.setData("text/plain", key);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={() => setDraggedKey(null)}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
if (draggedKey) move(draggedKey, key);
|
||||
setDraggedKey(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="lovart-preview-handle"
|
||||
aria-label={`${toolLabel(tool)},按左右方向键调整顺序`}
|
||||
onKeyDown={(event) => {
|
||||
const offset =
|
||||
event.key === "ArrowLeft"
|
||||
? -1
|
||||
: event.key === "ArrowRight"
|
||||
? 1
|
||||
: 0;
|
||||
if (offset && keys[index + offset]) {
|
||||
event.preventDefault();
|
||||
move(key, keys[index + offset]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{toolIcon(tool, 18)}
|
||||
{draft.showNames && <span>{toolLabel(tool)}</span>}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除${toolLabel(tool)}`}
|
||||
onClick={() => toggle(key)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lovart-customizer-tools">
|
||||
{tools.map((tool) => {
|
||||
const key = tool.props["data-tool-key"]!;
|
||||
const isPinned = Boolean(draft?.pinnedKeys.includes(key));
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-label={`${isPinned ? "取消固定" : "固定"}${toolLabel(tool)}`}
|
||||
aria-pressed={isPinned}
|
||||
onClick={() => toggle(key)}
|
||||
>
|
||||
{toolIcon(tool, 18)}
|
||||
<span>{toolLabel(tool)}</span>
|
||||
<ReferenceIcon
|
||||
name={
|
||||
isPinned
|
||||
? "toolbar-pin-filled"
|
||||
: "toolbar-pin-outline"
|
||||
}
|
||||
size={13}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<footer className="lovart-customizer-footer">
|
||||
<label>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-label="显示工具名称"
|
||||
aria-checked={Boolean(draft?.showNames)}
|
||||
className="lovart-customizer-switch"
|
||||
onClick={() =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, showNames: !current.showNames }
|
||||
: current
|
||||
)
|
||||
}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
显示工具名称
|
||||
</label>
|
||||
<div>
|
||||
<button type="button" onClick={() => setDraft(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="lovart-customizer-save"
|
||||
onClick={() => {
|
||||
if (draft && saveImageToolbarPreferences(draft))
|
||||
setDraft(null);
|
||||
else
|
||||
toast.error("无法保存工具栏设置,请检查浏览器存储权限");
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@ -42,7 +42,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { toast } from "sonner";
|
||||
@ -56,6 +56,7 @@ import { CanvasLayoutReplaceBatchDialog } from "@/components/canvas/CanvasLayout
|
||||
import { TextEditColorField } from "@/components/canvas/TextEditColorField";
|
||||
import { DetailSurfaceOverlayRenderer } from "@/components/canvas/DetailSurfaceOverlayRenderer";
|
||||
import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||
import {
|
||||
@ -1307,6 +1308,8 @@ export function ImageNode({
|
||||
}: ImageNodeProps) {
|
||||
const router = useRouter();
|
||||
const routeParams = useParams();
|
||||
const pathname = usePathname();
|
||||
const useReferenceImageToolbar = /^\/canvas\/[^/]+\/?$/.test(pathname);
|
||||
const canvasId = Array.isArray(routeParams?.id)
|
||||
? routeParams.id[0] || ""
|
||||
: typeof routeParams?.id === "string"
|
||||
@ -1532,9 +1535,11 @@ export function ImageNode({
|
||||
.map((action) => action.key)
|
||||
);
|
||||
const isSelectedImageActionInline = (key: SelectedImageToolbarActionKey) =>
|
||||
selectedImageInlineActionKeys.has(key);
|
||||
useReferenceImageToolbar
|
||||
? enabledSelectedImageToolbarActions.some((action) => action.key === key)
|
||||
: selectedImageInlineActionKeys.has(key);
|
||||
const isSelectedImageActionInMore = (key: SelectedImageToolbarActionKey) =>
|
||||
selectedImageMoreActionKeys.has(key);
|
||||
!useReferenceImageToolbar && selectedImageMoreActionKeys.has(key);
|
||||
const hasHoverImageMorePrimaryActions =
|
||||
imageRemoveBackgroundEnabled ||
|
||||
imageRemoveWatermarkEnabled ||
|
||||
@ -1560,6 +1565,7 @@ export function ImageNode({
|
||||
const hasSelectedImageMorePrimaryActions =
|
||||
selectedImageMoreActionKeys.size > 0;
|
||||
const hasSelectedImageMoreMenu =
|
||||
useReferenceImageToolbar ||
|
||||
hasSelectedImageMorePrimaryActions ||
|
||||
(imageNodeMoreEnabled && hasCanvasAgentAnnotations) ||
|
||||
(imageNodeMoreEnabled && imageDeleteEnabled);
|
||||
@ -9640,6 +9646,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageLayoutReplaceItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
@ -10192,8 +10200,12 @@ export function ImageNode({
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div
|
||||
className="fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62"
|
||||
<ReferenceImageToolbar
|
||||
referencePresentation={useReferenceImageToolbar}
|
||||
defaultPinnedKeys={enabledSelectedImageToolbarActions.slice(0, selectedImageInlineActionCount).map((action) => action.key)}
|
||||
className={cn("fixed z-[9999] flex items-center gap-0.5 rounded-[24px] border border-white/70 bg-white/82 px-2 py-1.5 text-gray-800 shadow-[0_18px_52px_rgba(15,23,42,0.20)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-[-10px] before:-z-10 before:rounded-[32px] before:bg-white/35 before:shadow-[0_18px_64px_rgba(15,23,42,0.18)] before:backdrop-blur-2xl before:content-[''] supports-[backdrop-filter]:bg-white/72 dark:border-white/12 dark:bg-[#08090c]/72 dark:text-zinc-100 dark:shadow-[0_24px_72px_rgba(0,0,0,0.58)] dark:before:bg-black/35 dark:before:shadow-[0_24px_80px_rgba(0,0,0,0.62)] supports-[backdrop-filter]:dark:bg-[#08090c]/62", useReferenceImageToolbar && "lovart-image-action-bar")}
|
||||
data-selected-image-toolbar="true"
|
||||
data-reference-presentation={useReferenceImageToolbar ? "true" : undefined}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
@ -10217,7 +10229,8 @@ export function ImageNode({
|
||||
className="mr-1 inline-flex h-10 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-xl bg-slate-900 px-3 font-semibold text-[13px] text-white hover:bg-slate-800 hover:text-white dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-200"
|
||||
disabled={isProcessing || isAddingToChat}
|
||||
onClick={handleAddToChat}
|
||||
title={imageAddToChatItem.label}
|
||||
data-tool-key="addToChat"
|
||||
title={imageAddToChatItem.label}
|
||||
variant="ghost"
|
||||
>
|
||||
{isAddingToChat ? (
|
||||
@ -10228,6 +10241,8 @@ export function ImageNode({
|
||||
className="h-4 w-4 dark:invert-0"
|
||||
fallback={MessageSquarePlus}
|
||||
item={imageAddToChatItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
)}
|
||||
<span className="whitespace-nowrap">{imageAddToChatItem.label}</span>
|
||||
@ -10242,6 +10257,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("pickObject") ? (
|
||||
<Button
|
||||
data-tool-key="pickObject"
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing || isPickerLoading || isThisNodePicking}
|
||||
onClick={handleStartPicking}
|
||||
@ -10252,6 +10268,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Crosshair}
|
||||
item={imagePickObjectItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-move-object" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imagePickObjectItem.label}
|
||||
</Button>
|
||||
@ -10259,6 +10277,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("enhance") ? (
|
||||
<Button
|
||||
data-tool-key="enhance"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("enhance")}
|
||||
@ -10269,6 +10288,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
item={imageEnhanceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-upscale" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageEnhanceItem.label}
|
||||
<ImageActionCreditBadge compact label={enhanceCostShortLabel} />
|
||||
@ -10277,6 +10298,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("removeBackground") ? (
|
||||
<Button
|
||||
data-tool-key="removeBackground"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("rmbg")}
|
||||
@ -10287,6 +10309,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Scissors}
|
||||
item={imageRemoveBackgroundItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-remove-background" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageRemoveBackgroundItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10298,6 +10322,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("aiWashQuick") ? (
|
||||
<Button
|
||||
data-tool-key="aiWashQuick"
|
||||
className={cn(
|
||||
"group relative h-10 rounded-xl px-3 font-medium text-[13px] transition",
|
||||
aiWashQuickPanelOpen
|
||||
@ -10307,6 +10332,7 @@ export function ImageNode({
|
||||
disabled={isProcessing || isSubmittingAiWashQuick}
|
||||
onClick={() => handleAction("ai-wash")}
|
||||
title={aiWashQuickModeLabel}
|
||||
data-reference-active={aiWashQuickPanelOpen ? "true" : undefined}
|
||||
variant="ghost"
|
||||
>
|
||||
{isSubmittingAiWashQuick ? (
|
||||
@ -10317,6 +10343,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
)}
|
||||
{aiWashQuickModeLabel}
|
||||
@ -10329,6 +10357,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("textEdit") ? (
|
||||
<Button
|
||||
data-tool-key="textEdit"
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing || isDetectingText || isApplyingTextEdit}
|
||||
onClick={() => handleAction("text-edit")}
|
||||
@ -10339,6 +10368,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Type}
|
||||
item={imageTextEditItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-edit-text" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageTextEditItem.label}
|
||||
</Button>
|
||||
@ -10346,6 +10377,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("boxCutout") ? (
|
||||
<Button
|
||||
data-tool-key="boxCutout"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("box-cutout")}
|
||||
@ -10356,6 +10388,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Scan}
|
||||
item={imageBoxCutoutItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageBoxCutoutItem.label}
|
||||
<ImageActionCreditBadge compact label={boxCutoutCostShortLabel} />
|
||||
@ -10364,6 +10398,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("localEdit") ? (
|
||||
<Button
|
||||
data-tool-key="localEdit"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing || isSubmittingLocalEdit}
|
||||
onClick={() => handleAction("local-edit")}
|
||||
@ -10374,6 +10409,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Wand2}
|
||||
item={imageLocalEditItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageLocalEditItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10385,6 +10422,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("layoutReplace") ? (
|
||||
<Button
|
||||
data-tool-key="layoutReplace"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("layout-replace")}
|
||||
@ -10395,6 +10433,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageLayoutReplaceItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10406,6 +10446,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("removeWatermark") ? (
|
||||
<Button
|
||||
data-tool-key="removeWatermark"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("watermark")}
|
||||
@ -10416,6 +10457,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Eraser}
|
||||
item={imageRemoveWatermarkItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageRemoveWatermarkItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10427,6 +10470,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("resize") ? (
|
||||
<Button
|
||||
data-tool-key="resize"
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("resize")}
|
||||
@ -10444,6 +10488,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("multiAngle") ? (
|
||||
<Button
|
||||
data-tool-key="multiAngle"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("angle")}
|
||||
@ -10454,6 +10499,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Box}
|
||||
item={imageMultiAngleItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageMultiAngleItem.label}
|
||||
<ImageActionCreditBadge
|
||||
@ -10465,6 +10512,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("generate3dModel") ? (
|
||||
<Button
|
||||
data-tool-key="generate3dModel"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("generate-3d-model")}
|
||||
@ -10486,6 +10534,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("generate3dWorld") ? (
|
||||
<Button
|
||||
data-tool-key="generate3dWorld"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("generate-3d-world")}
|
||||
@ -10507,6 +10556,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("layerSplit") ? (
|
||||
<Button
|
||||
data-tool-key="layerSplit"
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("layer-split")}
|
||||
@ -10517,6 +10567,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Layers}
|
||||
item={imageLayerSplitItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageLayerSplitItem.label}
|
||||
</Button>
|
||||
@ -10524,6 +10576,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("extend") ? (
|
||||
<Button
|
||||
data-tool-key="extend"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("extend")}
|
||||
@ -10534,6 +10587,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Expand}
|
||||
item={imageExtendItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageExtendItem.label}
|
||||
<ImageActionCreditBadge compact label={extendCostShortLabel} />
|
||||
@ -10542,6 +10597,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("crop") ? (
|
||||
<Button
|
||||
data-tool-key="crop"
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("crop")}
|
||||
@ -10552,6 +10608,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={Crop}
|
||||
item={imageCropItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageCropItem.label}
|
||||
</Button>
|
||||
@ -10559,6 +10617,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("adjust") ? (
|
||||
<Button
|
||||
data-tool-key="adjust"
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("adjust")}
|
||||
@ -10569,6 +10628,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={SlidersHorizontal}
|
||||
item={imageAdjustItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageAdjustItem.label}
|
||||
</Button>
|
||||
@ -10576,6 +10637,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("flipRotate") ? (
|
||||
<Button
|
||||
data-tool-key="flipRotate"
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("flip-rotate")}
|
||||
@ -10586,6 +10648,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={FlipHorizontal2}
|
||||
item={imageFlipRotateItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageFlipRotateItem.label}
|
||||
</Button>
|
||||
@ -10593,6 +10657,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("promptRegenerate") ? (
|
||||
<Button
|
||||
data-tool-key="promptRegenerate"
|
||||
className="group relative h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("prompt-regenerate")}
|
||||
@ -10614,6 +10679,7 @@ export function ImageNode({
|
||||
|
||||
{isSelectedImageActionInline("note") ? (
|
||||
<Button
|
||||
data-tool-key="note"
|
||||
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
|
||||
disabled={isProcessing}
|
||||
onClick={() => handleAction("note")}
|
||||
@ -10624,6 +10690,8 @@ export function ImageNode({
|
||||
className="mr-1.5 h-4 w-4"
|
||||
fallback={StickyNote}
|
||||
item={imageNoteItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
{imageNoteItem.label}
|
||||
</Button>
|
||||
@ -10648,12 +10716,14 @@ export function ImageNode({
|
||||
className="h-4 w-4"
|
||||
fallback={MoreHorizontal}
|
||||
item={imageNodeMoreItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-more" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="center"
|
||||
className="bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100"
|
||||
className={cn("bg-white opacity-100 backdrop-blur-none dark:bg-zinc-950 dark:text-zinc-100", useReferenceImageToolbar && "lovart-image-action-menu")}
|
||||
side="bottom"
|
||||
>
|
||||
{isSelectedImageActionInMore("boxCutout") ? (
|
||||
@ -10662,6 +10732,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Scan}
|
||||
item={imageBoxCutoutItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-vector" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageBoxCutoutItem.label}</span>
|
||||
<ImageActionCreditBadge label={boxCutoutCostShortLabel} />
|
||||
@ -10673,6 +10745,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Wand2}
|
||||
item={imageLocalEditItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-local-edit" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageLocalEditItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
@ -10686,6 +10760,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageLayoutReplaceItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
@ -10697,6 +10773,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Eraser}
|
||||
item={imageRemoveWatermarkItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-erase" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageRemoveWatermarkItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
@ -10720,6 +10798,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Box}
|
||||
item={imageMultiAngleItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-angle" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageMultiAngleItem.label}</span>
|
||||
<ImageActionCreditBadge
|
||||
@ -10761,6 +10841,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Layers}
|
||||
item={imageLayerSplitItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-extract-layer" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageLayerSplitItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10771,6 +10853,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Expand}
|
||||
item={imageExtendItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-outpaint" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageExtendItem.label}</span>
|
||||
<ImageActionCreditBadge label={extendCostShortLabel} />
|
||||
@ -10782,6 +10866,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={Crop}
|
||||
item={imageCropItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-crop" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageCropItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10792,6 +10878,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={SlidersHorizontal}
|
||||
item={imageAdjustItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-adjust" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageAdjustItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10802,6 +10890,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={FlipHorizontal2}
|
||||
item={imageFlipRotateItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-flip-rotate" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageFlipRotateItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10827,6 +10917,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={StickyNote}
|
||||
item={imageNoteItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-mark" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
{imageNoteItem.label}
|
||||
</DropdownMenuItem>
|
||||
@ -10866,7 +10958,8 @@ export function ImageNode({
|
||||
void handleDownload();
|
||||
}}
|
||||
size="icon"
|
||||
title={imageDownloadItem.label}
|
||||
data-tool-key="download"
|
||||
title={imageDownloadItem.label}
|
||||
variant="ghost"
|
||||
>
|
||||
{isDownloadingOriginalImage ? (
|
||||
@ -10876,11 +10969,13 @@ export function ImageNode({
|
||||
className="h-4 w-4"
|
||||
fallback={Download}
|
||||
item={imageDownloadItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-download" : undefined}
|
||||
referenceSize={25}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</ReferenceImageToolbar>
|
||||
</TooltipProvider>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import "@/public/vendor/drawnix.css";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
|
||||
30
components/flow-board/mind-marker-board.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { PlaitBoard } from "@plait/core";
|
||||
import type { withEmoji } from "@plait/mind";
|
||||
import { getXmindMarker } from "@/lib/flow-board/xmind-markers";
|
||||
|
||||
/** Use the native marker artwork in Plait's existing emoji geometry and hit targets. */
|
||||
export function withXmindMarkers(board: PlaitBoard) {
|
||||
const markerBoard = board as ReturnType<typeof withEmoji>;
|
||||
const renderEmoji = markerBoard.renderEmoji;
|
||||
markerBoard.renderEmoji = (container, props) => {
|
||||
const marker = getXmindMarker(props.emojiItem.name);
|
||||
if (!marker) return renderEmoji(container, props);
|
||||
const image = document.createElement("img");
|
||||
image.className = "mind-node-emoji xmind-topic-marker";
|
||||
image.draggable = false;
|
||||
let currentProps = props;
|
||||
const update = (next: Partial<typeof props>) => {
|
||||
currentProps = { ...currentProps, ...next };
|
||||
const current = getXmindMarker(currentProps.emojiItem.name);
|
||||
image.src = current?.asset || marker.asset;
|
||||
image.alt = current?.name || marker.name;
|
||||
image.dataset.markerId = current?.id || marker.id;
|
||||
image.style.width = `${currentProps.fontSize}px`;
|
||||
image.style.height = `${currentProps.fontSize}px`;
|
||||
};
|
||||
update(props);
|
||||
container.append(image);
|
||||
return { update, destroy: () => image.remove() };
|
||||
};
|
||||
return board;
|
||||
}
|
||||
@ -16,6 +16,8 @@ import {
|
||||
specialSubtreeBounds,
|
||||
} from "@/lib/flow-board/mind-special-layout";
|
||||
|
||||
import { measureXmindTopics } from "@/lib/flow-board/xmind-typography";
|
||||
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
type TableCell = {
|
||||
x: number;
|
||||
@ -457,6 +459,7 @@ function specialLink(component: MindNodeComponent) {
|
||||
}
|
||||
class SpecialMindRoot extends PlaitMindComponent {
|
||||
override updateMindLayout(element = this.element) {
|
||||
measureXmindTopics(this.board, element);
|
||||
super.updateMindLayout(element);
|
||||
if (isSpecialMindLayout(element.specialLayout))
|
||||
arrangeSpecialMind(this.root, element.specialLayout);
|
||||
|
||||
@ -20,7 +20,7 @@ export function XmindAnnotationPanel({
|
||||
.join("");
|
||||
return (
|
||||
<aside
|
||||
className="absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
||||
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
||||
data-flow-board-shell-control="true"
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
|
||||
|
||||
70
components/flow-board/xmind-command-menu.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, type LucideIcon } from "lucide-react";
|
||||
|
||||
export type XmindCommand = {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
separator?: boolean;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
export function XmindCommandMenu({ label, children, commands, className = "", align = "left" }: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
commands: XmindCommand[];
|
||||
className?: string;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const dismiss = (event: PointerEvent) => {
|
||||
if (!host.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", dismiss);
|
||||
return () => document.removeEventListener("pointerdown", dismiss);
|
||||
}, [open]);
|
||||
return (
|
||||
<div className={`xmind-menu-host ${className}`} ref={host} data-flow-board-shell-control="true">
|
||||
<button type="button" ref={trigger} aria-label={label} aria-haspopup="menu" aria-expanded={open}
|
||||
className="xmind-menu-trigger" onClick={() => setOpen(value => !value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
requestAnimationFrame(() => host.current?.querySelector<HTMLButtonElement>("[role=menuitem]:not(:disabled)")?.focus());
|
||||
}
|
||||
}}>
|
||||
{children}<ChevronDown size={12} />
|
||||
</button>
|
||||
{open ? <div role="menu" aria-label={label} className={`xmind-command-menu ${align === "right" ? "xmind-menu-right" : ""}`}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus();
|
||||
}
|
||||
const items = Array.from(host.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") || []);
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
||||
items[next]?.focus();
|
||||
}
|
||||
}}>
|
||||
{commands.map(({ label: title, icon: Icon, shortcut, disabled, checked, separator, run }) => <div key={title}>
|
||||
{separator ? <hr className="xmind-menu-separator" /> : null}
|
||||
<button type="button" role="menuitem" disabled={disabled} className="xmind-command" onClick={() => { setOpen(false); run(); }}>
|
||||
{Icon ? <Icon size={16} /> : <span className="xmind-command-check">{checked ? "✓" : ""}</span>}
|
||||
<span>{title}</span>{shortcut ? <kbd>{shortcut}</kbd> : null}
|
||||
</button>
|
||||
</div>)}
|
||||
</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
778
components/flow-board/xmind-gantt.tsx
Normal file
@ -0,0 +1,778 @@
|
||||
"use client";
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Link2,
|
||||
Minus,
|
||||
Plus,
|
||||
Printer,
|
||||
Search,
|
||||
UserRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
addDays,
|
||||
dateIndex,
|
||||
getTaskRows,
|
||||
indexDate,
|
||||
todayDate,
|
||||
type TopicTask,
|
||||
} from "@/lib/flow-board/topic-tasks";
|
||||
import { getTopicEntries } from "@/lib/flow-board/topic-details";
|
||||
import {
|
||||
activeWorkingCalendar,
|
||||
getProjectCalendar,
|
||||
isWorkingDate,
|
||||
preserveNonWorkingDue,
|
||||
taskDueDate,
|
||||
workingDaysBetween,
|
||||
} from "@/lib/flow-board/project-calendar";
|
||||
const ROW = 38,
|
||||
HEADER = 64;
|
||||
export function XmindGantt({
|
||||
elements,
|
||||
title,
|
||||
onClose,
|
||||
onEdit,
|
||||
onCalendar,
|
||||
onChange,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}: {
|
||||
elements: PlaitElement[];
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onEdit: (id: string) => void;
|
||||
onCalendar: () => void;
|
||||
onChange: (id: string, task: TopicTask) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
panelRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
|
||||
return () => {
|
||||
if (previous?.isConnected) previous.focus();
|
||||
};
|
||||
}, []);
|
||||
const [scale, setScale] = useState(34),
|
||||
[query, setQuery] = useState(""),
|
||||
[assignee, setAssignee] = useState(""),
|
||||
[status, setStatus] = useState("all");
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [drag, setDrag] = useState<{
|
||||
id: string;
|
||||
x: number;
|
||||
kind: "move" | "start" | "due";
|
||||
task: TopicTask;
|
||||
days: number;
|
||||
} | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rowScrollRef = useRef<HTMLDivElement>(null);
|
||||
const projectCalendar = getProjectCalendar(elements);
|
||||
const calendar = activeWorkingCalendar(projectCalendar);
|
||||
const rows = getTaskRows(elements);
|
||||
const visible = rows.filter(
|
||||
(row) =>
|
||||
row.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()) &&
|
||||
(!assignee || row.task.assignees.includes(assignee)) &&
|
||||
(status === "all" ||
|
||||
(status === "done" && row.task.progress === 100) ||
|
||||
(status === "open" && row.task.progress < 100)) &&
|
||||
!rows.some(
|
||||
(parent) =>
|
||||
collapsed.has(parent.element.id) &&
|
||||
parent.path.length < row.path.length &&
|
||||
parent.path.every((n, i) => row.path[i] === n)
|
||||
)
|
||||
);
|
||||
const candidates = getTopicEntries(elements).filter(
|
||||
(e) => !rows.some((r) => r.element.id === e.element.id)
|
||||
);
|
||||
const [candidate, setCandidate] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const today = dateIndex(todayDate());
|
||||
const dated = rows.filter((r) => r.task.start && r.task.due);
|
||||
const from =
|
||||
Math.min(today, ...dated.map((r) => dateIndex(r.task.start))) - 3;
|
||||
const last =
|
||||
Math.max(today + 20, ...dated.map((r) => dateIndex(r.task.due))) + 4;
|
||||
// Very long plans use a virtual day grid; no thousands of day DOM nodes.
|
||||
const width = Math.max(800, (last - from + 1) * scale);
|
||||
const [scrollX, setScrollX] = useState(0),
|
||||
[viewportWidth, setViewportWidth] = useState(1000);
|
||||
const tickStep = scale >= 24 ? 1 : scale >= 10 ? 7 : 30;
|
||||
const monthBands = useMemo(() => {
|
||||
const start = from + Math.max(0, Math.floor(scrollX / scale) - 31),
|
||||
end = Math.min(
|
||||
last,
|
||||
from + Math.ceil((scrollX + viewportWidth) / scale) + 31
|
||||
);
|
||||
const bands: { date: string; offset: number; days: number }[] = [];
|
||||
for (let day = start; day <= end; ) {
|
||||
const date = indexDate(day),
|
||||
next = dateIndex(`${date.slice(0, 7)}-01`);
|
||||
const d = new Date(next * 86_400_000);
|
||||
d.setUTCMonth(d.getUTCMonth() + 1);
|
||||
const monthEnd = d.getTime() / 86_400_000;
|
||||
const bandStart = Math.max(from, next),
|
||||
bandEnd = Math.min(last + 1, monthEnd);
|
||||
bands.push({
|
||||
date: date.slice(0, 7),
|
||||
offset: bandStart - from,
|
||||
days: bandEnd - bandStart,
|
||||
});
|
||||
day = monthEnd;
|
||||
}
|
||||
return bands;
|
||||
}, [from, last, scrollX, scale, viewportWidth]);
|
||||
const ticks = useMemo(() => {
|
||||
const result: number[] = [];
|
||||
const begin = Math.max(
|
||||
0,
|
||||
Math.floor(scrollX / scale / tickStep) * tickStep - tickStep
|
||||
);
|
||||
const end = Math.min(
|
||||
last - from,
|
||||
Math.ceil((scrollX + viewportWidth) / scale) + tickStep
|
||||
);
|
||||
for (let i = begin; i <= end; i += tickStep) result.push(i);
|
||||
return result;
|
||||
}, [scrollX, scale, tickStep, viewportWidth, last, from]);
|
||||
const moveTask = (task: TopicTask, days: number) => {
|
||||
const start = addDays(task.start, days);
|
||||
return {
|
||||
...task,
|
||||
start,
|
||||
due: preserveNonWorkingDue(
|
||||
addDays(task.due, days),
|
||||
taskDueDate(start, task.duration, calendar),
|
||||
calendar
|
||||
),
|
||||
};
|
||||
};
|
||||
const effective = (row: (typeof rows)[number]) => {
|
||||
if (drag?.id !== row.element.id || !drag.days) return row.task;
|
||||
const task = row.task;
|
||||
if (drag.kind === "move") return moveTask(task, drag.days);
|
||||
const start =
|
||||
drag.kind === "start" ? addDays(task.start, drag.days) : task.start;
|
||||
const due = drag.kind === "due" ? addDays(task.due, drag.days) : task.due;
|
||||
return {
|
||||
...task,
|
||||
start,
|
||||
due,
|
||||
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
|
||||
};
|
||||
};
|
||||
const goToday = () =>
|
||||
scrollRef.current?.scrollTo({
|
||||
left: Math.max(0, (today - from) * scale - 200),
|
||||
behavior: "smooth",
|
||||
});
|
||||
const downloadCsv = () => {
|
||||
const quote = (v: unknown) => `"${String(v ?? "").replaceAll('"', '""')}"`;
|
||||
const content =
|
||||
"\ufeff" +
|
||||
[
|
||||
[
|
||||
"任务名称",
|
||||
"开始日期",
|
||||
"截止日期",
|
||||
"时长(天)",
|
||||
"进度(%)",
|
||||
"优先级",
|
||||
"负责人",
|
||||
"前置任务",
|
||||
],
|
||||
...visible.map((r) => [
|
||||
r.title,
|
||||
r.task.start,
|
||||
r.task.due,
|
||||
r.task.duration,
|
||||
r.task.progress,
|
||||
r.task.priority == null ? "" : `P${r.task.priority}`,
|
||||
r.task.assignees.join(", "),
|
||||
r.task.dependencies
|
||||
.map(
|
||||
(d) =>
|
||||
`${rows.find((t) => t.element.id === d.id)?.title || d.id} ${d.type}`
|
||||
)
|
||||
.join(", "),
|
||||
]),
|
||||
]
|
||||
.map((row) => row.map(quote).join(","))
|
||||
.join("\r\n");
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([content], { type: "text/csv;charset=utf-8" })
|
||||
);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${title}-甘特图.csv`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
const linkTasks = () => {
|
||||
if (selected.length !== 2) return;
|
||||
const target = rows.find((r) => r.element.id === selected[1]);
|
||||
if (!target) return;
|
||||
onChange(target.element.id, {
|
||||
...target.task,
|
||||
dependencies: [
|
||||
...target.task.dependencies.filter((d) => d.id !== selected[0]),
|
||||
{ id: selected[0], type: "FS", lag: 0 },
|
||||
],
|
||||
});
|
||||
};
|
||||
return (
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="甘特图"
|
||||
className="xmind-gantt"
|
||||
ref={panelRef}
|
||||
data-flow-board-shell-control="true"
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
const controls = Array.from(
|
||||
panelRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled),input,select,[tabindex="0"]'
|
||||
) || []
|
||||
);
|
||||
const first = controls[0],
|
||||
last = controls.at(-1);
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
e.key.toLowerCase() === "z" &&
|
||||
!(e.target instanceof HTMLInputElement) &&
|
||||
!(e.target instanceof HTMLSelectElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.shiftKey) onRedo();
|
||||
else onUndo();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="xmind-gantt-toolbar">
|
||||
<div className="xmind-gantt-title">
|
||||
<button type="button" aria-label="关闭甘特图" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<h2>甘特图</h2>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="xmind-gantt-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="连接选中任务"
|
||||
title="按选择顺序建立完成 → 开始依赖"
|
||||
disabled={selected.length !== 2}
|
||||
onClick={linkTasks}
|
||||
>
|
||||
<Link2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="项目工作日"
|
||||
title="项目工作日"
|
||||
onClick={onCalendar}
|
||||
>
|
||||
<CalendarDays size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="导出甘特图 CSV"
|
||||
onClick={downloadCsv}
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="打印甘特图"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
<Printer size={18} />
|
||||
</button>
|
||||
<button type="button" aria-label="定位今天" onClick={goToday}>
|
||||
<CalendarDays size={18} />
|
||||
</button>
|
||||
<div className="xmind-gantt-zoom">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="缩小甘特图"
|
||||
onClick={() => setScale((s) => Math.max(2, s - 4))}
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
aria-label="甘特图缩放"
|
||||
min={2}
|
||||
max={64}
|
||||
value={scale}
|
||||
onChange={(e) => setScale(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="放大甘特图"
|
||||
onClick={() => setScale((s) => Math.min(64, s + 4))}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="xmind-gantt-filters">
|
||||
<label>
|
||||
<Search size={14} />
|
||||
<input
|
||||
aria-label="搜索甘特图任务"
|
||||
placeholder="搜索任务"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
aria-label="按负责人筛选"
|
||||
value={assignee}
|
||||
onChange={(e) => setAssignee(e.target.value)}
|
||||
>
|
||||
<option value="">全部负责人</option>
|
||||
{[...new Set(rows.flatMap((r) => r.task.assignees))].map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="按任务进度筛选"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="all">全部任务</option>
|
||||
<option value="open">未完成</option>
|
||||
<option value="done">已完成</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="选择新增任务主题"
|
||||
value={candidate}
|
||||
onChange={(e) => setCandidate(e.target.value)}
|
||||
>
|
||||
<option value="">选择主题添加任务</option>
|
||||
{candidates.map((e) => (
|
||||
<option key={e.element.id} value={e.element.id}>
|
||||
{e.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!candidate}
|
||||
onClick={() => {
|
||||
onEdit(candidate);
|
||||
setCandidate("");
|
||||
}}
|
||||
>
|
||||
添加任务
|
||||
</button>
|
||||
<span>
|
||||
{calendar ? `${calendar.name} · 跳过非工作日 · ` : ""}
|
||||
{visible.length} 个任务
|
||||
</span>
|
||||
</div>
|
||||
<div className="xmind-gantt-body">
|
||||
<div className="xmind-gantt-table">
|
||||
<div className="xmind-gantt-table-head">
|
||||
<span>任务名称</span>
|
||||
<span>开始日期</span>
|
||||
<span>时长</span>
|
||||
</div>
|
||||
<div
|
||||
className="xmind-gantt-row-scroll"
|
||||
ref={rowScrollRef}
|
||||
onScroll={(e) => {
|
||||
const node = scrollRef.current;
|
||||
if (node && node.scrollTop !== e.currentTarget.scrollTop)
|
||||
node.scrollTop = e.currentTarget.scrollTop;
|
||||
}}
|
||||
data-gantt-row-scroll="true"
|
||||
>
|
||||
{visible.map((row) => (
|
||||
<div
|
||||
className={`xmind-gantt-row ${selected.includes(row.element.id) ? "is-selected" : ""}`}
|
||||
key={row.element.id}
|
||||
style={{ height: ROW }}
|
||||
>
|
||||
<div
|
||||
className="xmind-gantt-row-name"
|
||||
style={{ paddingLeft: 8 + Math.min(row.depth, 5) * 12 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`选择任务 ${row.title}`}
|
||||
checked={selected.includes(row.element.id)}
|
||||
onChange={(e) =>
|
||||
setSelected((s) =>
|
||||
e.target.checked
|
||||
? [...s, row.element.id]
|
||||
: s.filter((id) => id !== row.element.id)
|
||||
)
|
||||
}
|
||||
/>
|
||||
{row.summary ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`折叠任务 ${row.title}`}
|
||||
aria-expanded={!collapsed.has(row.element.id)}
|
||||
onClick={() =>
|
||||
setCollapsed((old) => {
|
||||
const next = new Set(old);
|
||||
if (next.has(row.element.id))
|
||||
next.delete(row.element.id);
|
||||
else next.add(row.element.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{collapsed.has(row.element.id) ? (
|
||||
<ChevronRight size={12} />
|
||||
) : (
|
||||
<ChevronDown size={12} />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="xmind-gantt-task-title"
|
||||
title={row.title}
|
||||
onClick={() => onEdit(row.element.id)}
|
||||
>
|
||||
{row.title}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="xmind-gantt-date"
|
||||
onClick={() => onEdit(row.element.id)}
|
||||
>
|
||||
{effective(row).start || "未排期"}
|
||||
</button>
|
||||
<span>{effective(row).duration} 天</span>
|
||||
</div>
|
||||
))}
|
||||
{!visible.length ? (
|
||||
<div className="xmind-gantt-empty">
|
||||
<CalendarDays size={52} />
|
||||
<strong>{rows.length ? "没有匹配的任务" : "没有任务"}</strong>
|
||||
<p>
|
||||
{rows.length
|
||||
? "调整搜索或筛选条件。"
|
||||
: "请选择一个主题以添加任务。"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="xmind-gantt-timeline"
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => {
|
||||
setScrollX(e.currentTarget.scrollLeft);
|
||||
setViewportWidth(e.currentTarget.clientWidth);
|
||||
const rowScroll = rowScrollRef.current;
|
||||
if (rowScroll && rowScroll.scrollTop !== e.currentTarget.scrollTop)
|
||||
rowScroll.scrollTop = e.currentTarget.scrollTop;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="xmind-gantt-grid"
|
||||
style={{
|
||||
width,
|
||||
height: Math.max(600, HEADER + visible.length * ROW),
|
||||
backgroundSize: `${scale}px ${ROW}px`,
|
||||
}}
|
||||
>
|
||||
{calendar
|
||||
? Array.from(
|
||||
{ length: Math.ceil(viewportWidth / scale) + 3 },
|
||||
(_, index) =>
|
||||
Math.max(0, Math.floor(scrollX / scale) - 1) + index
|
||||
)
|
||||
.filter(
|
||||
(offset) =>
|
||||
offset <= last - from &&
|
||||
!isWorkingDate(indexDate(from + offset), calendar)
|
||||
)
|
||||
.map((offset) => (
|
||||
<div
|
||||
key={`off-${offset}`}
|
||||
className="xmind-gantt-day-off"
|
||||
data-gantt-day-off={indexDate(from + offset)}
|
||||
style={{
|
||||
left: offset * scale,
|
||||
width: scale,
|
||||
top: HEADER,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
<div
|
||||
className="xmind-gantt-calendar"
|
||||
style={{ width, height: HEADER }}
|
||||
>
|
||||
{ticks.map((offset) => {
|
||||
const date = indexDate(from + offset);
|
||||
return (
|
||||
<div
|
||||
className={`xmind-gantt-day ${calendar && !isWorkingDate(date, calendar) ? "is-day-off" : ""}`}
|
||||
key={offset}
|
||||
style={{ left: offset * scale, width: tickStep * scale }}
|
||||
>
|
||||
<b>
|
||||
{tickStep >= 30
|
||||
? date.slice(5, 7) + "月"
|
||||
: tickStep >= 7
|
||||
? date.slice(5)
|
||||
: String(Number(date.slice(8)))}
|
||||
</b>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{monthBands.map((band) => (
|
||||
<div
|
||||
key={band.date}
|
||||
className="xmind-gantt-month"
|
||||
style={{
|
||||
left: band.offset * scale,
|
||||
width: band.days * scale,
|
||||
}}
|
||||
>
|
||||
{band.date.replace("-", "年")}月
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="xmind-gantt-today"
|
||||
aria-label="今天"
|
||||
style={{ left: (today - from) * scale, top: HEADER }}
|
||||
/>
|
||||
<svg
|
||||
className="xmind-gantt-connections"
|
||||
aria-label="任务依赖关系"
|
||||
role="img"
|
||||
width={width}
|
||||
height={Math.max(600, HEADER + visible.length * ROW)}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="gantt-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8d94a1" />
|
||||
</marker>
|
||||
</defs>
|
||||
{visible.flatMap((row, i) =>
|
||||
row.task.dependencies.map((d) => {
|
||||
const fromIndex = visible.findIndex(
|
||||
(r) => r.element.id === d.id
|
||||
);
|
||||
if (fromIndex < 0) return null;
|
||||
const before = effective(visible[fromIndex]),
|
||||
after = effective(row);
|
||||
if (!before.start || !after.start) return null;
|
||||
const x1 =
|
||||
(dateIndex(
|
||||
d.type[0] === "F" ? before.due : before.start
|
||||
) -
|
||||
from +
|
||||
(d.type[0] === "F" ? 1 : 0)) *
|
||||
scale,
|
||||
x2 =
|
||||
(dateIndex(d.type[1] === "F" ? after.due : after.start) -
|
||||
from +
|
||||
(d.type[1] === "F" ? 1 : 0)) *
|
||||
scale,
|
||||
y1 = HEADER + fromIndex * ROW + ROW / 2,
|
||||
y2 = HEADER + i * ROW + ROW / 2;
|
||||
return (
|
||||
<path
|
||||
key={`${row.element.id}-${d.id}`}
|
||||
data-gantt-dependency={`${d.id}:${row.element.id}`}
|
||||
d={`M${x1},${y1} H${x1 + 9} V${y2} H${x2}`}
|
||||
fill="none"
|
||||
stroke="#8d94a1"
|
||||
strokeWidth={1.3}
|
||||
markerEnd="url(#gantt-arrow)"
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</svg>
|
||||
{visible.map((row, i) =>
|
||||
selected.includes(row.element.id) ? (
|
||||
<div
|
||||
key={`selection-${row.element.id}`}
|
||||
className="xmind-gantt-selection"
|
||||
style={{ top: HEADER + i * ROW, height: ROW, width }}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{visible.map((row, i) => {
|
||||
const t = effective(row);
|
||||
if (!t.start || !t.due) return null;
|
||||
const left = (dateIndex(t.start) - from) * scale,
|
||||
barWidth = Math.max(
|
||||
6,
|
||||
(dateIndex(t.due) - dateIndex(t.start) + 1) * scale
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={row.element.id}
|
||||
className={`xmind-gantt-bar ${row.summary ? "is-summary" : ""} ${t.progress === 100 ? "is-complete" : ""}`}
|
||||
data-gantt-task-id={row.element.id}
|
||||
style={{ left, top: HEADER + i * ROW + 6, width: barWidth }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`任务时间条 ${row.title}`}
|
||||
aria-pressed={selected.includes(row.element.id)}
|
||||
title={`${row.title}\n${t.start} — ${t.due}\n进度 ${t.progress}%`}
|
||||
onDoubleClick={() => onEdit(row.element.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onEdit(row.element.id);
|
||||
if (
|
||||
!row.summary &&
|
||||
["ArrowLeft", "ArrowRight"].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
const days = e.key === "ArrowLeft" ? -1 : 1;
|
||||
onChange(row.element.id, moveTask(t, days));
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (row.summary || e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setSelected((ids) =>
|
||||
e.metaKey || e.ctrlKey
|
||||
? ids.includes(row.element.id)
|
||||
? ids.filter((id) => id !== row.element.id)
|
||||
: [...ids, row.element.id]
|
||||
: [row.element.id]
|
||||
);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const kind =
|
||||
(e.target as HTMLElement).dataset.resize === "start"
|
||||
? "start"
|
||||
: (e.target as HTMLElement).dataset.resize === "due"
|
||||
? "due"
|
||||
: "move";
|
||||
setDrag({
|
||||
id: row.element.id,
|
||||
x: e.clientX,
|
||||
kind,
|
||||
task: row.task,
|
||||
days: 0,
|
||||
});
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (drag?.id !== row.element.id) return;
|
||||
let days = Math.round((e.clientX - drag.x) / scale);
|
||||
if (drag.kind === "start")
|
||||
days = Math.min(
|
||||
dateIndex(drag.task.due) - dateIndex(drag.task.start),
|
||||
days
|
||||
);
|
||||
if (drag.kind === "due")
|
||||
days = Math.max(
|
||||
dateIndex(drag.task.start) - dateIndex(drag.task.due),
|
||||
days
|
||||
);
|
||||
setDrag({ ...drag, days });
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
if (drag?.id !== row.element.id) return;
|
||||
if (drag.days) onChange(row.element.id, t);
|
||||
setDrag(null);
|
||||
}}
|
||||
onPointerCancel={() => setDrag(null)}
|
||||
>
|
||||
<div
|
||||
className="xmind-gantt-progress"
|
||||
style={{ width: `${t.progress}%` }}
|
||||
/>
|
||||
{!row.summary ? (
|
||||
<>
|
||||
<span
|
||||
data-resize="start"
|
||||
className="xmind-gantt-resize is-start"
|
||||
/>
|
||||
<span
|
||||
data-resize="due"
|
||||
className="xmind-gantt-resize is-due"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<span className="xmind-gantt-bar-label">
|
||||
<span
|
||||
className="xmind-gantt-avatar"
|
||||
title={t.assignees.join("、") || "未分配负责人"}
|
||||
>
|
||||
<UserRound size={12} />
|
||||
</span>
|
||||
<span>{row.title}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="xmind-gantt-footer">
|
||||
<span>
|
||||
{drag
|
||||
? `调整 ${drag.days > 0 ? "+" : ""}${drag.days} 天`
|
||||
: "拖动调整日期 · 拉伸修改工期 · 双击编辑任务"}
|
||||
</span>
|
||||
<span>{notice}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setAssignee("");
|
||||
setStatus("all");
|
||||
setNotice("");
|
||||
}}
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
94
components/flow-board/xmind-image-export.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Loader2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ImageExportOptions } from "@/lib/flow-board/image-export";
|
||||
|
||||
export function XmindImageExport({
|
||||
busy,
|
||||
onExport,
|
||||
onClose,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onExport: (
|
||||
options: Pick<ImageExportOptions, "scale" | "transparent">
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [scale, setScale] = useState<1 | 2 | 3>(2);
|
||||
const [transparent, setTransparent] = useState(false);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
node?.showModal();
|
||||
return () => node?.close();
|
||||
}, []);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="xmind-image-export"
|
||||
aria-labelledby="image-export-title"
|
||||
data-flow-board-shell-control="true"
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<h2 id="image-export-title">导出图片</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭图片导出"
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<p>导出当前工作表的完整内容为 PNG 图片。</p>
|
||||
<label className="xmind-image-export-field">
|
||||
清晰度
|
||||
<select
|
||||
aria-label="图片清晰度"
|
||||
value={scale}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setScale(Number(event.target.value) as 1 | 2 | 3)
|
||||
}
|
||||
>
|
||||
<option value={1}>标准 · 1 倍</option>
|
||||
<option value={2}>高清 · 2 倍</option>
|
||||
<option value={3}>超清 · 3 倍</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="xmind-image-export-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={transparent}
|
||||
disabled={busy}
|
||||
onChange={(event) => setTransparent(event.target.checked)}
|
||||
/>
|
||||
透明背景
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" disabled={busy} onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xmind-primary-button"
|
||||
disabled={busy}
|
||||
onClick={() => onExport({ scale, transparent })}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={16} />
|
||||
)}
|
||||
{busy ? "正在导出…" : "导出 PNG"}
|
||||
</button>
|
||||
</footer>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
46
components/flow-board/xmind-marker-panel.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Search, X } from "lucide-react";
|
||||
import { XMIND_MARKER_GROUPS, type XmindMarker } from "@/lib/flow-board/xmind-markers";
|
||||
|
||||
export function XmindMarkerPanel({ selectedCount, activeIds, mixedIds, onApply, onClear, onClose }: {
|
||||
selectedCount: number;
|
||||
activeIds: string[];
|
||||
mixedIds: string[];
|
||||
onApply: (marker: XmindMarker) => void;
|
||||
onClear: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState<string[]>([]);
|
||||
const needle = query.trim().toLowerCase();
|
||||
const groups = XMIND_MARKER_GROUPS.filter(group => showAll || needle || !group.hidden).map(group => ({
|
||||
...group, markers: group.markers.filter(marker => (showAll || needle || !marker.hidden) && (!needle || `${group.label} ${marker.name} ${marker.id}`.toLowerCase().includes(needle))),
|
||||
})).filter(group => group.markers.length);
|
||||
return <aside className="xmind-format-panel xmind-marker-panel" aria-label="标记面板" data-flow-board-shell-control="true"
|
||||
onPointerDownCapture={event => event.stopPropagation()} onMouseDownCapture={event => event.stopPropagation()}>
|
||||
<div className="xmind-panel-heading"><span>标记</span><button type="button" aria-label="关闭标记面板" onClick={onClose}><X size={16} /></button></div>
|
||||
<div className="xmind-marker-search"><Search size={14} /><input aria-label="搜索标记" placeholder="搜索标记" value={query} onChange={event => setQuery(event.target.value)} /></div>
|
||||
<p className="xmind-marker-selection" role="status">{selectedCount ? `已选中 ${selectedCount} 个主题` : "先选中主题,再添加标记"}</p>
|
||||
<div className="xmind-panel-content">
|
||||
{groups.map(group => <section className="xmind-marker-group" key={group.id}>
|
||||
<button type="button" className="xmind-marker-group-heading" aria-expanded={!collapsed.includes(group.id) || Boolean(needle)} onClick={() => setCollapsed(ids => ids.includes(group.id) ? ids.filter(id => id !== group.id) : [...ids, group.id])}>
|
||||
<ChevronDown size={13} /><span>{group.label}</span><span>{group.markers.length}</span>
|
||||
</button>
|
||||
{!collapsed.includes(group.id) || needle ? <div className="xmind-marker-grid">
|
||||
{group.markers.map(marker => <button type="button" key={marker.id} data-marker-id={marker.id}
|
||||
aria-label={marker.id.startsWith("priority-") ? marker.name : `${group.label} ${marker.name}`}
|
||||
title={`${group.label} · ${marker.name}`} aria-pressed={activeIds.includes(marker.id) ? true : mixedIds.includes(marker.id) ? "mixed" : false}
|
||||
disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={() => onApply(marker)}>
|
||||
<img src={marker.asset} alt="" draggable={false} loading="lazy" />
|
||||
</button>)}
|
||||
</div> : null}
|
||||
</section>)}
|
||||
{!groups.length ? <p className="xmind-marker-empty">没有匹配的标记</p> : null}
|
||||
{!needle ? <button type="button" className="xmind-marker-more" aria-expanded={showAll} onClick={() => setShowAll(value => !value)}>{showAll ? "收起更多标记" : "更多标记"}</button> : null}
|
||||
</div>
|
||||
<div className="xmind-marker-footer"><button type="button" disabled={!selectedCount} onPointerDown={event => event.preventDefault()} onMouseDown={event => event.preventDefault()} onClick={onClear}>清空所选节点标记</button></div>
|
||||
</aside>;
|
||||
}
|
||||
280
components/flow-board/xmind-native.css
Normal file
@ -0,0 +1,280 @@
|
||||
/* Xmind editing controls use the project's home/canvas typography and density. */
|
||||
.xmind-workspace {
|
||||
--xmind-panel-width: 240px;
|
||||
--xmind-ink: #24282b;
|
||||
--xmind-muted: #888b8e;
|
||||
--xmind-panel: #f3f4f5;
|
||||
--xmind-border: #e3e5e8;
|
||||
color: var(--xmind-ink);
|
||||
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
.xmind-workspace button { cursor: pointer; }
|
||||
.xmind-workspace :is(button,input,textarea,select) { font-family: inherit; }
|
||||
.xmind-workspace button:disabled { cursor: default; opacity: .3; }
|
||||
.xmind-workspace button:focus-visible { outline: 2px solid #35b5ff; outline-offset: 2px; }
|
||||
.xmind-topic-marker { display:inline-block; object-fit:contain; flex-shrink:0; vertical-align:middle; }
|
||||
.xmind-marker-search { display:flex; align-items:center; gap:6px; margin:4px 12px; padding:6px 8px; border:1px solid var(--xmind-border); border-radius:7px; background:white; }
|
||||
.xmind-marker-search input { width:100%; min-width:0; outline:none; background:transparent; font-size:12px; }
|
||||
.xmind-marker-selection { margin:6px 12px 10px; font-size:11px; color:var(--xmind-muted); }
|
||||
.xmind-marker-panel .xmind-panel-content { flex:1; overflow-x:hidden; }
|
||||
.xmind-marker-group { margin-bottom:12px; }
|
||||
.xmind-marker-group-heading { display:flex; align-items:center; gap:5px; width:100%; height:26px; font-size:12px; font-weight:600; }
|
||||
.xmind-marker-group-heading > span:last-child { margin-left:auto; font-size:10px; font-weight:400; color:var(--xmind-muted); }
|
||||
.xmind-marker-group-heading[aria-expanded=false] svg { transform:rotate(-90deg); }
|
||||
.xmind-marker-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }
|
||||
.xmind-marker-grid button { display:grid; place-items:center; width:100%; height:28px; border:1px solid transparent; border-radius:5px; background:transparent; }
|
||||
.xmind-marker-grid button:hover:not(:disabled) { background:#e3e6ea; }
|
||||
.xmind-marker-grid button[aria-pressed=true] { border-color:#387cff; background:#e9f0ff; }
|
||||
.xmind-marker-grid button[aria-pressed=mixed] { border:1px dashed #387cff; background:#e9f0ff80; }
|
||||
.xmind-marker-grid img { width:20px; height:20px; object-fit:contain; }
|
||||
.xmind-marker-more { width:100%; padding:7px; border-radius:6px; color:#3569e8; background:#e9edf4; font-size:12px; }
|
||||
.xmind-marker-empty { padding:24px 0; text-align:center; color:var(--xmind-muted); }
|
||||
.xmind-marker-footer { padding:10px 12px; border-top:1px solid var(--xmind-border); }
|
||||
.xmind-marker-footer button { width:100%; padding:7px; border:1px solid var(--xmind-border); border-radius:6px; background:white; font-size:12px; }
|
||||
.xmind-topbar { position:absolute; top:0; left:0; right:var(--xmind-panel-offset,0px); height:56px; z-index:50; display:flex; align-items:flex-start; padding:12px 12px 0; gap:8px; pointer-events:none; }
|
||||
.xmind-topbar > * { pointer-events:auto; }
|
||||
.xmind-document-heading { display:flex; align-items:center; gap:8px; min-width:200px; }
|
||||
.xmind-home-button { display:grid; place-items:center; width:32px; height:32px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; flex-shrink:0; }
|
||||
.xmind-document-name { height:32px; display:flex; flex-direction:column; justify-content:center; gap:0; min-width:100px; }
|
||||
.xmind-document-name input { background:transparent; border:1px solid transparent; border-radius:5px; font-size:13px; font-weight:600; width:140px; padding:0 3px; outline:none; }
|
||||
.xmind-document-name input:focus { border-color:#35b5ff; }
|
||||
.xmind-document-name span { font-size:11px; padding-left:4px; }
|
||||
.xmind-document-menu { margin-left:-8px; opacity:0; transition:opacity .15s; }
|
||||
.xmind-document-heading:hover .xmind-document-menu,.xmind-document-heading:focus-within .xmind-document-menu { opacity:1; }
|
||||
.xmind-pointer-toolbar { position:absolute; top:64px; left:12px; display:flex; flex-direction:column; gap:2px; padding:3px; border:1px solid var(--xmind-border); border-radius:10px; background:#fffffff5; box-shadow:0 2px 8px #00000008; }
|
||||
.xmind-pointer-toolbar .xmind-icon-button { width:28px; height:28px; }
|
||||
.xmind-primary-tools { display:flex; align-items:center; gap:8px; margin:auto; margin-top:0; }
|
||||
.xmind-toolbar-pill { display:flex; align-items:center; padding:2px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; backdrop-filter:blur(24px); }
|
||||
.xmind-icon-button { width:30px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--xmind-ink); background:transparent; flex-shrink:0; }
|
||||
.xmind-icon-button > svg { width:18px; height:18px; }
|
||||
.xmind-icon-button:hover:not(:disabled),.xmind-home-button:hover,.xmind-menu-trigger:hover { background:#00000009; }
|
||||
.xmind-icon-button.is-active { background:#363b3e; color:white; }
|
||||
.xmind-utility-tools { display:flex; align-items:center; gap:8px; }
|
||||
.xmind-menu-host { position:relative; }
|
||||
.xmind-menu-trigger { display:flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:2px 5px; border-radius:24px; }
|
||||
.xmind-document-menu .xmind-menu-trigger > svg:last-child,.xmind-sheet-actions .xmind-menu-trigger > svg:last-child { display:none; }
|
||||
.xmind-command-menu { position:absolute; top:calc(100% + 10px); left:0; min-width:220px; max-height:min(70vh,540px); overflow-y:auto; padding:5px; border:1px solid #dedfe2; border-radius:10px; background:#fcfcfcf5; backdrop-filter:blur(24px); box-shadow:0 12px 32px #00000020,0 2px 6px #0000000a; z-index:70; }
|
||||
.xmind-menu-right { left:auto; right:0; }
|
||||
.xmind-command { width:100%; display:flex; align-items:center; gap:10px; border-radius:5px; padding:7px 9px; text-align:left; min-height:32px; color:#34393c; font-size:13px; }
|
||||
.xmind-command:hover:not(:disabled),.xmind-command:focus-visible { background:#35a6fc; color:white; outline:none; }
|
||||
.xmind-command kbd { font:inherit; opacity:.5; margin-left:auto; font-size:11px; }
|
||||
.xmind-command-check { display:block; width:16px; }
|
||||
.xmind-menu-separator { height:1px; background:#e1e2e4; margin:4px 6px; }
|
||||
.xmind-format-panel { position:absolute; top:8px; right:8px; bottom:8px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid #e5e7e9; box-shadow:-5px 0 20px #00000005; display:flex; flex-direction:column; z-index:35; overflow:hidden; }
|
||||
.xmind-panel-heading { display:flex; justify-content:space-between; align-items:center; min-height:36px; padding:0 12px; font-weight:600; }
|
||||
.xmind-panel-heading button { border-radius:6px; padding:4px; color:#7a7e83; }
|
||||
.xmind-panel-heading button:hover { background:#e2e4e6; }
|
||||
.xmind-panel-tabs { display:flex; margin:4px 10px 10px; padding:3px; height:28px; border-radius:8px; background:#e7e8eb; flex-shrink:0; }
|
||||
.xmind-panel-tabs button { flex:1; border-radius:6px; font-size:12px; color:#6d7276; }
|
||||
.xmind-panel-tabs button[aria-selected=true] { background:#ffffffd9; color:#23282b; font-weight:600; }
|
||||
.xmind-panel-content { overflow-y:auto; min-height:0; padding:0 12px 12px; scrollbar-width:thin; scrollbar-color:#b5b5b5 transparent; }
|
||||
.xmind-topic-preview { min-height:48px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; display:flex; align-items:center; gap:8px; padding:8px; margin-bottom:12px; }
|
||||
.xmind-topic-preview > span { flex:1; text-align:center; font-size:14px; padding:5px; }
|
||||
.xmind-topic-preview svg { color:#5c6267; flex-shrink:0; }
|
||||
.xmind-format-section { padding:12px 0; border-top:1px solid var(--xmind-border); }
|
||||
.xmind-format-section h3 { display:flex; align-items:center; gap:5px; margin:0 0 10px; font-size:12px; font-weight:600; }
|
||||
.xmind-setting-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; font-size:12px; }
|
||||
.xmind-setting-row h3 { margin:0; }
|
||||
.xmind-setting-row > span { flex-shrink:0; }
|
||||
.xmind-setting-row select { height:25px; background:#fff; border:1px solid var(--xmind-border); border-radius:8px; min-width:74px; max-width:170px; padding:0 8px; font-size:12px; }
|
||||
.xmind-color-control { height:25px; width:74px; padding:0; border:1px solid var(--xmind-border); border-radius:8px; overflow:hidden; background:white; cursor:pointer; }
|
||||
.xmind-color-control::-webkit-color-swatch-wrapper { padding:0; }
|
||||
.xmind-color-control::-webkit-color-swatch { border:0; border-radius:7px; }
|
||||
.xmind-segmented { display:flex; border:1px solid var(--xmind-border); background:white; border-radius:8px; overflow:hidden; height:27px; }
|
||||
.xmind-segmented button { flex:1; display:grid; place-items:center; margin:2px; border-radius:6px; }
|
||||
.xmind-segmented button[aria-pressed=true] { background:#e4e6e8; }
|
||||
.xmind-segmented button:hover:not(:disabled) { background:#f1f2f3; }
|
||||
.xmind-shape-rectangle { width:18px; height:12px; border:1.5px solid currentColor; border-radius:3px; }
|
||||
.xmind-shape-line { width:24px; border-bottom:1.5px solid currentColor; }
|
||||
.xmind-text-marks { height:28px; margin-top:12px; }
|
||||
.xmind-structure-trigger { display:flex; height:26px; align-items:center; padding:0 8px; background:white; border:1px solid var(--xmind-border); border-radius:8px; }
|
||||
.xmind-structure-trigger svg[data-layout-preview] { width:35px; height:24px; }
|
||||
.xmind-current-structure { padding:4px 10px; margin-top:14px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; font-size:12px; color:#7e8388; }
|
||||
.xmind-structure-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; margin-top:12px; }
|
||||
.xmind-structure-grid button { display:flex; flex-direction:column; justify-content:center; align-items:center; gap:4px; min-height:64px; border:1px solid #e0e3e7; border-radius:7px; font-size:9px; padding:4px 2px; background:white; }
|
||||
.xmind-structure-grid button[aria-pressed=true] { border-color:#36b4ff; background:#edf8ff; color:#179cdc; }
|
||||
.xmind-background-presets { display:grid; grid-template-columns:repeat(6,1fr); gap:6px; }
|
||||
.xmind-background-presets button { height:28px; border:1px solid #d6d8dc; border-radius:6px; }
|
||||
.xmind-background-presets button[aria-pressed=true] { outline:2px solid #35b5ff; }
|
||||
.xmind-theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
|
||||
.xmind-theme-grid button { background:white; border:1px solid #e1e3e6; border-radius:8px; font-size:11px; padding:8px; }
|
||||
.xmind-theme-grid button[aria-pressed=true] { border-color:#35b5ff; }
|
||||
.xmind-theme-grid button > span { display:flex; margin-bottom:8px; height:12px; overflow:hidden; border-radius:3px; }
|
||||
.xmind-theme-grid i { flex:1; }
|
||||
.xmind-switch { width:30px; height:18px; border-radius:12px; background:#bfc3c8; position:relative; }
|
||||
.xmind-switch i { position:absolute; top:2px; left:2px; height:14px; width:14px; background:white; border-radius:50%; transition:transform .15s; }
|
||||
.xmind-switch[aria-checked=true] { background:#35b5ff; }
|
||||
.xmind-switch[aria-checked=true] i { transform:translateX(12px); }
|
||||
.xmind-canvas-preview { height:96px; border:1px solid var(--xmind-border); border-radius:8px; margin-top:12px; }
|
||||
.xmind-sheetbar { position:absolute; bottom:10px; left:16px; right:calc(var(--xmind-panel-offset,0px) + 16px); height:30px; z-index:40; display:flex; align-items:center; gap:8px; pointer-events:none; }
|
||||
.xmind-sheetbar > * { pointer-events:auto; }
|
||||
.xmind-sheet-tabs { display:flex; gap:4px; max-width:60%; overflow-x:auto; scrollbar-width:none; }
|
||||
.xmind-sheet-tab-host { flex-shrink:0; }
|
||||
.xmind-sheet-tab-host > button { border-radius:16px; padding:4px 18px; font-size:12px; font-weight:500; background:#ffffffd9; max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.xmind-sheet-tab-host > button[aria-pressed=true] { background:#e4e5e6; font-weight:600; }
|
||||
.xmind-sheet-tab-host input { width:130px; height:25px; border-radius:6px; background:white; border:1px solid #35b5ff; padding:0 8px; }
|
||||
.xmind-sheet-context { position:absolute; bottom:36px; min-width:140px; padding:5px; border:1px solid #e2e4e7; border-radius:10px; background:white; box-shadow:0 10px 25px #00000015; }
|
||||
.xmind-sheet-context button { display:block; width:100%; text-align:left; padding:7px 10px; border-radius:6px; }
|
||||
.xmind-sheet-context button:hover { background:#f0f3f5; }
|
||||
.xmind-footer-divider { width:1px; height:13px; background:#dfe2e5; flex-shrink:0; }
|
||||
.xmind-footer-icon { display:grid; place-items:center; width:26px; height:26px; border-radius:6px; }
|
||||
.xmind-footer-icon:hover { background:#f2f3f5; }
|
||||
.xmind-sheet-actions { opacity:0; }
|
||||
.xmind-sheetbar:hover .xmind-sheet-actions,.xmind-sheet-actions:focus-within { opacity:1; }
|
||||
.xmind-sheet-status { margin-left:auto; display:flex; gap:12px; align-items:center; background:#ffffffd9; }
|
||||
.xmind-topic-count { font-size:11px; white-space:nowrap; }
|
||||
.xmind-zoom-menu .xmind-command-menu,.xmind-sheet-actions .xmind-command-menu { top:auto; bottom:calc(100% + 8px); }
|
||||
.xmind-zoom-menu .xmind-menu-trigger { font-size:12px; padding:0; min-width:50px; }
|
||||
.xmind-outline-toggle { font-weight:600; font-size:13px; white-space:nowrap; }
|
||||
.xmind-workspace .flow-board-canvas-shell { top:0; bottom:0; right:var(--xmind-panel-offset,0px); }
|
||||
.xmind-workspace .board-host-svg { background:transparent !important; }
|
||||
.xmind-workspace .board-active-svg { z-index:2; pointer-events:none; }
|
||||
.xmind-workspace .active-host-g path[stroke="#6698FF"] { stroke:#387cff; stroke-width:2px; vector-effect:non-scaling-stroke; }
|
||||
.xmind-workspace .active-host-g path[fill="#6698FF25"] { fill:rgba(56,124,255,.12); }
|
||||
.xmind-outline-shell { position:absolute; inset:56px var(--xmind-panel-offset,0px) 48px 0; z-index:20; }
|
||||
.xmind-workspace .xmind-aux-panel { top:8px; bottom:8px; right:8px; width:var(--xmind-panel-width); max-width:var(--xmind-panel-width); border-radius:12px; border:1px solid var(--xmind-border); background:var(--xmind-panel); }
|
||||
.xmind-zen-exit { position:absolute; top:16px; right:16px; z-index:45; display:flex; gap:8px; align-items:center; padding:9px 14px; border-radius:24px; background:#fffffff0; box-shadow:0 4px 20px #0000000c; font-size:12px; }
|
||||
.xmind-empty-canvas { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:20; border:1px solid #333; background:white; border-radius:8px; padding:8px 16px; font-size:16px; font-weight:600; }
|
||||
.xmind-search-panel { position:absolute; top:64px; left:8px; bottom:48px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid var(--xmind-border); z-index:45; display:flex; flex-direction:column; box-shadow:5px 0 20px #00000008; }
|
||||
.xmind-workspace:has(.xmind-search-panel) .xmind-pointer-toolbar { visibility:hidden; }
|
||||
.xmind-search-panel .xmind-panel-content { padding:0 14px 14px; }
|
||||
.xmind-search-box { display:flex; gap:8px; border-radius:8px; border:1px solid var(--xmind-border); background:white; padding:8px; align-items:center; margin:8px 0; }
|
||||
.xmind-search-box input { outline:0; width:100%; background:transparent; font-size:13px; }
|
||||
.xmind-search-options { display:flex; align-items:center; gap:12px; margin:10px 0; font-size:11px; }
|
||||
.xmind-search-panel select { width:100%; height:30px; border-radius:8px; background:white; border:1px solid var(--xmind-border); padding:0 8px; font-size:12px; }
|
||||
.xmind-search-navigation { display:flex; align-items:center; gap:8px; margin:12px 0; font-size:11px; color:#81868b; }
|
||||
.xmind-search-navigation span { flex:1; }
|
||||
.xmind-search-navigation button { padding:4px; border-radius:5px; }
|
||||
.xmind-search-navigation button:hover { background:#e5e7e9; }
|
||||
.xmind-search-result { display:block; width:100%; text-align:left; padding:10px; border-radius:8px; margin:4px 0; background:#ffffffa6; }
|
||||
.xmind-search-result.is-active { outline:1px solid #35b5ff; background:#ecf8ff; }
|
||||
.xmind-search-result > span { display:block; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.xmind-search-result small { font-size:10px; color:#8a8e91; }
|
||||
.xmind-search-replace-buttons { display:flex; gap:8px; margin:10px 0 16px; }
|
||||
.xmind-search-replace-buttons button { flex:1; border:1px solid var(--xmind-border); border-radius:6px; background:white; padding:5px; font-size:11px; }
|
||||
.xmind-presentation { position:absolute; inset:0; z-index:60; background:#fff; display:flex; flex-direction:column; }
|
||||
.xmind-presentation > header { display:flex; align-items:center; justify-content:space-between; padding:20px 28px; color:#8b9095; font-size:12px; }
|
||||
.xmind-presentation > header button { display:flex; align-items:center; gap:6px; }
|
||||
.xmind-presentation-slide { display:flex; flex:1; flex-direction:column; align-items:center; justify-content:center; padding:60px; overflow:auto; }
|
||||
.xmind-presentation-slide h1 { font-size:clamp(28px,4vw,56px); font-weight:650; text-align:center; }
|
||||
.xmind-presentation-slide ul { display:flex; flex-direction:column; gap:20px; margin-top:40px; font-size:clamp(18px,2vw,28px); }
|
||||
.xmind-presentation-slide li { display:flex; align-items:center; gap:15px; }
|
||||
.xmind-presentation-slide li::before { content:""; width:8px; height:8px; border-radius:50%; background:#ff7160; }
|
||||
.xmind-presentation > footer { display:flex; justify-content:center; align-items:center; gap:20px; padding:20px; font-size:12px; color:#72787e; }
|
||||
.xmind-presentation > footer button { padding:8px; border:1px solid #e6e7e9; border-radius:50%; }
|
||||
@media (max-width:1100px) { .xmind-window-dots { display:none; } .xmind-document-heading { min-width:160px; gap:8px; } .xmind-document-name input { width:100px; } .xmind-utility-tools { gap:8px; } .xmind-primary-tools { gap:8px; } .xmind-topbar { gap:8px; padding-left:12px; } }
|
||||
@media (max-width:850px) { .xmind-document-name { display:none; } .xmind-document-heading { min-width:auto; } .xmind-document-menu { opacity:1; } .xmind-utility-tools > .xmind-toolbar-pill:nth-child(2) { display:none; } .xmind-topbar { justify-content:space-between; } }
|
||||
@media (max-width:640px) { .xmind-workspace { --xmind-panel-offset:0px !important; } .xmind-format-panel,.xmind-workspace .xmind-aux-panel { width:min(var(--xmind-panel-width),calc(100vw - 16px)); } .xmind-topbar { gap:4px; padding:12px 8px; } .xmind-home-button { width:32px; } .xmind-primary-tools { gap:4px; margin:0 auto; } .xmind-icon-button { width:28px; } .xmind-document-menu { display:none; } .xmind-topic-count { display:none; } .xmind-sheetbar { left:10px; right:10px; gap:5px; } .xmind-sheet-status { gap:6px; } .xmind-sheet-tabs { max-width:50%; } }
|
||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Regular.ttf') format('truetype'); font-weight:400; font-style:normal; font-display:swap; }
|
||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Medium.ttf') format('truetype'); font-weight:500; font-style:normal; font-display:swap; }
|
||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Bold.ttf') format('truetype'); font-weight:700; font-style:normal; font-display:swap; }
|
||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Extrabold.ttf') format('truetype'); font-weight:800; font-style:normal; font-display:swap; }
|
||||
@layer base {
|
||||
.xmind-workspace .drawnix,.xmind-workspace .drawnix * { --ui-font:Arial,"PingFang SC",sans-serif; font-family:Arial,"PingFang SC",sans-serif !important; }
|
||||
.xmind-workspace .drawnix .plait-text-container,.xmind-workspace .drawnix .plait-text-container * { font-family:NeverMind,Arial,"PingFang SC",sans-serif !important; }
|
||||
}
|
||||
|
||||
.xmind-native-icon { display:block; width:18px; height:18px; background:currentColor; mask-size:contain; mask-position:center; mask-repeat:no-repeat; }
|
||||
|
||||
/* Topic notes follow the native compact formatting toolbar. */
|
||||
.xmind-notes-editor { border:1px solid #e2e5eb; border-radius:12px; background:#fafbfc; overflow:hidden; }
|
||||
.xmind-notes-toolbar { display:flex; justify-content:center; align-items:center; gap:2px; padding:7px; border-bottom:1px solid #eceef1; }
|
||||
.xmind-notes-toolbar button { display:grid; place-items:center; width:28px; height:28px; border-radius:6px; }
|
||||
.xmind-notes-toolbar button:hover,.xmind-notes-toolbar button[aria-pressed=true] { background:#e9edf5; color:#3569e8; }
|
||||
.xmind-note-content { min-height:180px; max-height:380px; overflow:auto; padding:12px; outline:none; font-size:13px; line-height:1.7; color:#30343b; counter-reset:note-number; }
|
||||
.xmind-note-content p { margin:0 0 4px; position:relative; }
|
||||
.xmind-note-content p[data-note-list] { padding-left:20px; }
|
||||
.xmind-note-content p[data-note-list=bullet]::before { content:"•"; position:absolute; left:3px; }
|
||||
.xmind-note-content p[data-note-list=number] { counter-increment:note-number; }
|
||||
.xmind-note-content p[data-note-list=number]::before { content:counter(note-number) "."; position:absolute; left:0; }
|
||||
.xmind-note-content a { color:#3569e8; text-decoration:underline; }
|
||||
.xmind-note-link-form { padding:8px; display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.xmind-note-link-form input { min-width:0; flex:1; border:1px solid #dfe2e7; padding:4px; border-radius:5px; }
|
||||
.xmind-note-link-form p { width:100%; color:#c0392b; font-size:11px; }
|
||||
.xmind-task-backdrop { position:absolute; inset:0; background:rgb(20 27 40 / .16); z-index:85; display:grid; place-items:center; padding:20px; }
|
||||
.xmind-task-editor { width:min(460px,100%); max-height:calc(100vh - 40px); overflow:auto; border:1px solid #e0e3e8; border-radius:16px; background:#fafbfc; color:#343b48; padding:18px; box-shadow:0 16px 64px rgb(20 27 40 / .18); font-size:12px; }
|
||||
.xmind-task-editor header,.xmind-task-editor header h2 { display:flex; align-items:center; gap:8px; }
|
||||
.xmind-task-editor header { justify-content:space-between; }.xmind-task-editor h2 { font-size:14px; font-weight:600; }
|
||||
.xmind-task-name { margin:14px 0; font-size:15px; font-weight:600; overflow-wrap:anywhere; }
|
||||
.xmind-task-fields { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.xmind-task-fields label { display:flex; flex-direction:column; gap:5px; }
|
||||
.xmind-task-editor input,.xmind-task-editor select { width:100%; min-width:0; height:32px; padding:0 8px; border:1px solid #dfe2e7; background:white; border-radius:6px; outline:none; }
|
||||
.xmind-task-editor input:focus,.xmind-task-editor select:focus { border-color:#799ce8; }
|
||||
.xmind-task-editor h3 { font-weight:600; margin:16px 0 8px; }
|
||||
.xmind-task-dependencies>div { display:flex; justify-content:space-between; padding:6px 0; }.xmind-task-add-dependency { display:grid; grid-template-columns:1fr 1fr 50px 34px; gap:5px; }
|
||||
.xmind-task-editor footer { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:20px; }.xmind-task-editor footer button { border-radius:6px; padding:7px 11px; }.xmind-task-editor footer button:first-child { margin-right:auto; }
|
||||
.xmind-primary-button { background:#3569e8; color:white; }.xmind-danger,.xmind-task-error { color:#c23c36; }.xmind-task-error { margin-top:10px; }
|
||||
.xmind-gantt { position:absolute; inset:0; z-index:75; display:flex; flex-direction:column; background:#edf0f3; color:#30343b; font-size:12px; }
|
||||
.xmind-gantt-toolbar { display:flex; align-items:center; justify-content:space-between; min-height:58px; padding:8px 16px; gap:10px; }.xmind-gantt-title,.xmind-gantt-actions { display:flex; align-items:center; gap:12px; }.xmind-gantt-title h2 { font-size:15px; font-weight:600; white-space:nowrap; }.xmind-gantt-title>span { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#7d8592; }
|
||||
.xmind-gantt-actions>button,.xmind-gantt-title>button { display:grid; place-items:center; width:32px; height:32px; background:#fafbfc; border-radius:12px; }.xmind-gantt button:disabled { opacity:.35; cursor:default; }.xmind-gantt-zoom { display:flex; align-items:center; gap:8px; padding:6px 10px; background:#fafbfc; border-radius:16px; }.xmind-gantt-zoom input { width:90px; }
|
||||
.xmind-gantt-filters { display:flex; align-items:center; gap:8px; padding:0 16px 10px; flex-wrap:wrap; }.xmind-gantt-filters label { display:flex; align-items:center; gap:6px; background:#fafbfc; padding:0 8px; border:1px solid #e0e3e8; border-radius:6px; }.xmind-gantt-filters input { width:130px; height:28px; outline:none; background:transparent; }.xmind-gantt-filters select { max-width:210px; height:30px; border:1px solid #e0e3e8; background:#fafbfc; padding:0 8px; border-radius:6px; }.xmind-gantt-filters>span { margin-left:auto; color:#7d8592; }
|
||||
.xmind-gantt-body { min-height:0; flex:1; display:grid; grid-template-columns:minmax(290px,34%) minmax(0,1fr); gap:12px; padding:0 12px; }.xmind-gantt-table,.xmind-gantt-timeline { border:1px solid #dfe3e9; border-radius:12px; background:#fafbfc; overflow:hidden; min-width:0; position:relative; }.xmind-gantt-table { display:flex; flex-direction:column; }.xmind-gantt-table-head,.xmind-gantt-row { display:grid; grid-template-columns:minmax(0,1fr) 104px 54px; align-items:center; }.xmind-gantt-table-head { height:64px; min-height:64px; font-weight:600; border-bottom:1px solid #e2e5eb; }.xmind-gantt-table-head>span { display:flex; align-items:center; height:100%; padding:0 12px; border-right:1px solid #e2e5eb; }.xmind-gantt-row-scroll { overflow:auto; scrollbar-width:none; flex:1; position:relative; }.xmind-gantt-row { border-bottom:1px solid #e2e5eb; }.xmind-gantt-row.is-selected { background:#edf3ff; }.xmind-gantt-row>span { padding:0 7px; }.xmind-gantt-row-name { display:flex; align-items:center; gap:5px; overflow:hidden; }.xmind-gantt-row-name input { flex-shrink:0; }.xmind-gantt-task-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }.xmind-gantt-date { font-size:11px; text-align:left; padding:0 8px; }
|
||||
.xmind-gantt-timeline { overflow:auto; }.xmind-gantt-grid { position:relative; background-image:linear-gradient(to right,#e7e9ee 1px,transparent 1px),linear-gradient(to bottom,#e7e9ee 1px,transparent 1px); background-position:0 64px; }.xmind-gantt-calendar { position:sticky; top:0; z-index:5; background:#fafbfc; border-bottom:1px solid #e2e5eb; }.xmind-gantt-day { position:absolute; top:0; height:64px; display:flex; flex-direction:column; justify-content:space-around; align-items:center; border-right:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }.xmind-gantt-day span { font-size:10px; color:#8b929d; }.xmind-gantt-day b { font-weight:500; }.xmind-gantt-today { position:absolute; bottom:0; width:2px; background:#fa603b; pointer-events:none; z-index:2; }.xmind-gantt-connections { position:absolute; inset:0; pointer-events:none; z-index:2; }.xmind-gantt-bar { position:absolute; height:26px; border-radius:16px; background:#fac28a; cursor:grab; touch-action:none; z-index:3; overflow:hidden; min-width:6px; border:1px solid #f5b978; }.xmind-gantt-bar:focus { outline:2px solid #3569e8; outline-offset:2px; }.xmind-gantt-progress { position:absolute; inset:0 auto 0 0; background:#f3a660; }.xmind-gantt-bar.is-complete { background:#b8dcca; border-color:#68a986; }.xmind-gantt-bar.is-complete .xmind-gantt-progress { background:#86b99b; }.xmind-gantt-bar.is-summary { height:10px; margin-top:4px; background:#667387; border-color:#667387; cursor:default; }.xmind-gantt-bar-label { position:relative; display:flex; align-items:center; gap:8px; padding:0 6px; height:24px; color:#72491f; font-size:11px; pointer-events:none; white-space:nowrap; }
|
||||
.xmind-gantt-bar-label>span:last-child { flex:1; overflow:hidden; text-overflow:ellipsis; text-align:center; }
|
||||
.xmind-gantt-avatar { width:18px; height:18px; border-radius:50%; background:#eceff1; color:#939ba2; display:grid; place-items:center; flex-shrink:0; }
|
||||
.xmind-gantt-selection { position:absolute; left:0; background:rgb(125 136 148 / .15); pointer-events:none; z-index:1; }
|
||||
.xmind-gantt-bar[aria-pressed=true] { outline:2px solid #539efa; outline-offset:1px; }.xmind-gantt-bar.is-summary .xmind-gantt-bar-label { display:none; }.xmind-gantt-resize { position:absolute; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:4; }.xmind-gantt-resize.is-start { left:0; }.xmind-gantt-resize.is-due { right:0; }
|
||||
.xmind-gantt-empty { min-height:300px; height:70%; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#a0a6af; gap:10px; }.xmind-gantt-empty strong { font-size:15px; color:#424852; }.xmind-gantt-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:32px; padding:0 16px; color:#7d8592; font-size:11px; }
|
||||
@media(max-width:800px) { .xmind-gantt-title>span { display:none; }.xmind-gantt-body { grid-template-columns:260px minmax(0,1fr); gap:6px; padding:0 6px; }.xmind-gantt-table-head,.xmind-gantt-row { grid-template-columns:minmax(0,1fr) 84px 42px; }.xmind-gantt-actions { gap:5px; }.xmind-gantt-toolbar { padding:8px; }.xmind-gantt-zoom input { width:50px; } }
|
||||
@media print { .xmind-workspace:has(.xmind-gantt)>:not(.xmind-gantt) { display:none!important; }.xmind-gantt { position:fixed; background:white; }.xmind-gantt-toolbar button,.xmind-gantt-actions,.xmind-gantt-filters,.xmind-gantt-footer { display:none; }.xmind-gantt-timeline,.xmind-gantt-row-scroll { overflow:visible; }.xmind-gantt-body { overflow:visible; }.xmind-gantt-table { overflow:visible; }.xmind-gantt-row-name input { display:none; } }
|
||||
|
||||
.xmind-gantt-grid { min-height:100%; }
|
||||
.xmind-gantt-day { top:28px; height:36px; }
|
||||
.xmind-gantt-month { position:absolute; top:0; height:28px; padding:6px 12px; text-align:center; color:#7d8592; border-right:1px solid #e2e5eb; border-bottom:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }
|
||||
|
||||
.xmind-topic-badges { position:absolute; inset:0; pointer-events:none; z-index:12; overflow:hidden; }
|
||||
.xmind-topic-badge { position:absolute; display:flex; align-items:center; gap:2px; pointer-events:auto; }
|
||||
.xmind-topic-badge button { display:grid; place-items:center; width:19px; height:19px; background:rgb(255 255 255 / .9); border-radius:4px; color:#8b929d; }
|
||||
.xmind-topic-badge button:hover { background:#edf3ff; color:#3569e8; }
|
||||
|
||||
.xmind-note-content p:not([data-note-list=number]) { counter-reset:note-number; }
|
||||
|
||||
.xmind-gantt-day-off { position:absolute; background:rgb(111 124 139 / .09); pointer-events:none; z-index:0; }
|
||||
.xmind-gantt-day.is-day-off { color:#929aa6; background:#eef0f3; }
|
||||
.xmind-task-calendar-info { display:flex; justify-content:space-between; align-items:center; gap:10px; margin:12px 0; color:#7d8592; font-size:11px; }
|
||||
.xmind-task-calendar-info button { padding:6px 10px; border:1px solid #dce1e8; border-radius:6px; background:#f7f8fa; color:#3b4452; }
|
||||
.xmind-project-calendar-backdrop,.xmind-calendar-exception-backdrop { position:absolute; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; background:rgb(29 36 46 / .22); padding:16px; }
|
||||
.xmind-calendar-exception-backdrop { position:fixed; z-index:110; }
|
||||
.xmind-project-calendar,.xmind-calendar-exception { display:flex; flex-direction:column; width:740px; max-width:100%; max-height:calc(100svh - 32px); color:#343b48; font-size:12px; background:#f7f8fa; border:1px solid #dce1e8; border-radius:14px; box-shadow:0 18px 60px rgb(0 0 0 / .17); overflow:hidden; }
|
||||
.xmind-project-calendar>header,.xmind-calendar-exception>header { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px; }
|
||||
.xmind-project-calendar h2,.xmind-calendar-exception h2 { display:flex; align-items:center; gap:9px; font-size:16px; font-weight:600; }
|
||||
.xmind-project-calendar>header p { margin-top:6px; font-size:11px; color:#8b929d; }
|
||||
.xmind-project-calendar>header button,.xmind-calendar-exception>header button { padding:4px; border-radius:5px; }
|
||||
.xmind-project-calendar-content { display:grid; grid-template-columns:290px minmax(0,1fr); gap:12px; padding:0 20px 18px; min-height:0; overflow:auto; }
|
||||
.xmind-calendar-preview,.xmind-calendar-settings { padding:15px; border:1px solid #e0e4ea; border-radius:10px; background:#fff; }
|
||||
.xmind-project-calendar label,.xmind-calendar-exception label { display:flex; flex-direction:column; gap:6px; font-size:11px; }
|
||||
.xmind-project-calendar input:not([type=checkbox]),.xmind-project-calendar select,.xmind-calendar-exception input,.xmind-calendar-exception select { height:31px; padding:0 9px; border:1px solid #dce1e8; background:#fff; border-radius:6px; font-size:12px; outline:none; min-width:0; width:100%; }
|
||||
.xmind-project-calendar input:focus,.xmind-project-calendar select:focus,.xmind-calendar-exception input:focus,.xmind-calendar-exception select:focus { border-color:#6592ef; box-shadow:0 0 0 2px rgb(53 105 232 / .1); }
|
||||
.xmind-project-calendar h3 { font-size:12px; font-weight:600; }
|
||||
.xmind-calendar-import { display:flex; align-items:flex-end; gap:8px; margin-bottom:18px; }.xmind-calendar-import label { flex:1; min-width:0; }.xmind-calendar-import>button { color:#3569e8; font-size:11px; margin-bottom:8px; }
|
||||
.xmind-calendar-month-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:9px; }.xmind-calendar-month-header button { padding:5px; }.xmind-calendar-month-header strong { font-weight:500; }
|
||||
.xmind-calendar-month-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }.xmind-calendar-month-grid>button,.xmind-calendar-weekday { height:29px; display:grid; place-items:center; border-radius:5px; font-size:11px; }
|
||||
.xmind-calendar-weekday { color:#8b929d; }.xmind-calendar-month-grid .is-day-off { background:repeating-linear-gradient(135deg,#f1f3f6,#f1f3f6 3px,#e9ecf0 3px,#e9ecf0 4px); color:#89929e; }.xmind-calendar-month-grid .is-other-month { opacity:.4; }.xmind-calendar-month-grid>button[aria-pressed=true] { background:#edf3ff; box-shadow:inset 0 0 0 1px #6592ef; color:#3569e8; }
|
||||
.xmind-calendar-hint { color:#929aa6; font-size:10px; line-height:1.6; margin-top:9px; }
|
||||
.xmind-project-calendar .xmind-calendar-skip { flex-direction:row; align-items:center; gap:7px; margin:18px 0; font-size:12px; }.xmind-calendar-working-week { display:flex; justify-content:space-between; gap:5px; padding:12px 0 18px; border-bottom:1px solid #e9edf2; }.xmind-calendar-working-week label { align-items:center; }
|
||||
.xmind-calendar-rule-heading { display:flex; align-items:center; justify-content:space-between; margin:16px 0 9px; }.xmind-calendar-rule-heading button { padding:5px; background:#f2f4f7; border-radius:5px; }.xmind-calendar-rules { max-height:225px; overflow:auto; }.xmind-calendar-rules>div { display:flex; justify-content:space-between; align-items:center; gap:7px; padding:9px 0; border-bottom:1px solid #eef0f4; }.xmind-calendar-rules>div>button:first-child { flex:1; text-align:left; min-width:0; }.xmind-calendar-rules strong,.xmind-calendar-rules span { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.xmind-calendar-rules strong { font-weight:500; }.xmind-calendar-rules span { font-size:10px; color:#8b929d; margin-top:5px; }.xmind-calendar-rules>div>button:last-child { padding:6px; color:#8b929d; }.xmind-calendar-rules>p { color:#929aa6; font-size:11px; padding:10px 0; }
|
||||
.xmind-project-calendar>footer,.xmind-calendar-exception>footer { padding:13px 20px; display:flex; justify-content:space-between; gap:10px; border-top:1px solid #e2e6ec; background:#fff; }.xmind-project-calendar>footer>div { display:flex; gap:8px; }.xmind-project-calendar>footer button,.xmind-calendar-exception>footer button,.xmind-calendar-personal>button { padding:8px 12px; border:1px solid #dce1e8; border-radius:6px; font-size:11px; }.xmind-project-calendar>footer .xmind-primary-button,.xmind-calendar-exception>footer .xmind-primary-button { border-color:#3569e8; }
|
||||
.xmind-calendar-personal { border-top:1px solid #e6e9ee; margin-top:14px; padding-top:12px; }.xmind-calendar-personal p { color:#929aa6; font-size:10px; margin:7px 0; }.xmind-calendar-personal>div { display:flex; justify-content:space-between; align-items:center; margin:9px 0; }.xmind-calendar-personal>label { margin:12px 0 8px; }
|
||||
.xmind-calendar-exception { width:420px; }.xmind-calendar-exception-fields { display:flex; flex-direction:column; gap:12px; padding:0 20px 20px; overflow:auto; }.xmind-calendar-range { display:grid; grid-template-columns:1fr 1fr; gap:12px; }.xmind-calendar-exception>footer { justify-content:flex-end; }
|
||||
@media(max-width:700px) { .xmind-project-calendar-content { grid-template-columns:1fr; }.xmind-calendar-preview { max-width:360px; width:100%; justify-self:center; }.xmind-project-calendar>footer { flex-wrap:wrap; }.xmind-project-calendar>footer>div:last-child { margin-left:auto; } }
|
||||
|
||||
.xmind-notes-popover { position:absolute; z-index:40; width:min(360px,calc(100vw - 32px)); padding:8px; background:#fafbfc; border:1px solid #e5e8ed; border-radius:18px; box-shadow:0 14px 44px rgb(0 0 0 / .14); color:#30343b; }
|
||||
.xmind-notes-popover>header { display:flex; align-items:center; justify-content:space-between; padding:0 6px; height:24px; font-size:11px; color:#9198a3; }.xmind-notes-popover>header>span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.xmind-notes-popover>header>div { display:flex; align-items:center; gap:8px; }
|
||||
.xmind-notes-popover .xmind-notes-editor { border:0; border-radius:12px; }.xmind-notes-popover .xmind-notes-toolbar { border-bottom:0; padding:4px; }.xmind-notes-popover .xmind-note-content { min-height:220px; max-height:280px; padding:8px; }.xmind-note-empty { min-height:220px; padding:16px; font-size:12px; color:#8b929d; }
|
||||
|
||||
.xmind-image-export { width:400px; max-width:calc(100vw - 32px); border:1px solid #e5e5e8; border-radius:14px; padding:24px; color:#303034; background:white; box-shadow:0 18px 60px #0002; font:14px NeverMind,Arial,"PingFang SC",sans-serif; }
|
||||
.xmind-image-export::backdrop { background:#0004; }
|
||||
.xmind-image-export header,.xmind-image-export footer { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.xmind-image-export h2 { margin:0; font-size:18px; font-weight:600; }
|
||||
.xmind-image-export p { color:#737378; margin:18px 0 24px; line-height:1.6; }
|
||||
.xmind-image-export button { display:inline-flex; gap:7px; align-items:center; justify-content:center; min-height:34px; border:0; border-radius:7px; padding:7px 12px; background:#f2f2f4; color:inherit; cursor:pointer; }
|
||||
.xmind-image-export button:disabled { opacity:.55; cursor:wait; }
|
||||
.xmind-image-export button.xmind-primary-button { background:#2463eb; color:white; }
|
||||
.xmind-image-export-field { display:flex; align-items:center; justify-content:space-between; gap:20px; }
|
||||
.xmind-image-export select { width:220px; height:36px; background:white; border:1px solid #dcdce2; border-radius:7px; padding:0 10px; color:inherit; }
|
||||
.xmind-image-export-check { display:flex; align-items:center; gap:8px; margin:20px 0 28px; }
|
||||
.xmind-image-export-check input { accent-color:#2463eb; }
|
||||
.xmind-image-export footer { justify-content:flex-end; }
|
||||
318
components/flow-board/xmind-notes-editor.tsx
Normal file
@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
Link2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createEditor,
|
||||
Editor,
|
||||
Element,
|
||||
Transforms,
|
||||
type Descendant,
|
||||
} from "slate";
|
||||
import { withHistory } from "slate-history";
|
||||
import {
|
||||
Editable,
|
||||
Slate,
|
||||
withReact,
|
||||
ReactEditor,
|
||||
type RenderElementProps,
|
||||
type RenderLeafProps,
|
||||
} from "slate-react";
|
||||
import {
|
||||
type NoteDocument,
|
||||
type NoteParagraph,
|
||||
safeNoteLink,
|
||||
serializeNoteDocument,
|
||||
} from "@/lib/flow-board/topic-notes";
|
||||
|
||||
export function XmindNotesEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: NoteDocument;
|
||||
onChange: (value: NoteDocument) => void;
|
||||
}) {
|
||||
const [editor] = useState(() => withHistory(withReact(createEditor())));
|
||||
const sourceRef = useRef(value);
|
||||
sourceRef.current = value;
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [linkOpen, setLinkOpen] = useState(false),
|
||||
[link, setLink] = useState("");
|
||||
const [linkError, setLinkError] = useState("");
|
||||
const savedSelection = useRef(editor.selection);
|
||||
useEffect(() => {
|
||||
if (
|
||||
JSON.stringify(serializeNoteDocument(editor.children as NoteDocument)) ===
|
||||
JSON.stringify(serializeNoteDocument(value))
|
||||
)
|
||||
return;
|
||||
editor.children = value as Descendant[];
|
||||
editor.selection = null;
|
||||
editor.history = { undos: [], redos: [] };
|
||||
editor.onChange();
|
||||
}, [editor, value]);
|
||||
const marks = Editor.marks(editor) as Record<string, unknown> | null;
|
||||
// Toolbar presses can arrive before Slate's throttled selection listener.
|
||||
const syncDOMSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (
|
||||
!selection?.anchorNode ||
|
||||
!ReactEditor.hasDOMNode(editor, selection.anchorNode)
|
||||
)
|
||||
return;
|
||||
const range = ReactEditor.toSlateRange(editor, selection, {
|
||||
exactMatch: false,
|
||||
suppressThrow: true,
|
||||
});
|
||||
if (range) Transforms.select(editor, range);
|
||||
};
|
||||
const toggleMark = (
|
||||
mark: "bold" | "italic" | "underline" | "strikethrough"
|
||||
) => {
|
||||
syncDOMSelection();
|
||||
const currentMarks = Editor.marks(editor) as Record<string, unknown> | null;
|
||||
if (currentMarks?.[mark]) Editor.removeMark(editor, mark);
|
||||
else Editor.addMark(editor, mark, true);
|
||||
setRevision((v) => v + 1);
|
||||
};
|
||||
const toggleList = (list: "bullet" | "number") => {
|
||||
syncDOMSelection();
|
||||
const match = Array.from(
|
||||
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
|
||||
)[0];
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{
|
||||
list: (match?.[0] as NoteParagraph)?.list === list ? undefined : list,
|
||||
} as Partial<Descendant>,
|
||||
{ match: (n) => Element.isElement(n) }
|
||||
);
|
||||
};
|
||||
const applyLink = () => {
|
||||
const href = link.trim() ? safeNoteLink(link.trim()) : "";
|
||||
if (href === null) {
|
||||
setLinkError("请输入完整的网页或邮件链接。");
|
||||
return;
|
||||
}
|
||||
if (savedSelection.current)
|
||||
Transforms.select(editor, savedSelection.current);
|
||||
if (href) Editor.addMark(editor, "href", href);
|
||||
else Editor.removeMark(editor, "href");
|
||||
setLinkOpen(false);
|
||||
editor.onChange();
|
||||
};
|
||||
const leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
||||
const t = leaf as {
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikethrough?: boolean;
|
||||
href?: string;
|
||||
};
|
||||
const href = safeNoteLink(t.href || "");
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
style={{
|
||||
fontWeight: t.bold ? 700 : undefined,
|
||||
fontStyle: t.italic ? "italic" : undefined,
|
||||
textDecoration:
|
||||
[
|
||||
t.underline ? "underline" : "",
|
||||
t.strikethrough ? "line-through" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ") || undefined,
|
||||
}}
|
||||
>
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
title={href}
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => {
|
||||
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
|
||||
}}
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const element = ({ attributes, children, element }: RenderElementProps) => {
|
||||
const p = element as NoteParagraph;
|
||||
return (
|
||||
<p
|
||||
{...attributes}
|
||||
data-note-list={p.list}
|
||||
style={{ marginLeft: Math.max(0, Math.min(120, p.indent || 0)) }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="xmind-notes-editor" data-editor-revision={revision}>
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={value as Descendant[]}
|
||||
onChange={(next) => {
|
||||
setRevision((v) => v + 1);
|
||||
if (editor.operations.every((o) => o.type === "set_selection"))
|
||||
return;
|
||||
const doc = next as NoteDocument;
|
||||
if (
|
||||
JSON.stringify(serializeNoteDocument(doc)) !==
|
||||
JSON.stringify(serializeNoteDocument(sourceRef.current))
|
||||
)
|
||||
onChangeRef.current(doc);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="xmind-notes-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="笔记格式"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["bold", "加粗", Bold],
|
||||
["italic", "斜体", Italic],
|
||||
["underline", "下划线", Underline],
|
||||
["strikethrough", "删除线", Strikethrough],
|
||||
] as const
|
||||
).map(([mark, label, Icon]) => (
|
||||
<button
|
||||
key={mark}
|
||||
type="button"
|
||||
aria-label={`笔记${label}`}
|
||||
aria-pressed={Boolean(marks?.[mark])}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleMark(mark);
|
||||
}}
|
||||
>
|
||||
<Icon size={17} />
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="笔记无序列表"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleList("bullet");
|
||||
}}
|
||||
>
|
||||
<List size={17} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="笔记有序列表"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleList("number");
|
||||
}}
|
||||
>
|
||||
<ListOrdered size={17} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="笔记链接"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
syncDOMSelection();
|
||||
savedSelection.current = editor.selection;
|
||||
setLink(String(marks?.href || ""));
|
||||
setLinkError("");
|
||||
setLinkOpen(!linkOpen);
|
||||
}}
|
||||
>
|
||||
<Link2 size={17} />
|
||||
</button>
|
||||
</div>
|
||||
{linkOpen ? (
|
||||
<div className="xmind-note-link-form">
|
||||
<input
|
||||
aria-label="笔记链接地址"
|
||||
placeholder="https://…"
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
applyLink();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={applyLink}>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="取消笔记链接"
|
||||
onClick={() => setLinkOpen(false)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{linkError ? <p role="alert">{linkError}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Editable
|
||||
aria-label="主题备注"
|
||||
className="xmind-note-content"
|
||||
placeholder="为这个主题补充说明…"
|
||||
renderLeaf={leaf}
|
||||
renderElement={element}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
["b", "i", "u"].includes(e.key.toLowerCase())
|
||||
) {
|
||||
e.preventDefault();
|
||||
toggleMark(
|
||||
(
|
||||
{ b: "bold", i: "italic", u: "underline" } as Record<
|
||||
string,
|
||||
"bold" | "italic" | "underline"
|
||||
>
|
||||
)[e.key.toLowerCase()]
|
||||
);
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
|
||||
e.stopPropagation();
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const match = Array.from(
|
||||
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
|
||||
)[0];
|
||||
const indent = (match?.[0] as NoteParagraph)?.indent || 0;
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
{
|
||||
indent: Math.max(
|
||||
0,
|
||||
Math.min(120, indent + (e.shiftKey ? -20 : 20))
|
||||
),
|
||||
} as Partial<Descendant>,
|
||||
{ match: (n) => Element.isElement(n) }
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
components/flow-board/xmind-notes-popover.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
import {
|
||||
type PlaitBoard,
|
||||
type PlaitElement,
|
||||
toHostPointFromViewBoxPoint,
|
||||
toScreenPointFromHostPoint,
|
||||
} from "@plait/core";
|
||||
import { getRectangleByNode, MindElement } from "@plait/mind";
|
||||
import { MoreHorizontal, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
getNoteDocument,
|
||||
type NoteDocument,
|
||||
} from "@/lib/flow-board/topic-notes";
|
||||
import { getTopicTitle } from "@/lib/flow-board/topic-details";
|
||||
import { XmindNotesEditor } from "./xmind-notes-editor";
|
||||
export function XmindNotesPopover({
|
||||
board,
|
||||
topic,
|
||||
viewport,
|
||||
onChange,
|
||||
onDetails,
|
||||
onClose,
|
||||
}: {
|
||||
board: PlaitBoard | null;
|
||||
topic: PlaitElement | null;
|
||||
viewport: unknown;
|
||||
onChange: (doc: NoteDocument) => void;
|
||||
onDetails: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [position, setPosition] = useState({ left: 400, top: 300 });
|
||||
useEffect(() => {
|
||||
if (!board || !topic) return;
|
||||
const update = () => {
|
||||
const node = MindElement.getNode(topic as MindElement);
|
||||
if (!node) return;
|
||||
const rect = getRectangleByNode(node),
|
||||
point = toScreenPointFromHostPoint(
|
||||
board,
|
||||
toHostPointFromViewBoxPoint(board, [
|
||||
rect.x + rect.width / 2,
|
||||
rect.y + rect.height,
|
||||
])
|
||||
);
|
||||
const host = ref.current?.parentElement?.getBoundingClientRect();
|
||||
const width = Math.min(360, window.innerWidth - 32);
|
||||
const right = window.innerWidth > 800 ? 240 : 0;
|
||||
const left = Math.max(
|
||||
12,
|
||||
Math.min(window.innerWidth - right - width - 16, point[0] - width / 2)
|
||||
);
|
||||
const top = Math.max(
|
||||
78,
|
||||
Math.min(window.innerHeight - 340, point[1] + 12)
|
||||
);
|
||||
setPosition({ left: left - (host?.x || 0), top: top - (host?.y || 0) });
|
||||
};
|
||||
const frame = requestAnimationFrame(update);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [board, topic?.id, topic?.data, viewport]);
|
||||
return (
|
||||
<aside
|
||||
ref={ref}
|
||||
className="xmind-notes-popover"
|
||||
aria-label="主题笔记"
|
||||
style={position}
|
||||
data-flow-board-shell-control="true"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<span title={topic ? getTopicTitle(topic) : "笔记"}>
|
||||
{topic ? getTopicTitle(topic) : "笔记"}
|
||||
</span>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="主题详情"
|
||||
title="主题详情"
|
||||
onClick={onDetails}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
<button type="button" aria-label="关闭笔记" onClick={onClose}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{topic ? (
|
||||
<XmindNotesEditor
|
||||
key={topic.id}
|
||||
value={getNoteDocument(topic)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : (
|
||||
<p className="xmind-note-empty">选择一个主题以编辑笔记。</p>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
28
components/flow-board/xmind-presentation.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
|
||||
|
||||
export function XmindPresentation({ elements, title, onClose }: { elements: PlaitElement[]; title: string; onClose: () => void }) {
|
||||
const slides = getTopicEntries(elements).filter(({ element, depth }) => depth === 0 || element.children?.length);
|
||||
const [index,setIndex] = useState(0);
|
||||
const entry = slides[Math.min(index,slides.length - 1)];
|
||||
const next = () => setIndex(value => Math.min(slides.length - 1,value + 1));
|
||||
const previous = () => setIndex(value => Math.max(0,value - 1));
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") { event.preventDefault(); onClose(); }
|
||||
if (["ArrowRight","ArrowDown"," ","PageDown"].includes(event.key)) { event.preventDefault(); next(); }
|
||||
if (["ArrowLeft","ArrowUp","PageUp"].includes(event.key)) { event.preventDefault(); previous(); }
|
||||
};
|
||||
window.addEventListener("keydown",onKey);
|
||||
return () => window.removeEventListener("keydown",onKey);
|
||||
}, [onClose,slides.length]);
|
||||
return <section className="xmind-presentation" aria-label="演示视图" data-flow-board-shell-control="true">
|
||||
<header><span>{title}</span><button type="button" aria-label="退出演示" onClick={onClose}><X size={16} />退出演示</button></header>
|
||||
<div className="xmind-presentation-slide"><h1>{entry?.title || title}</h1><ul>{(entry?.element.children || []).map(child => <li key={child.id}>{getTopicTitle(child)}</li>)}</ul></div>
|
||||
<footer><button type="button" aria-label="上一页" disabled={index === 0} onClick={previous}><ChevronLeft size={18} /></button><span>{Math.min(index + 1,slides.length)} / {slides.length}</span><button type="button" aria-label="下一页" disabled={index >= slides.length - 1} onClick={next}><ChevronRight size={18} /></button></footer>
|
||||
</section>;
|
||||
}
|
||||
650
components/flow-board/xmind-project-calendar.tsx
Normal file
@ -0,0 +1,650 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
CALENDAR_FREQUENCIES,
|
||||
defaultWorkingCalendar,
|
||||
isWorkingDate,
|
||||
readWorkingCalendar,
|
||||
validateWorkingCalendar,
|
||||
type CalendarRule,
|
||||
type ProjectCalendarConfig,
|
||||
type ProjectWorkingCalendar,
|
||||
} from "@/lib/flow-board/project-calendar";
|
||||
import {
|
||||
addDays,
|
||||
dateIndex,
|
||||
DAY,
|
||||
indexDate,
|
||||
todayDate,
|
||||
} from "@/lib/flow-board/calendar-dates";
|
||||
const STORAGE_KEY = "flow-board.personal-calendars.v1";
|
||||
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
const FREQUENCY_LABELS: Record<CalendarRule["frequency"], string> = {
|
||||
NONE: "不重复",
|
||||
DAILY: "每天",
|
||||
WEEKLY: "每周",
|
||||
MONTHLY_BY_DATE: "每月(按日期)",
|
||||
MONTHLY_BY_WEEKDAY: "每月(按星期)",
|
||||
YEARLY: "每年",
|
||||
};
|
||||
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
function useCalendarDialogFocus(ref: React.RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
ref.current?.querySelector<HTMLElement>("input,button")?.focus();
|
||||
return () => {
|
||||
if (previous?.isConnected) previous.focus();
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
||||
function trapCalendarTab(
|
||||
event: React.KeyboardEvent,
|
||||
panel: HTMLElement | null
|
||||
) {
|
||||
if (event.key !== "Tab") return;
|
||||
const controls = Array.from(
|
||||
panel?.querySelectorAll<HTMLElement>(
|
||||
"button:not(:disabled),input:not(:disabled),select:not(:disabled)"
|
||||
) || []
|
||||
);
|
||||
const first = controls[0],
|
||||
last = controls.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
function CalendarExceptionEditor({
|
||||
rule,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
rule: CalendarRule;
|
||||
onSave: (rule: CalendarRule) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(() => copy(rule)),
|
||||
[error, setError] = useState("");
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useCalendarDialogFocus(ref);
|
||||
const patch = (changes: Partial<CalendarRule>) =>
|
||||
setDraft((value) => ({ ...value, ...changes }));
|
||||
const save = () => {
|
||||
const problem = validateWorkingCalendar({
|
||||
id: "validation",
|
||||
name: "",
|
||||
defaultWorkingDays: [1],
|
||||
rules: [draft],
|
||||
});
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
const selected = draft.selectedDates[0];
|
||||
if (
|
||||
(draft.effectiveRange?.start && selected < draft.effectiveRange.start) ||
|
||||
(draft.effectiveRange?.end && selected > draft.effectiveRange.end)
|
||||
) {
|
||||
setError("例外日期必须在生效范围内。");
|
||||
return;
|
||||
}
|
||||
onSave({ ...draft, name: draft.name.trim() || "例外" });
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="xmind-calendar-exception-backdrop"
|
||||
data-flow-board-shell-control="true"
|
||||
onKeyUp={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
trapCalendarTab(event, ref.current);
|
||||
if (event.key === "Escape") onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={ref}
|
||||
className="xmind-calendar-exception"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="工作日例外"
|
||||
>
|
||||
<header>
|
||||
<h2>工作日例外</h2>
|
||||
<button type="button" aria-label="关闭工作日例外" onClick={onClose}>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="xmind-calendar-exception-fields">
|
||||
<label>
|
||||
名称
|
||||
<input
|
||||
aria-label="例外名称"
|
||||
value={draft.name}
|
||||
onChange={(event) => patch({ name: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
日期
|
||||
<input
|
||||
type="date"
|
||||
aria-label="例外日期"
|
||||
value={draft.selectedDates[0] || ""}
|
||||
onChange={(event) =>
|
||||
patch({ selectedDates: [event.target.value] })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
类型
|
||||
<select
|
||||
aria-label="例外类型"
|
||||
value={draft.type}
|
||||
onChange={(event) =>
|
||||
patch({ type: event.target.value as CalendarRule["type"] })
|
||||
}
|
||||
>
|
||||
<option value="NON_WORKING_DAY">休息日</option>
|
||||
<option value="WORKING_DAY">工作日(补班)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
重复
|
||||
<select
|
||||
aria-label="例外重复规则"
|
||||
value={draft.frequency}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
frequency: event.target.value as CalendarRule["frequency"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{CALENDAR_FREQUENCIES.map((frequency) => (
|
||||
<option key={frequency} value={frequency}>
|
||||
{FREQUENCY_LABELS[frequency]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{draft.frequency !== "NONE" ? (
|
||||
<label>
|
||||
每隔
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
aria-label="例外重复间隔"
|
||||
value={draft.interval || 1}
|
||||
onChange={(event) =>
|
||||
patch({ interval: Number(event.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="xmind-calendar-range">
|
||||
<label>
|
||||
生效开始
|
||||
<input
|
||||
type="date"
|
||||
aria-label="例外生效开始"
|
||||
value={draft.effectiveRange?.start || ""}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
effectiveRange: {
|
||||
...draft.effectiveRange,
|
||||
start: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
生效结束
|
||||
<input
|
||||
type="date"
|
||||
aria-label="例外生效结束"
|
||||
value={draft.effectiveRange?.end || ""}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
effectiveRange: {
|
||||
...draft.effectiveRange,
|
||||
end: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="xmind-calendar-hint">
|
||||
留空表示不限制生效日期。后添加的例外优先。
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="xmind-task-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="xmind-primary-button" onClick={save}>
|
||||
保存例外
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function XmindProjectCalendar({
|
||||
config,
|
||||
onSave,
|
||||
onClose,
|
||||
}: {
|
||||
config: ProjectCalendarConfig;
|
||||
onSave: (config: ProjectCalendarConfig) => boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useCalendarDialogFocus(ref);
|
||||
const [draft, setDraft] = useState(() =>
|
||||
copy(config.settings || defaultWorkingCalendar())
|
||||
);
|
||||
const [skip, setSkip] = useState(config.settings ? config.skip : true),
|
||||
[error, setError] = useState("");
|
||||
const [selectedDate, setSelectedDate] = useState(todayDate()),
|
||||
[month, setMonth] = useState(todayDate().slice(0, 7));
|
||||
const [exception, setException] = useState<CalendarRule | null>(null),
|
||||
[manage, setManage] = useState(false);
|
||||
const [templates, setTemplates] = useState<ProjectWorkingCalendar[]>(() => {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
|
||||
return Array.isArray(stored)
|
||||
? stored
|
||||
.map(readWorkingCalendar)
|
||||
.filter((item): item is ProjectWorkingCalendar => Boolean(item))
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const storeTemplates = (value: ProjectWorkingCalendar[]) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||
setTemplates(value);
|
||||
return true;
|
||||
} catch {
|
||||
setError("个人日历保存失败,请检查浏览器存储空间。");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const save = () => {
|
||||
const problem = validateWorkingCalendar(draft);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
onSave({
|
||||
settings: { ...draft, name: draft.name.trim() || "项目日历" },
|
||||
skip,
|
||||
})
|
||||
)
|
||||
onClose();
|
||||
};
|
||||
const saveTemplate = () => {
|
||||
const problem = validateWorkingCalendar(draft);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
if (!templateName.trim()) {
|
||||
setError("请输入个人日历名称。");
|
||||
return;
|
||||
}
|
||||
const value = {
|
||||
...copy(draft),
|
||||
id: crypto.randomUUID(),
|
||||
name: templateName.trim(),
|
||||
};
|
||||
if (storeTemplates([...templates, value])) {
|
||||
setTemplateName("");
|
||||
setError("");
|
||||
setMessage(`已保存个人日历:${value.name}`);
|
||||
}
|
||||
};
|
||||
const moveMonth = (step: number) => {
|
||||
const date = new Date(`${month}-01T00:00:00Z`);
|
||||
date.setUTCMonth(date.getUTCMonth() + step);
|
||||
setMonth(date.toISOString().slice(0, 7));
|
||||
};
|
||||
const first = dateIndex(`${month}-01`),
|
||||
offset = (new Date(first * DAY).getUTCDay() + 6) % 7;
|
||||
const days = Array.from({ length: 42 }, (_, index) =>
|
||||
indexDate(first - offset + index)
|
||||
);
|
||||
const addException = () =>
|
||||
setException({
|
||||
id: crypto.randomUUID(),
|
||||
name: "",
|
||||
type: isWorkingDate(selectedDate, draft)
|
||||
? "NON_WORKING_DAY"
|
||||
: "WORKING_DAY",
|
||||
frequency: "NONE",
|
||||
selectedDates: [selectedDate],
|
||||
effectiveRange: { start: selectedDate, end: selectedDate },
|
||||
});
|
||||
return (
|
||||
<div
|
||||
className="xmind-project-calendar-backdrop"
|
||||
data-flow-board-shell-control="true"
|
||||
onKeyUp={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (exception) return;
|
||||
trapCalendarTab(event, ref.current);
|
||||
if (event.key === "Escape") onClose();
|
||||
}}
|
||||
>
|
||||
<section
|
||||
ref={ref}
|
||||
className="xmind-project-calendar"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="项目工作日"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2>
|
||||
<CalendarDays size={19} />
|
||||
项目工作日
|
||||
</h2>
|
||||
<p>只应用于当前工作表,不影响其他工作表。</p>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭项目工作日" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="xmind-project-calendar-content">
|
||||
<div className="xmind-calendar-preview">
|
||||
<div className="xmind-calendar-import">
|
||||
<label>
|
||||
导入日历
|
||||
<select
|
||||
aria-label="导入个人日历"
|
||||
value=""
|
||||
onChange={(event) => {
|
||||
const value =
|
||||
event.target.value === "basic"
|
||||
? { ...defaultWorkingCalendar(), name: "基本日历" }
|
||||
: templates.find(
|
||||
(item) => item.id === event.target.value
|
||||
);
|
||||
if (value) {
|
||||
setDraft(copy(value));
|
||||
setError("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">当前项目:{draft.name}</option>
|
||||
<option value="basic">基本日历(周一至周五)</option>
|
||||
{templates.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManage((value) => !value)}
|
||||
>
|
||||
管理
|
||||
</button>
|
||||
</div>
|
||||
<div className="xmind-calendar-month-header">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="上个月"
|
||||
onClick={() => moveMonth(-1)}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<strong>{month.replace("-", "年")}月</strong>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="下个月"
|
||||
onClick={() => moveMonth(1)}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="xmind-calendar-month-grid" aria-label="工作日预览">
|
||||
{WEEKDAYS.map((day) => (
|
||||
<span className="xmind-calendar-weekday" key={day}>
|
||||
{day}
|
||||
</span>
|
||||
))}
|
||||
{days.map((date) => (
|
||||
<button
|
||||
type="button"
|
||||
key={date}
|
||||
data-calendar-date={date}
|
||||
data-working={isWorkingDate(date, draft)}
|
||||
aria-label={`${date} ${isWorkingDate(date, draft) ? "工作日" : "休息日"}`}
|
||||
aria-pressed={selectedDate === date}
|
||||
className={`${date.slice(0, 7) !== month ? "is-other-month" : ""} ${isWorkingDate(date, draft) ? "" : "is-day-off"}`}
|
||||
onClick={() => setSelectedDate(date)}
|
||||
>
|
||||
{Number(date.slice(8))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="xmind-calendar-hint">
|
||||
阴影为休息日。选择日期后可添加例外。
|
||||
</p>
|
||||
{manage ? (
|
||||
<div className="xmind-calendar-personal">
|
||||
<h3>个人日历</h3>
|
||||
<p>保存在当前浏览器,可用于其他工作表。</p>
|
||||
{templates.length ? (
|
||||
templates.map((item) => (
|
||||
<div key={item.id}>
|
||||
<span>{item.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除个人日历 ${item.name}`}
|
||||
onClick={() =>
|
||||
storeTemplates(
|
||||
templates.filter((value) => value.id !== item.id)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p>还没有个人日历。</p>
|
||||
)}
|
||||
<label>
|
||||
名称
|
||||
<input
|
||||
aria-label="个人日历名称"
|
||||
value={templateName}
|
||||
onChange={(event) => setTemplateName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={saveTemplate}>
|
||||
保存为个人日历
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="xmind-calendar-settings">
|
||||
<label>
|
||||
日历名称
|
||||
<input
|
||||
aria-label="项目日历名称"
|
||||
value={draft.name}
|
||||
onChange={(event) =>
|
||||
setDraft((value) => ({ ...value, name: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="xmind-calendar-skip">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="跳过非工作日"
|
||||
checked={skip}
|
||||
onChange={(event) => setSkip(event.target.checked)}
|
||||
/>
|
||||
跳过非工作日
|
||||
</label>
|
||||
<h3>每周工作日</h3>
|
||||
<div className="xmind-calendar-working-week">
|
||||
{WEEKDAYS.map((day, index) => (
|
||||
<label key={day}>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`周${day}为工作日`}
|
||||
checked={draft.defaultWorkingDays.includes(index + 1)}
|
||||
onChange={(event) => {
|
||||
const value = event.target.checked
|
||||
? [...draft.defaultWorkingDays, index + 1].sort()
|
||||
: draft.defaultWorkingDays.filter(
|
||||
(value) => value !== index + 1
|
||||
);
|
||||
if (!value.length) {
|
||||
setError("请至少保留一个每周工作日。");
|
||||
return;
|
||||
}
|
||||
setDraft((settings) => ({
|
||||
...settings,
|
||||
defaultWorkingDays: value,
|
||||
}));
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
{day}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="xmind-calendar-rule-heading">
|
||||
<h3>例外</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="添加工作日例外"
|
||||
onClick={addException}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="xmind-calendar-rules">
|
||||
{[...draft.rules].reverse().map((rule) => (
|
||||
<div key={rule.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`编辑工作日例外 ${rule.name}`}
|
||||
onClick={() => setException(copy(rule))}
|
||||
>
|
||||
<strong>{rule.name}</strong>
|
||||
<span>
|
||||
{rule.type === "WORKING_DAY" ? "补班" : "休息日"} ·{" "}
|
||||
{rule.selectedDates.join("、")} ·{" "}
|
||||
{FREQUENCY_LABELS[rule.frequency]}
|
||||
{rule.interval && rule.interval > 1
|
||||
? `(间隔 ${rule.interval})`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除工作日例外 ${rule.name}`}
|
||||
onClick={() =>
|
||||
setDraft((value) => ({
|
||||
...value,
|
||||
rules: value.rules.filter(
|
||||
(item) => item.id !== rule.id
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!draft.rules.length ? (
|
||||
<p>可添加节假日、调休或重复例外。</p>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="xmind-task-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{message ? <p role="status">{message}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<div>
|
||||
{config.settings ? (
|
||||
<button
|
||||
type="button"
|
||||
className="xmind-danger"
|
||||
onClick={() => {
|
||||
if (onSave({ settings: null, skip: false })) onClose();
|
||||
}}
|
||||
>
|
||||
移除项目日历
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setManage(true)}>
|
||||
保存为日历
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="xmind-primary-button"
|
||||
onClick={save}
|
||||
>
|
||||
保存工作日
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
{exception ? (
|
||||
<CalendarExceptionEditor
|
||||
rule={exception}
|
||||
onSave={(rule) =>
|
||||
setDraft((value) => ({
|
||||
...value,
|
||||
rules: value.rules.some((item) => item.id === rule.id)
|
||||
? value.rules.map((item) => (item.id === rule.id ? rule : item))
|
||||
: [...value.rules, rule],
|
||||
}))
|
||||
}
|
||||
onClose={() => setException(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
components/flow-board/xmind-search-panel.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { searchTopics, type SearchSheet, type TopicSearchResult } from "@/lib/flow-board/topic-search";
|
||||
|
||||
export function XmindSearchPanel({ sheets, activeSheetId, onJump, onReplace, onClose }: {
|
||||
sheets: SearchSheet[];
|
||||
activeSheetId: string;
|
||||
onJump: (result: TopicSearchResult) => void;
|
||||
onReplace: (results: TopicSearchResult[], query: string, replacement: string, caseSensitive: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [replacement, setReplacement] = useState("");
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [scope, setScope] = useState("current");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const results = searchTopics(scope === "all" ? sheets : sheets.filter(sheet => sheet.id === activeSheetId),query,caseSensitive);
|
||||
useEffect(() => { input.current?.focus(); }, []);
|
||||
useEffect(() => { setActiveIndex(0); }, [query,caseSensitive,scope]);
|
||||
const navigate = (step: number) => {
|
||||
if (!results.length) return;
|
||||
const next = (Math.min(activeIndex,results.length - 1) + step + results.length) % results.length;
|
||||
setActiveIndex(next); onJump(results[next]);
|
||||
};
|
||||
return <aside className="xmind-search-panel" aria-label="查找与替换" data-flow-board-shell-control="true" onKeyDown={event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); }
|
||||
}}>
|
||||
<div className="xmind-panel-heading"><span>查找与替换</span><button type="button" aria-label="关闭查找" onClick={onClose}><X size={16} /></button></div>
|
||||
<div className="xmind-panel-content">
|
||||
<label className="xmind-search-box"><Search size={15} /><input ref={input} aria-label="查找主题" placeholder="查找主题" value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); navigate(e.shiftKey ? -1 : 1); } }} /></label>
|
||||
<div className="xmind-search-options"><label><input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} /> 区分大小写</label></div>
|
||||
<select aria-label="查找范围" value={scope} onChange={e => setScope(e.target.value)}><option value="current">当前画布</option><option value="all">所有画布</option></select>
|
||||
<label className="xmind-search-box"><input aria-label="替换为" placeholder="替换为" value={replacement} onChange={e => setReplacement(e.target.value)} /></label>
|
||||
<div className="xmind-search-replace-buttons">
|
||||
<button type="button" disabled={!results.length} onClick={() => onReplace([results[Math.min(activeIndex,results.length - 1)]],query,replacement,caseSensitive)}>替换</button>
|
||||
<button type="button" disabled={!results.length} onClick={() => onReplace(results,query,replacement,caseSensitive)}>全部替换</button>
|
||||
</div>
|
||||
<div className="xmind-search-navigation"><span role="status">{query ? `${results.length} 个匹配主题` : "输入内容开始查找"}</span><button type="button" aria-label="上一个结果" disabled={!results.length} onClick={() => navigate(-1)}><ChevronUp size={16} /></button><button type="button" aria-label="下一个结果" disabled={!results.length} onClick={() => navigate(1)}><ChevronDown size={16} /></button></div>
|
||||
<div aria-label="查找结果">{results.map((result,index) => <button type="button" key={`${result.sheetId}:${result.id}`} aria-label={`定位 ${result.title}`} className={`xmind-search-result ${index === activeIndex ? "is-active" : ""}`} onClick={() => { setActiveIndex(index); onJump(result); }}><span>{result.title}</span><small>{result.sheetTitle} · {result.occurrences} 处匹配</small></button>)}</div>
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
384
components/flow-board/xmind-task-editor.tsx
Normal file
@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CalendarDays, X } from "lucide-react";
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
|
||||
import {
|
||||
activeWorkingCalendar,
|
||||
getProjectCalendar,
|
||||
taskDueDate,
|
||||
workingDaysBetween,
|
||||
} from "@/lib/flow-board/project-calendar";
|
||||
import {
|
||||
DAY,
|
||||
getTopicTask,
|
||||
newTopicTask,
|
||||
validateTopicTask,
|
||||
type TopicTask,
|
||||
type TaskDependency,
|
||||
} from "@/lib/flow-board/topic-tasks";
|
||||
export function XmindTaskEditor({
|
||||
topic,
|
||||
elements,
|
||||
onSave,
|
||||
onClose,
|
||||
onGantt,
|
||||
onCalendar,
|
||||
}: {
|
||||
topic: PlaitElement;
|
||||
elements: PlaitElement[];
|
||||
onSave: (id: string, task: TopicTask | null) => void;
|
||||
onClose: () => void;
|
||||
onGantt: () => void;
|
||||
onCalendar: () => void;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current?.querySelector<HTMLInputElement>("input")?.focus();
|
||||
return () => {
|
||||
if (previous?.isConnected) previous.focus();
|
||||
};
|
||||
}, []);
|
||||
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
|
||||
const existing = getTopicTask(topic);
|
||||
const [draft, setDraft] = useState<TopicTask>(
|
||||
() => existing || newTopicTask()
|
||||
);
|
||||
const [error, setError] = useState("");
|
||||
const [assigneesDraft, setAssigneesDraft] = useState(
|
||||
(existing?.assignees || []).join(", ")
|
||||
);
|
||||
const [dependency, setDependency] = useState("");
|
||||
const [dependencyType, setDependencyType] =
|
||||
useState<TaskDependency["type"]>("FS");
|
||||
const [lag, setLag] = useState("0");
|
||||
const source = JSON.stringify(existing);
|
||||
useEffect(() => {
|
||||
setDraft(getTopicTask(topic) || newTopicTask());
|
||||
setAssigneesDraft((getTopicTask(topic)?.assignees || []).join(", "));
|
||||
setError("");
|
||||
}, [topic.id, source]);
|
||||
const tasks = getTopicEntries(elements).filter(
|
||||
(e) => e.element.id !== topic.id && getTopicTask(e.element)
|
||||
);
|
||||
const patch = (p: Partial<TopicTask>) => setDraft((t) => ({ ...t, ...p }));
|
||||
const withAssignees = () => ({
|
||||
...draft,
|
||||
assignees: [
|
||||
...new Set(
|
||||
assigneesDraft
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
],
|
||||
});
|
||||
const save = () => {
|
||||
const task = withAssignees();
|
||||
const problem = validateTopicTask(topic.id, task, elements);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
onSave(topic.id, task);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="xmind-task-backdrop"
|
||||
data-flow-board-shell-control="true"
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
const controls = Array.from(
|
||||
dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
"button:not(:disabled),input,select"
|
||||
) || []
|
||||
);
|
||||
const first = controls[0],
|
||||
last = controls.at(-1);
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last?.focus();
|
||||
}
|
||||
if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="任务信息"
|
||||
className="xmind-task-editor"
|
||||
ref={dialogRef}
|
||||
>
|
||||
<header>
|
||||
<h2>
|
||||
<CalendarDays size={17} />
|
||||
任务信息
|
||||
</h2>
|
||||
<button type="button" aria-label="关闭任务信息" onClick={onClose}>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</header>
|
||||
<p className="xmind-task-name">{getTopicTitle(topic)}</p>
|
||||
<div className="xmind-task-fields">
|
||||
<label>
|
||||
开始日期
|
||||
<input
|
||||
type="date"
|
||||
aria-label="任务开始日期"
|
||||
value={draft.start}
|
||||
onChange={(e) => {
|
||||
const start = e.target.value;
|
||||
patch({
|
||||
start,
|
||||
due:
|
||||
start && draft.duration >= 1
|
||||
? taskDueDate(start, draft.duration, calendar)
|
||||
: "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
截止日期
|
||||
<input
|
||||
type="date"
|
||||
aria-label="任务截止日期"
|
||||
value={draft.due}
|
||||
onChange={(e) => {
|
||||
const due = e.target.value;
|
||||
patch({
|
||||
due,
|
||||
...(draft.start && due && due >= draft.start
|
||||
? {
|
||||
duration: Math.max(
|
||||
1,
|
||||
workingDaysBetween(draft.start, due, calendar)
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{calendar ? "时长(工作日)" : "时长(天)"}
|
||||
<input
|
||||
type="number"
|
||||
aria-label="任务工期"
|
||||
min={1}
|
||||
max={36500}
|
||||
value={draft.duration}
|
||||
onChange={(e) => {
|
||||
const duration = Number(e.target.value);
|
||||
patch({
|
||||
duration,
|
||||
...(draft.start && duration >= 1 && duration <= 36500
|
||||
? { due: taskDueDate(draft.start, duration, calendar) }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
进度(%)
|
||||
<input
|
||||
type="number"
|
||||
aria-label="任务进度"
|
||||
min={0}
|
||||
max={100}
|
||||
value={draft.progress}
|
||||
onChange={(e) => patch({ progress: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
优先级
|
||||
<select
|
||||
aria-label="任务优先级"
|
||||
value={draft.priority ?? ""}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
priority:
|
||||
e.target.value === "" ? null : Number(e.target.value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">无</option>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<option value={i} key={i}>
|
||||
P{i}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
负责人
|
||||
<input
|
||||
aria-label="任务负责人"
|
||||
placeholder="多人用逗号分隔"
|
||||
value={assigneesDraft}
|
||||
onChange={(e) => setAssigneesDraft(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<h3>前置任务</h3>
|
||||
<div className="xmind-task-dependencies">
|
||||
{draft.dependencies.map((dep, i) => (
|
||||
<div key={`${dep.id}-${i}`}>
|
||||
<span>
|
||||
{tasks.find((e) => e.element.id === dep.id)?.title ||
|
||||
"已删除的任务"}{" "}
|
||||
· {dep.type}
|
||||
{dep.lag ? ` · ${dep.lag / DAY} 天` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移除前置任务 ${tasks.find((e) => e.element.id === dep.id)?.title || dep.id}`}
|
||||
onClick={() =>
|
||||
patch({
|
||||
dependencies: draft.dependencies.filter((_, n) => n !== i),
|
||||
})
|
||||
}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="xmind-task-add-dependency">
|
||||
<select
|
||||
aria-label="前置任务"
|
||||
value={dependency}
|
||||
onChange={(e) => setDependency(e.target.value)}
|
||||
>
|
||||
<option value="">选择任务</option>
|
||||
{tasks.map((e) => (
|
||||
<option value={e.element.id} key={e.element.id}>
|
||||
{e.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="依赖类型"
|
||||
value={dependencyType}
|
||||
onChange={(e) =>
|
||||
setDependencyType(e.target.value as TaskDependency["type"])
|
||||
}
|
||||
>
|
||||
<option value="FS">完成 → 开始</option>
|
||||
<option value="SS">开始 → 开始</option>
|
||||
<option value="FF">完成 → 完成</option>
|
||||
<option value="SF">开始 → 完成</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
aria-label="依赖间隔天数"
|
||||
value={lag}
|
||||
onChange={(e) => setLag(e.target.value)}
|
||||
title="间隔天数(负数为提前)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dependency}
|
||||
onClick={() => {
|
||||
const next = {
|
||||
...draft,
|
||||
dependencies: [
|
||||
...draft.dependencies.filter((d) => d.id !== dependency),
|
||||
{
|
||||
id: dependency,
|
||||
type: dependencyType,
|
||||
lag: Number(lag) * DAY,
|
||||
},
|
||||
],
|
||||
};
|
||||
const problem = validateTopicTask(topic.id, next, elements);
|
||||
if (problem) setError(problem);
|
||||
else {
|
||||
setDraft(next);
|
||||
setDependency("");
|
||||
setError("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="xmind-task-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="xmind-task-calendar-info">
|
||||
<span>
|
||||
{calendar ? `已跳过非工作日 · ${calendar.name}` : "包含非工作日"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const task = withAssignees(),
|
||||
problem = validateTopicTask(topic.id, task, elements);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
onSave(topic.id, task);
|
||||
onClose();
|
||||
onCalendar();
|
||||
}}
|
||||
>
|
||||
项目工作日
|
||||
</button>
|
||||
</div>
|
||||
<footer>
|
||||
{existing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="xmind-danger"
|
||||
onClick={() => {
|
||||
onSave(topic.id, null);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
移除任务
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const task = withAssignees();
|
||||
const problem = validateTopicTask(topic.id, task, elements);
|
||||
if (problem) {
|
||||
setError(problem);
|
||||
return;
|
||||
}
|
||||
onSave(topic.id, task);
|
||||
onClose();
|
||||
onGantt();
|
||||
}}
|
||||
>
|
||||
查看甘特图
|
||||
</button>
|
||||
<button type="button" className="xmind-primary-button" onClick={save}>
|
||||
保存任务
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
components/flow-board/xmind-topic-badges.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
import {
|
||||
type PlaitBoard,
|
||||
type PlaitElement,
|
||||
toHostPointFromViewBoxPoint,
|
||||
toScreenPointFromHostPoint,
|
||||
} from "@plait/core";
|
||||
import { MindElement, getRectangleByNode } from "@plait/mind";
|
||||
import { FileText } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getTopicDetails, getTopicTitle } from "@/lib/flow-board/topic-details";
|
||||
import { getTopicTask } from "@/lib/flow-board/topic-tasks";
|
||||
export function XmindTopicBadges({
|
||||
board,
|
||||
elements,
|
||||
viewport,
|
||||
onNotes,
|
||||
onTask,
|
||||
}: {
|
||||
board: PlaitBoard | null;
|
||||
elements: PlaitElement[];
|
||||
viewport: unknown;
|
||||
onNotes: (id: string) => void;
|
||||
onTask: (id: string) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [badges, setBadges] = useState<
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
notes: boolean;
|
||||
progress: number | null;
|
||||
left: number;
|
||||
top: number;
|
||||
}[]
|
||||
>([]);
|
||||
useEffect(() => {
|
||||
if (!board) return;
|
||||
const update = () => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const origin = host.getBoundingClientRect();
|
||||
const result: typeof badges = [];
|
||||
const visit = (children: PlaitElement[]) => {
|
||||
for (const element of children) {
|
||||
const notes = Boolean(getTopicDetails(element).notes.trim()),
|
||||
task = getTopicTask(element);
|
||||
if (notes || task) {
|
||||
const node = MindElement.getNode(element as MindElement);
|
||||
if (node) {
|
||||
const rect = getRectangleByNode(node);
|
||||
const point = toScreenPointFromHostPoint(
|
||||
board,
|
||||
toHostPointFromViewBoxPoint(board, [
|
||||
rect.x + rect.width,
|
||||
rect.y + rect.height / 2,
|
||||
])
|
||||
);
|
||||
result.push({
|
||||
id: element.id,
|
||||
title: getTopicTitle(element),
|
||||
notes,
|
||||
progress: task?.progress ?? null,
|
||||
left: point[0] - origin.x + 5,
|
||||
top: point[1] - origin.y - 9,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!element.isCollapsed && element.children) visit(element.children);
|
||||
}
|
||||
};
|
||||
visit(elements);
|
||||
setBadges(result);
|
||||
};
|
||||
const frame = requestAnimationFrame(update);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [board, elements, viewport]);
|
||||
return (
|
||||
<div ref={hostRef} className="xmind-topic-badges" aria-label="主题内容提示">
|
||||
{badges.map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="xmind-topic-badge"
|
||||
style={{ left: b.left, top: b.top }}
|
||||
data-flow-board-shell-control="true"
|
||||
>
|
||||
{b.progress !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`编辑任务 ${b.title}`}
|
||||
title={`任务进度 ${b.progress}%`}
|
||||
onClick={() => onTask(b.id)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<circle
|
||||
cx={8}
|
||||
cy={8}
|
||||
r={6}
|
||||
fill="#f8fafc"
|
||||
stroke="#afb7c4"
|
||||
strokeWidth={1.4}
|
||||
/>
|
||||
<circle
|
||||
cx={8}
|
||||
cy={8}
|
||||
r={6}
|
||||
fill="none"
|
||||
stroke="#6b8ee4"
|
||||
strokeWidth={3}
|
||||
strokeDasharray={`${(b.progress * 37.7) / 100} 37.7`}
|
||||
transform="rotate(-90 8 8)"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
{b.notes ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`查看笔记 ${b.title}`}
|
||||
title="查看笔记"
|
||||
onClick={() => onNotes(b.id)}
|
||||
>
|
||||
<FileText size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -18,6 +18,9 @@ import {
|
||||
type TopicEntry,
|
||||
} from "@/lib/flow-board/topic-details";
|
||||
|
||||
import { getNoteDocument } from "@/lib/flow-board/topic-notes";
|
||||
import { XmindNotesEditor } from "./xmind-notes-editor";
|
||||
|
||||
export type TopicDetailSection = "notes" | "links" | "labels";
|
||||
|
||||
export function XmindTopicPanel({
|
||||
@ -41,7 +44,7 @@ export function XmindTopicPanel({
|
||||
const [attachmentError, setAttachmentError] = useState("");
|
||||
const [isReadingAttachment, setIsReadingAttachment] = useState(false);
|
||||
const attachmentRef = useRef<HTMLInputElement>(null);
|
||||
const noteRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const linkRef = useRef<HTMLInputElement>(null);
|
||||
const labelRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
@ -52,9 +55,8 @@ export function XmindTopicPanel({
|
||||
setAttachmentError("");
|
||||
}, [topic?.id]);
|
||||
useEffect(() => {
|
||||
({ notes: noteRef, links: linkRef, labels: labelRef })[
|
||||
section
|
||||
].current?.focus();
|
||||
if (section === "links") linkRef.current?.focus();
|
||||
if (section === "labels") labelRef.current?.focus();
|
||||
}, [section]);
|
||||
const safeHref = getSafeTopicHref(linkDraft);
|
||||
const invalidHref = Boolean(linkDraft.trim()) && !safeHref;
|
||||
@ -96,7 +98,7 @@ export function XmindTopicPanel({
|
||||
};
|
||||
return (
|
||||
<aside
|
||||
className="absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
||||
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
||||
data-flow-board-shell-control="true"
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
|
||||
@ -114,20 +116,10 @@ export function XmindTopicPanel({
|
||||
<p className="truncate font-medium" title={getTopicTitle(topic)}>
|
||||
{getTopicTitle(topic)}
|
||||
</p>
|
||||
<label className="block space-y-2">
|
||||
<span className="flex items-center gap-2 font-medium">
|
||||
<StickyNote size={14} />
|
||||
备注
|
||||
</span>
|
||||
<textarea
|
||||
aria-label="主题备注"
|
||||
ref={noteRef}
|
||||
className="min-h-40 w-full resize-y rounded border border-[#dfe2e7] p-2 text-sm leading-6 outline-none focus:border-blue-400"
|
||||
placeholder="为这个主题补充说明…"
|
||||
value={details.notes}
|
||||
onChange={(event) => onPatch({ notes: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<section className="space-y-2">
|
||||
<h3 className="flex items-center gap-2 font-medium"><StickyNote size={14} />笔记</h3>
|
||||
<XmindNotesEditor key={topic.id} value={getNoteDocument(topic)} onChange={(notesDocument) => onPatch({notesDocument})} />
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h3 className="flex items-center gap-2 font-medium">
|
||||
<Tag size={14} />
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { ReferenceIcon, type ReferenceIconName } from "@/components/lovart/reference-icon";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
@ -69,7 +71,6 @@ import {
|
||||
normalizeLovartHomeSidebarConfig,
|
||||
type LovartHomeSidebarItemKey,
|
||||
} from "@/lib/home/lovart-sidebar-config";
|
||||
import { BrandTopBar } from "@/components/layout/brand-top-bar";
|
||||
import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured-gallery";
|
||||
import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer";
|
||||
import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section";
|
||||
@ -420,16 +421,15 @@ function AccountMenuItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
<DropdownMenuItem
|
||||
onSelect={onClick}
|
||||
className="flex w-full items-center gap-3 rounded-2xl px-2.5 py-1.5 text-left text-[13px] font-medium text-black/80 hover:bg-black/5 dark:text-white/85 dark:hover:bg-white/6"
|
||||
>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-black/5 text-black/70 dark:bg-white/8 dark:text-white/70">
|
||||
<Icon className="h-[16px] w-[16px]" />
|
||||
</span>
|
||||
<span className="flex-1">{label}</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1637,7 +1637,31 @@ export function LovartHome({
|
||||
) => {
|
||||
const item = getLovartSidebarItem(key);
|
||||
const FallbackIcon = LOVART_HOME_SIDEBAR_FALLBACK_ICONS[key];
|
||||
const sizeClassName = options?.sizeClassName || "h-[18px] w-[18px]";
|
||||
const sizeClassName = options?.sizeClassName || "h-[20px] w-[20px]";
|
||||
|
||||
const referenceIcons: Record<LovartHomeSidebarItemKey, ReferenceIconName> = {
|
||||
newProject: "plus",
|
||||
home: "home",
|
||||
projects: "folder",
|
||||
profile: "profile",
|
||||
gallery: "brand",
|
||||
tutorial: "tutorial",
|
||||
imageEditor: "image-local-edit",
|
||||
aiCad: "model-auto",
|
||||
model3d: "model-auto",
|
||||
scene: "layers",
|
||||
agent: "skill",
|
||||
videoCanvas: "video",
|
||||
flowBoard: "connections",
|
||||
productPlanning: "page",
|
||||
toolbox1: "toolbox",
|
||||
toolbox2: "toolbox",
|
||||
settings: "settings",
|
||||
};
|
||||
const reference = referenceIcons[key];
|
||||
if (reference && !item.iconUrl) {
|
||||
return <ReferenceIcon name={reference} size={20} className={options?.filled ? "text-white" : undefined} />;
|
||||
}
|
||||
|
||||
if (options?.filled) {
|
||||
return (
|
||||
@ -1703,8 +1727,261 @@ export function LovartHome({
|
||||
THEME_MODE_OPTIONS[0];
|
||||
const SelectedThemeIcon = selectedThemeOption.icon;
|
||||
|
||||
const homeThemeControl = () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
title={`主题模式:${selectedThemeOption.label}`}
|
||||
aria-label={`主题模式:${selectedThemeOption.label}`}
|
||||
>
|
||||
<SelectedThemeIcon className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="z-[160] !min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{THEME_MODE_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
const isSelected = selectedThemeMode === option.value;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
aria-label={option.label}
|
||||
className={cn(
|
||||
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
|
||||
isSelected
|
||||
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
|
||||
: "text-black/70 dark:text-white/70"
|
||||
)}
|
||||
onSelect={() => setTheme(option.value)}
|
||||
title={option.label}
|
||||
>
|
||||
<OptionIcon className="h-[15px] w-[15px]" />
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
const homeUtilityControls = () => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="通知"
|
||||
title="通知"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10"
|
||||
>
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{homeThemeControl()}
|
||||
</>
|
||||
);
|
||||
const homeAccountControls = () => (
|
||||
<div className="flex w-full flex-col items-center gap-[12px]">
|
||||
{isMembershipEnabled ? (
|
||||
<HoverCard openDelay={100} closeDelay={120}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
aria-label="积分与会员"
|
||||
className="h-auto w-[36px] rounded-[8px] px-0 py-2 text-[#333] hover:bg-black/5 dark:text-white/75 dark:hover:bg-white/10"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-[4px] text-[10px] leading-none">
|
||||
<Zap className="h-[20px] w-[20px]" />
|
||||
{membershipData?.summary?.currentCredits ?? 0}
|
||||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="z-[150] w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-[22px] font-semibold leading-none text-black dark:text-white">
|
||||
{currentPlan?.name || "Free"}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2.5 border-t border-black/10 pt-3 text-[14px] dark:border-white/10 dark:text-white/90">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
积分
|
||||
</span>
|
||||
<span>{membershipData?.summary?.currentCredits ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
每日免费积分
|
||||
</span>
|
||||
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 inline-flex items-center gap-1 text-[12px] font-medium text-black/80 hover:text-black dark:text-white/75 dark:hover:text-white"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
使用详情
|
||||
<ChevronDown className="h-4 w-4 -rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : null}
|
||||
|
||||
{isAuthenticated ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[#d4551f] text-[11px] font-semibold text-white"
|
||||
aria-label="账号菜单"
|
||||
title="账号菜单"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="z-[150] max-h-[calc(100svh-24px)] w-[300px] overflow-y-auto rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="p-3.5">
|
||||
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
||||
{userInitials}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[15px] font-semibold text-black dark:text-white">
|
||||
{userDisplayName}
|
||||
</div>
|
||||
<div className="truncate text-[12px] text-black/45 dark:text-white/45">
|
||||
{user?.email || ""}
|
||||
</div>
|
||||
</div>
|
||||
{isMembershipEnabled ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
|
||||
<DropdownMenuItem asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5 dark:text-white/80 dark:hover:bg-white/6"
|
||||
onClick={() => {
|
||||
setAccountCenterTab("account");
|
||||
setUsageFilter("all");
|
||||
setAccountCenterOpen(true);
|
||||
}}
|
||||
>
|
||||
<span>积分</span>
|
||||
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60 dark:text-white/55">
|
||||
{membershipData?.summary?.currentCredits ?? 0}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{isMembershipEnabled ? (
|
||||
<AccountMenuItem
|
||||
icon={Users}
|
||||
label="创建团队"
|
||||
onClick={() => toast.info("团队功能后续开放")}
|
||||
/>
|
||||
) : null}
|
||||
<AccountMenuItem
|
||||
icon={User}
|
||||
label="账户管理"
|
||||
onClick={() => {
|
||||
setAccountCenterTab("account");
|
||||
setUsageFilter("all");
|
||||
setAccountCenterOpen(true);
|
||||
}}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={Info}
|
||||
label="使用指南"
|
||||
onClick={() => toast.info("使用指南后续开放")}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={Mail}
|
||||
label="联系我们"
|
||||
onClick={() => setContactDialogOpen(true)}
|
||||
/>
|
||||
<div className="flex items-center justify-between border-t border-black/10 px-2 py-2 dark:border-white/10">
|
||||
<span className="text-[13px] text-black/65 dark:text-white/65">
|
||||
通知与主题
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{homeUtilityControls()}
|
||||
</div>
|
||||
</div>
|
||||
<AccountMenuItem
|
||||
icon={LogOut}
|
||||
label="退出登录"
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{homeThemeControl()}
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-[32px] w-[36px] items-center justify-center rounded-full border border-black/15 bg-white text-[11px] font-medium text-black/80 hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
|
||||
onClick={openLoginDialog}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
|
||||
<div className="lovart-home-surface min-h-svh overflow-x-hidden bg-white text-[#171717] dark:bg-[#0a0a0c] dark:text-zinc-100 [&_button]:cursor-pointer [&_input]:cursor-pointer [&_textarea]:cursor-pointer [&_img]:cursor-pointer [&_a]:cursor-pointer [&_[role=button]]:cursor-pointer">
|
||||
{shouldShowHomeBanner ? (
|
||||
<LovartPromoBanner
|
||||
actionText={homeBannerActionText}
|
||||
@ -1714,236 +1991,12 @@ export function LovartHome({
|
||||
text={homeBannerText}
|
||||
/>
|
||||
) : null}
|
||||
<BrandTopBar
|
||||
contentClassName="pl-[80px]"
|
||||
logo={displayLogo}
|
||||
offsetTop={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}
|
||||
onLogoClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
showBrand={false}
|
||||
siteName={siteName || "Lovart"}
|
||||
>
|
||||
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10 dark:bg-white/6 dark:text-white/70 dark:hover:bg-white/10">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{isMembershipEnabled ? (
|
||||
<HoverCard openDelay={100} closeDelay={120}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
className="h-8 rounded-[14px] border border-black/10 bg-white px-2 text-[14px] text-black/72 shadow-sm hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<div className="inline-flex items-center gap-1 rounded-[9px] px-1.5 py-0.5 text-xs">
|
||||
<Zap className="h-3 w-3 text-amber-500" />
|
||||
{membershipData?.summary?.currentCredits ?? 0}
|
||||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-[22px] font-semibold leading-none text-black dark:text-white">
|
||||
{currentPlan?.name || "Free"}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2.5 border-t border-black/10 pt-3 text-[14px] dark:border-white/10 dark:text-white/90">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
积分
|
||||
</span>
|
||||
<span>{membershipData?.summary?.currentCredits ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex items-center gap-2 text-black/80 dark:text-white/75">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
每日免费积分
|
||||
</span>
|
||||
<span>{membershipData?.summary?.dailyFreeCredits ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 inline-flex items-center gap-1 text-[12px] font-medium text-black/80 hover:text-black dark:text-white/75 dark:hover:text-white"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
使用详情
|
||||
<ChevronDown className="h-4 w-4 -rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[11px] border border-black/10 bg-white text-black/70 shadow-sm transition hover:bg-black/5 hover:text-black dark:border-white/10 dark:bg-white/6 dark:text-white/72 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
title={`主题模式:${selectedThemeOption.label}`}
|
||||
aria-label={`主题模式:${selectedThemeOption.label}`}
|
||||
>
|
||||
<SelectedThemeIcon className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="!min-w-0 w-auto rounded-[18px] border-black/10 bg-white p-1.5 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{THEME_MODE_OPTIONS.map((option) => {
|
||||
const OptionIcon = option.icon;
|
||||
const isSelected = selectedThemeMode === option.value;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
aria-label={option.label}
|
||||
className={cn(
|
||||
"flex h-[34px] w-[34px] cursor-pointer items-center justify-center rounded-[10px] p-0 focus:bg-black/5 dark:focus:bg-white/8",
|
||||
isSelected
|
||||
? "bg-black text-white focus:bg-black focus:text-white dark:bg-white dark:text-black dark:focus:bg-white dark:focus:text-black"
|
||||
: "text-black/70 dark:text-white/70"
|
||||
)}
|
||||
onSelect={() => setTheme(option.value)}
|
||||
title={option.label}
|
||||
>
|
||||
<OptionIcon className="h-[15px] w-[15px]" />
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<HoverCard openDelay={80} closeDelay={120}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white"
|
||||
aria-label="Account menu"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="end"
|
||||
sideOffset={12}
|
||||
className="w-[300px] rounded-3xl border border-black/10 bg-white p-0 shadow-xl dark:border-white/10 dark:bg-[#141416]"
|
||||
>
|
||||
<div className="p-3.5">
|
||||
<div className="rounded-3xl border border-black/10 bg-white p-3.5 dark:border-white/10 dark:bg-[#19191d]">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d4551f] text-[14px] font-semibold text-white">
|
||||
{userInitials}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[15px] font-semibold text-black dark:text-white">
|
||||
{userDisplayName}
|
||||
</div>
|
||||
<div className="truncate text-[12px] text-black/45 dark:text-white/45">
|
||||
{user?.email || ""}
|
||||
</div>
|
||||
</div>
|
||||
{isMembershipEnabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 rounded-2xl bg-[#2f3542] px-4 text-[13px] text-white hover:bg-[#242a36]"
|
||||
onClick={() => setUpgradeDialogOpen(true)}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-black/10 pt-3 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-2xl px-1 py-1 text-[13px] font-semibold text-black/80 hover:bg-black/5 dark:text-white/80 dark:hover:bg-white/6"
|
||||
onClick={() => {
|
||||
setAccountCenterTab("account");
|
||||
setUsageFilter("all");
|
||||
setAccountCenterOpen(true);
|
||||
}}
|
||||
>
|
||||
<span>积分</span>
|
||||
<span className="inline-flex items-center gap-2 text-[13px] font-semibold text-black/60 dark:text-white/55">
|
||||
{membershipData?.summary?.currentCredits ?? 0}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
{isMembershipEnabled ? (
|
||||
<AccountMenuItem
|
||||
icon={Users}
|
||||
label="创建团队"
|
||||
onClick={() => toast.info("团队功能后续开放")}
|
||||
/>
|
||||
) : null}
|
||||
<AccountMenuItem
|
||||
icon={User}
|
||||
label="账户管理"
|
||||
onClick={() => {
|
||||
setAccountCenterTab("account");
|
||||
setUsageFilter("all");
|
||||
setAccountCenterOpen(true);
|
||||
}}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={Info}
|
||||
label="使用指南"
|
||||
onClick={() => toast.info("使用指南后续开放")}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={Mail}
|
||||
label="联系我们"
|
||||
onClick={() => setContactDialogOpen(true)}
|
||||
/>
|
||||
<AccountMenuItem
|
||||
icon={LogOut}
|
||||
label="退出登录"
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 items-center justify-center rounded-full border border-black/15 bg-white px-3 text-xs font-medium text-black/80 hover:bg-black/5 dark:border-white/12 dark:bg-[#161618] dark:text-white/80 dark:hover:bg-white/8"
|
||||
onClick={openLoginDialog}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</BrandTopBar>
|
||||
<div className="fixed bottom-5 left-4 z-[100] flex w-[44px] items-center rounded-full bg-[#f5f5f5] py-2 shadow-sm lg:hidden dark:bg-[#141416]">
|
||||
{homeAccountControls()}
|
||||
</div>
|
||||
|
||||
<LovartSidebar
|
||||
accountControls={homeAccountControls()}
|
||||
agentEnabled={siteSettingsData?.agentEnabled !== false}
|
||||
getItem={getLovartSidebarItem}
|
||||
isEnabled={isLovartSidebarItemEnabled}
|
||||
@ -1982,9 +2035,12 @@ export function LovartHome({
|
||||
/>
|
||||
|
||||
<main
|
||||
style={
|
||||
{ "--home-banner-offset": `${shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0}px` } as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"ml-[92px] mr-10 w-auto max-w-none pb-16 max-lg:mx-5",
|
||||
shouldShowHomeBanner ? "pt-[113px]" : "pt-[68px]"
|
||||
"ml-[120px] mr-[60px] w-auto max-w-none pb-16 max-lg:mx-5",
|
||||
shouldShowHomeBanner ? "pt-[124px]" : "pt-[76px]"
|
||||
)}
|
||||
>
|
||||
{!isProjectsOnlyView && (
|
||||
@ -2013,25 +2069,15 @@ export function LovartHome({
|
||||
setPrompt={setPrompt}
|
||||
siteName={siteName}
|
||||
selectedModelId={selectedComposerModel.id}
|
||||
onOpenGallery={() => featuredSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LovartProjectsSection
|
||||
deletingProjectId={deletingProjectId}
|
||||
hasMoreProjects={hasMoreProjects}
|
||||
isProjectsOnlyView={isProjectsOnlyView}
|
||||
onCreateProject={openNewCanvas}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onOpenProject={openCanvasProject}
|
||||
onPrepareCreate={preloadCanvasWorkspace}
|
||||
onPrepareProject={prepareCanvasProject}
|
||||
onViewAllProjects={openAllProjectsInPage}
|
||||
projects={recentProjects}
|
||||
projectSectionRef={projectSectionRef}
|
||||
/>
|
||||
|
||||
{!isProjectsOnlyView && (
|
||||
<LovartFeaturedGallery
|
||||
onOpenProjects={openAllProjectsInPage}
|
||||
onOpenMaterials={handleOpenImageEditor}
|
||||
activeCategory={activeCategory}
|
||||
categories={discoveryTabs}
|
||||
featuredItems={featuredItems}
|
||||
@ -2042,6 +2088,22 @@ export function LovartHome({
|
||||
setActiveCategory={setActiveCategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(isProjectsOnlyView || recentProjects.length > 0) && (
|
||||
<LovartProjectsSection
|
||||
deletingProjectId={deletingProjectId}
|
||||
hasMoreProjects={hasMoreProjects}
|
||||
isProjectsOnlyView={isProjectsOnlyView}
|
||||
onCreateProject={openNewCanvas}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
onOpenProject={openCanvasProject}
|
||||
onPrepareCreate={preloadCanvasWorkspace}
|
||||
onPrepareProject={prepareCanvasProject}
|
||||
onViewAllProjects={openAllProjectsInPage}
|
||||
projects={recentProjects}
|
||||
projectSectionRef={projectSectionRef}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<button
|
||||
@ -2132,7 +2194,7 @@ export function LovartHome({
|
||||
|
||||
{loginDialogOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
|
||||
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/85 p-4 backdrop-blur-[1px]"
|
||||
onClick={() => setLoginDialogOpen(false)}
|
||||
>
|
||||
<div
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
import { Gem, Heart, Loader2, PlayCircle } from "lucide-react";
|
||||
import type { Ref } from "react";
|
||||
import type { FeaturedImage } from "./types";
|
||||
|
||||
type LovartFeaturedGalleryProps = {
|
||||
onOpenProjects: () => void;
|
||||
onOpenMaterials: () => void;
|
||||
activeCategory: string;
|
||||
categories: string[];
|
||||
featuredItems: FeaturedImage[];
|
||||
@ -16,7 +19,11 @@ type LovartFeaturedGalleryProps = {
|
||||
function getFeaturedPreviewUrl(item: FeaturedImage) {
|
||||
const source = item.thumbnailUrl || item.originalUrl;
|
||||
|
||||
if (!source || !/^https:\/\/yybb\.top\//i.test(source) || source.includes("?")) {
|
||||
if (
|
||||
!source ||
|
||||
!/^https:\/\/yybb\.top\//i.test(source) ||
|
||||
source.includes("?")
|
||||
) {
|
||||
return source;
|
||||
}
|
||||
|
||||
@ -24,6 +31,8 @@ function getFeaturedPreviewUrl(item: FeaturedImage) {
|
||||
}
|
||||
|
||||
export function LovartFeaturedGallery({
|
||||
onOpenProjects,
|
||||
onOpenMaterials,
|
||||
activeCategory,
|
||||
categories,
|
||||
featuredItems,
|
||||
@ -34,21 +43,44 @@ export function LovartFeaturedGallery({
|
||||
setActiveCategory,
|
||||
}: LovartFeaturedGalleryProps) {
|
||||
return (
|
||||
<section ref={featuredSectionRef} className="mt-14">
|
||||
<h2 className="text-[16px] font-semibold leading-none text-[#1f1f1f] dark:text-white">
|
||||
灵感发现
|
||||
</h2>
|
||||
<section
|
||||
ref={featuredSectionRef}
|
||||
className="lovart-discovery mt-16 scroll-mt-28"
|
||||
aria-label="灵感发现"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 text-[13px] text-[#858585] dark:text-white/50">
|
||||
<span className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] bg-[#ededed] px-3 text-[#252525] dark:bg-white/10 dark:text-white">
|
||||
<ReferenceIcon name="skill" size={16} />
|
||||
灵感发现
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenProjects}
|
||||
className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] px-3 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<ReferenceIcon name="folder" size={16} />
|
||||
我的项目
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMaterials}
|
||||
className="inline-flex h-7 shrink-0 items-center gap-2 rounded-[8px] px-3 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<ReferenceIcon name="brand" size={16} />
|
||||
我的素材
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<div className="flex items-center gap-x-5 overflow-x-auto border-b border-[#ededed] px-3 dark:border-white/10">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category)}
|
||||
className={`h-7 rounded-[7px] px-3 text-[12px] font-normal leading-none transition-colors duration-150 ${
|
||||
className={`relative h-9 shrink-0 border-b px-0 text-[13px] font-normal leading-none transition-colors duration-150 ${
|
||||
activeCategory === category
|
||||
? "bg-[#f2f2f2] text-[#202020] dark:bg-[#24242a] dark:text-white"
|
||||
: "text-[#5f5f5f] hover:bg-[#f7f7f7] hover:text-[#202020] dark:text-white/55 dark:hover:bg-[#17171b]"
|
||||
? "border-[#303030] text-[#202020] dark:border-white dark:text-white"
|
||||
: "border-transparent text-[#858585] hover:text-[#202020] dark:text-white/55 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
@ -58,15 +90,19 @@ export function LovartFeaturedGallery({
|
||||
|
||||
{featuredItems.length === 0 ? (
|
||||
<div className="mt-[22px] rounded-[10px] border border-dashed border-black/15 bg-[#f7f7f7] p-10 text-center text-[14px] text-black/35 dark:border-white/12 dark:bg-[#141416] dark:text-white/35">
|
||||
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
|
||||
{isLoadingFeatured
|
||||
? "灵感内容加载中..."
|
||||
: "当前没有可展示的灵感数据。"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-[14px] columns-1 gap-[10px] md:columns-2 lg:columns-5">
|
||||
<div className="mt-6 columns-1 gap-4 sm:columns-2 lg:columns-4">
|
||||
{featuredItems.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
|
||||
onClick={() =>
|
||||
window.open(item.originalUrl || item.thumbnailUrl, "_blank")
|
||||
}
|
||||
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
||||
>
|
||||
<img
|
||||
@ -78,14 +114,17 @@ export function LovartFeaturedGallery({
|
||||
const image = event.currentTarget;
|
||||
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
|
||||
|
||||
if (!fallbackUrl || image.dataset.fallbackApplied === "true") {
|
||||
if (
|
||||
!fallbackUrl ||
|
||||
image.dataset.fallbackApplied === "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
image.dataset.fallbackApplied = "true";
|
||||
image.src = fallbackUrl;
|
||||
}}
|
||||
className="block h-auto w-full rounded-[6px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||
className="block h-auto w-full rounded-[12px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||
/>
|
||||
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
||||
<div className="flex min-w-0 items-center gap-[6px] pr-2">
|
||||
@ -112,7 +151,10 @@ export function LovartFeaturedGallery({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
|
||||
<div
|
||||
ref={featuredLoadMoreRef}
|
||||
className="mt-3 flex h-10 items-center justify-center"
|
||||
>
|
||||
{isLoadingMoreFeatured ? (
|
||||
<div className="inline-flex items-center gap-2 text-sm text-black/45 dark:text-white/45">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { ArrowUp, Box, Lightbulb, Loader2, Plus, Sparkles, X } from "lucide-react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
import { ModelIdentityIcon } from "@/components/lovart/model-identity-icon";
|
||||
import { Lightbulb, Loader2, X } from "lucide-react";
|
||||
import { useRef, type ChangeEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@ -52,23 +54,19 @@ type LovartHeroComposerProps = {
|
||||
onModelChange: (modelId: string) => void;
|
||||
onStartAgentMode: () => void;
|
||||
selectedModelId: string;
|
||||
onOpenGallery: () => void;
|
||||
};
|
||||
|
||||
export function LovartHeroComposer({
|
||||
composerPlaceholder,
|
||||
displayLogo,
|
||||
files,
|
||||
handleAddFiles,
|
||||
heroPromoActionText,
|
||||
heroPromoText,
|
||||
heroSubtitleText,
|
||||
heroTitleText,
|
||||
homeAnimationData,
|
||||
isAgentModeActive,
|
||||
isMembershipEnabled,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
openUpgradeDialog,
|
||||
prompt,
|
||||
removeFile,
|
||||
setPrompt,
|
||||
@ -77,63 +75,71 @@ export function LovartHeroComposer({
|
||||
onModelChange,
|
||||
onStartAgentMode,
|
||||
selectedModelId,
|
||||
onOpenGallery,
|
||||
}: LovartHeroComposerProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
const suggestions = [
|
||||
{
|
||||
label: "电商",
|
||||
icon: "commerce" as const,
|
||||
prompt: "为我的产品设计一张高转化的电商主图,突出产品卖点。",
|
||||
},
|
||||
{
|
||||
label: "社交媒体",
|
||||
icon: "social" as const,
|
||||
prompt: "设计一张适合社交媒体发布的视觉海报。",
|
||||
},
|
||||
{
|
||||
label: "品牌",
|
||||
icon: "branding" as const,
|
||||
prompt: "为我的品牌设计一套统一的视觉形象。",
|
||||
},
|
||||
{
|
||||
label: "创意",
|
||||
icon: "creativity" as const,
|
||||
prompt: "创作一张富有想象力和视觉冲击力的创意作品。",
|
||||
},
|
||||
{
|
||||
label: "网站",
|
||||
icon: "website" as const,
|
||||
prompt: "设计一个简洁、现代的网站首页视觉稿。",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="mt-6 text-center">
|
||||
{isMembershipEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openUpgradeDialog}
|
||||
className="inline-flex w-full max-w-[620px] items-center gap-1.5 rounded-[12px] bg-[#fde8bf] px-3 py-1.5 text-[11px] leading-5 text-black/80 transition-colors duration-150 hover:bg-[#f9dfac] sm:w-auto sm:rounded-full sm:text-[12px] dark:bg-[#3f3013] dark:text-[#f6dfaa] dark:hover:bg-[#493819]"
|
||||
>
|
||||
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
|
||||
<span className="line-clamp-2 min-w-0 flex-1 text-left sm:line-clamp-1">
|
||||
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="inline-flex w-full max-w-[620px] items-center gap-1.5 rounded-[12px] bg-[#fde8bf] px-3 py-1.5 text-[11px] leading-5 text-black/80 sm:w-auto sm:rounded-full sm:text-[12px] dark:bg-[#3f3013] dark:text-[#f6dfaa]">
|
||||
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
|
||||
<span className="line-clamp-2 min-w-0 flex-1 text-left sm:line-clamp-1">
|
||||
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<section className="lovart-hero mt-6 text-center" aria-label="开始设计">
|
||||
<h1 className="text-[20px] font-medium leading-7 tracking-[-0.02em] dark:text-white">
|
||||
{heroTitleText || "你想设计什么?"}
|
||||
</h1>
|
||||
{heroSubtitleText ? (
|
||||
<p className="mt-2 text-[13px] text-black/45 dark:text-white/45">
|
||||
{heroSubtitleText}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-7 flex items-center justify-center gap-1.5">
|
||||
{displayLogo ? (
|
||||
<img
|
||||
src={displayLogo}
|
||||
alt={siteName}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-[28px] font-semibold leading-tight sm:text-[30px] dark:text-white">
|
||||
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[12px] text-black/38 dark:text-white/48">
|
||||
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
|
||||
</p>
|
||||
|
||||
<div className="mx-auto mt-7 w-full max-w-[760px] rounded-[16px] border border-[#e1e4e8] bg-white px-[18px] pb-[10px] pt-[14px] shadow-[0_8px_22px_rgba(0,0,0,0.055)] transition-shadow duration-150 focus-within:shadow-[0_10px_26px_rgba(0,0,0,0.075)] dark:border-white/10 dark:bg-[#141416] dark:shadow-[0_14px_38px_rgba(0,0,0,0.32)]">
|
||||
<div className="lovart-home-composer mx-auto mt-7 w-full max-w-[760px] rounded-[24px] border border-[#e5e5e5] bg-white px-[16px] pb-[8px] pt-[14px] shadow-[0_2px_4px_rgba(0,0,0,0.04),0_8px_20px_rgba(0,0,0,0.025)] transition-shadow duration-150 focus-within:shadow-[0_3px_8px_rgba(0,0,0,0.07)] dark:border-white/12 dark:bg-[#141416]">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
aria-label="描述你的设计需求"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={composerPlaceholder}
|
||||
className="min-h-[60px] resize-none border-0 bg-transparent px-0 !text-[16px] leading-6 text-[#20242a] shadow-none placeholder:text-[#aeb3bb] focus-visible:ring-0 md:!text-[14px] dark:text-white dark:placeholder:text-white/35"
|
||||
className="min-h-[54px] resize-none border-0 bg-transparent px-0 !text-[16px] leading-6 text-[#20242a] shadow-none placeholder:text-[#b7b7b7] focus-visible:ring-0 md:!text-[14px] dark:text-white dark:placeholder:text-white/35"
|
||||
/>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2 px-1">
|
||||
{files.map((item) => (
|
||||
<div key={item.id} className="group relative h-11 w-11 overflow-hidden rounded-md border border-black/10 dark:border-white/10">
|
||||
<img src={item.previewUrl} alt={item.file.name} className="h-full w-full object-cover" />
|
||||
<div
|
||||
key={item.id}
|
||||
className="group relative h-11 w-11 overflow-hidden rounded-md border border-black/10 dark:border-white/10"
|
||||
>
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt={item.file.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 hidden h-5 w-5 items-center justify-center rounded-bl bg-black/60 text-white group-hover:flex"
|
||||
@ -150,8 +156,10 @@ export function LovartHeroComposer({
|
||||
<div className="flex min-h-[44px] items-center justify-between pt-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<label className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-[#20242a] transition-colors duration-150 hover:bg-black/5 dark:text-white/72 dark:hover:bg-white/8">
|
||||
<Plus className="h-4 w-4" strokeWidth={1.9} />
|
||||
<ReferenceIcon name="plus" size={18} />
|
||||
<input
|
||||
ref={uploadRef}
|
||||
aria-label="上传参考图"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
@ -186,7 +194,17 @@ export function LovartHeroComposer({
|
||||
title="选择模型"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center">
|
||||
<Box className="block h-4 w-4" strokeWidth={1.9} />
|
||||
<ModelIdentityIcon
|
||||
name={
|
||||
modelOptions.find((model) => model.id === selectedModelId)
|
||||
?.displayName || "auto"
|
||||
}
|
||||
avatarUrl={
|
||||
modelOptions.find((model) => model.id === selectedModelId)
|
||||
?.avatarUrl
|
||||
}
|
||||
size={18}
|
||||
/>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
@ -201,18 +219,15 @@ export function LovartHeroComposer({
|
||||
className="h-8 rounded-[7px] text-[12px] [&>span:first-child]:hidden [&>span:last-child]:block [&>span:last-child]:w-full"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full border border-black/10 bg-white text-[10px] font-medium text-[#6b7280] dark:border-white/15 dark:bg-white/8 dark:text-white/70">
|
||||
{model.avatarUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
src={model.avatarUrl}
|
||||
/>
|
||||
) : (
|
||||
(model.displayName || "A").slice(0, 1)
|
||||
)}
|
||||
<ModelIdentityIcon
|
||||
name={model.displayName}
|
||||
identity={`${model.modelId || model.id} ${model.providerType || ""}`}
|
||||
avatarUrl={model.avatarUrl}
|
||||
size={18}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{model.displayName}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
@ -220,19 +235,63 @@ export function LovartHeroComposer({
|
||||
</Select>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
aria-label="开始生成"
|
||||
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
|
||||
className="h-9 w-9 rounded-full bg-[#eeeeef] p-0 text-[#c2c7cf] transition-colors duration-150 hover:bg-[#e5e6e8] hover:text-[#7f8794] dark:bg-white/10 dark:text-white/40 dark:hover:bg-white/16"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4.5 w-4.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" strokeWidth={1.8} />
|
||||
<ReferenceIcon name="arrow-up" size={18} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-[760px] space-y-3 px-4 text-left">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mr-1 w-[96px] shrink-0 text-[13px] text-[#8b8b8b] dark:text-white/45">
|
||||
{siteName} 帮你做:
|
||||
</span>
|
||||
{suggestions.map(({ label, icon, prompt: suggestion }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className="lovart-suggestion"
|
||||
onClick={() => {
|
||||
setPrompt(suggestion);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<ReferenceIcon name={icon} size={18} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mr-1 w-[96px] shrink-0 text-[13px] text-[#8b8b8b] dark:text-white/45">
|
||||
连接/收集灵感:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lovart-suggestion"
|
||||
onClick={() => uploadRef.current?.click()}
|
||||
>
|
||||
<ReferenceIcon name="connections" size={18} />
|
||||
上传我的参考素材
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="lovart-suggestion"
|
||||
onClick={onOpenGallery}
|
||||
>
|
||||
<ReferenceIcon name="clipper" size={18} />
|
||||
浏览灵感,发现更多创意
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
|
||||
<div className="mt-6 w-full">
|
||||
<HomeAnimationEmbed htmlContent={homeAnimationData.htmlContent} />
|
||||
|
||||
@ -8,7 +8,7 @@ type LovartPromoBannerProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const LOVART_PROMO_BANNER_HEIGHT = 45;
|
||||
export const LOVART_PROMO_BANNER_HEIGHT = 48;
|
||||
|
||||
export function LovartPromoBanner({
|
||||
actionText,
|
||||
@ -20,7 +20,7 @@ export function LovartPromoBanner({
|
||||
const showAction = Boolean(link?.trim() && actionText?.trim());
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 top-0 z-[120] h-[45px] bg-[#ddff58] text-[#11130d]">
|
||||
<div className="fixed inset-x-0 top-0 z-[120] h-[48px] bg-[#e4ff58] text-[#11130d]">
|
||||
<div className="mx-auto flex h-full max-w-[1440px] items-center justify-center gap-3 px-11 sm:px-12">
|
||||
<Sparkles className="hidden h-4 w-4 shrink-0 sm:block" />
|
||||
<p className="min-w-0 truncate text-center text-[13px] font-normal sm:text-[15px]">
|
||||
|
||||
@ -20,6 +20,7 @@ type SidebarItem = {
|
||||
};
|
||||
|
||||
type LovartSidebarProps = {
|
||||
accountControls?: ReactNode;
|
||||
agentEnabled: boolean;
|
||||
getItem: (key: LovartHomeSidebarItemKey) => SidebarItem;
|
||||
isEnabled: (key: LovartHomeSidebarItemKey) => boolean;
|
||||
@ -56,6 +57,7 @@ type LovartSidebarProps = {
|
||||
};
|
||||
|
||||
export function LovartSidebar({
|
||||
accountControls,
|
||||
agentEnabled,
|
||||
getItem,
|
||||
isEnabled,
|
||||
@ -88,9 +90,9 @@ export function LovartSidebar({
|
||||
"rounded-[12px] border border-black/8 bg-white/96 p-2 text-[#111827] shadow-[0_18px_44px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-[#141416]/96 dark:text-white dark:shadow-[0_18px_44px_rgba(0,0,0,0.4)]";
|
||||
const sidebarButtonClass = (active = false) =>
|
||||
[
|
||||
"group flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[#202020] transition-colors duration-150 hover:bg-[#f5f5f5] dark:text-white dark:hover:bg-white/10",
|
||||
"group flex h-[36px] w-[36px] shrink-0 items-center justify-center rounded-[8px] text-[#202020] transition-colors duration-150 hover:bg-[#f5f5f5] dark:text-white dark:hover:bg-white/10",
|
||||
active
|
||||
? "bg-[#f5f5f5] text-[#202020] dark:bg-white/10 dark:text-white"
|
||||
? "bg-[#e5e5e5] text-[#202020] dark:bg-white/10 dark:text-white"
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@ -112,7 +114,7 @@ export function LovartSidebar({
|
||||
>
|
||||
{renderIcon(key, {
|
||||
active: options?.active,
|
||||
sizeClassName: "h-4 w-4",
|
||||
sizeClassName: "h-[20px] w-[20px]",
|
||||
})}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@ -133,7 +135,7 @@ export function LovartSidebar({
|
||||
className={sidebarButtonClass()}
|
||||
title={getItem(key).label}
|
||||
>
|
||||
{renderIcon(key, { sizeClassName: "h-4 w-4" })}
|
||||
{renderIcon(key, { sizeClassName: "h-[20px] w-[20px]" })}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
@ -164,15 +166,15 @@ export function LovartSidebar({
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[56px] flex-col items-center border-r lg:flex",
|
||||
"fixed bottom-0 left-0 top-0 z-[100] hidden w-[60px] flex-col items-center border-r lg:flex",
|
||||
isDark
|
||||
? "border-white/10 bg-black"
|
||||
: "border-[#e9e9e9] bg-white",
|
||||
: "border-[#ededed] bg-[#f5f5f5]",
|
||||
].join(" ")}
|
||||
style={{ top: topOffset }}
|
||||
>
|
||||
<TooltipProvider delayDuration={120}>
|
||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto overflow-x-hidden px-2 pb-3 pt-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="flex min-h-0 w-full flex-1 flex-col items-center overflow-y-auto overflow-x-hidden px-2 pt-[16px] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@ -197,18 +199,18 @@ export function LovartSidebar({
|
||||
<TooltipContent side="right">{siteName}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="mt-5 flex w-full flex-col items-center gap-3.5">
|
||||
<div className="mt-[22px] flex w-full flex-col items-center gap-[2px]">
|
||||
{isEnabled("newProject") ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewCanvas}
|
||||
className={sidebarButtonClass()}
|
||||
className={`${sidebarButtonClass()} mb-[14px] border border-[#e5e5e5] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:bg-white/5`}
|
||||
title={getItem("newProject").label}
|
||||
>
|
||||
{renderIcon("newProject", {
|
||||
sizeClassName: "h-4 w-4",
|
||||
sizeClassName: "h-[20px] w-[20px]",
|
||||
})}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@ -228,7 +230,7 @@ export function LovartSidebar({
|
||||
className={sidebarButtonClass()}
|
||||
title={getItem("tutorial").label}
|
||||
>
|
||||
{renderIcon("tutorial", { sizeClassName: "h-4 w-4" })}
|
||||
{renderIcon("tutorial", { sizeClassName: "h-[20px] w-[20px]" })}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
@ -281,9 +283,6 @@ export function LovartSidebar({
|
||||
</HoverCard>
|
||||
) : null}
|
||||
{tooltipButton("imageEditor", onImageEditor)}
|
||||
{tooltipButton("aiCad", () => onRoute("/ai-cad"))}
|
||||
{tooltipButton("model3d", () => onRoute("/model3d"))}
|
||||
{tooltipButton("scene", () => onRoute("/pascal-editor"))}
|
||||
{agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null}
|
||||
{videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null}
|
||||
{tooltipButton("flowBoard", () => onRoute("/flow-board"))}
|
||||
@ -294,9 +293,10 @@ export function LovartSidebar({
|
||||
{toolbox("toolbox2", toolbox2Links, toolbox2Enabled)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex w-full flex-col items-center gap-1 pt-4">
|
||||
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full shrink-0 flex-col items-center gap-[10px] px-2 pb-[22px] pt-[12px]">
|
||||
{settingsHref ? tooltipButton("settings", () => onRoute(settingsHref)) : null}
|
||||
{accountControls}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</aside>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, ChevronUp, Link2, Scan, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||
@ -36,6 +37,7 @@ interface ImageParamsBarProps {
|
||||
hideImageCount?: boolean; // 隐藏图片数量按钮(仅个别页面需要)
|
||||
hideAdvancedOptions?: boolean; // 隐藏高级设置按钮
|
||||
inlineTheme?: "light" | "dark" | "minimal";
|
||||
referencePresentation?: boolean;
|
||||
showInlineDivider?: boolean;
|
||||
// 🆕 图片生成模型特性配置(从模型配置中读取)
|
||||
imageGenConfig?: {
|
||||
@ -133,6 +135,7 @@ export function ImageParamsBar({
|
||||
hideImageCount = false,
|
||||
hideAdvancedOptions = false,
|
||||
inlineTheme = "light",
|
||||
referencePresentation = false,
|
||||
showInlineDivider = true,
|
||||
imageGenConfig,
|
||||
}: ImageParamsBarProps) {
|
||||
@ -493,11 +496,11 @@ export function ImageParamsBar({
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 shrink-0 rounded-full bg-[#f1f1f2] px-2.5 text-[13px] font-normal text-[#20242a] hover:bg-[#e9eaec] focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]"
|
||||
className={cn("h-8 shrink-0 rounded-full bg-[#f1f1f2] px-2.5 text-[13px] font-normal text-[#20242a] hover:bg-[#e9eaec] focus-visible:ring-0 focus-visible:ring-offset-0 dark:bg-[#242529] dark:text-zinc-100 dark:hover:bg-[#2c2d33]", referencePresentation && "lovart-image-params")}
|
||||
title="生成参数"
|
||||
>
|
||||
{qualityLabel} · {selectedAspectLabel} · {selectedImageCount} img
|
||||
{minimalParamsOpen ? (
|
||||
<span className={referencePresentation ? "min-w-0 truncate text-[13px]" : undefined}>{qualityLabel} · {selectedAspectLabel} · {selectedImageCount} {referencePresentation ? "张" : "img"}</span>
|
||||
{referencePresentation ? <ReferenceIcon name="chevron-down" size={18} className={minimalParamsOpen ? "rotate-180" : undefined} /> : minimalParamsOpen ? (
|
||||
<ChevronUp className="ml-1 h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="ml-1 h-3 w-3" />
|
||||
|
||||
44
components/lovart/model-identity-icon.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getReferenceModelIcon, ReferenceIcon } from "./reference-icon";
|
||||
|
||||
type ModelIdentityIconProps = {
|
||||
name: string;
|
||||
identity?: string;
|
||||
avatarUrl?: string | null;
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ModelIdentityIcon({
|
||||
name,
|
||||
identity,
|
||||
avatarUrl,
|
||||
size = 18,
|
||||
className,
|
||||
}: ModelIdentityIconProps) {
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||
const reference = getReferenceModelIcon(`${name} ${identity || ""}`);
|
||||
if (reference || !avatarUrl || failedUrl === avatarUrl) {
|
||||
return (
|
||||
<ReferenceIcon
|
||||
name={reference || "model-auto"}
|
||||
size={size}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
src={avatarUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ width: size, height: size, objectFit: "contain", flexShrink: 0 }}
|
||||
onError={() => setFailedUrl(avatarUrl)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
125
components/lovart/reference-icon.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ReferenceIconName =
|
||||
| "home"
|
||||
| "folder"
|
||||
| "skill"
|
||||
| "brand"
|
||||
| "plus"
|
||||
| "model-auto"
|
||||
| "arrow-up"
|
||||
| "commerce"
|
||||
| "social"
|
||||
| "branding"
|
||||
| "creativity"
|
||||
| "website"
|
||||
| "connections"
|
||||
| "clipper"
|
||||
| "cursor"
|
||||
| "pan"
|
||||
| "text"
|
||||
| "sparkles"
|
||||
| "style-controls"
|
||||
| "image-enhance"
|
||||
| "tutorial"
|
||||
| "video"
|
||||
| "toolbox"
|
||||
| "settings"
|
||||
| "profile"
|
||||
| "comment"
|
||||
| "draw"
|
||||
| "image"
|
||||
| "layers"
|
||||
| "history"
|
||||
| "expand"
|
||||
| "collapse"
|
||||
| "reference"
|
||||
| "skill-filled"
|
||||
| "brand-filled"
|
||||
| "pinterest"
|
||||
| "close"
|
||||
| "reference-image"
|
||||
| "chevron-down"
|
||||
| "points"
|
||||
| "page"
|
||||
| "model-banana"
|
||||
| "model-gpt"
|
||||
| "model-seedream"
|
||||
| "model-luma"
|
||||
| "model-flux"
|
||||
| "model-midjourney"
|
||||
| "model-ideogram"
|
||||
| "model-recraft"
|
||||
| "image-local-edit"
|
||||
| "image-layout-replace"
|
||||
| "image-mark"
|
||||
| "image-upscale"
|
||||
| "image-remove-background"
|
||||
| "image-erase"
|
||||
| "image-extract-layer"
|
||||
| "image-edit-text"
|
||||
| "image-angle"
|
||||
| "image-motion"
|
||||
| "image-more"
|
||||
| "image-download"
|
||||
| "image-move-object"
|
||||
| "image-mockup"
|
||||
| "image-outpaint"
|
||||
| "image-adjust"
|
||||
| "image-crop"
|
||||
| "image-vector"
|
||||
| "image-flip-rotate"
|
||||
| "toolbar-pin-outline"
|
||||
| "toolbar-pin-filled"
|
||||
| "toolbar-reset"
|
||||
| "toolbar-close";
|
||||
|
||||
type ReferenceIconProps = {
|
||||
name: ReferenceIconName;
|
||||
size?: number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function ReferenceIcon({
|
||||
name,
|
||||
size = 18,
|
||||
className,
|
||||
style,
|
||||
}: ReferenceIconProps) {
|
||||
const source = `url("/icons/lovart/${name}.svg")`;
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-reference-icon={name}
|
||||
className={cn("inline-block shrink-0 align-middle", className)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: "currentColor",
|
||||
WebkitMaskImage: source,
|
||||
maskImage: source,
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
WebkitMaskPosition: "center",
|
||||
maskPosition: "center",
|
||||
WebkitMaskSize: "contain",
|
||||
maskSize: "contain",
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function getReferenceModelIcon(name: string): ReferenceIconName | null {
|
||||
if (/nano[\s-]*banana|gemini|imagen/i.test(name)) return "model-banana";
|
||||
if (/gpt|openai|dall[\s-]*e/i.test(name)) return "model-gpt";
|
||||
if (/seedream|doubao/i.test(name)) return "model-seedream";
|
||||
if (/flux/i.test(name)) return "model-flux";
|
||||
if (/luma|uni[\s-]*1/i.test(name)) return "model-luma";
|
||||
if (/midjourney/i.test(name)) return "model-midjourney";
|
||||
if (/ideogram|p[\s-]*image/i.test(name)) return "model-ideogram";
|
||||
if (/recraft/i.test(name)) return "model-recraft";
|
||||
return null;
|
||||
}
|
||||
57
docs/XMind-26.04-客户端界面对照.md
Normal file
@ -0,0 +1,57 @@
|
||||
# 本机 Xmind 客户端源码与界面对照
|
||||
|
||||
本轮修改位于用户指定的“旧电脑桌面/第四部分/开发项目/ai-chatbot-main”。本机 `/Applications/Xmind.app` 为 26.04.01337,与此前的 26.05 参考包分别记录。
|
||||
|
||||
- 读取 `Contents/Resources/app.asar`,核对 `renderer/editor-frame.js`、`renderer/editor.js`、Vanakit 样式及浮动工具栏/工作表栏样式。完整第三方代码只放在临时目录。
|
||||
- 浮动工具栏按客户端顶部 16px、组间距 12px、圆角 24px 调整。主题/子主题、联系、概要、外框、插入、导出、大纲、ZEN、演示、标记和格式入口接入现有画布。菜单支持方向键和 Escape。
|
||||
- 格式侧栏包含样式/画布分类及可展开结构选择。底部胶囊工作表支持重命名取消及缩放菜单。图标、NeverMind 字体来源记录在各自 public 子目录。
|
||||
- 修正全局等宽字体覆盖、字体测量与实际渲染不一致引发的标题裁切。导入保留无填充、零边框、分支配色,透明填充随 XMind 导出。
|
||||
- 查找支持折叠后代、当前/全部工作表、跨工作表定位。跨富文本叶替换保持文字标记,替换中的 `$&` 按字面写入;批量替换可撤销/重做。
|
||||
- 30 项数据测试、18 项浏览器测试、类型检查及新增组件/工具的 Biome 检查通过。浏览器测试覆盖安装包 9 个基础样例,并检查主主题字体和文字不裁切。
|
||||
|
||||
此轮完成上述基础能力和界面调整,尚未达到全功能及逐像素一致。格式控件、节点间距、左侧分支顺序、完整 Pitch、全部主题、任务/甘特图、云协作等仍有差距,不能用测试数量认定与原生完全对等。完整验收清单见 [功能对照](./XMind-复刻功能对照.md)。
|
||||
|
||||
## 2026-10-04 实机操作与项目 UI 调整
|
||||
|
||||
- 本机客户端实际打开网页导出的 XMind 文件、显示备注和标签、将主主题字号改为 24pt,并保存;网页再次导入后,字号、两行纯文本备注和附件字节保留。该指定样例通过,不代表所有样式和内容无损兼容。
|
||||
- 实际打开原生查找、标记和甘特窗口。任务编辑及完整演示遇到授权界面;云端服务、所有导出类型、全部布局变体仍未完成逐项实机覆盖。
|
||||
- 修改前的 17 个网页功能组操作中 14 个通过、3 个文字格式组异常。进一步读取可见文字片段,确认选中整个主题修改字号、文字颜色或标记时,格式落入空文字片段,已有标题未更新。该问题仍待修复。
|
||||
- 相同窗口 1480×873、DPR=2、100% 下,原生样例主主题色块约 137×41,网页约 124×37;同侧分支间距约 130 与 63,左侧分支顺序不同。测量的是色块区域,不能视为逐像素一致。
|
||||
- 根据用户最新要求,界面改为项目首页使用的 Arial/PingFang 字体与紧凑控件:取消模拟窗口按钮,工具栏圆角改为 8px,侧栏从 264px 改为 240px,减少预览卡片和分组留白。画布主题字体和实际缩放由文档保留。检查显示页面 CSS zoom=1、transform=none,新建画板 100%。
|
||||
- 增加常驻选择/平移按钮、V/H 快捷键与当前模式反馈,初始化和工作表切换默认选择模式;切换选择/平移时重置创建模式。文字输入框的 V/H 不被快捷键拦截。
|
||||
- 框选两分支、批量删除和撤销、平移后再次框选、按钮/快捷键切换和文本输入均通过浏览器验证;查找侧栏遮挡文件菜单的问题已修正。20 项浏览器用例全部通过(首轮 18 项通过,两个既有测试因新增工具占据原点击坐标而失败,调整为空白坐标后复测通过);TypeScript 检查通过。800px 窗口无页面横向溢出。
|
||||
- 开发预览点击保存后刷新回空白,不是正式账号云端保存的验收结果。详细截图和 46 项能力对照保存在本机 `.local/xmind-audit-2026-10-04/index.html`,未修改用户原始 Xmind 文档。
|
||||
|
||||
## 2026-10-04 框选线可见性修复
|
||||
|
||||
- 原有框选数量测试未覆盖实际显示。实测发现预览页没有应用画板基础样式,活动 SVG 的 position=static,区域顶边位于屏幕 y=1309,因此选中数量变化但框线不可见。
|
||||
- 将画板基础 CSS 移到共享客户端组件中打包加载,替代只在正式路由加载的外部样式链接。修复后活动 SVG 为 absolute,覆盖画布可视区域且 pointer-events=none,不拦截编辑操作。
|
||||
- 框选边线为 2px 蓝色,拖动区域有浅蓝填充;松开后去掉拖动填充,保留被选主题和多选范围描边。
|
||||
- 框选回归新增可视坐标、边框颜色/宽度、填充及松开后高亮检查,覆盖平移后再框选。实际截图保存在 `.local/xmind-audit-2026-10-04/web/22-marquee-drag-after.png`。
|
||||
|
||||
## 2026-10-05 主题图片粘贴修复
|
||||
|
||||
- 修复主题文字编辑时跳过图片粘贴的问题:选中一个主题或正在编辑该主题时,剪贴板图片作为主题图片插入,保留标题;同一主题再次插入替换原图。插入菜单的图片入口采用相同行为,空白画布保留独立图片插入。
|
||||
- 图片按比例缩放,主题图片宽度上限 240px。异步读取/上传后重新查找目标主题,切换画布或目标已删除时不插入其他位置。文字、标题、备注等文本粘贴保留各自原有处理。
|
||||
- 开发预览直接保存图片数据,避免依赖登录上传;正式页面继续使用原有上传接口。支持 JPEG、PNG、WebP,保留原有大小限制。
|
||||
- 4 项浏览器用例通过,使用实际剪贴板和粘贴快捷键验证编辑状态、选中状态、空白画布及文字粘贴;同时覆盖图片撤销/重做、XMind 导出嵌入资源和标题/备注不拦截。TypeScript 与本轮文件 Biome 检查通过。正式账号上传接口未在此轮实测。
|
||||
|
||||
## 2026-10-05 原生标记面板与文件往返
|
||||
|
||||
- 原先采用少量系统 Emoji 代替原生标记,大部分标记不能导出;旧面板在 240px 侧栏中使用固定宽度网格,会溢出。现使用安装包的标记目录、原生 SVG/PNG 和标记入口图标,默认显示标签、优先级、任务、旗帜、星星、人像、符号 7 组 58 个标记。
|
||||
- 提取客户端目录中的 219 份标记资源,包含 197 个不同 ID;共享 ID 按客户端目录顺序解析。搜索可查找隐藏标记,“更多标记”展示完整目录,并用于旧文档导入显示。资源来源和哈希记录在 `public/icons/xmind/markers/`。
|
||||
- 选中主题后添加标记,同组替换,再次点击移除;多选显示混合状态,点击后为全部选中主题应用同一值。批量修改和清空各作为一次撤销操作。清空同时移除保留的未知原生标记,撤销可恢复;普通自定义 Emoji 不受影响。
|
||||
- 标记在主题上以客户端图标呈现,支持 XMind 导入导出。未识别的 ID 继续作为元数据保留,不能宣称所有第三方自定义标记都可显示。贴纸、插画、自定义标记库及图例交互仍未实现;原先禁用的占位页签已移除。
|
||||
- 3 项标记浏览器测试覆盖面板尺寸、实际图标加载、增删替换、清空、批量撤销/重做、搜索和更多标记;2 项数据测试覆盖全部目录资源存在、197 个 ID 文件往返及旧 Emoji 兼容。TypeScript 与本轮文件的 Biome 检查通过。
|
||||
- 连续操作发现成功提示会盖住顶部插入菜单;将白板内的提示移至底部,并取消清空标记后的遮挡提示。图片粘贴用例等待画布字体加载后再操作,避免加载中的坐标变化影响双击。
|
||||
|
||||
|
||||
## 2026-10-06 笔记与甘特图
|
||||
|
||||
已加入富文本笔记、主题笔记/任务提示、任务信息窗口和甘特图。甘特图参考客户端左右表格/时间网格、橙色胶囊时间条、任务名称与负责人提示、选中行高亮、圆角容器、缩放和今天线,按项目现有控件尺寸实现。日级任务可编辑日期/工期、进度、优先级、负责人及四种前置关系;拖动和拉伸支持联动排期及批次撤销。
|
||||
|
||||
文件格式按安装包核对:最新笔记使用 `realHTML.content`,任务使用 `org.xmind.ui.task` 扩展、0–1 进度和毫秒工期。笔记默认在主题旁的浮窗编辑,支持展开主题详情侧栏;甘特图高级工作日日历、小时级及里程碑、团队成员服务和原生全部导出方式尚未对等。
|
||||
|
||||
本机 XMind 已实际打开本轮导出文件,任务卡片和原生甘特图正确显示 2026 年 10 月 5–6 日(2 天)与 7–9 日(3 天)、负责人“小明”;原生甘特图的前置关系箭头正常显示;笔记浮窗显示“1. 交付说明”、加粗及蓝色链接。指定样例的原生读取已验证,不能据此认定全部复杂笔记和任务设置均无损。
|
||||
|
||||
本轮 9 项数据检查、15 项浏览器检查(分批运行)、TypeScript 和相关代码检查通过。浏览器覆盖笔记与甘特图,并回归标记、框选、图片粘贴、主题内容及文件往返;首次空白画布导出超时后的复测已通过。快捷键不再把笔记全选变成画布多选。
|
||||
@ -18,21 +18,21 @@
|
||||
| 联系线 | 基础 | 控制点编辑、箭头、虚线、复杂布局、双向联系 |
|
||||
| 外框 | 基础 | 标题、全形状、嵌套、填充、范围调整 |
|
||||
| 概要 | 基础 | 范围调整、多重概要、完整样式 |
|
||||
| 图标/标记、贴纸 | 部分标记 | 全标记映射、自定义图标、贴纸库 |
|
||||
| 备注、外链、主题链接、标签 | 基础 | 已验证纯文本备注、标签和外链编辑、撤销、工作表切换、快照序列化;同画布链接可展开折叠分支定位。待补富文本备注、跨画布链接、画布内容标识、真实云端保存重载 |
|
||||
| 图标/标记、贴纸 | 原生标记可用,贴纸/插画待实现 | 自定义标记、图例交互及贴纸/插画库 |
|
||||
| 备注、外链、主题链接、标签 | 基础 | 已增加富文本笔记(加粗、斜体、下划线、删除线、列表、缩进、链接),写入客户端最新 realHTML 和 plain 格式并保留旧段落格式;支持笔记提示、撤销和项目内往返。标签和外链编辑、工作表切换已验证;待补备注图片、跨画布链接和真实云端保存重载 |
|
||||
| 图片、附件、音频、公式 | 部分支持 | 新旧 XMind 内嵌 PNG/JPEG/GIF/WebP 导入;附件新增/替换/下载/移除/撤销。待补 SVG、音频、公式、资源导出和真实样本覆盖 |
|
||||
| 任务、待办、进度、日期、负责人、甘特图 | 待实现 | 任务模型、视图和筛选,不能用普通图标替代 |
|
||||
| 查找替换、过滤、聚焦、导航 | 待实现 | 折叠节点和多画布范围、替换撤销 |
|
||||
| 任务、待办、进度、日期、负责人、甘特图 | 任务及甘特图基础已实现 | 任务开始/截止日期、日级工期、进度、优先级、多负责人、四种依赖;甘特图拖动/拉伸、联动排期、批次撤销、汇总任务、折叠、筛选、缩放、今天定位、CSV 和浏览器打印;原生任务扩展导入导出。待办模型、工作日日历、小时级/里程碑、原生全部导出与团队成员服务待补 |
|
||||
| 查找替换、过滤、聚焦、导航 | 查找替换基础 | 已验证折叠后代、当前/全部画布范围、跨工作表定位、替换批量撤销/重做和跨富文本叶替换;过滤、分支聚焦待补 |
|
||||
| 大纲视图 | 基础 | 已验证画布双向改名、新增/删除、缩进/提升、拖动重排、展开折叠、缩放和撤销;待补混合富文本、批量选区、分支聚焦、大纲专用导出和原生视觉对照 |
|
||||
| 浮动工具栏、主题添加按钮 | 待新版对照 | 26.05 安装包有新增编辑体验文案,需按原生行为补齐 |
|
||||
| 编辑器偏好设置 | 待实现 | 默认缩放、默认中日韩字体、一周起始日、快捷输入启动偏好 |
|
||||
| ZEN、演示/Pitch、分步展示 | 待实现 | 交互、快捷键、导出与演示行为 |
|
||||
| ZEN、演示/Pitch、分步展示 | ZEN / 演示基础 | 已有隐藏编辑工具、Escape 恢复和主题演示前后页;完整 Pitch 模板、分步动画、演示导出待补 |
|
||||
| 多工作表新建/切换/重命名/复制/删除 | 基础 | 跨工作表移动/链接、合并 XMind 文件 |
|
||||
| 云文件、自动保存 | 基础 | 离线恢复、版本历史、异常恢复与冲突 |
|
||||
| XMind 新版 JSON / 旧版 XML 导入 | 基础 | 资源、全部样式、结构和扩展内容;真实样本库 |
|
||||
| MD、OPML、FreeMind 导入 | 基础 | 更多格式与大文件性能 |
|
||||
| XMind 导出与往返兼容 | 基础 | 已有多工作表 ZIP 导出与项目内往返测试;原生 XMind 打开和保存回传尚未核验,不能仅自读自写认定兼容 |
|
||||
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | 待逐项核验 | 全图/选区/单页/多页、透明背景、纸张和清晰度 |
|
||||
| XMind 导出与往返兼容 | 基础,指定原生往返样例已核验 | 本机 26.04 实际打开网页导出文件、修改字号并保存回传,指定主题的字号/纯文本备注/附件字节保留;全结构、复杂样式与混合富文本的原生无损往返仍待核验 |
|
||||
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | PNG 已支持;其余待核验 | 当前工作表全图 PNG,1/2/3 倍清晰度、画布背景或透明背景;选区、多页和其他格式待补 |
|
||||
| 打印、密码、缓存、文件恢复 | 待实现 | 不得以浏览器存储代替完整文件保护 |
|
||||
| 模板、主题库、手绘风格 | 待实现/部分底层能力 | 内置模板、自定义、导入与复用 |
|
||||
| 协作、评论、分享、权限 | 待实现 | 项目已有登录不等于实时协作完成 |
|
||||
@ -97,3 +97,15 @@
|
||||
- 结构面板增加两枚独立 SVG 图标;表格主题使用整格几何参与命中和画布适配。修复 8 位 XMind 色值、原生圆角主题边框残留和新增长文本裁切。
|
||||
- 27 项数据测试、15 项浏览器测试和 TypeScript 检查通过;全量浏览器回归再次覆盖安装包的 9 个基础样例。另用安装包中的 `mood_tracker.xmind`、`coordinating_upcoming_release_1.0.xmind`、`tree_table.xmind`、`film_production.xmind` 完成主题完整性导入导出验证。
|
||||
- 当前矩阵支持 XMind 的列扩展和标签映射;列增删/改名的专用交互、复杂嵌套子树、原生应用打开导出文件和逐像素多主题对照仍待实现。
|
||||
|
||||
|
||||
## 2026-10-06 笔记与甘特图补齐
|
||||
|
||||
- 标记功能已先提交到本地 Git:`e6a68b34`。
|
||||
- 富文本笔记使用现有 Slate 编辑能力,支持选中文字或继续输入时的格式、列表、缩进及安全链接;画布笔记提示可直接打开编辑。读取现代 `realHTML`、旧 `html.content.paragraphs` 与纯文本笔记,导出同时写入 `realHTML`/`plain` 和旧格式。格式依据已安装客户端 `6996.js` 的 Converter 核对。
|
||||
- 任务写入原生 `org.xmind.ui.task` 扩展;日期使用本地日历日边界,进度存为 0–1,工期和依赖间隔存为毫秒。导入/导出重映射依赖主题 ID,保留其他原生扩展。
|
||||
- 甘特图与画布共用主题数据及历史。可添加/编辑/移除任务、按前置关系排期、拒绝循环或父子依赖、拖动/拉伸时间条、按任务名称/负责人/进度筛选,支持汇总任务范围/进度、缩放、定位今天、CSV 导出和浏览器打印。移除任务时清理其他任务的引用,一次撤销恢复全部受影响任务。
|
||||
- 本轮 9 项笔记/任务/导出数据检查、15 项浏览器检查(分批运行)、TypeScript 和相关文件 Biome 检查通过。浏览器覆盖富文本选区/列表/链接/撤销、任务日期/依赖/拖动/拉伸、校验/CSV/工作表隔离,以及原有图片粘贴、标记、框选、主题内容和导出。首次整批运行的空白画布导出用例超时,关闭追踪录制后单独复测通过;检查日志保留在本地验证目录。正式云账号保存重载没有在本轮验证。
|
||||
- 已在安装的 XMind 26.04 中打开网页导出文件:笔记浮窗显示有序列表、加粗和蓝色链接,原生甘特图显示两条时间条、日期/工期/负责人和前置关系箭头。网页时间条改为客户端的橙色胶囊外观,显示任务名与负责人提示,选中行和时间条同步高亮。
|
||||
- 修复笔记快捷键被画布拦截及快速选区格式不同步的问题;笔记、侧栏输入和任务窗口的键盘/剪贴板事件各自处理,编辑时保留当前主题。
|
||||
- 当前是可用的富文本笔记和日级甘特图能力,未宣称与客户端全部功能或像素完全一致。高级工作日日历、小时/里程碑、原生全部导出格式、团队成员服务及笔记图片仍需继续补齐。
|
||||
|
||||
105
hooks/use-canvas-image-archives.ts
Normal file
@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
import { useEffect } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import {
|
||||
collectImageArchiveSources,
|
||||
type ArchivedImageUrlMap,
|
||||
} from "@/lib/storage/image-archive-urls";
|
||||
|
||||
function preload(url: string, signal: AbortSignal): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
let done = false;
|
||||
const finish = (loaded: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
image.onload = image.onerror = null;
|
||||
resolve(loaded);
|
||||
};
|
||||
const cancel = () => finish(false);
|
||||
const timer = setTimeout(cancel, 15_000);
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
image.onload = () => finish(true);
|
||||
image.onerror = cancel;
|
||||
if (signal.aborted) cancel();
|
||||
else image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function useCanvasImageArchives(projectId: string) {
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const checked = new Set<string>();
|
||||
const unknownChecks = new Map<string, number>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let polling = false;
|
||||
const poll = async () => {
|
||||
if (polling || controller.signal.aborted || document.visibilityState === "hidden") return;
|
||||
const state = useCanvasStore.getState();
|
||||
if (state.projectId !== projectId) return;
|
||||
const urls = collectImageArchiveSources(state.nodes)
|
||||
.filter((url) => !checked.has(url))
|
||||
.slice(0, 100);
|
||||
if (!urls.length) return;
|
||||
polling = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/canvas/${encodeURIComponent(projectId)}/image-archives`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ urls }),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const result = (await response.json()) as {
|
||||
replacements: ArchivedImageUrlMap;
|
||||
pending: string[];
|
||||
};
|
||||
const pending = new Set(result.pending);
|
||||
const ready: ArchivedImageUrlMap = {};
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const target = result.replacements[url];
|
||||
if (target) {
|
||||
if (await preload(target, controller.signal)) {
|
||||
ready[url] = target;
|
||||
checked.add(url);
|
||||
checked.add(target);
|
||||
}
|
||||
} else if (!pending.has(url)) {
|
||||
// A direct generation can reach the browser before its first autosave.
|
||||
// Allow ownership records to catch up before classifying a URL as untracked.
|
||||
const checks = (unknownChecks.get(url) || 0) + 1;
|
||||
unknownChecks.set(url, checks);
|
||||
if (checks >= 10) checked.add(url);
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (
|
||||
!controller.signal.aborted &&
|
||||
useCanvasStore.getState().projectId === projectId &&
|
||||
Object.keys(ready).length
|
||||
) {
|
||||
useCanvasStore.getState().replaceArchivedImageUrls(ready);
|
||||
}
|
||||
} catch {
|
||||
// Temporary network errors keep provider previews and are retried on the next poll.
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
};
|
||||
const tick = () => {
|
||||
void poll();
|
||||
timer = setTimeout(tick, 3_000);
|
||||
};
|
||||
timer = setTimeout(tick, 1_000);
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [projectId]);
|
||||
}
|
||||
@ -350,14 +350,12 @@ async function uploadImageBuffer(buffer: Buffer, mimeType: string) {
|
||||
}
|
||||
|
||||
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
|
||||
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "atlascloud" });
|
||||
if (immediatePreview) return immediatePreview;
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
const parsed = parseDataUrl(imageUrl);
|
||||
return uploadImageBuffer(parsed.buffer, parsed.mimeType);
|
||||
}
|
||||
const deferred = await createDeferredUploadedImage(imageUrl, {
|
||||
providerType: "atlascloud",
|
||||
});
|
||||
if (deferred) return deferred;
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
|
||||
@ -329,15 +329,13 @@ async function uploadImageBuffer(
|
||||
}
|
||||
|
||||
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
|
||||
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "bananaimage" });
|
||||
if (immediatePreview) return immediatePreview;
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
const parsed = parseDataUrl(imageUrl);
|
||||
return uploadImageBuffer(parsed.buffer, parsed.mimeType);
|
||||
}
|
||||
|
||||
const deferred = await createDeferredUploadedImage(imageUrl, {
|
||||
providerType: "bananaimage",
|
||||
});
|
||||
if (deferred) return deferred;
|
||||
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
|
||||
@ -119,6 +119,9 @@ async function inferImageAspectRatioFromUrl(url: string): Promise<string | null>
|
||||
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
|
||||
*/
|
||||
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> {
|
||||
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "gemini-image" });
|
||||
if (deferred) return deferred;
|
||||
|
||||
try {
|
||||
// 解析 base64 数据
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
@ -62,6 +62,9 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer
|
||||
* 将 base64 图片上传到 S3,返回缩略图和原图的 URL
|
||||
*/
|
||||
async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jpeg"): Promise<{ thumbnail: string; original: string }> {
|
||||
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "huanke-gemini" });
|
||||
if (deferred) return deferred;
|
||||
|
||||
try {
|
||||
// 解析 base64 数据
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
@ -430,6 +430,9 @@ async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage>
|
||||
}
|
||||
|
||||
async function uploadBase64ToS3(base64Data: string, mimeType: string) {
|
||||
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "openai-image" });
|
||||
if (deferred) return deferred;
|
||||
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const extension = detectImageExtension("", mimeType);
|
||||
return uploadGeneratedBuffer(
|
||||
|
||||
@ -665,6 +665,9 @@ function generateFilename(prefix: string, extension: string) {
|
||||
}
|
||||
|
||||
async function uploadImageBuffer(buffer: Buffer, extension: string): Promise<UploadedImage> {
|
||||
const mime = extension === "jpg" || extension === "jpeg" ? "image/jpeg" : `image/${extension}`;
|
||||
const deferred = await createDeferredUploadedImage(`data:${mime};base64,${buffer.toString("base64")}`, { providerType: "snapgen-image" });
|
||||
if (deferred) return deferred;
|
||||
const thumbnailBuffer = await createThumbnail(buffer);
|
||||
|
||||
const originalFilename = generateFilename("snapgen-image-orig", extension);
|
||||
|
||||
@ -177,6 +177,9 @@ async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage>
|
||||
}
|
||||
|
||||
async function uploadBase64ToS3(base64Data: string, mimeType: string) {
|
||||
const deferred = await createDeferredUploadedImage(`data:${mimeType || "image/png"};base64,${base64Data}`, { providerType: "stepfun-image" });
|
||||
if (deferred) return deferred;
|
||||
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const extension = detectImageExtension("", mimeType);
|
||||
return uploadGeneratedBuffer(
|
||||
|
||||
@ -491,14 +491,12 @@ async function uploadBuffer(buffer: Buffer, mimeType: string) {
|
||||
}
|
||||
|
||||
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
|
||||
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "vip98k" });
|
||||
if (immediatePreview) return immediatePreview;
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
const parsed = parseDataUrl(imageUrl);
|
||||
return uploadBuffer(parsed.buffer, parsed.mimeType);
|
||||
}
|
||||
const deferred = await createDeferredUploadedImage(imageUrl, {
|
||||
providerType: "vip98k",
|
||||
});
|
||||
if (deferred) return deferred;
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
|
||||
@ -269,15 +269,13 @@ async function uploadImageBuffer(
|
||||
}
|
||||
|
||||
async function archiveOutputImage(imageUrl: string): Promise<UploadedImage> {
|
||||
const immediatePreview = await createDeferredUploadedImage(imageUrl, { providerType: "visionary" });
|
||||
if (immediatePreview) return immediatePreview;
|
||||
if (imageUrl.startsWith("data:")) {
|
||||
const parsed = parseDataUrl(imageUrl);
|
||||
return uploadImageBuffer(parsed.buffer, parsed.mimeType);
|
||||
}
|
||||
|
||||
const deferred = await createDeferredUploadedImage(imageUrl, {
|
||||
providerType: "visionary",
|
||||
});
|
||||
if (deferred) return deferred;
|
||||
|
||||
const response = await fetch(imageUrl, {
|
||||
cache: "no-store",
|
||||
|
||||
@ -428,9 +428,9 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise<Buffer
|
||||
/**
|
||||
* 将图片 URL 或 base64 上传到 S3,返回缩略图和原图的 URL
|
||||
*/
|
||||
async function uploadImageToS3(imageData: string): Promise<{ thumbnail: string; original: string }> {
|
||||
async function uploadImageToS3(imageData: string, deferArchive = true): Promise<{ thumbnail: string; original: string }> {
|
||||
try {
|
||||
if (/^https?:\/\//i.test(imageData)) {
|
||||
if (deferArchive && /^(?:https?:\/\/|data:image\/)/i.test(imageData)) {
|
||||
const deferred = await createDeferredUploadedImage(imageData, {
|
||||
providerType: "volcano-vision",
|
||||
});
|
||||
@ -653,7 +653,7 @@ async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt
|
||||
if (imageUrl.startsWith('data:image')) {
|
||||
// Base64格式:需要先上传到S3获取URL
|
||||
logger.debug("[Volcano Vision] Found Base64 image, uploading to S3...");
|
||||
const uploaded = await uploadImageToS3(imageUrl);
|
||||
const uploaded = await uploadImageToS3(imageUrl, false);
|
||||
const rewritten = await rewriteUploadPublicUrlForVolcano(uploaded.original);
|
||||
processedImages.push(rewritten);
|
||||
} else {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import {
|
||||
@ -122,6 +123,7 @@ export async function saveGeneratedImages(
|
||||
savedOriginalUrls: string[];
|
||||
}> {
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
imageMarkdown = await resolveArchivedImageUrls(imageMarkdown);
|
||||
const matches = [...imageMarkdown.matchAll(imageRegex)];
|
||||
|
||||
let savedImageCount = 0;
|
||||
|
||||
74
lib/canvas-image-toolbar-preferences.ts
Normal file
@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export const IMAGE_TOOLBAR_STORAGE_KEY = "canvas.image-toolbar.v1";
|
||||
const CHANGE_EVENT = "canvas-image-toolbar-preferences";
|
||||
export type ImageToolbarPreferences = {
|
||||
pinnedKeys: string[];
|
||||
showNames: boolean;
|
||||
};
|
||||
|
||||
export function parseImageToolbarPreferences(
|
||||
raw: string | null
|
||||
): ImageToolbarPreferences | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const value = JSON.parse(raw);
|
||||
if (
|
||||
!value ||
|
||||
!Array.isArray(value.pinnedKeys) ||
|
||||
typeof value.showNames !== "boolean"
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
pinnedKeys: [
|
||||
...new Set<string>(
|
||||
value.pinnedKeys.filter(
|
||||
(key: unknown) => typeof key === "string" && key.length < 80
|
||||
)
|
||||
),
|
||||
],
|
||||
showNames: value.showNames,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
try {
|
||||
return window.localStorage.getItem(IMAGE_TOOLBAR_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function subscribe(listener: () => void) {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (!event.key || event.key === IMAGE_TOOLBAR_STORAGE_KEY) listener();
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(CHANGE_EVENT, listener);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(CHANGE_EVENT, listener);
|
||||
};
|
||||
}
|
||||
export function useImageToolbarPreferences() {
|
||||
const raw = useSyncExternalStore(subscribe, snapshot, () => null);
|
||||
return parseImageToolbarPreferences(raw);
|
||||
}
|
||||
export function saveImageToolbarPreferences(
|
||||
preferences: ImageToolbarPreferences
|
||||
) {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
IMAGE_TOOLBAR_STORAGE_KEY,
|
||||
JSON.stringify(preferences)
|
||||
);
|
||||
window.dispatchEvent(new Event(CHANGE_EVENT));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -1,192 +1,40 @@
|
||||
import "server-only";
|
||||
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
||||
DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
|
||||
DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
|
||||
type CanvasImageTaskQueueConfig,
|
||||
normalizeCanvasImageTaskQueueConfig,
|
||||
} from "./image-task-config";
|
||||
import { normalizeCanvasImageTaskQueueConfig } from "./image-task-config";
|
||||
|
||||
const CONCURRENCY_CONFIG_CACHE_MS = 5 * 1000;
|
||||
const RECENT_EXECUTION_SAMPLE_LIMIT = 24;
|
||||
const DEFAULT_ESTIMATED_WAIT_MS = 45 * 1000;
|
||||
const concurrencyStateKey = "__canvas_image_model_concurrency__";
|
||||
|
||||
interface ModelWaiter {
|
||||
ticketId: string;
|
||||
ownerKey: string;
|
||||
resolve: () => void;
|
||||
}
|
||||
|
||||
interface ModelConcurrencyState {
|
||||
running: Map<string, string>;
|
||||
waiters: ModelWaiter[];
|
||||
dispatchTimer: ReturnType<typeof setTimeout> | null;
|
||||
delayedUntil: number | null;
|
||||
recentExecutionDurationsMs: number[];
|
||||
}
|
||||
|
||||
interface CanvasImageConcurrencyState {
|
||||
models: Map<string, ModelConcurrencyState>;
|
||||
}
|
||||
|
||||
let cachedConfig:
|
||||
| {
|
||||
expiresAt: number;
|
||||
value: CanvasImageTaskQueueConfig;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
function getGlobalConcurrencyState(): CanvasImageConcurrencyState {
|
||||
const stateKey = "__unrestricted_canvas_image_models__";
|
||||
type ModelState = { running: Set<string>; durations: number[] };
|
||||
function getModels() {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
[concurrencyStateKey]?: CanvasImageConcurrencyState;
|
||||
[stateKey]?: Map<string, ModelState>;
|
||||
};
|
||||
|
||||
if (!globalState[concurrencyStateKey]) {
|
||||
globalState[concurrencyStateKey] = {
|
||||
models: new Map<string, ModelConcurrencyState>(),
|
||||
};
|
||||
}
|
||||
|
||||
return globalState[concurrencyStateKey] as CanvasImageConcurrencyState;
|
||||
return (globalState[stateKey] ??= new Map());
|
||||
}
|
||||
|
||||
function getOrCreateModelState(modelKey: string) {
|
||||
const state = getGlobalConcurrencyState();
|
||||
const existing = state.models.get(modelKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
function getModel(key: string) {
|
||||
const models = getModels();
|
||||
const normalized = normalizeCanvasImageModelKey(key);
|
||||
let state = models.get(normalized);
|
||||
if (!state) {
|
||||
state = { running: new Set(), durations: [] };
|
||||
models.set(normalized, state);
|
||||
}
|
||||
|
||||
const created: ModelConcurrencyState = {
|
||||
running: new Map<string, string>(),
|
||||
waiters: [],
|
||||
dispatchTimer: null,
|
||||
delayedUntil: null,
|
||||
recentExecutionDurationsMs: [],
|
||||
};
|
||||
state.models.set(modelKey, created);
|
||||
return created;
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearModelDispatchTimer(modelState: ModelConcurrencyState) {
|
||||
if (modelState.dispatchTimer) {
|
||||
clearTimeout(modelState.dispatchTimer);
|
||||
modelState.dispatchTimer = null;
|
||||
}
|
||||
|
||||
modelState.delayedUntil = null;
|
||||
}
|
||||
|
||||
function cleanupModelState(modelKey: string) {
|
||||
const state = getGlobalConcurrencyState();
|
||||
const modelState = state.models.get(modelKey);
|
||||
if (!modelState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
modelState.running.size === 0 &&
|
||||
modelState.waiters.length === 0 &&
|
||||
modelState.recentExecutionDurationsMs.length === 0
|
||||
) {
|
||||
clearModelDispatchTimer(modelState);
|
||||
state.models.delete(modelKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCanvasImageModelKey(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return "__default__";
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized || "__default__";
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "__default__";
|
||||
}
|
||||
|
||||
function normalizeCanvasImageOwnerKey(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return "__anonymous__";
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized || "__anonymous__";
|
||||
export async function getCanvasImageTaskQueueConfig() {
|
||||
return normalizeCanvasImageTaskQueueConfig(undefined);
|
||||
}
|
||||
|
||||
function getMaxConcurrentPerOwner(maxConcurrent: number) {
|
||||
return Math.max(1, Math.ceil(Math.max(1, maxConcurrent) / 2));
|
||||
export async function getCanvasImageSingleModelConcurrency() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getRunningCountForOwner(
|
||||
modelState: ModelConcurrencyState,
|
||||
ownerKey: string
|
||||
) {
|
||||
let count = 0;
|
||||
for (const runningOwnerKey of modelState.running.values()) {
|
||||
if (runningOwnerKey === ownerKey) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
export async function getCanvasImageBatchDispatchIntervalMs() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function getCanvasImageTaskQueueConfig(): Promise<CanvasImageTaskQueueConfig> {
|
||||
const now = Date.now();
|
||||
if (cachedConfig && cachedConfig.expiresAt > now) {
|
||||
return cachedConfig.value;
|
||||
}
|
||||
|
||||
const envConcurrency = process.env.CANVAS_IMAGE_TASK_CONCURRENCY;
|
||||
const envBatchDispatchIntervalMs = process.env.CANVAS_IMAGE_TASK_BATCH_DISPATCH_INTERVAL_MS;
|
||||
let config = normalizeCanvasImageTaskQueueConfig(undefined, {
|
||||
envConcurrency,
|
||||
envBatchDispatchIntervalMs,
|
||||
});
|
||||
|
||||
try {
|
||||
const setting = await getSystemSetting({
|
||||
key: CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY,
|
||||
});
|
||||
if (setting?.value !== undefined && setting.value !== null) {
|
||||
config = normalizeCanvasImageTaskQueueConfig(setting.value, {
|
||||
envConcurrency,
|
||||
envBatchDispatchIntervalMs,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[Canvas Image Concurrency] Failed to load DB config, using fallback", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
cachedConfig = {
|
||||
expiresAt: now + CONCURRENCY_CONFIG_CACHE_MS,
|
||||
value: {
|
||||
concurrency: config.concurrency || DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY,
|
||||
batchDispatchIntervalMs: Number.isFinite(config.batchDispatchIntervalMs)
|
||||
? config.batchDispatchIntervalMs
|
||||
: DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
|
||||
},
|
||||
};
|
||||
|
||||
return cachedConfig.value;
|
||||
}
|
||||
|
||||
export async function getCanvasImageSingleModelConcurrency(): Promise<number> {
|
||||
return (await getCanvasImageTaskQueueConfig()).concurrency;
|
||||
}
|
||||
|
||||
export async function getCanvasImageBatchDispatchIntervalMs(): Promise<number> {
|
||||
return (await getCanvasImageTaskQueueConfig()).batchDispatchIntervalMs;
|
||||
}
|
||||
|
||||
export function getCanvasImageRunningCountForModel(modelKey: string) {
|
||||
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
|
||||
return getGlobalConcurrencyState().models.get(normalizedModelKey)?.running.size || 0;
|
||||
return getModels().get(normalizeCanvasImageModelKey(modelKey))?.running.size || 0;
|
||||
}
|
||||
|
||||
export type CanvasImageModelQueueStatus = {
|
||||
status: "pending" | "running" | "succeeded" | "failed";
|
||||
queuePosition: number;
|
||||
@ -197,143 +45,27 @@ export type CanvasImageModelQueueStatus = {
|
||||
source: "model_queue";
|
||||
};
|
||||
|
||||
function getRecentAverageExecutionMs(modelState: ModelConcurrencyState) {
|
||||
const samples = modelState.recentExecutionDurationsMs.filter(
|
||||
(value) => Number.isFinite(value) && value > 0
|
||||
);
|
||||
if (samples.length === 0) {
|
||||
return DEFAULT_ESTIMATED_WAIT_MS;
|
||||
}
|
||||
const total = samples.reduce((sum, value) => sum + value, 0);
|
||||
return Math.max(1000, Math.round(total / samples.length));
|
||||
}
|
||||
|
||||
function buildModelQueueSnapshot(params: {
|
||||
modelState: ModelConcurrencyState;
|
||||
queuePosition: number;
|
||||
sameModelPendingCount: number;
|
||||
sameModelRunningCount: number;
|
||||
concurrencyLimit: number;
|
||||
}): CanvasImageModelQueueStatus {
|
||||
const averageExecutionMs = getRecentAverageExecutionMs(params.modelState);
|
||||
const estimatedWaitMs =
|
||||
params.sameModelPendingCount <= 0
|
||||
? 0
|
||||
: Math.max(
|
||||
averageExecutionMs,
|
||||
Math.ceil((params.queuePosition + 1) / Math.max(1, params.concurrencyLimit)) *
|
||||
averageExecutionMs
|
||||
);
|
||||
|
||||
return {
|
||||
status: "pending",
|
||||
queuePosition: Math.max(0, params.queuePosition),
|
||||
sameModelPendingCount: Math.max(0, params.sameModelPendingCount),
|
||||
sameModelRunningCount: Math.max(0, params.sameModelRunningCount),
|
||||
concurrencyLimit: Math.max(1, params.concurrencyLimit),
|
||||
estimatedWaitMs,
|
||||
source: "model_queue",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCanvasImageModelQueueStatus(params: {
|
||||
modelKey: string;
|
||||
}): Promise<CanvasImageModelQueueStatus> {
|
||||
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey);
|
||||
const { concurrency: maxConcurrent } = await getCanvasImageTaskQueueConfig();
|
||||
const modelState =
|
||||
getGlobalConcurrencyState().models.get(normalizedModelKey) || {
|
||||
running: new Map<string, string>(),
|
||||
waiters: [],
|
||||
dispatchTimer: null,
|
||||
delayedUntil: null,
|
||||
recentExecutionDurationsMs: [],
|
||||
};
|
||||
return buildModelQueueSnapshot({
|
||||
modelState,
|
||||
queuePosition: modelState.waiters.length > 0 ? modelState.waiters.length - 1 : 0,
|
||||
sameModelPendingCount: modelState.waiters.length,
|
||||
sameModelRunningCount: modelState.running.size,
|
||||
concurrencyLimit: maxConcurrent,
|
||||
});
|
||||
}
|
||||
|
||||
export function recordCanvasImageModelExecutionDuration(
|
||||
modelKey: string,
|
||||
durationMs: number
|
||||
) {
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
|
||||
const modelState = getOrCreateModelState(normalizedModelKey);
|
||||
modelState.recentExecutionDurationsMs.push(durationMs);
|
||||
if (modelState.recentExecutionDurationsMs.length > RECENT_EXECUTION_SAMPLE_LIMIT) {
|
||||
modelState.recentExecutionDurationsMs.splice(
|
||||
0,
|
||||
modelState.recentExecutionDurationsMs.length - RECENT_EXECUTION_SAMPLE_LIMIT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function drainModelWaiters(
|
||||
modelKey: string,
|
||||
trigger: "arrival" | "slot-release" | "timer"
|
||||
) {
|
||||
const normalizedModelKey = normalizeCanvasImageModelKey(modelKey);
|
||||
const modelState = getGlobalConcurrencyState().models.get(normalizedModelKey);
|
||||
if (!modelState || modelState.waiters.length === 0) {
|
||||
cleanupModelState(normalizedModelKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const { concurrency: maxConcurrent, batchDispatchIntervalMs } =
|
||||
await getCanvasImageTaskQueueConfig();
|
||||
if (modelState.running.size >= maxConcurrent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (modelState.delayedUntil && modelState.delayedUntil > now) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger === "slot-release" && batchDispatchIntervalMs > 0) {
|
||||
if (!modelState.dispatchTimer) {
|
||||
modelState.delayedUntil = now + batchDispatchIntervalMs;
|
||||
modelState.dispatchTimer = setTimeout(() => {
|
||||
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
|
||||
if (!state) return;
|
||||
clearModelDispatchTimer(state);
|
||||
void drainModelWaiters(normalizedModelKey, "timer");
|
||||
}, batchDispatchIntervalMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearModelDispatchTimer(modelState);
|
||||
while (modelState.running.size < maxConcurrent && modelState.waiters.length > 0) {
|
||||
const maxConcurrentPerOwner = getMaxConcurrentPerOwner(maxConcurrent);
|
||||
const waiterIndex = modelState.waiters.findIndex(
|
||||
(item) =>
|
||||
getRunningCountForOwner(modelState, item.ownerKey) < maxConcurrentPerOwner
|
||||
);
|
||||
if (waiterIndex < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const [waiter] = modelState.waiters.splice(waiterIndex, 1);
|
||||
if (!waiter) {
|
||||
break;
|
||||
}
|
||||
modelState.running.set(waiter.ticketId, waiter.ownerKey);
|
||||
waiter.resolve();
|
||||
}
|
||||
|
||||
cleanupModelState(normalizedModelKey);
|
||||
return {
|
||||
status: "running",
|
||||
queuePosition: 0,
|
||||
sameModelPendingCount: 0,
|
||||
sameModelRunningCount: getCanvasImageRunningCountForModel(params.modelKey),
|
||||
concurrencyLimit: 0,
|
||||
estimatedWaitMs: 0,
|
||||
source: "model_queue",
|
||||
};
|
||||
}
|
||||
export function recordCanvasImageModelExecutionDuration(modelKey: string, durationMs: number) {
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
|
||||
const state = getModel(modelKey);
|
||||
state.durations.push(durationMs);
|
||||
if (state.durations.length > 24) state.durations.shift();
|
||||
}
|
||||
|
||||
// Keep execution tracking/release semantics, without model or owner admission limits.
|
||||
export async function acquireCanvasImageModelSlot(params: {
|
||||
modelKey: string;
|
||||
ownerKey?: string;
|
||||
@ -341,86 +73,15 @@ export async function acquireCanvasImageModelSlot(params: {
|
||||
debugMeta?: Record<string, unknown>;
|
||||
onQueued?: (status: CanvasImageModelQueueStatus) => void | Promise<void>;
|
||||
}) {
|
||||
const normalizedModelKey = normalizeCanvasImageModelKey(params.modelKey);
|
||||
const normalizedOwnerKey = normalizeCanvasImageOwnerKey(params.ownerKey);
|
||||
const {
|
||||
concurrency: maxConcurrent,
|
||||
batchDispatchIntervalMs,
|
||||
} = await getCanvasImageTaskQueueConfig();
|
||||
const modelState = getOrCreateModelState(normalizedModelKey);
|
||||
const ticketId = `${normalizedModelKey}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`;
|
||||
const waitingSince = Date.now();
|
||||
const hasActiveDelay =
|
||||
modelState.delayedUntil !== null && modelState.delayedUntil > waitingSince;
|
||||
const maxConcurrentPerOwner = getMaxConcurrentPerOwner(maxConcurrent);
|
||||
|
||||
if (
|
||||
modelState.waiters.length === 0 &&
|
||||
!hasActiveDelay &&
|
||||
modelState.running.size < maxConcurrent &&
|
||||
getRunningCountForOwner(modelState, normalizedOwnerKey) < maxConcurrentPerOwner
|
||||
) {
|
||||
modelState.running.set(ticketId, normalizedOwnerKey);
|
||||
return {
|
||||
release: async () => {
|
||||
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
|
||||
if (!state) return;
|
||||
state.running.delete(ticketId);
|
||||
await drainModelWaiters(normalizedModelKey, "slot-release");
|
||||
},
|
||||
waitMs: 0,
|
||||
maxConcurrent,
|
||||
batchDispatchIntervalMs,
|
||||
};
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const queuePosition = modelState.waiters.length;
|
||||
modelState.waiters.push({
|
||||
ticketId,
|
||||
ownerKey: normalizedOwnerKey,
|
||||
resolve,
|
||||
});
|
||||
if (typeof params.onQueued === "function") {
|
||||
const queueStatus = buildModelQueueSnapshot({
|
||||
modelState,
|
||||
queuePosition,
|
||||
sameModelPendingCount: modelState.waiters.length,
|
||||
sameModelRunningCount: modelState.running.size,
|
||||
concurrencyLimit: maxConcurrent,
|
||||
});
|
||||
Promise.resolve(params.onQueued(queueStatus)).catch((error) => {
|
||||
console.warn("[Canvas Image Concurrency] onQueued callback failed:", {
|
||||
debugLabel: params.debugLabel || "canvas-image",
|
||||
modelKey: normalizedModelKey,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...params.debugMeta,
|
||||
});
|
||||
});
|
||||
}
|
||||
void drainModelWaiters(normalizedModelKey, "arrival");
|
||||
});
|
||||
|
||||
const waitMs = Date.now() - waitingSince;
|
||||
if (waitMs > 250) {
|
||||
console.info("[Canvas Image Concurrency] Waited for model slot:", {
|
||||
debugLabel: params.debugLabel || "canvas-image",
|
||||
modelKey: normalizedModelKey,
|
||||
waitMs,
|
||||
batchDispatchIntervalMs,
|
||||
...params.debugMeta,
|
||||
});
|
||||
}
|
||||
|
||||
const state = getModel(params.modelKey);
|
||||
const ticket = crypto.randomUUID();
|
||||
state.running.add(ticket);
|
||||
return {
|
||||
release: async () => {
|
||||
const state = getGlobalConcurrencyState().models.get(normalizedModelKey);
|
||||
if (!state) return;
|
||||
state.running.delete(ticketId);
|
||||
await drainModelWaiters(normalizedModelKey, "slot-release");
|
||||
state.running.delete(ticket);
|
||||
},
|
||||
waitMs,
|
||||
maxConcurrent,
|
||||
batchDispatchIntervalMs,
|
||||
waitMs: 0,
|
||||
maxConcurrent: 0,
|
||||
batchDispatchIntervalMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,130 +1,21 @@
|
||||
export const CANVAS_IMAGE_TASK_QUEUE_CONFIG_KEY = "canvas_image_task_queue_config";
|
||||
|
||||
export const MIN_CANVAS_IMAGE_TASK_CONCURRENCY = 1;
|
||||
export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 3;
|
||||
export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 100;
|
||||
// Zero means unrestricted. Legacy DB/environment caps must not re-enable queueing.
|
||||
export const MIN_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
|
||||
export const DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
|
||||
export const MAX_CANVAS_IMAGE_TASK_CONCURRENCY = 0;
|
||||
export const MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
|
||||
export const DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
|
||||
export const MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 60 * 1000;
|
||||
export const MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS = 0;
|
||||
|
||||
export interface CanvasImageTaskQueueConfig {
|
||||
concurrency: number;
|
||||
batchDispatchIntervalMs: number;
|
||||
}
|
||||
|
||||
function clampConcurrency(value: number) {
|
||||
return Math.min(
|
||||
MAX_CANVAS_IMAGE_TASK_CONCURRENCY,
|
||||
Math.max(MIN_CANVAS_IMAGE_TASK_CONCURRENCY, Math.floor(value))
|
||||
);
|
||||
}
|
||||
|
||||
function clampBatchDispatchIntervalMs(value: number) {
|
||||
return Math.min(
|
||||
MAX_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS,
|
||||
Math.max(MIN_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS, Math.floor(value))
|
||||
);
|
||||
}
|
||||
|
||||
function parseConcurrencyCandidate(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return clampConcurrency(value);
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return clampConcurrency(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBatchDispatchIntervalCandidate(
|
||||
value: unknown,
|
||||
options?: { treatAsSeconds?: boolean }
|
||||
): number | null {
|
||||
const normalize = (candidate: number) =>
|
||||
clampBatchDispatchIntervalMs(
|
||||
options?.treatAsSeconds ? candidate * 1000 : candidate
|
||||
);
|
||||
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return normalize(value);
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number.parseFloat(value.trim());
|
||||
if (Number.isFinite(parsed)) {
|
||||
return normalize(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeCanvasImageTaskQueueConfig(
|
||||
value: unknown,
|
||||
options?: { envConcurrency?: unknown; envBatchDispatchIntervalMs?: unknown }
|
||||
_value: unknown,
|
||||
_options?: { envConcurrency?: unknown; envBatchDispatchIntervalMs?: unknown },
|
||||
): CanvasImageTaskQueueConfig {
|
||||
const raw = value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const concurrencyCandidates = [
|
||||
raw.concurrency,
|
||||
raw.maxConcurrency,
|
||||
raw.maxConcurrent,
|
||||
typeof value === "number" || typeof value === "string" ? value : undefined,
|
||||
options?.envConcurrency,
|
||||
];
|
||||
|
||||
let concurrency = DEFAULT_CANVAS_IMAGE_TASK_CONCURRENCY;
|
||||
for (const candidate of concurrencyCandidates) {
|
||||
const parsed = parseConcurrencyCandidate(candidate);
|
||||
if (parsed !== null) {
|
||||
concurrency = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const intervalCandidates = [
|
||||
raw.batchDispatchIntervalMs,
|
||||
raw.dispatchIntervalMs,
|
||||
raw.batchDispatchDelayMs,
|
||||
raw.batchDispatchInterval,
|
||||
raw.dispatchInterval,
|
||||
options?.envBatchDispatchIntervalMs,
|
||||
];
|
||||
const intervalSecondsCandidates = [
|
||||
raw.batchDispatchIntervalSeconds,
|
||||
raw.dispatchIntervalSeconds,
|
||||
];
|
||||
|
||||
let batchDispatchIntervalMs = DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS;
|
||||
for (const candidate of intervalCandidates) {
|
||||
const parsed = parseBatchDispatchIntervalCandidate(candidate);
|
||||
if (parsed !== null) {
|
||||
batchDispatchIntervalMs = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (batchDispatchIntervalMs === DEFAULT_CANVAS_IMAGE_BATCH_DISPATCH_INTERVAL_MS) {
|
||||
for (const candidate of intervalSecondsCandidates) {
|
||||
const parsed = parseBatchDispatchIntervalCandidate(candidate, {
|
||||
treatAsSeconds: true,
|
||||
});
|
||||
if (parsed !== null) {
|
||||
batchDispatchIntervalMs = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
concurrency,
|
||||
batchDispatchIntervalMs,
|
||||
};
|
||||
return { concurrency: 0, batchDispatchIntervalMs: 0 };
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, desc, eq, lt, notInArray, sql } from "drizzle-orm";
|
||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||
|
||||
import { and, asc, eq, lt, notInArray, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { canvasImageTask } from "@/lib/db/schema";
|
||||
import {
|
||||
getCanvasImageBatchDispatchIntervalMs,
|
||||
getCanvasImageRunningCountForModel,
|
||||
getCanvasImageSingleModelConcurrency,
|
||||
normalizeCanvasImageModelKey,
|
||||
@ -70,10 +71,6 @@ interface QueueState {
|
||||
pendingSet: Set<string>;
|
||||
running: Map<string, RunningTaskEntry>;
|
||||
runningByModel: Map<string, Set<string>>;
|
||||
runningByModelUser: Map<string, Set<string>>;
|
||||
nextBatchEligibleAt: Map<string, number>;
|
||||
nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>;
|
||||
awaitingNextBatch: Set<string>;
|
||||
lastStaleRunningCleanupAt: number;
|
||||
draining: boolean;
|
||||
}
|
||||
@ -83,9 +80,6 @@ const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 12 * 60 * 1000;
|
||||
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||
const PENDING_LOOKAHEAD_LIMIT = 100;
|
||||
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
|
||||
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
|
||||
const DEFAULT_ESTIMATED_WAIT_MS = 45 * 1000;
|
||||
const queueStateKey = "__canvas_image_task_queue_state__";
|
||||
|
||||
function getQueueState(): QueueState {
|
||||
@ -98,10 +92,6 @@ function getQueueState(): QueueState {
|
||||
pendingSet: new Set<string>(),
|
||||
running: new Map<string, RunningTaskEntry>(),
|
||||
runningByModel: new Map<string, Set<string>>(),
|
||||
runningByModelUser: new Map<string, Set<string>>(),
|
||||
nextBatchEligibleAt: new Map<string, number>(),
|
||||
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
||||
awaitingNextBatch: new Set<string>(),
|
||||
lastStaleRunningCleanupAt: 0,
|
||||
draining: false,
|
||||
};
|
||||
@ -184,14 +174,6 @@ function isStyleProTaskRequest(request: unknown): request is StyleProTaskRequest
|
||||
);
|
||||
}
|
||||
|
||||
function getModelUserKey(modelKey: string, userId: string) {
|
||||
return `${modelKey}::${userId || "__anonymous__"}`;
|
||||
}
|
||||
|
||||
function getMaxConcurrentPerUser(maxConcurrentPerModel: number) {
|
||||
return Math.max(1, Math.ceil(Math.max(1, maxConcurrentPerModel) / 2));
|
||||
}
|
||||
|
||||
async function getTaskQueueMeta(taskId: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
@ -209,27 +191,12 @@ async function getTaskQueueMeta(taskId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function getRunningCountForModel(modelKey: string) {
|
||||
const state = getQueueState();
|
||||
return state.runningByModel.get(modelKey)?.size || 0;
|
||||
}
|
||||
|
||||
function getRunningCountForModelUser(modelKey: string, userId: string) {
|
||||
const state = getQueueState();
|
||||
return state.runningByModelUser.get(getModelUserKey(modelKey, userId))?.size || 0;
|
||||
}
|
||||
|
||||
function markRunningTask(taskId: string, modelKey: string, userId: string) {
|
||||
const state = getQueueState();
|
||||
state.running.set(taskId, { modelKey, userId });
|
||||
const tasks = state.runningByModel.get(modelKey) || new Set<string>();
|
||||
tasks.add(taskId);
|
||||
state.runningByModel.set(modelKey, tasks);
|
||||
|
||||
const modelUserKey = getModelUserKey(modelKey, userId);
|
||||
const userTasks = state.runningByModelUser.get(modelUserKey) || new Set<string>();
|
||||
userTasks.add(taskId);
|
||||
state.runningByModelUser.set(modelUserKey, userTasks);
|
||||
}
|
||||
|
||||
function releaseRunningTask(taskId: string) {
|
||||
@ -240,7 +207,7 @@ function releaseRunningTask(taskId: string) {
|
||||
}
|
||||
|
||||
state.running.delete(taskId);
|
||||
const { modelKey, userId } = entry;
|
||||
const { modelKey } = entry;
|
||||
const tasks = state.runningByModel.get(modelKey);
|
||||
if (tasks) {
|
||||
tasks.delete(taskId);
|
||||
@ -249,65 +216,9 @@ function releaseRunningTask(taskId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const modelUserKey = getModelUserKey(modelKey, userId);
|
||||
const userTasks = state.runningByModelUser.get(modelUserKey);
|
||||
if (userTasks) {
|
||||
userTasks.delete(taskId);
|
||||
if (userTasks.size === 0) {
|
||||
state.runningByModelUser.delete(modelUserKey);
|
||||
}
|
||||
}
|
||||
|
||||
return modelKey;
|
||||
}
|
||||
|
||||
function isModelBatchDispatchDelayed(modelKey: string) {
|
||||
const state = getQueueState();
|
||||
const delayedUntil = state.nextBatchEligibleAt.get(modelKey);
|
||||
if (!delayedUntil) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (delayedUntil <= Date.now()) {
|
||||
state.nextBatchEligibleAt.delete(modelKey);
|
||||
const timer = state.nextBatchTimers.get(modelKey);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
state.nextBatchTimers.delete(modelKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function markModelAwaitingNextBatch(modelKey: string) {
|
||||
getQueueState().awaitingNextBatch.add(modelKey);
|
||||
}
|
||||
|
||||
function scheduleModelNextBatch(modelKey: string, delayMs: number) {
|
||||
const state = getQueueState();
|
||||
if (delayMs <= 0) {
|
||||
state.awaitingNextBatch.delete(modelKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.nextBatchTimers.has(modelKey)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
state.awaitingNextBatch.delete(modelKey);
|
||||
state.nextBatchEligibleAt.set(modelKey, Date.now() + delayMs);
|
||||
const timer = setTimeout(() => {
|
||||
const latestState = getQueueState();
|
||||
latestState.nextBatchTimers.delete(modelKey);
|
||||
latestState.nextBatchEligibleAt.delete(modelKey);
|
||||
void drainCanvasImageTaskQueue();
|
||||
}, delayMs);
|
||||
state.nextBatchTimers.set(modelKey, timer);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function enqueueLocalTask(
|
||||
taskId: string,
|
||||
meta?: { modelKey?: string; userId?: string } | string
|
||||
@ -334,42 +245,14 @@ async function enqueueLocalTask(
|
||||
});
|
||||
state.pendingSet.add(taskId);
|
||||
|
||||
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
|
||||
if (
|
||||
getRunningCountForModel(resolvedModelKey) >= maxConcurrentPerModel ||
|
||||
getRunningCountForModelUser(resolvedModelKey, resolvedUserId) >=
|
||||
getMaxConcurrentPerUser(maxConcurrentPerModel)
|
||||
) {
|
||||
markModelAwaitingNextBatch(resolvedModelKey);
|
||||
}
|
||||
}
|
||||
|
||||
function hasModelCapacity(
|
||||
modelKey: string,
|
||||
userId: string,
|
||||
maxConcurrentPerModel: number
|
||||
) {
|
||||
return (
|
||||
getRunningCountForModel(modelKey) < maxConcurrentPerModel &&
|
||||
getRunningCountForModelUser(modelKey, userId) <
|
||||
getMaxConcurrentPerUser(maxConcurrentPerModel)
|
||||
);
|
||||
}
|
||||
|
||||
async function claimNextPendingTask(
|
||||
maxConcurrentPerModel: number
|
||||
): Promise<PendingTaskEntry | null> {
|
||||
async function claimNextPendingTask(): Promise<PendingTaskEntry | null> {
|
||||
const state = getQueueState();
|
||||
for (let index = 0; index < state.pending.length; index += 1) {
|
||||
const entry = state.pending[index];
|
||||
if (!entry) continue;
|
||||
if (state.running.has(entry.taskId)) continue;
|
||||
if (isModelBatchDispatchDelayed(entry.modelKey)) continue;
|
||||
if (!hasModelCapacity(entry.modelKey, entry.userId, maxConcurrentPerModel)) {
|
||||
markModelAwaitingNextBatch(entry.modelKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
state.pending.splice(index, 1);
|
||||
state.pendingSet.delete(entry.taskId);
|
||||
return entry;
|
||||
@ -396,15 +279,7 @@ async function claimNextPendingTask(
|
||||
if (state.pendingSet.has(row.id) || state.running.has(row.id)) continue;
|
||||
|
||||
const modelKey = normalizeTaskModelKey(row.request);
|
||||
if (isModelBatchDispatchDelayed(modelKey)) {
|
||||
continue;
|
||||
}
|
||||
const userId = String(row.userId || "__anonymous__");
|
||||
if (!hasModelCapacity(modelKey, userId, maxConcurrentPerModel)) {
|
||||
markModelAwaitingNextBatch(modelKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
taskId: row.id,
|
||||
modelKey,
|
||||
@ -553,7 +428,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
||||
.update(canvasImageTask)
|
||||
.set({
|
||||
status: "succeeded",
|
||||
result: result as CanvasImageTaskResult,
|
||||
result: await resolveArchivedImageUrls(result) as CanvasImageTaskResult,
|
||||
error: null,
|
||||
completedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@ -575,7 +450,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
||||
and(eq(canvasImageTask.id, entry.taskId), eq(canvasImageTask.status, "running"))
|
||||
);
|
||||
} finally {
|
||||
const modelKey = releaseRunningTask(entry.taskId);
|
||||
releaseRunningTask(entry.taskId);
|
||||
let nextBatchTaskId: string | null = null;
|
||||
if (completedBatchRequest?.batchId) {
|
||||
try {
|
||||
@ -592,12 +467,6 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (modelKey) {
|
||||
const batchDispatchIntervalMs = await getCanvasImageBatchDispatchIntervalMs();
|
||||
if (getQueueState().awaitingNextBatch.has(modelKey)) {
|
||||
scheduleModelNextBatch(modelKey, batchDispatchIntervalMs);
|
||||
}
|
||||
}
|
||||
if (nextBatchTaskId) {
|
||||
await enqueueLocalTask(nextBatchTaskId);
|
||||
}
|
||||
@ -612,9 +481,8 @@ export async function drainCanvasImageTaskQueue() {
|
||||
state.draining = true;
|
||||
try {
|
||||
await markStaleDatabaseRunningTasksFailed();
|
||||
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
|
||||
while (true) {
|
||||
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);
|
||||
const nextTask = await claimNextPendingTask();
|
||||
if (!nextTask) break;
|
||||
if (state.running.has(nextTask.taskId)) continue;
|
||||
markRunningTask(nextTask.taskId, nextTask.modelKey, nextTask.userId);
|
||||
@ -622,6 +490,11 @@ export async function drainCanvasImageTaskQueue() {
|
||||
}
|
||||
} finally {
|
||||
state.draining = false;
|
||||
// An arrival can occur while the final database scan is in flight. Dispatch it
|
||||
// immediately rather than relying on a later status poll to wake the queue.
|
||||
if (state.pending.some((entry) => !state.running.has(entry.taskId))) {
|
||||
void drainCanvasImageTaskQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -718,58 +591,6 @@ export async function createCanvasImageTask(params: {
|
||||
return task;
|
||||
}
|
||||
|
||||
async function estimateModelQueueWaitMs(params: {
|
||||
modelKey: string;
|
||||
sameModelRunningCount: number;
|
||||
queuePosition: number;
|
||||
concurrencyLimit: number;
|
||||
}) {
|
||||
const rows = await db
|
||||
.select({
|
||||
request: canvasImageTask.request,
|
||||
startedAt: canvasImageTask.startedAt,
|
||||
completedAt: canvasImageTask.completedAt,
|
||||
})
|
||||
.from(canvasImageTask)
|
||||
.where(eq(canvasImageTask.status, "succeeded"))
|
||||
.orderBy(desc(canvasImageTask.completedAt))
|
||||
.limit(QUEUE_STATUS_RECENT_SAMPLE_LIMIT);
|
||||
|
||||
const durations = rows
|
||||
.filter((row) => normalizeTaskModelKey(row.request) === params.modelKey)
|
||||
.map((row) => {
|
||||
if (!row.startedAt || !row.completedAt) return 0;
|
||||
return row.completedAt.getTime() - row.startedAt.getTime();
|
||||
})
|
||||
.filter((value) => Number.isFinite(value) && value > 1000)
|
||||
.slice(0, 12);
|
||||
|
||||
const averageDurationMs =
|
||||
durations.length > 0
|
||||
? Math.round(durations.reduce((sum, value) => sum + value, 0) / durations.length)
|
||||
: DEFAULT_ESTIMATED_WAIT_MS;
|
||||
const immediatelyStartable = Math.max(
|
||||
params.concurrencyLimit - params.sameModelRunningCount,
|
||||
0
|
||||
);
|
||||
|
||||
if (params.queuePosition < immediatelyStartable) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tasksAheadAfterImmediateSlots = Math.max(
|
||||
0,
|
||||
params.queuePosition - immediatelyStartable
|
||||
);
|
||||
const waveCount =
|
||||
Math.floor(
|
||||
tasksAheadAfterImmediateSlots / Math.max(1, params.concurrencyLimit)
|
||||
) +
|
||||
(params.sameModelRunningCount > 0 || immediatelyStartable === 0 ? 1 : 0);
|
||||
|
||||
return Math.max(0, waveCount * averageDurationMs);
|
||||
}
|
||||
|
||||
async function buildCanvasImageTaskQueueStatus(task: {
|
||||
id: string;
|
||||
status: CanvasImageTaskStatus;
|
||||
@ -789,51 +610,13 @@ async function buildCanvasImageTaskQueueStatus(task: {
|
||||
getCanvasImageRunningCountForModel(modelKey)
|
||||
);
|
||||
|
||||
if (task.status !== "pending") {
|
||||
return {
|
||||
status: task.status,
|
||||
queuePosition: 0,
|
||||
sameModelPendingCount: 0,
|
||||
sameModelRunningCount,
|
||||
concurrencyLimit,
|
||||
estimatedWaitMs: 0,
|
||||
source: "model_queue" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingRows = await db
|
||||
.select({
|
||||
id: canvasImageTask.id,
|
||||
request: canvasImageTask.request,
|
||||
createdAt: canvasImageTask.createdAt,
|
||||
})
|
||||
.from(canvasImageTask)
|
||||
.where(eq(canvasImageTask.status, "pending"))
|
||||
.orderBy(asc(canvasImageTask.createdAt))
|
||||
.limit(QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT);
|
||||
|
||||
const sameModelPendingRows = pendingRows.filter(
|
||||
(row) => normalizeTaskModelKey(row.request) === modelKey
|
||||
);
|
||||
const queuePosition = Math.max(
|
||||
sameModelPendingRows.findIndex((row) => row.id === task.id),
|
||||
0
|
||||
);
|
||||
const sameModelPendingCount = sameModelPendingRows.length;
|
||||
const estimatedWaitMs = await estimateModelQueueWaitMs({
|
||||
modelKey,
|
||||
sameModelRunningCount,
|
||||
queuePosition,
|
||||
concurrencyLimit,
|
||||
});
|
||||
|
||||
return {
|
||||
status: task.status,
|
||||
queuePosition,
|
||||
sameModelPendingCount,
|
||||
queuePosition: 0,
|
||||
sameModelPendingCount: 0,
|
||||
sameModelRunningCount,
|
||||
concurrencyLimit,
|
||||
estimatedWaitMs,
|
||||
estimatedWaitMs: 0,
|
||||
source: "model_queue" as const,
|
||||
};
|
||||
}
|
||||
|
||||
41
lib/canvas/viewport-fit.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { CanvasNode, Viewport } from "@/types/canvas";
|
||||
|
||||
export function getCanvasFitViewport(
|
||||
nodes: Pick<CanvasNode, "position" | "size">[],
|
||||
container: { width: number; height: number },
|
||||
occlusionRight = 0
|
||||
): Viewport | null {
|
||||
const visibleWidth = container.width - Math.max(0, occlusionRight);
|
||||
if (visibleWidth <= 0 || container.height <= 0) return null;
|
||||
const validNodes = nodes.filter(
|
||||
(node) =>
|
||||
[
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.width,
|
||||
node.size.height,
|
||||
].every(Number.isFinite) &&
|
||||
node.size.width > 0 &&
|
||||
node.size.height > 0
|
||||
);
|
||||
if (!validNodes.length) return null;
|
||||
const minX = Math.min(...validNodes.map((node) => node.position.x));
|
||||
const minY = Math.min(...validNodes.map((node) => node.position.y));
|
||||
const maxX = Math.max(
|
||||
...validNodes.map((node) => node.position.x + node.size.width)
|
||||
);
|
||||
const maxY = Math.max(
|
||||
...validNodes.map((node) => node.position.y + node.size.height)
|
||||
);
|
||||
const padding = Math.min(80, visibleWidth / 4, container.height / 4);
|
||||
const zoom = Math.min(
|
||||
4,
|
||||
visibleWidth / (maxX - minX + 2 * padding),
|
||||
container.height / (maxY - minY + 2 * padding)
|
||||
);
|
||||
return {
|
||||
x: visibleWidth / 2 - ((minX + maxX) / 2) * zoom,
|
||||
y: container.height / 2 - ((minY + maxY) / 2) * zoom,
|
||||
zoom,
|
||||
};
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { resolveArchivedImageUrls } from "@/lib/storage/image-archive-db";
|
||||
|
||||
import {
|
||||
and,
|
||||
@ -1590,6 +1591,7 @@ export async function syncCanvasNodesForProject(params: {
|
||||
viewport?: { x: number; y: number; zoom: number };
|
||||
} | null | undefined;
|
||||
}) {
|
||||
params = { ...params, canvasData: await resolveArchivedImageUrls(params.canvasData) };
|
||||
const nodes = Array.isArray(params.canvasData?.nodes) ? params.canvasData?.nodes : [];
|
||||
const assetUrls = Array.from(
|
||||
new Set(
|
||||
|
||||
24
lib/flow-board/calendar-dates.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export const DAY = 86_400_000;
|
||||
export function dateIndex(date: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return NaN;
|
||||
const timestamp = Date.parse(`${date}T00:00:00Z`);
|
||||
return Number.isFinite(timestamp) &&
|
||||
new Date(timestamp).toISOString().slice(0, 10) === date
|
||||
? timestamp / DAY
|
||||
: NaN;
|
||||
}
|
||||
export const indexDate = (index: number) =>
|
||||
new Date(index * DAY).toISOString().slice(0, 10);
|
||||
export const addDays = (date: string, days: number) =>
|
||||
indexDate(dateIndex(date) + days);
|
||||
export function timestampDate(value: unknown) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "";
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return "";
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
export const todayDate = () => timestampDate(Date.now());
|
||||
export const dateTimestamp = (date: string, end = false) =>
|
||||
Number.isFinite(dateIndex(date))
|
||||
? new Date(`${date}T${end ? "23:59:59.999" : "00:00:00"}`).getTime()
|
||||
: null;
|
||||
137
lib/flow-board/image-export.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { PlaitBoard } from "@plait/core";
|
||||
|
||||
export type ImageExportOptions = {
|
||||
scale: 1 | 2 | 3;
|
||||
transparent: boolean;
|
||||
backgroundColor: string;
|
||||
};
|
||||
|
||||
const textStyles = [
|
||||
"display",
|
||||
"box-sizing",
|
||||
"margin",
|
||||
"padding",
|
||||
"width",
|
||||
"height",
|
||||
"font-family",
|
||||
"font-size",
|
||||
"font-weight",
|
||||
"font-style",
|
||||
"line-height",
|
||||
"color",
|
||||
"background-color",
|
||||
"text-align",
|
||||
"text-decoration",
|
||||
"white-space",
|
||||
"word-break",
|
||||
"overflow-wrap",
|
||||
"border-radius",
|
||||
"vertical-align",
|
||||
];
|
||||
|
||||
async function embedResource(url: string): Promise<string> {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!response.ok) throw new Error("图片资源加载失败,请稍后重试");
|
||||
const blob = await response.blob();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error("图片资源读取失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/** Render the complete content layer in board coordinates, independent of pan/zoom. */
|
||||
export async function exportBoardImage(
|
||||
board: PlaitBoard,
|
||||
options: ImageExportOptions
|
||||
) {
|
||||
if (!board.children.length) throw new Error("当前工作表没有可导出的内容");
|
||||
await document.fonts.ready;
|
||||
const host = PlaitBoard.getElementHost(board);
|
||||
const bounds = host.getBBox();
|
||||
const padding = 32;
|
||||
const width = Math.ceil(bounds.width + padding * 2);
|
||||
const height = Math.ceil(bounds.height + padding * 2);
|
||||
const pixelWidth = width * options.scale;
|
||||
const pixelHeight = height * options.scale;
|
||||
if (!bounds.width || !bounds.height)
|
||||
throw new Error("画布尚未准备好,请稍后重试");
|
||||
if (
|
||||
pixelWidth > 16_384 ||
|
||||
pixelHeight > 16_384 ||
|
||||
pixelWidth * pixelHeight > 64_000_000
|
||||
) {
|
||||
throw new Error("图片尺寸过大,请降低清晰度或拆分工作表后导出");
|
||||
}
|
||||
const clone = host.cloneNode(true) as SVGGElement;
|
||||
clone.removeAttribute("transform");
|
||||
const sources = host.querySelectorAll("foreignObject *");
|
||||
const copies = clone.querySelectorAll("foreignObject *");
|
||||
sources.forEach((source, index) => {
|
||||
const copy = copies[index] as HTMLElement;
|
||||
const computed = getComputedStyle(source);
|
||||
for (const property of textStyles)
|
||||
copy.style.setProperty(property, computed.getPropertyValue(property));
|
||||
copy.removeAttribute("contenteditable");
|
||||
});
|
||||
const resources = new Map<string, Promise<string>>();
|
||||
await Promise.all(
|
||||
Array.from(clone.querySelectorAll("img, image")).map(async (node) => {
|
||||
const attribute = node.tagName.toLowerCase() === "img" ? "src" : "href";
|
||||
const url =
|
||||
node.getAttribute(attribute) ||
|
||||
node.getAttributeNS("http://www.w3.org/1999/xlink", "href");
|
||||
if (!url || url.startsWith("data:")) return;
|
||||
if (!resources.has(url)) resources.set(url, embedResource(url));
|
||||
node.setAttribute(attribute, await resources.get(url)!);
|
||||
node.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
|
||||
})
|
||||
);
|
||||
const namespace = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(namespace, "svg");
|
||||
svg.setAttribute("width", String(width));
|
||||
svg.setAttribute("height", String(height));
|
||||
svg.setAttribute(
|
||||
"viewBox",
|
||||
`${bounds.x - padding} ${bounds.y - padding} ${width} ${height}`
|
||||
);
|
||||
for (const defs of PlaitBoard.getHost(board).querySelectorAll(
|
||||
":scope > defs"
|
||||
))
|
||||
svg.append(defs.cloneNode(true));
|
||||
const style = document.createElementNS(namespace, "style");
|
||||
const weights = [400, 500, 700, 800];
|
||||
const names = ["Regular", "Medium", "Bold", "Extrabold"];
|
||||
style.textContent = (
|
||||
await Promise.all(
|
||||
names.map(async (name, index) => {
|
||||
const data = await embedResource(`/fonts/xmind/NeverMind-${name}.ttf`);
|
||||
return `@font-face{font-family:NeverMind;src:url('${data}') format('truetype');font-weight:${weights[index]};}`;
|
||||
})
|
||||
)
|
||||
).join("\n");
|
||||
svg.append(style, clone);
|
||||
const image = new Image();
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
// A self-contained data URI keeps foreignObject content safe to draw on canvas.
|
||||
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = pixelWidth;
|
||||
canvas.height = pixelHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("浏览器无法生成图片");
|
||||
if (!options.transparent) {
|
||||
context.fillStyle = options.backgroundColor;
|
||||
context.fillRect(0, 0, pixelWidth, pixelHeight);
|
||||
}
|
||||
context.drawImage(image, 0, 0, pixelWidth, pixelHeight);
|
||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(value) => (value ? resolve(value) : reject(new Error("图片生成失败"))),
|
||||
"image/png"
|
||||
)
|
||||
);
|
||||
return { blob, width: pixelWidth, height: pixelHeight };
|
||||
}
|
||||
288
lib/flow-board/project-calendar.ts
Normal file
@ -0,0 +1,288 @@
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { getTopicEntries } from "./topic-details";
|
||||
import { addDays, dateIndex, DAY, indexDate } from "./calendar-dates";
|
||||
|
||||
export const CALENDAR_PROVIDER = "org.xmind.ui.working-day-settings";
|
||||
export const CALENDAR_FREQUENCIES = [
|
||||
"NONE",
|
||||
"DAILY",
|
||||
"WEEKLY",
|
||||
"MONTHLY_BY_DATE",
|
||||
"MONTHLY_BY_WEEKDAY",
|
||||
"YEARLY",
|
||||
] as const;
|
||||
export type CalendarRule = {
|
||||
id: string;
|
||||
name: string;
|
||||
selectedDates: string[];
|
||||
type: "WORKING_DAY" | "NON_WORKING_DAY";
|
||||
frequency: (typeof CALENDAR_FREQUENCIES)[number];
|
||||
interval?: number;
|
||||
effectiveRange?: { start?: string; end?: string };
|
||||
};
|
||||
export type ProjectWorkingCalendar = {
|
||||
id: string;
|
||||
name: string;
|
||||
defaultWorkingDays: number[];
|
||||
rules: CalendarRule[];
|
||||
effectiveRange?: { start?: string; end?: string };
|
||||
};
|
||||
export type ProjectCalendarConfig = {
|
||||
settings: ProjectWorkingCalendar | null;
|
||||
skip: boolean;
|
||||
};
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
export function defaultWorkingCalendar(): ProjectWorkingCalendar {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
name: "基本日历",
|
||||
defaultWorkingDays: [1, 2, 3, 4, 5],
|
||||
rules: [],
|
||||
};
|
||||
}
|
||||
export function calendarRoot(elements: PlaitElement[]) {
|
||||
return (
|
||||
getTopicEntries(elements).find((entry) => entry.depth === 0)?.element ||
|
||||
null
|
||||
);
|
||||
}
|
||||
export function readWorkingCalendar(
|
||||
value: unknown
|
||||
): ProjectWorkingCalendar | null {
|
||||
const source = record(value);
|
||||
if (!Array.isArray(source.defaultWorkingDays) || !Array.isArray(source.rules))
|
||||
return null;
|
||||
return {
|
||||
...source,
|
||||
id: String(source.id || "project-calendar"),
|
||||
name: String(source.name || "项目日历"),
|
||||
defaultWorkingDays: source.defaultWorkingDays.filter(
|
||||
(day): day is number => Number.isInteger(day) && day >= 1 && day <= 7
|
||||
),
|
||||
rules: source.rules.map((rule, index) => {
|
||||
const item = record(rule);
|
||||
return {
|
||||
...item,
|
||||
id: String(item.id || `calendar-rule-${index}`),
|
||||
name: String(item.name || "例外"),
|
||||
selectedDates: Array.isArray(item.selectedDates)
|
||||
? item.selectedDates.filter(
|
||||
(date): date is string =>
|
||||
typeof date === "string" && Number.isFinite(dateIndex(date))
|
||||
)
|
||||
: [],
|
||||
type: item.type === "WORKING_DAY" ? "WORKING_DAY" : "NON_WORKING_DAY",
|
||||
frequency: CALENDAR_FREQUENCIES.includes(
|
||||
item.frequency as CalendarRule["frequency"]
|
||||
)
|
||||
? (item.frequency as CalendarRule["frequency"])
|
||||
: "NONE",
|
||||
} as CalendarRule;
|
||||
}),
|
||||
} as ProjectWorkingCalendar;
|
||||
}
|
||||
export function getProjectCalendar(
|
||||
elements: PlaitElement[]
|
||||
): ProjectCalendarConfig {
|
||||
const sheet = record(record(calendarRoot(elements)?.xmind).sheet);
|
||||
const extension = Array.isArray(sheet.extensions)
|
||||
? sheet.extensions
|
||||
.map(record)
|
||||
.find((item) => item.provider === CALENDAR_PROVIDER)
|
||||
: null;
|
||||
const settings = readWorkingCalendar(extension?.content);
|
||||
return {
|
||||
settings,
|
||||
skip: Boolean(settings && sheet.nonWorkingDaysSkipping === "skip"),
|
||||
};
|
||||
}
|
||||
export function patchProjectCalendar(
|
||||
root: PlaitElement,
|
||||
config: ProjectCalendarConfig
|
||||
) {
|
||||
const metadata = { ...record(root.xmind) },
|
||||
sheet = { ...record(metadata.sheet) };
|
||||
const extensions = Array.isArray(sheet.extensions)
|
||||
? sheet.extensions.map(record)
|
||||
: [];
|
||||
const existing = extensions.find(
|
||||
(item) => item.provider === CALENDAR_PROVIDER
|
||||
);
|
||||
sheet.extensions = [
|
||||
...extensions.filter((item) => item.provider !== CALENDAR_PROVIDER),
|
||||
...(config.settings
|
||||
? [{ ...existing, provider: CALENDAR_PROVIDER, content: config.settings }]
|
||||
: []),
|
||||
];
|
||||
sheet.nonWorkingDaysSkipping =
|
||||
config.skip && config.settings ? "skip" : "include";
|
||||
metadata.sheet = sheet;
|
||||
return metadata;
|
||||
}
|
||||
export const activeWorkingCalendar = (config: ProjectCalendarConfig) =>
|
||||
config.skip ? config.settings : null;
|
||||
function inRange(date: string, range?: CalendarRule["effectiveRange"]) {
|
||||
return (
|
||||
(!range?.start || date >= range.start) && (!range?.end || date <= range.end)
|
||||
);
|
||||
}
|
||||
function ruleMatches(date: string, rule: CalendarRule) {
|
||||
if (!inRange(date, rule.effectiveRange)) return false;
|
||||
const index = dateIndex(date),
|
||||
target = new Date(index * DAY),
|
||||
interval = rule.interval || 1;
|
||||
return rule.selectedDates.some((selected) => {
|
||||
const seedIndex = dateIndex(selected),
|
||||
seed = new Date(seedIndex * DAY);
|
||||
const difference = Math.abs(index - seedIndex),
|
||||
months = Math.abs(
|
||||
(target.getUTCFullYear() - seed.getUTCFullYear()) * 12 +
|
||||
target.getUTCMonth() -
|
||||
seed.getUTCMonth()
|
||||
);
|
||||
switch (rule.frequency) {
|
||||
case "NONE":
|
||||
return selected === date;
|
||||
case "DAILY":
|
||||
return difference % interval === 0;
|
||||
case "WEEKLY":
|
||||
return (
|
||||
target.getUTCDay() === seed.getUTCDay() &&
|
||||
Math.floor(difference / 7) % interval === 0
|
||||
);
|
||||
case "MONTHLY_BY_DATE":
|
||||
return (
|
||||
target.getUTCDate() === seed.getUTCDate() && months % interval === 0
|
||||
);
|
||||
case "MONTHLY_BY_WEEKDAY":
|
||||
return (
|
||||
target.getUTCDay() === seed.getUTCDay() &&
|
||||
Math.ceil(target.getUTCDate() / 7) ===
|
||||
Math.ceil(seed.getUTCDate() / 7) &&
|
||||
months % interval === 0
|
||||
);
|
||||
case "YEARLY":
|
||||
return (
|
||||
target.getUTCMonth() === seed.getUTCMonth() &&
|
||||
target.getUTCDate() === seed.getUTCDate() &&
|
||||
Math.abs(target.getUTCFullYear() - seed.getUTCFullYear()) %
|
||||
interval ===
|
||||
0
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
export function isWorkingDate(
|
||||
date: string,
|
||||
settings: ProjectWorkingCalendar | null
|
||||
) {
|
||||
if (!settings || !inRange(date, settings.effectiveRange)) return true;
|
||||
const weekday = new Date(dateIndex(date) * DAY).getUTCDay() || 7;
|
||||
let result = settings.defaultWorkingDays.includes(weekday);
|
||||
for (const rule of settings.rules)
|
||||
if (ruleMatches(date, rule)) result = rule.type === "WORKING_DAY";
|
||||
return result;
|
||||
}
|
||||
export function nextWorkingDate(
|
||||
date: string,
|
||||
settings: ProjectWorkingCalendar | null,
|
||||
direction: 1 | -1 = 1
|
||||
) {
|
||||
for (
|
||||
let day = dateIndex(date), attempt = 0;
|
||||
attempt <= 36500;
|
||||
day += direction, attempt++
|
||||
) {
|
||||
const candidate = indexDate(day);
|
||||
if (isWorkingDate(candidate, settings)) return candidate;
|
||||
}
|
||||
throw new Error("日历中连续 100 年没有可用工作日,请调整例外规则。");
|
||||
}
|
||||
export function addWorkingDays(
|
||||
date: string,
|
||||
days: number,
|
||||
settings: ProjectWorkingCalendar | null
|
||||
) {
|
||||
if (!settings) return addDays(date, days);
|
||||
const direction = days < 0 ? -1 : 1;
|
||||
let result = date;
|
||||
for (let left = Math.abs(days); left > 0; left--)
|
||||
result = nextWorkingDate(addDays(result, direction), settings, direction);
|
||||
return result;
|
||||
}
|
||||
export function taskDueDate(
|
||||
start: string,
|
||||
duration: number,
|
||||
settings: ProjectWorkingCalendar | null
|
||||
) {
|
||||
return addWorkingDays(
|
||||
nextWorkingDate(start, settings),
|
||||
duration - 1,
|
||||
settings
|
||||
);
|
||||
}
|
||||
export function taskStartDate(
|
||||
due: string,
|
||||
duration: number,
|
||||
settings: ProjectWorkingCalendar | null
|
||||
) {
|
||||
return addWorkingDays(
|
||||
nextWorkingDate(due, settings, -1),
|
||||
1 - duration,
|
||||
settings
|
||||
);
|
||||
}
|
||||
export function workingDaysBetween(
|
||||
start: string,
|
||||
due: string,
|
||||
settings: ProjectWorkingCalendar | null
|
||||
) {
|
||||
if (!settings) return dateIndex(due) - dateIndex(start) + 1;
|
||||
let duration = 0;
|
||||
for (let day = dateIndex(start), last = dateIndex(due); day <= last; day++)
|
||||
if (isWorkingDate(indexDate(day), settings)) duration++;
|
||||
return duration;
|
||||
}
|
||||
export function preserveNonWorkingDue(
|
||||
due: string,
|
||||
calculated: string,
|
||||
settings: ProjectWorkingCalendar | null
|
||||
) {
|
||||
return settings &&
|
||||
due &&
|
||||
!isWorkingDate(due, settings) &&
|
||||
nextWorkingDate(due, settings, -1) === calculated
|
||||
? due
|
||||
: calculated;
|
||||
}
|
||||
export function validateWorkingCalendar(
|
||||
calendar: ProjectWorkingCalendar
|
||||
): string | null {
|
||||
if (!calendar.defaultWorkingDays.length) return "请至少保留一个每周工作日。";
|
||||
const validateRange = (range?: CalendarRule["effectiveRange"]) =>
|
||||
!range ||
|
||||
((!range.start || Number.isFinite(dateIndex(range.start))) &&
|
||||
(!range.end || Number.isFinite(dateIndex(range.end))) &&
|
||||
(!range.start || !range.end || range.start <= range.end));
|
||||
if (!validateRange(calendar.effectiveRange)) return "日历生效范围无效。";
|
||||
for (const rule of calendar.rules) {
|
||||
if (
|
||||
!rule.selectedDates.length ||
|
||||
rule.selectedDates.some((date) => !Number.isFinite(dateIndex(date)))
|
||||
)
|
||||
return "请选择有效的例外日期。";
|
||||
if (!validateRange(rule.effectiveRange))
|
||||
return "例外结束日期不能早于开始日期。";
|
||||
if (
|
||||
rule.interval !== undefined &&
|
||||
(!Number.isInteger(rule.interval) ||
|
||||
rule.interval < 1 ||
|
||||
rule.interval > 100)
|
||||
)
|
||||
return "重复间隔必须是 1 至 100。";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { getNoteDocument, noteDocumentText, serializeNoteDocument, type NoteDocument } from "./topic-notes";
|
||||
|
||||
export type TopicAttachment = { name: string; url: string; mimeType: string };
|
||||
export type TopicDetails = {
|
||||
notes: string;
|
||||
notesDocument?: NoteDocument;
|
||||
labels: string[];
|
||||
href: string;
|
||||
topicLinkId: string;
|
||||
@ -64,19 +66,7 @@ export function getTopicDetails(element: PlaitElement | null): TopicDetails {
|
||||
: typeof data.labels === "string"
|
||||
? [data.labels]
|
||||
: [];
|
||||
const htmlParagraphs = record(
|
||||
record(record(data.notes).html).content
|
||||
).paragraphs;
|
||||
const htmlText = Array.isArray(htmlParagraphs)
|
||||
? htmlParagraphs
|
||||
.map((paragraph) => {
|
||||
const spans = record(paragraph).spans;
|
||||
return Array.isArray(spans)
|
||||
? spans.map((span) => String(record(span).text || "")).join("")
|
||||
: "";
|
||||
})
|
||||
.join("\n")
|
||||
: "";
|
||||
const htmlText = typeof notes === "string" ? "" : noteDocumentText(getNoteDocument(element));
|
||||
const attachment = record(data.attachment);
|
||||
const safeAttachment =
|
||||
typeof attachment.url === "string" &&
|
||||
@ -108,7 +98,8 @@ export function patchTopicDetails(
|
||||
) {
|
||||
const data = { ...record(element.xmind) };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (key === "notes")
|
||||
if (key === "notesDocument") data.notes = serializeNoteDocument(value as NoteDocument);
|
||||
else if (key === "notes")
|
||||
data.notes = value ? { plain: { content: value } } : undefined;
|
||||
else data[key] = value;
|
||||
}
|
||||
|
||||
290
lib/flow-board/topic-notes.ts
Normal file
@ -0,0 +1,290 @@
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
|
||||
export type NoteText = {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikethrough?: boolean;
|
||||
href?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
export type NoteParagraph = {
|
||||
type: "paragraph";
|
||||
list?: "bullet" | "number";
|
||||
indent?: number;
|
||||
properties?: Record<string, unknown>;
|
||||
children: NoteText[];
|
||||
};
|
||||
export type NoteDocument = NoteParagraph[];
|
||||
const record = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === "object" && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: {};
|
||||
export const safeNoteLink = (value: string) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol)
|
||||
? url.href
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
function parseNoteHTML(html: string): NoteDocument | null {
|
||||
if (typeof DOMParser === "undefined") return null;
|
||||
const dom = new DOMParser().parseFromString(html, "text/html");
|
||||
const ignored = new Set([
|
||||
"SCRIPT",
|
||||
"STYLE",
|
||||
"IFRAME",
|
||||
"OBJECT",
|
||||
"SVG",
|
||||
"FORM",
|
||||
"INPUT",
|
||||
]);
|
||||
const inline = (
|
||||
node: Node,
|
||||
inherited: Partial<NoteText> = {}
|
||||
): NoteText[] => {
|
||||
if (node.nodeType === 3)
|
||||
return [{ ...inherited, text: node.textContent || "" }];
|
||||
if (!(node instanceof Element) || ignored.has(node.tagName)) return [];
|
||||
if (node.tagName === "BR") return [{ ...inherited, text: "\n" }];
|
||||
const style = (node as HTMLElement).style;
|
||||
const marks: Partial<NoteText> = { ...inherited };
|
||||
if (
|
||||
["B", "STRONG"].includes(node.tagName) ||
|
||||
style.fontWeight === "bold" ||
|
||||
Number(style.fontWeight) >= 600
|
||||
)
|
||||
marks.bold = true;
|
||||
if (["I", "EM"].includes(node.tagName) || style.fontStyle === "italic")
|
||||
marks.italic = true;
|
||||
if (node.tagName === "U" || style.textDecoration.includes("underline"))
|
||||
marks.underline = true;
|
||||
if (
|
||||
["S", "DEL", "STRIKE"].includes(node.tagName) ||
|
||||
style.textDecoration.includes("line-through")
|
||||
)
|
||||
marks.strikethrough = true;
|
||||
const href = safeNoteLink(node.getAttribute("href") || "");
|
||||
if (href) marks.href = href;
|
||||
return Array.from(node.childNodes).flatMap((child) => inline(child, marks));
|
||||
};
|
||||
const result: NoteDocument = [];
|
||||
const visit = (node: Node, list?: "bullet" | "number") => {
|
||||
if (node.nodeType === 3) {
|
||||
if (node.textContent?.trim())
|
||||
result.push({
|
||||
type: "paragraph",
|
||||
children: [{ text: node.textContent }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!(node instanceof Element) || ignored.has(node.tagName)) return;
|
||||
if (["OL", "UL"].includes(node.tagName)) {
|
||||
Array.from(node.children).forEach((child) =>
|
||||
visit(child, node.tagName === "OL" ? "number" : "bullet")
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
[
|
||||
"P",
|
||||
"LI",
|
||||
"DIV",
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"H4",
|
||||
"H5",
|
||||
"H6",
|
||||
"BLOCKQUOTE",
|
||||
"PRE",
|
||||
].includes(node.tagName)
|
||||
) {
|
||||
const blocks = Array.from(node.children).some((child) =>
|
||||
["P", "LI", "DIV", "OL", "UL"].includes(child.tagName)
|
||||
);
|
||||
if (blocks && node.tagName !== "LI") {
|
||||
Array.from(node.childNodes).forEach((child) => visit(child, list));
|
||||
return;
|
||||
}
|
||||
const leaves = Array.from(node.childNodes)
|
||||
.filter(
|
||||
(child) =>
|
||||
!(child instanceof Element && ["OL", "UL"].includes(child.tagName))
|
||||
)
|
||||
.flatMap((child) => inline(child));
|
||||
result.push({
|
||||
type: "paragraph",
|
||||
...(list ? { list } : {}),
|
||||
indent: Number.parseFloat((node as HTMLElement).style.marginLeft) || 0,
|
||||
children: leaves.length ? leaves : [{ text: "" }],
|
||||
});
|
||||
Array.from(node.children)
|
||||
.filter((child) => ["OL", "UL"].includes(child.tagName))
|
||||
.forEach((child) => visit(child));
|
||||
return;
|
||||
}
|
||||
const leaves = inline(node);
|
||||
if (leaves.length) result.push({ type: "paragraph", children: leaves });
|
||||
};
|
||||
Array.from(dom.body.childNodes).forEach((node) => visit(node));
|
||||
return result.length ? result : null;
|
||||
}
|
||||
const escapeHTML = (value: string) =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
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",
|
||||
},
|
||||
},
|
||||
})),
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
58
lib/flow-board/topic-search.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { getTopicEntries } from "./topic-details";
|
||||
|
||||
export type TopicSearchResult = { id: string; title: string; sheetId: string; sheetTitle: string; occurrences: number };
|
||||
export type SearchSheet = { id: string; title: string; children: PlaitElement[] };
|
||||
|
||||
export function searchTopics(sheets: SearchSheet[], query: string, caseSensitive = false): TopicSearchResult[] {
|
||||
if (!query) return [];
|
||||
const needle = caseSensitive ? query : query.toLocaleLowerCase();
|
||||
return sheets.flatMap(sheet => getTopicEntries(sheet.children).flatMap(({ element, title }) => {
|
||||
const text = caseSensitive ? title : title.toLocaleLowerCase();
|
||||
let count = 0;
|
||||
let offset = 0;
|
||||
for (let at = text.indexOf(needle, offset); at !== -1; at = text.indexOf(needle, offset)) {
|
||||
count++;
|
||||
offset = at + needle.length;
|
||||
}
|
||||
return count ? [{ id: element.id, title, sheetId: sheet.id, sheetTitle: sheet.title, occurrences: count }] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
/** A match may cross styled leaves. Replacement inherits the first matched leaf's marks. */
|
||||
export function replaceTopicText(element: PlaitElement, query: string, replacement: string, caseSensitive = false) {
|
||||
const topic = element.data?.topic;
|
||||
if (!query || !topic) return topic;
|
||||
const leaves: { node: Record<string, unknown>; start: number; end: number }[] = [];
|
||||
let source = "";
|
||||
const collect = (node: Record<string, unknown>) => {
|
||||
if (typeof node.text === "string") {
|
||||
const start = source.length;
|
||||
source += node.text;
|
||||
leaves.push({ node, start, end: source.length });
|
||||
} else if (Array.isArray(node.children)) {
|
||||
node.children.forEach(child => collect(child as Record<string, unknown>));
|
||||
}
|
||||
};
|
||||
collect(topic);
|
||||
const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), caseSensitive ? "g" : "gi");
|
||||
const matches = Array.from(source.matchAll(pattern), match => ({ start: match.index!, end: match.index! + match[0].length }));
|
||||
const replacements = new Map<object, string>();
|
||||
for (const { node, start, end } of leaves) {
|
||||
let cursor = start;
|
||||
let text = "";
|
||||
for (const match of matches) {
|
||||
if (match.end <= start || match.start >= end) continue;
|
||||
text += source.slice(cursor, Math.max(cursor, match.start));
|
||||
if (match.start >= start) text += replacement;
|
||||
cursor = Math.min(end, match.end);
|
||||
}
|
||||
replacements.set(node, text + source.slice(cursor, end));
|
||||
}
|
||||
const visit = (node: Record<string, unknown>): Record<string, unknown> => {
|
||||
if (typeof node.text === "string") return { ...node, text: replacements.get(node) ?? node.text };
|
||||
if (Array.isArray(node.children)) return { ...node, children: node.children.map(child => visit(child as Record<string, unknown>)) };
|
||||
return node;
|
||||
};
|
||||
return visit(topic);
|
||||
}
|
||||
268
lib/flow-board/topic-tasks.ts
Normal file
@ -0,0 +1,268 @@
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { getTopicEntries } from "./topic-details";
|
||||
export const TASK_PROVIDER = "org.xmind.ui.task";
|
||||
import {
|
||||
DAY,
|
||||
dateIndex,
|
||||
indexDate,
|
||||
addDays,
|
||||
dateTimestamp,
|
||||
timestampDate,
|
||||
todayDate,
|
||||
} from "./calendar-dates";
|
||||
export {
|
||||
DAY,
|
||||
dateIndex,
|
||||
indexDate,
|
||||
addDays,
|
||||
dateTimestamp,
|
||||
timestampDate,
|
||||
todayDate,
|
||||
} from "./calendar-dates";
|
||||
import {
|
||||
activeWorkingCalendar,
|
||||
getProjectCalendar,
|
||||
taskDueDate,
|
||||
taskStartDate,
|
||||
preserveNonWorkingDue,
|
||||
workingDaysBetween,
|
||||
type ProjectCalendarConfig,
|
||||
} from "./project-calendar";
|
||||
export type TaskDependency = {
|
||||
id: string;
|
||||
type: "FS" | "SS" | "FF" | "SF";
|
||||
lag: number;
|
||||
};
|
||||
export type TopicTask = {
|
||||
start: string;
|
||||
due: string;
|
||||
duration: number;
|
||||
progress: number;
|
||||
priority: number | null;
|
||||
assignees: string[];
|
||||
dependencies: TaskDependency[];
|
||||
creator?: string;
|
||||
};
|
||||
const record = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === "object" && !Array.isArray(v)
|
||||
? (v as Record<string, unknown>)
|
||||
: {};
|
||||
export function getTaskContent(element: PlaitElement) {
|
||||
const extensions = record(element.xmind).extensions;
|
||||
if (!Array.isArray(extensions)) return null;
|
||||
const ext = extensions.map(record).find((e) => e.provider === TASK_PROVIDER);
|
||||
const content = record(ext?.content);
|
||||
return typeof content.progress === "number" ? content : null;
|
||||
}
|
||||
export function getTopicTask(element: PlaitElement): TopicTask | null {
|
||||
const c = getTaskContent(element);
|
||||
if (!c) return null;
|
||||
return {
|
||||
start: timestampDate(c.start),
|
||||
due: timestampDate(c.due),
|
||||
duration: Math.max(1, Math.round(Number(c.duration || DAY) / DAY)),
|
||||
progress: Math.max(0, Math.min(100, Math.round(Number(c.progress) * 100))),
|
||||
priority: typeof c.priority === "number" ? c.priority : null,
|
||||
assignees: Array.isArray(c.assignees)
|
||||
? c.assignees.filter((s): s is string => typeof s === "string")
|
||||
: [],
|
||||
dependencies: Array.isArray(c.dependencies)
|
||||
? c.dependencies.flatMap((d) => {
|
||||
const item = record(d);
|
||||
return typeof item.id === "string" &&
|
||||
["FS", "SS", "FF", "SF"].includes(String(item.type))
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
type: item.type as TaskDependency["type"],
|
||||
lag: Number(item.lag) || 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
: [],
|
||||
...(typeof c.creator === "string" ? { creator: c.creator } : {}),
|
||||
};
|
||||
}
|
||||
export function newTopicTask(): TopicTask {
|
||||
return {
|
||||
start: todayDate(),
|
||||
due: todayDate(),
|
||||
duration: 1,
|
||||
progress: 0,
|
||||
priority: null,
|
||||
assignees: [],
|
||||
dependencies: [],
|
||||
};
|
||||
}
|
||||
export function patchTopicTask(element: PlaitElement, task: TopicTask | null) {
|
||||
const metadata = { ...record(element.xmind) };
|
||||
const extensions = Array.isArray(metadata.extensions)
|
||||
? metadata.extensions.map(record)
|
||||
: [];
|
||||
const existing = extensions.find((e) => e.provider === TASK_PROVIDER);
|
||||
const others = extensions.filter((e) => e.provider !== TASK_PROVIDER);
|
||||
if (task)
|
||||
others.push({
|
||||
...existing,
|
||||
provider: TASK_PROVIDER,
|
||||
content: {
|
||||
...record(existing?.content),
|
||||
...task,
|
||||
start: dateTimestamp(task.start),
|
||||
due: dateTimestamp(task.due, true),
|
||||
duration: task.duration * DAY,
|
||||
progress: task.progress / 100,
|
||||
},
|
||||
});
|
||||
metadata.extensions = others;
|
||||
return metadata;
|
||||
}
|
||||
export function validateTopicTask(
|
||||
id: string,
|
||||
task: TopicTask,
|
||||
elements: PlaitElement[]
|
||||
): string | null {
|
||||
if (
|
||||
(task.start && !Number.isFinite(dateIndex(task.start))) ||
|
||||
(task.due && !Number.isFinite(dateIndex(task.due)))
|
||||
)
|
||||
return "请输入有效日期。";
|
||||
if (task.start && task.due && task.due < task.start)
|
||||
return "截止日期不能早于开始日期。";
|
||||
if (
|
||||
!Number.isInteger(task.duration) ||
|
||||
task.duration < 1 ||
|
||||
task.duration > 36500
|
||||
)
|
||||
return "工期必须是 1 至 36500 天。";
|
||||
if (
|
||||
!Number.isFinite(task.progress) ||
|
||||
task.progress < 0 ||
|
||||
task.progress > 100
|
||||
)
|
||||
return "进度必须在 0% 至 100% 之间。";
|
||||
const entries = getTopicEntries(elements),
|
||||
byId = new Map(entries.map((e) => [e.element.id, e]));
|
||||
const me = byId.get(id);
|
||||
for (const dep of task.dependencies) {
|
||||
const target = byId.get(dep.id);
|
||||
if (!target || !getTopicTask(target.element)) return "前置任务已不存在。";
|
||||
if (!Number.isFinite(dep.lag)) return "间隔天数无效。";
|
||||
if (
|
||||
dep.id === id ||
|
||||
(me &&
|
||||
(target.path.every((n, i) => me.path[i] === n) ||
|
||||
me.path.every((n, i) => target.path[i] === n)))
|
||||
)
|
||||
return "任务不能依赖自身、父任务或子任务。";
|
||||
const visit = (current: string, seen = new Set<string>()): boolean => {
|
||||
if (current === id) return true;
|
||||
if (seen.has(current)) return false;
|
||||
seen.add(current);
|
||||
const item = byId.get(current);
|
||||
return item
|
||||
? (getTopicTask(item.element)?.dependencies || []).some((d) =>
|
||||
visit(d.id, seen)
|
||||
)
|
||||
: false;
|
||||
};
|
||||
if (visit(dep.id)) return "前置任务形成循环,请调整依赖关系。";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function getTaskRows(elements: PlaitElement[]) {
|
||||
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
|
||||
return getTopicEntries(elements).flatMap((entry) => {
|
||||
const task = getTopicTask(entry.element);
|
||||
if (!task) return [];
|
||||
const descendants = getTopicEntries(entry.element.children || [])
|
||||
.map((e) => getTopicTask(e.element))
|
||||
.filter((t): t is TopicTask => Boolean(t?.start && t.due));
|
||||
if (!descendants.length) return [{ ...entry, task, summary: false }];
|
||||
const start = descendants.map((t) => t.start).sort()[0],
|
||||
due = descendants
|
||||
.map((t) => t.due)
|
||||
.sort()
|
||||
.at(-1)!;
|
||||
const total = descendants.reduce((s, t) => s + t.duration, 0);
|
||||
return [
|
||||
{
|
||||
...entry,
|
||||
summary: true,
|
||||
task: {
|
||||
...task,
|
||||
start,
|
||||
due,
|
||||
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
|
||||
progress: Math.round(
|
||||
descendants.reduce((s, t) => s + t.progress * t.duration, 0) / total
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
/** Propagate finish/start constraints in topological order; the caller commits one undo batch. */
|
||||
export function scheduleTaskChanges(
|
||||
elements: PlaitElement[],
|
||||
id: string,
|
||||
task: TopicTask,
|
||||
config: ProjectCalendarConfig = getProjectCalendar(elements)
|
||||
): Map<string, TopicTask> {
|
||||
const error = validateTopicTask(id, task, elements);
|
||||
if (error) throw new Error(error);
|
||||
const calendar = activeWorkingCalendar(config);
|
||||
const tasks = new Map(
|
||||
getTopicEntries(elements).flatMap((e) => {
|
||||
const t = getTopicTask(e.element);
|
||||
return t ? [[e.element.id, t] as const] : [];
|
||||
})
|
||||
);
|
||||
tasks.set(id, task);
|
||||
const changed = new Map<string, TopicTask>([[id, task]]),
|
||||
visiting = new Set<string>(),
|
||||
done = new Set<string>();
|
||||
const visit = (key: string) => {
|
||||
if (done.has(key)) return;
|
||||
if (visiting.has(key)) return;
|
||||
visiting.add(key);
|
||||
const t = tasks.get(key)!;
|
||||
for (const d of t.dependencies) if (tasks.has(d.id)) visit(d.id);
|
||||
if (t.start && t.due) {
|
||||
let earliest = dateIndex(t.start);
|
||||
for (const d of t.dependencies) {
|
||||
const before = tasks.get(d.id);
|
||||
if (!before?.start || !before.due) continue;
|
||||
const lag = d.lag / DAY;
|
||||
const anchor = d.type[0] === "F" ? before.due : before.start;
|
||||
const endpoint = addDays(
|
||||
anchor,
|
||||
d.type[0] === "F" ? Math.ceil(lag) : Math.floor(lag)
|
||||
);
|
||||
const constraint =
|
||||
d.type === "FS"
|
||||
? addDays(endpoint, 1)
|
||||
: d.type === "SS"
|
||||
? endpoint
|
||||
: taskStartDate(endpoint, t.duration, calendar);
|
||||
earliest = Math.max(earliest, dateIndex(constraint));
|
||||
}
|
||||
const start = indexDate(earliest);
|
||||
const due = preserveNonWorkingDue(
|
||||
t.due,
|
||||
taskDueDate(start, t.duration, calendar),
|
||||
calendar
|
||||
);
|
||||
if (start !== t.start || (calendar && due !== t.due)) {
|
||||
const next = { ...t, start, due };
|
||||
tasks.set(key, next);
|
||||
changed.set(key, next);
|
||||
}
|
||||
}
|
||||
visiting.delete(key);
|
||||
done.add(key);
|
||||
};
|
||||
tasks.forEach((_, key) => visit(key));
|
||||
return changed;
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { getXmindMarker } from "./xmind-markers";
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import {
|
||||
getTopicDetails,
|
||||
@ -33,15 +34,6 @@ const layouts: Record<string, string> = {
|
||||
"right-bottom-indented": "org.xmind.ui.tree.right",
|
||||
"left-bottom-indented": "org.xmind.ui.tree.left",
|
||||
};
|
||||
const markerIds: Record<string, string> = {
|
||||
"✅": "task-done",
|
||||
"▶️": "task-start",
|
||||
"🚩": "flag-red",
|
||||
"⭐": "star-red",
|
||||
...Object.fromEntries(
|
||||
[1, 2, 3, 4, 5, 6, 7].map((n) => [`${n}️⃣`, `priority-${n}`])
|
||||
),
|
||||
};
|
||||
const summary = (element: PlaitElement) =>
|
||||
Number.isInteger(element.start) && Number.isInteger(element.end);
|
||||
function textOf(value: unknown): string {
|
||||
@ -151,6 +143,17 @@ export async function exportXmind(
|
||||
],
|
||||
},
|
||||
];
|
||||
// Merge native task extensions with structure extensions; remap every dependency to exported topic IDs.
|
||||
const nativeExtensions = array(metadata.extensions).filter(ext => !["org.xmind.ui.spreadsheet", ...Object.values(layouts)].includes(String(ext.provider))).map(ext => {
|
||||
if (ext.provider !== "org.xmind.ui.task") return ext;
|
||||
const content = record(ext.content);
|
||||
return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: array(content.dependencies).flatMap(dep => {
|
||||
const target = ids.get(String(dep.id));
|
||||
if (!target) {warnings.add("指向缺失主题的任务依赖未导出"); return [];}
|
||||
return [{...dep, id: target}];
|
||||
})} : {})}};
|
||||
});
|
||||
if (nativeExtensions.length) topic.extensions = [...array(topic.extensions), ...nativeExtensions];
|
||||
const leaves = array(record(data.topic).children);
|
||||
const leaf = leaves[0] || {};
|
||||
if (leaves.length > 1) warnings.add("主题内混合文字格式已按首段样式导出");
|
||||
@ -160,6 +163,7 @@ export async function exportXmind(
|
||||
properties[key] = String(value);
|
||||
};
|
||||
copy("svg:fill", element.fill);
|
||||
if (element.fill === "transparent") properties["fill-pattern"] = "none";
|
||||
copy("border-line-color", element.strokeColor);
|
||||
copy("border-line-width", element.strokeWidth);
|
||||
copy("line-color", element.branchColor);
|
||||
@ -191,7 +195,7 @@ export async function exportXmind(
|
||||
if (details.labels.length) topic.labels = details.labels;
|
||||
const markerSet = new Set<string>();
|
||||
for (const emoji of array(data.emojis)) {
|
||||
const marker = markerIds[String(emoji.name)];
|
||||
const marker = getXmindMarker(String(emoji.name))?.id;
|
||||
if (marker) markerSet.add(marker);
|
||||
else warnings.add("部分自定义图标尚不能写入 XMind 标记");
|
||||
}
|
||||
@ -199,7 +203,7 @@ export async function exportXmind(
|
||||
for (const marker of array(metadata.markers))
|
||||
if (
|
||||
typeof marker.markerId === "string" &&
|
||||
!Object.values(markerIds).includes(marker.markerId)
|
||||
!getXmindMarker(marker.markerId)
|
||||
)
|
||||
markerSet.add(marker.markerId);
|
||||
if (markerSet.size)
|
||||
@ -331,6 +335,7 @@ export async function exportXmind(
|
||||
class: "sheet",
|
||||
title: source.title,
|
||||
rootTopic: root,
|
||||
...record(record(topics.find(entry => !parents.has(entry.element.id))?.element.xmind).sheet),
|
||||
relationships,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type JSZip from "jszip";
|
||||
import { getXmindMarker, xmindMarkerName } from "./xmind-markers";
|
||||
import { getTopicEntries } from "./topic-details";
|
||||
import {
|
||||
SPECIAL_MIND_LAYOUTS,
|
||||
@ -233,11 +234,13 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
const floating: PlaitElement[] = [];
|
||||
const annotations: PlaitElement[] = [];
|
||||
const theme = record(sheet.theme);
|
||||
const palette = text(record(record(theme.map).properties)["multi-line-colors"] || record(record(theme.map).properties)["color-list"]).split(/\s+/).map(color).filter((value): value is string => Boolean(value));
|
||||
const build = (
|
||||
topic: Data,
|
||||
depth: number,
|
||||
root = false,
|
||||
floatingRoot = false
|
||||
floatingRoot = false,
|
||||
inheritedBranchColor = ""
|
||||
): MindElement => {
|
||||
if (++nodeCount > 10_000 || depth > 128)
|
||||
throw new MindMapImportError(
|
||||
@ -280,16 +283,20 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
...record(record(topic.style).properties),
|
||||
};
|
||||
const fill = color(properties["svg:fill"]);
|
||||
if (fill) element.fill = fill;
|
||||
if (properties["fill-pattern"] === "none") element.fill = "transparent";
|
||||
else if (fill) element.fill = fill;
|
||||
else if (properties["svg:fill"] === "inherited" && inheritedBranchColor) element.fill = inheritedBranchColor;
|
||||
const stroke = color(properties["border-line-color"]);
|
||||
if (stroke) element.strokeColor = stroke;
|
||||
const branch = color(properties["line-color"]);
|
||||
if (branch) element.branchColor = branch;
|
||||
else if (inheritedBranchColor) element.branchColor = inheritedBranchColor;
|
||||
const borderWidth = Number.parseFloat(
|
||||
String(properties["border-line-width"] || "")
|
||||
);
|
||||
if (Number.isFinite(borderWidth))
|
||||
element.strokeWidth = Math.max(0, Math.min(16, borderWidth));
|
||||
if (borderWidth === 0 || properties["border-line-pattern"] === "none") element.strokeColor = "transparent";
|
||||
const branchWidth = Number.parseFloat(
|
||||
String(properties["line-width"] || "")
|
||||
);
|
||||
@ -311,6 +318,7 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
);
|
||||
const leaf = {
|
||||
text: text(topic.title, "未命名主题"),
|
||||
...(properties["fo:color"] === "inherited" ? { color: "#000000" } : {}),
|
||||
...(color(properties["fo:color"])
|
||||
? { color: color(properties["fo:color"]) }
|
||||
: {}),
|
||||
@ -331,31 +339,20 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
};
|
||||
element.data.topic.children = [leaf];
|
||||
const metadata: Data = {};
|
||||
for (const key of ["notes", "href", "labels", "markers", "image"])
|
||||
for (const key of ["notes", "href", "labels", "markers", "image", "extensions"])
|
||||
if (topic[key] !== undefined) metadata[key] = topic[key];
|
||||
if (Object.keys(metadata).length) element.xmind = metadata;
|
||||
|
||||
const emojis: string[] = [];
|
||||
for (const marker of list(topic.markers)) {
|
||||
const id = text(marker.markerId);
|
||||
const priority = id.match(/^priority-([1-7])$/);
|
||||
const mapped = priority
|
||||
? `${priority[1]}️⃣`
|
||||
: (
|
||||
{
|
||||
"task-done": "✅",
|
||||
"task-start": "▶️",
|
||||
"flag-red": "🚩",
|
||||
"star-red": "⭐",
|
||||
} as Record<string, string>
|
||||
)[id];
|
||||
if (mapped) emojis.push(mapped);
|
||||
if (getXmindMarker(id)) emojis.push(xmindMarkerName(id));
|
||||
else warnings.add("部分图标已保留为数据,暂不显示");
|
||||
}
|
||||
if (emojis.length)
|
||||
element.data.emojis = emojis.map((name) => ({ name }));
|
||||
element.children = list(groups.attached).map((child) =>
|
||||
build(child, depth + 1)
|
||||
element.children = list(groups.attached).map((child,index) =>
|
||||
build(child, depth + 1, false, false, depth === 0 && palette.length ? palette[index % palette.length] : inheritedBranchColor)
|
||||
);
|
||||
const attachedCount = element.children.length;
|
||||
if (element.layout === MindLayoutType.standard) {
|
||||
@ -446,8 +443,22 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
return element;
|
||||
};
|
||||
const root = build(record(sheet.rootTopic), 0, true);
|
||||
const sheetMetadata: Data = {};
|
||||
for (const key of ["extensions", "nonWorkingDaysSkipping", "topicTasksDisplay", "topicTasksCreatorVisibility"]) if (sheet[key] !== undefined) sheetMetadata[key] = sheet[key];
|
||||
if (Object.keys(sheetMetadata).length) root.xmind = {...record(root.xmind), sheet: sheetMetadata};
|
||||
for (const { element } of getTopicEntries([root, ...floating])) {
|
||||
const metadata = record(element.xmind);
|
||||
if (Array.isArray(metadata.extensions)) metadata.extensions = metadata.extensions.map(value => {
|
||||
const ext = record(value);
|
||||
if (ext.provider !== "org.xmind.ui.task") return ext;
|
||||
const content = record(ext.content);
|
||||
return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: content.dependencies.map(value => {
|
||||
const dep = record(value), mapped = topicIds.get(text(dep.id));
|
||||
if (!mapped) warnings.add("部分任务依赖指向缺失主题,已保留引用");
|
||||
return {...dep, id: mapped || dep.id};
|
||||
})} : {})}};
|
||||
});
|
||||
if (Object.keys(metadata).length) element.xmind = metadata;
|
||||
if (
|
||||
typeof metadata.href === "string" &&
|
||||
metadata.href.startsWith("xmind:#")
|
||||
|
||||
1428
lib/flow-board/xmind-marker-catalog.json
Normal file
42
lib/flow-board/xmind-markers.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import catalog from "./xmind-marker-catalog.json" with { type: "json" };
|
||||
|
||||
export type XmindMarker = { id: string; name: string; asset: string; hidden: boolean };
|
||||
export type XmindMarkerGroup = { id: string; label: string; hidden: boolean; markers: XmindMarker[] };
|
||||
export const XMIND_MARKER_GROUPS: XmindMarkerGroup[] = catalog;
|
||||
// The client resolves shared IDs to their first catalog entry.
|
||||
const markers = new Map<string, XmindMarker & { groupId: string }>();
|
||||
for (const group of catalog) {
|
||||
for (const marker of group.markers) {
|
||||
if (!markers.has(marker.id)) markers.set(marker.id, { ...marker, groupId: group.id });
|
||||
}
|
||||
}
|
||||
const aliases: Record<string, string> = {
|
||||
"🔴": "tag-red", "🟠": "tag-orange", "🟡": "tag-yellow", "🟢": "tag-green", "🔵": "tag-blue", "🟣": "tag-dark-purple", "⚪": "tag-grey",
|
||||
"✅": "task-done", "▶️": "task-start", "◔": "task-quarter", "◑": "task-half", "◕": "task-3quar", "🚧": "task-5oct",
|
||||
"🚩": "flag-red", "⭐": "star-red", "🌟": "star-yellow", "👤": "people-blue",
|
||||
"❗": "c_simbol-exclam", "❓": "c_simbol-question", "💡": "symbol-idea", "❤️": "c_symbol_heart", "⚡": "symbol-lightning", "📌": "symbol-pin",
|
||||
...Object.fromEntries(Array.from({ length: 9 }, (_, index) => [`${index + 1}️⃣`, `priority-${index + 1}`])),
|
||||
};
|
||||
|
||||
export function getXmindMarker(name: string) {
|
||||
return markers.get(name.startsWith("xmind:") ? name.slice(6) : aliases[name] || name);
|
||||
}
|
||||
|
||||
export function xmindMarkerName(id: string) {
|
||||
return `xmind:${id}`;
|
||||
}
|
||||
|
||||
export function getXmindMarkerIds(emojis: { name: string }[] = []) {
|
||||
return [...new Set(emojis.flatMap(emoji => {
|
||||
const marker = getXmindMarker(emoji.name);
|
||||
return marker ? [marker.id] : [];
|
||||
}))];
|
||||
}
|
||||
|
||||
/** Native markers are mutually exclusive within each group. Unrelated emoji stay intact. */
|
||||
export function toggleXmindMarker(emojis: { name: string }[], markerId: string, remove: boolean) {
|
||||
const marker = getXmindMarker(markerId);
|
||||
if (!marker) return emojis;
|
||||
const remaining = emojis.filter(emoji => getXmindMarker(emoji.name)?.groupId !== marker.groupId);
|
||||
return remove ? remaining : [...remaining, { name: xmindMarkerName(markerId) }];
|
||||
}
|
||||
23
lib/flow-board/xmind-typography.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { measureElement, updateElementSizeCache } from "@plait/common";
|
||||
import type { PlaitBoard } from "@plait/core";
|
||||
import { getDefaultFontSizeForMindElement, NodeSpace, type MindElement, type PlaitMindBoard } from "@plait/mind";
|
||||
|
||||
const measured = new WeakMap<object, number>();
|
||||
|
||||
/** Plait measures with a system font by default. Seed its cache with the canvas font. */
|
||||
export function measureXmindTopics(board: PlaitBoard, element: MindElement) {
|
||||
const topic = element.data?.topic;
|
||||
const maxWidth = NodeSpace.getTopicMaxDynamicWidth(board as PlaitMindBoard, element);
|
||||
if (topic && measured.get(topic) !== maxWidth) {
|
||||
const size = measureElement(board, topic, {
|
||||
fontSize: getDefaultFontSizeForMindElement(element),
|
||||
fontFamily: 'NeverMind, Arial, "PingFang SC", sans-serif',
|
||||
}, maxWidth);
|
||||
updateElementSizeCache(board, topic, {
|
||||
width: Math.ceil(size.width) + 2,
|
||||
height: Math.ceil(size.height),
|
||||
});
|
||||
measured.set(topic, maxWidth);
|
||||
}
|
||||
for (const child of element.children || []) measureXmindTopics(board, child);
|
||||
}
|
||||
@ -3,6 +3,7 @@ import "server-only";
|
||||
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
|
||||
import {
|
||||
getImageArchiveTaskStats,
|
||||
recoverStaleImageArchiveTasks,
|
||||
getImageArchiveTasksToReplace,
|
||||
getImageArchiveTasksToStore,
|
||||
markImageArchiveTaskArchiving,
|
||||
@ -26,14 +27,16 @@ export async function runImageArchiveJob() {
|
||||
const batchSize = getBatchSize();
|
||||
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
||||
|
||||
await recoverStaleImageArchiveTasks();
|
||||
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
||||
let storeSucceeded = 0;
|
||||
let storeFailed = 0;
|
||||
|
||||
for (const task of storeTasks) {
|
||||
const storeOne = async (task: (typeof storeTasks)[number]) => {
|
||||
try {
|
||||
await markImageArchiveTaskArchiving(task.id);
|
||||
const archived = await archiveTemporaryImage(task.sourceUrl);
|
||||
if (!(await markImageArchiveTaskArchiving(task.id))) return;
|
||||
const metadata = task.metadata as { inlineSource?: string } | null;
|
||||
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl);
|
||||
await markImageArchiveTaskStored({
|
||||
id: task.id,
|
||||
targetUrl: archived.url,
|
||||
@ -54,12 +57,13 @@ export async function runImageArchiveJob() {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const remainingSlots = Math.max(1, batchSize - storeTasks.length);
|
||||
const replaceTasks = await getImageArchiveTasksToReplace({
|
||||
limit: remainingSlots,
|
||||
});
|
||||
// Bucket work has a small worker pool independent of unrestricted model generation.
|
||||
for (let index = 0; index < storeTasks.length; index += 4) {
|
||||
await Promise.all(storeTasks.slice(index, index + 4).map(storeOne));
|
||||
}
|
||||
const replaceTasks = await getImageArchiveTasksToReplace({ limit: 50 });
|
||||
let replaceSucceeded = 0;
|
||||
let replaceFailed = 0;
|
||||
|
||||
@ -74,7 +78,7 @@ export async function runImageArchiveJob() {
|
||||
}
|
||||
|
||||
try {
|
||||
await markImageArchiveTaskReplacing(task.id);
|
||||
if (!(await markImageArchiveTaskReplacing(task.id))) continue;
|
||||
const result = await replaceImageUrlInDatabase(task.sourceUrl, task.targetUrl);
|
||||
await markImageArchiveTaskCompleted({
|
||||
id: task.id,
|
||||
|
||||
@ -1,30 +1,34 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, asc, eq, inArray, lt, lte, sql } from "drizzle-orm";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
isGeneratedImageSource,
|
||||
collectImageArchiveSources,
|
||||
replaceArchivedImageUrls,
|
||||
} from "./image-archive-urls";
|
||||
import { and, asc, eq, inArray, lt, lte, or, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { imageArchiveTask, type ImageArchiveTask } from "@/lib/db/schema";
|
||||
|
||||
export type ImageArchiveTaskRecord = ImageArchiveTask;
|
||||
|
||||
const DEFAULT_DISPLAY_HOLD_HOURS = 1;
|
||||
const MAX_IMAGE_ARCHIVE_ATTEMPTS = 12;
|
||||
const ARCHIVE_LEASE_MS = 30 * 60 * 1000;
|
||||
|
||||
function getDisplayHoldHours() {
|
||||
const raw = Number(
|
||||
process.env.IMAGE_ARCHIVE_DISPLAY_HOLD_HOURS || DEFAULT_DISPLAY_HOLD_HOURS
|
||||
);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_DISPLAY_HOLD_HOURS;
|
||||
return Math.max(0, Math.min(24 * 30, raw));
|
||||
}
|
||||
|
||||
// Replace as soon as the durable object exists, regardless of legacy hold settings.
|
||||
export function getImageArchiveDisplayHoldMs() {
|
||||
return getDisplayHoldHours() * 60 * 60 * 1000;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function isArchivableHttpUrl(value: string) {
|
||||
if (!/^https?:\/\//i.test(value)) return false;
|
||||
if (/^(data|blob):/i.test(value)) return false;
|
||||
return true;
|
||||
export function imageArchiveSourceKey(source: string) {
|
||||
if (!source.startsWith("data:")) return source;
|
||||
const configured =
|
||||
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
|
||||
const origin = configured
|
||||
? new URL(configured).origin
|
||||
: `http://localhost:${process.env.PORT || "3000"}`;
|
||||
const key = createHash("sha256").update(source).digest("hex");
|
||||
return `${origin}/api/generated-image-preview/${key}`;
|
||||
}
|
||||
|
||||
export async function enqueueImageArchiveTask(params: {
|
||||
@ -34,30 +38,35 @@ export async function enqueueImageArchiveTask(params: {
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const sourceUrl = String(params.sourceUrl || "").trim();
|
||||
if (!isArchivableHttpUrl(sourceUrl)) return null;
|
||||
if (!isGeneratedImageSource(sourceUrl)) return null;
|
||||
|
||||
const now = new Date();
|
||||
const archiveAfterAt = params.archiveAfterAt || now;
|
||||
const replaceAfterAt =
|
||||
params.replaceAfterAt ||
|
||||
new Date(now.getTime() + getImageArchiveDisplayHoldMs());
|
||||
params.replaceAfterAt || new Date(now.getTime() + getImageArchiveDisplayHoldMs());
|
||||
|
||||
const [task] = await db
|
||||
.insert(imageArchiveTask)
|
||||
.values({
|
||||
sourceUrl,
|
||||
sourceUrl: imageArchiveSourceKey(sourceUrl),
|
||||
status: "pending",
|
||||
archiveAfterAt,
|
||||
replaceAfterAt,
|
||||
metadata: params.metadata || null,
|
||||
metadata: sourceUrl.startsWith("data:")
|
||||
? { ...params.metadata, inlineSource: sourceUrl }
|
||||
: params.metadata || null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: imageArchiveTask.sourceUrl,
|
||||
set: {
|
||||
replaceAfterAt,
|
||||
metadata: params.metadata || undefined,
|
||||
updatedAt: now,
|
||||
metadata: sql`case when ${imageArchiveTask.targetUrl} is not null then ${imageArchiveTask.metadata}
|
||||
else coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || ${JSON.stringify(
|
||||
sourceUrl.startsWith("data:")
|
||||
? { ...params.metadata, inlineSource: sourceUrl }
|
||||
: params.metadata || {},
|
||||
)}::jsonb end`,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
@ -71,10 +80,8 @@ export async function enqueueImageArchiveTasks(params: {
|
||||
}) {
|
||||
const urls = Array.from(
|
||||
new Set(
|
||||
(params.urls || [])
|
||||
.map((url) => String(url || "").trim())
|
||||
.filter(isArchivableHttpUrl)
|
||||
)
|
||||
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
|
||||
),
|
||||
);
|
||||
|
||||
const tasks: ImageArchiveTaskRecord[] = [];
|
||||
@ -97,15 +104,15 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) {
|
||||
and(
|
||||
inArray(imageArchiveTask.status, ["pending", "failed"]),
|
||||
lte(imageArchiveTask.archiveAfterAt, now),
|
||||
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS)
|
||||
)
|
||||
lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
|
||||
.limit(Math.max(1, Math.min(50, params.limit)));
|
||||
}
|
||||
|
||||
export async function markImageArchiveTaskArchiving(id: string) {
|
||||
await db
|
||||
const claimed = await db
|
||||
.update(imageArchiveTask)
|
||||
.set({
|
||||
status: "archiving",
|
||||
@ -113,7 +120,15 @@ export async function markImageArchiveTaskArchiving(id: string) {
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(imageArchiveTask.id, id));
|
||||
.where(
|
||||
and(
|
||||
eq(imageArchiveTask.id, id),
|
||||
inArray(imageArchiveTask.status, ["pending", "failed"]),
|
||||
lte(imageArchiveTask.archiveAfterAt, new Date()),
|
||||
),
|
||||
)
|
||||
.returning({ id: imageArchiveTask.id });
|
||||
return claimed.length > 0;
|
||||
}
|
||||
|
||||
export async function markImageArchiveTaskStored(params: {
|
||||
@ -128,18 +143,16 @@ export async function markImageArchiveTaskStored(params: {
|
||||
targetUrl: params.targetUrl,
|
||||
targetStorageConfigId: params.targetStorageConfigId || null,
|
||||
archivedAt: new Date(),
|
||||
replaceAfterAt: new Date(),
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(imageArchiveTask.id, params.id));
|
||||
}
|
||||
|
||||
export async function markImageArchiveTaskFailed(params: {
|
||||
id: string;
|
||||
error: unknown;
|
||||
}) {
|
||||
export async function markImageArchiveTaskFailed(params: { id: string; error: unknown }) {
|
||||
const message = String(
|
||||
params.error instanceof Error ? params.error.message : params.error || "Unknown error"
|
||||
params.error instanceof Error ? params.error.message : params.error || "Unknown error",
|
||||
).slice(0, 2000);
|
||||
const isPermanentSourceError = /\bHTTP (?:404|410)\b/i.test(message);
|
||||
|
||||
@ -160,29 +173,35 @@ export async function markImageArchiveTaskFailed(params: {
|
||||
.where(eq(imageArchiveTask.id, params.id));
|
||||
}
|
||||
|
||||
function isArchiveReplacementDue(now: Date) {
|
||||
return and(
|
||||
or(
|
||||
eq(imageArchiveTask.status, "stored"),
|
||||
and(
|
||||
eq(imageArchiveTask.status, "completed"),
|
||||
sql`${imageArchiveTask.metadata}->>'replacementPasses' = '1'`,
|
||||
),
|
||||
),
|
||||
lte(imageArchiveTask.replaceAfterAt, now),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getImageArchiveTasksToReplace(params: { limit: number }) {
|
||||
const now = new Date();
|
||||
return await db
|
||||
.select()
|
||||
.from(imageArchiveTask)
|
||||
.where(
|
||||
and(
|
||||
eq(imageArchiveTask.status, "stored"),
|
||||
lte(imageArchiveTask.replaceAfterAt, now)
|
||||
)
|
||||
)
|
||||
.where(isArchiveReplacementDue(new Date()))
|
||||
.orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id))
|
||||
.limit(Math.max(1, Math.min(50, params.limit)));
|
||||
}
|
||||
|
||||
export async function markImageArchiveTaskReplacing(id: string) {
|
||||
await db
|
||||
const claimed = await db
|
||||
.update(imageArchiveTask)
|
||||
.set({
|
||||
status: "replacing",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(imageArchiveTask.id, id));
|
||||
.set({ status: "replacing", updatedAt: new Date() })
|
||||
.where(and(eq(imageArchiveTask.id, id), isArchiveReplacementDue(new Date())))
|
||||
.returning({ id: imageArchiveTask.id });
|
||||
return claimed.length > 0;
|
||||
}
|
||||
|
||||
export async function markImageArchiveTaskCompleted(params: {
|
||||
@ -195,23 +214,20 @@ export async function markImageArchiveTaskCompleted(params: {
|
||||
status: "completed",
|
||||
replacedAt: new Date(),
|
||||
lastError: null,
|
||||
metadata:
|
||||
params.replacedCount === undefined
|
||||
? undefined
|
||||
: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || ${JSON.stringify({
|
||||
replacedCount: params.replacedCount,
|
||||
})}::jsonb`,
|
||||
// Reconcile once more after delivery has settled: asynchronous generation/task
|
||||
// writes may arrive just after the first replacement. The live URL is already ready.
|
||||
replaceAfterAt: new Date(Date.now() + 60_000),
|
||||
metadata: sql`(coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) - 'inlineSource')
|
||||
|| jsonb_build_object('replacementPasses', coalesce((${imageArchiveTask.metadata}->>'replacementPasses')::int, 0) + 1)
|
||||
|| ${JSON.stringify(params.replacedCount === undefined ? {} : { replacedCount: params.replacedCount })}::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(imageArchiveTask.id, params.id));
|
||||
}
|
||||
|
||||
export async function markImageArchiveTaskReplaceFailed(params: {
|
||||
id: string;
|
||||
error: unknown;
|
||||
}) {
|
||||
export async function markImageArchiveTaskReplaceFailed(params: { id: string; error: unknown }) {
|
||||
const message = String(
|
||||
params.error instanceof Error ? params.error.message : params.error || "Unknown error"
|
||||
params.error instanceof Error ? params.error.message : params.error || "Unknown error",
|
||||
).slice(0, 2000);
|
||||
|
||||
await db
|
||||
@ -230,6 +246,7 @@ export async function getImageArchiveTaskStats() {
|
||||
.select({
|
||||
status: imageArchiveTask.status,
|
||||
count: sql<number>`count(*)::int`,
|
||||
reconciliationCount: sql<number>`count(*) filter (where ${imageArchiveTask.status} = 'completed' and ${imageArchiveTask.metadata}->>'replacementPasses' = '1')::int`,
|
||||
})
|
||||
.from(imageArchiveTask)
|
||||
.groupBy(imageArchiveTask.status);
|
||||
@ -237,9 +254,11 @@ export async function getImageArchiveTaskStats() {
|
||||
const pendingStore = grouped
|
||||
.filter((item) => item.status === "pending" || item.status === "failed")
|
||||
.reduce((sum, item) => sum + (item.count || 0), 0);
|
||||
const pendingReplace = grouped
|
||||
.filter((item) => item.status === "stored")
|
||||
.reduce((sum, item) => sum + (item.count || 0), 0);
|
||||
const pendingReplace =
|
||||
grouped
|
||||
.filter((item) => item.status === "stored")
|
||||
.reduce((sum, item) => sum + (item.count || 0), 0) +
|
||||
grouped.reduce((sum, item) => sum + (item.reconciliationCount || 0), 0);
|
||||
|
||||
return {
|
||||
grouped,
|
||||
@ -247,3 +266,50 @@ export async function getImageArchiveTaskStats() {
|
||||
pendingReplace,
|
||||
};
|
||||
}
|
||||
|
||||
// Interrupted workers can be claimed again after a lease expires. Failed uploads keep
|
||||
// their original source and retry time, so transient bucket errors cannot lose images.
|
||||
export async function recoverStaleImageArchiveTasks() {
|
||||
const staleBefore = new Date(Date.now() - ARCHIVE_LEASE_MS);
|
||||
await db
|
||||
.update(imageArchiveTask)
|
||||
.set({
|
||||
status: sql`case when ${imageArchiveTask.attemptCount} >= ${MAX_IMAGE_ARCHIVE_ATTEMPTS} then 'abandoned' else 'failed' end`,
|
||||
archiveAfterAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(eq(imageArchiveTask.status, "archiving"), lt(imageArchiveTask.updatedAt, staleBefore)),
|
||||
);
|
||||
await db
|
||||
.update(imageArchiveTask)
|
||||
.set({ status: "stored", replaceAfterAt: new Date(), updatedAt: new Date() })
|
||||
.where(
|
||||
and(eq(imageArchiveTask.status, "replacing"), lt(imageArchiveTask.updatedAt, staleBefore)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getArchivedImageUrlMappings(urls: string[]) {
|
||||
if (!urls.length) return {};
|
||||
const sources = [...new Set(urls.filter(isGeneratedImageSource))];
|
||||
const tasks = await db
|
||||
.select({ sourceUrl: imageArchiveTask.sourceUrl, targetUrl: imageArchiveTask.targetUrl })
|
||||
.from(imageArchiveTask)
|
||||
.where(inArray(imageArchiveTask.sourceUrl, sources.map(imageArchiveSourceKey)));
|
||||
const targets = new Map(
|
||||
tasks.filter((task) => task.targetUrl).map((task) => [task.sourceUrl, task.targetUrl!]),
|
||||
);
|
||||
const replacements: Record<string, string> = {};
|
||||
for (const source of sources) {
|
||||
const target = targets.get(imageArchiveSourceKey(source));
|
||||
if (target) replacements[source] = target;
|
||||
}
|
||||
return replacements;
|
||||
}
|
||||
|
||||
// Normalize late writes too: an archive can finish before a task/canvas save arrives.
|
||||
export async function resolveArchivedImageUrls<T>(value: T): Promise<T> {
|
||||
const sources = collectImageArchiveSources(value);
|
||||
if (!sources.length) return value;
|
||||
return replaceArchivedImageUrls(value, await getArchivedImageUrlMappings(sources));
|
||||
}
|
||||
|
||||
56
lib/storage/image-archive-urls.ts
Normal file
@ -0,0 +1,56 @@
|
||||
// Shared by the server and canvas; no storage or network work happens here.
|
||||
export type ArchivedImageUrlMap = Record<string, string>;
|
||||
|
||||
export function isGeneratedImageSource(value: string) {
|
||||
return (
|
||||
/^https?:\/\//i.test(value) || /^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/=]+$/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function collectImageArchiveSources(value: unknown): string[] {
|
||||
const urls = new Set<string>();
|
||||
const visit = (item: unknown) => {
|
||||
if (typeof item === "string") {
|
||||
if (isGeneratedImageSource(item)) urls.add(item);
|
||||
else
|
||||
for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
|
||||
if (isGeneratedImageSource(match[1])) urls.add(match[1]);
|
||||
}
|
||||
} else if (Array.isArray(item)) item.forEach(visit);
|
||||
else if (item && typeof item === "object" && !(item instanceof Date))
|
||||
Object.values(item).forEach(visit);
|
||||
};
|
||||
visit(value);
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
// Structural sharing preserves unaffected nodes and Date values; no undo step is added.
|
||||
export function replaceArchivedImageUrls<T>(value: T, replacements: ArchivedImageUrlMap): T {
|
||||
if (typeof value === "string") {
|
||||
if (replacements[value]) return replacements[value] as T;
|
||||
// URLs inside generated-image Markdown carry both preview and original URLs.
|
||||
return value.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt: string, url: string) => {
|
||||
const nextAlt = alt
|
||||
.split("|||")
|
||||
.map((part) => replacements[part] || part)
|
||||
.join("|||");
|
||||
return ``;
|
||||
}) 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;
|
||||
}
|
||||
66
lib/storage/image-archive-worker.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import "server-only";
|
||||
import { after } from "next/server";
|
||||
|
||||
const workerKey = "__generated_image_archive_worker__";
|
||||
type Worker = {
|
||||
running: boolean;
|
||||
scheduled: boolean;
|
||||
requested: boolean;
|
||||
retryTimer?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
function getWorker(): Worker {
|
||||
const root = globalThis as typeof globalThis & { [workerKey]?: Worker };
|
||||
return (root[workerKey] ??= { running: false, scheduled: false, requested: false });
|
||||
}
|
||||
|
||||
export function scheduleImageArchiveWorker(deliveryConfirmed = false) {
|
||||
const worker = getWorker();
|
||||
worker.requested = true;
|
||||
if (worker.running || (worker.scheduled && !deliveryConfirmed)) return;
|
||||
worker.scheduled = true;
|
||||
if (worker.retryTimer) {
|
||||
clearTimeout(worker.retryTimer);
|
||||
worker.retryTimer = undefined;
|
||||
}
|
||||
const run = async () => {
|
||||
if (worker.running) return;
|
||||
worker.scheduled = false;
|
||||
worker.running = true;
|
||||
let retryNeeded = false;
|
||||
try {
|
||||
const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job");
|
||||
do {
|
||||
worker.requested = false;
|
||||
const result = await runImageArchiveJob();
|
||||
retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0;
|
||||
// Stop when only backoff tasks remain; do not spin while storage is down.
|
||||
if (!worker.requested && result.store.total === 0 && result.replace.total === 0) break;
|
||||
} while (true);
|
||||
} catch (error) {
|
||||
retryNeeded = true;
|
||||
console.error("[ImageArchiveWorker] background archive failed", error);
|
||||
} finally {
|
||||
worker.running = false;
|
||||
if (worker.requested) scheduleImageArchiveWorker();
|
||||
else if (retryNeeded) {
|
||||
worker.retryTimer = setTimeout(scheduleImageArchiveWorker, 60_000);
|
||||
worker.retryTimer.unref?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
// Detached generation tasks may inherit a request that has already closed. Keep
|
||||
// a Node-server fallback so archiving never depends on the user leaving a tab open.
|
||||
const deliveryFallback = setTimeout(() => {
|
||||
if (worker.scheduled && !worker.running) void run();
|
||||
}, 1_000);
|
||||
deliveryFallback.unref?.();
|
||||
try {
|
||||
// Request handlers deliver first. Durable task rows also allow cron/restart recovery.
|
||||
after(run);
|
||||
} catch {
|
||||
// Durable canvas tasks can run outside Next's request context on the Node server.
|
||||
setImmediate(() => {
|
||||
void run();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,13 @@ import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { isGeneratedImageSource } from "./image-archive-urls";
|
||||
import { scheduleImageArchiveWorker } from "./image-archive-worker";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
enqueueImageArchiveTasks,
|
||||
getImageArchiveDisplayHoldMs,
|
||||
imageArchiveSourceKey,
|
||||
} from "@/lib/storage/image-archive-db";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
@ -25,16 +28,12 @@ function getDownloadTimeoutMs() {
|
||||
return Math.max(5_000, Math.min(5 * 60_000, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string) {
|
||||
return /^https?:\/\//i.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
export function isDeferredImageArchiveEnabled() {
|
||||
return process.env.AI_IMAGE_DEFERRED_ARCHIVE !== "false";
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isImmediateImageArchiveEnabled() {
|
||||
return process.env.AI_IMAGE_IMMEDIATE_ARCHIVE !== "false";
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function registerGeneratedImageTemporaryUrls(params: {
|
||||
@ -44,24 +43,24 @@ export async function registerGeneratedImageTemporaryUrls(params: {
|
||||
if (!isDeferredImageArchiveEnabled()) return [];
|
||||
const urls = Array.from(
|
||||
new Set(
|
||||
(params.urls || [])
|
||||
.map((url) => String(url || "").trim())
|
||||
.filter(isHttpUrl)
|
||||
)
|
||||
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
|
||||
),
|
||||
);
|
||||
if (urls.length === 0) return [];
|
||||
|
||||
try {
|
||||
return await enqueueImageArchiveTasks({
|
||||
const tasks = await enqueueImageArchiveTasks({
|
||||
urls,
|
||||
metadata: {
|
||||
...(params.metadata || {}),
|
||||
displayHoldMs: getImageArchiveDisplayHoldMs(),
|
||||
},
|
||||
});
|
||||
scheduleImageArchiveWorker();
|
||||
return tasks;
|
||||
} catch (error) {
|
||||
logger.warn("enqueue temporary image archive failed", {
|
||||
urls,
|
||||
imageCount: urls.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
@ -77,10 +76,8 @@ export async function formatDeferredGeneratedImageMarkdown(params: {
|
||||
}) {
|
||||
const urls = Array.from(
|
||||
new Set(
|
||||
(params.urls || [])
|
||||
.map((url) => String(url || "").trim())
|
||||
.filter(isHttpUrl)
|
||||
)
|
||||
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
|
||||
),
|
||||
);
|
||||
if (urls.length === 0) {
|
||||
throw new Error("AI 未返回可用图片地址");
|
||||
@ -97,17 +94,17 @@ export async function formatDeferredGeneratedImageMarkdown(params: {
|
||||
return persisted
|
||||
.map(
|
||||
({ originalUrl, thumbnailUrl }) =>
|
||||
``
|
||||
``,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export async function createDeferredUploadedImage(
|
||||
sourceUrl: string,
|
||||
metadata?: DeferredImageArchiveMetadata | null
|
||||
metadata?: DeferredImageArchiveMetadata | null,
|
||||
) {
|
||||
const url = String(sourceUrl || "").trim();
|
||||
if (!isDeferredImageArchiveEnabled() || !isHttpUrl(url)) return null;
|
||||
if (!isDeferredImageArchiveEnabled() || !isGeneratedImageSource(url)) return null;
|
||||
|
||||
const [persisted] = await persistGeneratedImageUrls({
|
||||
urls: [url],
|
||||
@ -124,48 +121,20 @@ async function persistGeneratedImageUrls(params: {
|
||||
urls: string[];
|
||||
metadata?: DeferredImageArchiveMetadata | null;
|
||||
}) {
|
||||
if (!isImmediateImageArchiveEnabled()) {
|
||||
await registerGeneratedImageTemporaryUrls(params);
|
||||
return params.urls.map((url) => ({
|
||||
originalUrl: url,
|
||||
thumbnailUrl: url,
|
||||
}));
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
params.urls.map(async (url) => {
|
||||
try {
|
||||
const archived = await archiveTemporaryImage(url);
|
||||
logger.info("generated image archived before response", {
|
||||
providerType: params.metadata?.providerType,
|
||||
modelId: params.metadata?.modelId,
|
||||
bytes: archived.bytes,
|
||||
});
|
||||
return {
|
||||
originalUrl: archived.url,
|
||||
thumbnailUrl: archived.thumbnailUrl || archived.url,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("immediate generated image archive failed; queued for retry", {
|
||||
providerType: params.metadata?.providerType,
|
||||
modelId: params.metadata?.modelId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await registerGeneratedImageTemporaryUrls({
|
||||
urls: [url],
|
||||
metadata: params.metadata || null,
|
||||
});
|
||||
return {
|
||||
originalUrl: url,
|
||||
thumbnailUrl: url,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
// Only the durable queue insert is awaited. Downloading, thumbnailing and bucket
|
||||
// uploads run after delivery and never extend the provider response time.
|
||||
await registerGeneratedImageTemporaryUrls(params);
|
||||
return params.urls.map((url) => {
|
||||
const preview = imageArchiveSourceKey(url);
|
||||
return { originalUrl: preview, thumbnailUrl: preview };
|
||||
});
|
||||
}
|
||||
|
||||
function getImageExtension(contentType: string | null, sourceUrl: string) {
|
||||
const normalized = String(contentType || "").split(";")[0].trim().toLowerCase();
|
||||
const normalized = String(contentType || "")
|
||||
.split(";")[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const byType: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
@ -178,7 +147,11 @@ function getImageExtension(contentType: string | null, sourceUrl: string) {
|
||||
|
||||
try {
|
||||
const pathname = new URL(sourceUrl).pathname.toLowerCase();
|
||||
const ext = pathname.split(".").pop()?.replace(/[^a-z0-9]+/g, "") || "";
|
||||
const ext =
|
||||
pathname
|
||||
.split(".")
|
||||
.pop()
|
||||
?.replace(/[^a-z0-9]+/g, "") || "";
|
||||
if (["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(ext)) {
|
||||
return ext === "jpeg" ? "jpg" : ext;
|
||||
}
|
||||
@ -192,14 +165,20 @@ function getImageExtension(contentType: string | null, sourceUrl: string) {
|
||||
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
) as ArrayBuffer;
|
||||
}
|
||||
|
||||
export async function downloadTemporaryImage(params: {
|
||||
sourceUrl: string;
|
||||
retries?: number;
|
||||
}) {
|
||||
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
|
||||
if (params.sourceUrl.startsWith("data:")) {
|
||||
const match = params.sourceUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
||||
if (!match) throw new Error("Invalid inline generated image");
|
||||
return {
|
||||
buffer: Buffer.from(match[2], "base64"),
|
||||
contentType: match[1],
|
||||
extension: getImageExtension(match[1], ""),
|
||||
};
|
||||
}
|
||||
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
@ -249,7 +228,7 @@ export async function archiveTemporaryImage(sourceUrl: string) {
|
||||
const thumbnailUploaded = thumbnailBuffer
|
||||
? await uploadFile(
|
||||
`ai-generated-archive-thumb-${objectSuffix}.webp`,
|
||||
toArrayBuffer(thumbnailBuffer)
|
||||
toArrayBuffer(thumbnailBuffer),
|
||||
).catch((error) => {
|
||||
logger.warn("generated image thumbnail upload failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
assetMemorySnapshot,
|
||||
canvasDetailArchive,
|
||||
canvasNode,
|
||||
canvasImageTask,
|
||||
canvasProject,
|
||||
conversationWorkingState,
|
||||
externalImageApiRequestLog,
|
||||
@ -26,18 +27,14 @@ function normalizeMediaUrl(value: string): string {
|
||||
const parsed = new URL(canonical);
|
||||
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase();
|
||||
} catch {
|
||||
return canonical
|
||||
.split("#")[0]
|
||||
.split("?")[0]
|
||||
.replace(/\/+$/, "")
|
||||
.toLowerCase();
|
||||
return canonical.split("#")[0].split("?")[0].replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function replaceDeepUrls(
|
||||
value: unknown,
|
||||
sourceUrl: string,
|
||||
targetUrl: string
|
||||
targetUrl: string,
|
||||
): { value: unknown; changed: boolean } {
|
||||
if (typeof value === "string") {
|
||||
if (!value.includes(sourceUrl)) {
|
||||
@ -70,10 +67,7 @@ export function replaceDeepUrls(
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
export async function replaceImageUrlInDatabase(
|
||||
sourceUrl: string,
|
||||
targetUrl: string
|
||||
) {
|
||||
export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: string) {
|
||||
if (!sourceUrl || !targetUrl || sourceUrl === targetUrl) {
|
||||
return { changed: 0 };
|
||||
}
|
||||
@ -106,8 +100,8 @@ export async function replaceImageUrlInDatabase(
|
||||
eq(mediaAsset.thumbnailUrl, sourceUrl),
|
||||
eq(mediaAsset.normalizedUrl, sourceNormalizedUrl),
|
||||
sql`${mediaAsset.metadata}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${mediaAsset.summary}::text like ${`%${sourceUrl}%`}`
|
||||
)
|
||||
sql`${mediaAsset.summary}::text like ${`%${sourceUrl}%`}`,
|
||||
),
|
||||
)
|
||||
.limit(500);
|
||||
|
||||
@ -117,8 +111,7 @@ export async function replaceImageUrlInDatabase(
|
||||
const thumbnail = replaceDeepUrls(item.thumbnailUrl, sourceUrl, targetUrl);
|
||||
const metadata = replaceDeepUrls(item.metadata, sourceUrl, targetUrl);
|
||||
const summary = replaceDeepUrls(item.summary, sourceUrl, targetUrl);
|
||||
const shouldNormalize =
|
||||
item.normalizedUrl === sourceNormalizedUrl || original.changed;
|
||||
const shouldNormalize = item.normalizedUrl === sourceNormalizedUrl || original.changed;
|
||||
const nextOriginal = (original.value as string) || item.originalUrl;
|
||||
|
||||
if (
|
||||
@ -139,8 +132,7 @@ export async function replaceImageUrlInDatabase(
|
||||
originalUrl: nextOriginal,
|
||||
normalizedUrl: shouldNormalize ? targetNormalizedUrl : item.normalizedUrl,
|
||||
previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl,
|
||||
thumbnailUrl:
|
||||
(thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
|
||||
thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
|
||||
metadata: (metadata.value ?? item.metadata) as any,
|
||||
summary: (summary.value ?? item.summary) as any,
|
||||
updatedAt: new Date(),
|
||||
@ -152,8 +144,7 @@ export async function replaceImageUrlInDatabase(
|
||||
.set({
|
||||
originalUrl: nextOriginal,
|
||||
previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl,
|
||||
thumbnailUrl:
|
||||
(thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
|
||||
thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl,
|
||||
metadata: (metadata.value ?? item.metadata) as any,
|
||||
summary: (summary.value ?? item.summary) as any,
|
||||
updatedAt: new Date(),
|
||||
@ -170,8 +161,8 @@ export async function replaceImageUrlInDatabase(
|
||||
or(
|
||||
eq(assetMemorySnapshot.normalizedUrl, sourceNormalizedUrl),
|
||||
sql`${assetMemorySnapshot.metadata}::text like ${`%${sourceUrl}%`}`,
|
||||
like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`)
|
||||
)
|
||||
like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`),
|
||||
),
|
||||
)
|
||||
.limit(500);
|
||||
|
||||
@ -203,8 +194,8 @@ export async function replaceImageUrlInDatabase(
|
||||
.where(
|
||||
or(
|
||||
sql`${message.parts}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${message.attachments}::text like ${`%${sourceUrl}%`}`
|
||||
)
|
||||
sql`${message.attachments}::text like ${`%${sourceUrl}%`}`,
|
||||
),
|
||||
)
|
||||
.limit(500);
|
||||
|
||||
@ -243,120 +234,67 @@ export async function replaceImageUrlInDatabase(
|
||||
changed += 1;
|
||||
}
|
||||
|
||||
const escapedSource = JSON.stringify(sourceUrl).slice(1, -1);
|
||||
const escapedTarget = JSON.stringify(targetUrl).slice(1, -1);
|
||||
const projects = await db
|
||||
.select({
|
||||
id: canvasProject.id,
|
||||
canvasData: canvasProject.canvasData,
|
||||
.update(canvasProject)
|
||||
.set({
|
||||
canvasData: sql`replace(${canvasProject.canvasData}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
thumbnail: sql`case when ${canvasProject.thumbnail} = ${sourceUrl} then ${targetUrl} else ${canvasProject.thumbnail} end`,
|
||||
// URL maintenance is not a content revision; keep autosave conflict detection valid.
|
||||
})
|
||||
.from(canvasProject)
|
||||
.where(sql`${canvasProject.canvasData}::text like ${`%${sourceUrl}%`}`)
|
||||
.limit(500);
|
||||
|
||||
for (const item of projects) {
|
||||
const replaced = replaceDeepUrls(item.canvasData, sourceUrl, targetUrl);
|
||||
if (!replaced.changed) continue;
|
||||
|
||||
await db
|
||||
.update(canvasProject)
|
||||
.set({
|
||||
canvasData: replaced.value as any,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(canvasProject.id, item.id));
|
||||
changed += 1;
|
||||
}
|
||||
|
||||
const nodes = await db
|
||||
.select({
|
||||
id: canvasNode.id,
|
||||
data: canvasNode.data,
|
||||
})
|
||||
.from(canvasNode)
|
||||
.where(sql`${canvasNode.data}::text like ${`%${sourceUrl}%`}`)
|
||||
.limit(500);
|
||||
|
||||
for (const item of nodes) {
|
||||
const replaced = replaceDeepUrls(item.data, sourceUrl, targetUrl);
|
||||
if (!replaced.changed) continue;
|
||||
|
||||
await db
|
||||
.update(canvasNode)
|
||||
.set({
|
||||
data: replaced.value as any,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(canvasNode.id, item.id));
|
||||
changed += 1;
|
||||
}
|
||||
|
||||
const workingStates = await db
|
||||
.select()
|
||||
.from(conversationWorkingState)
|
||||
.where(
|
||||
or(
|
||||
sql`${conversationWorkingState.currentFocusImageUrls}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${conversationWorkingState.selectedImageUrls}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${conversationWorkingState.lastUploadedImageUrls}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${conversationWorkingState.lastGeneratedImageUrls}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${conversationWorkingState.lastReferencedImageUrls}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${conversationWorkingState.lastResolvedTargetImageUrls}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${conversationWorkingState.lastCanvasSnapshot}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${conversationWorkingState.lastTurnSummary}::text like ${`%${sourceUrl}%`}`
|
||||
)
|
||||
sql`position(${escapedSource} in ${canvasProject.canvasData}::text) > 0`,
|
||||
eq(canvasProject.thumbnail, sourceUrl),
|
||||
),
|
||||
)
|
||||
.limit(500);
|
||||
.returning({ id: canvasProject.id });
|
||||
changed += projects.length;
|
||||
const nodes = await db
|
||||
.update(canvasNode)
|
||||
.set({
|
||||
data: sql`replace(${canvasNode.data}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
})
|
||||
.where(sql`position(${escapedSource} in ${canvasNode.data}::text) > 0`)
|
||||
.returning({ id: canvasNode.id });
|
||||
changed += nodes.length;
|
||||
const tasks = await db
|
||||
.update(canvasImageTask)
|
||||
.set({
|
||||
result: sql`replace(${canvasImageTask.result}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
})
|
||||
.where(sql`position(${escapedSource} in ${canvasImageTask.result}::text) > 0`)
|
||||
.returning({ id: canvasImageTask.id });
|
||||
changed += tasks.length;
|
||||
|
||||
for (const item of workingStates) {
|
||||
const currentFocus = replaceDeepUrls(
|
||||
item.currentFocusImageUrls,
|
||||
sourceUrl,
|
||||
targetUrl
|
||||
);
|
||||
const selected = replaceDeepUrls(item.selectedImageUrls, sourceUrl, targetUrl);
|
||||
const uploaded = replaceDeepUrls(item.lastUploadedImageUrls, sourceUrl, targetUrl);
|
||||
const generated = replaceDeepUrls(item.lastGeneratedImageUrls, sourceUrl, targetUrl);
|
||||
const referenced = replaceDeepUrls(item.lastReferencedImageUrls, sourceUrl, targetUrl);
|
||||
const resolved = replaceDeepUrls(
|
||||
item.lastResolvedTargetImageUrls,
|
||||
sourceUrl,
|
||||
targetUrl
|
||||
);
|
||||
const canvasSnapshot = replaceDeepUrls(
|
||||
item.lastCanvasSnapshot,
|
||||
sourceUrl,
|
||||
targetUrl
|
||||
);
|
||||
const turnSummary = replaceDeepUrls(item.lastTurnSummary, sourceUrl, targetUrl);
|
||||
|
||||
if (
|
||||
!currentFocus.changed &&
|
||||
!selected.changed &&
|
||||
!uploaded.changed &&
|
||||
!generated.changed &&
|
||||
!referenced.changed &&
|
||||
!resolved.changed &&
|
||||
!canvasSnapshot.changed &&
|
||||
!turnSummary.changed
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(conversationWorkingState)
|
||||
.set({
|
||||
currentFocusImageUrls: currentFocus.value as any,
|
||||
selectedImageUrls: selected.value as any,
|
||||
lastUploadedImageUrls: uploaded.value as any,
|
||||
lastGeneratedImageUrls: generated.value as any,
|
||||
lastReferencedImageUrls: referenced.value as any,
|
||||
lastResolvedTargetImageUrls: resolved.value as any,
|
||||
lastCanvasSnapshot: (canvasSnapshot.value ?? item.lastCanvasSnapshot) as any,
|
||||
lastTurnSummary: (turnSummary.value ?? item.lastTurnSummary) as any,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(conversationWorkingState.id, item.id));
|
||||
changed += 1;
|
||||
}
|
||||
const workingStates = await db
|
||||
.update(conversationWorkingState)
|
||||
.set({
|
||||
currentFocusImageUrls: sql`replace(${conversationWorkingState.currentFocusImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
selectedImageUrls: sql`replace(${conversationWorkingState.selectedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
lastUploadedImageUrls: sql`replace(${conversationWorkingState.lastUploadedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
lastGeneratedImageUrls: sql`replace(${conversationWorkingState.lastGeneratedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
lastReferencedImageUrls: sql`replace(${conversationWorkingState.lastReferencedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
lastResolvedTargetImageUrls: sql`replace(${conversationWorkingState.lastResolvedTargetImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
lastCanvasSnapshot: sql`replace(${conversationWorkingState.lastCanvasSnapshot}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
lastTurnSummary: sql`replace(${conversationWorkingState.lastTurnSummary}::text, ${escapedSource}, ${escapedTarget})::jsonb`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
or(
|
||||
sql`position(${escapedSource} in ${conversationWorkingState.currentFocusImageUrls}::text) > 0`,
|
||||
sql`position(${escapedSource} in ${conversationWorkingState.selectedImageUrls}::text) > 0`,
|
||||
sql`position(${escapedSource} in ${conversationWorkingState.lastUploadedImageUrls}::text) > 0`,
|
||||
sql`position(${escapedSource} in ${conversationWorkingState.lastGeneratedImageUrls}::text) > 0`,
|
||||
sql`position(${escapedSource} in ${conversationWorkingState.lastReferencedImageUrls}::text) > 0`,
|
||||
sql`position(${escapedSource} in ${conversationWorkingState.lastResolvedTargetImageUrls}::text) > 0`,
|
||||
sql`position(${escapedSource} in ${conversationWorkingState.lastCanvasSnapshot}::text) > 0`,
|
||||
sql`position(${escapedSource} in ${conversationWorkingState.lastTurnSummary}::text) > 0`,
|
||||
),
|
||||
)
|
||||
.returning({ id: conversationWorkingState.id });
|
||||
changed += workingStates.length;
|
||||
|
||||
const detailArchives = await db
|
||||
.select({
|
||||
@ -374,8 +312,8 @@ export async function replaceImageUrlInDatabase(
|
||||
sql`${canvasDetailArchive.brief}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${canvasDetailArchive.slots}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${canvasDetailArchive.uiSurfaceSnapshot}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${canvasDetailArchive.uiSurfaceLatestReview}::text like ${`%${sourceUrl}%`}`
|
||||
)
|
||||
sql`${canvasDetailArchive.uiSurfaceLatestReview}::text like ${`%${sourceUrl}%`}`,
|
||||
),
|
||||
)
|
||||
.limit(500);
|
||||
|
||||
@ -416,8 +354,8 @@ export async function replaceImageUrlInDatabase(
|
||||
.where(
|
||||
or(
|
||||
like(model3dAsset.previewImageUrl, `%${sourceUrl}%`),
|
||||
sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`
|
||||
)
|
||||
sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`,
|
||||
),
|
||||
)
|
||||
.limit(500);
|
||||
|
||||
@ -429,8 +367,7 @@ export async function replaceImageUrlInDatabase(
|
||||
await db
|
||||
.update(model3dAsset)
|
||||
.set({
|
||||
previewImageUrl:
|
||||
(preview.value as string | null | undefined) ?? item.previewImageUrl,
|
||||
previewImageUrl: (preview.value as string | null | undefined) ?? item.previewImageUrl,
|
||||
assetPayload: (payload.value ?? item.assetPayload) as any,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@ -444,22 +381,14 @@ export async function replaceImageUrlInDatabase(
|
||||
.where(
|
||||
or(
|
||||
sql`${externalImageApiRequestLog.responsePayload}::text like ${`%${sourceUrl}%`}`,
|
||||
sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`
|
||||
)
|
||||
sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`,
|
||||
),
|
||||
)
|
||||
.limit(500);
|
||||
|
||||
for (const item of apiLogs) {
|
||||
const responsePayload = replaceDeepUrls(
|
||||
item.responsePayload,
|
||||
sourceUrl,
|
||||
targetUrl
|
||||
);
|
||||
const generatedUrls = replaceDeepUrls(
|
||||
item.generatedImageUrls,
|
||||
sourceUrl,
|
||||
targetUrl
|
||||
);
|
||||
const responsePayload = replaceDeepUrls(item.responsePayload, sourceUrl, targetUrl);
|
||||
const generatedUrls = replaceDeepUrls(item.generatedImageUrls, sourceUrl, targetUrl);
|
||||
if (!responsePayload.changed && !generatedUrls.changed) continue;
|
||||
|
||||
await db
|
||||
|
||||
BIN
public/fonts/xmind/NeverMind-Bold.ttf
Normal file
BIN
public/fonts/xmind/NeverMind-Extrabold.ttf
Normal file
BIN
public/fonts/xmind/NeverMind-Medium.ttf
Normal file
BIN
public/fonts/xmind/NeverMind-Regular.ttf
Normal file
5
public/fonts/xmind/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Xmind 画布字体
|
||||
|
||||
来自本机 Xmind 26.04.01337 安装包 `/static/snowbird/resource/fonts/files/` 的 NeverMind 字体,按用户要求读取客户端资源用于本地界面对照。包含 Regular(400)、Medium(500)、Bold(700)、Extrabold(800)。
|
||||
|
||||
流程白板在导入前等待字体加载,并以画布字体计算主题文字尺寸,避免系统字体测量与实际渲染不一致导致标题换行裁切。
|
||||
16
public/icons/lovart/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
# UI reference icons
|
||||
|
||||
These vector shapes were captured from the rendered Lovart UI at the user-selected reference:
|
||||
https://www.lovart.ai/canvas?projectId=2rqrq08o10cg
|
||||
|
||||
Only SVG drawing elements and local fragment references are retained. No scripts, event attributes, external references, account data or authentication data are included.
|
||||
|
||||
The model families are GPT/OpenAI, Nano Banana, Seedream, Flux, Luma, Midjourney, Ideogram and Recraft. Model IDs, parameter handling and provider calls remain in the existing application code. Other model names retain their configured avatar, with a neutral icon when an avatar is unavailable.
|
||||
|
||||
Selected-image toolbar and overflow-menu shapes were captured from the same reference with a temporary test image: annotation, HD upscale, background removal, eraser, layer extraction, text editing, angle editing, motion, more, download, object move, mockup, outpaint, adjustment, crop, vector and flip/rotate. The toolbar keeps the existing configured actions and custom icon overrides.
|
||||
|
||||
The local editing icon is an application-neutral selection/brush drawing rather than the reference service’s branded quick-edit logo. Toolbar customization uses local icons and stores only tool order, pinned tools and label visibility in the browser.
|
||||
|
||||
Application-specific actions use local neutral icons adapted from the existing Lucide geometry: pan, text, sparkle, tutorial, video, toolbox, profile, settings and layout replacement. Their 1.5-unit strokes match the reference icon set. Home navigation and the main image-canvas toolbar render at 20px, including configured icon overrides in the home navigation.
|
||||
|
||||
The style controls and image enhancement actions use local slider and framed-landscape drawings in place of sparkle symbols, with the same 24-unit viewBox, 1.5-unit strokes and 20px toolbar size.
|
||||
1
public/icons/lovart/arrow-up.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M11.25 20V5.81l-4.72 4.72a.75.75 0 1 1-1.06-1.06l6-6 .056-.052a.75.75 0 0 1 1.004.052l6 6a.75.75 0 1 1-1.06 1.06l-4.72-4.72V20a.75.75 0 0 1-1.5 0" /></svg>
|
||||
|
After Width: | Height: | Size: 299 B |
1
public/icons/lovart/brand-filled.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M13.621 13.23a4.11 4.11 0 1 1-3.007 1.31l1.067-.389a2.53 2.53 0 0 0 1.144-.844q.387-.075.796-.076M5.012 6.236c.345-.125.73-.057 1.011.18l5.454 4.574a1.028 1.028 0 0 1-.31 1.752l-6.688 2.435a1.027 1.027 0 0 1-1.364-1.144l1.237-7.01c.063-.36.315-.661.66-.787M13.364 2.837a1.03 1.03 0 0 1 .78-.103L20.1 4.33c.547.147.873.71.726 1.258l-1.595 5.955a1.03 1.03 0 0 1-1.26.726l-4.752-1.274a2.53 2.53 0 0 0-.778-1.154l-1.033-.866 1.479-5.514c.07-.263.242-.488.478-.624" /></svg>
|
||||
|
After Width: | Height: | Size: 613 B |
1
public/icons/lovart/brand.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M13.234 13.236a4.11 4.11 0 0 1 3.897 4.104l-.005.211a4.11 4.11 0 0 1-8.209 0l-.005-.21a4.11 4.11 0 0 1 4.11-4.11zm-.212 1.495a2.61 2.61 0 1 0 0 5.219 2.61 2.61 0 0 0 0-5.219m-8.51-8.495c.345-.126.73-.057 1.012.179l5.453 4.575c.28.236.414.604.35.966l-.032.133a1.03 1.03 0 0 1-.628.654L3.98 15.177l-.131.038c-.265.059-.542.01-.77-.135l-.111-.081a1.03 1.03 0 0 1-.352-.966l1.237-7.01c.064-.361.315-.662.66-.787m-.277 7.25 5.149-1.874-4.198-3.523zm9.53-10.649a1.03 1.03 0 0 1 .78-.102L20.5 4.33c.548.147.873.71.727 1.257l-1.596 5.955-.031.096a1.03 1.03 0 0 1-.447.528l-.091.046a1.03 1.03 0 0 1-.69.056l-5.954-1.595a1.03 1.03 0 0 1-.726-1.258l1.595-5.954c.07-.264.243-.488.479-.625m-.503 6.51 5.041 1.35 1.35-5.041-5.04-1.35z" /></svg>
|
||||
|
After Width: | Height: | Size: 874 B |
1
public/icons/lovart/branding.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 18 18"><path fill="currentColor" d="M11.588 2.25c.62 0 1.12 0 1.523.032.41.034.77.105 1.103.275.53.27.96.7 1.23 1.229.169.333.24.693.274 1.103.033.403.032.903.032 1.523v5.176c0 .62 0 1.12-.032 1.523-.034.41-.105.77-.275 1.103-.27.53-.7.96-1.229 1.23-.333.169-.693.24-1.103.274-.403.033-.903.032-1.523.032H6.412c-.62 0-1.12 0-1.523-.032-.41-.034-.77-.105-1.103-.275-.53-.27-.96-.7-1.23-1.229-.169-.333-.24-.693-.274-1.103-.033-.403-.032-.903-.032-1.523V6.412c0-.62 0-1.12.032-1.523.034-.41.105-.77.275-1.103.27-.53.7-.96 1.229-1.23.333-.169.693-.24 1.103-.274.403-.033.903-.032 1.523-.032zM9.563 14.625h2.025c.639 0 1.085 0 1.432-.029.34-.027.536-.08.684-.155.317-.162.575-.42.737-.737.076-.149.128-.344.155-.684.029-.347.029-.793.029-1.432v-.338H9.563zm-6.188-6.75v3.713c0 .639 0 1.085.029 1.432.027.34.08.536.155.684.162.317.42.575.737.737.149.076.344.128.684.155.347.029.793.029 1.432.029h2.026v-6.75zm6.188 2.25h5.062V6.412c0-.639 0-1.085-.029-1.432-.027-.34-.08-.535-.155-.684a1.7 1.7 0 0 0-.737-.737c-.149-.076-.344-.128-.684-.155-.347-.029-.793-.029-1.432-.029H9.563zm-3.15-6.75c-.64 0-1.086 0-1.433.029-.34.027-.535.08-.684.155-.317.162-.575.42-.737.737-.076.149-.128.344-.155.684-.029.347-.029.793-.029 1.432v.338h5.063V3.375z" /></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/icons/lovart/chevron-down.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M15.47 9.47a.75.75 0 1 1 1.06 1.06l-2.94 2.94a2.25 2.25 0 0 1-3.18 0l-2.94-2.94a.75.75 0 1 1 1.06-1.06l2.94 2.94a.75.75 0 0 0 1.06 0z" /></svg>
|
||||
|
After Width: | Height: | Size: 287 B |
1
public/icons/lovart/clipper.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 18 18"><path fill="currentColor" d="M10.688 9.75a.562.562 0 1 1 0 1.125H7.312a.562.562 0 1 1 0-1.125z" /><path fill="currentColor" fill-rule="evenodd" d="M14.16 3.059a.563.563 0 0 1 .93.632L13.006 6.75h1.431c.311 0 .563.252.563.563v6.375a2.813 2.813 0 0 1-2.812 2.812H5.812A2.813 2.813 0 0 1 3 13.688V7.312c0-.31.252-.562.563-.562h8.093a.6.6 0 0 1 .053-.094zM4.126 13.687c0 .933.756 1.688 1.688 1.688h6.375c.931 0 1.687-.755 1.687-1.687V7.875h-9.75z" clip-rule="evenodd" /><path fill="currentColor" d="M5.356 2.786a.3.3 0 0 1 .537 0l.312.624a.3.3 0 0 0 .134.134l.624.312a.3.3 0 0 1 0 .537l-.358.179-.266.133a.3.3 0 0 0-.134.134l-.312.624-.023.039a.3.3 0 0 1-.49 0l-.024-.039-.312-.624a.3.3 0 0 0-.092-.109l-.042-.025-.624-.312a.3.3 0 0 1 0-.537l.624-.312a.3.3 0 0 0 .134-.134zM9.415 1.42a.375.375 0 0 1 .67 0l.36.718a.38.38 0 0 0 .167.168l.717.359a.375.375 0 0 1 0 .67l-.717.36a.38.38 0 0 0-.168.167l-.359.717a.375.375 0 0 1-.67 0l-.36-.717a.4.4 0 0 0-.115-.136l-.052-.032-.717-.359a.375.375 0 0 1 0-.67l.717-.36a.38.38 0 0 0 .168-.167z" /></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
public/icons/lovart/close.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M15.005 7.934a.75.75 0 0 1 1.061 1.06L13.06 12l3.006 3.005a.75.75 0 0 1-1.06 1.061L12 13.06l-3.005 3.006a.75.75 0 0 1-1.06-1.06L10.938 12 7.934 8.995a.75.75 0 1 1 1.06-1.06L12 10.938z" /></svg>
|
||||
|
After Width: | Height: | Size: 337 B |
1
public/icons/lovart/collapse.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M18 3.25A3.75 3.75 0 0 1 21.75 7v10A3.75 3.75 0 0 1 18 20.75H6A3.75 3.75 0 0 1 2.25 17V7A3.75 3.75 0 0 1 6 3.25zm-8.75 16H18A2.25 2.25 0 0 0 20.25 17V7A2.25 2.25 0 0 0 18 4.75H9.25zM6 4.75A2.25 2.25 0 0 0 3.75 7v10A2.25 2.25 0 0 0 6 19.25h1.75V4.75z" /></svg>
|
||||
|
After Width: | Height: | Size: 403 B |
1
public/icons/lovart/comment.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0.9" d="M20.5 12c0-4.38-3.766-8-8.5-8s-8.5 3.62-8.5 8c0 1.372.367 2.662 1.014 3.791.482.842.853 1.945.524 3.07q-.076.26-.161.517a16 16 0 0 0 1.241-.205c.922-.19 1.86-.072 2.7.248A9 9 0 0 0 12 20c4.734 0 8.5-3.62 8.5-8m1.5 0c0 5.285-4.517 9.5-10 9.5-1.31 0-2.564-.24-3.714-.677-.615-.233-1.262-.305-1.865-.18a18.5 18.5 0 0 1-2.597.34.75.75 0 0 1-.747-1.016c.196-.51.373-1.018.522-1.527l.027-.106c.116-.537-.047-1.157-.414-1.797A9.1 9.1 0 0 1 2 12c0-5.285 4.517-9.5 10-9.5S22 6.715 22 12" /></svg>
|
||||
|
After Width: | Height: | Size: 631 B |
1
public/icons/lovart/commerce.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 18 18"><path fill="currentColor" d="M13.652 2.25c1.27 0 2.383.852 2.715 2.078l.416 1.538a2.52 2.52 0 0 1-.846 2.629v4.443a2.813 2.813 0 0 1-2.813 2.812H5.25a2.81 2.81 0 0 1-2.812-2.812V8.495a2.52 2.52 0 0 1-.846-2.629l.416-1.538A2.81 2.81 0 0 1 4.722 2.25zm-.991 5.928c-.419.5-1.035.822-1.738.822a2.26 2.26 0 0 1-1.736-.821c-.418.5-1.035.82-1.737.821-.702 0-1.32-.322-1.738-.822a2.4 2.4 0 0 1-2.15.799v3.96c0 .933.755 1.688 1.687 1.688h7.875c.932 0 1.688-.756 1.688-1.687V8.977a2.394 2.394 0 0 1-2.151-.798M4.72 3.375c-.761 0-1.43.51-1.629 1.247l-.416 1.54v.002c-.243.885.4 1.711 1.212 1.711.713 0 1.263-.522 1.263-1.246v-.01a.563.563 0 0 1 1.125.006v.008c.002.708.548 1.242 1.174 1.242.627 0 1.174-.536 1.174-1.246v-.008a.563.563 0 0 1 1.125 0v.008c0 .71.547 1.246 1.174 1.246s1.174-.536 1.174-1.246v-.008a.563.563 0 0 1 1.125 0v.008c0 .724.55 1.246 1.263 1.246.811 0 1.456-.826 1.213-1.71v-.003l-.417-1.54a1.69 1.69 0 0 0-1.63-1.247z" /></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/icons/lovart/connections.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 18 18"><path fill="currentColor" d="M2.49 10.664a2.623 2.623 0 0 1 3.442 3.804 5.6 5.6 0 0 0 3.065.907q.542 0 1.055-.099a.562.562 0 0 1 .21 1.106q-.617.117-1.265.118a6.72 6.72 0 0 1-3.932-1.264 2.623 2.623 0 0 1-2.574-4.572m10.806-5.408a.563.563 0 0 1 .794.055 6.73 6.73 0 0 1 1.58 5.46 2.624 2.624 0 0 1-2.79 4.44 2.625 2.625 0 0 1 1.712-4.866q.032-.296.033-.599a5.6 5.6 0 0 0-1.383-3.696.563.563 0 0 1 .054-.794m-8.194 6.932a1.5 1.5 0 1 0-2.598 1.5 1.5 1.5 0 0 0 2.598-1.5m9.84-.55a1.5 1.5 0 1 0-1.5 2.6 1.5 1.5 0 0 0 1.5-2.6M9 1.314a2.625 2.625 0 1 1-2.51 3.391 5.65 5.65 0 0 0-2.843 3.282.563.563 0 1 1-1.068-.35 6.77 6.77 0 0 1 3.835-4.131A2.62 2.62 0 0 1 9 1.313m0 1.125a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3" /></svg>
|
||||
|
After Width: | Height: | Size: 811 B |