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 · lightui-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;
}
}