Plate Editor
This commit is contained in:
@ -1,44 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { Bold, Italic, Strikethrough } from "lucide-react"
|
||||
import type { Value } from 'platejs';
|
||||
|
||||
import { BasicBlocksKit } from '@/components/editor/plugins/basic-blocks-kit';
|
||||
import { BasicMarksKit } from '@/components/editor/plugins/basic-marks-kit';
|
||||
import { Editor, EditorContainer } from '@/components/ui/editor';
|
||||
import { FixedToolbar } from '@/components/ui/fixed-toolbar';
|
||||
import { MarkToolbarButton } from '@/components/ui/mark-toolbar-button';
|
||||
import { ToolbarButton } from '@/components/ui/toolbar';
|
||||
import { Plate, usePlateEditor } from 'platejs/react';
|
||||
|
||||
|
||||
const initialValue: Value = [
|
||||
{
|
||||
children: [{ text: 'Title' }],
|
||||
type: 'h3',
|
||||
},
|
||||
{
|
||||
children: [{ text: 'This is a quote.' }],
|
||||
type: 'blockquote',
|
||||
},
|
||||
{
|
||||
type: 'p',
|
||||
children: [
|
||||
{ text: 'Hello! Try out the ' },
|
||||
{ text: 'bold', bold: true },
|
||||
{ text: ', ' },
|
||||
{ text: 'italic', italic: true },
|
||||
{ text: ', and ' },
|
||||
{ text: 'underline', underline: true },
|
||||
{ text: ' formatting.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TosEditor() {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl m-5 focus:outline-none',
|
||||
},
|
||||
},
|
||||
content: '<p>Hello World! 🌎️</p>',
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML())
|
||||
},
|
||||
})
|
||||
|
||||
if (!editor) return null
|
||||
const editor = usePlateEditor({
|
||||
plugins: [
|
||||
...BasicBlocksKit,
|
||||
...BasicMarksKit,
|
||||
], // Add the mark plugins
|
||||
value: initialValue, // Set initial content
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ToggleGroup type="multiple" className="flex gap-1">
|
||||
<ToggleGroupItem onClick={() => editor.chain().focus().toggleBold().run()} pressed={editor.isActive("bold")}>
|
||||
<Bold className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem onClick={() => editor.chain().focus().toggleItalic().run()} pressed={editor.isActive("italic")}>
|
||||
<Italic className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem onClick={() => editor.chain().focus().toggleStrike().run()} pressed={editor.isActive("strike")}>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
<div className={cn("border rounded-md p-4 min-h-[200px]", "bg-background text-foreground")}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<Plate editor={editor}> {/* Provides editor context */}
|
||||
<FixedToolbar className="justify-start rounded-t-lg">
|
||||
{/* Element Toolbar Buttons */}
|
||||
<ToolbarButton onClick={() => editor.tf.h1.toggle()}>H1</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.tf.h2.toggle()}>H2</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.tf.h3.toggle()}>H3</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.tf.blockquote.toggle()}>Quote</ToolbarButton>
|
||||
{/* Mark Toolbar Buttons */}
|
||||
<MarkToolbarButton nodeType="bold" tooltip="Bold (⌘+B)">B</MarkToolbarButton>
|
||||
<MarkToolbarButton nodeType="italic" tooltip="Italic (⌘+I)">I</MarkToolbarButton>
|
||||
<MarkToolbarButton nodeType="underline" tooltip="Underline (⌘+U)">U</MarkToolbarButton>
|
||||
</FixedToolbar>
|
||||
<EditorContainer> {/* Styles the editor area */}
|
||||
<Editor placeholder="Type your amazing content here..." />
|
||||
</EditorContainer>
|
||||
</Plate>
|
||||
);
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { CodeNode } from "@lexical/code"
|
||||
import { LinkNode } from "@lexical/link"
|
||||
import { ListItemNode, ListNode } from "@lexical/list"
|
||||
import {
|
||||
InitialConfigType,
|
||||
LexicalComposer,
|
||||
} from "@lexical/react/LexicalComposer"
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable"
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
|
||||
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
|
||||
import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"
|
||||
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
|
||||
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
|
||||
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"
|
||||
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"
|
||||
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
|
||||
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
|
||||
import { EditorState } from "lexical"
|
||||
import { useCallback } from "react"
|
||||
import ToolbarPlugin from "./plugins/ToolbarPlugin"
|
||||
|
||||
type Props = {
|
||||
onChange?: (editorState: EditorState) => void
|
||||
placeholder?: string
|
||||
initialEditorState?: string
|
||||
}
|
||||
|
||||
export default function LexicalEditor({
|
||||
onChange,
|
||||
placeholder = "Write your Terms of Service here...",
|
||||
initialEditorState,
|
||||
}: Props) {
|
||||
const initialConfig: InitialConfigType = {
|
||||
namespace: "TosEditor",
|
||||
editorState: initialEditorState ?? undefined,
|
||||
theme: {
|
||||
paragraph: "mb-2",
|
||||
heading: {
|
||||
h1: "text-2xl font-bold",
|
||||
h2: "text-xl font-semibold",
|
||||
h3: "text-lg font-medium",
|
||||
},
|
||||
quote: "border-l-4 pl-4 italic text-muted-foreground",
|
||||
list: {
|
||||
nested: {
|
||||
listitem: "ml-6",
|
||||
},
|
||||
ol: "list-decimal list-inside",
|
||||
ul: "list-disc list-inside",
|
||||
listitem: "my-1",
|
||||
},
|
||||
code: "bg-muted text-sm font-mono rounded p-1",
|
||||
},
|
||||
nodes: [
|
||||
HeadingNode,
|
||||
QuoteNode,
|
||||
CodeNode,
|
||||
ListNode,
|
||||
ListItemNode,
|
||||
HorizontalRuleNode,
|
||||
LinkNode,
|
||||
],
|
||||
onError: (error) => {
|
||||
console.error("Lexical Error:", error)
|
||||
},
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(editorState: EditorState) => {
|
||||
if (onChange) onChange(editorState)
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div className="border rounded-md shadow-sm bg-background text-foreground p-4">
|
||||
<ToolbarPlugin />
|
||||
<div className="relative min-h-[200px] mt-2">
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable className="min-h-[200px] border rounded-md p-4 bg-background text-foreground focus:outline-none" />
|
||||
}
|
||||
placeholder={
|
||||
<div className="absolute top-2 left-2 text-muted-foreground pointer-events-none text-sm">
|
||||
{placeholder}
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
</div>
|
||||
<HistoryPlugin />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<MarkdownShortcutPlugin />
|
||||
<OnChangePlugin onChange={handleChange} />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { defaultValueCtx, Editor, rootCtx } from "@milkdown/core";
|
||||
import { block } from "@milkdown/plugin-block";
|
||||
import { clipboard } from "@milkdown/plugin-clipboard";
|
||||
import { cursor } from "@milkdown/plugin-cursor";
|
||||
import { history } from "@milkdown/plugin-history";
|
||||
import { indent } from "@milkdown/plugin-indent";
|
||||
import { listener, listenerCtx } from "@milkdown/plugin-listener";
|
||||
import { prism } from "@milkdown/plugin-prism";
|
||||
import { gfm } from "@milkdown/preset-gfm";
|
||||
import { Milkdown, useEditor } from "@milkdown/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface MilkdownEditorProps {
|
||||
initialMarkdown?: string;
|
||||
onSave?: (markdown: string, html: string) => void;
|
||||
}
|
||||
|
||||
export function MilkdownEditor({ initialMarkdown = "", onSave }: MilkdownEditorProps) {
|
||||
const [markdown, setMarkdown] = useState(initialMarkdown);
|
||||
const [html, setHtml] = useState("");
|
||||
const [mode, setMode] = useState<"editor" | "markdown">("editor");
|
||||
|
||||
useEditor((root) => {
|
||||
return Editor.make()
|
||||
.config((ctx) => {
|
||||
ctx.set(rootCtx, root);
|
||||
ctx.set(defaultValueCtx, initialMarkdown);
|
||||
|
||||
const listener = ctx.get(listenerCtx);
|
||||
listener.updated((_ctx, doc) => {
|
||||
const md = doc?.toString() ?? "";
|
||||
setMarkdown(md);
|
||||
|
||||
const htmlContent = document.querySelector(".milkdown")?.innerHTML ?? "";
|
||||
setHtml(htmlContent);
|
||||
});
|
||||
})
|
||||
.use(gfm)
|
||||
.use(listener)
|
||||
.use(history)
|
||||
.use(clipboard)
|
||||
.use(cursor)
|
||||
.use(indent)
|
||||
.use(prism)
|
||||
.use(block);
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.(markdown, html);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<Tabs value={mode} onValueChange={(value) => setMode(value as "editor" | "markdown")}>
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="editor">WYSIWYG</TabsTrigger>
|
||||
<TabsTrigger value="markdown">Markdown</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="editor">
|
||||
<div className="border rounded-lg p-4 bg-background">
|
||||
<Milkdown />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="markdown">
|
||||
<Textarea
|
||||
value={markdown}
|
||||
onChange={(e) => setMarkdown(e.target.value)}
|
||||
rows={20}
|
||||
className="font-mono"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,139 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { $createCodeNode } from "@lexical/code"
|
||||
import { TOGGLE_LINK_COMMAND } from "@lexical/link"
|
||||
import {
|
||||
INSERT_UNORDERED_LIST_COMMAND,
|
||||
REMOVE_LIST_COMMAND,
|
||||
} from "@lexical/list"
|
||||
import {
|
||||
useLexicalComposerContext,
|
||||
} from "@lexical/react/LexicalComposerContext"
|
||||
import {
|
||||
$createHeadingNode,
|
||||
$createQuoteNode,
|
||||
} from "@lexical/rich-text"
|
||||
import {
|
||||
$setBlocksType,
|
||||
} from "@lexical/selection"
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from "lexical"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export default function ToolbarPlugin() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
// You can optionally handle selection changes here.
|
||||
}
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_CRITICAL
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
const format = (formatType: "bold" | "italic" | "underline" | "strikethrough") => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, formatType)
|
||||
}
|
||||
|
||||
const setHeading = (tag: "h1" | "h2" | "h3" | "paragraph") => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
if (tag === "paragraph") {
|
||||
$setBlocksType(selection, () => $createParagraphNode())
|
||||
} else {
|
||||
$setBlocksType(selection, () => $createHeadingNode(tag))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const insertQuote = () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createQuoteNode())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const insertCodeBlock = () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createCodeNode())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const insertBulletList = () => {
|
||||
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
|
||||
}
|
||||
|
||||
const removeList = () => {
|
||||
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined)
|
||||
}
|
||||
|
||||
const toggleLink = () => {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
|
||||
url: "https://example.com", // optional: replace with modal input
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 border-b pb-2 mb-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => format("bold")}>
|
||||
Bold
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => format("italic")}>
|
||||
Italic
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => format("underline")}>
|
||||
Underline
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => format("strikethrough")}>
|
||||
Strike
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setHeading("paragraph")}>
|
||||
P
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setHeading("h1")}>
|
||||
H1
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setHeading("h2")}>
|
||||
H2
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setHeading("h3")}>
|
||||
H3
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={insertBulletList}>
|
||||
• List
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={removeList}>
|
||||
⛔ List
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={insertQuote}>
|
||||
“ Quote
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={insertCodeBlock}>
|
||||
Code
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={toggleLink}>
|
||||
🔗 Link
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user