feat(canvas): 绘图工具 - 集成 Excalidraw 并支持后台切换默认引擎

This commit is contained in:
ddCat committed 2026-08-03 14:58:49 +08:00
1 parent 7fa5d7215a
commit 6058602bf2
28 files changed
+1617 -177

No files matched your search

@@ -0,0 +1,26 @@
import type { CanvasNodeData } from "@/types/canvas";
export type CanvasDrawingEngine = "tldraw" | "excalidraw";
export type CanvasDrawingEngineSetting = {
defaultEngine: CanvasDrawingEngine;
configured?: boolean;
updatedBy?: string;
createdAt?: string;
updatedAt?: string;
};
export const DEFAULT_DRAWING_ENGINE: CanvasDrawingEngine = "excalidraw";
export function drawingEngineForNode(node?: Pick<CanvasNodeData, "metadata"> | null): CanvasDrawingEngine {
// 旧绘图节点没有引擎标记,其快照只能由 tldraw 读取。
return node?.metadata?.drawingEngine === "excalidraw" ? "excalidraw" : "tldraw";
}
export function isDrawingEngineAvailable(engine: CanvasDrawingEngine) {
return engine === "excalidraw" || import.meta.env.DEV || Boolean(import.meta.env.VITE_TLDRAW_LICENSE_KEY);
}
export function drawingEngineLabel(engine: CanvasDrawingEngine) {
return engine === "excalidraw" ? "Excalidraw" : "tldraw";
}
@@ -0,0 +1,47 @@
import { convertToExcalidrawElements } from "@excalidraw/excalidraw";
import type { FileId } from "@excalidraw/excalidraw/element/types";
import type { BinaryFileData, BinaryFiles, DataURL } from "@excalidraw/excalidraw/types";
const INITIAL_DRAWING_SHAPE_MAX_DIMENSION = 1200;
type DrawingImageSource = {
dataUrl: string;
width: number;
height: number;
mimeType: string;
name: string;
};
export function createExcalidrawDrawingFromImage(source: DrawingImageSource) {
const now = Date.now();
const fileId = crypto.randomUUID() as FileId;
const scale = Math.min(1, INITIAL_DRAWING_SHAPE_MAX_DIMENSION / Math.max(source.width, source.height));
const width = Math.max(1, Math.round(source.width * scale));
const height = Math.max(1, Math.round(source.height * scale));
const elements = convertToExcalidrawElements([{
type: "image",
x: -width / 2,
y: -height / 2,
width,
height,
fileId,
status: "saved",
scale: [1, 1],
crop: null,
}]);
const files: BinaryFiles = {
[fileId]: {
id: fileId,
dataURL: source.dataUrl as DataURL,
mimeType: source.mimeType as BinaryFileData["mimeType"],
created: now,
lastRetrieved: now,
},
};
const snapshot = {
elements,
appState: { viewBackgroundColor: "#ffffff" },
files,
};
return { snapshot, pageId: "excalidraw-page" };
}
+33 -60
View File
@@ -1,12 +1,13 @@
import localforage from "localforage";
import { AssetRecordType, createShapeId, createTLStore, type TLImageShape } from "tldraw";
import type { CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
import { readImageMeta } from "@/lib/image-utils";
import { getActiveUserScope } from "@/lib/user-scope";
import { imageToDataUrl } from "@/services/image-storage";
export type CanvasDrawingSnapshot = {
version: 1;
version: 2;
engine: CanvasDrawingEngine;
snapshot: unknown;
revision: number;
updatedAt: string;
@@ -36,7 +37,8 @@ const drawingPreviewStore = localforage.createInstance({ name: "infinite-canvas"
const drawingRenderStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_generation_renders" });
const INITIAL_DRAWING_RENDER_MAX_DIMENSION = 2048;
const INITIAL_DRAWING_RENDER_PADDING = 24;
const INITIAL_DRAWING_SHAPE_MAX_DIMENSION = 1200;
type LegacyCanvasDrawingSnapshot = Omit<CanvasDrawingSnapshot, "version" | "engine"> & { version: 1 };
function drawingKey(projectId: string, drawingId: string) {
return `${getActiveUserScope()}:${projectId}:${drawingId}`;
@@ -44,22 +46,25 @@ function drawingKey(projectId: string, drawingId: string) {
export async function loadCanvasDrawing(projectId: string, drawingId: string) {
if (!projectId || !drawingId) return null;
return drawingStore.getItem<CanvasDrawingSnapshot>(drawingKey(projectId, drawingId));
const saved = await drawingStore.getItem<CanvasDrawingSnapshot | LegacyCanvasDrawingSnapshot>(drawingKey(projectId, drawingId));
return normalizeCanvasDrawingSnapshot(saved);
}
export async function saveCanvasDrawing(
projectId: string,
drawingId: string,
engine: CanvasDrawingEngine,
snapshot: unknown,
previous?: CanvasDrawingSnapshot | null,
preview?: Blob | null,
render?: CanvasDrawingRenderDraft | null,
) {
const summary = summarizeCanvasDrawing(snapshot);
const summary = summarizeCanvasDrawing(engine, snapshot);
const revision = (previous?.revision || 0) + 1;
const updatedAt = new Date().toISOString();
const next: CanvasDrawingSnapshot = {
version: 1,
version: 2,
engine,
snapshot,
revision,
updatedAt,
@@ -83,66 +88,22 @@ export async function saveCanvasDrawing(
export async function createCanvasDrawingFromImage(
projectId: string,
drawingId: string,
engine: CanvasDrawingEngine,
image: { url: string; storageKey?: string; name: string; mimeType?: string },
) {
const dataUrl = await imageToDataUrl({ url: image.url, storageKey: image.storageKey, name: image.name, mimeType: image.mimeType });
if (!dataUrl?.startsWith("data:image/")) throw new Error("无法读取来源图片");
const [{ width, height, mimeType }, sourceBlob] = await Promise.all([
readImageMeta(dataUrl),
fetch(dataUrl).then((response) => response.blob()),
]);
const store = createTLStore();
const page = store.allRecords().find((record) => record.typeName === "page");
if (!page) throw new Error("无法初始化绘图页面");
const assetId = AssetRecordType.createId();
const shapeScale = Math.min(1, INITIAL_DRAWING_SHAPE_MAX_DIMENSION / Math.max(width, height));
const shapeWidth = Math.max(1, Math.round(width * shapeScale));
const shapeHeight = Math.max(1, Math.round(height * shapeScale));
const asset = AssetRecordType.create({
id: assetId,
type: "image",
props: {
w: width,
h: height,
name: image.name || "来源图片",
isAnimated: false,
mimeType: mimeType || sourceBlob.type || image.mimeType || "image/png",
src: dataUrl,
...(sourceBlob.size > 0 ? { fileSize: sourceBlob.size } : {}),
},
});
const shape: TLImageShape = {
id: createShapeId(),
typeName: "shape",
type: "image",
parentId: page.id,
index: "a1" as TLImageShape["index"],
x: -shapeWidth / 2,
y: -shapeHeight / 2,
rotation: 0,
isLocked: false,
opacity: 1,
props: {
w: shapeWidth,
h: shapeHeight,
playing: false,
url: "",
assetId,
crop: null,
flipX: false,
flipY: false,
altText: image.name || "来源图片",
},
meta: {},
};
store.put([asset, shape]);
const { width, height, mimeType } = await readImageMeta(dataUrl);
const source = { dataUrl, width, height, mimeType: mimeType || image.mimeType || "image/png", name: image.name || "来源图片" };
const document = engine === "excalidraw"
? (await import("@/lib/canvas/canvas-drawing-excalidraw-document")).createExcalidrawDrawingFromImage(source)
: await (await import("@/lib/canvas/canvas-drawing-tldraw-document")).createTldrawDrawingFromImage(source);
// 来源图必须进入绘图快照本身,不能继续依赖可能被替换或清理的原节点 URL。
try {
const render = await createInitialDrawingRender(dataUrl, width, height, page.id);
return await saveCanvasDrawing(projectId, drawingId, store.getStoreSnapshot(), null, render.blob, render);
const render = await createInitialDrawingRender(dataUrl, width, height, document.pageId);
return await saveCanvasDrawing(projectId, drawingId, engine, document.snapshot, null, render.blob, render);
} catch (error) {
await removeCanvasDrawing(projectId, drawingId).catch((cleanupError) => console.warn("清理失败的绘图初始化数据失败", cleanupError));
throw error;
@@ -195,10 +156,15 @@ export async function cloneCanvasDrawing(projectId: string, sourceDrawingId: str
url: render.url,
} satisfies CanvasDrawingRenderDraft
: undefined;
return saveCanvasDrawing(projectId, targetDrawingId, source.snapshot, null, preview || undefined, renderDraft);
return saveCanvasDrawing(projectId, targetDrawingId, source.engine, source.snapshot, null, preview || undefined, renderDraft);
}
export function summarizeCanvasDrawing(snapshot: unknown) {
export function summarizeCanvasDrawing(engine: CanvasDrawingEngine, snapshot: unknown) {
if (engine === "excalidraw") {
const root = snapshot && typeof snapshot === "object" ? snapshot as Record<string, unknown> : {};
const elements = Array.isArray(root.elements) ? root.elements : [];
return { shapeCount: elements.filter((element) => Boolean(element) && typeof element === "object" && !(element as { isDeleted?: boolean }).isDeleted).length, pageCount: 1 };
}
const root = snapshot && typeof snapshot === "object" ? snapshot as Record<string, unknown> : {};
const document = root.document && typeof root.document === "object" ? root.document as Record<string, unknown> : root;
const pages = pagesFromSnapshot(document);
@@ -208,6 +174,13 @@ export function summarizeCanvasDrawing(snapshot: unknown) {
return { shapeCount, pageCount: Math.max(pageCount, 1) };
}
function normalizeCanvasDrawingSnapshot(saved: CanvasDrawingSnapshot | LegacyCanvasDrawingSnapshot | null) {
if (!saved) return null;
if (saved.version === 1) return { ...saved, version: 2, engine: "tldraw" } satisfies CanvasDrawingSnapshot;
if (saved.version === 2 && (saved.engine === "tldraw" || saved.engine === "excalidraw")) return saved;
throw new Error("绘图文档版本或引擎无效");
}
function pagesFromSnapshot(document: Record<string, unknown>) {
const pages = document.pages;
return pages && typeof pages === "object" && !Array.isArray(pages) ? Object.keys(pages).length : 0;
@@ -0,0 +1,45 @@
import { AssetRecordType, createShapeId, createTLStore, type TLImageShape } from "tldraw";
const INITIAL_DRAWING_SHAPE_MAX_DIMENSION = 1200;
type DrawingImageSource = {
dataUrl: string;
width: number;
height: number;
mimeType: string;
name: string;
};
export async function createTldrawDrawingFromImage(source: DrawingImageSource) {
const sourceBlob = await fetch(source.dataUrl).then((response) => response.blob());
const store = createTLStore();
const page = store.allRecords().find((record) => record.typeName === "page");
if (!page) throw new Error("无法初始化 tldraw 绘图页面");
const assetId = AssetRecordType.createId();
const scale = Math.min(1, INITIAL_DRAWING_SHAPE_MAX_DIMENSION / Math.max(source.width, source.height));
const width = Math.max(1, Math.round(source.width * scale));
const height = Math.max(1, Math.round(source.height * scale));
const asset = AssetRecordType.create({
id: assetId,
type: "image",
props: {
w: source.width,
h: source.height,
name: source.name,
isAnimated: false,
mimeType: source.mimeType,
src: source.dataUrl,
...(sourceBlob.size > 0 ? { fileSize: sourceBlob.size } : {}),
},
});
const shape: TLImageShape = {
id: createShapeId(), typeName: "shape", type: "image", parentId: page.id,
index: "a1" as TLImageShape["index"], x: -width / 2, y: -height / 2, rotation: 0,
isLocked: false, opacity: 1,
props: { w: width, h: height, playing: false, url: "", assetId, crop: null, flipX: false, flipY: false, altText: source.name },
meta: {},
};
store.put([asset, shape]);
return { snapshot: store.getStoreSnapshot(), pageId: page.id };
}
+1 -1
View File
@@ -49,7 +49,7 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
}),
);
const data: CanvasExportFile = { app: "infinite-canvas", version: 3, exportedAt: new Date().toISOString(), projects: exportedProjects };
const data: CanvasExportFile = { app: "infinite-canvas", version: 4, exportedAt: new Date().toISOString(), projects: exportedProjects };
const zip = await createZip([{ name: "projects.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, `${safeFileName(fileName)}.zip`);
}
+1
View File
@@ -25,6 +25,7 @@ export async function applyUserSession(payload: AuthSessionPayload) {
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
useUserStore.getState().setUser(payload.user);
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
await Promise.all([useCanvasStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate()]);
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });