Example: inline-menu
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/svelte-example-inline-menunpx shadcn@latest add @prosekit/vue-example-inline-menuimport 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { defaultContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
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 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 defaultContent: 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 {
TooltipContent,
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
? (
<TooltipContent className="z-50 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 [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{props.tooltip}
</TooltipContent>
)
: 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 { InlinePopover } 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 (
<>
<InlinePopover
data-testid="inline-menu-main"
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
onOpenChange={(open) => {
if (!open) {
setLinkMenuOpen(false)
}
}}
>
{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>
)}
</InlinePopover>
{items.link && (
<InlinePopover
placement="bottom"
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={setLinkMenuOpen}
data-testid="inline-menu-link"
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden 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>
)}
</InlinePopover>
)}
</>
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { defaultContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
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 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>'use client'
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 defaultContent: 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 {
TooltipContent,
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
? (
<TooltipContent className="z-50 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 [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{props.tooltip}
</TooltipContent>
)
: 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/react'
import { InlinePopover } 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 (
<>
<InlinePopover
data-testid="inline-menu-main"
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
onOpenChange={(open) => {
if (!open) {
setLinkMenuOpen(false)
}
}}
>
{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>
)}
</InlinePopover>
{items.link && (
<InlinePopover
placement={'bottom'}
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={setLinkMenuOpen}
data-testid="inline-menu-link"
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 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={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"
>
</input>
</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>
)}
</InlinePopover>
)}
</>
)
}<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { defaultContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
const mount = (element: HTMLElement) => {
editor.mount(element)
return { destroy: () => editor.unmount() }
}
</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 use: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 defaultContent: 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 {
TooltipContent,
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}
<TooltipContent class="z-50 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 [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{props.tooltip}
</TooltipContent>
{/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 { InlinePopover } 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>
<InlinePopover
data-testid="inline-menu-main"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
onOpenChange={(open) => {
if (!open) linkMenuOpen = false
}}
>
{#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}
</InlinePopover>
<InlinePopover
placement="bottom"
defaultOpen={false}
open={linkMenuOpen}
data-testid="inline-menu-link"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
onOpenChange={(open) => {
linkMenuOpen = open
}}
>
{#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}
</InlinePopover><script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import {
ref,
watchPostEffect,
} from 'vue'
import { defaultContent } from '../../sample/sample-doc-inline-menu'
import { InlineMenu } from '../../ui/inline-menu'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
const editorRef = ref<HTMLDivElement | null>(null)
watchPostEffect((onCleanup) => {
editor.mount(editorRef.value)
onCleanup(() => editor.unmount())
})
</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="editorRef" 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 defaultContent: 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 {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/vue/tooltip'
defineProps<{
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
}>()
</script>
<template>
<TooltipRoot>
<TooltipTrigger class="block">
<button
:data-state="pressed ? 'on' : 'off'"
:disabled="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="onClick"
@mousedown.prevent
>
<slot />
<span v-if="tooltip" class="sr-only">{{ tooltip }}</span>
</button>
</TooltipTrigger>
<TooltipContent v-if="tooltip" class="z-50 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 [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{{ tooltip }}
</TooltipContent>
</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 { InlinePopover } 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>
<InlinePopover
data-testid="inline-menu-main"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
@open-change="(open) => {
if (!open) linkMenuOpen = false
}"
>
<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>
</InlinePopover>
<InlinePopover
v-if="items.link"
placement="bottom"
:default-open="false"
:open="linkMenuOpen"
data-testid="inline-menu-link"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
@open-change="(open) => {
linkMenuOpen = open
}"
>
<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>
</InlinePopover>
</template>