This commit is contained in:
2025-07-06 14:17:06 +02:00
parent 9390eaa6eb
commit 946173a557
12 changed files with 3988 additions and 6 deletions

View File

@ -0,0 +1,44 @@
"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"
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
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>
)
}

View File

@ -0,0 +1,102 @@
"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>
)
}

View File

@ -0,0 +1,87 @@
"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>
);
}

View File

@ -0,0 +1,139 @@
"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>
)
}