石家庄企业做网站经典营销案例分析
本文为开发开源项目的真实开发经历,感兴趣的可以来给我的项目点个star,谢谢啦~
具体博文介绍:
开源|Documind协同文档(接入deepseek-r1、支持实时聊天)Documind 🚀 一个支持实时聊天和接入 - 掘金
前言
由于展示的代码都较为简单,只对个别地方进行讲解,自行阅读或AI辅助阅读即可。
抽离简单组件
这个工具栏由多个工具小组件组成,我们可以将简单的部分抽离成公共组件ToolbarButton
然后通过传入的配置项ToolbarButtonProps
来激活这个公共组件。
import { cn } from "@/lib/utils";
import { LucideIcon } from "lucide-react";interface ToolbarButtonProps {onClick?: () => void; //点击事件isActive?: boolean; //激活状态样式icon: LucideIcon; //具体icontitle: string; //名称
}
export const ToolbarButton = ({onClick,isActive,icon: Icon,title,
}: ToolbarButtonProps) => {return (<div className="flex flex-col items-center justify-center"><buttontype="button"onClick={onClick}title={title}className={cn("text-sm h-7 min-w-7 flex items-center justify-center rounded-sm hover:bg-neutral-200/80",isActive && "bg-neutral-200/80")}><Icon className="size-4" /></button></div>);
};
编写配置项
onClick
看不懂的话可以去看官方文档或者问AI
import {Undo2Icon,Redo2Icon,PrinterIcon,SpellCheckIcon,BoldIcon,ItalicIcon,UnderlineIcon,MessageSquareIcon,ListTodoIcon,RemoveFormattingIcon,type LucideIcon,
} from "lucide-react";
import { Editor } from '@tiptap/react';// 定义section项的类型
export interface ToolbarItem {label: string;icon: LucideIcon;onClick: (editor?: Editor) => void;isActive?: boolean;title: string;
}//传入的editor用于绑定事件
export const getToolbarSections = (editor?: Editor): ToolbarItem[][] => [[{label: "Undo",icon: Undo2Icon,onClick: () => editor?.chain().focus().undo().run(),isActive: false,title: "Undo",},{label: "Redo",icon: Redo2Icon,onClick: () => editor?.chain().focus().redo().run(),isActive: false,title: "Redo",},{label: "Print",icon: PrinterIcon,onClick: () => {window.print();},title: "Print",},{label: "Spell Check",icon: SpellCheckIcon,onClick: () => {const current = editor?.view.dom.getAttribute("spellcheck");editor?.view.dom.setAttribute("spellcheck",current === "true" ? "false" : "true");},title: "Spell Check",},],[{label: "Bold",icon: BoldIcon,isActive: typeof editor?.isActive === 'function' ? editor.isActive("bold") : false,onClick: () => editor?.chain().focus().toggleBold().run(),title: "Bold",},{label: "Italic",icon: ItalicIcon,isActive: typeof editor?.isActive === 'function' ? editor.isActive("italic") : false,onClick: () => editor?.chain().focus().toggleItalic().run(),title: "Italic",},{label: "Underline",icon: UnderlineIcon,isActive: editor?.isActive("underline"),onClick: () => editor?.chain().focus().toggleUnderline().run(),title: "Underline",},],[{label: "Comment",icon: MessageSquareIcon,onClick: () => {editor?.chain().focus().addPendingComment().run();},isActive: editor?.isActive("liveblocksCommentMark"),title: "Comment",},{label: "List Todo",icon: ListTodoIcon,onClick: () => {editor?.chain().focus().toggleTaskList().run();},isActive: editor?.isActive("taskList"),title: "List Todo",},{label: "Remove Formatting",icon: RemoveFormattingIcon,onClick: () => {editor?.chain().focus().unsetAllMarks().run();},title: "Remove Formatting",},],
];
组装配置项和公共组件
import { useEditorStore } from "@/store/use-editor-store";
import { getToolbarSections } from "@/lib/useSections";
const { editor } = useEditorStore();//来自zustand
const sections = getToolbarSections(editor || undefined);//传入的editor用于cnClick事件
{sections[0].map((item) => (<ToolbarButton key={item.label} {...item} />
))}{sections[2].map((item) => (<ToolbarButton key={item.label} {...item} />
))}
编写复杂组件
有些组件功能相对复杂,所以无法抽离成公共组件
FontFamilyButton
组件
使用了shadcn中的DropdownMenu
套件
用于设置字体的fontFamily
"use client";import { ChevronDownIcon } from "lucide-react";
import { useEditorStore } from "@/store/use-editor-store";
import {DropdownMenu,DropdownMenuContent,DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";//font预设
const fonts = [{ label: "Arial", value: "Arial" },{ label: "Times New Roman", value: "Times New Roman" },{ label: "Courier New", value: "Courier New" },{ label: "Georgia", value: "Georgia" },{ label: "Verdana", value: "Verdana" },
];export const FontFamilyButton = () => {const { editor } = useEditorStore(); //zustand状态管理return (<div className="flex flex-col items-center justify-center"><DropdownMenu><DropdownMenuTrigger asChild><buttontitle="Select font family"type="button"className={cn("h-7 w-[120px] shrink-0 flex items-center justify-between rounded-sm hover:bg-neutral-200/80 px-1.5 overflow-hidden text-sm")}><span className="truncate">{editor?.getAttributes("textStyle").fontFamily || "Arial"}</span><ChevronDownIcon className="ml-2 size-4 shrink-0" /></button></DropdownMenuTrigger><DropdownMenuContent className="p-1 flex flex-col gap-y-1">{fonts.map(({ label, value }) => (<buttononClick={() => editor?.chain().focus().setFontFamily(value).run()}key={value}title="Select font family"type="button"className={cn("w-full flex items-center gap-x-2 px-2 py-1 rounded-sm hover:bg-neutral-200/80",editor?.getAttributes("textStyle").fontFamily === value &&"bg-neutral-200/80")}style={{ fontFamily: value }}><span className="text-sm">{label}</span></button>))}</DropdownMenuContent></DropdownMenu></div>);
};
HeadingLevelButton
组件
使用了shadcn中的DropdownMenu
套件
用于设置标题大小
"use client";import { type Level } from "@tiptap/extension-heading";
import { ChevronDownIcon } from "lucide-react";
import { useEditorStore } from "@/store/use-editor-store";
import {DropdownMenuContent,DropdownMenuTrigger,DropdownMenu,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";export const HeadingLevelButton = () => {const { editor } = useEditorStore();const headings = [{ label: "Normal text", value: 0, fontSize: "16px" },{ label: "Heading 1", value: 1, fontSize: "32px" },{ label: "Heading 2", value: 2, fontSize: "24px" },{ label: "Heading 3", value: 3, fontSize: "20px" },{ label: "Heading 4", value: 4, fontSize: "18px" },{ label: "Heading 5", value: 5, fontSize: "16px" },];const getCurrentHeading = () => {for (let level = 1; level <= 5; level++) {if (editor?.isActive(`heading`, { level })) {return `Heading ${level}`;}}return "Normal text";};return (<div className="flex flex-col items-center justify-center"><DropdownMenu><DropdownMenuTrigger asChild><button className="h-7 w-[120px] flex shrink-0 items-center justify-center rounded-sm hover:bg-neutral-200/80 overflow-hidden text-sm"><span className="truncate">{getCurrentHeading()}</span><ChevronDownIcon className="ml-2 size-4 shrink-0" /></button></DropdownMenuTrigger><DropdownMenuContent className="p-1 flex flex-col gap-y-1">{headings.map(({ label, value, fontSize }) => (<buttonkey={value}style={{ fontSize }}onClick={() => {if (value === 0) {editor?.chain().focus().setParagraph().run();} else {editor?.chain().focus().toggleHeading({ level: value as Level }).run();}}}className={cn("flex item-ccenter gap-x-2 px-2 py-1 rounded-sm hover:bg-neutral-200/80",(value === 0 && !editor?.isActive("heading")) ||(editor?.isActive("heading", { level: value }) &&"bg-neutral-200/80"))}>{label}</button>))}</DropdownMenuContent></DropdownMenu></div>);
};
FontSizeButton
组件
import { MinusIcon, PlusIcon } from "lucide-react";
import { useEditorStore } from "@/store/use-editor-store";
import { useState, useEffect } from "react";export const FontSizeButton = () => {const { editor } = useEditorStore();// 获取当前字体大小(去除px单位)const currentFontSize = editor?.getAttributes("textStyle").fontSize? editor?.getAttributes("textStyle").fontSize.replace("px", ""): "16";const [fontSize, setFontSize] = useState(currentFontSize);const [inputValue, setInputValue] = useState(currentFontSize);const [isEditing, setIsEditing] = useState(false);const updateFontSize = (newSize: string) => {const size = parseInt(newSize); // 将字符串转换为数字if (!isNaN(size) && size > 0) {//应用层更新editor?.chain().focus().setFontSize(`${size}px`).run();//UI层状态更新setFontSize(newSize);setInputValue(newSize);setIsEditing(false);}};//用于显示当前选中文本的字体大小useEffect(() => {const update = () => {const current = editor?.getAttributes("textStyle").fontSize || "16px";const newFontSize = current.replace("px", "");setFontSize(newFontSize);setInputValue(newFontSize);setIsEditing(false);};//订阅tiptap的selectionUpdate事件editor?.on("selectionUpdate", update);// 返回一个清理函数,用于在组件卸载时取消订阅return () => {editor?.off("selectionUpdate", update);};}, [editor]);// 在输入框输入内容时,更新输入框的值const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {setInputValue(e.target.value);};// 在输入框失去焦点时,更新字体大小const handleInputBlur = () => {updateFontSize(inputValue);};// 在输入框按下回车键时,更新字体大小const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {if (e.key === "Enter") {e.preventDefault();updateFontSize(inputValue);editor?.commands.focus();}};//字号减const increment = () => {const newSize = parseInt(fontSize) + 1;updateFontSize(newSize.toString());};//字号加const decrement = () => {const newSize = parseInt(fontSize) - 1;updateFontSize(newSize.toString());};return (<div className="flex items-center gap-x-0.5">{/* 减号按钮 */}<buttonclassName="shrink-0 h-7 w-7 flex flex-col items-center justify-center rounded-sm hover:bg-neutral-200/80"onClick={decrement}title="font size"type="button"><MinusIcon className="size-4" /></button>{/* 输入框 */}{isEditing ? (<inputtype="text"value={inputValue}onChange={handleInputChange} //编辑中保存onBlur={handleInputBlur} //失去焦点后保存onKeyDown={handleKeyDown} //回车保存className="border border-neutral-400 text-center h-7 w-10 rounded-sm bg-transparent focus:outline-none focus:ring-0"/>) : (<buttonclassName="text-sm border border-neutral-400 text-center h-7 w-10 rounded-sm bg-transparent focus:outline-none focus:ring-0"onClick={() => {setIsEditing(true);setFontSize(currentFontSize);}}title="font size"type="button">{currentFontSize}</button>)}{/* 加号按钮 */}<buttonclassName="shrink-0 h-7 w-7 flex flex-col items-center justify-center rounded-sm hover:bg-neutral-200/80"onClick={increment}title="font size"type="button"><PlusIcon className="size-4" /></button></div>);
};
TextColorbutton
组件
使用了shadcn中的DropdownMenu
套件和react-color中的SketchPicker
颜色选择器
用于设置字体颜色
import { useEditorStore } from "@/store/use-editor-store";
import {DropdownMenuContent,DropdownMenuTrigger,DropdownMenu,
} from "@/components/ui/dropdown-menu";
import { type ColorResult, SketchPicker } from "react-color";
export const TextColorbutton = () => {const { editor } = useEditorStore();const value = editor?.getAttributes("textStyle").color || "#000000";//当前所选文本颜色//用于选择颜色后SketchPicker 组件会将其传入onChangeconst onChange = (color: ColorResult) => {editor?.chain().focus().setColor(color.hex).run();};return (<div className="flex flex-col items-center justify-center"><DropdownMenu><DropdownMenuTrigger asChild><buttontitle="Text Color"className="text-sm h-7 min-w-7 flex flex-col items-center justify-center rounded-sm hover:bg-neutral-200/80"><span className="text-xs">A</span><divclassName="h-0.5 w-full"style={{ backgroundColor: value }}></div></button></DropdownMenuTrigger><DropdownMenuContent className="p-0"><SketchPicker color={value} // 传入当前颜色以展示onChange={onChange} //设置tiptap中文本颜色/></DropdownMenuContent></DropdownMenu></div>);
};
HighlightButton
组件
和上面TextColorbutton
组件相似,这里是用于设置字体的背景颜色
import { useEditorStore } from "@/store/use-editor-store";
import {DropdownMenuContent,DropdownMenuTrigger,DropdownMenu,
} from "@/components/ui/dropdown-menu";
import { type ColorResult, SketchPicker } from "react-color";
import { HighlighterIcon } from "lucide-react";
export const HighlightButton = () => {const { editor } = useEditorStore();const value = editor?.getAttributes("highlight").color || "#FFFFFFFF";const onChange = (color: ColorResult) => {editor?.chain().focus().setHighlight({ color: color.hex }).run();};return (<div className="flex flex-col items-center justify-center"><DropdownMenu><DropdownMenuTrigger asChild><buttontitle="Text Color"className="text-sm h-7 min-w-7 flex flex-col items-center justify-center rounded-sm hover:bg-neutral-200/80"><HighlighterIcon className="size-4" /></button></DropdownMenuTrigger><DropdownMenuContent className="p-0"><SketchPicker color={value} onChange={onChange} /></DropdownMenuContent></DropdownMenu></div>);
};
LinkButton
组件
使用了shadcn中的DropdownMenu
套件
用于给选中文本添加跳转链接
import { useEditorStore } from "@/store/use-editor-store";
import { useState } from "react";
import { Link2Icon } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {DropdownMenuContent,DropdownMenuTrigger,DropdownMenu,
} from "@/components/ui/dropdown-menu";export const LinkButton = () => {const { editor } = useEditorStore();const [value, setValue] = useState("");//给选中文本设置链接属性const onChange = (href: string) => {editor?.chain().focus().extendMarkRange("link").setLink({ href }).run();setValue("");};return (<div className="flex flex-col items-center justify-center"><DropdownMenu//下拉菜单时提取当前所选文本的链接属性onOpenChange={(open) => {if (open) {setValue(editor?.getAttributes("link").href || "");}}}><DropdownMenuTrigger asChild><buttontitle="Text Color"className="text-sm h-7 min-w-7 flex flex-col items-center justify-center rounded-sm hover:bg-neutral-200/80"><Link2Icon className="size-4" /></button></DropdownMenuTrigger><DropdownMenuContent className="p-2.5 flex items-center gap-x-2"><Inputplaceholder="https://example.com"value={value}onChange={(e) => setValue(e.target.value)}/>{/* 点击后触发默认事件关闭下拉菜单 */}<Button onClick={() => onChange(value)}>Apply</Button></DropdownMenuContent></DropdownMenu></div>);
};
ImageButton
组件
使用了cloudinary
的上传组件CldUploadButton
依赖下载:npm i next-cloudinary
需要在env环境变量中添加CLOUDINARY_URL=xxxx
(只需在env中设置无需显示调用,在官网获取)uploadPreset
的值也需要从cloudinary
官网获取,具体见next-cloudinary
文档。
此组件引出了闭包捕获问题,具体见文章:
import { useEditorStore } from "@/store/use-editor-store";
import { ImageIcon } from "lucide-react";
import { CldUploadButton } from "next-cloudinary";export const ImageButton = () => {const onChange = (src: string) => {const currentEditor = useEditorStore.getState().editor; currentEditor?.chain().focus().setImage({ src }).run();}const uploadPhoto = (result: any) => {onChange(result?.info?.secure_url);};return (<div className="flex flex-col items-center justify-center">{/* 图片插入下拉菜单 */}<CldUploadButtonoptions={{ maxFiles: 1 }}onSuccess={uploadPhoto}uploadPreset="官网获取"><ImageIcon className="size-4" /></CldUploadButton></div>);
};
AlignButton
组件
使用了shadcn中的DropdownMenu
套件
用于提供四种文本对其方式:
- 左对齐(AlignLeft)
- 居中对齐(Align Center)
- 右对齐(AlignRight)
- 两端对齐(AlignJustify)
import {AlignCenterIcon,AlignJustifyIcon,AlignLeftIcon,AlignRightIcon,
} from "lucide-react";
import {DropdownMenu,DropdownMenuContent,DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/use-editor-store";export const AlignButton = () => {const { editor } = useEditorStore();const alignments = [{label: "Align Left",icon: AlignLeftIcon,value: "left",},{label: "Align Center",icon: AlignCenterIcon,value: "center",},{label: "Align Right",icon: AlignRightIcon,value: "right",},{label: "Align Justify",icon: AlignJustifyIcon,value: "justify",},];return (<div className="flex flex-col items-center justify-center"><DropdownMenu><DropdownMenuTrigger asChild><buttontitle="Align"className="text-sm h-7 min-w-7 flex flex-col items-center justify-center rounded-sm hover:bg-neutral-200/80"><AlignLeftIcon className="size-4" /></button></DropdownMenuTrigger><DropdownMenuContent className="p-0">{alignments.map(({ label, icon: Icon, value }) => (<buttonkey={value}title={label}onClick={() => editor?.chain().focus().setTextAlign(value).run()}className={cn("w-full flex items-center gap-x-2 px-1 py-1 rounded-sm hover:bg-neutral-200/80",editor?.isActive({ textAlign: value }) && "bg-neutral-200/80")}><Icon className="size-4" /><span className="text-sm">{label}</span></button>))}</DropdownMenuContent></DropdownMenu></div>);
};
ListButton
组件
使用了shadcn中的DropdownMenu
套件
用于提供下拉菜单切换无序列表和有序列表
import { ListIcon, ListOrderedIcon } from "lucide-react";
import {DropdownMenu,DropdownMenuContent,DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/use-editor-store";export const ListButton = () => {const { editor } = useEditorStore();const lists = [{label: "Bullet List",icon: ListIcon,isActive: () => editor?.isActive("bulletlist"),onClick: () => editor?.chain().focus().toggleBulletList().run(),},{label: "Ordered List",icon: ListOrderedIcon,isActive: () => editor?.isActive("orderedlist"),onClick: () => editor?.chain().focus().toggleOrderedList().run(),},];return (<div className="flex flex-col items-center justify-center"><DropdownMenu><DropdownMenuTrigger asChild><buttontitle="Align"className="text-sm h-7 min-w-7 flex flex-col items-center justify-center rounded-sm hover:bg-neutral-200/80"type="button"><ListIcon className="size-4" /></button></DropdownMenuTrigger><DropdownMenuContent className="p-0">{lists.map(({ label, icon: Icon, onClick, isActive }) => (<buttonkey={label}onClick={onClick}className={cn("w-full flex items-center gap-x-2 px-1 py-1 rounded-sm hover:bg-neutral-200/80",isActive() && "bg-neutral-200/80")}><Icon className="size-4" /><span className="text-sm">{label}</span></button>))}</DropdownMenuContent></DropdownMenu></div>);
};
LineHeightButton
组件
使用了shadcn中的DropdownMenu
套件
用于切换行高
import { ListCollapseIcon } from "lucide-react";
import {DropdownMenu,DropdownMenuContent,DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/store/use-editor-store";export const LineHeightButton = () => {const { editor } = useEditorStore();const listHeights = [{label:"Default",value:"normal"},{label:"Single",value:"1"},{label:"1.15",value:"1.15"},{label:"1.5",value:"1.5"},{label:"Double",value:"2"},];return (<div className="flex flex-col items-center justify-center"><DropdownMenu><DropdownMenuTrigger asChild><buttontitle="Align"className="text-sm h-7 min-w-7 flex flex-col items-center justify-center rounded-sm hover:bg-neutral-200/80"><ListCollapseIcon className="size-4" /></button></DropdownMenuTrigger><DropdownMenuContent className="p-0">{listHeights.map(({ label, value }) => (<buttonkey={value}title={label}onClick={() => editor?.chain().focus().setLineHeight(value).run()}type="button"className={cn("w-full flex items-center gap-x-2 px-1 py-1 rounded-sm hover:bg-neutral-200/80" ,editor?.getAttributes("paragraph")?.lineHeight === value && "bg-neutral-200/80")}><span className="text-sm">{label}</span></button>))}</DropdownMenuContent></DropdownMenu></div>);
};
拼接成toolbar工具栏组件
Separator
为shadcn中分割线组件
"use client";
import { ToolbarButton } from "./toolBarButton";
import { useEditorStore } from "@/store/use-editor-store";
import { Separator } from "@/components/ui/separator";
import { FontFamilyButton } from "./fontFamilyButton";
import { getToolbarSections } from "@/lib/useSections";
import { HeadingLevelButton } from "./headingButton";
import { TextColorbutton } from "./textColorButton";
import { HighlightButton } from "./highLightButton";
import { LinkButton } from "./linkButton";
import { ImageButton } from "./imageButton";
import { AlignButton } from "./alignButton";
import { ListButton } from "./ListButton";
import { FontSizeButton } from "./fontSizeButton";
import { LineHeightButton } from "./lineHeightButton";
export const Toolbar = () => {const { editor } = useEditorStore();const sections = getToolbarSections(editor || undefined);return (<div className="bg-[#F1F4F9] px-2.5 py-0.5 rounded-[24px] min-h-[40px] flex item-center gap-x-0.5 overflow-x-auto ">{sections[0].map((item) => (<ToolbarButton key={item.label} {...item} />))}{/* 分隔符组件 */}<div className="flex flex-col items-center justify-center"><Separator orientation="vertical" className="h-6 bg-neutral-300" /></div><FontFamilyButton /><div className="flex flex-col items-center justify-center"><Separator orientation="vertical" className="h-6 bg-neutral-300" /></div><HeadingLevelButton /><div className="flex flex-col items-center justify-center"><Separator orientation="vertical" className="h-6 bg-neutral-300" /></div>{/* TODO:Font size */}<FontSizeButton /><div className="flex flex-col items-center justify-center"><Separator orientation="vertical" className="h-6 bg-neutral-300" /></div>{sections[1].map((item) => (<ToolbarButton key={item.label} {...item} />))}<TextColorbutton /><HighlightButton /><div className="flex flex-col items-center justify-center"><Separator orientation="vertical" className="h-6 bg-neutral-300" /></div><LinkButton /><ImageButton /><AlignButton /><ListButton /><LineHeightButton />{sections[2].map((item) => (<ToolbarButton key={item.label} {...item} />))}</div>);
};