Slash menu
A slash menu is a popup of editor commands triggered by typing /. It filters the list as the user types and lets them pick with arrow keys or the mouse.
'use client'
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 { SlashMenu } from '../../ui/slash-menu'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension })
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] 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>
<SlashMenu />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'export { default as SlashMenu } from './slash-menu''use client'
import { AutocompleteEmpty } from 'prosekit/react/autocomplete'
export default function SlashMenuEmpty() {
return (
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
)
}'use client'
import { AutocompleteItem } from 'prosekit/react/autocomplete'
export default function SlashMenuItem(props: {
label: string
kbd?: string
onSelect: () => void
}) {
return (
<AutocompleteItem onSelect={props.onSelect} className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>{props.label}</span>
{props.kbd && <kbd className="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>}
</AutocompleteItem>
)
}'use client'
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/react'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/react/autocomplete'
import SlashMenuEmpty from './slash-menu-empty'
import SlashMenuItem from './slash-menu-item'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu() {
const editor = useEditor<BasicExtension>()
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
onSelect={() => editor.commands.setParagraph()}
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
onSelect={() => editor.commands.setHeading({ level: 1 })}
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
onSelect={() => editor.commands.setHeading({ level: 2 })}
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
onSelect={() => editor.commands.setHeading({ level: 3 })}
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
onSelect={() => editor.commands.wrapInList({ kind: 'bullet' })}
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
onSelect={() => editor.commands.wrapInList({ kind: 'ordered' })}
/>
<SlashMenuItem
label="Task list"
kbd="[]"
onSelect={() => editor.commands.wrapInList({ kind: 'task' })}
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
onSelect={() => editor.commands.wrapInList({ kind: 'toggle' })}
/>
<SlashMenuItem
label="Quote"
kbd=">"
onSelect={() => editor.commands.setBlockquote()}
/>
<SlashMenuItem
label="Table"
onSelect={() => editor.commands.insertTable({ row: 3, col: 3 })}
/>
<SlashMenuItem
label="Divider"
kbd="---"
onSelect={() => editor.commands.insertHorizontalRule()}
/>
<SlashMenuItem
label="Code"
kbd="```"
onSelect={() => editor.commands.setCodeBlock()}
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}import '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 { SlashMenu } from '../../ui/slash-menu'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension })
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] 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>
<SlashMenu />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'export { default as SlashMenu } from './slash-menu'import { AutocompleteEmpty } from 'prosekit/preact/autocomplete'
export default function SlashMenuEmpty() {
return (
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
)
}import { AutocompleteItem } from 'prosekit/preact/autocomplete'
export default function SlashMenuItem(props: {
label: string
kbd?: string
onSelect: () => void
}) {
return (
<AutocompleteItem onSelect={props.onSelect} className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>{props.label}</span>
{props.kbd && <kbd className="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>}
</AutocompleteItem>
)
}import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/preact'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/preact/autocomplete'
import SlashMenuEmpty from './slash-menu-empty'
import SlashMenuItem from './slash-menu-item'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu() {
const editor = useEditor<BasicExtension>()
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
onSelect={() => editor.commands.setParagraph()}
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
onSelect={() => editor.commands.setHeading({ level: 1 })}
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
onSelect={() => editor.commands.setHeading({ level: 2 })}
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
onSelect={() => editor.commands.setHeading({ level: 3 })}
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
onSelect={() => editor.commands.wrapInList({ kind: 'bullet' })}
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
onSelect={() => editor.commands.wrapInList({ kind: 'ordered' })}
/>
<SlashMenuItem
label="Task list"
kbd="[]"
onSelect={() => editor.commands.wrapInList({ kind: 'task' })}
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
onSelect={() => editor.commands.wrapInList({ kind: 'toggle' })}
/>
<SlashMenuItem
label="Quote"
kbd=">"
onSelect={() => editor.commands.setBlockquote()}
/>
<SlashMenuItem
label="Table"
onSelect={() => editor.commands.insertTable({ row: 3, col: 3 })}
/>
<SlashMenuItem
label="Divider"
kbd="---"
onSelect={() => editor.commands.insertHorizontalRule()}
/>
<SlashMenuItem
label="Code"
kbd="```"
onSelect={() => editor.commands.setCodeBlock()}
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { SlashMenu } from '../../ui/slash-menu'
import { defineExtension } from './extension'
export default function Editor(): JSX.Element {
const extension = defineExtension()
const editor = createEditor({ extension })
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-[canvas] 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>
<SlashMenu />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'export { default as SlashMenu } from './slash-menu'import { AutocompleteEmpty } from 'prosekit/solid/autocomplete'
import type { JSX } from 'solid-js'
export default function SlashMenuEmpty(): JSX.Element {
return (
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
)
}import { AutocompleteItem } from 'prosekit/solid/autocomplete'
import { Show, type JSX } from 'solid-js'
export default function SlashMenuItem(props: {
label: string
kbd?: string
onSelect: () => void
}): JSX.Element {
return (
<AutocompleteItem onSelect={props.onSelect} class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>{props.label}</span>
<Show when={props.kbd}>
<kbd class="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>
</Show>
</AutocompleteItem>
)
}import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/solid'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/solid/autocomplete'
import type { JSX } from 'solid-js'
import SlashMenuEmpty from './slash-menu-empty'
import SlashMenuItem from './slash-menu-item'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu(): JSX.Element {
const editor = useEditor<BasicExtension>()
return (
<AutocompleteRoot regex={regex}>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
onSelect={() => editor().commands.setParagraph()}
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
onSelect={() => editor().commands.setHeading({ level: 1 })}
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
onSelect={() => editor().commands.setHeading({ level: 2 })}
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
onSelect={() => editor().commands.setHeading({ level: 3 })}
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
onSelect={() => editor().commands.wrapInList({ kind: 'bullet' })}
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
onSelect={() => editor().commands.wrapInList({ kind: 'ordered' })}
/>
<SlashMenuItem
label="Task list"
kbd="[]"
onSelect={() => editor().commands.wrapInList({ kind: 'task' })}
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
onSelect={() => editor().commands.wrapInList({ kind: 'toggle' })}
/>
<SlashMenuItem
label="Quote"
kbd=">"
onSelect={() => editor().commands.setBlockquote()}
/>
<SlashMenuItem
label="Table"
onSelect={() => editor().commands.insertTable({ row: 3, col: 3 })}
/>
<SlashMenuItem
label="Divider"
kbd="---"
onSelect={() => editor().commands.insertHorizontalRule()}
/>
<SlashMenuItem
label="Code"
kbd="```"
onSelect={() => editor().commands.setCodeBlock()}
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
)
}<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { SlashMenu } from '../../ui/slash-menu'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension })
</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-[canvas] 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>
<SlashMenu />
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'export { default as SlashMenu } from './slash-menu.svelte'<script lang="ts">
import { AutocompleteEmpty } from 'prosekit/svelte/autocomplete'
</script>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty><script lang="ts">
import { AutocompleteItem } from 'prosekit/svelte/autocomplete'
interface Props {
label: string
kbd?: string
onSelect: () => void
}
const props: Props = $props()
</script>
<AutocompleteItem class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800" onSelect={props.onSelect}>
<span>{props.label}</span>{#if props.kbd}<kbd class="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>{/if}
</AutocompleteItem><script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/svelte'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/svelte/autocomplete'
import SlashMenuEmpty from './slash-menu-empty.svelte'
import SlashMenuItem from './slash-menu-item.svelte'
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
</script>
<AutocompleteRoot {regex}>
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
onSelect={() => $editor.commands.setParagraph()}
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
onSelect={() => $editor.commands.setHeading({ level: 1 })}
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
onSelect={() => $editor.commands.setHeading({ level: 2 })}
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
onSelect={() => $editor.commands.setHeading({ level: 3 })}
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
onSelect={() => $editor.commands.wrapInList({ kind: 'bullet' })}
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
onSelect={() => $editor.commands.wrapInList({ kind: 'ordered' })}
/>
<SlashMenuItem
label="Task list"
kbd="[]"
onSelect={() => $editor.commands.wrapInList({ kind: 'task' })}
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
onSelect={() => $editor.commands.wrapInList({ kind: 'toggle' })}
/>
<SlashMenuItem
label="Quote"
kbd=">"
onSelect={() => $editor.commands.setBlockquote()}
/>
<SlashMenuItem
label="Table"
onSelect={() => $editor.commands.insertTable({ row: 3, col: 3 })}
/>
<SlashMenuItem
label="Divider"
kbd="---"
onSelect={() => $editor.commands.insertHorizontalRule()}
/>
<SlashMenuItem
label="Code"
kbd="```"
onSelect={() => $editor.commands.setCodeBlock()}
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { renderSlashMenu } from '../../ui/slash-menu'
import { defineExtension } from './extension'
export function setupVanillaEditor() {
const extension = defineExtension()
const editor = createEditor({ extension })
return {
render: () => {
const port = document.createElement('div')
port.className = 'box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white'
const scrolling = document.createElement('div')
scrolling.className = 'relative w-full flex-1 box-border overflow-y-auto'
port.append(scrolling)
const content = document.createElement('div')
content.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'
scrolling.append(content)
scrolling.append(renderSlashMenu(editor))
editor.mount(content)
return port
},
destroy: () => {
editor.unmount()
},
}
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { setupVanillaEditor } from './editor'export { renderSlashMenu } from './slash-menu'import 'prosekit/web/autocomplete'
import type { AutocompleteEmptyElement } from 'prosekit/web/autocomplete'
export function renderSlashMenuEmpty() {
const empty = document.createElement('prosekit-autocomplete-empty') as AutocompleteEmptyElement
empty.className = 'relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800'
const span = document.createElement('span')
span.textContent = 'No results'
empty.append(span)
return empty
}import 'prosekit/web/autocomplete'
import type { AutocompleteItemElement } from 'prosekit/web/autocomplete'
export function renderSlashMenuItem(options: {
label: string
kbd?: string
onSelect: () => void
}) {
const item = document.createElement('prosekit-autocomplete-item') as AutocompleteItemElement
item.className = 'relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800'
item.addEventListener('select', () => options.onSelect())
const span = document.createElement('span')
span.textContent = options.label
item.append(span)
if (options.kbd) {
const kbd = document.createElement('kbd')
kbd.className = 'text-xs font-mono text-gray-400 dark:text-gray-500'
kbd.textContent = options.kbd
item.append(kbd)
}
return item
}import 'prosekit/web/autocomplete'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import { canUseRegexLookbehind } from 'prosekit/core'
import type { AutocompletePopupElement, AutocompletePositionerElement, AutocompleteRootElement } from 'prosekit/web/autocomplete'
import { renderSlashMenuEmpty } from './slash-menu-empty'
import { renderSlashMenuItem } from './slash-menu-item'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export function renderSlashMenu(
editor: Editor<BasicExtension>,
) {
const root = document.createElement('prosekit-autocomplete-root') as AutocompleteRootElement
root.editor = editor
root.regex = regex
const positioner = document.createElement('prosekit-autocomplete-positioner') as AutocompletePositionerElement
positioner.className = 'block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none'
const popup = document.createElement('prosekit-autocomplete-popup') as AutocompletePopupElement
popup.className = 'box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap'
const content = document.createElement('div')
content.className = 'flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain'
content.append(renderSlashMenuItem({ label: 'Text', kbd: undefined, onSelect: () => editor.commands.setParagraph() }))
content.append(renderSlashMenuItem({ label: 'Heading 1', kbd: '#', onSelect: () => editor.commands.setHeading({ level: 1 }) }))
content.append(renderSlashMenuItem({ label: 'Heading 2', kbd: '##', onSelect: () => editor.commands.setHeading({ level: 2 }) }))
content.append(renderSlashMenuItem({ label: 'Heading 3', kbd: '###', onSelect: () => editor.commands.setHeading({ level: 3 }) }))
content.append(renderSlashMenuItem({ label: 'Bullet list', kbd: '-', onSelect: () => editor.commands.wrapInList({ kind: 'bullet' }) }))
content.append(renderSlashMenuItem({ label: 'Ordered list', kbd: '1.', onSelect: () => editor.commands.wrapInList({ kind: 'ordered' }) }))
content.append(renderSlashMenuItem({ label: 'Task list', kbd: '[]', onSelect: () => editor.commands.wrapInList({ kind: 'task' }) }))
content.append(renderSlashMenuItem({ label: 'Toggle list', kbd: '>>', onSelect: () => editor.commands.wrapInList({ kind: 'toggle' }) }))
content.append(renderSlashMenuItem({ label: 'Quote', kbd: '>', onSelect: () => editor.commands.setBlockquote() }))
content.append(renderSlashMenuItem({ label: 'Table', onSelect: () => editor.commands.insertTable({ row: 3, col: 3 }) }))
content.append(renderSlashMenuItem({ label: 'Divider', kbd: '---', onSelect: () => editor.commands.insertHorizontalRule() }))
content.append(renderSlashMenuItem({ label: 'Code', kbd: '```', onSelect: () => editor.commands.setCodeBlock() }))
content.append(renderSlashMenuEmpty())
popup.append(content)
positioner.append(popup)
root.append(positioner)
return root
}<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 { SlashMenu } from '../../ui/slash-menu'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension })
</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-[canvas] 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" />
<SlashMenu />
</div>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'export { default as SlashMenu } from './slash-menu.vue'<script setup lang="ts">
import { AutocompleteEmpty } from 'prosekit/vue/autocomplete'
</script>
<template>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
</template><script setup lang="ts">
import { AutocompleteItem } from 'prosekit/vue/autocomplete'
const props = defineProps<{
label: string
kbd?: string
onSelect: () => void
}>()
</script>
<template>
<AutocompleteItem class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800" @select="props.onSelect">
<span>{{ props.label }}</span>
<kbd v-if="props.kbd" class="text-xs font-mono text-gray-400 dark:text-gray-500">{{ props.kbd }}</kbd>
</AutocompleteItem>
</template><script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/vue'
import { AutocompletePopup, AutocompletePositioner, AutocompleteRoot } from 'prosekit/vue/autocomplete'
import SlashMenuEmpty from './slash-menu-empty.vue'
import SlashMenuItem from './slash-menu-item.vue'
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
</script>
<template>
<AutocompleteRoot :regex="regex">
<AutocompletePositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<AutocompletePopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<SlashMenuItem
label="Text"
@select="() => editor.commands.setParagraph()"
/>
<SlashMenuItem
label="Heading 1"
kbd="#"
@select="() => editor.commands.setHeading({ level: 1 })"
/>
<SlashMenuItem
label="Heading 2"
kbd="##"
@select="() => editor.commands.setHeading({ level: 2 })"
/>
<SlashMenuItem
label="Heading 3"
kbd="###"
@select="() => editor.commands.setHeading({ level: 3 })"
/>
<SlashMenuItem
label="Bullet list"
kbd="-"
@select="() => editor.commands.wrapInList({ kind: 'bullet' })"
/>
<SlashMenuItem
label="Ordered list"
kbd="1."
@select="() => editor.commands.wrapInList({ kind: 'ordered' })"
/>
<SlashMenuItem
label="Task list"
kbd="[]"
@select="() => editor.commands.wrapInList({ kind: 'task' })"
/>
<SlashMenuItem
label="Toggle list"
kbd=">>"
@select="() => editor.commands.wrapInList({ kind: 'toggle' })"
/>
<SlashMenuItem
label="Quote"
kbd=">"
@select="() => editor.commands.setBlockquote()"
/>
<SlashMenuItem
label="Table"
@select="() => editor.commands.insertTable({ row: 3, col: 3 })"
/>
<SlashMenuItem
label="Divider"
kbd="---"
@select="() => editor.commands.insertHorizontalRule()"
/>
<SlashMenuItem
label="Code"
kbd="```"
@select="() => editor.commands.setCodeBlock()"
/>
<SlashMenuEmpty />
</div>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompleteRoot>
</template>import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { ContextProvider } from '@lit/context'
import { html, LitElement, type PropertyDeclaration, type PropertyValues } from 'lit'
import { createRef, ref, type Ref } from 'lit/directives/ref.js'
import type { Editor } from 'prosekit/core'
import { createEditor } from 'prosekit/core'
import { editorContext } from '../../ui/editor-context'
import { registerLitEditorSlashMenu } from '../../ui/slash-menu'
import { defineExtension } from './extension'
export class LitEditor extends LitElement {
static override properties = {
editor: {
state: true,
attribute: false,
} satisfies PropertyDeclaration<Editor>,
}
private editor: Editor
private ref: Ref<HTMLDivElement>
constructor() {
super()
const extension = defineExtension()
this.editor = createEditor({ extension })
this.ref = createRef<HTMLDivElement>()
new ContextProvider(this, {
context: editorContext,
initialValue: this.editor,
})
}
override createRenderRoot() {
return this
}
override disconnectedCallback() {
this.editor.unmount()
super.disconnectedCallback()
}
override updated(changedProperties: PropertyValues) {
super.updated(changedProperties)
this.editor.mount(this.ref.value)
}
override render() {
return html`<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-[canvas] text-black dark:text-white">
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ${ref(this.ref)} 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>
<lit-editor-slash-menu style="display: contents;"></lit-editor-slash-menu>
</div>
</div>`
}
}
export function registerLitEditor() {
registerLitEditorSlashMenu()
if (customElements.get('lit-editor-example-slash-menu')) return
customElements.define('lit-editor-example-slash-menu', LitEditor)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-example-slash-menu': LitEditor
}
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { LitEditor as ExampleEditor, registerLitEditor } from './editor'import { createContext } from '@lit/context'
import type { Editor } from 'prosekit/core'
export const editorContext = createContext<Editor | undefined>('prosekit-editor')export { registerLitEditorSlashMenu } from './slash-menu'import { html, LitElement } from 'lit'
export class SlashMenuEmptyElement extends LitElement {
override createRenderRoot() {
return this
}
override render() {
return html`
<prosekit-autocomplete-empty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800">
<span>No results</span>
</prosekit-autocomplete-empty>
`
}
}import { html, LitElement } from 'lit'
export class SlashMenuItemElement extends LitElement {
static override properties = {
label: { type: String },
kbd: { type: String },
}
label: string
kbd: string
constructor() {
super()
this.label = ''
this.kbd = ''
}
override createRenderRoot() {
return this
}
// TODO: maybe this should changed to valueChange event??
handleSelect = () => {
this.dispatchEvent(new CustomEvent('select'))
}
override render() {
return html`<prosekit-autocomplete-item
@select=${this.handleSelect}
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-md px-3 py-1.5 text-sm box-border cursor-default select-none whitespace-nowrap outline-hidden data-highlighted:bg-gray-100 dark:data-highlighted:bg-gray-800"
>
<span>${this.label}</span>${this.kbd
? html`<kbd class="text-xs font-mono text-gray-400 dark:text-gray-500">${this.kbd}</kbd>`
: ''}
</prosekit-autocomplete-item>`
}
}import { ContextConsumer } from '@lit/context'
import { html, LitElement } from 'lit'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import { canUseRegexLookbehind } from 'prosekit/core'
import {
registerAutocompleteEmptyElement,
registerAutocompleteItemElement,
registerAutocompletePopupElement,
registerAutocompletePositionerElement,
registerAutocompleteRootElement,
} from 'prosekit/lit/autocomplete'
import { editorContext } from '../editor-context'
import { SlashMenuEmptyElement } from './slash-menu-empty'
import { SlashMenuItemElement } from './slash-menu-item'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
class SlashMenuElement extends LitElement {
private editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
override createRenderRoot() {
return this
}
override render() {
const editor = this.editorConsumer.value as Editor<BasicExtension> | undefined
if (!editor) {
return html``
}
return html`<prosekit-autocomplete-root .editor=${editor} .regex=${regex}>
<prosekit-autocomplete-positioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-autocomplete-popup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col relative max-h-100 min-h-0 min-w-60 select-none overflow-hidden whitespace-nowrap">
<div class="flex flex-col flex-1 min-h-0 overflow-y-auto p-1 bg-[canvas] overscroll-contain">
<lit-editor-slash-menu-item
class="contents"
label="Text"
@select=${() => editor.commands.setParagraph()}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Heading 1"
kbd="#"
@select=${() => editor.commands.setHeading({ level: 1 })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Heading 2"
kbd="##"
@select=${() => editor.commands.setHeading({ level: 2 })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Heading 3"
kbd="###"
@select=${() => editor.commands.setHeading({ level: 3 })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Bullet list"
kbd="-"
@select=${() => editor.commands.wrapInList({ kind: 'bullet' })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Ordered list"
kbd="1."
@select=${() => editor.commands.wrapInList({ kind: 'ordered' })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Task list"
kbd="[]"
@select=${() => editor.commands.wrapInList({ kind: 'task' })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Toggle list"
kbd=">>"
@select=${() => editor.commands.wrapInList({ kind: 'toggle' })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Quote"
kbd=">"
@select=${() => editor.commands.setBlockquote()}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Table"
@select=${() => editor.commands.insertTable({ row: 3, col: 3 })}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Divider"
kbd="---"
@select=${() => editor.commands.insertHorizontalRule()}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-item
class="contents"
label="Code"
kbd="\`\`\`"
@select=${() => editor.commands.setCodeBlock()}
></lit-editor-slash-menu-item>
<lit-editor-slash-menu-empty class="contents"></lit-editor-slash-menu-empty>
</div>
</prosekit-autocomplete-popup>
</prosekit-autocomplete-positioner>
</prosekit-autocomplete-root>`
}
}
export function registerLitEditorSlashMenu() {
registerAutocompleteEmptyElement()
registerAutocompleteItemElement()
registerAutocompletePopupElement()
registerAutocompletePositionerElement()
registerAutocompleteRootElement()
if (!customElements.get('lit-editor-slash-menu-item')) {
customElements.define('lit-editor-slash-menu-item', SlashMenuItemElement)
}
if (!customElements.get('lit-editor-slash-menu-empty')) {
customElements.define('lit-editor-slash-menu-empty', SlashMenuEmptyElement)
}
if (customElements.get('lit-editor-slash-menu')) return
customElements.define('lit-editor-slash-menu', SlashMenuElement)
}Install
Section titled “Install”npx shadcn@latest add @prosekit/react-ui-slash-menunpx shadcn@latest add @prosekit/preact-ui-slash-menunpx shadcn@latest add @prosekit/solid-ui-slash-menunpx shadcn@latest add @prosekit/svelte-ui-slash-menunpx shadcn@latest add @prosekit/vue-ui-slash-menuCopy and paste the code from the demo above into your project.
Structure
Section titled “Structure”The slash menu is built on the autocomplete primitive, which detects the trigger character (/), tracks the query text after it, anchors the floating list to the cursor, and handles arrow-key navigation, Enter, and Escape.
AutocompleteRoot # root component
└── AutocompletePositioner # anchors the popup to the cursor
└── AutocompletePopup # the floating panel
├── AutocompleteItem # one per command (heading, list, table, …)
└── AutocompleteEmpty # shown when no item matches the queryEach AutocompleteItem's onSelect runs an editor command. The regex you pass to AutocompleteRoot decides what counts as the trigger.
API reference
Section titled “API reference”- prosekit/react/autocomplete
- prosekit/vue/autocomplete
- prosekit/preact/autocomplete
- prosekit/svelte/autocomplete
- prosekit/solid/autocomplete
See also
Section titled “See also”- Mention menu: same primitive, with
@or#as the trigger character.