shadimage
prosekit
blockreact-example-notionsource: ok · hit

react-example-notion

Notion-style editor with block-based editing.

npx shadcn add "https://prosekit.dev/r/react-example-notion.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@base-ui/react

Registry dependencies

https://unpkg.com/prosekit-registry/dist/r/react-sample-sample-doc-notion.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-sample-sample-tag-data.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-sample-sample-uploader.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-sample-sample-user-data.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-ui-code-block-view.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-ui-drop-indicator.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-ui-inline-menu.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-ui-table-handle.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-ui-tag-menu.jsonhttps://unpkg.com/prosekit-registry/dist/r/react-ui-user-menu.json

Files14 · source cached 10/14/2026, 11:41:04 AM

registry/src/react/examples/notion/block-handle-menu.tsx
'use client'

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.ts'

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>
  )
}
registry/src/react/examples/notion/block-handle.tsx
'use client'

import { Tooltip } from '@base-ui/react/tooltip'
import { BlockHandleAdd, BlockHandleDraggable, BlockHandlePopup, BlockHandlePositioner, BlockHandleRoot } from 'prosekit/react/block-handle'

import BlockHandleMenu from './block-handle-menu.tsx'

interface Props {
  enabled: boolean
  dir?: 'ltr' | 'rtl'
}

export default function BlockHandle(props: Props) {
  if (!props.enabled) {
    return null
  }

  return (
    <Tooltip.Provider>
      <BlockHandleRoot>
        <BlockHandlePositioner
          placement={props.dir === 'rtl' ? 'right' : 'left'}
          className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
        >
          <BlockHandlePopup className="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95">
            <Tooltip.Root>
              <Tooltip.Trigger className="m-0 p-0">
                <BlockHandleAdd className="h-6 w-6 cursor-pointer flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
                  <div className="i-lucide-plus size-5 block" />
                </BlockHandleAdd>
              </Tooltip.Trigger>
              <Tooltip.Portal>
                <Tooltip.Positioner sideOffset={10} side="bottom">
                  <Tooltip.Popup className="
                      flex flex-col justify-center items-center
                      px-2 py-1
                      rounded-md
                      bg-[canvas]
                      text-sm
                      z-100
                      origin-(--transform-origin)
                      shadow-lg shadow-gray-200 outline-1 outline-gray-200
                      transition-[transform,scale,opacity]
                      data-ending-style:opacity-0 data-ending-style:scale-90
                      data-instant:transition-none
                      data-starting-style:opacity-0 data-starting-style:scale-90
                      dark:shadow-none dark:outline-gray-300 dark:-outline-offset-1">
                    <span>
                      <span>Click{' '}</span>
                      <span className="opacity-50">to add below</span>
                    </span>
                    <span>
                      <span>Option-click{' '}</span>
                      <span className="opacity-50">to add above</span>
                    </span>
                  </Tooltip.Popup>
                </Tooltip.Positioner>
              </Tooltip.Portal>
            </Tooltip.Root>
            <Tooltip.Root>
              <Tooltip.Trigger className="m-0 p-0">
                <BlockHandleMenu>
                  <BlockHandleDraggable className="h-6 w-5 cursor-grab flex items-center box-border justify-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-400/50">
                    <div className="i-lucide-grip-vertical size-5 block" />
                  </BlockHandleDraggable>
                </BlockHandleMenu>
              </Tooltip.Trigger>
              <Tooltip.Portal>
                <Tooltip.Positioner sideOffset={10} side="bottom">
                  <Tooltip.Popup className="
                      flex flex-col justify-center items-center
                      px-2 py-1
                      rounded-md
                      bg-[canvas]
                      text-sm
                      z-100
                      origin-(--transform-origin)
                      shadow-lg shadow-gray-200 outline-1 outline-gray-200
                      transition-[transform,scale,opacity]
                      data-ending-style:opacity-0 data-ending-style:scale-90
                      data-instant:transition-none
                      data-starting-style:opacity-0 data-starting-style:scale-90
                      dark:shadow-none dark:outline-gray-300 dark:-outline-offset-1">
                    <span>
                      <span>Drag{' '}</span>
                      <span className="opacity-50">to move</span>
                    </span>
                    <span>
                      <span>Click{' '}</span>
                      <span className="opacity-50">to open menu</span>
                    </span>
                  </Tooltip.Popup>
                </Tooltip.Positioner>
              </Tooltip.Portal>
            </Tooltip.Root>
          </BlockHandlePopup>
        </BlockHandlePositioner>
      </BlockHandleRoot>
    </Tooltip.Provider>
  )
}
registry/src/react/examples/notion/editor.tsx
'use client'

import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import './style.css'

import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo, useState } from 'react'

import { sampleContent } from '../../sample/sample-doc-notion.ts'
import { tags } from '../../sample/sample-tag-data.ts'
import { users } from '../../sample/sample-user-data.ts'
import { DropIndicator } from '../../ui/drop-indicator/index.ts'
import { InlineMenu } from '../../ui/inline-menu/index.ts'
import { TableHandle } from '../../ui/table-handle/index.ts'
import { TagMenu } from '../../ui/tag-menu/index.ts'
import { UserMenu } from '../../ui/user-menu/index.ts'

import BlockHandle from './block-handle.tsx'
import { defineExtension } from './extension.ts'
import { SlashMenu } from './slash-menu/index.ts'

interface EditorProps {
  initialContent?: NodeJSON
}

export default function Editor(props: EditorProps) {
  const defaultContent = props.initialContent ?? sampleContent
  const editor = useMemo(() => {
    const extension = defineExtension()
    return createEditor({ extension, defaultContent })
  }, [defaultContent])

  const [slashMenuOpen, setSlashMenuOpen] = useState(false)

  return (
    <ProseKit editor={editor}>
      <div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white" data-prosekit-example="notion">
        <div className="relative w-full flex-1 box-border overflow-y-auto">
          <div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
          <InlineMenu />
          <SlashMenu onOpenChange={setSlashMenuOpen} />
          <UserMenu users={users} />
          <TagMenu tags={tags} />
          <BlockHandle enabled={!slashMenuOpen} />
          <TableHandle />
          <DropIndicator />
        </div>
      </div>
    </ProseKit>
  )
}
registry/src/react/examples/notion/extension.ts
import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { defineTextColor } from 'prosekit/extensions/text-color'

import { sampleUploader } from '../../sample/sample-uploader.ts'
import { defineCodeBlockView } from '../../ui/code-block-view/index.ts'

import { defineImageView } from './image-view/index.ts'

export function defineExtension() {
  return union(
    defineBasicExtension(),
    definePlaceholder({ placeholder: 'Press / for commands...' }),
    defineMention(),
    defineCodeBlockShiki(),
    defineHorizontalRule(),
    defineCodeBlockView(),
    defineImageView(),
    defineImageUploadHandler({
      uploader: sampleUploader,
    }),
    defineTextColor(),
  )
}

export type EditorExtension = ReturnType<typeof defineExtension>
registry/src/react/examples/notion/image-view/image-view-content.tsx
'use client'

import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { ReactNodeViewProps } from 'prosekit/react'
import { ResizableHandle, ResizableRoot } from 'prosekit/react/resizable'
import { useEffect, useState, type SyntheticEvent } from 'react'

export default function ImageViewContent(props: ReactNodeViewProps) {
  const attrs = props.node.attrs as ImageAttrs
  const url = attrs.src || ''
  const uploading = url.startsWith('blob:')

  const [aspectRatio, setAspectRatio] = useState<number | undefined>()
  const [error, setError] = useState<string | undefined>()
  const [progress, setProgress] = useState(0)

  useEffect(() => {
    if (!uploading) return

    const uploadTask = UploadTask.get<string>(url)
    if (!uploadTask) return

    let canceled = false

    uploadTask.finished.catch((error) => {
      if (canceled) return
      setError(String(error))
    })
    const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
      if (canceled) return
      setProgress(total ? loaded / total : 0)
    })

    return () => {
      canceled = true
      unsubscribeProgress()
    }
  }, [url, uploading])

  const handleImageLoad = (event: SyntheticEvent) => {
    const img = event.target as HTMLImageElement
    const { naturalWidth, naturalHeight } = img
    const ratio = naturalWidth / naturalHeight
    if (ratio && Number.isFinite(ratio)) {
      setAspectRatio(ratio)
    }
    if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
      props.setAttrs({ width: naturalWidth, height: naturalHeight })
    }
  }

  return (
    <ResizableRoot
      width={attrs.width ?? undefined}
      height={attrs.height ?? undefined}
      aspectRatio={aspectRatio}
      onResizeEnd={(event) => props.setAttrs(event.detail)}
      data-selected={props.selected ? '' : undefined}
      className="relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-150 max-w-full min-h-16 min-w-16 outline-2 outline-transparent data-selected:outline-blue-500 outline-solid"
    >
      {url && !error && (
        <img
          src={url}
          onLoad={handleImageLoad}
          alt="upload preview"
          className="h-full w-full max-w-full max-h-full object-contain"
        />
      )}
      {uploading && !error && (
        <div className="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
          <div className="i-lucide-loader-circle size-4 animate-spin block"></div>
          <div>{Math.round(progress * 100)}%</div>
        </div>
      )}
      {error && (
        <div className="absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container">
          <div className="i-lucide-image-off size-8 block"></div>
          <div className="hidden opacity-80 @xs:block">
            Failed to upload image
          </div>
        </div>
      )}
      <ResizableHandle
        className="absolute bottom-0 right-0 rounded-sm m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-data-resizing:opacity-100"
        position="bottom-right"
      >
        <div className="i-lucide-arrow-down-right size-4 block"></div>
      </ResizableHandle>
    </ResizableRoot>
  )
}
registry/src/react/examples/notion/image-view/image-view-placeholder.tsx
'use client'

import { useEditor } from 'prosekit/react'

import { sampleUploader } from '../../../sample/sample-uploader.ts'
import type { EditorExtension } from '../extension.ts'

interface Props {
  getPos: () => number | undefined
  selected: boolean
}

export default function ImageViewPlaceholder(props: Props) {
  const editor = useEditor<EditorExtension>()

  const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
    const file = event.target.files?.[0]
    if (!file) return

    const pos = props.getPos()
    if (typeof pos !== 'number') return

    editor.commands.uploadImage({ file, uploader: sampleUploader, pos, replace: true })

    // Reset input so the same file can be selected again
    event.target.value = ''
  }

  return (
    <label
      className="flex w-full cursor-pointer items-center rounded-lg gap-3 px-4 py-3 transition-colors data-selected:outline-blue-500 outline-1 outline-transparent bg-gray-500/10 text-current/40 hover:bg-gray-500/20 hover:text-current/60"
      aria-label="Add an image"
      data-selected={props.selected ? '' : undefined}
    >
      <input
        type="file"
        accept="image/*"
        className="hidden"
        onChange={handleFileChange}
      />
      <span className="block i-lucide-image size-4" />
      <span className="text-sm font-medium">Add an image</span>
    </label>
  )
}
registry/src/react/examples/notion/image-view/image-view.tsx
'use client'

import type { ImageAttrs } from 'prosekit/extensions/image'
import type { ReactNodeViewProps } from 'prosekit/react'

import ImageViewContent from './image-view-content.tsx'
import ImageViewPlaceholder from './image-view-placeholder.tsx'

export default function ImageView(props: ReactNodeViewProps) {
  const attrs = props.node.attrs as ImageAttrs
  const url = attrs.src || ''

  if (url) {
    return <ImageViewContent {...props} />
  } else {
    return <ImageViewPlaceholder getPos={props.getPos} selected={props.selected} />
  }
}
registry/src/react/examples/notion/image-view/index.ts
import type { Extension } from 'prosekit/core'
import { defineReactNodeView, type ReactNodeViewComponent } from 'prosekit/react'

import ImageView from './image-view.tsx'

export function defineImageView(): Extension {
  return defineReactNodeView({
    name: 'image',
    component: ImageView satisfies ReactNodeViewComponent,
  })
}
registry/src/react/examples/notion/index.ts
export { default as ExampleEditor } from './editor.tsx'
registry/src/react/examples/notion/slash-menu/index.ts
export { default as SlashMenu } from './slash-menu.tsx'
registry/src/react/examples/notion/slash-menu/slash-menu-empty.tsx
'use client'

import { AutocompleteEmpty } from 'prosekit/react/autocomplete'

export default function SlashMenuEmpty() {
  return (
    <AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
      <span>No results</span>
    </AutocompleteEmpty>
  )
}
registry/src/react/examples/notion/slash-menu/slash-menu-item.tsx
'use client'

import { AutocompleteItem } from 'prosekit/react/autocomplete'

export default function SlashMenuItem(props: {
  label: string
  kbd?: string
  onSelect: () => void
}) {
  return (
    <AutocompleteItem onSelect={props.onSelect} className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
      <span>{props.label}</span>
      {props.kbd && <kbd className="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>}
    </AutocompleteItem>
  )
}
registry/src/react/examples/notion/slash-menu/slash-menu.tsx
'use client'

import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/react'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/react/autocomplete'

import SlashMenuEmpty from './slash-menu-empty.tsx'
import SlashMenuItem from './slash-menu-item.tsx'

// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = new RegExp(
  (canUseRegexLookbehind() ? String.raw`(?<!\S)` : '')
    + String.raw`\/(\S.*)?$`,
  'u',
)

interface Props {
  onOpenChange: (open: boolean) => void
}

export default function SlashMenu(props: Props) {
  const editor = useEditor<BasicExtension>()

  return (
    <AutocompleteRoot
      regex={regex}
      onOpenChange={(event) => {
        props.onOpenChange(event.detail)
      }}
    >
      <AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
        <AutocompletePopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
          <div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
            <SlashMenuItem
              label="Text"
              onSelect={() => editor.commands.setParagraph()}
            />

            <SlashMenuItem
              label="Heading 1"
              kbd="#"
              onSelect={() => editor.commands.setHeading({ level: 1 })}
            />

            <SlashMenuItem
              label="Heading 2"
              kbd="##"
              onSelect={() => editor.commands.setHeading({ level: 2 })}
            />

            <SlashMenuItem
              label="Heading 3"
              kbd="###"
              onSelect={() => editor.commands.setHeading({ level: 3 })}
            />

            <SlashMenuItem
              label="Bullet list"
              kbd="-"
              onSelect={() => editor.commands.wrapInList({ kind: 'bullet' })}
            />

            <SlashMenuItem
              label="Ordered list"
              kbd="1."
              onSelect={() => editor.commands.wrapInList({ kind: 'ordered' })}
            />

            <SlashMenuItem
              label="Task list"
              kbd="[]"
              onSelect={() => editor.commands.wrapInList({ kind: 'task' })}
            />

            <SlashMenuItem
              label="Toggle list"
              kbd=">>"
              onSelect={() => editor.commands.wrapInList({ kind: 'toggle' })}
            />

            <SlashMenuItem
              label="Quote"
              kbd=">"
              onSelect={() => editor.commands.setBlockquote()}
            />

            <SlashMenuItem
              label="Table"
              onSelect={() => editor.commands.insertTable({ row: 3, col: 3 })}
            />

            <SlashMenuItem
              label="Divider"
              kbd="---"
              onSelect={() => editor.commands.insertHorizontalRule()}
            />

            <SlashMenuItem
              label="Code"
              kbd="```"
              onSelect={() => editor.commands.setCodeBlock()}
            />

            <SlashMenuItem
              label="Image"
              onSelect={() => editor.commands.insertImage({ src: '' })}
            />

            <SlashMenuEmpty />
          </div>
        </AutocompletePopup>
      </AutocompletePositioner>
    </AutocompleteRoot>
  )
}
registry/src/react/examples/notion/style.css
[data-prosekit-example="notion"] {
  & .prosekit-autocomplete-match[data-autocomplete-match-text="/"]:after {
    content: "Filter...";
    opacity: 0.5;
  }
}

More from prosekit

Browse all components in this library →