feat(canvas): 绘图工具支持自定义 tldraw License Key (#100)

* feat(canvas): 绘图工具 - 支持自定义 tldraw License Key

* docs(canvas): 绘图工具 - 添加 License Key 页面截图
This commit is contained in:
ahyang
2026-08-03 21:38:04 +08:00
committed by GitHub
parent 9b3ec218e2
commit d83ec033bb
11 changed files with 186 additions and 47 deletions

View File

@@ -6,6 +6,7 @@ import type { CanvasDrawingEditorHandle } from "@/components/canvas/canvas-drawi
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 { useUserStore } from "@/stores/use-user-store";
import type { CanvasNodeData } from "@/types/canvas";
const CanvasDrawingTldrawEditor = lazy(() => import("@/components/canvas/canvas-drawing-tldraw-editor").then((module) => ({ default: module.CanvasDrawingTldrawEditor })));
@@ -22,6 +23,7 @@ type CanvasDrawingEditorModalProps = {
export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSaved }: CanvasDrawingEditorModalProps) {
const { message } = App.useApp();
const colorScheme = useThemeStore((state) => state.theme);
const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey);
const engine = drawingEngineForNode(node);
const currentRef = useRef<CanvasDrawingSnapshot | null>(null);
const editorRef = useRef<CanvasDrawingEditorHandle | null>(null);
@@ -76,7 +78,7 @@ export function CanvasDrawingEditorModal({ open, projectId, node, onClose, onSav
onClose();
};
const unavailable = !isDrawingEngineAvailable(engine);
const unavailable = !isDrawingEngineAvailable(engine, tldrawLicenseKey);
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">
<div className="flex h-[min(92dvh,980px)] flex-col">

View File

@@ -4,12 +4,15 @@ import type { Editor } from "tldraw";
import "tldraw/tldraw.css";
import type { CanvasDrawingEditorHandle, CanvasDrawingEditorProps } from "@/components/canvas/canvas-drawing-editor-types";
import { resolveTldrawLicenseKey } from "@/lib/canvas/canvas-drawing-engine";
import { useUserStore } from "@/stores/use-user-store";
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 tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey);
const store = useMemo(() => {
const next = createTLStore();
if (snapshot) loadSnapshot(next, snapshot as never);
@@ -46,7 +49,7 @@ export const CanvasDrawingTldrawEditor = forwardRef<CanvasDrawingEditorHandle, C
locale="zh-cn"
colorScheme={colorScheme}
options={SINGLE_PAGE_OPTIONS}
licenseKey={import.meta.env.VITE_TLDRAW_LICENSE_KEY || undefined}
licenseKey={resolveTldrawLicenseKey(tldrawLicenseKey)}
onMount={(editor) => {
editorRef.current = editor;
// 绘图节点只保留单页,避免预览和生成引用出现页选择歧义。
@@ -61,4 +64,4 @@ export const CanvasDrawingTldrawEditor = forwardRef<CanvasDrawingEditorHandle, C
}}
/>
);
});
});

View File

@@ -4,6 +4,7 @@ export type CanvasDrawingEngine = "tldraw" | "excalidraw";
export type CanvasDrawingEngineSetting = {
defaultEngine: CanvasDrawingEngine;
tldrawLicenseKey?: string;
configured?: boolean;
updatedBy?: string;
createdAt?: string;
@@ -17,10 +18,14 @@ export function drawingEngineForNode(node?: Pick<CanvasNodeData, "metadata"> | n
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 resolveTldrawLicenseKey(configuredLicenseKey?: string) {
return configuredLicenseKey?.trim() || import.meta.env.VITE_TLDRAW_LICENSE_KEY?.trim() || undefined;
}
export function isDrawingEngineAvailable(engine: CanvasDrawingEngine, configuredLicenseKey?: string) {
return engine === "excalidraw" || Boolean(resolveTldrawLicenseKey(configuredLicenseKey));
}
export function drawingEngineLabel(engine: CanvasDrawingEngine) {
return engine === "excalidraw" ? "Excalidraw" : "tldraw";
}
}

View File

@@ -1,4 +1,4 @@
import { App, Button, Segmented, Tag } from "antd";
import { App, Button, Input, Segmented, Tag } from "antd";
import { Brush, Save } from "lucide-react";
import { useEffect, useState } from "react";
@@ -13,6 +13,7 @@ export default function DrawingEngineSettingsPage() {
const current = useUserStore((state) => state.drawingEngine);
const setDrawingEngine = useUserStore((state) => state.setDrawingEngine);
const [engine, setEngine] = useState<CanvasDrawingEngine>(current.defaultEngine);
const [tldrawLicenseKey, setTldrawLicenseKey] = useState(current.tldrawLicenseKey || "");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -22,6 +23,7 @@ export default function DrawingEngineSettingsPage() {
if (cancelled) return;
setDrawingEngine(setting);
setEngine(setting.defaultEngine);
setTldrawLicenseKey(setting.tldrawLicenseKey || "");
}).catch((error) => {
if (!cancelled) message.error(error instanceof Error ? error.message : "读取绘图工具配置失败");
}).finally(() => {
@@ -30,17 +32,19 @@ export default function DrawingEngineSettingsPage() {
return () => { cancelled = true; };
}, [message, setDrawingEngine]);
const tldrawAvailable = isDrawingEngineAvailable("tldraw", tldrawLicenseKey);
const save = async () => {
if (!isDrawingEngineAvailable(engine)) {
message.error("当前生产构建未配置 tldraw License Key");
if (!isDrawingEngineAvailable(engine, tldrawLicenseKey)) {
message.error("请先配置有效的 tldraw License Key");
return;
}
setSaving(true);
try {
const { setting } = await updateAdminDrawingEngineSetting(engine);
const { setting } = await updateAdminDrawingEngineSetting({ defaultEngine: engine, tldrawLicenseKey });
setDrawingEngine(setting);
setEngine(setting.defaultEngine);
message.success("默认绘图工具已更新");
setTldrawLicenseKey(setting.tldrawLicenseKey || "");
message.success("绘图工具配置已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存绘图工具配置失败");
} finally {
@@ -48,28 +52,35 @@ export default function DrawingEngineSettingsPage() {
}
};
const dirty = engine !== current.defaultEngine;
const dirty = engine !== current.defaultEngine || tldrawLicenseKey !== (current.tldrawLicenseKey || "");
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 className="flex flex-col gap-5 px-5 py-5">
<div className="w-full sm:max-w-md">
<label className="mb-2 block text-sm font-medium">tldraw License Key</label>
<Input.Password value={tldrawLicenseKey} onChange={(event) => setTldrawLicenseKey(event.target.value)} placeholder="输入 tldraw License Key" disabled={loading || saving} autoComplete="off" />
<p className="mt-2 text-xs text-foreground/55">填入有效的 License Key 后保存,即可解锁 tldraw 选项。</p>
</div>
<div className="flex flex-col gap-4 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: !tldrawAvailable },
]}
/>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || !dirty} onClick={() => void save()}>保存配置</Button>
</div>
</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}
{!tldrawAvailable ? <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>
);
}
}

View File

@@ -8,6 +8,7 @@ import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor,
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 { useUserStore } from "@/stores/use-user-store";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ConnectionHandle, type ContextMenuState, type Position, type ViewportTransform } from "@/types/canvas";
type UseCanvasConnectionControllerOptions = {
@@ -55,6 +56,7 @@ export function useCanvasConnectionController({
setDrawingNodeId,
}: UseCanvasConnectionControllerOptions) {
const { message } = App.useApp();
const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey);
const [connectingParams, setConnectingParams] = useState<ConnectionHandle | null>(null);
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
@@ -114,7 +116,7 @@ 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)) {
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
return;
}
@@ -190,7 +192,7 @@ export function useCanvasConnectionController({
else if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Script && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
closeConnectionCreateMenu();
setConnecting(null);
}, [closeConnectionCreateMenu, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds]);
}, [closeConnectionCreateMenu, defaultDrawingEngine, message, nodesRef, projectId, setConnecting, setConnections, setDialogNodeId, setDrawingNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, tldrawLicenseKey]);
const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
const world = screenToCanvas(clientX, clientY);

View File

@@ -10,6 +10,7 @@ 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";
import { useUserStore } from "@/stores/use-user-store";
type CanvasClipboard = {
nodes: CanvasNodeData[];
@@ -52,6 +53,7 @@ export function useCanvasNodeOperations({
onNodesDeleted,
}: UseCanvasNodeOperationsOptions) {
const { message } = App.useApp();
const tldrawLicenseKey = useUserStore((state) => state.drawingEngine.tldrawLicenseKey);
const clipboardRef = useRef<CanvasClipboard | null>(null);
const preferCopiedNodesRef = useRef(false);
const markerWritePendingRef = useRef(false);
@@ -120,7 +122,7 @@ export function useCanvasNodeOperations({
}, [selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const createNode = useCallback((type: CanvasNodeType, position?: Position) => {
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine)) {
if (type === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
return;
}
@@ -128,7 +130,7 @@ export function useCanvasNodeOperations({
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, defaultDrawingEngine, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId]);
}, [commitNodes, defaultDrawingEngine, getCanvasCenter, message, nodesRef, selectNodes, setDialogNodeId, tldrawLicenseKey]);
const arrangeSelectedNodes = useCallback((mode: "row" | "column" | "grid" | "flow") => {
const selected = nodesRef.current.filter((node) => selectedNodeIdsRef.current.has(node.id) && !node.metadata?.locked && !isFrameNode(node));

View File

@@ -437,8 +437,8 @@ 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 updateAdminDrawingEngineSetting(input: Pick<CanvasDrawingEngineSetting, "defaultEngine" | "tldrawLicenseKey">) {
return request<{ setting: CanvasDrawingEngineSetting }>(api.patch("/admin/settings/drawing-engine", input));
}
export function listAdminApiLogs(params: AdminListParams = {}) {