Shadcn Editor
Installation
bash
npx shadcn@latest add zeno-admin/shadcn-editor/editor也可以使用部署后的 Registry URL:
bash
npx shadcn@latest add https://shadcn-editor-nu.vercel.app/r/editor.json已有 shadcn/ui 组件的项目在覆盖提示中选择 No,保留项目现有的 UI 实现。
@zenoadmin 收录进 后可使用:
bash
npx shadcn@latest add @zenoadmin/editorHost locale and theme
Editor 会继承宿主项目根节点的 .dark / .light class 和 shadcn CSS variables,无需单独的 ThemeProvider。
宿主已有语言开关时,使用 <Editor showLanguageSelect={false} /> 隐藏 Editor 内置语言选择器。
ZenoAdmin 可以把 Inertia locale 直接传给 Editor;zh_CN 会自动映射为 zh-CN:
tsx
const { locale } = useTranslation();
<EditorI18nProvider locale={locale} onLocaleChange={(next) => switchAdminLocale(locale, next === "zh-CN" ? "zh_CN" : next) }> <Editor /></EditorI18nProvider>;Usage
ts
import { CodeHighlightNode, CodeNode } from "@lexical/code";import { AutoFocusExtension, ClearEditorExtension, DecoratorTextExtension, HorizontalRuleExtension, SelectionAlwaysOnDisplayExtension } from "@lexical/extension";import { HistoryExtension } from "@lexical/history";import { AutoLinkExtension, ClickableLinkExtension, LinkExtension } from "@lexical/link";import { CheckListExtension, ListExtension } from "@lexical/list";import { CHECK_LIST, ELEMENT_TRANSFORMERS, MULTILINE_ELEMENT_TRANSFORMERS, TEXT_FORMAT_TRANSFORMERS, TEXT_MATCH_TRANSFORMERS } from "@lexical/markdown";import { OverflowNode } from "@lexical/overflow";import { CharacterLimitPlugin } from "@lexical/react/LexicalCharacterLimitPlugin";import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer";import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin";import { TablePlugin } from "@lexical/react/LexicalTablePlugin";import { RichTextExtension } from "@lexical/rich-text";import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";import { type EditorState, type SerializedEditorState, configExtension, defineExtension } from "lexical";import { useMemo, useState } from "react";
import { useBlockViewer } from "@/components/block-viewer-provider";import { ContentEditable } from "@/components/editor/editor-ui/content-editable";import { DateTimeExtension } from "@/components/editor/extensions/date-time-extension";import { EmojisExtension } from "@/components/editor/extensions/emojis-extension";import { ImagesExtension } from "@/components/editor/extensions/images-extension";import { MarkdownShortcutsExtension } from "@/components/editor/extensions/markdown-shortcuts-extension";import { MaxLengthExtension } from "@/components/editor/extensions/max-length-extension";import { AutocompleteNode } from "@/components/editor/nodes/autocomplete-node";import { TweetNode } from "@/components/editor/nodes/embeds/tweet-node";import { YouTubeNode } from "@/components/editor/nodes/embeds/youtube-node";import { EmojiNode } from "@/components/editor/nodes/emoji-node";import { LayoutContainerNode } from "@/components/editor/nodes/layout-container-node";import { LayoutItemNode } from "@/components/editor/nodes/layout-item-node";import { MentionNode } from "@/components/editor/nodes/mention-node";import { SpecialTextNode } from "@/components/editor/nodes/special-text-node";import { ActionsPlugin } from "@/components/editor/plugins/actions/actions-plugin";import { ClearEditorActionPlugin } from "@/components/editor/plugins/actions/clear-editor-plugin";import { CounterCharacterPlugin } from "@/components/editor/plugins/actions/counter-character-plugin";import { EditModeTogglePlugin } from "@/components/editor/plugins/actions/edit-mode-toggle-plugin";import { ImportExportPlugin } from "@/components/editor/plugins/actions/import-export-plugin";import { MarkdownTogglePlugin } from "@/components/editor/plugins/actions/markdown-toggle-plugin";import { ShareContentPlugin } from "@/components/editor/plugins/actions/share-content-plugin";import { SpeechToTextPlugin } from "@/components/editor/plugins/actions/speech-to-text-plugin";import { TreeViewPlugin } from "@/components/editor/plugins/actions/tree-view-plugin";import { AutoCompletePlugin } from "@/components/editor/plugins/auto-complete-plugin";import { CodeActionMenuPlugin } from "@/components/editor/plugins/code-action-menu-plugin";import { CodeHighlightPlugin } from "@/components/editor/plugins/code-highlight-plugin";import { ComponentPickerMenuPlugin } from "@/components/editor/plugins/component-picker-menu-plugin";import { ContextMenuPlugin } from "@/components/editor/plugins/context-menu-plugin";import { DraggableBlockPlugin } from "@/components/editor/plugins/draggable-block-plugin";import { AutoEmbedPlugin } from "@/components/editor/plugins/embeds/auto-embed-plugin";import { TwitterPlugin } from "@/components/editor/plugins/embeds/twitter-plugin";import { YouTubePlugin } from "@/components/editor/plugins/embeds/youtube-plugin";import { EmojiPickerPlugin } from "@/components/editor/plugins/emoji-picker-plugin";import { FloatingLinkEditorPlugin } from "@/components/editor/plugins/floating-link-editor-plugin";import { FloatingTextFormatToolbarPlugin } from "@/components/editor/plugins/floating-text-format-plugin";import { LayoutPlugin } from "@/components/editor/plugins/layout-plugin";import { MentionsPlugin } from "@/components/editor/plugins/mentions-plugin";import { DateTimePickerPlugin } from "@/components/editor/plugins/picker/date-time-picker-plugin";import { EmbedsPickerPlugin } from "@/components/editor/plugins/picker/embeds-picker-plugin";import { SpecialTextPlugin } from "@/components/editor/plugins/special-text-plugin";import { TabFocusPlugin } from "@/components/editor/plugins/tab-focus-plugin";import { BlockFormatDropDown } from "@/components/editor/plugins/toolbar/block-format-toolbar-plugin";import { FormatBulletedList } from "@/components/editor/plugins/toolbar/block-format/format-bulleted-list";import { FormatCheckList } from "@/components/editor/plugins/toolbar/block-format/format-check-list";import { FormatCodeBlock } from "@/components/editor/plugins/toolbar/block-format/format-code-block";import { FormatHeading } from "@/components/editor/plugins/toolbar/block-format/format-heading";import { FormatNumberedList } from "@/components/editor/plugins/toolbar/block-format/format-numbered-list";import { FormatParagraph } from "@/components/editor/plugins/toolbar/block-format/format-paragraph";import { FormatQuote } from "@/components/editor/plugins/toolbar/block-format/format-quote";import { BlockInsertPlugin } from "@/components/editor/plugins/toolbar/block-insert-plugin";import { InsertColumnsLayout } from "@/components/editor/plugins/toolbar/block-insert/insert-columns-layout";import { InsertEmbeds } from "@/components/editor/plugins/toolbar/block-insert/insert-embeds";import { InsertHorizontalRule } from "@/components/editor/plugins/toolbar/block-insert/insert-horizontal-rule";import { InsertImage } from "@/components/editor/plugins/toolbar/block-insert/insert-image";import { InsertTable } from "@/components/editor/plugins/toolbar/block-insert/insert-table";import { ClearFormattingToolbarPlugin } from "@/components/editor/plugins/toolbar/clear-formatting-toolbar-plugin";import { CodeLanguageToolbarPlugin } from "@/components/editor/plugins/toolbar/code-language-toolbar-plugin";import { ElementFormatToolbarPlugin } from "@/components/editor/plugins/toolbar/element-format-toolbar-plugin";import { FontBackgroundToolbarPlugin } from "@/components/editor/plugins/toolbar/font-background-toolbar-plugin";import { FontColorToolbarPlugin } from "@/components/editor/plugins/toolbar/font-color-toolbar-plugin";import { FontFamilyToolbarPlugin } from "@/components/editor/plugins/toolbar/font-family-toolbar-plugin";import { FontFormatToolbarPlugin } from "@/components/editor/plugins/toolbar/font-format-toolbar-plugin";import { FontSizeToolbarPlugin } from "@/components/editor/plugins/toolbar/font-size-toolbar-plugin";import { HistoryToolbarPlugin } from "@/components/editor/plugins/toolbar/history-toolbar-plugin";import { LinkToolbarPlugin } from "@/components/editor/plugins/toolbar/link-toolbar-plugin";import { SubSuperToolbarPlugin } from "@/components/editor/plugins/toolbar/subsuper-toolbar-plugin";import { ToolbarPlugin } from "@/components/editor/plugins/toolbar/toolbar-plugin";import { editorTheme } from "@/components/editor/themes/editor-theme";import { EMOJI } from "@/components/editor/transformers/markdown-emoji-transformer";import { HR } from "@/components/editor/transformers/markdown-hr-transformer";import { IMAGE } from "@/components/editor/transformers/markdown-image-transformer";import { TABLE } from "@/components/editor/transformers/markdown-table-transformer";import { TWEET } from "@/components/editor/transformers/markdown-tweet-transformer";import { validateUrl } from "@/components/editor/utils/url";import { Separator } from "@/components/ui/separator";import { TooltipProvider } from "@/components/ui/tooltip";
const placeholder = "Press / for commands...";const maxLength = 30;
export function Editor({ editorState, editorSerializedState, onChange, onSerializedChange,}: { editorState?: EditorState; editorSerializedState?: SerializedEditorState; onChange?: (editorState: EditorState) => void; onSerializedChange?: (editorSerializedState: SerializedEditorState) => void;}) { const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null); const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false);
const onRef = (_floatingAnchorElem: HTMLDivElement) => { if (_floatingAnchorElem !== null) { setFloatingAnchorElem(_floatingAnchorElem); } };
const AppExtension = useMemo( () => defineExtension({ dependencies: [ RichTextExtension, AutoFocusExtension, SelectionAlwaysOnDisplayExtension, HistoryExtension, configExtension(LinkExtension, { validateUrl, attributes: { rel: "noopener noreferrer", target: "_blank" }, }), AutoLinkExtension, ClickableLinkExtension, configExtension(MaxLengthExtension, { disabled: false, maxLength }), configExtension(MarkdownShortcutsExtension, { transformers: [ TABLE, HR, IMAGE, EMOJI, TWEET, CHECK_LIST, ...ELEMENT_TRANSFORMERS, ...MULTILINE_ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS, ], }), ClearEditorExtension, EmojisExtension, DecoratorTextExtension, configExtension(ListExtension, { shouldPreserveNumbering: false }), CheckListExtension, HorizontalRuleExtension, ImagesExtension, DateTimeExtension, ], name: "@shadcn-editor", namespace: "Playground", nodes: [ OverflowNode, EmojiNode, MentionNode, AutocompleteNode, SpecialTextNode, CodeNode, CodeHighlightNode, TableNode, TableCellNode, TableRowNode, LayoutContainerNode, LayoutItemNode, TweetNode, YouTubeNode, ], $initialEditorState(editor) { if (editorSerializedState) { editor.parseEditorState(editorSerializedState); } else if (editorState) { editor.setEditorState(editorState); } }, theme: editorTheme, }), [editorState, editorSerializedState], );
return ( <div className="bg-background overflow-hidden rounded-lg border shadow w-full"> <LexicalExtensionComposer extension={AppExtension} contentEditable={null}> <TooltipProvider> <div className="relative"> <ToolbarPlugin> {({ blockType }) => ( <div className="vertical-align-middle sticky top-0 z-10 flex items-center gap-2 overflow-auto border-b p-1"> <HistoryToolbarPlugin /> <Separator orientation="vertical" className="h-7!" /> <BlockFormatDropDown> <FormatParagraph /> <FormatHeading levels={["h1", "h2", "h3"]} /> <FormatNumberedList /> <FormatBulletedList /> <FormatCheckList /> <FormatCodeBlock /> <FormatQuote /> </BlockFormatDropDown> {blockType === "code" ? ( <CodeLanguageToolbarPlugin /> ) : ( <> <FontFamilyToolbarPlugin /> <Separator orientation="vertical" className="h-7!" /> <FontSizeToolbarPlugin /> <FontFormatToolbarPlugin /> <SubSuperToolbarPlugin /> <LinkToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} /> <ClearFormattingToolbarPlugin /> <FontColorToolbarPlugin /> <FontBackgroundToolbarPlugin /> <ElementFormatToolbarPlugin /> <BlockInsertPlugin> <InsertHorizontalRule /> <InsertImage /> <InsertTable /> <InsertColumnsLayout /> <InsertEmbeds /> </BlockInsertPlugin> </> )} </div> )} </ToolbarPlugin> <div className="relative"> <div className=""> <div className="" ref={onRef}> <ContentEditable placeholder={placeholder} className="h-[calc(100vh-141px)] pl-4" /> </div> </div> <ComponentPickerMenuPlugin baseOptions={[]} /> <EmojiPickerPlugin /> <AutoEmbedPlugin /> <MentionsPlugin /> <AutoCompletePlugin /> <ContextMenuPlugin /> <SpecialTextPlugin /> <TabFocusPlugin /> <TabIndentationPlugin /> <CodeHighlightPlugin /> <TablePlugin /> <LayoutPlugin /> <TwitterPlugin /> <YouTubePlugin /> <DraggableBlockPlugin anchorElem={floatingAnchorElem} baseOptions={[]} /> <FloatingTextFormatToolbarPlugin anchorElem={floatingAnchorElem} setIsLinkEditMode={setIsLinkEditMode} /> <FloatingLinkEditorPlugin anchorElem={floatingAnchorElem} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} /> <CodeActionMenuPlugin anchorElem={floatingAnchorElem} /> </div> <ActionsPlugin> <div className="clear-both flex items-center justify-between gap-2 overflow-auto border-t p-1"> <div className="flex flex-1 justify-start text-xs text-gray-500"> <CharacterLimitPlugin maxLength={maxLength} charset="UTF-16" /> </div> <div> <CounterCharacterPlugin charset="UTF-16" /> </div> <div className="flex flex-1 justify-end"> <SpeechToTextPlugin /> <ShareContentPlugin /> <ImportExportPlugin /> <MarkdownTogglePlugin shouldPreserveNewLinesInMarkdown={true} transformers={[ TABLE, HR, IMAGE, EMOJI, TWEET, CHECK_LIST, ...ELEMENT_TRANSFORMERS, ...MULTILINE_ELEMENT_TRANSFORMERS, ...TEXT_FORMAT_TRANSFORMERS, ...TEXT_MATCH_TRANSFORMERS, ]} /> <EditModeTogglePlugin /> <ClearEditorActionPlugin /> <TreeViewPlugin /> </div> </div> </ActionsPlugin> </div>
<OnChangePlugin ignoreSelectionChange={true} onChange={(editorState) => { onChange?.(editorState); onSerializedChange?.(editorState.toJSON()); }} /> </TooltipProvider> </LexicalExtensionComposer> </div> );}