Implement debounced auto save

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-17 16:55:02 +04:00
parent a9f4ecacfa
commit 3a054c70ba
8 changed files with 458 additions and 61 deletions

View File

@@ -1,18 +1,204 @@
import { EditorContent, useEditor } from "@tiptap/react";
import { Blockquote } from "@tiptap/extension-blockquote";
import { Bold } from "@tiptap/extension-bold";
import { Code } from "@tiptap/extension-code";
import { CodeBlock } from "@tiptap/extension-code-block";
import { Document } from "@tiptap/extension-document";
import { HardBreak } from "@tiptap/extension-hard-break";
import { Heading } from "@tiptap/extension-heading";
import { HorizontalRule } from "@tiptap/extension-horizontal-rule";
import { Italic } from "@tiptap/extension-italic";
import { Link } from "@tiptap/extension-link";
import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Strike } from "@tiptap/extension-strike";
import { Text } from "@tiptap/extension-text";
import { Underline } from "@tiptap/extension-underline";
import { Dropcursor, Gapcursor, UndoRedo } from "@tiptap/extensions";
import { type Content, EditorContent, useEditor, useEditorState } from "@tiptap/react";
import { BubbleMenu, FloatingMenu } from "@tiptap/react/menus";
import { StarterKit } from "@tiptap/starter-kit";
import { useEffect } from "react";
import { tv } from "tailwind-variants";
const extensions = [
Document,
Paragraph,
Text,
Heading.configure({ levels: [1, 2, 3] }),
Bold,
Italic,
Strike,
Underline,
Code,
CodeBlock,
Link.configure({ openOnClick: false }),
Blockquote,
BulletList,
OrderedList,
ListItem,
HorizontalRule,
HardBreak,
Dropcursor,
Gapcursor,
UndoRedo,
];
const richEditorVariants = tv({
slots: {
bubbleMenu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
floatingMenu: ["flex items-center gap-1 rounded-lg border border-border-mid bg-level-0 p-1 shadow-md"],
menuButton: ["px-2 py-1 text-sm rounded-sm font-semibold bg-level-0 hover:bg-subtle"],
},
variants: {
active: {
true: {
menuButton: ["bg-active"],
},
},
},
});
const { bubbleMenu, floatingMenu, menuButton } = richEditorVariants();
type MenuButtonProps = {
label: string;
active?: boolean;
onClick: () => void;
};
function MenuButton({ label, active, onClick }: MenuButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={menuButton({ active })}
>
{label}
</button>
);
}
interface RichEditorProps {
content: string;
onChange: (content: string) => void;
}
export function RichEditor(props: RichEditorProps) {
const { content, onChange } = props;
export const RichEditor = () => {
const editor = useEditor({
extensions: [StarterKit], // define your extension array
content: "<p>Hello World!</p>", // initial content
extensions,
content: (content ? JSON.parse(content) : "") as Content,
});
const watchedContent = useEditorState({
editor,
selector: ({ editor }) => {
return JSON.stringify(editor.getJSON());
},
});
useEffect(() => {
if (watchedContent !== content) {
onChange(watchedContent);
}
}, [content, watchedContent, onChange]);
return (
<>
<div>
<BubbleMenu
editor={editor}
className={bubbleMenu()}
>
<MenuButton
label="Bold"
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
/>
<MenuButton
label="Italic"
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
/>
<MenuButton
label="Underline"
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
/>
<MenuButton
label="Strike"
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
/>
<MenuButton
label="Code"
active={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
/>
<MenuButton
label="Link"
active={editor.isActive("link")}
onClick={() => {
if (editor.isActive("link")) {
editor.chain().focus().unsetLink().run();
return;
}
const url = window.prompt("URL");
if (url) {
editor.chain().focus().setLink({ href: url }).run();
}
}}
/>
</BubbleMenu>
<FloatingMenu
editor={editor}
className={floatingMenu()}
>
<MenuButton
label="H1"
active={editor.isActive("heading", { level: 1 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()}
/>
<MenuButton
label="H2"
active={editor.isActive("heading", { level: 2 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()}
/>
<MenuButton
label="H3"
active={editor.isActive("heading", { level: 3 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()}
/>
<MenuButton
label="Bullet List"
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
/>
<MenuButton
label="Ordered List"
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
/>
<MenuButton
label="Code Block"
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
/>
<MenuButton
label="Blockquote"
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
/>
<MenuButton
label="Divider"
onClick={() => editor.chain().focus().setHorizontalRule().run()}
/>
</FloatingMenu>
<EditorContent editor={editor} />
<FloatingMenu editor={editor}>This is the floating menu</FloatingMenu>
<BubbleMenu editor={editor}>This is the bubble menu</BubbleMenu>
</>
</div>
);
};
}