tsx
import 'prosekit/basic/style.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import BlockHandle from './block-handle'
import { defineExtension } from './extension'
import InlineMenu from './inline-menu'
import SlashMenu from './slash-menu'
import TagMenu from './tag-menu'
import Toolbar from './toolbar'
import UserMenu from './user-menu'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension })
}, [])
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 shadow dark:border-zinc-700 flex flex-col bg-white dark:bg-neutral-900'>
<Toolbar />
<div className='relative w-full flex-1 box-border overflow-y-scroll'>
<div ref={editor.mount} className='ProseMirror box-border min-h-full px-[max(4rem,_calc(50%-20rem))] py-8 outline-none outline-0 [&_span[data-mention="user"]]:text-blue-500 [&_span[data-mention="tag"]]:text-violet-500 [&_pre]:text-white [&_pre]:bg-zinc-800'></div>
<InlineMenu />
<SlashMenu />
<UserMenu />
<TagMenu />
<BlockHandle />
</div>
</div>
</ProseKit>
)
}
ts
import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import {
defineCodeBlock,
defineCodeBlockShiki,
} from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import {
defineReactNodeView,
type ReactNodeViewComponent,
} from 'prosekit/react'
import CodeBlockView from './code-block-view'
import ImageView from './image-view'
import { defineImageFileHandlers } from './upload-file'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineCodeBlock(),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineReactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies ReactNodeViewComponent,
}),
defineReactNodeView({
name: 'image',
component: ImageView satisfies ReactNodeViewComponent,
}),
defineImageFileHandlers(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>
tsx
import { useEditor } from 'prosekit/react'
import Button from './button'
import type { EditorExtension } from './extension'
import { ImageUploadPopover } from './image-upload-popover'
export default function Toolbar() {
const editor = useEditor<EditorExtension>({ update: true })
return (
<div className='z-2 box-border border-zinc-200 dark:border-zinc-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center'>
<Button
pressed={false}
disabled={!editor.commands.undo.canExec()}
onClick={editor.commands.undo}
tooltip="Undo"
>
<div className='i-lucide-undo-2 h-5 w-5' />
</Button>
<Button
pressed={false}
disabled={!editor.commands.redo.canExec()}
onClick={editor.commands.redo}
tooltip="Redo"
>
<div className='i-lucide-redo-2 h-5 w-5' />
</Button>
<Button
pressed={editor.marks.bold.isActive()}
disabled={!editor.commands.toggleBold.canExec()}
onClick={editor.commands.toggleBold}
tooltip="Bold"
>
<div className='i-lucide-bold h-5 w-5' />
</Button>
<Button
pressed={editor.marks.italic.isActive()}
disabled={!editor.commands.toggleItalic.canExec()}
onClick={editor.commands.toggleItalic}
tooltip="Italic"
>
<div className='i-lucide-italic h-5 w-5' />
</Button>
<Button
pressed={editor.marks.underline.isActive()}
disabled={!editor.commands.toggleUnderline.canExec()}
onClick={editor.commands.toggleUnderline}
tooltip="Underline"
>
<div className='i-lucide-underline h-5 w-5' />
</Button>
<Button
pressed={editor.marks.strike.isActive()}
disabled={!editor.commands.toggleStrike.canExec()}
onClick={editor.commands.toggleStrike}
tooltip="Strike"
>
<div className='i-lucide-strikethrough h-5 w-5' />
</Button>
<Button
pressed={editor.marks.code.isActive()}
disabled={!editor.commands.toggleCode.canExec()}
onClick={editor.commands.toggleCode}
tooltip="Code"
>
<div className='i-lucide-code h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.codeBlock.isActive()}
disabled={!editor.commands.insertCodeBlock.canExec({ language: 'javascript' })}
onClick={() => editor.commands.insertCodeBlock({ language: 'javascript' })}
tooltip="Code Block"
>
<div className='i-lucide-square-code h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.heading.isActive({ level: 1 })}
disabled={!editor.commands.toggleHeading.canExec({ level: 1 })}
onClick={() => editor.commands.toggleHeading({ level: 1 })}
tooltip="Heading 1"
>
<div className='i-lucide-heading-1 h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.heading.isActive({ level: 2 })}
disabled={!editor.commands.toggleHeading.canExec({ level: 2 })}
onClick={() => editor.commands.toggleHeading({ level: 2 })}
tooltip="Heading 2"
>
<div className='i-lucide-heading-2 h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.heading.isActive({ level: 3 })}
disabled={!editor.commands.toggleHeading.canExec({ level: 3 })}
onClick={() => editor.commands.toggleHeading({ level: 3 })}
tooltip="Heading 3"
>
<div className='i-lucide-heading-3 h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.horizontalRule.isActive()}
disabled={!editor.commands.insertHorizontalRule.canExec()}
onClick={() => editor.commands.insertHorizontalRule()}
tooltip="Divider"
>
<div className='i-lucide-minus h-5 w-5'></div>
</Button>
<Button
pressed={editor.nodes.list.isActive({ kind: 'bullet' })}
disabled={!editor.commands.toggleList.canExec({ kind: 'bullet' })}
onClick={() => editor.commands.toggleList.canExec({ kind: 'bullet' })}
tooltip="Bullet List"
>
<div className='i-lucide-list h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.list.isActive({ kind: 'ordered' })}
disabled={!editor.commands.toggleList.canExec({ kind: 'ordered' })}
onClick={() => editor.commands.toggleList.canExec({ kind: 'ordered' })}
tooltip="Ordered List"
>
<div className='i-lucide-list-ordered h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.list.isActive({ kind: 'task' })}
disabled={!editor.commands.toggleList.canExec({ kind: 'task' })}
onClick={() => editor.commands.toggleList.canExec({ kind: 'task' })}
tooltip="Task List"
>
<div className='i-lucide-list-checks h-5 w-5' />
</Button>
<Button
pressed={editor.nodes.list.isActive({ kind: 'toggle' })}
disabled={!editor.commands.toggleList.canExec({ kind: 'toggle' })}
onClick={() => editor.commands.toggleList.canExec({ kind: 'toggle' })}
tooltip="Toggle List"
>
<div className='i-lucide-list-collapse h-5 w-5' />
</Button>
<Button
pressed={false}
disabled={!editor.commands.indentList.canExec()}
tooltip="Increase indentation"
onClick={() => editor.commands.indentList()}
>
<div className='i-lucide-indent-increase h-5 w-5' />
</Button>
<Button
pressed={false}
disabled={!editor.commands.dedentList.canExec()}
tooltip="Decrease indentation"
onClick={() => editor.commands.dedentList()}
>
<div className='i-lucide-indent-decrease h-5 w-5' />
</Button>
<ImageUploadPopover
disabled={!editor.commands.insertImage.canExec()}
tooltip="Insert Image"
>
<div className='i-lucide-image h-5 w-5' />
</ImageUploadPopover>
</div>
)
}
tsx
import {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopover,
} from 'prosekit/react/block-handle'
export default function BlockHandle() {
return (
<BlockHandlePopover className='flex items-center flex-row box-border justify-center transition border-0 py-2 [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200'>
<BlockHandleAdd className='flex items-center box-border justify-center h-[1.5em] w-[1.5em] hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded text-zinc-500/50 dark:text-zinc-500/50 cursor-pointer'>
<div className='i-lucide-plus h-5 w-5' />
</BlockHandleAdd>
<BlockHandleDraggable className='flex items-center box-border justify-center h-[1.5em] w-[1.2em] hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded text-zinc-500/50 dark:text-zinc-500/50 cursor-grab'>
<div className='i-lucide-grip-vertical h-5 w-5' />
</BlockHandleDraggable>
</BlockHandlePopover>
)
}
tsx
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { ReactNodeViewProps } from 'prosekit/react'
export default function CodeBlockView(props: ReactNodeViewProps) {
const attrs = props.node.attrs as CodeBlockAttrs
const language = attrs.language
const setLanguage = (language: string) => {
const attrs: CodeBlockAttrs = { language }
props.setAttrs(attrs)
}
return (
<>
<div className='relative left-2 top-3 h-0 select-none overflow-visible' contentEditable={false}>
<select
className='outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded border-none bg-transparent px-2 py-1 text-xs transition text-white opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 [div[data-node-view-root]:hover_&]:hover:opacity-80'
onChange={(event) => setLanguage(event.target.value)}
value={language || ''}
>
<option value="">Plain Text</option>
{shikiBundledLanguagesInfo.map((info) => (
<option key={info.id} value={info.id}>
{info.name}
</option>
))}
</select>
</div>
<pre ref={props.contentRef} data-language={language}></pre>
</>
)
}
tsx
import { useEditor } from 'prosekit/react'
import {
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from 'prosekit/react/popover'
import {
useState,
type FC,
type ReactNode,
} from 'react'
import Button from './button'
import type { EditorExtension } from './extension'
export const ImageUploadPopover: FC<{
tooltip: string
disabled: boolean
children: ReactNode
}> = ({ tooltip, disabled, children }) => {
const [open, setOpen] = useState(false)
const [webUrl, setWebUrl] = useState('')
const [objectUrl, setObjectUrl] = useState('')
const url = webUrl || objectUrl
const editor = useEditor<EditorExtension>()
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = (
event,
) => {
const file = event.target.files?.[0]
if (file) {
setObjectUrl(URL.createObjectURL(file))
setWebUrl('')
} else {
setObjectUrl('')
}
}
const handleWebUrlChange: React.ChangeEventHandler<HTMLInputElement> = (
event,
) => {
const url = event.target.value
if (url) {
setWebUrl(url)
setObjectUrl('')
} else {
setWebUrl('')
}
}
const deferResetState = () => {
setTimeout(() => {
setWebUrl('')
setObjectUrl('')
}, 300)
}
const handleSubmit = () => {
editor.commands.insertImage({ src: url })
deferResetState()
setOpen(false)
}
const handleOpenChange = (open: boolean) => {
if (!open) {
deferResetState()
}
setOpen(open)
}
return (
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open} disabled={disabled} tooltip={tooltip}>
{children}
</Button>
</PopoverTrigger>
<PopoverContent className='flex flex-col gap-y-4 p-6 text-sm w-sm z-10 box-border rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-neutral-900 shadow-lg [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'>
{objectUrl ? null : (
<>
<label>Embed Link</label>
<input
className='flex h-9 rounded-md w-full bg-white dark:bg-neutral-900 px-3 py-2 text-sm placeholder:text-zinc-500 dark:placeholder:text-zinc-500 transition border box-border border-zinc-200 dark:border-zinc-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-zinc-900 dark:focus-visible:ring-zinc-300 focus-visible:ring-offset-0 outline-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50'
placeholder="Paste the image link..."
type="url"
value={webUrl}
onChange={handleWebUrlChange}
/>
</>
)}
{webUrl ? null : (
<>
<label>Upload</label>
<input
className='flex h-9 rounded-md w-full bg-white dark:bg-neutral-900 px-3 py-2 text-sm placeholder:text-zinc-500 dark:placeholder:text-zinc-500 transition border box-border border-zinc-200 dark:border-zinc-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-zinc-900 dark:focus-visible:ring-zinc-300 focus-visible:ring-offset-0 outline-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50'
accept="image/*"
type="file"
onChange={handleFileChange}
/>
</>
)}
{url
? (
<button className='inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-neutral-900 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 dark:focus-visible:ring-zinc-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-zinc-900 dark:bg-zinc-50 text-zinc-50 dark:text-zinc-900 hover:bg-zinc-900/90 dark:hover:bg-zinc-50/90 h-10 px-4 py-2 w-full' onClick={handleSubmit}>
Insert Image
</button>
)
: null}
</PopoverContent>
</PopoverRoot>
)
}
tsx
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 ImageView(props: ReactNodeViewProps) {
const { setAttrs, node } = props
const attrs = 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 (!url.startsWith('blob:')) {
return
}
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) {
return
}
const abortController = new AbortController()
void uploadTask.finished
.then((resultUrl) => {
if (resultUrl && typeof resultUrl === 'string') {
if (abortController.signal.aborted) {
return
}
setAttrs({ src: resultUrl })
} else {
if (abortController.signal.aborted) {
return
}
setError('Unexpected upload result')
}
UploadTask.delete(uploadTask.objectURL)
})
.catch((error) => {
if (abortController.signal.aborted) {
return
}
setError(String(error))
UploadTask.delete(uploadTask.objectURL)
})
const unsubscribe = uploadTask.subscribeProgress(({ loaded, total }) => {
if (abortController.signal.aborted) {
return
}
if (total > 0) {
setProgress(loaded / total)
}
})
return () => {
unsubscribe()
abortController.abort()
}
}, [url, setAttrs])
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)) {
setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => setAttrs(event.detail)}
data-selected={props.selected ? '' : undefined}
className='relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-[600px] max-w-full min-h-[64px] min-w-[64px] outline-2 outline-transparent data-[selected]:outline-blue-500 outline-solid'
>
{url && !error && (
<img
src={url}
onLoad={handleImageLoad}
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 bg-gray-800/60 p-1.5 text-xs text-white/80 transition'>
<div className='i-lucide-loader-circle h-4 w-4 animate-spin'></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 h-8 w-8'></div>
<div className='hidden opacity-80 @xs:block'>
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className='absolute bottom-0 right-0 rounded 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 h-4 w-4'></div>
</ResizableHandle>
</ResizableRoot>
)
}
tsx
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor } from 'prosekit/react'
import { InlinePopover } from 'prosekit/react/inline-popover'
import { useState } from 'react'
import Button from './button'
import type { EditorExtension } from './extension'
export default function InlineMenu() {
const editor = useEditor<EditorExtension>({ update: true })
const [linkMenuOpen, setLinkMenuOpen] = useState(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const getCurrentLink = (state: EditorState): string | undefined => {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
const handleLinkUpdate = (href?: string) => {
if (href) {
editor.commands.addLink({ href })
} else {
editor.commands.removeLink()
}
setLinkMenuOpen(false)
editor.focus()
}
return (
<>
<InlinePopover
data-testid="inline-menu-main"
className='z-10 box-border border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-neutral-900 shadow-lg [&:not([data-state])]:hidden relative flex min-w-[8rem] space-x-1 overflow-auto whitespace-nowrap rounded-md p-1'
onOpenChange={(open) => {
if (!open) {
setLinkMenuOpen(false)
}
}}
>
<Button
pressed={editor.marks.bold.isActive()}
disabled={!editor.commands.toggleBold.canExec()}
onClick={() => editor.commands.toggleBold()}
tooltip="Bold"
>
<div className='i-lucide-bold h-5 w-5'></div>
</Button>
<Button
pressed={editor.marks.italic.isActive()}
disabled={!editor.commands.toggleItalic.canExec()}
onClick={() => editor.commands.toggleItalic()}
tooltip="Italic"
>
<div className='i-lucide-italic h-5 w-5'></div>
</Button>
<Button
pressed={editor.marks.underline.isActive()}
disabled={!editor.commands.toggleUnderline.canExec()}
onClick={() => editor.commands.toggleUnderline()}
tooltip="Underline"
>
<div className='i-lucide-underline h-5 w-5'></div>
</Button>
<Button
pressed={editor.marks.strike.isActive()}
disabled={!editor.commands.toggleStrike.canExec()}
onClick={() => editor.commands.toggleStrike()}
tooltip="Strikethrough"
>
<div className='i-lucide-strikethrough h-5 w-5'></div>
</Button>
<Button
pressed={editor.marks.code.isActive()}
disabled={!editor.commands.toggleCode.canExec()}
onClick={() => editor.commands.toggleCode()}
tooltip="Code"
>
<div className='i-lucide-code h-5 w-5'></div>
</Button>
{editor.commands.addLink.canExec({ href: '' }) && (
<Button
pressed={editor.marks.link.isActive()}
onClick={() => {
editor.commands.expandLink()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div className='i-lucide-link h-5 w-5'></div>
</Button>
)}
</InlinePopover>
<InlinePopover
placement={'bottom'}
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={setLinkMenuOpen}
data-testid="inline-menu-link"
className='z-10 box-border border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-neutral-900 shadow-lg [&:not([data-state])]:hidden relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch'
>
{linkMenuOpen && (
<form
onSubmit={(event) => {
event.preventDefault()
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}}
>
<input
placeholder="Paste the link..."
defaultValue={getCurrentLink(editor.state)}
className='flex h-9 rounded-md w-full bg-white dark:bg-neutral-900 px-3 py-2 text-sm placeholder:text-zinc-500 dark:placeholder:text-zinc-500 transition border box-border border-zinc-200 dark:border-zinc-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-zinc-900 dark:focus-visible:ring-zinc-300 focus-visible:ring-offset-0 outline-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50'
>
</input>
</form>
)}
{editor.marks.link.isActive() && (
<button
onClick={() => handleLinkUpdate()}
onMouseDown={(event) => event.preventDefault()}
className='inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-neutral-900 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900 dark:focus-visible:ring-zinc-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-zinc-900 dark:bg-zinc-50 text-zinc-50 dark:text-zinc-900 hover:bg-zinc-900/90 dark:hover:bg-zinc-50/90 h-9 px-3'
>
Remove link
</button>
)}
</InlinePopover>
</>
)
}
tsx
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/react/autocomplete'
import type { EditorExtension } from './extension'
export default function SlashMenu() {
const editor = useEditor<EditorExtension>()
return (
<AutocompletePopover regex={/\/.*$/iu} className='relative block max-h-[25rem] min-w-[8rem] select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-neutral-900 shadow-lg [&:not([data-state])]:hidden'>
<AutocompleteList>
<AutocompleteEmpty className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'>
No results
</AutocompleteEmpty>
<AutocompleteItem
className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'
onSelect={() => editor.commands.setHeading({ level: 1 })}
>
Heading 1
</AutocompleteItem>
<AutocompleteItem
className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'
onSelect={() => editor.commands.setHeading({ level: 2 })}
>
Heading 2
</AutocompleteItem>
<AutocompleteItem
className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'
onSelect={() => editor.commands.wrapInList({ kind: 'task' })}
>
Task list
</AutocompleteItem>
<AutocompleteItem
className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'
onSelect={() => editor.commands.wrapInList({ kind: 'bullet' })}
>
Bullet list
</AutocompleteItem>
<AutocompleteItem
className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'
onSelect={() => editor.commands.wrapInList({ kind: 'ordered' })}
>
Ordered list
</AutocompleteItem>
<AutocompleteItem
className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'
onSelect={() => editor.commands.wrapInList({ kind: 'toggle' })}
>
Toggle list
</AutocompleteItem>
</AutocompleteList>
</AutocompletePopover>
)
}
ts
export const tags = [
{ id: 1, label: 'book' },
{ id: 2, label: 'movie' },
{ id: 3, label: 'trip' },
{ id: 4, label: 'music' },
{ id: 5, label: 'art' },
{ id: 6, label: 'food' },
{ id: 7, label: 'sport' },
{ id: 8, label: 'technology' },
{ id: 9, label: 'fashion' },
{ id: 10, label: 'nature' },
]
tsx
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/react/autocomplete'
import type { EditorExtension } from './extension'
import { tags } from './tag-data'
export default function TagMenu() {
const editor = useEditor<EditorExtension>()
const handleTagInsert = (id: number, label: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '#' + label,
kind: 'tag',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompletePopover
regex={/#[\da-z]*$/i}
className='relative block max-h-[25rem] min-w-[8rem] select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-neutral-900 shadow-lg [&:not([data-state])]:hidden'
>
<AutocompleteList>
<AutocompleteEmpty className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'>
No results
</AutocompleteEmpty>
{tags.map((tag) => (
<AutocompleteItem
key={tag.id}
className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
)
}
ts
import {
insertNode,
union,
} from 'prosekit/core'
import {
defineFileDropHandler,
defineFilePasteHandler,
UploadTask,
type Uploader,
} from 'prosekit/extensions/file'
/**
* Returns an extension that handles image file uploads when pasting or dropping
* images into the editor.
*/
export function defineImageFileHandlers() {
return union(
defineFilePasteHandler(({ view, file }) => {
// Only handle image files
if (!file.type.startsWith('image/')) {
return false
}
// Upload the image to https://tmpfiles.org/
const uploadTask = new UploadTask({
file,
uploader: tmpfilesUploader,
})
// Insert the image node at the current text selection position
const command = insertNode({
type: 'image',
attrs: { src: uploadTask.objectURL },
})
return command(view.state, view.dispatch, view)
}),
defineFileDropHandler(({ view, file, pos }) => {
// Only handle image files
if (!file.type.startsWith('image/')) {
return false
}
// Upload the image to https://tmpfiles.org/
const uploadTask = new UploadTask({
file,
uploader: tmpfilesUploader,
})
// Insert the image node at the drop position
const command = insertNode({
type: 'image',
attrs: { src: uploadTask.objectURL },
pos,
})
return command(view.state, view.dispatch, view)
}),
)
}
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted after 1 hour.
*/
const tmpfilesUploader: Uploader<string> = ({
file,
onProgress,
}): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && onProgress) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText)
const url: string = (json.data.url as string).replace(
'tmpfiles.org/',
'tmpfiles.org/dl/',
)
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}
ts
export const users = [
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Alice' },
{ id: 3, name: 'Ben' },
{ id: 4, name: 'Bob' },
{ id: 5, name: 'Charlie' },
{ id: 6, name: 'Cara' },
{ id: 7, name: 'Derek' },
{ id: 8, name: 'Diana' },
{ id: 9, name: 'Ethan' },
{ id: 10, name: 'Eva' },
{ id: 11, name: 'Frank' },
{ id: 12, name: 'Fiona' },
{ id: 13, name: 'George' },
{ id: 14, name: 'Gina' },
{ id: 15, name: 'Harry' },
{ id: 16, name: 'Hannah' },
{ id: 17, name: 'Ivan' },
{ id: 18, name: 'Iris' },
{ id: 19, name: 'Jack' },
{ id: 20, name: 'Jasmine' },
{ id: 21, name: 'Kevin' },
{ id: 22, name: 'Kate' },
{ id: 23, name: 'Leo' },
{ id: 24, name: 'Lily' },
{ id: 25, name: 'Mike' },
{ id: 26, name: 'Mia' },
{ id: 27, name: 'Nathan' },
{ id: 28, name: 'Nancy' },
{ id: 29, name: 'Oscar' },
{ id: 30, name: 'Olivia' },
{ id: 31, name: 'Paul' },
{ id: 32, name: 'Penny' },
{ id: 33, name: 'Quentin' },
{ id: 34, name: 'Queen' },
{ id: 35, name: 'Roger' },
{ id: 36, name: 'Rita' },
{ id: 37, name: 'Sam' },
{ id: 38, name: 'Sara' },
{ id: 39, name: 'Tom' },
{ id: 40, name: 'Tina' },
{ id: 41, name: 'Ulysses' },
{ id: 42, name: 'Una' },
{ id: 43, name: 'Victor' },
{ id: 44, name: 'Vera' },
{ id: 45, name: 'Walter' },
{ id: 46, name: 'Wendy' },
{ id: 47, name: 'Xavier' },
{ id: 48, name: 'Xena' },
{ id: 49, name: 'Yan' },
{ id: 50, name: 'Yvonne' },
{ id: 51, name: 'Zack' },
{ id: 52, name: 'Zara' },
]
tsx
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/react/autocomplete'
import type { EditorExtension } from './extension'
import { users } from './user-data'
export default function UserMenu() {
const editor = useEditor<EditorExtension>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompletePopover regex={/@\w*$/} className='relative block max-h-[25rem] min-w-[8rem] select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-neutral-900 shadow-lg [&:not([data-state])]:hidden'>
<AutocompleteList>
<AutocompleteEmpty className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'>
No results
</AutocompleteEmpty>
{users.map((user) => (
<AutocompleteItem
key={user.id}
className='relative block min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-zinc-100 dark:data-[focused]:bg-zinc-800'
onSelect={() => handleUserInsert(user.id, user.name)}
>
{user.name}
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
)
}
tsx
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/react/tooltip'
import type { ReactNode } from 'react'
export default function Button({
pressed,
disabled,
onClick,
tooltip,
children,
}: {
pressed?: boolean
disabled?: boolean
onClick?: VoidFunction
tooltip?: string
children: ReactNode
}) {
return (
<TooltipRoot>
<TooltipTrigger className='block'>
<button
data-state={pressed ? 'on' : 'off'}
disabled={disabled}
onClick={() => onClick?.()}
onMouseDown={(event) => event.preventDefault()}
className='outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-zinc-900 dark:focus-visible:ring-zinc-300 disabled:pointer-events-none min-w-9 min-h-9 disabled:opacity-50 hover:disabled:opacity-50 bg-transparent hover:bg-zinc-100 dark:hover:bg-zinc-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700'
>
{children}
{tooltip ? <span className="sr-only">{tooltip}</span> : null}
</button>
</TooltipTrigger>
{tooltip
? (
<TooltipContent className='z-50 overflow-hidden rounded-md border border-solid bg-zinc-900 dark:bg-zinc-50 px-3 py-1.5 text-xs text-zinc-50 dark:text-zinc-900 shadow-sm [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'>
{tooltip}
</TooltipContent>
)
: null}
</TooltipRoot>
)
}