diff --git a/package-lock.json b/package-lock.json index 540beb1..ffc885f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f8ae86a..9d7832b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ProfessionalBlogPostForm.tsx b/src/components/ProfessionalBlogPostForm.tsx index 8421809..359848f 100644 --- a/src/components/ProfessionalBlogPostForm.tsx +++ b/src/components/ProfessionalBlogPostForm.tsx @@ -258,7 +258,7 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos

- {editIdentifier ? 'Edit Article' : 'Write New Article'} + {editIdentifier ? 'Edit Article' : 'New Article'}

{editIdentifier ? 'Update your article' : 'Share your thoughts with the world'} @@ -437,7 +437,7 @@ export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPos Content -

+
setEditorState(value)} diff --git a/src/components/blocks/editor-00/nodes.ts b/src/components/blocks/editor-00/nodes.ts index 0629382..57b04e9 100644 --- a/src/components/blocks/editor-00/nodes.ts +++ b/src/components/blocks/editor-00/nodes.ts @@ -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 | LexicalNodeReplacement> = - [HeadingNode, ParagraphNode, TextNode, QuoteNode] + [ + HeadingNode, + ParagraphNode, + TextNode, + QuoteNode, + ListNode, + ListItemNode, + CodeNode, + CodeHighlightNode, + LinkNode, + AutoLinkNode, + ] diff --git a/src/components/blocks/editor-00/plugins.tsx b/src/components/blocks/editor-00/plugins.tsx index c2898ab..9e41f1e 100644 --- a/src/components/blocks/editor-00/plugins.tsx +++ b/src/components/blocks/editor-00/plugins.tsx @@ -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 (
- {/* toolbar plugins */} + {/* Toolbar */} + + + {/* Core Plugins */} + + + + + + {/* Editor */}
+
- +
} ErrorBoundary={LexicalErrorBoundary} /> - {/* editor plugins */}
- {/* actions plugins */}
) } diff --git a/src/components/editor/plugins/toolbar/ToolbarPlugin.tsx b/src/components/editor/plugins/toolbar/ToolbarPlugin.tsx new file mode 100644 index 0000000..0a4d75e --- /dev/null +++ b/src/components/editor/plugins/toolbar/ToolbarPlugin.tsx @@ -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('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 ( +
+ {/* Undo/Redo */} +
+ + +
+ + + + {/* Block Type Selector */} + + + + + {/* Text Formatting */} +
+ formatText('bold')} + title="Bold" + className="h-8 w-8 p-0" + > + + + formatText('italic')} + title="Italic" + className="h-8 w-8 p-0" + > + + + formatText('underline')} + title="Underline" + className="h-8 w-8 p-0" + > + + + formatText('strikethrough')} + title="Strikethrough" + className="h-8 w-8 p-0" + > + + + formatText('code')} + title="Inline Code" + className="h-8 w-8 p-0" + > + + +
+ + + + {/* Quick Headings */} +
+ + + +
+ + + + {/* Lists and Quote */} +
+ + + + +
+ + + + {/* Link */} + + + + + +
+

Insert Link

+
+ setLinkUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleLinkSubmit(); + } + }} + /> + +
+
+
+
+ + + + {/* Text Alignment */} +
+ + + + +
+
+ ); +}