tsx
import 'prosekit/basic/style.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { defineExtension } from './extension'
import InlineMenu from './inline-menu'
const defaultContent =
'<p><b>Try to select some text</b></p>' +
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Aliquet nec ullamcorper sit amet risus. Nam aliquam sem et tortor consequat id porta. Interdum posuere lorem ipsum dolor sit amet. Lectus sit amet est placerat in egestas erat. Egestas sed tempus urna et pharetra pharetra. Sit amet cursus sit amet dictum sit amet. Porttitor leo a diam sollicitudin. Tellus orci ac auctor augue. Tellus in hac habitasse platea dictumst vestibulum. At elementum eu facilisis sed odio morbi. Dolor magna eget est lorem ipsum. Et malesuada fames ac turpis egestas. Arcu risus quis varius quam quisque id diam. Purus viverra accumsan in nisl nisi scelerisque eu ultrices. Ut tortor pretium viverra suspendisse potenti nullam ac tortor vitae.</p>'.repeat(
10,
)
export default function Editor() {
const editor = useMemo(() => {
return createEditor({ extension: defineExtension(), defaultContent })
}, [])
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'>
<div className='relative w-full flex-1 box-border overflow-y-scroll'>
<div
ref={editor.mount}
spellCheck={false}
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 />
</div>
</div>
</ProseKit>
)
}
ts
import { defineBasicExtension } from 'prosekit/basic'
export function defineExtension() {
return defineBasicExtension()
}
export type EditorExtension = ReturnType<typeof defineExtension>
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 {
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>
)
}