Example: user-menu-dynamic
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 { defineExtension } from './extension' import UserMenuDynamic from './user-menu-dynamic' 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 flex flex-col bg-white dark:bg-gray-950 color-black dark:color-white'> <div className='relative w-full flex-1 box-border overflow-y-scroll'> <div ref={editor.mount} className='ProseMirror box-border min-h-full px-[max(4rem,_calc(50%-20rem))] py-8 outline-none outline-0 [&_span[data-mention="user"]]:text-blue-500 [&_span[data-mention="tag"]]:text-violet-500'></div> <UserMenuDynamic /> </div> </div> </ProseKit> ) }
import { defineBasicExtension } from 'prosekit/basic' import { union } from 'prosekit/core' import { defineMention } from 'prosekit/extensions/mention' import { definePlaceholder } from 'prosekit/extensions/placeholder' export function defineExtension() { return union( defineBasicExtension(), definePlaceholder({ placeholder: 'Type @ to metion someone...', }), defineMention(), ) } export type EditorExtension = ReturnType<typeof defineExtension>
import { useEffect, useState, } from 'react' import { users as allUsers } from './user-data' /** * Simulate a user searching with some delay. */ export function useUserQuery(query: string, enabled: boolean) { const [users, setUsers] = useState<{ name: string; id: number }[]>([]) const [loading, setLoading] = useState(true) useEffect(() => { if (!enabled) { setUsers([]) return } setLoading(true) const searchQuery = query.toLowerCase() const id = setTimeout(async () => { await waitForTestBlocking() setLoading(false) setUsers( allUsers .filter((user) => user.name.toLowerCase().includes(searchQuery)) .slice(0, 10), ) }, 500) return () => { clearTimeout(id) } }, [enabled, query]) return { loading, users } } /** * Use a global variable to simulate a network request delay. */ async function waitForTestBlocking() { return await new Promise((resolve) => { const id = setInterval(() => { const hasTestBlocking = !!(window as any)._PROSEKIT_TEST_BLOCKING if (!hasTestBlocking) { clearInterval(id) resolve(true) } }, 100) }) }
export const users = [ { id: 1, name: 'Alex' }, { id: 2, name: 'Alice' }, { id: 3, name: 'Ben' }, { id: 4, name: 'Bob' }, { id: 5, name: 'Charlie' }, { id: 6, name: 'Cara' }, { id: 7, name: 'Derek' }, { id: 8, name: 'Diana' }, { id: 9, name: 'Ethan' }, { id: 10, name: 'Eva' }, { id: 11, name: 'Frank' }, { id: 12, name: 'Fiona' }, { id: 13, name: 'George' }, { id: 14, name: 'Gina' }, { id: 15, name: 'Harry' }, { id: 16, name: 'Hannah' }, { id: 17, name: 'Ivan' }, { id: 18, name: 'Iris' }, { id: 19, name: 'Jack' }, { id: 20, name: 'Jasmine' }, { id: 21, name: 'Kevin' }, { id: 22, name: 'Kate' }, { id: 23, name: 'Leo' }, { id: 24, name: 'Lily' }, { id: 25, name: 'Mike' }, { id: 26, name: 'Mia' }, { id: 27, name: 'Nathan' }, { id: 28, name: 'Nancy' }, { id: 29, name: 'Oscar' }, { id: 30, name: 'Olivia' }, { id: 31, name: 'Paul' }, { id: 32, name: 'Penny' }, { id: 33, name: 'Quentin' }, { id: 34, name: 'Queen' }, { id: 35, name: 'Roger' }, { id: 36, name: 'Rita' }, { id: 37, name: 'Sam' }, { id: 38, name: 'Sara' }, { id: 39, name: 'Tom' }, { id: 40, name: 'Tina' }, { id: 41, name: 'Ulysses' }, { id: 42, name: 'Una' }, { id: 43, name: 'Victor' }, { id: 44, name: 'Vera' }, { id: 45, name: 'Walter' }, { id: 46, name: 'Wendy' }, { id: 47, name: 'Xavier' }, { id: 48, name: 'Xena' }, { id: 49, name: 'Yan' }, { id: 50, name: 'Yvonne' }, { id: 51, name: 'Zack' }, { id: 52, name: 'Zara' }, ]
import { useEditor } from 'prosekit/react' import { AutocompleteEmpty, AutocompleteItem, AutocompleteList, AutocompletePopover, } from 'prosekit/react/autocomplete' import { useState } from 'react' import type { EditorExtension } from './extension' import { useUserQuery } from './use-user-query' export default function UserMenuDynamic() { const editor = useEditor<EditorExtension>() const [query, setQuery] = useState('') const [open, setOpen] = useState(false) const handleUserInsert = (id: number, username: string) => { editor.commands.insertMention({ id: id.toString(), value: '@' + username, kind: 'user', }) editor.commands.insertText({ text: ' ' }) } const { loading, users } = useUserQuery(query, open) return ( <AutocompletePopover regex={/@\w*$/} onQueryChange={setQuery} onOpenChange={setOpen} className='relative block max-h-[25rem] min-w-[15rem] select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden' > <AutocompleteList filter={null}> <AutocompleteEmpty className='relative flex items-center justify-between min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-gray-100 dark:data-[focused]:bg-gray-800'> {loading ? 'Loading...' : 'No results'} </AutocompleteEmpty> {users.map((user) => ( <AutocompleteItem key={user.id} className='relative flex items-center justify-between min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-gray-100 dark:data-[focused]:bg-gray-800' onSelect={() => handleUserInsert(user.id, user.name)} > <span className={loading ? 'opacity-50' : undefined}> {user.name} </span> </AutocompleteItem> ))} </AutocompleteList> </AutocompletePopover> ) }
<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 { defineExtension } from './extension' import UserMenuDynamic from './user-menu-dynamic.vue' const editor = createEditor({ extension: defineExtension() }) 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 flex flex-col bg-white dark:bg-gray-950 color-black dark:color-white'> <div class='relative w-full flex-1 box-border overflow-y-scroll'> <div ref="editorRef" class='ProseMirror box-border min-h-full px-[max(4rem,_calc(50%-20rem))] py-8 outline-none outline-0 [&_span[data-mention="user"]]:text-blue-500 [&_span[data-mention="tag"]]:text-violet-500' /> <UserMenuDynamic /> </div> </div> </ProseKit> </template>
import { defineBasicExtension } from 'prosekit/basic' import { union } from 'prosekit/core' import { defineMention } from 'prosekit/extensions/mention' import { definePlaceholder } from 'prosekit/extensions/placeholder' export function defineExtension() { return union( defineBasicExtension(), definePlaceholder({ placeholder: 'Type @ to metion someone...', }), defineMention(), ) } export type EditorExtension = ReturnType<typeof defineExtension>
import { ref, watchEffect, type Ref, } from 'vue' import { users as allUsers } from './user-data' /** * Simulate a user searching with some delay. */ export function useUserQuery(query: Ref<string>, enabled: Ref<boolean>) { const users = ref<{ id: number; name: string }[]>([]) const loading = ref(true) watchEffect((onInvalidate) => { if (!enabled.value) { users.value = [] return } loading.value = true const searchQuery = query.value.toLowerCase() const timeoutId = setTimeout(async () => { await waitForTestBlocking() loading.value = false users.value = allUsers .filter((user) => user.name.toLowerCase().includes(searchQuery)) .slice(0, 10) }, 500) onInvalidate(() => { clearTimeout(timeoutId) }) }) return { loading, users } } /** * Use a global variable to simulate a network request delay. */ async function waitForTestBlocking() { return await new Promise((resolve) => { const id = setInterval(() => { const hasTestBlocking = !!(window as any)._PROSEKIT_TEST_BLOCKING if (!hasTestBlocking) { clearInterval(id) resolve(true) } }, 100) }) }
export const users = [ { id: 1, name: 'Alex' }, { id: 2, name: 'Alice' }, { id: 3, name: 'Ben' }, { id: 4, name: 'Bob' }, { id: 5, name: 'Charlie' }, { id: 6, name: 'Cara' }, { id: 7, name: 'Derek' }, { id: 8, name: 'Diana' }, { id: 9, name: 'Ethan' }, { id: 10, name: 'Eva' }, { id: 11, name: 'Frank' }, { id: 12, name: 'Fiona' }, { id: 13, name: 'George' }, { id: 14, name: 'Gina' }, { id: 15, name: 'Harry' }, { id: 16, name: 'Hannah' }, { id: 17, name: 'Ivan' }, { id: 18, name: 'Iris' }, { id: 19, name: 'Jack' }, { id: 20, name: 'Jasmine' }, { id: 21, name: 'Kevin' }, { id: 22, name: 'Kate' }, { id: 23, name: 'Leo' }, { id: 24, name: 'Lily' }, { id: 25, name: 'Mike' }, { id: 26, name: 'Mia' }, { id: 27, name: 'Nathan' }, { id: 28, name: 'Nancy' }, { id: 29, name: 'Oscar' }, { id: 30, name: 'Olivia' }, { id: 31, name: 'Paul' }, { id: 32, name: 'Penny' }, { id: 33, name: 'Quentin' }, { id: 34, name: 'Queen' }, { id: 35, name: 'Roger' }, { id: 36, name: 'Rita' }, { id: 37, name: 'Sam' }, { id: 38, name: 'Sara' }, { id: 39, name: 'Tom' }, { id: 40, name: 'Tina' }, { id: 41, name: 'Ulysses' }, { id: 42, name: 'Una' }, { id: 43, name: 'Victor' }, { id: 44, name: 'Vera' }, { id: 45, name: 'Walter' }, { id: 46, name: 'Wendy' }, { id: 47, name: 'Xavier' }, { id: 48, name: 'Xena' }, { id: 49, name: 'Yan' }, { id: 50, name: 'Yvonne' }, { id: 51, name: 'Zack' }, { id: 52, name: 'Zara' }, ]
<script setup lang="ts"> import { useEditor } from 'prosekit/vue' import { AutocompleteEmpty, AutocompleteItem, AutocompleteList, AutocompletePopover, } from 'prosekit/vue/autocomplete' import { ref } from 'vue' import type { EditorExtension } from './extension' import { useUserQuery } from './use-user-query' const editor = useEditor<EditorExtension>() function handleUserInsert(id: number, username: string) { editor.value.commands.insertMention({ id: id.toString(), value: '@' + username, kind: 'user', }) editor.value.commands.insertText({ text: ' ' }) } const query = ref('') const open = ref(false) const handleQueryChange = (value: string) => (query.value = value) const handleOpenChange = (value: boolean) => (open.value = value) const { users, loading } = useUserQuery(query, open) </script> <template> <AutocompletePopover :regex="/@\w*$/" class='relative block max-h-[25rem] min-w-[15rem] select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden' @query-change="handleQueryChange" @open-change="handleOpenChange" > <AutocompleteList :filter="null"> <AutocompleteEmpty class='relative flex items-center justify-between min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-gray-100 dark:data-[focused]:bg-gray-800'> {{ loading ? 'Loading...' : 'No results' }} </AutocompleteEmpty> <AutocompleteItem v-for="user in users" :key="user.id" class='relative flex items-center justify-between min-w-[8rem] scroll-my-1 rounded px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-none data-[focused]:bg-gray-100 dark:data-[focused]:bg-gray-800' @select="() => handleUserInsert(user.id, user.name)" > <span :class="loading && 'opacity-50'"> {{ user.name }} </span> </AutocompleteItem> </AutocompleteList> </AutocompletePopover> </template>