返回 presentation-ai
ProseMirrorEditor.tsx
根目录 / src / components / prose-mirror / ProseMirrorEditor.tsx
1 import { cn } from "@/lib/utils";
2 import { baseKeymap, toggleMark } from "prosemirror-commands";
3 import { history, redo, undo } from "prosemirror-history";
4 import { keymap } from "prosemirror-keymap";
5 import {
6 defaultMarkdownParser,
7 defaultMarkdownSerializer,
8 } from "prosemirror-markdown";
9 import { EditorState } from "prosemirror-state";
10 import { EditorView } from "prosemirror-view";
11 import type React from "react";
12 import { useCallback, useEffect, useRef, useState } from "react";
13 import FloatingToolbar from "./FloatingToolbar";
14 import mySchema from "./ProseMirrorSchema";
15
16 interface ProseMirrorEditorProps {
17 content: string;
18 onChange: (content: string) => void;
19 isEditing: boolean;
20 onChangeState?: (hasChanges: boolean) => void;
21 onBlur?: () => void;
22 className?: string;
23 showFloatingToolbar?: boolean;
24 }
25
26 const ProseMirrorEditor: React.FC<ProseMirrorEditorProps> = ({
27 content,
28 onChange,
29 isEditing,
30 onChangeState,
31 onBlur,
32 className,
33 showFloatingToolbar = true,
34 }) => {
35 const editorRef = useRef<HTMLDivElement>(null);
36 const viewRef = useRef<EditorView | null>(null);
37 const originalContentRef = useRef(content);
38 const toolbarTimeoutRef = useRef<NodeJS.Timeout | null>(null);
39 const [toolbarState, setToolbarState] = useState({
40 isVisible: false,
41 top: 0,
42 left: 0,
43 });
44
45 const checkForChanges = (newContent: string) => {
46 const hasChanges = newContent !== originalContentRef.current;
47 onChangeState?.(hasChanges);
48 };
49
50 const updateToolbarPosition = useCallback((view: EditorView) => {
51 const { from, to } = view.state.selection;
52
53 if (from === to) {
54 setToolbarState((prev) => ({ ...prev, isVisible: false }));
55 if (toolbarTimeoutRef.current) {
56 clearTimeout(toolbarTimeoutRef.current);
57 }
58 return;
59 }
60
61 const editorBox = editorRef.current?.getBoundingClientRect();
62 if (!editorBox) return;
63
64 const { top: editorTop, left: editorLeft } = editorBox;
65 const start = view.coordsAtPos(from);
66 const end = view.coordsAtPos(to);
67
68 // Calculate position relative to the editor
69 const selectionTop = Math.min(start.top, end.top);
70 const selectionLeft = (start.left + end.left) / 2;
71
72 // Clear any existing timeout
73 if (toolbarTimeoutRef.current) {
74 clearTimeout(toolbarTimeoutRef.current);
75 }
76
77 // Set a new timeout for showing the toolbar
78 toolbarTimeoutRef.current = setTimeout(() => {
79 setToolbarState({
80 isVisible: true,
81 top: selectionTop - editorTop,
82 left: selectionLeft - editorLeft,
83 });
84 }, 150);
85 }, []);
86
87 useEffect(() => {
88 if (!editorRef.current) return;
89
90 // Add global styles to remove ProseMirror focus outline
91 const style = document.createElement("style");
92 style.textContent = `
93 .ProseMirror {
94 outline: none !important;
95 position: relative !important;
96 }
97 .ProseMirror-focused {
98 outline: none !important;
99 }
100 `;
101 document.head.appendChild(style);
102
103 const state = EditorState.create({
104 doc: defaultMarkdownParser.parse(content),
105 schema: mySchema,
106 plugins: [
107 history(),
108 keymap({
109 "Mod-z": undo,
110 "Mod-y": redo,
111 "Mod-Shift-z": redo,
112 "Mod-b": (state, dispatch) => {
113 const strongMark = state.schema.marks.strong;
114 if (!strongMark) return false;
115 return toggleMark(strongMark)(state, dispatch);
116 },
117 "Mod-i": (state, dispatch) => {
118 const emMark = state.schema.marks.em;
119 if (!emMark) return false;
120 return toggleMark(emMark)(state, dispatch);
121 },
122 }),
123 keymap(baseKeymap),
124 ],
125 });
126
127 const view = new EditorView(editorRef.current, {
128 state,
129 editable: () => isEditing,
130 dispatchTransaction: (transaction) => {
131 const newState = view.state.apply(transaction);
132 view.updateState(newState);
133
134 const markdownContent = defaultMarkdownSerializer.serialize(
135 newState.doc,
136 );
137 onChange(markdownContent);
138 checkForChanges(markdownContent);
139
140 if (transaction.selectionSet && isEditing) {
141 updateToolbarPosition(view);
142 }
143 },
144 handleDOMEvents: {
145 blur: (_view, event) => {
146 // Check if the related target (where focus is going) is part of our toolbar
147 const relatedTarget = event.relatedTarget as HTMLElement;
148 if (
149 relatedTarget?.closest('[role="menu"]') ??
150 relatedTarget?.closest(".floating-toolbar")
151 ) {
152 // If clicking toolbar or dropdown, don't hide
153 return false;
154 }
155
156 if (toolbarTimeoutRef.current) {
157 clearTimeout(toolbarTimeoutRef.current);
158 }
159 setToolbarState((prev) => ({ ...prev, isVisible: false }));
160 onBlur?.();
161 return false;
162 },
163 focus: () => {
164 const { from, to } = view.state.selection;
165 if (from !== to) {
166 updateToolbarPosition(view);
167 }
168 return false;
169 },
170 },
171 });
172
173 viewRef.current = view;
174 originalContentRef.current = content;
175 onChangeState?.(false);
176
177 return () => {
178 if (viewRef.current) {
179 viewRef.current.destroy();
180 }
181 if (toolbarTimeoutRef.current) {
182 clearTimeout(toolbarTimeoutRef.current);
183 }
184 document.head.removeChild(style);
185 };
186 }, [isEditing, updateToolbarPosition]);
187
188 // Update content when it changes externally
189 useEffect(() => {
190 if (viewRef.current) {
191 const currentContent = defaultMarkdownSerializer.serialize(
192 viewRef.current.state.doc,
193 );
194 if (currentContent !== content) {
195 const newState = EditorState.create({
196 doc: defaultMarkdownParser.parse(content),
197 schema: mySchema,
198 plugins: viewRef.current.state.plugins,
199 });
200 viewRef.current.updateState(newState);
201 originalContentRef.current = content;
202 onChangeState?.(false);
203 }
204 }
205 }, [content]);
206
207 return (
208 <div className="relative">
209 <div
210 ref={editorRef}
211 className={cn(
212 "prose max-w-none focus:ring-0 focus:outline-hidden dark:prose-invert",
213 isEditing ? "cursor-text" : "cursor-default",
214 className,
215 )}
216 />
217 {viewRef.current && showFloatingToolbar && (
218 <FloatingToolbar
219 view={viewRef.current}
220 isVisible={toolbarState.isVisible && isEditing}
221 top={toolbarState.top}
222 left={toolbarState.left}
223 />
224 )}
225 </div>
226 );
227 };
228
229 export default ProseMirrorEditor;
230
230 lines Plain Text