返回 presentation-ai
presentation-editor.tsx
根目录 / src / components / notebook / presentation / editor / presentation-editor.tsx
1 "use client";
2
3 import { TooltipProvider } from "@/components/plate/ui/tooltip";
4 import { loadCustomFonts } from "@/lib/presentation/loadCustomFont";
5 import { usePresentationState } from "@/states/presentation-state";
6
7 import "@/styles/presentation.css";
8
9 import { type Value } from "platejs";
10 import React, { useEffect, useRef } from "react";
11
12 import { type PlateNode, type PlateSlide } from "../utils/parser";
13 import { EditablePlate } from "./components/EditablePlate";
14 import { PresentationRoot } from "./components/PresentationRoot";
15 import { useDebouncedOnChange } from "./hooks/useDebouncedOnChange";
16 import { useEditorFonts } from "./hooks/useEditorFonts";
17 import { useHistoryGuard } from "./hooks/useHistoryGuard";
18 import { usePresentationEditorInstance } from "./hooks/usePresentationEditorInstance";
19 import { useSlideFocus } from "./hooks/useSlideFocus";
20 import { slideSignature } from "./utils/slideSignature";
21
22 interface PresentationEditorProps {
23 initialContent?: PlateSlide;
24 className?: string;
25 id?: string;
26 isGenerating: boolean;
27 readOnly?: boolean;
28 isPreview?: boolean;
29 }
30
31 // Use React.memo with a custom comparison function to prevent unnecessary re-renders
32 const PresentationEditor = React.memo(
33 ({
34 initialContent,
35 className,
36 id,
37 isGenerating = false,
38 readOnly = false,
39 isPreview = false,
40 }: PresentationEditorProps) => {
41 const isPresenting = usePresentationState((s) => s.isPresenting);
42 const currentSlideId = usePresentationState((s) => s.currentSlideId);
43 const setCurrentSlideId = usePresentationState((s) => s.setCurrentSlideId);
44
45 const { fontsToLoad, onEditorReady, updateFontsFromEditor } =
46 useEditorFonts();
47 const lastContentRef = useRef<string>("");
48
49 const editor = usePresentationEditorInstance({
50 initialValue: initialContent?.content as Value | undefined,
51 id: id,
52 onReady: ({ editor }) => {
53 lastContentRef.current = JSON.stringify(initialContent?.content);
54 onEditorReady(editor, [
55 initialContent?.fontFamily?.heading,
56 initialContent?.fontFamily?.body,
57 ]);
58 },
59 });
60
61 // Load custom fonts if defined in initialContent
62 useEffect(() => {
63 if (initialContent?.fontFamily) {
64 const {
65 heading,
66 body,
67 headingUrl,
68 bodyUrl,
69 headingWeight,
70 bodyWeight,
71 } = initialContent.fontFamily;
72
73 if (headingUrl || bodyUrl) {
74 loadCustomFonts({
75 headingFont: heading,
76 headingUrl,
77 headingWeight,
78 bodyFont: body,
79 bodyUrl,
80 bodyWeight,
81 }).catch((error) => {
82 console.error("Failed to load custom fonts in editor:", error);
83 });
84 }
85 }
86 }, [initialContent?.fontFamily]);
87
88 useSlideFocus(editor, currentSlideId, initialContent?.id);
89
90 useHistoryGuard({
91 editor,
92 initialContent,
93 isGenerating,
94 readOnly: Boolean(readOnly),
95 isPresenting,
96 currentSlideId,
97 lastContentRef,
98 });
99
100 const handleDebouncedApply = React.useCallback(
101 (value: Value) => {
102 updateFontsFromEditor(editor);
103 const slideId = initialContent?.id ?? id;
104 if (slideId) {
105 lastContentRef.current = JSON.stringify(value);
106 usePresentationState.getState().updateSlide(slideId, {
107 content: value as PlateNode[],
108 });
109 }
110 },
111 [editor, id, initialContent?.id, updateFontsFromEditor],
112 );
113
114 const debouncedOnChange = useDebouncedOnChange<Value>({
115 isGenerating,
116 onApply: handleDebouncedApply,
117 });
118
119 return (
120 <TooltipProvider>
121 <PresentationRoot
122 className={className}
123 initialContent={initialContent}
124 fontsToLoad={fontsToLoad}
125 isPresenting={isPresenting}
126 readOnly={Boolean(readOnly)}
127 isStatic={false}
128 onActivateSlide={(slideId) => setCurrentSlideId(slideId)}
129 >
130 <EditablePlate
131 editor={editor}
132 className={className}
133 id={id}
134 readOnly={Boolean(readOnly)}
135 isPreview={Boolean(isPreview)}
136 isGenerating={Boolean(isGenerating)}
137 isPresenting={Boolean(isPresenting)}
138 initialContent={initialContent}
139 onFocusSlide={(slideId) => setCurrentSlideId(slideId)}
140 onDebouncedChange={(value) => debouncedOnChange(value)}
141 />
142 </PresentationRoot>
143 </TooltipProvider>
144 );
145 },
146 (prev, next) => {
147 if (prev.id !== next.id) return false;
148 // Deep-compare important slide fields using a stable JSON signature
149 if (
150 slideSignature(prev.initialContent) !==
151 slideSignature(next.initialContent)
152 ) {
153 return false;
154 }
155 if (prev.readOnly !== next.readOnly) return false;
156 if (prev.isPreview !== next.isPreview) return false;
157 if (prev.className !== next.className) return false;
158 if (prev.isGenerating !== next.isGenerating) return false;
159 // Intentionally ignore function prop identity (onChange) differences
160 return true;
161 },
162 );
163
164 PresentationEditor.displayName = "PresentationEditor";
165
166 export default PresentationEditor;
167
167 lines Plain Text