Add Lexical plugins and update editor functionality in ProfessionalBlogPostForm

This commit is contained in:
2025-10-05 19:15:29 +02:00
parent 5d4341d738
commit 54ce6f55a2
6 changed files with 520 additions and 8 deletions

4
package-lock.json generated
View File

@@ -11,9 +11,13 @@
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@lexical/code": "^0.36.2",
"@lexical/link": "^0.36.2",
"@lexical/list": "^0.36.2",
"@lexical/markdown": "^0.36.2",
"@lexical/react": "^0.36.2",
"@lexical/rich-text": "^0.36.2",
"@lexical/selection": "^0.36.2",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.8",
"@radix-ui/react-accordion": "^1.2.0",

View File

@@ -13,9 +13,13 @@
"@fontsource-variable/inter": "^5.2.6",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^3.9.0",
"@lexical/code": "^0.36.2",
"@lexical/link": "^0.36.2",
"@lexical/list": "^0.36.2",
"@lexical/markdown": "^0.36.2",
"@lexical/react": "^0.36.2",
"@lexical/rich-text": "^0.36.2",
"@lexical/selection": "^0.36.2",
"@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.4",
"@nostrify/react": "npm:@jsr/nostrify__react@^0.2.8",
"@radix-ui/react-accordion": "^1.2.0",

View File

@@ -258,7 +258,7 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{editIdentifier ? 'Edit Article' : 'Write New Article'}
{editIdentifier ? 'Edit Article' : 'New Article'}
</h1>
<p className="text-muted-foreground mt-1">
{editIdentifier ? 'Update your article' : 'Share your thoughts with the world'}
@@ -437,7 +437,7 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos
<CardTitle className="text-lg">Content</CardTitle>
</CardHeader>
<CardContent>
<div className={`${isMobile ? 'min-h-[400px]' : 'min-h-[600px]'}`}>
<div >
<Editor
editorSerializedState={editorState}
onSerializedChange={(value) => setEditorState(value)}

View File

@@ -1,4 +1,7 @@
import { HeadingNode, QuoteNode } from "@lexical/rich-text"
import { ListNode, ListItemNode } from "@lexical/list"
import { CodeNode, CodeHighlightNode } from "@lexical/code"
import { LinkNode, AutoLinkNode } from "@lexical/link"
import {
Klass,
LexicalNode,
@@ -8,4 +11,15 @@ import {
} from "lexical"
export const nodes: ReadonlyArray<Klass<LexicalNode> | LexicalNodeReplacement> =
[HeadingNode, ParagraphNode, TextNode, QuoteNode]
[
HeadingNode,
ParagraphNode,
TextNode,
QuoteNode,
ListNode,
ListItemNode,
CodeNode,
CodeHighlightNode,
LinkNode,
AutoLinkNode,
]

View File

@@ -1,8 +1,14 @@
import { useState } from "react"
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"
import { ListPlugin } from "@lexical/react/LexicalListPlugin"
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"
import { TRANSFORMERS } from "@lexical/markdown"
import { ContentEditable } from "@/components/editor/editor-ui/content-editable"
import { ToolbarPlugin } from "@/components/editor/plugins/toolbar/ToolbarPlugin"
export function Plugins() {
const [_floatingAnchorElem, setFloatingAnchorElem] =
@@ -16,21 +22,28 @@ export function Plugins() {
return (
<div className="relative">
{/* toolbar plugins */}
{/* Toolbar */}
<ToolbarPlugin />
{/* Core Plugins */}
<HistoryPlugin />
<ListPlugin />
<LinkPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
{/* Editor */}
<div className="relative">
<RichTextPlugin
contentEditable={
<div className="">
<div className="min-h-[400px]">
<div className="" ref={onRef}>
<ContentEditable placeholder={"Start typing ..."} />
<ContentEditable placeholder={"Start typing your article..."} />
</div>
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
{/* editor plugins */}
</div>
{/* actions plugins */}
</div>
)
}

View File

@@ -0,0 +1,477 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useCallback, useEffect, useState } from 'react';
import {
$getSelection,
$isRangeSelection,
FORMAT_TEXT_COMMAND,
FORMAT_ELEMENT_COMMAND,
REDO_COMMAND,
UNDO_COMMAND,
$createParagraphNode,
} from 'lexical';
import { $setBlocksType } from '@lexical/selection';
import {
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
REMOVE_LIST_COMMAND,
$isListNode,
ListNode,
} from '@lexical/list';
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
type HeadingTagType,
} from '@lexical/rich-text';
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { $isCodeNode, $createCodeNode } from '@lexical/code';
import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils';
import {
Bold,
Italic,
Underline,
Strikethrough,
Code,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Link as LinkIcon,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Undo,
Redo,
FileCode,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Toggle } from '@/components/ui/toggle';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
const LowPriority = 1;
type BlockType = 'paragraph' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'quote' | 'code' | 'ul' | 'ol';
export function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const [blockType, setBlockType] = useState<BlockType>('paragraph');
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isUnderline, setIsUnderline] = useState(false);
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const [isLink, setIsLink] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [isLinkPopoverOpen, setIsLinkPopoverOpen] = useState(false);
const updateToolbar = useCallback(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// Update text format
setIsBold(selection.hasFormat('bold'));
setIsItalic(selection.hasFormat('italic'));
setIsUnderline(selection.hasFormat('underline'));
setIsStrikethrough(selection.hasFormat('strikethrough'));
setIsCode(selection.hasFormat('code'));
// Update link
const node = selection.anchor.getNode();
const parent = node.getParent();
if ($isLinkNode(parent) || $isLinkNode(node)) {
setIsLink(true);
} else {
setIsLink(false);
}
// Update block type
const anchorNode = selection.anchor.getNode();
const element =
anchorNode.getKey() === 'root'
? anchorNode
: anchorNode.getTopLevelElementOrThrow();
const elementKey = element.getKey();
const elementDOM = editor.getElementByKey(elementKey);
if (elementDOM !== null) {
if ($isListNode(element)) {
const parentList = $getNearestNodeOfType(anchorNode, ListNode);
const type = parentList ? parentList.getListType() : element.getListType();
setBlockType(type === 'number' ? 'ol' : 'ul');
} else {
const type = $isHeadingNode(element)
? element.getTag()
: element.getType();
setBlockType(type as BlockType);
}
}
}
}, [editor]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar();
});
}),
editor.registerCommand(
REDO_COMMAND,
() => {
setCanRedo(editor.getEditorState()._nodeMap.size > 0);
return false;
},
LowPriority
),
editor.registerCommand(
UNDO_COMMAND,
() => {
setCanUndo(editor.getEditorState()._nodeMap.size > 0);
return false;
},
LowPriority
)
);
}, [editor, updateToolbar]);
const formatText = (format: 'bold' | 'italic' | 'underline' | 'strikethrough' | 'code') => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
};
const formatBlockType = (type: BlockType) => {
if (type === 'paragraph') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode());
}
});
} else if (type === 'h1' || type === 'h2' || type === 'h3' || type === 'h4' || type === 'h5' || type === 'h6') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(type as HeadingTagType));
}
});
} else if (type === 'quote') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode());
}
});
} else if (type === 'code') {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
if ($isCodeNode(selection.anchor.getNode())) {
$setBlocksType(selection, () => $createParagraphNode());
} else {
$setBlocksType(selection, () => $createCodeNode());
}
}
});
} else if (type === 'ul') {
if (blockType !== 'ul') {
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
} else if (type === 'ol') {
if (blockType !== 'ol') {
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
} else {
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
}
}
};
const formatAlignment = (alignment: 'left' | 'center' | 'right' | 'justify') => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment);
};
const insertLink = () => {
if (!isLink) {
setIsLinkPopoverOpen(true);
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
};
const handleLinkSubmit = () => {
if (linkUrl) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl);
setLinkUrl('');
setIsLinkPopoverOpen(false);
}
};
return (
<div className="flex flex-wrap items-center gap-1 p-2 border-b bg-muted/30">
{/* Undo/Redo */}
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
disabled={!canUndo}
className="h-8 w-8 p-0"
title="Undo"
>
<Undo className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
disabled={!canRedo}
className="h-8 w-8 p-0"
title="Redo"
>
<Redo className="h-4 w-4" />
</Button>
</div>
<Separator orientation="vertical" className="h-8" />
{/* Block Type Selector */}
<Select value={blockType} onValueChange={(value) => formatBlockType(value as BlockType)}>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="paragraph">Paragraph</SelectItem>
<SelectItem value="h1">Heading 1</SelectItem>
<SelectItem value="h2">Heading 2</SelectItem>
<SelectItem value="h3">Heading 3</SelectItem>
<SelectItem value="quote">Quote</SelectItem>
<SelectItem value="code">Code Block</SelectItem>
</SelectContent>
</Select>
<Separator orientation="vertical" className="h-8" />
{/* Text Formatting */}
<div className="flex gap-1">
<Toggle
size="sm"
pressed={isBold}
onPressedChange={() => formatText('bold')}
title="Bold"
className="h-8 w-8 p-0"
>
<Bold className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={isItalic}
onPressedChange={() => formatText('italic')}
title="Italic"
className="h-8 w-8 p-0"
>
<Italic className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={isUnderline}
onPressedChange={() => formatText('underline')}
title="Underline"
className="h-8 w-8 p-0"
>
<Underline className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={isStrikethrough}
onPressedChange={() => formatText('strikethrough')}
title="Strikethrough"
className="h-8 w-8 p-0"
>
<Strikethrough className="h-4 w-4" />
</Toggle>
<Toggle
size="sm"
pressed={isCode}
onPressedChange={() => formatText('code')}
title="Inline Code"
className="h-8 w-8 p-0"
>
<Code className="h-4 w-4" />
</Toggle>
</div>
<Separator orientation="vertical" className="h-8" />
{/* Quick Headings */}
<div className="flex gap-1">
<Button
variant={blockType === 'h1' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => formatBlockType('h1')}
title="Heading 1"
className="h-8 w-8 p-0"
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
variant={blockType === 'h2' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => formatBlockType('h2')}
title="Heading 2"
className="h-8 w-8 p-0"
>
<Heading2 className="h-4 w-4" />
</Button>
<Button
variant={blockType === 'h3' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => formatBlockType('h3')}
title="Heading 3"
className="h-8 w-8 p-0"
>
<Heading3 className="h-4 w-4" />
</Button>
</div>
<Separator orientation="vertical" className="h-8" />
{/* Lists and Quote */}
<div className="flex gap-1">
<Button
variant={blockType === 'ul' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => formatBlockType('ul')}
title="Bullet List"
className="h-8 w-8 p-0"
>
<List className="h-4 w-4" />
</Button>
<Button
variant={blockType === 'ol' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => formatBlockType('ol')}
title="Numbered List"
className="h-8 w-8 p-0"
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
variant={blockType === 'quote' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => formatBlockType('quote')}
title="Quote"
className="h-8 w-8 p-0"
>
<Quote className="h-4 w-4" />
</Button>
<Button
variant={blockType === 'code' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => formatBlockType('code')}
title="Code Block"
className="h-8 w-8 p-0"
>
<FileCode className="h-4 w-4" />
</Button>
</div>
<Separator orientation="vertical" className="h-8" />
{/* Link */}
<Popover open={isLinkPopoverOpen} onOpenChange={setIsLinkPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant={isLink ? 'secondary' : 'ghost'}
size="sm"
onClick={insertLink}
title="Insert Link"
className="h-8 w-8 p-0"
>
<LinkIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="space-y-2">
<h4 className="font-medium text-sm">Insert Link</h4>
<div className="flex gap-2">
<Input
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleLinkSubmit();
}
}}
/>
<Button size="sm" onClick={handleLinkSubmit}>
Insert
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Separator orientation="vertical" className="h-8" />
{/* Text Alignment */}
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => formatAlignment('left')}
title="Align Left"
className="h-8 w-8 p-0"
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => formatAlignment('center')}
title="Align Center"
className="h-8 w-8 p-0"
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => formatAlignment('right')}
title="Align Right"
className="h-8 w-8 p-0"
>
<AlignRight className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => formatAlignment('justify')}
title="Justify"
className="h-8 w-8 p-0"
>
<AlignJustify className="h-4 w-4" />
</Button>
</div>
</div>
);
}