Example: user-menu-dynamic
Install this example with
shadcn:npx shadcn@latest add @prosekit/react-example-user-menu-dynamicnpx shadcn@latest add @prosekit/preact-example-user-menu-dynamicnpx shadcn@latest add @prosekit/svelte-example-user-menu-dynamicnpx shadcn@latest add @prosekit/vue-example-user-menu-dynamicimport '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 { 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-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>
<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 mention someone...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import {
useEffect,
useState,
} from 'preact/hooks'
import type { User } from '../../sample/query-users'
import { queryUsers } from '../../sample/query-users'
/**
* Simulate a user searching with some delay.
*/
export function useUserQuery(query: string, enabled: boolean) {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
if (!enabled && users.length > 0) {
setUsers([])
}
useEffect(() => {
if (!enabled) {
return
}
let cancelled = false
void (async () => {
setLoading(true)
const filteredUsers = await queryUsers(query)
if (cancelled) {
return
}
setUsers(filteredUsers)
setLoading(false)
})()
return () => {
cancelled = true
}
}, [enabled, query])
return { loading, users }
}import { useState } from 'preact/hooks'
import { UserMenu } from '../../ui/user-menu'
import { useUserQuery } from './use-user-query'
export default function UserMenuDynamic() {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const { loading, users } = useUserQuery(query, open)
return (
<UserMenu
users={users}
loading={loading}
onQueryChange={setQuery}
onOpenChange={setOpen}
/>
)
}import { users } from './user-data'
export interface User {
id: number
name: string
}
const connectHandlers: VoidFunction[] = []
let networkStatus: 'fast' | 'slow' | 'offline' = 'slow'
/**
* A utility function to simulate different network states. Useful for testing.
*
* @internal
*/
export function simulateNetworkStatus(status: 'fast' | 'slow' | 'offline') {
networkStatus = status
if (status !== 'offline') {
connectHandlers.forEach((handler) => handler())
connectHandlers.length = 0
}
}
/**
* Simulate a user searching with some delay.
*/
export async function queryUsers(query: string): Promise<User[]> {
if (networkStatus === 'offline') {
await new Promise<void>((resolve) => connectHandlers.push(resolve))
}
if (networkStatus === 'slow') {
await new Promise<void>((resolve) => setTimeout(resolve, 300))
}
const normalizedQuery = query.toLowerCase().trim()
const filteredUsers = users
.filter((user) => user.name.toLowerCase().includes(normalizedQuery))
.slice(0, 10)
return filteredUsers
}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' },
]export { default as UserMenu } from './user-menu'import type { BasicExtension } from 'prosekit/basic'
import {
canUseRegexLookbehind,
type Union,
} from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/preact'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/preact/autocomplete'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompletePopover
regex={regex}
className="relative block max-h-100 min-w-60 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"
onQueryChange={props.onQueryChange}
onOpenChange={props.onOpenChange}
>
<AutocompleteList>
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{props.users.map((user) => (
<AutocompleteItem
key={user.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span className={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
)
}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-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>
<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 mention someone...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>'use client'
export { default as ExampleEditor } from './editor'import {
useEffect,
useState,
} from 'react'
import type { User } from '../../sample/query-users'
import { queryUsers } from '../../sample/query-users'
/**
* Simulate a user searching with some delay.
*/
export function useUserQuery(query: string, enabled: boolean) {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
if (!enabled && users.length > 0) {
setUsers([])
}
useEffect(() => {
if (!enabled) {
return
}
let cancelled = false
void (async () => {
setLoading(true)
const filteredUsers = await queryUsers(query)
if (cancelled) {
return
}
setUsers(filteredUsers)
setLoading(false)
})()
return () => {
cancelled = true
}
}, [enabled, query])
return { loading, users }
}import { useState } from 'react'
import { UserMenu } from '../../ui/user-menu'
import { useUserQuery } from './use-user-query'
export default function UserMenuDynamic() {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const { loading, users } = useUserQuery(query, open)
return (
<UserMenu
users={users}
loading={loading}
onQueryChange={setQuery}
onOpenChange={setOpen}
/>
)
}import { users } from './user-data'
export interface User {
id: number
name: string
}
const connectHandlers: VoidFunction[] = []
let networkStatus: 'fast' | 'slow' | 'offline' = 'slow'
/**
* A utility function to simulate different network states. Useful for testing.
*
* @internal
*/
export function simulateNetworkStatus(status: 'fast' | 'slow' | 'offline') {
networkStatus = status
if (status !== 'offline') {
connectHandlers.forEach((handler) => handler())
connectHandlers.length = 0
}
}
/**
* Simulate a user searching with some delay.
*/
export async function queryUsers(query: string): Promise<User[]> {
if (networkStatus === 'offline') {
await new Promise<void>((resolve) => connectHandlers.push(resolve))
}
if (networkStatus === 'slow') {
await new Promise<void>((resolve) => setTimeout(resolve, 300))
}
const normalizedQuery = query.toLowerCase().trim()
const filteredUsers = users
.filter((user) => user.name.toLowerCase().includes(normalizedQuery))
.slice(0, 10)
return filteredUsers
}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' },
]export { default as UserMenu } from './user-menu'import type { BasicExtension } from 'prosekit/basic'
import {
canUseRegexLookbehind,
type Union,
} from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/react/autocomplete'
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompletePopover
regex={regex}
className="relative block max-h-100 min-w-60 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"
onQueryChange={props.onQueryChange}
onOpenChange={props.onOpenChange}
>
<AutocompleteList>
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{props.users.map((user) => (
<AutocompleteItem
key={user.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span className={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
)
}- examples/user-menu-dynamic/editor.svelte
- examples/user-menu-dynamic/extension.ts
- examples/user-menu-dynamic/index.ts
- examples/user-menu-dynamic/use-user-query.svelte.ts
- examples/user-menu-dynamic/user-menu-dynamic.svelte
- sample/query-users.ts
- sample/user-data.ts
- ui/user-menu/index.ts
- ui/user-menu/user-menu.svelte
<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import UserMenuDynamic from './user-menu-dynamic.svelte'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension })
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>
<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 mention someone...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import {
queryUsers,
type User,
} from '../../sample/query-users'
export function useUserQuery(
getQuery: () => string,
getEnabled: () => boolean,
) {
let loading = $state(true)
let users = $state<User[]>([])
$effect(() => {
const query = getQuery()
const enabled = getEnabled()
if (!enabled) {
users = []
return
}
loading = true
let cancelled = false
void queryUsers(query).then((result) => {
if (cancelled) {
return
}
users = result
loading = false
})
return () => {
cancelled = true
}
})
return {
getLoading: () => loading,
getUsers: () => users,
}
}<script lang="ts">
import { UserMenu } from '../../ui/user-menu'
import { useUserQuery } from './use-user-query.svelte.js'
let query = $state('')
let open = $state(false)
const { getLoading, getUsers } = useUserQuery(() => query, () => open)
function handleQueryChange(value: string) {
query = value
}
function handleOpenChange(value: boolean) {
open = value
}
</script>
<UserMenu
users={getUsers()}
loading={getLoading()}
onQueryChange={handleQueryChange}
onOpenChange={handleOpenChange}
/>import { users } from './user-data'
export interface User {
id: number
name: string
}
const connectHandlers: VoidFunction[] = []
let networkStatus: 'fast' | 'slow' | 'offline' = 'slow'
/**
* A utility function to simulate different network states. Useful for testing.
*
* @internal
*/
export function simulateNetworkStatus(status: 'fast' | 'slow' | 'offline') {
networkStatus = status
if (status !== 'offline') {
connectHandlers.forEach((handler) => handler())
connectHandlers.length = 0
}
}
/**
* Simulate a user searching with some delay.
*/
export async function queryUsers(query: string): Promise<User[]> {
if (networkStatus === 'offline') {
await new Promise<void>((resolve) => connectHandlers.push(resolve))
}
if (networkStatus === 'slow') {
await new Promise<void>((resolve) => setTimeout(resolve, 300))
}
const normalizedQuery = query.toLowerCase().trim()
const filteredUsers = users
.filter((user) => user.name.toLowerCase().includes(normalizedQuery))
.slice(0, 10)
return filteredUsers
}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' },
]export { default as UserMenu } from './user-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import {
canUseRegexLookbehind,
type Union,
} from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/svelte'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/svelte/autocomplete'
interface Props {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}
const props: Props = $props()
const loading = $derived(props.loading ?? false)
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleUserInsert(id: number, username: string) {
$editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
$editor.commands.insertText({ text: ' ' })
}
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
</script>
<AutocompletePopover
{regex}
class="relative block max-h-100 min-w-60 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"
onQueryChange={props.onQueryChange}
onOpenChange={props.onOpenChange}
>
<AutocompleteList>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{#each props.users as user (user.id)}
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
{#if loading}
<span class="opacity-50">
{user.name}
</span>
{:else}
<span>
{user.name}
</span>
{/if}
</AutocompleteItem>
{/each}
</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 extension = defineExtension()
const editor = createEditor({ extension })
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"></div>
<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 mention someone...',
}),
defineMention(),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import type { Ref } from 'vue'
import {
ref,
watchEffect,
} from 'vue'
import {
queryUsers,
type User,
} from '../../sample/query-users'
/**
* Simulate a user searching with some delay.
*/
export function useUserQuery(query: Ref<string>, enabled: Ref<boolean>) {
const users = ref<User[]>([])
const loading = ref(true)
watchEffect(
(onCleanup) => {
if (!enabled.value) {
users.value = []
return
}
loading.value = true
let cancelled = false
void queryUsers(query.value).then((result) => {
if (cancelled) {
return
}
users.value = result
loading.value = false
})
onCleanup(() => {
cancelled = true
})
},
)
return { loading, users }
}<script setup lang="ts">
import { ref } from 'vue'
import { UserMenu } from '../../ui/user-menu'
import { useUserQuery } from './use-user-query'
const query = ref('')
const open = ref(false)
const { loading, users } = useUserQuery(query, open)
function handleQueryChange(q: string) {
query.value = q
}
function handleOpenChange(o: boolean) {
open.value = o
}
</script>
<template>
<UserMenu
:users="users"
:loading="loading"
@query-change="handleQueryChange"
@open-change="handleOpenChange"
/>
</template>import { users } from './user-data'
export interface User {
id: number
name: string
}
const connectHandlers: VoidFunction[] = []
let networkStatus: 'fast' | 'slow' | 'offline' = 'slow'
/**
* A utility function to simulate different network states. Useful for testing.
*
* @internal
*/
export function simulateNetworkStatus(status: 'fast' | 'slow' | 'offline') {
networkStatus = status
if (status !== 'offline') {
connectHandlers.forEach((handler) => handler())
connectHandlers.length = 0
}
}
/**
* Simulate a user searching with some delay.
*/
export async function queryUsers(query: string): Promise<User[]> {
if (networkStatus === 'offline') {
await new Promise<void>((resolve) => connectHandlers.push(resolve))
}
if (networkStatus === 'slow') {
await new Promise<void>((resolve) => setTimeout(resolve, 300))
}
const normalizedQuery = query.toLowerCase().trim()
const filteredUsers = users
.filter((user) => user.name.toLowerCase().includes(normalizedQuery))
.slice(0, 10)
return filteredUsers
}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' },
]export { default as UserMenu } from './user-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import {
canUseRegexLookbehind,
type Union,
} from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/vue'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/vue/autocomplete'
const props = defineProps<{
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}>()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleUserInsert(id: number, username: string) {
editor.value.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.value.commands.insertText({ text: ' ' })
}
// Match inputs like "@", "@foo", "@foo bar" etc. Do not match "@ foo".
const regex = canUseRegexLookbehind() ? /(?<!\S)@(\S.*)?$/u : /@(\S.*)?$/u
</script>
<template>
<AutocompletePopover
:regex="regex"
class="relative block max-h-100 min-w-60 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="props.onQueryChange"
@open-change="props.onOpenChange"
>
<AutocompleteList>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{{ props.loading ? 'Loading...' : 'No results' }}
</AutocompleteEmpty>
<AutocompleteItem
v-for="user in props.users"
:key="user.id"
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="() => handleUserInsert(user.id, user.name)"
>
<span v-if="props.loading" class="opacity-50">
{{ user.name }}
</span>
<span v-else>
{{ user.name }}
</span>
</AutocompleteItem>
</AutocompleteList>
</AutocompletePopover>
</template>