feat(canvas): 绘图工具 - 集成 Excalidraw 并支持后台切换默认引擎
This commit is contained in:
@@ -1,17 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { App, Modal } from "antd";
|
||||
import { Check, Maximize2, X } from "lucide-react";
|
||||
import { Tldraw, createTLStore, getSnapshot, loadSnapshot } from "tldraw";
|
||||
import type { Editor } from "tldraw";
|
||||
import "tldraw/tldraw.css";
|
||||
|
||||
import type { CanvasDrawingEditorHandle } from "@/components/canvas/canvas-drawing-editor-types";
|
||||
import { drawingEngineForNode, drawingEngineLabel, isDrawingEngineAvailable } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import { loadCanvasDrawing, saveCanvasDrawing, type CanvasDrawingSnapshot } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { loadCanvasDrawing, saveCanvasDrawing, type CanvasDrawingRenderDraft, type CanvasDrawingSnapshot } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
const SINGLE_PAGE_OPTIONS = { maxPages: 1 } as const;
|
||||
const DRAWING_RENDER_MAX_DIMENSION = 2048;
|
||||
const DRAWING_RENDER_PADDING = 24;
|
||||
const CanvasDrawingTldrawEditor = lazy(() => import("@/components/canvas/canvas-drawing-tldraw-editor").then((module) => ({ default: module.CanvasDrawingTldrawEditor })));
|
||||
const CanvasDrawingExcalidrawEditor = lazy(() => import("@/components/canvas/canvas-drawing-excalidraw-editor").then((module) => ({ default: module.CanvasDrawingExcalidrawEditor })));
|
||||
|
||||
type CanvasDrawingEditorModalProps = {
|
||||
open: boolean;
|
||||
@@ -24,34 +22,44 @@ type CanvasDrawingEditorModalProps = {
|
||||
export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSaved }: CanvasDrawingEditorModalProps) {
|
||||
const { message } = App.useApp();
|
||||
const colorScheme = useThemeStore((state) => state.theme);
|
||||
const store = useMemo(() => createTLStore(), [node?.id]);
|
||||
const engine = drawingEngineForNode(node);
|
||||
const currentRef = useRef<CanvasDrawingSnapshot | null>(null);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const editorRef = useRef<CanvasDrawingEditorHandle | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<unknown>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !node?.metadata?.drawingId) return;
|
||||
let cancelled = false;
|
||||
setLoaded(false);
|
||||
setReady(false);
|
||||
setLoadError("");
|
||||
setSnapshot(null);
|
||||
currentRef.current = null;
|
||||
void loadCanvasDrawing(projectId, node.metadata.drawingId).then((saved) => {
|
||||
if (cancelled) return;
|
||||
if (saved && saved.engine !== engine) throw new Error(`绘图节点标记为 ${drawingEngineLabel(engine)},但文档属于 ${drawingEngineLabel(saved.engine)}`);
|
||||
currentRef.current = saved;
|
||||
if (saved?.snapshot) loadSnapshot(store, saved.snapshot as never);
|
||||
setReady(true);
|
||||
}).catch(() => {
|
||||
if (!cancelled) setReady(true);
|
||||
setSnapshot(saved?.snapshot || null);
|
||||
setLoaded(true);
|
||||
}).catch((error) => {
|
||||
if (cancelled) return;
|
||||
const detail = error instanceof Error ? error.message : "本地绘图文档无法读取";
|
||||
setLoadError(detail);
|
||||
message.error(`绘图加载失败:${detail}`);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, projectId, node?.metadata?.drawingId, store]);
|
||||
}, [engine, message, node?.metadata?.drawingId, open, projectId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!node?.metadata?.drawingId || !ready) return false;
|
||||
if (!node?.metadata?.drawingId || !ready || !editorRef.current) return false;
|
||||
setSaving(true);
|
||||
try {
|
||||
const render = await createDrawingRender(editorRef.current);
|
||||
const saved = await saveCanvasDrawing(projectId, node.metadata.drawingId, getSnapshot(store), currentRef.current, render?.blob || null, render);
|
||||
const draft = await editorRef.current.createSave();
|
||||
const saved = await saveCanvasDrawing(projectId, node.metadata.drawingId, engine, draft.snapshot, currentRef.current, draft.preview, draft.render);
|
||||
currentRef.current = saved;
|
||||
onSaved(node.id, saved);
|
||||
return true;
|
||||
@@ -64,86 +72,35 @@ export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSav
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
if (ready && !(await handleSave())) return;
|
||||
if (!loadError && ready && !(await handleSave())) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const unavailable = !isDrawingEngineAvailable(engine);
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => void handleClose()}
|
||||
footer={null}
|
||||
closable={false}
|
||||
destroyOnHidden
|
||||
width="100vw"
|
||||
centered
|
||||
styles={{ body: { padding: 0 }, container: { padding: 0, overflow: "hidden" } }}
|
||||
className="canvas-drawing-editor-modal"
|
||||
>
|
||||
<Modal open={open} onCancel={() => void handleClose()} footer={null} closable={false} destroyOnHidden width="100vw" centered styles={{ body: { padding: 0 }, container: { padding: 0, overflow: "hidden" } }} className="canvas-drawing-editor-modal">
|
||||
<div className="flex h-[min(92dvh,980px)] flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4" style={{ background: "var(--background)", borderColor: "var(--border)" }}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Maximize2 className="size-4 opacity-55" />
|
||||
<span className="truncate text-sm font-semibold">{node?.title || "绘图"}</span>
|
||||
<span className="text-[11px] opacity-45">{ready ? "已加载" : "正在加载"}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2"><Maximize2 className="size-4 opacity-55" /><span className="truncate text-sm font-semibold">{node?.title || "绘图"}</span><span className="text-[11px] opacity-45">{drawingEngineLabel(engine)} · {ready ? "已加载" : "正在加载"}</span></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition hover:bg-black/5 disabled:opacity-45 dark:hover:bg-white/10" disabled={!ready || saving} onClick={() => void handleSave()}>
|
||||
<Check className="size-3.5" />{saving ? "保存中" : "保存绘图"}
|
||||
</button>
|
||||
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition hover:bg-black/5 disabled:opacity-45 dark:hover:bg-white/10" disabled={!ready || saving || unavailable} onClick={() => void handleSave()}><Check className="size-3.5" />{saving ? "保存中" : "保存绘图"}</button>
|
||||
<button type="button" className="grid size-8 place-items-center rounded-md border transition hover:bg-black/5 dark:hover:bg-white/10" aria-label="关闭绘图编辑器" onClick={() => void handleClose()}><X className="size-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{ready ? (
|
||||
<Tldraw
|
||||
store={store}
|
||||
locale="zh-cn"
|
||||
colorScheme={colorScheme}
|
||||
options={SINGLE_PAGE_OPTIONS}
|
||||
licenseKey={import.meta.env.VITE_TLDRAW_LICENSE_KEY || undefined}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor;
|
||||
// 每个绘图节点只允许一个页面;旧快照若包含多页,也在进入编辑器时收敛为首页。
|
||||
const [primaryPage, ...extraPages] = editor.getPages();
|
||||
if (primaryPage) editor.setCurrentPage(primaryPage.id);
|
||||
extraPages.forEach((page) => editor.deletePage(page.id));
|
||||
editor.setCurrentTool("draw");
|
||||
return () => {
|
||||
if (editorRef.current === editor) editorRef.current = null;
|
||||
};
|
||||
}}
|
||||
/>
|
||||
) : <div className="grid h-full place-items-center text-sm opacity-55">正在准备绘图画布...</div>}
|
||||
{unavailable ? <EditorState title="tldraw 未授权" detail="当前生产构建没有配置有效的 tldraw License Key。" /> : loadError ? <EditorState title="绘图无法打开" detail={loadError} /> : loaded ? (
|
||||
<Suspense fallback={<EditorState title="正在载入绘图工具" />}>
|
||||
{engine === "excalidraw"
|
||||
? <CanvasDrawingExcalidrawEditor ref={editorRef} snapshot={snapshot} colorScheme={colorScheme} onReady={() => setReady(true)} />
|
||||
: <CanvasDrawingTldrawEditor ref={editorRef} snapshot={snapshot} colorScheme={colorScheme} onReady={() => setReady(true)} />}
|
||||
</Suspense>
|
||||
) : <EditorState title="正在准备绘图画布" />}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
async function createDrawingRender(editor: Editor | null): Promise<CanvasDrawingRenderDraft | null> {
|
||||
if (!editor) throw new Error("绘图编辑器尚未准备完成");
|
||||
const shapeIds = [...editor.getCurrentPageShapeIds()];
|
||||
if (!shapeIds.length) return null;
|
||||
const bounds = editor.getShapesPageBounds(shapeIds);
|
||||
if (!bounds) throw new Error("无法读取绘图内容边界");
|
||||
|
||||
const sourceDimension = Math.max(bounds.width, bounds.height) + DRAWING_RENDER_PADDING * 2;
|
||||
const scale = Math.min(4, DRAWING_RENDER_MAX_DIMENSION / Math.max(1, sourceDimension));
|
||||
const image = await editor.toImage(shapeIds, {
|
||||
format: "png",
|
||||
background: true,
|
||||
padding: DRAWING_RENDER_PADDING,
|
||||
scale,
|
||||
pixelRatio: 1,
|
||||
darkMode: false,
|
||||
});
|
||||
return {
|
||||
blob: image.blob,
|
||||
pageId: editor.getCurrentPageId(),
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
mimeType: image.blob.type || "image/png",
|
||||
background: "white",
|
||||
};
|
||||
function EditorState({ title, detail }: { title: string; detail?: string }) {
|
||||
return <div className="grid h-full place-items-center px-6 text-center"><div><div className="text-sm font-medium">{title}</div>{detail ? <div className="mt-1 text-xs text-foreground/50">{detail}</div> : null}</div></div>;
|
||||
}
|
||||
|
||||
15
web/src/components/canvas/canvas-drawing-editor-types.ts
Normal file
15
web/src/components/canvas/canvas-drawing-editor-types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { CanvasDrawingRenderDraft } from "@/lib/canvas/canvas-drawing-storage";
|
||||
|
||||
export type CanvasDrawingEditorHandle = {
|
||||
createSave: () => Promise<{
|
||||
snapshot: unknown;
|
||||
preview: Blob | null;
|
||||
render: CanvasDrawingRenderDraft | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CanvasDrawingEditorProps = {
|
||||
snapshot: unknown;
|
||||
colorScheme: "light" | "dark";
|
||||
onReady: () => void;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
import { Excalidraw, exportToBlob, getNonDeletedElements, serializeAsJSON } from "@excalidraw/excalidraw";
|
||||
import type { ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types";
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
|
||||
import type { CanvasDrawingEditorHandle, CanvasDrawingEditorProps } from "@/components/canvas/canvas-drawing-editor-types";
|
||||
|
||||
const DRAWING_RENDER_MAX_DIMENSION = 2048;
|
||||
const DRAWING_RENDER_PADDING = 24;
|
||||
|
||||
export const CanvasDrawingExcalidrawEditor = forwardRef<CanvasDrawingEditorHandle, CanvasDrawingEditorProps>(function CanvasDrawingExcalidrawEditor({ snapshot, colorScheme, onReady }, ref) {
|
||||
const apiRef = useRef<ExcalidrawImperativeAPI | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
createSave: async () => {
|
||||
const api = apiRef.current;
|
||||
if (!api) throw new Error("Excalidraw 编辑器尚未准备完成");
|
||||
const elements = api.getSceneElementsIncludingDeleted();
|
||||
const appState = api.getAppState();
|
||||
const files = api.getFiles();
|
||||
const serialized = serializeAsJSON(elements, appState, files, "local");
|
||||
const savedSnapshot = JSON.parse(serialized) as unknown;
|
||||
const visibleElements = getNonDeletedElements(elements);
|
||||
if (!visibleElements.length) return { snapshot: savedSnapshot, preview: null, render: null };
|
||||
|
||||
let outputWidth = 1;
|
||||
let outputHeight = 1;
|
||||
const blob = await exportToBlob({
|
||||
elements: visibleElements,
|
||||
appState: { ...appState, exportBackground: true, exportWithDarkMode: false, viewBackgroundColor: "#ffffff" },
|
||||
files,
|
||||
mimeType: "image/png",
|
||||
exportPadding: DRAWING_RENDER_PADDING,
|
||||
getDimensions: (width, height) => {
|
||||
const scale = Math.min(4, DRAWING_RENDER_MAX_DIMENSION / Math.max(1, width, height));
|
||||
outputWidth = Math.max(1, Math.round(width * scale));
|
||||
outputHeight = Math.max(1, Math.round(height * scale));
|
||||
return { width: outputWidth, height: outputHeight, scale };
|
||||
},
|
||||
});
|
||||
const render = { blob, pageId: "excalidraw-page", width: outputWidth, height: outputHeight, mimeType: blob.type || "image/png", background: "white" as const };
|
||||
return { snapshot: savedSnapshot, preview: blob, render };
|
||||
},
|
||||
}), []);
|
||||
|
||||
const initialData = snapshot && typeof snapshot === "object"
|
||||
? { ...(snapshot as ExcalidrawInitialDataState), scrollToContent: true }
|
||||
: { elements: [], appState: { viewBackgroundColor: "#ffffff" }, files: {}, scrollToContent: true };
|
||||
|
||||
return (
|
||||
<Excalidraw
|
||||
initialData={initialData}
|
||||
langCode="zh-CN"
|
||||
theme={colorScheme}
|
||||
autoFocus
|
||||
excalidrawAPI={(api) => {
|
||||
apiRef.current = api;
|
||||
api.setActiveTool({ type: "freedraw" });
|
||||
onReady();
|
||||
}}
|
||||
UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
64
web/src/components/canvas/canvas-drawing-tldraw-editor.tsx
Normal file
64
web/src/components/canvas/canvas-drawing-tldraw-editor.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { Tldraw, createTLStore, getSnapshot, loadSnapshot } from "tldraw";
|
||||
import type { Editor } from "tldraw";
|
||||
import "tldraw/tldraw.css";
|
||||
|
||||
import type { CanvasDrawingEditorHandle, CanvasDrawingEditorProps } from "@/components/canvas/canvas-drawing-editor-types";
|
||||
|
||||
const SINGLE_PAGE_OPTIONS = { maxPages: 1 } as const;
|
||||
const DRAWING_RENDER_MAX_DIMENSION = 2048;
|
||||
const DRAWING_RENDER_PADDING = 24;
|
||||
|
||||
export const CanvasDrawingTldrawEditor = forwardRef<CanvasDrawingEditorHandle, CanvasDrawingEditorProps>(function CanvasDrawingTldrawEditor({ snapshot, colorScheme, onReady }, ref) {
|
||||
const store = useMemo(() => {
|
||||
const next = createTLStore();
|
||||
if (snapshot) loadSnapshot(next, snapshot as never);
|
||||
return next;
|
||||
}, [snapshot]);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
createSave: async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) throw new Error("tldraw 编辑器尚未准备完成");
|
||||
const shapeIds = [...editor.getCurrentPageShapeIds()];
|
||||
if (!shapeIds.length) return { snapshot: getSnapshot(store), preview: null, render: null };
|
||||
const bounds = editor.getShapesPageBounds(shapeIds);
|
||||
if (!bounds) throw new Error("无法读取 tldraw 绘图边界");
|
||||
const sourceDimension = Math.max(bounds.width, bounds.height) + DRAWING_RENDER_PADDING * 2;
|
||||
const scale = Math.min(4, DRAWING_RENDER_MAX_DIMENSION / Math.max(1, sourceDimension));
|
||||
const image = await editor.toImage(shapeIds, { format: "png", background: true, padding: DRAWING_RENDER_PADDING, scale, pixelRatio: 1, darkMode: false });
|
||||
const render = {
|
||||
blob: image.blob,
|
||||
pageId: editor.getCurrentPageId(),
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
mimeType: image.blob.type || "image/png",
|
||||
background: "white" as const,
|
||||
};
|
||||
return { snapshot: getSnapshot(store), preview: image.blob, render };
|
||||
},
|
||||
}), [store]);
|
||||
|
||||
return (
|
||||
<Tldraw
|
||||
store={store}
|
||||
locale="zh-cn"
|
||||
colorScheme={colorScheme}
|
||||
options={SINGLE_PAGE_OPTIONS}
|
||||
licenseKey={import.meta.env.VITE_TLDRAW_LICENSE_KEY || undefined}
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor;
|
||||
// 绘图节点只保留单页,避免预览和生成引用出现页选择歧义。
|
||||
const [primaryPage, ...extraPages] = editor.getPages();
|
||||
if (primaryPage) editor.setCurrentPage(primaryPage.id);
|
||||
extraPages.forEach((page) => editor.deletePage(page.id));
|
||||
editor.setCurrentTool("draw");
|
||||
onReady();
|
||||
return () => {
|
||||
if (editorRef.current === editor) editorRef.current = null;
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user