基于tiptap实现富文本编辑器 - 文字浮动菜单(1)

583 阅读7分钟

项目地址:tiptap-editor

效果展示:tiptap-editor-tuntun.netlify.app/

持续更新中

初始化浮动菜单

在之前文章的操作已经支持了富文本大部分的元素,但是仅仅是可以展示,还没有一个方便的UI来编辑文本

传统的富文本编辑器操作文本的组件都是在顶部,但是像notion、飞书文档等都是采用浮动菜单的形式来编辑对应的文本。

image.png

image.png

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>
)

这样,只有在选中文本内容的时候,浮动菜单的内容就会展示;而被我们过滤掉的元素,其中的文本被选中也不会展示

image.png

可以展示之后,就可以小改一下菜单的样式

<div className="flex flex-row items-center gap-1 p-1 border bg-white rounded-md dark:bg-zinc-800">
  TextMenu
</div>

image.png

通用组件编写

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>

image.png

点击后即可为选中的文字加粗,并且按钮变为激活态

image.png

如果选中的文字已经是加粗的,加粗按钮已经显示为激活态

image.png

基础菜单项

按照上面的方法,我们写一些基础的菜单项

<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>

image.png

这样就支持了支持了切换加粗、斜体、删除线、下划线、内联代码、代码块、上标、下标的功能

文本类型选择器

文本类型选择器的功能是选择当前文本是正文或者标题还是列表

首先编写类型选择器的框架,使用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

将该组件添加到菜单中,并且增加一个分割线。之后就可以通过该组件切换正文和一级标题了。

image.png

之后再完善一下支持切换的类型

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'),
      },
    ],
  })
}

image.png

文字对齐选择器

支持文字对齐功能需要安装插件

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)

之后就可以切换文本的对齐方式了

image.png