Example: inline-menu
Inline formatting menu that appears on text selection.
Install this example with
shadcn: npx shadcn@latest add @prosekit/react-example-inline-menunpx shadcn@latest add @prosekit/preact-example-inline-menunpx shadcn@latest add @prosekit/solid-example-inline-menunpx shadcn@latest add @prosekit/svelte-example-inline-menunpx shadcn@latest add @prosekit/vue-example-inline-menu'use client'
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { sampleContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
return createEditor({ extension: defineExtension(), defaultContent })
}, [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 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white">
<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 />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
export function defineExtension() {
return defineBasicExtension()
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
const loremText =
'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.'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'bold',
},
],
text: 'Try to select some text',
},
],
},
...Array.from({ length: 10 }, () => ({
type: 'paragraph' as const,
content: [
{
type: 'text' as const,
text: loremText,
},
],
})),
],
}'use client'
import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/react/tooltip'
import type { MouseEventHandler, ReactNode } from 'react'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: MouseEventHandler<HTMLButtonElement>
tooltip?: string
children: ReactNode
}) {
return (
<TooltipRoot>
<TooltipTrigger className="block">
<button
data-state={props.pressed ? 'on' : 'off'}
disabled={props.disabled}
onClick={props.onClick}
onMouseDown={(event) => {
// Prevent the editor from being blurred when the button is clicked
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-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{props.children}
{props.tooltip ? <span className="sr-only">{props.tooltip}</span> : null}
</button>
</TooltipTrigger>
{props.tooltip
? (
<TooltipPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup className="flex box-border origin-(--transform-origin) transition 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 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'export { default as InlineMenu } from './inline-menu''use client'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/react'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/react/inline-popover'
import { useState } from 'react'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function 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
}
}
}
export default function InlineMenu() {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = useState(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor.commands.addLink({ href })
} else {
editor.commands.removeLink()
}
setLinkMenuOpen(false)
editor.focus()
}
return (
<>
<InlinePopoverRoot
onOpenChange={(event) => {
if (!event.detail) {
setLinkMenuOpen(false)
}
}}
>
<InlinePopoverPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-main"
className="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
>
{items.bold && (
<Button
pressed={items.bold.isActive}
disabled={!items.bold.canExec}
onClick={items.bold.command}
tooltip="Bold"
>
<div className="i-lucide-bold size-5 block"></div>
</Button>
)}
{items.italic && (
<Button
pressed={items.italic.isActive}
disabled={!items.italic.canExec}
onClick={items.italic.command}
tooltip="Italic"
>
<div className="i-lucide-italic size-5 block"></div>
</Button>
)}
{items.underline && (
<Button
pressed={items.underline.isActive}
disabled={!items.underline.canExec}
onClick={items.underline.command}
tooltip="Underline"
>
<div className="i-lucide-underline size-5 block"></div>
</Button>
)}
{items.strike && (
<Button
pressed={items.strike.isActive}
disabled={!items.strike.canExec}
onClick={items.strike.command}
tooltip="Strikethrough"
>
<div className="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
{items.code && (
<Button
pressed={items.code.isActive}
disabled={!items.code.canExec}
onClick={items.code.command}
tooltip="Code"
>
<div className="i-lucide-code size-5 block"></div>
</Button>
)}
{items.link && items.link.canExec && (
<Button
pressed={items.link.isActive}
onClick={() => {
items.link?.command?.()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div className="i-lucide-link size-5 block"></div>
</Button>
)}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
{items.link && (
<InlinePopoverRoot
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={(event) => setLinkMenuOpen(event.detail)}
>
<InlinePopoverPositioner placement="bottom" className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-link"
className="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none 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={items.link.currentLink}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
/>
</form>
)}
{items.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-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
)}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
)}
</>
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
return createEditor({ extension: defineExtension(), defaultContent })
}, [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 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white">
<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 />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
export function defineExtension() {
return defineBasicExtension()
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
const loremText =
'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.'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'bold',
},
],
text: 'Try to select some text',
},
],
},
...Array.from({ length: 10 }, () => ({
type: 'paragraph' as const,
content: [
{
type: 'text' as const,
text: loremText,
},
],
})),
],
}import type { ComponentChild, MouseEventHandler } from 'preact'
import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/preact/tooltip'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: MouseEventHandler<HTMLButtonElement>
tooltip?: string
children: ComponentChild
}) {
return (
<TooltipRoot>
<TooltipTrigger className="block">
<button
data-state={props.pressed ? 'on' : 'off'}
disabled={props.disabled}
onClick={props.onClick}
onMouseDown={(event) => {
// Prevent the editor from being blurred when the button is clicked
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-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{props.children}
{props.tooltip ? <span className="sr-only">{props.tooltip}</span> : null}
</button>
</TooltipTrigger>
{props.tooltip
? (
<TooltipPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup className="flex box-border origin-(--transform-origin) transition 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 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'export { default as InlineMenu } from './inline-menu'import type { JSX } from 'preact'
import { useState } from 'preact/hooks'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/preact'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/preact/inline-popover'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function 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
}
}
}
export default function InlineMenu() {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = useState(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor.commands.addLink({ href })
} else {
editor.commands.removeLink()
}
setLinkMenuOpen(false)
editor.focus()
}
const handleSubmit = (
event: JSX.TargetedEvent<HTMLFormElement, SubmitEvent>,
) => {
event.preventDefault()
const href = event.currentTarget.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}
return (
<>
<InlinePopoverRoot
onOpenChange={(event) => {
if (!event.detail) {
setLinkMenuOpen(false)
}
}}
>
<InlinePopoverPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-main"
className="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
>
{items.bold && (
<Button
pressed={items.bold.isActive}
disabled={!items.bold.canExec}
onClick={items.bold.command}
tooltip="Bold"
>
<div className="i-lucide-bold size-5 block"></div>
</Button>
)}
{items.italic && (
<Button
pressed={items.italic.isActive}
disabled={!items.italic.canExec}
onClick={items.italic.command}
tooltip="Italic"
>
<div className="i-lucide-italic size-5 block"></div>
</Button>
)}
{items.underline && (
<Button
pressed={items.underline.isActive}
disabled={!items.underline.canExec}
onClick={items.underline.command}
tooltip="Underline"
>
<div className="i-lucide-underline size-5 block"></div>
</Button>
)}
{items.strike && (
<Button
pressed={items.strike.isActive}
disabled={!items.strike.canExec}
onClick={items.strike.command}
tooltip="Strikethrough"
>
<div className="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
{items.code && (
<Button
pressed={items.code.isActive}
disabled={!items.code.canExec}
onClick={items.code.command}
tooltip="Code"
>
<div className="i-lucide-code size-5 block"></div>
</Button>
)}
{items.link && items.link.canExec && (
<Button
pressed={items.link.isActive}
onClick={() => {
items.link?.command?.()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div className="i-lucide-link size-5 block"></div>
</Button>
)}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
{items.link && (
<InlinePopoverRoot
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={(event) => setLinkMenuOpen(event.detail)}
>
<InlinePopoverPositioner placement="bottom" className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-link"
className="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
{linkMenuOpen && (
<form onSubmit={handleSubmit}>
<input
placeholder="Paste the link..."
defaultValue={items.link.currentLink}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
/>
</form>
)}
{items.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-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
)}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
)}
</>
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { sampleContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps): JSX.Element {
const defaultContent = props.initialContent ?? sampleContent
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
return (
<ProseKit editor={editor}>
<div class="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-white dark:bg-gray-950 text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} class="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 />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
export function defineExtension() {
return defineBasicExtension()
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
const loremText =
'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.'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'bold',
},
],
text: 'Try to select some text',
},
],
},
...Array.from({ length: 10 }, () => ({
type: 'paragraph' as const,
content: [
{
type: 'text' as const,
text: loremText,
},
],
})),
],
}import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/solid/tooltip'
import type { JSX } from 'solid-js'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
children: JSX.Element
}): JSX.Element {
return (
<TooltipRoot>
<TooltipTrigger class="block">
<button
data-state={props.pressed ? 'on' : 'off'}
disabled={props.disabled}
onClick={props.onClick}
onMouseDown={(event) => {
// Prevent the editor from being blurred when the button is clicked
event.preventDefault()
}}
class="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-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{props.children}
{props.tooltip ? <span class="sr-only">{props.tooltip}</span> : null}
</button>
</TooltipTrigger>
{props.tooltip
? (
<TooltipPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition 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 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'export { default as InlineMenu } from './inline-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/solid'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/solid/inline-popover'
import { createSignal, Show, type JSX } from 'solid-js'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function 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
}
}
}
export default function InlineMenu(): JSX.Element {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = createSignal(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor().commands.addLink({ href })
} else {
editor().commands.removeLink()
}
setLinkMenuOpen(false)
editor().focus()
}
return (
<>
<InlinePopoverRoot
onOpenChange={(event) => {
if (!event.detail) {
setLinkMenuOpen(false)
}
}}
>
<InlinePopoverPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
attr:data-testid="inline-menu-main"
class="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
>
<Show when={items().bold}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().italic}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().underline}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().strike}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Strikethrough"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().code}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().link?.canExec && items().link}>
{(item) => (
<Button
pressed={item().isActive}
onClick={() => {
item().command()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
)}
</Show>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
<Show when={items().link}>
{(item) => (
<InlinePopoverRoot
defaultOpen={false}
open={linkMenuOpen()}
onOpenChange={(event) => setLinkMenuOpen(event.detail)}
>
<InlinePopoverPositioner placement="bottom" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
attr:data-testid="inline-menu-link"
class="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
<Show when={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..."
value={item().currentLink || ''}
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
/>
</form>
</Show>
<Show when={item().isActive}>
<button
onClick={() => handleLinkUpdate()}
onMouseDown={(event) => event.preventDefault()}
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
</Show>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
)}
</Show>
</>
)
}<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { untrack } from 'svelte'
import { sampleContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
const props: {
initialContent?: NodeJSON
} = $props()
const extension = defineExtension()
const defaultContent = untrack(() => props.initialContent ?? sampleContent)
const editor = createEditor({ extension, defaultContent })
</script>
<ProseKit {editor}>
<div class="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-white dark:bg-gray-950 text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div {@attach editor.mount} class="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 />
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
export function defineExtension() {
return defineBasicExtension()
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import type { NodeJSON } from 'prosekit/core'
const loremText =
'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.'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'bold',
},
],
text: 'Try to select some text',
},
],
},
...Array.from({ length: 10 }, () => ({
type: 'paragraph' as const,
content: [
{
type: 'text' as const,
text: loremText,
},
],
})),
],
}<script lang="ts">
import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/svelte/tooltip'
interface Props {
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
children?: import('svelte').Snippet
}
const props: Props = $props()
const pressed = $derived(props.pressed ?? false)
const disabled = $derived(props.disabled ?? false)
</script>
<TooltipRoot>
<TooltipTrigger class="block">
<button
data-state={pressed ? 'on' : 'off'}
{disabled}
class="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-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
onclick={props.onClick}
onmousedown={(e) => e.preventDefault()}
>
{@render props.children?.()}
{#if props.tooltip}
<span class="sr-only">{props.tooltip}</span>
{/if}
</button>
</TooltipTrigger>
{#if props.tooltip}
<TooltipPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition 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 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
{/if}
</TooltipRoot>export { default as Button } from './button.svelte'export { default as InlineMenu } from './inline-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/svelte'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/svelte/inline-popover'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const from = state.selection.$from
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 editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
let linkMenuOpen = $state(false)
function toggleLinkMenuOpen() {
linkMenuOpen = !linkMenuOpen
}
function handleLinkUpdate(href?: string) {
if (href) {
$editor.commands.addLink({ href })
} else {
$editor.commands.removeLink()
}
linkMenuOpen = false
$editor.focus()
}
</script>
<InlinePopoverRoot
onOpenChange={(event) => {
if (!event.detail) linkMenuOpen = false
}}
>
<InlinePopoverPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-main"
class="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
>
{#if $items.bold}
<Button
pressed={$items.bold.isActive}
disabled={!$items.bold.canExec}
onClick={$items.bold.command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
{/if}
{#if $items.italic}
<Button
pressed={$items.italic.isActive}
disabled={!$items.italic.canExec}
onClick={$items.italic.command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
{/if}
{#if $items.underline}
<Button
pressed={$items.underline.isActive}
disabled={!$items.underline.canExec}
onClick={$items.underline.command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
{/if}
{#if $items.strike}
<Button
pressed={$items.strike.isActive}
disabled={!$items.strike.canExec}
onClick={$items.strike.command}
tooltip="Strikethrough"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
{/if}
{#if $items.code}
<Button
pressed={$items.code.isActive}
disabled={!$items.code.canExec}
onClick={$items.code.command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
{/if}
{#if $items.link?.canExec && $items.link}
<Button
pressed={$items.link.isActive}
onClick={() => {
$items.link!.command()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
{/if}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
<InlinePopoverRoot
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={(event) => {
linkMenuOpen = event.detail
}}
>
<InlinePopoverPositioner placement="bottom" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-link"
class="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
{#if linkMenuOpen && $items.link}
<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..."
value={$items.link.currentLink || ''}
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
/>
</form>
{/if}
{#if $items.link?.isActive}
<button
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
onclick={() => handleLinkUpdate()}
onmousedown={(e) => e.preventDefault()}
>
Remove link
</button>
{/if}
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot><script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { sampleContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
const props = defineProps<{
initialContent?: NodeJSON
}>()
const extension = defineExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({ extension, defaultContent })
</script>
<template>
<ProseKit :editor="editor">
<div class="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-white dark:bg-gray-950 text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div :ref="(el) => editor.mount(el as HTMLElement | null)" class="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" />
<InlineMenu />
</div>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
export function defineExtension() {
return defineBasicExtension()
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import type { NodeJSON } from 'prosekit/core'
const loremText =
'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.'
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [
{
type: 'bold',
},
],
text: 'Try to select some text',
},
],
},
...Array.from({ length: 10 }, () => ({
type: 'paragraph' as const,
content: [
{
type: 'text' as const,
text: loremText,
},
],
})),
],
}<script setup lang="ts">
import { TooltipPopup, TooltipPositioner, TooltipRoot, TooltipTrigger } from 'prosekit/vue/tooltip'
const props = defineProps<{
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
}>()
</script>
<template>
<TooltipRoot>
<TooltipTrigger class="block">
<button
:data-state="props.pressed ? 'on' : 'off'"
:disabled="props.disabled"
class="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-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
@click="props.onClick"
@mousedown.prevent
>
<slot />
<span v-if="props.tooltip" class="sr-only">{{ props.tooltip }}</span>
</button>
</TooltipTrigger>
<TooltipPositioner v-if="props.tooltip" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition 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 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs text-nowrap">
{{ props.tooltip }}
</TooltipPopup>
</TooltipPositioner>
</TooltipRoot>
</template>export { default as Button } from './button.vue'export { default as InlineMenu } from './inline-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import { useEditor, useEditorDerivedValue } from 'prosekit/vue'
import { InlinePopoverPopup, InlinePopoverPositioner, InlinePopoverRoot } from 'prosekit/vue/inline-popover'
import { ref } from 'vue'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function 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 editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const linkMenuOpen = ref(false)
function toggleLinkMenuOpen() {
linkMenuOpen.value = !linkMenuOpen.value
}
function handleLinkUpdate(href?: string) {
if (href) {
editor.value.commands.addLink({ href })
} else {
editor.value.commands.removeLink()
}
linkMenuOpen.value = false
editor.value.focus()
}
</script>
<template>
<InlinePopoverRoot
@open-change="(event) => {
if (!event.detail) linkMenuOpen = false
}"
>
<InlinePopoverPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-main"
class="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
>
<Button
v-if="items.bold"
:pressed="items.bold.isActive"
:disabled="!items.bold.canExec"
tooltip="Bold"
@click="items.bold.command"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
<Button
v-if="items.italic"
:pressed="items.italic.isActive"
:disabled="!items.italic.canExec"
tooltip="Italic"
@click="items.italic.command"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
<Button
v-if="items.underline"
:pressed="items.underline.isActive"
:disabled="!items.underline.canExec"
tooltip="Underline"
@click="items.underline.command"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
<Button
v-if="items.strike"
:pressed="items.strike.isActive"
:disabled="!items.strike.canExec"
tooltip="Strikethrough"
@click="items.strike.command"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
<Button
v-if="items.code"
:pressed="items.code.isActive"
:disabled="!items.code.canExec"
tooltip="Code"
@click="items.code.command"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
<Button
v-if="items.link?.canExec && items.link"
:pressed="items.link.isActive"
tooltip="Link"
@click="() => {
items.link!.command()
toggleLinkMenuOpen()
}"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
<InlinePopoverRoot
v-if="items.link"
:default-open="false"
:open="linkMenuOpen"
@open-change="(event) => {
linkMenuOpen = event.detail
}"
>
<InlinePopoverPositioner placement="bottom" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<InlinePopoverPopup
data-testid="inline-menu-link"
class="box-border origin-(--transform-origin) transition 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 border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg overscroll-none relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
<form
v-if="linkMenuOpen"
@submit.prevent="(event) => {
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}"
>
<input
placeholder="Paste the link..."
:value="items.link.currentLink || ''"
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
</form>
<button
v-if="items.link.isActive"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
@click="() => handleLinkUpdate()"
@mousedown.prevent
>
Remove link
</button>
</InlinePopoverPopup>
</InlinePopoverPositioner>
</InlinePopoverRoot>
</template>