1"use client";
2
3
15import { useCallback, useEffect, useRef, useState } from "react";
16import { EditorContent, useEditor, type Editor } from "@tiptap/react";
17import StarterKit from "@tiptap/starter-kit";
18import Placeholder from "@tiptap/extension-placeholder";
19import CharacterCount from "@tiptap/extension-character-count";
20import Link from "@tiptap/extension-link";
21import Image from "@tiptap/extension-image";
22import TaskList from "@tiptap/extension-task-list";
23import TaskItem from "@tiptap/extension-task-item";
24
25
26import { atomHtmlToText, atomTextToHtml, factoryEditorExtensions } from "@factory/editor";
27import { cn } from "@/lib/utils";
28import { registerPresenceEditor } from "@/lib/presence-editor-views";
29import { usePresenceCaretStore } from "@/stores/presence-store";
30import { SlashMenu, type SlashCommand } from "./slash-menu";
31
32export interface InlineEditorProps {
33 blockId: string;
34
35 content: unknown;
36 placeholder?: string;
37 className?: string;
38
39 onChange?: (blockId: string, content: unknown) => void;
40
41 onBlur?: (blockId: string, content: unknown) => void;
42
43 selected?: boolean;
44 readOnly?: boolean;
45
46 requestFocus?: boolean;
47 onFocusBlock?: (blockId: string) => void;
48
54 onEnterKey?: () => void;
55 onNavigate?: (dir: -1 | 1) => void;
56
60 slashContext?: { parentId: string | null; position: string };
61
66 onSlashPick?: (
67 command: SlashCommand,
68 context: { parentId: string | null; position: string },
69 ) => void;
70}
71
72
73function escapeHtml(s: string): string {
74 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
75}
76
77function toHtml(content: unknown): string {
78 if (!content) return "";
79
80
81
82 if (typeof content === "string") return content;
83
84
85
86
87
88
89 const c = content as { text?: unknown; title?: unknown };
90 const raw = typeof c.text === "string" ? c.text : typeof c.title === "string" ? c.title : undefined;
91 if (raw === undefined || raw === "") return "";
92
93
94 return `<p>${atomTextToHtml(raw)}</p>`;
95}
96
97
102function saveContent(editor: { getHTML(): string }): unknown {
103 const html = editor.getHTML();
104 const text = atomHtmlToText(html);
105 return text === null ? html : { text };
106}
107
108export function InlineEditor({
109 blockId,
110 content,
111 placeholder = "Type '/' for commands",
112 className,
113 onChange,
114 onBlur,
115 selected,
116 readOnly,
117 requestFocus,
118 onFocusBlock,
119 onEnterKey,
120 onNavigate,
121 slashContext,
122 onSlashPick,
123}: InlineEditorProps) {
124 const editorRef = useRef<Editor | null>(null);
125 const initialContent = useRef(toHtml(content));
126 const [slash, setSlash] = useState<{
127 query: string;
128 anchor: { top: number; left: number };
129 } | null>(null);
130
131
132 const handlers = useRef({ onEnterKey, onNavigate, slashContext, onSlashPick });
133 handlers.current = { onEnterKey, onNavigate, slashContext, onSlashPick };
134
135 const editor = useEditor({
136 extensions: [
137 StarterKit.configure({
138 heading: { levels: [1, 2, 3] },
139
140 bulletList: false,
141 orderedList: false,
142 codeBlock: false,
143 }),
144 TaskList,
145 TaskItem.configure({ nested: true }),
146 Placeholder.configure({ placeholder }),
147 CharacterCount,
148 Link.configure({ openOnClick: false, autolink: true }),
149 Image,
150
151 ...factoryEditorExtensions(),
152 ],
153 content: initialContent.current,
154 editable: !readOnly,
155 immediatelyRender: false,
156 editorProps: {
157 attributes: {
158 class: "editor-content focus:outline-none",
159 "data-block-id": blockId,
160 },
161 handleKeyDown: (_view, event: KeyboardEvent) => {
162 if (!editor) return false;
163 const { $from } = editor.state.selection;
164
165
166
167
168
169
170
171 if (event.key === "Enter" && !event.shiftKey && $from.depth === 1) {
172 event.preventDefault();
173 handlers.current.onEnterKey?.();
174 return true;
175 }
176 if (event.key === "ArrowUp" && $from.depth === 0 && $from.parentOffset === 0) {
177 event.preventDefault();
178 handlers.current.onNavigate?.(-1);
179 return true;
180 }
181 if (
182 event.key === "ArrowDown" &&
183 $from.depth === 0 &&
184 $from.parentOffset >= $from.parent.nodeSize - 2
185 ) {
186 event.preventDefault();
187 handlers.current.onNavigate?.(1);
188 return true;
189 }
190 return false;
191 },
192 },
193 onUpdate: ({ editor }) => {
194 editorRef.current = editor;
195 onChange?.(blockId, saveContent(editor));
196
197
198 const docText = editor.state.doc.textContent;
199 if (!docText.startsWith("/")) {
200 setSlash(null);
201 return;
202 }
203 const query = docText.slice(1);
204 if (query.trimStart().length > 24) {
205 setSlash(null);
206 return;
207 }
208 const pos = editor.state.selection.from;
209 const rect = editor.view.coordsAtPos(pos);
210 setSlash({ query, anchor: { top: rect.bottom + 4, left: rect.left } });
211 },
212 onBlur: ({ editor }) => {
213 editorRef.current = editor;
214 setSlash(null);
215 onBlur?.(blockId, saveContent(editor));
216
217
218
219 usePresenceCaretStore.getState().setCaret(null);
220 },
221 onFocus: ({ editor }) => {
222 onFocusBlock?.(blockId);
223 reportPresenceCaret(editor);
224 },
225 onSelectionUpdate: ({ editor }) => {
226
227
228 reportPresenceCaret(editor);
229 },
230 });
231
232
233
234
235
236
237 const reportPresenceCaret = (ed: Editor): void => {
238 const { from, to, empty, $from } = ed.state.selection;
239 usePresenceCaretStore.getState().setCaret({
240 blockId,
241 offset: from,
242 selection: empty
243 ? undefined
244 : { fromBlockId: blockId, fromOffset: $from.start(0), toBlockId: blockId, toOffset: to },
245 });
246 };
247
248
249
250 useEffect(() => {
251 if (!editor) return;
252 const next = toHtml(content);
253 if (editor.isFocused) return;
254 const current = editor.getHTML();
255 if (next !== current) {
256 editor.commands.setContent(next, false);
257 }
258 }, [content, editor]);
259
260
261
262
263
264 useEffect(() => {
265 if (!editor) return;
266 return registerPresenceEditor(blockId, editor);
267 }, [blockId, editor]);
268
269 const focus = useCallback(() => {
270 if (!editor) return;
271 editor.chain().focus("end").run();
272 }, [editor]);
273
274
275
276
277 const pickSlash = useCallback((cmd: SlashCommand) => {
278
279
280
281
282
283 setSlash(null);
284 const ed = editorRef.current;
285 if (ed) ed.chain().setContent("").focus("start").run();
286 const ctx = handlers.current.slashContext ?? { parentId: null, position: "" };
287 handlers.current.onSlashPick?.(cmd, ctx);
288 }, []);
289
290 useEffect(() => {
291 if (requestFocus) focus();
292 }, [requestFocus, focus]);
293
294 if (!editor) {
295 return (
296 <div
297 className={cn("editor-content min-h-[1.5em] text-sm", className)}
298 contentEditable={false}
299 aria-label={placeholder}
300 />
301 );
302 }
303
304 return (
305 <div className={cn("relative", selected && "rounded-sm", className)}>
306 <EditorContent editor={editor} />
307 {slash ? (
308 <SlashMenu
309 query={slash.query}
310 anchor={slash.anchor}
311 onPick={pickSlash}
312 onClose={() => setSlash(null)}
313 />
314 ) : null}
315 </div>
316 );
317}
318