feat(canvas): 绘图工具 - 集成 Excalidraw 并支持后台切换默认引擎
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 画布绘图节点支持 tldraw 与 Excalidraw 双引擎,管理员可配置新建默认工具,并保持历史绘图和导入导出兼容。
|
||||
|
||||
## v1.0.32
|
||||
|
||||
- 修复 GORM 技能总数查询将 `DISTINCT` 状态带入后续列表查询,导致 PostgreSQL 热门排序仍返回 `SQLSTATE 42P10` 的问题。
|
||||
|
||||
@@ -122,7 +122,12 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"user": publicUser, "systemChannels": channels, "runtimeLimits": limits})
|
||||
drawingEngine, err := svc.DrawingEngineSetting()
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"user": publicUser, "systemChannels": channels, "runtimeLimits": limits, "drawingEngine": drawingEngine})
|
||||
})
|
||||
r.GET("/channels/system", func(c *gin.Context) {
|
||||
if _, err := currentUser(c, svc); err != nil {
|
||||
@@ -449,6 +454,37 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.GET("/admin/settings/drawing-engine", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.AdminDrawingEngineSetting(user)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.PATCH("/admin/settings/drawing-engine", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.DrawingEngineSetting
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.UpdateDrawingEngineSetting(user, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.GET("/admin/settings/runtime-policy", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
114
backend/internal/service/drawing_engine.go
Normal file
114
backend/internal/service/drawing_engine.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const drawingEngineSettingKey = "drawing_engine"
|
||||
|
||||
const (
|
||||
DrawingEngineTldraw = "tldraw"
|
||||
DrawingEngineExcalidraw = "excalidraw"
|
||||
)
|
||||
|
||||
type DrawingEngineSetting struct {
|
||||
DefaultEngine string `json:"defaultEngine"`
|
||||
}
|
||||
|
||||
type PublicDrawingEngineSetting struct {
|
||||
DrawingEngineSetting
|
||||
Configured bool `json:"configured"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||
UpdatedAt time.Time `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
func defaultDrawingEngineSetting() DrawingEngineSetting {
|
||||
// 新部署默认使用真正开源的编辑器;历史节点没有引擎字段时仍由前端识别为 tldraw。
|
||||
return DrawingEngineSetting{DefaultEngine: DrawingEngineExcalidraw}
|
||||
}
|
||||
|
||||
func (s *Service) DrawingEngineSetting() (*PublicDrawingEngineSetting, error) {
|
||||
setting, value, err := s.readDrawingEngineSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicDrawingEngineSetting(setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) AdminDrawingEngineSetting(actor *model.User) (*PublicDrawingEngineSetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.DrawingEngineSetting()
|
||||
}
|
||||
|
||||
func (s *Service) UpdateDrawingEngineSetting(actor *model.User, value DrawingEngineSetting) (*PublicDrawingEngineSetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateDrawingEngineSetting(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, before, err := s.readDrawingEngineSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting := model.SystemSetting{Key: drawingEngineSettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID}
|
||||
if current != nil {
|
||||
setting.CreatedAt = current.CreatedAt
|
||||
}
|
||||
if err := s.repo.SaveSystemSetting(&setting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.appendAdminAudit(actor, "drawing_engine.update", "system_setting", drawingEngineSettingKey, "更新默认绘图工具", map[string]any{"before": before, "after": value}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return publicDrawingEngineSetting(&setting, value), nil
|
||||
}
|
||||
|
||||
func (s *Service) readDrawingEngineSetting() (*model.SystemSetting, DrawingEngineSetting, error) {
|
||||
setting, err := s.repo.SystemSetting(drawingEngineSettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, defaultDrawingEngineSetting(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, DrawingEngineSetting{}, err
|
||||
}
|
||||
value := DrawingEngineSetting{}
|
||||
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
|
||||
return nil, DrawingEngineSetting{}, errors.New("绘图工具配置格式无效")
|
||||
}
|
||||
if err := validateDrawingEngineSetting(value); err != nil {
|
||||
return nil, DrawingEngineSetting{}, err
|
||||
}
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func validateDrawingEngineSetting(value DrawingEngineSetting) error {
|
||||
if value.DefaultEngine != DrawingEngineTldraw && value.DefaultEngine != DrawingEngineExcalidraw {
|
||||
return BadAuthRequest("默认绘图工具必须是 tldraw 或 Excalidraw")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func publicDrawingEngineSetting(setting *model.SystemSetting, value DrawingEngineSetting) *PublicDrawingEngineSetting {
|
||||
result := &PublicDrawingEngineSetting{DrawingEngineSetting: value, Configured: setting != nil}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
21
backend/internal/service/drawing_engine_test.go
Normal file
21
backend/internal/service/drawing_engine_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultDrawingEngineUsesExcalidraw(t *testing.T) {
|
||||
setting := defaultDrawingEngineSetting()
|
||||
if setting.DefaultEngine != DrawingEngineExcalidraw {
|
||||
t.Fatalf("default engine = %q, want %q", setting.DefaultEngine, DrawingEngineExcalidraw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDrawingEngineSetting(t *testing.T) {
|
||||
for _, engine := range []string{DrawingEngineTldraw, DrawingEngineExcalidraw} {
|
||||
if err := validateDrawingEngineSetting(DrawingEngineSetting{DefaultEngine: engine}); err != nil {
|
||||
t.Fatalf("validate engine %q: %v", engine, err)
|
||||
}
|
||||
}
|
||||
if err := validateDrawingEngineSetting(DrawingEngineSetting{DefaultEngine: "unknown"}); err == nil {
|
||||
t.Fatal("validate unknown engine = nil")
|
||||
}
|
||||
}
|
||||
957
web/bun.lock
957
web/bun.lock
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@excalidraw/excalidraw": "0.18.1",
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@mediapipe/tasks-vision": "^0.10.35",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
26
web/src/lib/canvas/canvas-drawing-engine.ts
Normal file
26
web/src/lib/canvas/canvas-drawing-engine.ts
Normal file
@@ -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";
|
||||
}
|
||||
47
web/src/lib/canvas/canvas-drawing-excalidraw-document.ts
Normal file
47
web/src/lib/canvas/canvas-drawing-excalidraw-document.ts
Normal file
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
45
web/src/lib/canvas/canvas-drawing-tldraw-document.ts
Normal file
45
web/src/lib/canvas/canvas-drawing-tldraw-document.ts
Normal file
@@ -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 };
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Tooltip } from "antd";
|
||||
import { ArrowLeft, BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Infinity as InfinityIcon, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
|
||||
import { ArrowLeft, BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Infinity as InfinityIcon, Mail, MessageSquareText, Paintbrush, PanelLeftClose, PanelLeftOpen, RadioTower, Settings2, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Link, NavLink, Outlet } from "react-router";
|
||||
|
||||
@@ -40,6 +40,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{
|
||||
label: "系统配置",
|
||||
items: [
|
||||
{ path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: <Paintbrush className="size-4" /> },
|
||||
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
|
||||
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" /> },
|
||||
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { App, Button, Segmented, Tag } from "antd";
|
||||
import { Brush, Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { drawingEngineLabel, isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import { getAdminDrawingEngineSetting, updateAdminDrawingEngineSetting } from "@/services/api/auth";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
export default function DrawingEngineSettingsPage() {
|
||||
const { message } = App.useApp();
|
||||
const current = useUserStore((state) => state.drawingEngine);
|
||||
const setDrawingEngine = useUserStore((state) => state.setDrawingEngine);
|
||||
const [engine, setEngine] = useState<CanvasDrawingEngine>(current.defaultEngine);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getAdminDrawingEngineSetting().then(({ setting }) => {
|
||||
if (cancelled) return;
|
||||
setDrawingEngine(setting);
|
||||
setEngine(setting.defaultEngine);
|
||||
}).catch((error) => {
|
||||
if (!cancelled) message.error(error instanceof Error ? error.message : "读取绘图工具配置失败");
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [message, setDrawingEngine]);
|
||||
|
||||
const save = async () => {
|
||||
if (!isDrawingEngineAvailable(engine)) {
|
||||
message.error("当前生产构建未配置 tldraw License Key");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { setting } = await updateAdminDrawingEngineSetting(engine);
|
||||
setDrawingEngine(setting);
|
||||
setEngine(setting.defaultEngine);
|
||||
message.success("默认绘图工具已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存绘图工具配置失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dirty = engine !== current.defaultEngine;
|
||||
return (
|
||||
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器">
|
||||
<div className="mx-auto max-w-4xl space-y-5">
|
||||
<SettingsSectionCard icon={<Brush className="size-4" />} title="默认绘图引擎" description="已有绘图保持原引擎,仅新建绘图使用这里的选择。" status={<Tag variant="filled" color="blue">{drawingEngineLabel(current.defaultEngine)}</Tag>}>
|
||||
<div className="flex flex-col gap-4 px-5 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Segmented<CanvasDrawingEngine>
|
||||
block
|
||||
className="w-full sm:max-w-md"
|
||||
disabled={loading || saving}
|
||||
value={engine}
|
||||
onChange={setEngine}
|
||||
options={[
|
||||
{ label: "Excalidraw", value: "excalidraw" },
|
||||
{ label: "tldraw", value: "tldraw", disabled: !isDrawingEngineAvailable("tldraw") },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || !dirty} onClick={() => void save()}>保存配置</Button>
|
||||
</div>
|
||||
{!isDrawingEngineAvailable("tldraw") ? <div className="border-t border-border px-5 py-3 text-xs text-amber-600 dark:text-amber-400">当前生产构建没有 tldraw License Key,不能将其设为新建默认值。</div> : null}
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
@@ -91,7 +91,13 @@ export default function CanvasPage() {
|
||||
),
|
||||
);
|
||||
await Promise.all(data.projects.map(async (item) => {
|
||||
const importedProjectId = importProject(item.project);
|
||||
const drawingEngineById = new Map((item.drawingDocuments || []).map((document) => [document.drawingId, document.engine || "tldraw"]));
|
||||
const importedProjectId = importProject({
|
||||
...item.project,
|
||||
nodes: item.project.nodes.map((node) => node.type === "drawing" && node.metadata?.drawingId
|
||||
? { ...node, metadata: { ...node.metadata, drawingEngine: drawingEngineById.get(node.metadata.drawingId) || node.metadata.drawingEngine || "tldraw" } }
|
||||
: node),
|
||||
});
|
||||
await Promise.all((item.drawingDocuments || []).map((document) => {
|
||||
const previewFile = document.previewPath ? zip.get(document.previewPath) : undefined;
|
||||
const preview = previewFile && !previewFile.type ? previewFile.slice(0, previewFile.size, "image/png") : previewFile;
|
||||
@@ -107,7 +113,16 @@ export default function CanvasPage() {
|
||||
background: document.generationRender.background,
|
||||
} satisfies CanvasDrawingRenderDraft
|
||||
: undefined;
|
||||
return saveCanvasDrawing(importedProjectId, document.drawingId, document.snapshot, { ...document, version: 1, revision: Math.max(0, document.revision - 1) }, preview, render);
|
||||
const engine = document.engine || "tldraw";
|
||||
return saveCanvasDrawing(importedProjectId, document.drawingId, engine, document.snapshot, {
|
||||
version: 2,
|
||||
engine,
|
||||
snapshot: document.snapshot,
|
||||
revision: Math.max(0, document.revision - 1),
|
||||
updatedAt: document.updatedAt,
|
||||
shapeCount: document.shapeCount,
|
||||
pageCount: document.pageCount,
|
||||
}, preview, render);
|
||||
}));
|
||||
}));
|
||||
message.success(`已导入 ${data.projects.length} 个画布`);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
|
||||
import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { App } from "antd";
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer";
|
||||
@@ -192,6 +193,7 @@ function InfiniteCanvasPage() {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const cleanupAssetImages = useAssetStore((state) => state.cleanupImages);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const defaultDrawingEngine = useUserStore((state) => state.drawingEngine.defaultEngine);
|
||||
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
|
||||
const [connections, setConnections] = useState<CanvasConnection[]>([]);
|
||||
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
|
||||
@@ -603,6 +605,7 @@ function InfiniteCanvasPage() {
|
||||
toggleNodeLocked,
|
||||
} = useCanvasNodeOperations({
|
||||
projectId,
|
||||
defaultDrawingEngine,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
selectedNodeIdsRef,
|
||||
@@ -629,6 +632,7 @@ function InfiniteCanvasPage() {
|
||||
setConnecting,
|
||||
} = useCanvasConnectionController({
|
||||
projectId,
|
||||
defaultDrawingEngine,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
viewportRef,
|
||||
@@ -1668,7 +1672,7 @@ function InfiniteCanvasPage() {
|
||||
open={Boolean(drawingNode)}
|
||||
onClose={() => setDrawingNodeId(null)}
|
||||
onSaved={(nodeId, summary) => {
|
||||
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, drawingRevision: summary.revision, drawingUpdatedAt: summary.updatedAt, drawingShapeCount: summary.shapeCount, drawingPageCount: summary.pageCount } } : node));
|
||||
setNodes((current) => current.map((node) => node.id === nodeId ? { ...node, metadata: { ...node.metadata, drawingEngine: summary.engine, drawingRevision: summary.revision, drawingUpdatedAt: summary.updatedAt, drawingShapeCount: summary.shapeCount, drawingPageCount: summary.pageCount } } : node));
|
||||
message.success("绘图已保存");
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,11 +6,13 @@ import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspa
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
|
||||
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type UseCanvasConnectionControllerOptions = {
|
||||
projectId: string;
|
||||
defaultDrawingEngine: CanvasDrawingEngine;
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
viewportRef: { current: ViewportTransform };
|
||||
@@ -38,6 +40,7 @@ const NODE_STATUS_IDLE = "idle" as const;
|
||||
|
||||
export function useCanvasConnectionController({
|
||||
projectId,
|
||||
defaultDrawingEngine,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
viewportRef,
|
||||
@@ -111,11 +114,17 @@ export function useCanvasConnectionController({
|
||||
}, [connectionsRef, message, nodesRef, setConnections, setContextMenu, setNodes]);
|
||||
|
||||
const createConnectedNode = useCallback(async (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing, pending: PendingConnectionCreate) => {
|
||||
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine)) {
|
||||
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
|
||||
return;
|
||||
}
|
||||
const storyboardRow = type === CanvasNodeType.Video ? storyboardRowFromHandle(nodesRef.current, pending.connection.nodeId, pending.connection.handleId) : undefined;
|
||||
const videoPrompt = storyboardRow ? (storyboardRow.videoMotionPrompt || storyboardRow.plotDescription).trim() : "";
|
||||
const sourceNode = pending.connection.handleType === "source" ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
|
||||
const scriptPrompt = type === CanvasNodeType.Script && sourceNode?.type === CanvasNodeType.Text ? (sourceNode.metadata?.content || sourceNode.metadata?.prompt || "").trim() : "";
|
||||
const metadata = type === CanvasNodeType.Script && scriptPrompt
|
||||
const metadata = type === CanvasNodeType.Drawing
|
||||
? { drawingEngine: defaultDrawingEngine }
|
||||
: type === CanvasNodeType.Script && scriptPrompt
|
||||
? { prompt: scriptPrompt, composerContent: scriptPrompt }
|
||||
: type === CanvasNodeType.Video && storyboardRow
|
||||
? { prompt: videoPrompt, composerContent: videoPrompt, generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
|
||||
@@ -148,7 +157,7 @@ export function useCanvasConnectionController({
|
||||
closeConnectionCreateMenu();
|
||||
setConnecting(null);
|
||||
try {
|
||||
const saved = await createCanvasDrawingFromImage(projectId, newNode.metadata.drawingId, {
|
||||
const saved = await createCanvasDrawingFromImage(projectId, newNode.metadata.drawingId, defaultDrawingEngine, {
|
||||
url: sourceUrl,
|
||||
storageKey: drawingSourceNode.metadata?.storageKey,
|
||||
name: drawingSourceNode.title || "来源图片",
|
||||
@@ -157,6 +166,7 @@ export function useCanvasConnectionController({
|
||||
newNode.title = `${drawingSourceNode.title || "图片"} · 绘图`;
|
||||
newNode.metadata = {
|
||||
...newNode.metadata,
|
||||
drawingEngine: saved.engine,
|
||||
drawingRevision: saved.revision,
|
||||
drawingUpdatedAt: saved.updatedAt,
|
||||
drawingShapeCount: saved.shapeCount,
|
||||
@@ -180,7 +190,7 @@ export function useCanvasConnectionController({
|
||||
else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
closeConnectionCreateMenu();
|
||||
setConnecting(null);
|
||||
}, [closeConnectionCreateMenu, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
}, [closeConnectionCreateMenu, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
|
||||
const world = screenToCanvas(clientX, clientY);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createCanvasNode, removeCanvasNodes } from "@/lib/canvas/canvas-project
|
||||
import { isolateCopiedNodeMetadata } from "@/lib/canvas/canvas-node-copy";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState, type Position } from "@/types/canvas";
|
||||
import { cloneCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
|
||||
type CanvasClipboard = {
|
||||
nodes: CanvasNodeData[];
|
||||
@@ -21,6 +22,7 @@ const CANVAS_NODES_CLIPBOARD_STORAGE_KEY = "open-ai-canvas:nodes-clipboard";
|
||||
|
||||
type UseCanvasNodeOperationsOptions = {
|
||||
projectId: string;
|
||||
defaultDrawingEngine: CanvasDrawingEngine;
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
selectedNodeIdsRef: { current: Set<string> };
|
||||
@@ -36,6 +38,7 @@ type UseCanvasNodeOperationsOptions = {
|
||||
|
||||
export function useCanvasNodeOperations({
|
||||
projectId,
|
||||
defaultDrawingEngine,
|
||||
nodesRef,
|
||||
connectionsRef,
|
||||
selectedNodeIdsRef,
|
||||
@@ -100,6 +103,7 @@ export function useCanvasNodeOperations({
|
||||
...node,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
drawingEngine: saved.engine,
|
||||
drawingRevision: saved.revision,
|
||||
drawingUpdatedAt: saved.updatedAt,
|
||||
drawingShapeCount: saved.shapeCount,
|
||||
@@ -116,11 +120,15 @@ export function useCanvasNodeOperations({
|
||||
}, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const createNode = useCallback((type: CanvasNodeType, position?: Position) => {
|
||||
const node = createCanvasNode(type, position || getCanvasCenter());
|
||||
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine)) {
|
||||
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
|
||||
return;
|
||||
}
|
||||
const node = createCanvasNode(type, position || getCanvasCenter(), type === CanvasNodeType.Drawing ? { drawingEngine: defaultDrawingEngine } : undefined);
|
||||
commitNodes([...nodesRef.current, node]);
|
||||
selectNodes(new Set([node.id]));
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio && type !== CanvasNodeType.Frame && type !== CanvasNodeType.Drawing) setDialogNodeId(node.id);
|
||||
}, [commitNodes, getCanvasCenter, nodesRef, selectNodes, setDialogNodeId]);
|
||||
}, [commitNodes, defaultDrawingEngine, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId]);
|
||||
|
||||
const arrangeSelectedNodes = useCallback((mode: "row" | "column" | "grid" | "flow") => {
|
||||
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));
|
||||
|
||||
@@ -8,6 +8,7 @@ import ChannelsPage from "@/pages/admin/channels/channels-page";
|
||||
import LogsPage from "@/pages/admin/logs/logs-page";
|
||||
import RedemptionCodesPage from "@/pages/admin/redemption-codes/redemption-codes-page";
|
||||
import RuntimePolicySettingsPage from "@/pages/admin/settings/runtime-policy-settings-page";
|
||||
import DrawingEngineSettingsPage from "@/pages/admin/settings/drawing-engine-settings-page";
|
||||
import StorageSettingsPage from "@/pages/admin/settings/storage-settings-page";
|
||||
import StoryboardPromptsPage from "@/pages/admin/storyboard-prompts/storyboard-prompts-page";
|
||||
import UsersPage from "@/pages/admin/users/users-page";
|
||||
@@ -74,6 +75,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "redemption-codes", element: <RedemptionCodesPage /> },
|
||||
{ path: "logs", element: <LogsPage /> },
|
||||
{ path: "settings", element: <Navigate to="runtime-policy" replace /> },
|
||||
{ path: "settings/drawing-engine", element: <DrawingEngineSettingsPage /> },
|
||||
{ path: "settings/concurrency", element: <Navigate to="/admin/settings/runtime-policy" replace /> },
|
||||
{ path: "settings/runtime-policy", element: <RuntimePolicySettingsPage /> },
|
||||
{ path: "settings/access", element: <AccessSettingsPage /> },
|
||||
|
||||
@@ -3,6 +3,7 @@ import axios from "axios";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import type { CreditLedgerEntry } from "@/services/api/wallet";
|
||||
import type { GenerationTask, TaskStatus } from "@/services/api/task-center";
|
||||
import type { CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-engine";
|
||||
|
||||
const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true });
|
||||
|
||||
@@ -31,6 +32,7 @@ export type AuthSessionPayload = {
|
||||
user: LocalUser | null;
|
||||
systemChannels?: ModelChannel[];
|
||||
runtimeLimits?: RuntimeLimits;
|
||||
drawingEngine?: CanvasDrawingEngineSetting;
|
||||
};
|
||||
|
||||
export type RuntimeLimits = {
|
||||
@@ -431,6 +433,14 @@ export function resetAdminRuntimePolicySetting() {
|
||||
return request<{ setting: RuntimePolicySetting }>(api.delete("/admin/settings/runtime-policy"));
|
||||
}
|
||||
|
||||
export function getAdminDrawingEngineSetting() {
|
||||
return request<{ setting: CanvasDrawingEngineSetting }>(api.get("/admin/settings/drawing-engine"));
|
||||
}
|
||||
|
||||
export function updateAdminDrawingEngineSetting(defaultEngine: CanvasDrawingEngineSetting["defaultEngine"]) {
|
||||
return request<{ setting: CanvasDrawingEngineSetting }>(api.patch("/admin/settings/drawing-engine", { defaultEngine }));
|
||||
}
|
||||
|
||||
export function listAdminApiLogs(params: AdminListParams = {}) {
|
||||
return request<{ logs: ApiCallLog[]; total: number; page: number; limit: number }>(api.get("/admin/api-logs", { params }));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { DEFAULT_DRAWING_ENGINE, type CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-engine";
|
||||
|
||||
export type LocalUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -26,8 +28,10 @@ type UserStore = {
|
||||
hydrated: boolean;
|
||||
user: LocalUser | null;
|
||||
runtimeLimits: RuntimeLimits;
|
||||
drawingEngine: CanvasDrawingEngineSetting;
|
||||
setUser: (user: LocalUser | null) => void;
|
||||
setRuntimeLimits: (limits?: RuntimeLimits) => void;
|
||||
setDrawingEngine: (setting?: CanvasDrawingEngineSetting) => void;
|
||||
setHydrated: (hydrated: boolean) => void;
|
||||
clearSession: () => void;
|
||||
};
|
||||
@@ -36,8 +40,10 @@ export const useUserStore = create<UserStore>()((set) => ({
|
||||
hydrated: false,
|
||||
user: null,
|
||||
runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 },
|
||||
drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE },
|
||||
setUser: (user) => set({ user }),
|
||||
setRuntimeLimits: (runtimeLimits) => set({ runtimeLimits: runtimeLimits || { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 } }),
|
||||
setDrawingEngine: (drawingEngine) => set({ drawingEngine: drawingEngine || { defaultEngine: DEFAULT_DRAWING_ENGINE } }),
|
||||
setHydrated: (hydrated) => set({ hydrated }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 } }),
|
||||
clearSession: () => set({ user: null, runtimeLimits: { activeTaskLimit: 5, resourceUploadMB: 50, sessionUploadMB: 32 }, drawingEngine: { defaultEngine: DEFAULT_DRAWING_ENGINE } }),
|
||||
}));
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import type { CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
|
||||
export type CanvasExportFile = {
|
||||
app: "infinite-canvas";
|
||||
version: 3;
|
||||
version: 3 | 4;
|
||||
exportedAt: string;
|
||||
projects: CanvasProjectExportItem[];
|
||||
};
|
||||
@@ -14,7 +15,8 @@ export type CanvasProjectExportItem = {
|
||||
};
|
||||
|
||||
export type CanvasDrawingExport = {
|
||||
version: 1;
|
||||
version: 1 | 2;
|
||||
engine?: CanvasDrawingEngine;
|
||||
drawingId: string;
|
||||
previewPath?: string;
|
||||
snapshot: unknown;
|
||||
|
||||
@@ -225,6 +225,7 @@ export type CanvasNodeMetadata = {
|
||||
expandedHeight: number;
|
||||
};
|
||||
drawingId?: string;
|
||||
drawingEngine?: "tldraw" | "excalidraw";
|
||||
drawingRevision?: number;
|
||||
drawingUpdatedAt?: string;
|
||||
drawingPreviewStorageKey?: string;
|
||||
|
||||
23
web/test/canvas-drawing-engine.test.ts
Normal file
23
web/test/canvas-drawing-engine.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { drawingEngineForNode } from "../src/lib/canvas/canvas-drawing-engine";
|
||||
import { summarizeCanvasDrawing } from "../src/lib/canvas/canvas-drawing-storage";
|
||||
|
||||
describe("canvas drawing engines", () => {
|
||||
it("treats drawings without an engine as legacy tldraw documents", () => {
|
||||
expect(drawingEngineForNode({ metadata: {} } as never)).toBe("tldraw");
|
||||
expect(drawingEngineForNode({ metadata: { drawingEngine: "excalidraw" } } as never)).toBe("excalidraw");
|
||||
});
|
||||
|
||||
it("summarizes Excalidraw elements without deleted records", () => {
|
||||
expect(summarizeCanvasDrawing("excalidraw", {
|
||||
elements: [{ id: "visible", isDeleted: false }, { id: "deleted", isDeleted: true }],
|
||||
})).toEqual({ shapeCount: 1, pageCount: 1 });
|
||||
});
|
||||
|
||||
it("keeps the legacy tldraw snapshot summary", () => {
|
||||
expect(summarizeCanvasDrawing("tldraw", {
|
||||
store: { "page:1": { typeName: "page" }, "shape:1": { typeName: "shape" } },
|
||||
})).toEqual({ shapeCount: 1, pageCount: 1 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user