| 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 |