react-example-notion

PreviousNext

Notion-style editor with block-based editing.

Docs
prosekitblock

Preview

Loading preview…
registry/src/react/examples/notion/block-handle-menu.tsx
import { Menu } from '@base-ui/react'
import type { Editor } from 'prosekit/core'
import { clsx } from 'prosekit/core'
import type { ListAttrs } from 'prosekit/extensions/list'
import { useEditorDerivedValue } from 'prosekit/react'
import { useState } from 'react'

import type { EditorExtension } from './extension'

const POPUP_CLASSNAME =
  'origin-[var(--transform-origin)] rounded-md bg-[canvas] py-1 text-gray-900 shadow-lg shadow-gray-200 outline outline-1 outline-gray-200 transition-[transform,scale,opacity] data-[ending-style]:scale-90 data-[ending-style]:opacity-0 data-[starting-style]:scale-90 data-[starting-style]:opacity-0 dark:shadow-none dark:-outline-offset-1 dark:outline-gray-300 w-50'

const ITEM_CLASSNAME =
  'flex items-center justify-between gap-2 cursor-default py-2 px-3 text-sm leading-4 outline-none select-none data-highlighted:relative data-highlighted:z-0 data-highlighted:text-gray-50 data-highlighted:before:absolute data-highlighted:before:inset-x-1 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] data-highlighted:before:rounded-sm data-highlighted:before:bg-gray-900'

const TEXT_COLOR_CLASSNAME = clsx(`border rounded-sm relative after:absolute after:inset-0 after:flex after:items-center after:justify-center after:content-['A']`)

interface Props {
  children: React.ReactElement
}

interface SubmenuInfo {
  key: string
  label: string
  iconClassName?: string
  isAvailable: boolean
  children: ItemInfo[]
}

interface MenuItemInfo {
  key: string
  label: string
  isActive: boolean
  isAvailable: boolean
  iconClassName?: string
  shortcut?: string
  danger?: boolean
  onClick: () => void
  children?: never
}

type ItemInfo = SubmenuInfo | MenuItemInfo

function getActiveBlockType(editor: Editor<EditorExtension>) {
  if (editor.nodes.heading.isActive({ level: 1 })) {
    return 'h1'
  }

  if (editor.nodes.heading.isActive({ level: 2 })) {
    return 'h2'
  }

  if (editor.nodes.heading.isActive({ level: 3 })) {
    return 'h3'
  }

  if (editor.nodes.list.isActive({ kind: 'bullet' })) {
    return 'bullet-list'
  }

  if (editor.nodes.list.isActive({ kind: 'ordered' })) {
    return 'ordered-list'
  }

  if (editor.nodes.list.isActive({ kind: 'task' })) {
    return 'task-list'
  }

  if (editor.nodes.list.isActive({ kind: 'toggle' })) {
    return 'toggle-list'
  }

  if (editor.nodes.image.isActive()) {
    return 'image'
  }

  return 'text'
}

function turnIntoList(editor: Editor<EditorExtension>, attrs: ListAttrs) {
  editor.commands.setParagraph()
  editor.commands.wrapInList(attrs)
}

function getMenuItems(editor: Editor<EditorExtension>): ItemInfo[] {
  const activeBlockType = getActiveBlockType(editor)

  return [
    {
      key: 'turn-into',
      label: 'Turn into',
      iconClassName: 'i-lucide-refresh-cw',
      isAvailable: activeBlockType !== 'image',
      children: [
        {
          key: 'text',
          label: 'Text',
          iconClassName: 'i-lucide-type',
          isActive: activeBlockType === 'text',
          isAvailable: editor.commands.setParagraph.canExec(),
          onClick: () => editor.commands.setParagraph(),
        },
        {
          key: 'h1',
          label: 'Heading 1',
          iconClassName: 'i-lucide-heading-1',
          isActive: activeBlockType === 'h1',
          isAvailable: editor.commands.setHeading.canExec({ level: 1 }),
          onClick: () => editor.commands.setHeading({ level: 1 }),
        },
        {
          key: 'h2',
          label: 'Heading 2',
          iconClassName: 'i-lucide-heading-2',
          isActive: activeBlockType === 'h2',
          isAvailable: editor.commands.setHeading.canExec({ level: 2 }),
          onClick: () => editor.commands.setHeading({ level: 2 }),
        },
        {
          key: 'h3',
          label: 'Heading 3',
          iconClassName: 'i-lucide-heading-3',
          isActive: activeBlockType === 'h3',
          isAvailable: editor.commands.setHeading.canExec({ level: 3 }),
          onClick: () => editor.commands.setHeading({ level: 3 }),
        },
        {
          key: 'bullet-list',
          label: 'Bullet list',
          iconClassName: 'i-lucide-list',
          isActive: activeBlockType === 'bullet-list',
          isAvailable: editor.commands.wrapInList.canExec({ kind: 'bullet' }),
          onClick: () => turnIntoList(editor, { kind: 'bullet' }),
        },
        {
          key: 'ordered-list',
          label: 'Ordered list',
          iconClassName: 'i-lucide-list-ordered',
          isActive: activeBlockType === 'ordered-list',
          isAvailable: editor.commands.wrapInList.canExec({ kind: 'ordered' }),
          onClick: () => turnIntoList(editor, { kind: 'ordered' }),
        },
        {
          key: 'task-list',
          label: 'Task list',
          iconClassName: 'i-lucide-list-checks',
          isActive: activeBlockType === 'task-list',
          isAvailable: editor.commands.wrapInList.canExec({ kind: 'task' }),
          onClick: () => turnIntoList(editor, { kind: 'task' }),
        },
        {
          key: 'toggle-list',
          label: 'Toggle list',
          iconClassName: 'i-lucide-list-collapse',
          isActive: activeBlockType === 'toggle-list',
          isAvailable: editor.commands.wrapInList.canExec({ kind: 'toggle' }),
          onClick: () => turnIntoList(editor, { kind: 'toggle' }),
        },
      ],
    },
    {
      key: 'color',
      label: 'Color',
      iconClassName: 'i-lucide-paint-roller',
      isAvailable: activeBlockType !== 'image',
      children: [
        {
          key: 'default',
          label: 'Default Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-current text-current'),
          isActive: !editor.marks.textColor.isActive(),
          isAvailable: editor.commands.removeTextColor.canExec(),
          onClick: () => editor.commands.removeTextColor(),
        },
        {
          key: 'gray',
          label: 'Gray Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-gray-300 text-gray-500'),
          isActive: editor.marks.textColor.isActive({ color: 'gray' }),
          isAvailable: editor.commands.addTextColor.canExec({ color: 'gray' }),
          onClick: () => editor.commands.addTextColor({ color: 'gray' }),
        },
        {
          key: 'orange',
          label: 'Orange Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-orange-300 text-orange-500'),
          isActive: editor.marks.textColor.isActive({ color: 'orange' }),
          isAvailable: editor.commands.addTextColor.canExec({ color: 'orange' }),
          onClick: () => editor.commands.addTextColor({ color: 'orange' }),
        },
        {
          key: 'yellow',
          label: 'Yellow Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-yellow-300 text-yellow-500'),
          isActive: editor.marks.textColor.isActive({ color: 'yellow' }),
          isAvailable: editor.commands.addTextColor.canExec({ color: 'yellow' }),
          onClick: () => editor.commands.addTextColor({ color: 'yellow' }),
        },
        {
          key: 'green',
          label: 'Green Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-green-300 text-green-500'),
          isActive: editor.marks.textColor.isActive({ color: 'green' }),
          isAvailable: editor.commands.addTextColor.canExec({ color: 'green' }),
          onClick: () => editor.commands.addTextColor({ color: 'green' }),
        },
        {
          key: 'blue',
          label: 'Blue Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-blue-300 text-blue-500'),
          isActive: editor.marks.textColor.isActive({ color: 'blue' }),
          isAvailable: editor.commands.addTextColor.canExec({ color: 'blue' }),
          onClick: () => editor.commands.addTextColor({ color: 'blue' }),
        },
        {
          key: 'purple',
          label: 'Purple Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-purple-300 text-purple-500'),
          isActive: editor.marks.textColor.isActive({ color: 'purple' }),
          isAvailable: editor.commands.addTextColor.canExec({ color: 'purple' }),
          onClick: () => editor.commands.addTextColor({ color: 'purple' }),
        },
        {
          key: 'pink',
          label: 'Pink Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-pink-300 text-pink-500'),
          isActive: editor.marks.textColor.isActive({ color: 'pink' }),
          isAvailable: editor.commands.addTextColor.canExec({ color: 'pink' }),
          onClick: () => editor.commands.addTextColor({ color: 'pink' }),
        },
        {
          key: 'red',
          label: 'Red Text',
          iconClassName: clsx(TEXT_COLOR_CLASSNAME, 'border-red-300 text-red-500'),
          isActive: editor.marks.textColor.isActive({ color: 'red' }),
          isAvailable: editor.commands.addTextColor.canExec({ color: 'red' }),
          onClick: () => editor.commands.addTextColor({ color: 'red' }),
        },
      ],
    },
    {
      key: 'delete',
      label: 'Delete',
      iconClassName: 'i-lucide-trash-2',
      shortcut: 'Del',
      danger: true,
      isActive: false,
      isAvailable: true,
      onClick: () => editor.view.dispatch(editor.view.state.tr.deleteSelection()),
    },
  ]
}

function BlockHandleItem(props: { item: ItemInfo }) {
  if (!props.item.isAvailable) {
    return null
  } else if (props.item.children) {
    return (
      <Menu.SubmenuRoot>
        <Menu.SubmenuTrigger className={ITEM_CLASSNAME}>
          {props.item.iconClassName && <span className={clsx('inline-block size-4', props.item.iconClassName)} />}
          <span className="flex-1">{props.item.label}</span>
          <span className="inline-block size-4 i-lucide-chevron-right opacity-50">
          </span>
        </Menu.SubmenuTrigger>
        <Menu.Portal>
          <Menu.Positioner align="center">
            <Menu.Popup className={POPUP_CLASSNAME}>
              {props.item.children.map(item => <BlockHandleItem key={item.key} item={item} />)}
            </Menu.Popup>
          </Menu.Positioner>
        </Menu.Portal>
      </Menu.SubmenuRoot>
    )
  } else {
    return (
      <Menu.Item
        className={clsx(ITEM_CLASSNAME, 'group')}
        onClick={props.item.onClick}
      >
        {props.item.iconClassName && <span className={clsx('inline-block size-5', props.item.iconClassName)} />}
        <span className={clsx('flex-1', props.item.danger && 'group-data-highlighted:text-red-500')}>{props.item.label}</span>
        {props.item.isActive && <span className="inline-block size-4 i-lucide-check"></span>}
        {!props.item.isActive && props.item.shortcut && <span className="opacity-50">{props.item.shortcut}</span>}
      </Menu.Item>
    )
  }
}

export default function BlockHandleMenu(props: Props) {
  const [open, setOpen] = useState(false)

  const items = useEditorDerivedValue(getMenuItems)

  return (
    <Menu.Root
      open={open}
      onOpenChange={(open, details) => {
        // ignore the event to open the menu because by default Menu is opened
        // by a `mousedown` event but we only want to open the menu by a `click`
        // event.
        if (open && details.reason === 'trigger-press') {
          return
        }
        setOpen(open)
      }}
    >
      <Menu.Trigger
        render={props.children}
        nativeButton={false}
        onClick={(event) => {
          event.preventDefault()
          setOpen(open => !open)
        }}
      >
      </Menu.Trigger>
      <Menu.Portal>
        <Menu.Backdrop className="size-dvw flex fixed inset-0 opacity-0" />
        <Menu.Positioner className="outline-none" side="right" align="center">
          <Menu.Popup className={POPUP_CLASSNAME}>
            {items.map(item => <BlockHandleItem key={item.key} item={item} />)}
          </Menu.Popup>
        </Menu.Positioner>
      </Menu.Portal>
    </Menu.Root>
  )
}

Installation

npx shadcn@latest add @prosekit/react-example-notion

Usage

import { ReactExampleNotion } from "@/components/react-example-notion"
<ReactExampleNotion />