项目地址:tiptap-editor
效果展示:tiptap-editor-tuntun.netlify.app/
持续更新中
初始化浮动菜单
在之前文章的操作已经支持了富文本大部分的元素,但是仅仅是可以展示,还没有一个方便的UI来编辑文本
传统的富文本编辑器操作文本的组件都是在顶部,但是像notion、飞书文档等都是采用浮动菜单的形式来编辑对应的文本。
tiptap已经有浮动菜单的插件了,并且针对React和Vue也有集成的方式;由于我们使用的是react,所以可以从@tiptap/react
中导出BubbleMenu
使用。
import { BubbleMenu } from '@tiptap/react'
编写
新建src/components/editor/menu/text-menu.tsx
编写浮动菜单的基本结构,这里除了传入editor
实例,还设置了菜单的pluginKey
,这是因为我们之后还要添加更多的浮动菜单,所以这里要通过设置pluginKey
来区分不同的浮动菜单插件。
import { BubbleMenu, Editor } from '@tiptap/react'
export const TextMenu = ({ editor }: { editor: Editor }) => {
return (
<BubbleMenu pluginKey="text-menu" editor={editor}>
TextMenu
</BubbleMenu>
)
}
浮动菜单底层使用的是tippyjs,插件也提供了可以修改tippyjs的参数。
<BubbleMenu
tippyOptions={{
animation: 'fade',
popperOptions: {
placement: 'top-start',
modifiers: [
{
name: 'preventOverflow',
options: {
boundary: 'viewport',
padding: 8,
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['bottom-start', 'top-end', 'bottom-end'],
},
},
],
},
maxWidth: 'calc(100vw - 16px)',
}}
pluginKey="text-menu"
editor={editor}>
TextMenu
</BubbleMenu>
浮动菜单提供了一个参数shouldShow
来控制是否展示浮动菜单,所以需要写一个判断当前选中内容是否是文字的方法。
如果后续添加新的插件,根据插件是否需要展示浮动菜单,
shouldShow
的逻辑也会更改。
const shouldShow = (props: {
editor: Editor
view: EditorView
state: EditorState
oldState?: EditorState
from: number
to: number
}) => {
const { view, editor } = props
// 把不需要展示文字浮窗菜单的节点类型过滤掉
if (
[CodeBlock.name, Table.name, Link.name, HorizontalRule.name].some(
(name) => editor.isActive(name)
)
) {
return false
}
if (!view || editor.view.dragging) {
return false
}
const {
state: {
doc,
selection,
selection: { empty, from, to },
},
} = editor
const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(selection)
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false
}
return true
}
之后将浮动窗口渲染出来
// src/components/editor/index.tsx
return (
<div className="relative">
<EditorContent editor={editor} />
{editor && <TextMenu editor={editor} />}
</div>
)
这样,只有在选中文本内容的时候,浮动菜单的内容就会展示;而被我们过滤掉的元素,其中的文本被选中也不会展示
可以展示之后,就可以小改一下菜单的样式
<div className="flex flex-row items-center gap-1 p-1 border bg-white rounded-md dark:bg-zinc-800">
TextMenu
</div>
通用组件编写
Toggle Button
Toggle Button组件用处是设置一些有状态的按钮,比如文字可以是被加粗的或者是倾斜的,这种算是文字的状态,文字在被加粗和不被加粗的状态时,按钮的展示要有所不同。
这里使用Shadcn/ui的Toggle
组件,并且添加了Tooltip
功能。
// src/components/editor/menu/toggle-button.tsx
import { Toggle } from '@/components/ui/toggle'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { ToggleProps } from '@radix-ui/react-toggle'
import { forwardRef, memo, ReactNode } from 'react'
const RawToggleButton = forwardRef<
HTMLButtonElement,
ToggleProps & {
tooltip?: ReactNode
}
>(({ children, tooltip, ...props }, ref) => {
const content = (
<Toggle ref={ref} {...props} className={cn('w-8 h-8 p-0', props.className)}>
{children}
</Toggle>
)
if (tooltip) {
return (
<Tooltip>
<TooltipTrigger>
<div>{content}</div>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltip}</TooltipContent>
</Tooltip>
)
}
return content
})
RawToggleButton.displayName = 'RawToggleButton'
export const ToggleButton = memo(RawToggleButton)
添加Tooltip的Provider
<TooltipProvider>
<div className="relative">
<EditorContent editor={editor} />
{editor && <TextMenu editor={editor} />}
</div>
</TooltipProvider>
之后我们封装一个浮动菜单的Icon组件
// src/components/editor/menu/icon.tsx
import { cn } from '@/lib/utils'
import { icons } from 'lucide-react'
import { memo } from 'react'
export type IconProps = {
name: keyof typeof icons
className?: string
strokeWidth?: number
}
export const Icon = memo(({ name, className, strokeWidth }: IconProps) => {
const IconComponent = icons[name]
if (!IconComponent) {
return null
}
return (
<IconComponent
className={cn('w-4 h-4', className)}
strokeWidth={strokeWidth || 2.5}
/>
)
})
Icon.displayName = 'Icon'
之后用加粗作为例子,添加一个加粗文字的菜单项
<ToggleButton
onPressedChange={(pressed) => {
if (pressed) {
editor.chain().focus().toggleBold().run()
} else {
editor.chain().focus().unsetBold().run()
}
}}
pressed={editor.isActive('bold')}
tooltip="Bold">
<Icon name="Bold" />
</ToggleButton>
点击后即可为选中的文字加粗,并且按钮变为激活态
如果选中的文字已经是加粗的,加粗按钮已经显示为激活态
基础菜单项
按照上面的方法,我们写一些基础的菜单项
<ToggleButton
onPressedChange={(pressed) => {
if (pressed) {
editor.chain().focus().toggleBold().run()
} else {
editor.chain().focus().unsetBold().run()
}
}}
pressed={editor.isActive('bold')}
tooltip="加粗">
<Icon name="Bold" />
</ToggleButton>
<ToggleButton
onPressedChange={(pressed) => {
if (pressed) {
editor.chain().focus().toggleItalic().run()
} else {
editor.chain().focus().unsetItalic().run()
}
}}
pressed={editor.isActive('italic')}
tooltip="斜体">
<Icon name="Italic" />
</ToggleButton>
<ToggleButton
onPressedChange={(pressed) => {
if (pressed) {
editor.chain().focus().toggleStrike().run()
} else {
editor.chain().focus().unsetStrike().run()
}
}}
pressed={editor.isActive('strike')}
tooltip="删除线">
<Icon name="Strikethrough" />
</ToggleButton>
<ToggleButton
onPressedChange={(pressed) => {
if (pressed) {
editor.chain().focus().toggleUnderline().run()
} else {
editor.chain().focus().unsetUnderline().run()
}
}}
pressed={editor.isActive('underline')}
tooltip="下划线">
<Icon name="Underline" />
</ToggleButton>
<ToggleButton
onPressedChange={(pressed) => {
if (pressed) {
editor.chain().focus().toggleCode().run()
} else {
editor.chain().focus().unsetCode().run()
}
}}
pressed={editor.isActive('code')}
tooltip="代码">
<Icon name="Code" />
</ToggleButton>
<ToggleButton
onPressedChange={() => {
editor.chain().focus().toggleCodeBlock().run()
}}
pressed={editor.isActive('codeBlock')}
tooltip="代码块">
<Icon name="FileCode" />
</ToggleButton>
<ToggleButton
onPressedChange={() => {
editor.chain().focus().toggleSuperscript().run()
}}
pressed={editor.isActive('superscript')}
tooltip="上标">
<Icon name="Superscript" />
</ToggleButton>
<ToggleButton
onPressedChange={() => {
editor.chain().focus().toggleSubscript().run()
}}
pressed={editor.isActive('subscript')}
tooltip="下标">
<Icon name="Subscript" />
</ToggleButton>
这样就支持了支持了切换加粗、斜体、删除线、下划线、内联代码、代码块、上标、下标的功能
文本类型选择器
文本类型选择器的功能是选择当前文本是正文或者标题还是列表
首先编写类型选择器的框架,使用Shadcn/ui的Popover组件进行封装。
// src/components/editor/menu/content-type-picker.tsx
import { icons } from 'lucide-react'
import { memo, useMemo } from 'react'
import { ToggleButton } from './toggle-button'
import { Icon } from './icon'
import { Editor, useEditorState } from '@tiptap/react'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import styles from './index.module.css'
export interface ContentTypePickerOptionItem {
icon: keyof typeof icons
name: string
id: string
onClick: () => void
disabled: () => boolean
isActive: () => boolean
}
export const useContentTypePicker = (editor: Editor) => {
return useEditorState({
editor,
selector: (ctx): ContentTypePickerOptionItem[] => [
{
icon: 'Pilcrow',
name: 'Paragraph',
id: 'paragraph',
onClick: () =>
ctx.editor
.chain()
.focus()
.lift('taskItem')
.liftListItem('listItem')
.setParagraph()
.run(),
disabled: () => !ctx.editor.can().setParagraph(),
isActive: () =>
ctx.editor.isActive('paragraph') &&
!ctx.editor.isActive('orderedList') &&
!ctx.editor.isActive('bulletList') &&
!ctx.editor.isActive('taskList'),
},
{
icon: 'Heading1',
name: 'Heading 1',
id: 'heading1',
onClick: () =>
ctx.editor
.chain()
.focus()
.lift('taskItem')
.liftListItem('listItem')
.setHeading({ level: 1 })
.run(),
disabled: () => !ctx.editor.can().setHeading({ level: 1 }),
isActive: () => ctx.editor.isActive('heading', { level: 1 }),
},
],
})
}
export const ContentTypePicker = ({ editor }: { editor: Editor }) => {
const options = useContentTypePicker(editor)
const activeItem = useMemo(
() => options.find((option) => option.isActive()),
[options]
)
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToggleButton
className={cn(
'w-12 flex items-center gap-1',
styles.popoverArrowBtn
)}
tooltip="内容类型">
<Icon name={activeItem?.icon ?? 'Pilcrow'} />
<Icon className={cn(styles.popoverArrow)} name="ChevronDown" />
</ToggleButton>
</DropdownMenuTrigger>
<DropdownMenuContent
asChild
align="start"
sideOffset={8}
alignOffset={-4}
className="p-1 w-[200px]">
<div className="flex flex-col gap-1">
{options.map((option) => (
<ToggleButton
key={option.id}
onClick={option.onClick}
pressed={option.isActive()}
className={cn(
'w-full flex items-center gap-2 justify-start pl-4',
styles.popoverArrowBtn
)}>
<Icon name={option.icon} />
{option.name}
</ToggleButton>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const MemoContentTypePicker = memo(ContentTypePicker)
// index.module.css
.popoverArrow {
transition: all 0.3s;
}
.popoverArrowBtn[data-state="open"] .popoverArrow {
transform: rotate(180deg);
}
这里要注意一个点就是我们把Shadcn/ui的部分代码做了更改,不将content渲染到body上。
// src/components/ui/dropdown-menu.tsx
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
// <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
// </DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
将该组件添加到菜单中,并且增加一个分割线。之后就可以通过该组件切换正文和一级标题了。
之后再完善一下支持切换的类型
export const useContentTypePicker = (editor: Editor) => {
return useEditorState({
editor,
selector: (ctx): ContentTypePickerOptionItem[] => [
{
icon: 'Pilcrow',
name: '正文',
id: 'paragraph',
onClick: () =>
ctx.editor
.chain()
.focus()
.lift('taskItem')
.liftListItem('listItem')
.setParagraph()
.run(),
disabled: () => !ctx.editor.can().setParagraph(),
isActive: () =>
ctx.editor.isActive('paragraph') &&
!ctx.editor.isActive('orderedList') &&
!ctx.editor.isActive('bulletList') &&
!ctx.editor.isActive('taskList'),
},
{
icon: 'Heading1',
name: '一级标题',
id: 'heading1',
onClick: () =>
ctx.editor
.chain()
.focus()
.lift('taskItem')
.liftListItem('listItem')
.setHeading({ level: 1 })
.run(),
disabled: () => !ctx.editor.can().setHeading({ level: 1 }),
isActive: () => ctx.editor.isActive('heading', { level: 1 }),
},
{
icon: 'Heading2',
name: '二级标题',
id: 'heading2',
onClick: () =>
ctx.editor
.chain()
.focus()
.lift('taskItem')
.liftListItem('listItem')
.setHeading({ level: 2 })
.run(),
disabled: () => !ctx.editor.can().setHeading({ level: 2 }),
isActive: () => ctx.editor.isActive('heading', { level: 2 }),
},
{
icon: 'Heading3',
name: '三级标题',
id: 'heading3',
onClick: () =>
ctx.editor
.chain()
.focus()
.lift('taskItem')
.liftListItem('listItem')
.setHeading({ level: 3 })
.run(),
disabled: () => !ctx.editor.can().setHeading({ level: 3 }),
isActive: () => ctx.editor.isActive('heading', { level: 3 }),
},
{
icon: 'Heading4',
name: '四级标题',
id: 'heading4',
onClick: () =>
ctx.editor
.chain()
.focus()
.lift('taskItem')
.liftListItem('listItem')
.setHeading({ level: 4 })
.run(),
disabled: () => !ctx.editor.can().setHeading({ level: 4 }),
isActive: () => ctx.editor.isActive('heading', { level: 4 }),
},
{
icon: 'List',
name: '无序列表',
id: 'bulletList',
onClick: () => ctx.editor.chain().focus().toggleBulletList().run(),
disabled: () => !ctx.editor.can().toggleBulletList(),
isActive: () => ctx.editor.isActive('bulletList'),
},
{
icon: 'ListOrdered',
name: '有序列表',
id: 'orderedList',
onClick: () => ctx.editor.chain().focus().toggleOrderedList().run(),
disabled: () => !ctx.editor.can().toggleOrderedList(),
isActive: () => ctx.editor.isActive('orderedList'),
},
{
icon: 'ListTodo',
name: '待办列表',
id: 'todoList',
onClick: () => ctx.editor.chain().focus().toggleTaskList().run(),
disabled: () => !ctx.editor.can().toggleTaskList(),
isActive: () => ctx.editor.isActive('taskList'),
},
{
icon: 'Quote',
name: '引用',
id: 'blockquote',
onClick: () => ctx.editor.chain().focus().toggleBlockquote().run(),
disabled: () => !ctx.editor.can().toggleBlockquote(),
isActive: () => ctx.editor.isActive('blockquote'),
},
],
})
}
文字对齐选择器
支持文字对齐功能需要安装插件
pnpm install @tiptap/extension-text-align
之后对插件进行配置
TextAlign.configure({
types: ['heading', 'paragraph'],
})
之后就可以编写对齐选择器组件了
// src/components/editor/menu/align-picker.tsx
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Icon } from './icon'
import { ToggleButton } from './toggle-button'
import { Editor, useEditorState } from '@tiptap/react'
import { icons } from 'lucide-react'
import { memo } from 'react'
import { cn } from '@/lib/utils'
import styles from './index.module.css'
export interface AlignPickerOptionItem {
icon: keyof typeof icons
name: string
id: string
onClick: () => void
isActive: () => boolean
}
export const AlignPicker = ({ editor }: { editor: Editor }) => {
const options = useEditorState({
editor,
selector: (ctx): AlignPickerOptionItem[] => [
{
name: '左对齐',
id: 'align-left',
icon: 'AlignLeft',
onClick: () => ctx.editor.chain().focus().setTextAlign('left').run(),
isActive: () => ctx.editor.isActive({ textAlign: 'left' }),
},
{
name: '居中对齐',
id: 'align-center',
icon: 'AlignCenter',
onClick: () => ctx.editor.chain().focus().setTextAlign('center').run(),
isActive: () => ctx.editor.isActive({ textAlign: 'center' }),
},
{
name: '右对齐',
id: 'align-right',
icon: 'AlignRight',
onClick: () => ctx.editor.chain().focus().setTextAlign('right').run(),
isActive: () => ctx.editor.isActive({ textAlign: 'right' }),
},
{
name: '两端对齐',
id: 'justify',
icon: 'AlignJustify',
onClick: () => ctx.editor.chain().focus().setTextAlign('justify').run(),
isActive: () => ctx.editor.isActive({ textAlign: 'justify' }),
},
],
})
const activeItem = useMemo(() => {
return options.find((option) => option.isActive())
}, [options])
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToggleButton
pressed={false}
className={cn('w-12 flex items-center gap-1', styles.popoverArrowBtn)}
tooltip="对齐">
<Icon name={activeItem?.icon ?? 'AlignLeft'} />
<Icon className={cn(styles.popoverArrow)} name="ChevronDown" />
</ToggleButton>
</DropdownMenuTrigger>
<DropdownMenuContent
asChild
align="start"
sideOffset={8}
alignOffset={-4}
className="px-1 py-2 w-[200px]">
<div className="flex flex-col gap-1">
{options.map((option) => (
<ToggleButton
key={option.id}
pressed={option.isActive()}
className={cn(
'w-full flex items-center gap-2 justify-start pl-4',
styles.popoverArrowBtn
)}
onClick={option.onClick}>
<Icon name={option.icon} />
{option.name}
</ToggleButton>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
export const MemoAlignPicker = memo(AlignPicker)
之后就可以切换文本的对齐方式了