Example: full
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 BlockHandle from './block-handle' import { defineExtension } from './extension' import InlineMenu from './inline-menu' import SlashMenu from './slash-menu' import TagMenu from './tag-menu' import Toolbar from './toolbar' import UserMenu from './user-menu' 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'> <Toolbar /> <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> <InlineMenu /> <SlashMenu /> <UserMenu /> <TagMenu /> <BlockHandle /> </div> </div> </ProseKit> ) }
import { defineBasicExtension } from 'prosekit/basic' import { union } from 'prosekit/core' import { defineCodeBlock, defineCodeBlockShiki, } from 'prosekit/extensions/code-block' import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule' import { defineMention } from 'prosekit/extensions/mention' import { definePlaceholder } from 'prosekit/extensions/placeholder' import { defineReactNodeView, type ReactNodeViewComponent, } from 'prosekit/react' import CodeBlockView from './code-block-view' import ImageView from './image-view' import { defineImageFileHandlers } from './upload-file' export function defineExtension() { return union( defineBasicExtension(), definePlaceholder({ placeholder: 'Press / for commands...' }), defineMention(), defineCodeBlock(), defineCodeBlockShiki(), defineHorizontalRule(), defineReactNodeView({ name: 'codeBlock', contentAs: 'code', component: CodeBlockView satisfies ReactNodeViewComponent, }), defineReactNodeView({ name: 'image', component: ImageView satisfies ReactNodeViewComponent, }), defineImageFileHandlers(), ) } export type EditorExtension = ReturnType<typeof defineExtension>
import { useEditor } from 'prosekit/react' import Button from './button' import type { EditorExtension } from './extension' import { ImageUploadPopover } from './image-upload-popover' export default function Toolbar() { const editor = useEditor<EditorExtension>({ update: true }) return ( <div className='z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center'> <Button pressed={false} disabled={!editor.commands.undo.canExec()} onClick={editor.commands.undo} tooltip="Undo" > <div className='i-lucide-undo-2 h-5 w-5' /> </Button> <Button pressed={false} disabled={!editor.commands.redo.canExec()} onClick={editor.commands.redo} tooltip="Redo" > <div className='i-lucide-redo-2 h-5 w-5' /> </Button> <Button pressed={editor.marks.bold.isActive()} disabled={!editor.commands.toggleBold.canExec()} onClick={editor.commands.toggleBold} tooltip="Bold" > <div className='i-lucide-bold h-5 w-5' /> </Button> <Button pressed={editor.marks.italic.isActive()} disabled={!editor.commands.toggleItalic.canExec()} onClick={editor.commands.toggleItalic} tooltip="Italic" > <div className='i-lucide-italic h-5 w-5' /> </Button> <Button pressed={editor.marks.underline.isActive()} disabled={!editor.commands.toggleUnderline.canExec()} onClick={editor.commands.toggleUnderline} tooltip="Underline" > <div className='i-lucide-underline h-5 w-5' /> </Button> <Button pressed={editor.marks.strike.isActive()} disabled={!editor.commands.toggleStrike.canExec()} onClick={editor.commands.toggleStrike} tooltip="Strike" > <div className='i-lucide-strikethrough h-5 w-5' /> </Button> <Button pressed={editor.marks.code.isActive()} disabled={!editor.commands.toggleCode.canExec()} onClick={editor.commands.toggleCode} tooltip="Code" > <div className='i-lucide-code h-5 w-5' /> </Button> <Button pressed={editor.nodes.codeBlock.isActive()} disabled={!editor.commands.insertCodeBlock.canExec({ language: 'javascript' })} onClick={() => editor.commands.insertCodeBlock({ language: 'javascript' })} tooltip="Code Block" > <div className='i-lucide-square-code h-5 w-5' /> </Button> <Button pressed={editor.nodes.heading.isActive({ level: 1 })} disabled={!editor.commands.toggleHeading.canExec({ level: 1 })} onClick={() => editor.commands.toggleHeading({ level: 1 })} tooltip="Heading 1" > <div className='i-lucide-heading-1 h-5 w-5' /> </Button> <Button pressed={editor.nodes.heading.isActive({ level: 2 })} disabled={!editor.commands.toggleHeading.canExec({ level: 2 })} onClick={() => editor.commands.toggleHeading({ level: 2 })} tooltip="Heading 2" > <div className='i-lucide-heading-2 h-5 w-5' /> </Button> <Button pressed={editor.nodes.heading.isActive({ level: 3 })} disabled={!editor.commands.toggleHeading.canExec({ level: 3 })} onClick={() => editor.commands.toggleHeading({ level: 3 })} tooltip="Heading 3" > <div className='i-lucide-heading-3 h-5 w-5' /> </Button> <Button pressed={editor.nodes.horizontalRule.isActive()} disabled={!editor.commands.insertHorizontalRule.canExec()} onClick={() => editor.commands.insertHorizontalRule()} tooltip="Divider" > <div className='i-lucide-minus h-5 w-5'></div> </Button> <Button pressed={editor.nodes.list.isActive({ kind: 'bullet' })} disabled={!editor.commands.toggleList.canExec({ kind: 'bullet' })} onClick={() => editor.commands.toggleList({ kind: 'bullet' })} tooltip="Bullet List" > <div className='i-lucide-list h-5 w-5' /> </Button> <Button pressed={editor.nodes.list.isActive({ kind: 'ordered' })} disabled={!editor.commands.toggleList.canExec({ kind: 'ordered' })} onClick={() => editor.commands.toggleList({ kind: 'ordered' })} tooltip="Ordered List" > <div className='i-lucide-list-ordered h-5 w-5' /> </Button> <Button pressed={editor.nodes.list.isActive({ kind: 'task' })} disabled={!editor.commands.toggleList.canExec({ kind: 'task' })} onClick={() => editor.commands.toggleList({ kind: 'task' })} tooltip="Task List" > <div className='i-lucide-list-checks h-5 w-5' /> </Button> <Button pressed={editor.nodes.list.isActive({ kind: 'toggle' })} disabled={!editor.commands.toggleList.canExec({ kind: 'toggle' })} onClick={() => editor.commands.toggleList({ kind: 'toggle' })} tooltip="Toggle List" > <div className='i-lucide-list-collapse h-5 w-5' /> </Button> <Button pressed={false} disabled={!editor.commands.indentList.canExec()} tooltip="Increase indentation" onClick={() => editor.commands.indentList()} > <div className='i-lucide-indent-increase h-5 w-5' /> </Button> <Button pressed={false} disabled={!editor.commands.dedentList.canExec()} tooltip="Decrease indentation" onClick={() => editor.commands.dedentList()} > <div className='i-lucide-indent-decrease h-5 w-5' /> </Button> <ImageUploadPopover disabled={!editor.commands.insertImage.canExec()} tooltip="Insert Image" > <div className='i-lucide-image h-5 w-5' /> </ImageUploadPopover> </div> ) }
import { BlockHandleAdd, BlockHandleDraggable, BlockHandlePopover, } from 'prosekit/react/block-handle' export default function BlockHandle() { return ( <BlockHandlePopover className='flex items-center flex-row box-border justify-center transition border-0 [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200'> <BlockHandleAdd className='flex items-center box-border justify-center h-[1.5em] w-[1.5em] hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500/50 dark:text-gray-500/50 cursor-pointer'> <div className='i-lucide-plus h-5 w-5' /> </BlockHandleAdd> <BlockHandleDraggable className='flex items-center box-border justify-center h-[1.5em] w-[1.2em] hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500/50 dark:text-gray-500/50 cursor-grab'> <div className='i-lucide-grip-vertical h-5 w-5' /> </BlockHandleDraggable> </BlockHandlePopover> ) }
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block' import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block' import type { ReactNodeViewProps } from 'prosekit/react' export default function CodeBlockView(props: ReactNodeViewProps) { const attrs = props.node.attrs as CodeBlockAttrs const language = attrs.language const setLanguage = (language: string) => { const attrs: CodeBlockAttrs = { language } props.setAttrs(attrs) } return ( <> <div className='relative mx-2 top-3 h-0 select-none overflow-visible text-xs' contentEditable={false}> <select className='outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded border-none bg-transparent px-2 py-1 text-xs transition text-[var(--prosemirror-highlight)] opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 [div[data-node-view-root]:hover_&]:hover:opacity-80' onChange={(event) => setLanguage(event.target.value)} value={language || ''} > <option value="">Plain Text</option> {shikiBundledLanguagesInfo.map((info) => ( <option key={info.id} value={info.id}> {info.name} </option> ))} </select> </div> <pre ref={props.contentRef} data-language={language}></pre> </> ) }
import { useEditor } from 'prosekit/react' import { PopoverContent, PopoverRoot, PopoverTrigger, } from 'prosekit/react/popover' import { useState, type FC, type ReactNode, } from 'react' import Button from './button' import type { EditorExtension } from './extension' export const ImageUploadPopover: FC<{ tooltip: string disabled: boolean children: ReactNode }> = ({ tooltip, disabled, children }) => { const [open, setOpen] = useState(false) const [webUrl, setWebUrl] = useState('') const [objectUrl, setObjectUrl] = useState('') const url = webUrl || objectUrl const editor = useEditor<EditorExtension>() const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = ( event, ) => { const file = event.target.files?.[0] if (file) { setObjectUrl(URL.createObjectURL(file)) setWebUrl('') } else { setObjectUrl('') } } const handleWebUrlChange: React.ChangeEventHandler<HTMLInputElement> = ( event, ) => { const url = event.target.value if (url) { setWebUrl(url) setObjectUrl('') } else { setWebUrl('') } } const deferResetState = () => { setTimeout(() => { setWebUrl('') setObjectUrl('') }, 300) } const handleSubmit = () => { editor.commands.insertImage({ src: url }) deferResetState() setOpen(false) } const handleOpenChange = (open: boolean) => { if (!open) { deferResetState() } setOpen(open) } return ( <PopoverRoot open={open} onOpenChange={handleOpenChange}> <PopoverTrigger> <Button pressed={open} disabled={disabled} tooltip={tooltip}> {children} </Button> </PopoverTrigger> <PopoverContent className='flex flex-col gap-y-4 p-6 text-sm w-sm 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 will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'> {objectUrl ? null : ( <> <label>Embed Link</label> <input 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' placeholder="Paste the image link..." type="url" value={webUrl} onChange={handleWebUrlChange} /> </> )} {webUrl ? null : ( <> <label>Upload</label> <input 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' accept="image/*" type="file" onChange={handleFileChange} /> </> )} {url ? ( <button 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-none 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-10 px-4 py-2 w-full' onClick={handleSubmit}> Insert Image </button> ) : null} </PopoverContent> </PopoverRoot> ) }
import { UploadTask } from 'prosekit/extensions/file' import type { ImageAttrs } from 'prosekit/extensions/image' import type { ReactNodeViewProps } from 'prosekit/react' import { ResizableHandle, ResizableRoot, } from 'prosekit/react/resizable' import { useEffect, useState, type SyntheticEvent, } from 'react' export default function ImageView(props: ReactNodeViewProps) { const { setAttrs, node } = props const attrs = node.attrs as ImageAttrs const url = attrs.src || '' const uploading = url.startsWith('blob:') const [aspectRatio, setAspectRatio] = useState<number | undefined>() const [error, setError] = useState<string | undefined>() const [progress, setProgress] = useState(0) useEffect(() => { if (!url.startsWith('blob:')) { return } const uploadTask = UploadTask.get<string>(url) if (!uploadTask) { return } const abortController = new AbortController() void uploadTask.finished .then((resultUrl) => { if (resultUrl && typeof resultUrl === 'string') { if (abortController.signal.aborted) { return } setAttrs({ src: resultUrl }) } else { if (abortController.signal.aborted) { return } setError('Unexpected upload result') } UploadTask.delete(uploadTask.objectURL) }) .catch((error) => { if (abortController.signal.aborted) { return } setError(String(error)) UploadTask.delete(uploadTask.objectURL) }) const unsubscribe = uploadTask.subscribeProgress(({ loaded, total }) => { if (abortController.signal.aborted) { return } if (total > 0) { setProgress(loaded / total) } }) return () => { unsubscribe() abortController.abort() } }, [url, setAttrs]) const handleImageLoad = (event: SyntheticEvent) => { const img = event.target as HTMLImageElement const { naturalWidth, naturalHeight } = img const ratio = naturalWidth / naturalHeight if (ratio && Number.isFinite(ratio)) { setAspectRatio(ratio) } if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) { setAttrs({ width: naturalWidth, height: naturalHeight }) } } return ( <ResizableRoot width={attrs.width ?? undefined} height={attrs.height ?? undefined} aspectRatio={aspectRatio} onResizeEnd={(event) => setAttrs(event.detail)} data-selected={props.selected ? '' : undefined} className='relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-[600px] max-w-full min-h-[64px] min-w-[64px] outline-2 outline-transparent data-[selected]:outline-blue-500 outline-solid' > {url && !error && ( <img src={url} onLoad={handleImageLoad} className='h-full w-full max-w-full max-h-full object-contain' /> )} {uploading && !error && ( <div className='absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded bg-gray-800/60 p-1.5 text-xs text-white/80 transition'> <div className='i-lucide-loader-circle h-4 w-4 animate-spin'></div> <div>{Math.round(progress * 100)}%</div> </div> )} {error && ( <div className='absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container'> <div className='i-lucide-image-off h-8 w-8'></div> <div className='hidden opacity-80 @xs:block'> Failed to upload image </div> </div> )} <ResizableHandle className='absolute bottom-0 right-0 rounded m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-[[data-resizing]]:opacity-100' position="bottom-right" > <div className='i-lucide-arrow-down-right h-4 w-4'></div> </ResizableHandle> </ResizableRoot> ) }
import type { LinkAttrs } from 'prosekit/extensions/link' import type { EditorState } from 'prosekit/pm/state' import { useEditor } from 'prosekit/react' import { InlinePopover } from 'prosekit/react/inline-popover' import { useState } from 'react' import Button from './button' import type { EditorExtension } from './extension' export default function InlineMenu() { const editor = useEditor<EditorExtension>({ update: true }) const [linkMenuOpen, setLinkMenuOpen] = useState(false) const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open) const 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 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-[8rem] space-x-1 overflow-auto whitespace-nowrap rounded-md p-1' onOpenChange={(open) => { if (!open) { setLinkMenuOpen(false) } }} > <Button pressed={editor.marks.bold.isActive()} disabled={!editor.commands.toggleBold.canExec()} onClick={() => editor.commands.toggleBold()} tooltip="Bold" > <div className='i-lucide-bold h-5 w-5'></div> </Button> <Button pressed={editor.marks.italic.isActive()} disabled={!editor.commands.toggleItalic.canExec()} onClick={() => editor.commands.toggleItalic()} tooltip="Italic" > <div className='i-lucide-italic h-5 w-5'></div> </Button> <Button pressed={editor.marks.underline.isActive()} disabled={!editor.commands.toggleUnderline.canExec()} onClick={() => editor.commands.toggleUnderline()} tooltip="Underline" > <div className='i-lucide-underline h-5 w-5'></div> </Button> <Button pressed={editor.marks.strike.isActive()} disabled={!editor.commands.toggleStrike.canExec()} onClick={() => editor.commands.toggleStrike()} tooltip="Strikethrough" > <div className='i-lucide-strikethrough h-5 w-5'></div> </Button> <Button pressed={editor.marks.code.isActive()} disabled={!editor.commands.toggleCode.canExec()} onClick={() => editor.commands.toggleCode()} tooltip="Code" > <div className='i-lucide-code h-5 w-5'></div> </Button> {editor.commands.addLink.canExec({ href: '' }) && ( <Button pressed={editor.marks.link.isActive()} onClick={() => { editor.commands.expandLink() toggleLinkMenuOpen() }} tooltip="Link" > <div className='i-lucide-link h-5 w-5'></div> </Button> )} </InlinePopover> <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={getCurrentLink(editor.state)} 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' > </input> </form> )} {editor.marks.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-none 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 { AutocompleteEmpty } from 'prosekit/react/autocomplete' export default function SlashMenuEmpty() { return ( <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'> <span>No results</span> </AutocompleteEmpty> ) }
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-[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'> <span>{props.label}</span> {props.kbd && <kbd className='text-xs font-mono text-gray-400 dark:text-gray-500'>{props.kbd}</kbd>} </AutocompleteItem> ) }
import { useEditor } from 'prosekit/react' import { AutocompleteList, AutocompletePopover, } from 'prosekit/react/autocomplete' import type { EditorExtension } from './extension' import SlashMenuEmpty from './slash-menu-empty' import SlashMenuItem from './slash-menu-item' export default function SlashMenu() { const editor = useEditor<EditorExtension>() // Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading". const regex = /\/(|\S.*)$/iu return ( <AutocompletePopover regex={regex} 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> <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 /> </AutocompleteList> </AutocompletePopover> ) }
export const tags = [ { id: 1, label: 'book' }, { id: 2, label: 'movie' }, { id: 3, label: 'trip' }, { id: 4, label: 'music' }, { id: 5, label: 'art' }, { id: 6, label: 'food' }, { id: 7, label: 'sport' }, { id: 8, label: 'technology' }, { id: 9, label: 'fashion' }, { id: 10, label: 'nature' }, ]
import { useEditor } from 'prosekit/react' import { AutocompleteEmpty, AutocompleteItem, AutocompleteList, AutocompletePopover, } from 'prosekit/react/autocomplete' import type { EditorExtension } from './extension' import { tags } from './tag-data' export default function TagMenu() { const editor = useEditor<EditorExtension>() const handleTagInsert = (id: number, label: string) => { editor.commands.insertMention({ id: id.toString(), value: '#' + label, kind: 'tag', }) editor.commands.insertText({ text: ' ' }) } return ( <AutocompletePopover regex={/#[\da-z]*$/i} 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> <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'> No results </AutocompleteEmpty> {tags.map((tag) => ( <AutocompleteItem key={tag.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={() => handleTagInsert(tag.id, tag.label)} > #{tag.label} </AutocompleteItem> ))} </AutocompleteList> </AutocompletePopover> ) }
import { insertNode, union, } from 'prosekit/core' import { defineFileDropHandler, defineFilePasteHandler, UploadTask, type Uploader, } from 'prosekit/extensions/file' /** * Returns an extension that handles image file uploads when pasting or dropping * images into the editor. */ export function defineImageFileHandlers() { return union( defineFilePasteHandler(({ view, file }) => { // Only handle image files if (!file.type.startsWith('image/')) { return false } // Upload the image to https://tmpfiles.org/ const uploadTask = new UploadTask({ file, uploader: tmpfilesUploader, }) // Insert the image node at the current text selection position const command = insertNode({ type: 'image', attrs: { src: uploadTask.objectURL }, }) return command(view.state, view.dispatch, view) }), defineFileDropHandler(({ view, file, pos }) => { // Only handle image files if (!file.type.startsWith('image/')) { return false } // Upload the image to https://tmpfiles.org/ const uploadTask = new UploadTask({ file, uploader: tmpfilesUploader, }) // Insert the image node at the drop position const command = insertNode({ type: 'image', attrs: { src: uploadTask.objectURL }, pos, }) return command(view.state, view.dispatch, view) }), ) } /** * Uploads the given file to https://tmpfiles.org/ and returns the URL of the * uploaded file. * * This function is only for demonstration purposes. All uploaded files will be * deleted after 1 hour. */ const tmpfilesUploader: Uploader<string> = ({ file, onProgress, }): Promise<string> => { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() const formData = new FormData() formData.append('file', file) xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable && onProgress) { onProgress({ loaded: event.loaded, total: event.total, }) } }) xhr.addEventListener('load', () => { if (xhr.status === 200) { try { const json = JSON.parse(xhr.responseText) const url: string = (json.data.url as string).replace( 'tmpfiles.org/', 'tmpfiles.org/dl/', ) // Simulate a larger delay setTimeout(() => resolve(url), 1000) } catch (error) { reject(new Error('Failed to parse response', { cause: error })) } } else { reject(new Error(`Upload failed with status ${xhr.status}`)) } }) xhr.addEventListener('error', () => { reject(new Error('Upload failed')) }) xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true) xhr.send(formData) }) }
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 type { EditorExtension } from './extension' import { users } from './user-data' export default function UserMenu() { const editor = useEditor<EditorExtension>() const handleUserInsert = (id: number, username: string) => { editor.commands.insertMention({ id: id.toString(), value: '@' + username, kind: 'user', }) editor.commands.insertText({ text: ' ' }) } return ( <AutocompletePopover regex={/@\w*$/} 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> <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'> 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)} > {user.name} </AutocompleteItem> ))} </AutocompleteList> </AutocompletePopover> ) }
import { TooltipContent, TooltipRoot, TooltipTrigger, } from 'prosekit/react/tooltip' import type { ReactNode } from 'react' export default function Button({ pressed, disabled, onClick, tooltip, children, }: { pressed?: boolean disabled?: boolean onClick?: VoidFunction tooltip?: string children: ReactNode }) { return ( <TooltipRoot> <TooltipTrigger className='block'> <button data-state={pressed ? 'on' : 'off'} disabled={disabled} onClick={() => onClick?.()} onMouseDown={(event) => 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 disabled:opacity-50 hover:disabled:opacity-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' > {children} {tooltip ? <span className="sr-only">{tooltip}</span> : null} </button> </TooltipTrigger> {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-sm [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'> {tooltip} </TooltipContent> ) : null} </TooltipRoot> ) }
<script lang="ts"> import 'prosekit/basic/style.css' import 'prosekit/basic/typography.css' import { createEditor } from 'prosekit/core' import { ProseKit } from 'prosekit/svelte' import BlockHandle from './block-handle.svelte' import { defineExtension } from './extension' import InlineMenu from './inline-menu.svelte' import SlashMenu from './slash-menu.svelte' import TagMenu from './tag-menu.svelte' import Toolbar from './toolbar.svelte' import UserMenu from './user-menu.svelte' const editor = createEditor({ extension: defineExtension() }) 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 flex flex-col bg-white dark:bg-gray-950 color-black dark:color-white'> <Toolbar /> <div class='relative w-full flex-1 box-border overflow-y-scroll'> <div use:mount 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'></div> <InlineMenu /> <SlashMenu /> <UserMenu /> <TagMenu /> <BlockHandle /> </div> </div> </ProseKit>
import { defineBasicExtension } from 'prosekit/basic' import { union } from 'prosekit/core' import { defineCodeBlock, defineCodeBlockShiki, } from 'prosekit/extensions/code-block' import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule' import { defineMention } from 'prosekit/extensions/mention' import { definePlaceholder } from 'prosekit/extensions/placeholder' import { defineSvelteNodeView, type SvelteNodeViewComponent, } from 'prosekit/svelte' import CodeBlockView from './code-block-view.svelte' export function defineExtension() { return union( defineBasicExtension(), definePlaceholder({ placeholder: 'Press / for commands...' }), defineMention(), defineCodeBlock(), defineCodeBlockShiki(), defineSvelteNodeView({ name: 'codeBlock', contentAs: 'code', component: CodeBlockView as unknown as SvelteNodeViewComponent, }), defineHorizontalRule(), ) } export type EditorExtension = ReturnType<typeof defineExtension>
<script lang="ts"> import { useEditor } from 'prosekit/svelte' import Button from './button.svelte' import type { EditorExtension } from './extension' import ImageUploadPopover from './image-upload-popover.svelte' const editor = useEditor<EditorExtension>({ update: true }) </script> <div class='z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center'> <Button pressed={false} disabled={!$editor.commands.undo.canExec()} onClick={() => $editor.commands.undo()} tooltip="Undo" > <div class='i-lucide-undo-2 h-5 w-5'></div> </Button> <Button pressed={false} disabled={!$editor.commands.redo.canExec()} onClick={() => $editor.commands.redo()} tooltip="Redo" > <div class='i-lucide-redo-2 h-5 w-5'></div> </Button> <Button pressed={$editor.marks.bold.isActive()} disabled={!$editor.commands.toggleBold.canExec()} onClick={() => $editor.commands.toggleBold()} tooltip="Bold" > <div class='i-lucide-bold h-5 w-5'></div> </Button> <Button pressed={$editor.marks.italic.isActive()} disabled={!$editor.commands.toggleItalic.canExec()} onClick={() => $editor.commands.toggleItalic()} tooltip="Italic" > <div class='i-lucide-italic h-5 w-5'></div> </Button> <Button pressed={$editor.marks.underline.isActive()} disabled={!$editor.commands.toggleUnderline.canExec()} onClick={() => $editor.commands.toggleUnderline()} tooltip="Underline" > <div class='i-lucide-underline h-5 w-5'></div> </Button> <Button pressed={$editor.marks.strike.isActive()} disabled={!$editor.commands.toggleStrike.canExec()} onClick={() => $editor.commands.toggleStrike()} tooltip="Strike" > <div class='i-lucide-strikethrough h-5 w-5'></div> </Button> <Button pressed={$editor.marks.code.isActive()} disabled={!$editor.commands.toggleCode.canExec()} onClick={() => $editor.commands.toggleCode()} tooltip="Code" > <div class='i-lucide-code h-5 w-5'></div> </Button> <Button pressed={$editor.nodes.heading.isActive({ level: 1 })} disabled={!$editor.commands.toggleHeading.canExec({ level: 1 })} onClick={() => $editor.commands.toggleHeading({ level: 1 })} tooltip="Heading 1" > <div class='i-lucide-heading-1 h-5 w-5'></div> </Button> <Button pressed={$editor.nodes.heading.isActive({ level: 2 })} disabled={!$editor.commands.toggleHeading.canExec({ level: 2 })} onClick={() => $editor.commands.toggleHeading({ level: 2 })} tooltip="Heading 2" > <div class='i-lucide-heading-2 h-5 w-5'></div> </Button> <Button pressed={$editor.nodes.heading.isActive({ level: 3 })} disabled={!$editor.commands.toggleHeading.canExec({ level: 3 })} onClick={() => $editor.commands.toggleHeading({ level: 3 })} tooltip="Heading 3" > <div class='i-lucide-heading-3 h-5 w-5'></div> </Button> <Button pressed={$editor.nodes.horizontalRule.isActive()} disabled={!$editor.commands.insertHorizontalRule.canExec()} onClick={() => $editor.commands.insertHorizontalRule()} tooltip="Divider" > <div class='i-lucide-minus h-5 w-5'></div> </Button> <Button pressed={$editor.nodes.list.isActive({ kind: 'bullet' })} disabled={!$editor.commands.toggleList.canExec({ kind: 'bullet' })} onClick={() => $editor.commands.toggleList({ kind: 'bullet' })} tooltip="Bullet List" > <div class='i-lucide-list h-5 w-5'></div> </Button> <Button pressed={$editor.nodes.list.isActive({ kind: 'ordered' })} disabled={!$editor.commands.toggleList.canExec({ kind: 'ordered' })} onClick={() => $editor.commands.toggleList({ kind: 'ordered' })} tooltip="Ordered List" > <div class='i-lucide-list-ordered h-5 w-5'></div> </Button> <Button pressed={$editor.nodes.list.isActive({ kind: 'task' })} disabled={!$editor.commands.toggleList.canExec({ kind: 'task' })} onClick={() => $editor.commands.toggleList({ kind: 'task' })} tooltip="Task List" > <div class='i-lucide-list-checks h-5 w-5'></div> </Button> <Button pressed={$editor.nodes.list.isActive({ kind: 'toggle' })} disabled={!$editor.commands.toggleList.canExec({ kind: 'toggle' })} onClick={() => $editor.commands.toggleList({ kind: 'toggle' })} tooltip="Toggle List" > <div class='i-lucide-list-collapse h-5 w-5'></div> </Button> <Button pressed={false} disabled={!$editor.commands.indentList.canExec()} onClick={() => $editor.commands.indentList()} tooltip="Increase indentation" > <div class='i-lucide-indent-increase h-5 w-5'></div> </Button> <Button pressed={false} disabled={!$editor.commands.dedentList.canExec()} onClick={() => $editor.commands.dedentList()} tooltip="Decrease indentation" > <div class='i-lucide-indent-decrease h-5 w-5'></div> </Button> <ImageUploadPopover disabled={!$editor.commands.insertImage.canExec()} tooltip="Insert Image" > <div class='i-lucide-image h-5 w-5'></div> </ImageUploadPopover> </div>
<script lang="ts"> import { BlockHandleAdd, BlockHandleDraggable, BlockHandlePopover, } from 'prosekit/svelte/block-handle' </script> <BlockHandlePopover class='flex items-center flex-row box-border justify-center transition border-0 [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200'> <BlockHandleAdd class='flex items-center box-border justify-center h-[1.5em] w-[1.5em] hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500/50 dark:text-gray-500/50 cursor-pointer'> <div class='i-lucide-plus h-5 w-5'></div> </BlockHandleAdd> <BlockHandleDraggable class='flex items-center box-border justify-center h-[1.5em] w-[1.2em] hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500/50 dark:text-gray-500/50 cursor-grab'> <div class='i-lucide-grip-vertical h-5 w-5'></div> </BlockHandleDraggable> </BlockHandlePopover>
<script lang="ts"> import type { CodeBlockAttrs } from 'prosekit/extensions/code-block' import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block' import type { SvelteNodeViewProps } from 'prosekit/svelte' const { node, setAttrs, contentRef }: SvelteNodeViewProps = $props() const attrs = $node.attrs as CodeBlockAttrs const language = attrs.language const setLanguage = (language: string) => { const attrs: CodeBlockAttrs = { language } setAttrs(attrs) } const handleLanguageChange = (event: Event) => { setLanguage((event.target as HTMLSelectElement).value) } </script> <div class='relative mx-2 top-3 h-0 select-none overflow-visible text-xs' contentEditable={false}> <select class='outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded border-none bg-transparent px-2 py-1 text-xs transition text-[var(--prosemirror-highlight)] opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 [div[data-node-view-root]:hover_&]:hover:opacity-80' onchange={handleLanguageChange} value={language || ''} > <option value="">Plain Text</option> {#each shikiBundledLanguagesInfo as info (info.id)} <option value={info.id}>{info.name}</option> {/each} </select> </div> <pre use:contentRef data-language={language}></pre>
<script lang="ts"> import { useEditor } from 'prosekit/svelte' import { PopoverContent, PopoverRoot, PopoverTrigger, } from 'prosekit/svelte/popover' import Button from './button.svelte' import type { EditorExtension } from './extension' export let disabled: boolean export let tooltip: string let open = false let webUrl = '' let objectUrl = '' $: url = webUrl || objectUrl const editor = useEditor<EditorExtension>() const handleFileChange = (event: Event) => { const file = (event.target as HTMLInputElement)?.files?.[0] if (file) { objectUrl = URL.createObjectURL(file) webUrl = '' } else { objectUrl = '' } } const handleWebUrlChange = (event: Event) => { const url = (event.target as HTMLInputElement)?.value if (url) { webUrl = url objectUrl = '' } else { webUrl = '' } } const deferResetState = () => { setTimeout(() => { webUrl = '' objectUrl = '' }, 300) } const handleSubmit = () => { $editor.commands.insertImage({ src: url }) deferResetState() open = false } const handleOpenChange = (openValue: boolean) => { if (!openValue) { deferResetState() } open = openValue } </script> <PopoverRoot {open} on:OpenChange={handleOpenChange}> <PopoverTrigger> <Button pressed={open} {disabled} {tooltip}> <slot /> </Button> </PopoverTrigger> <PopoverContent class='flex flex-col gap-y-4 p-6 text-sm w-sm 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 will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'> {#if !objectUrl} <label for="embed-link-input">Embed Link</label> <input 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' placeholder="Paste the image link..." type="url" value={webUrl} on:input={handleWebUrlChange} id="embed-link-input" /> {/if} {#if !webUrl} <label for="upload-input">Upload</label> <input 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' accept="image/*" type="file" on:input={handleFileChange} /> {/if} {#if url} <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-none 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-10 px-4 py-2 w-full' on:click={handleSubmit}> Insert Image </button> {/if} </PopoverContent> </PopoverRoot>
<script lang="ts"> import type { EditorState } from 'prosekit/pm/state' import { useEditor } from 'prosekit/svelte' import { InlinePopover } from 'prosekit/svelte/inline-popover' import Button from './button.svelte' import type { EditorExtension } from './extension' const editor = useEditor<EditorExtension>({ update: true }) let linkMenuOpen = false const setLinkMenuOpen = (value: boolean) => { linkMenuOpen = value } const toggleLinkMenuOpen = () => { linkMenuOpen = !linkMenuOpen } const 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.href } } } const handleLinkUpdate = (href?: string) => { if (href) { $editor.commands.addLink({ href }) } else { $editor.commands.removeLink() } linkMenuOpen = false $editor.focus() } const handleSubmit = (event: Event) => { const target = event.target as HTMLFormElement | null const href = target?.querySelector('input')?.value?.trim() handleLinkUpdate(href) } </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-[8rem] space-x-1 overflow-auto whitespace-nowrap rounded-md p-1'> <Button pressed={$editor.marks.bold.isActive()} disabled={!$editor.commands.toggleBold.canExec()} tooltip="Bold" onClick={() => $editor.commands.toggleBold()} > <div class='i-lucide-bold h-5 w-5'></div> </Button> <Button pressed={$editor.marks.italic.isActive()} disabled={!$editor.commands.toggleItalic.canExec()} onClick={() => $editor.commands.toggleItalic()} tooltip="Italic" > <div class='i-lucide-italic h-5 w-5'></div> </Button> <Button pressed={$editor.marks.underline.isActive()} disabled={!$editor.commands.toggleUnderline.canExec()} onClick={() => $editor.commands.toggleUnderline()} tooltip="Underline" > <div class='i-lucide-underline h-5 w-5'></div> </Button> <Button pressed={$editor.marks.strike.isActive()} disabled={!$editor.commands.toggleStrike.canExec()} onClick={() => $editor.commands.toggleStrike()} tooltip="Strike" > <div class='i-lucide-strikethrough h-5 w-5'></div> </Button> <Button pressed={$editor.marks.code.isActive()} disabled={!$editor.commands.toggleCode.canExec()} onClick={() => $editor.commands.toggleCode()} tooltip="Code" > <div class='i-lucide-code h-5 w-5'></div> </Button> {#if $editor.commands.addLink.canExec({ href: '' })} <Button pressed={$editor.marks.link.isActive()} onClick={() => { $editor.commands.expandLink() toggleLinkMenuOpen() }} tooltip="Link" > <div class='i-lucide-link h-5 w-5'></div> </Button> {/if} </InlinePopover> <InlinePopover placement="bottom" defaultOpen={false} open={linkMenuOpen} onOpenChange={setLinkMenuOpen} 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' > {#if linkMenuOpen} <form on:submit|preventDefault={handleSubmit}> <input placeholder="Paste the link..." value={getCurrentLink($editor.state) || ''} 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' /> </form> {/if} {#if $editor.marks.link.isActive()} <button on:click={() => handleLinkUpdate()} on:mousedown|preventDefault class='inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-none 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> {/if} </InlinePopover>
<script lang="ts"> import { AutocompleteEmpty } from 'prosekit/svelte/autocomplete' </script> <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'> <span>No results</span> </AutocompleteEmpty>
<script lang="ts"> import { AutocompleteItem } from 'prosekit/svelte/autocomplete' export let onSelect: () => void export let label: string export let kbd: string | undefined = undefined </script> <AutocompleteItem {onSelect} 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'> <span>{label}</span> {#if kbd} <kbd class='text-xs font-mono text-gray-400 dark:text-gray-500'>{kbd}</kbd> {/if} </AutocompleteItem>
<script lang="ts"> import { useEditor } from 'prosekit/svelte' import { AutocompleteList, AutocompletePopover, } from 'prosekit/svelte/autocomplete' import type { EditorExtension } from './extension' import SlashMenuEmpty from './slash-menu-empty.svelte' import SlashMenuItem from './slash-menu-item.svelte' const editor = useEditor<EditorExtension>() // Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading". const regex = /\/(|\S.*)$/iu </script> <AutocompletePopover regex={regex} 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'> <AutocompleteList> <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 /> </AutocompleteList> </AutocompletePopover>
export const tags = [ { id: 1, label: 'book' }, { id: 2, label: 'movie' }, { id: 3, label: 'trip' }, { id: 4, label: 'music' }, { id: 5, label: 'art' }, { id: 6, label: 'food' }, { id: 7, label: 'sport' }, { id: 8, label: 'technology' }, { id: 9, label: 'fashion' }, { id: 10, label: 'nature' }, ]
<script lang="ts"> import { AutocompleteEmpty, AutocompleteItem, AutocompleteList, AutocompletePopover, } from 'prosekit/svelte/autocomplete' import { useEditor } from 'prosekit/svelte' import type { EditorExtension } from './extension' import { tags } from './tag-data' const editor = useEditor<EditorExtension>() const handleTagInsert = (id: number, label: string) => { $editor.commands.insertMention({ id: id.toString(), value: '#' + label, kind: 'tag', }) $editor.commands.insertText({ text: ' ' }) } </script> <AutocompletePopover regex={/#[\da-z]*$/i} 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'> <AutocompleteList> <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'>No results</AutocompleteEmpty> {#each tags as tag} <AutocompleteItem 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' onSelect={() => handleTagInsert(tag.id, tag.label)} > {tag.label} </AutocompleteItem> {/each} </AutocompleteList> </AutocompletePopover>
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 lang="ts"> import { AutocompleteEmpty, AutocompleteItem, AutocompleteList, AutocompletePopover, } from 'prosekit/svelte/autocomplete' import { useEditor } from 'prosekit/svelte' import type { EditorExtension } from './extension' import { users } from './user-data' const editor = useEditor<EditorExtension>() const handleUserInsert = (id: number, username: string) => { $editor.commands.insertMention({ id: id.toString(), value: '@' + username, kind: 'user', }) $editor.commands.insertText({ text: ' ' }) } </script> <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'> <AutocompleteList> <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'>No results</AutocompleteEmpty> {#each users as user} <AutocompleteItem 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' onSelect={() => handleUserInsert(user.id, user.name)} > {user.name} </AutocompleteItem> {/each} </AutocompleteList> </AutocompletePopover>
<script lang="ts"> import { TooltipContent, TooltipRoot, TooltipTrigger, } from 'prosekit/svelte/tooltip' import type { Snippet } from 'svelte' interface Props { pressed?: boolean disabled?: boolean tooltip?: string onClick?: VoidFunction children?: Snippet } let { pressed = false, disabled = false, tooltip = '', onClick = undefined, children, }: Props = $props() </script> <TooltipRoot> <TooltipTrigger class='block'> <button data-state={pressed ? 'on' : 'off'} {disabled} onclick={() => onClick?.()} onmousedown={(event) => event.preventDefault()} class='outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 disabled:opacity-50 hover:disabled:opacity-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' > {@render children?.()} {#if tooltip} <span class="sr-only">{tooltip}</span> {/if} </button> </TooltipTrigger> {#if 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-sm [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'> {tooltip} </TooltipContent> {/if} </TooltipRoot>
<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 BlockHandle from './block-handle.vue' import { defineExtension } from './extension' import InlineMenu from './inline-menu.vue' import SlashMenu from './slash-menu.vue' import TagMenu from './tag-menu.vue' import Toolbar from './toolbar.vue' import UserMenu from './user-menu.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'> <Toolbar /> <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' /> <InlineMenu /> <SlashMenu /> <UserMenu /> <TagMenu /> <BlockHandle /> </div> </div> </ProseKit> </template>
import { defineBasicExtension } from 'prosekit/basic' import { union } from 'prosekit/core' import { defineCodeBlock, defineCodeBlockShiki, } from 'prosekit/extensions/code-block' import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule' import { defineMention } from 'prosekit/extensions/mention' import { definePlaceholder } from 'prosekit/extensions/placeholder' import { defineVueNodeView, type VueNodeViewComponent, } from 'prosekit/vue' import CodeBlockView from './code-block-view.vue' import ImageView from './image-view.vue' import { defineImageFileHandlers } from './upload-file' export function defineExtension() { return union( defineBasicExtension(), definePlaceholder({ placeholder: 'Press / for commands...' }), defineMention(), defineCodeBlock(), defineCodeBlockShiki(), defineHorizontalRule(), defineVueNodeView({ name: 'codeBlock', contentAs: 'code', component: CodeBlockView as VueNodeViewComponent, }), defineVueNodeView({ name: 'image', component: ImageView as VueNodeViewComponent, }), defineImageFileHandlers(), ) } export type EditorExtension = ReturnType<typeof defineExtension>
<script setup lang="ts"> import { useEditor } from 'prosekit/vue' import Button from './button.vue' import type { EditorExtension } from './extension' import ImageUploadPopover from './image-upload-popover.vue' const editor = useEditor<EditorExtension>({ update: true }) </script> <template> <div class='z-2 box-border border-gray-200 dark:border-gray-800 border-solid border-l-0 border-r-0 border-t-0 border-b flex flex-wrap gap-1 p-2 items-center'> <Button :pressed="false" :disabled="!editor.commands.undo.canExec()" tooltip="Undo" @click="() => editor.commands.undo()" > <div class='i-lucide-undo-2 h-5 w-5' /> </Button> <Button :pressed="false" :disabled="!editor.commands.redo.canExec()" tooltip="Redo" @click="() => editor.commands.redo()" > <div class='i-lucide-redo-2 h-5 w-5' /> </Button> <Button :pressed="editor.marks.bold.isActive()" :disabled="!editor.commands.toggleBold.canExec()" tooltip="Bold" @click="() => editor.commands.toggleBold()" > <div class='i-lucide-bold h-5 w-5' /> </Button> <Button :pressed="editor.marks.italic.isActive()" :disabled="!editor.commands.toggleItalic.canExec()" tooltip="Italic" @click="() => editor.commands.toggleItalic()" > <div class='i-lucide-italic h-5 w-5' /> </Button> <Button :pressed="editor.marks.underline.isActive()" :disabled="!editor.commands.toggleUnderline.canExec()" tooltip="Underline" @click="() => editor.commands.toggleUnderline()" > <div class='i-lucide-underline h-5 w-5' /> </Button> <Button :pressed="editor.marks.strike.isActive()" :disabled="!editor.commands.toggleStrike.canExec()" tooltip="Strike" @click="() => editor.commands.toggleStrike()" > <div class='i-lucide-strikethrough h-5 w-5' /> </Button> <Button :pressed="editor.marks.code.isActive()" :disabled="!editor.commands.toggleCode.canExec()" tooltip="Code" @click="() => editor.commands.toggleCode()" > <div class='i-lucide-code h-5 w-5' /> </Button> <Button :pressed="editor.nodes.codeBlock.isActive()" :disabled="!editor.commands.toggleCodeBlock.canExec({ language: 'javascript' })" tooltip="Code Block" @click="() => editor.commands.toggleCodeBlock.canExec({ language: 'javascript' })" > <div class='i-lucide-square-code h-5 w-5' /> </Button> <Button :pressed="editor.nodes.heading.isActive({ level: 1 })" :disabled="!editor.commands.toggleHeading.canExec({ level: 1 })" tooltip="Heading 1" @click="() => editor.commands.toggleHeading({ level: 1 })" > <div class='i-lucide-heading-1 h-5 w-5' /> </Button> <Button :pressed="editor.nodes.heading.isActive({ level: 2 })" :disabled="!editor.commands.toggleHeading.canExec({ level: 2 })" tooltip="Heading 2" @click="() => editor.commands.toggleHeading({ level: 2 })" > <div class='i-lucide-heading-2 h-5 w-5' /> </Button> <Button :pressed="editor.nodes.heading.isActive({ level: 3 })" :disabled="!editor.commands.toggleHeading.canExec({ level: 3 })" tooltip="Heading 3" @click="() => editor.commands.toggleHeading({ level: 3 })" > <div class='i-lucide-heading-3 h-5 w-5' /> </Button> <Button :pressed="editor.nodes.horizontalRule.isActive()" :disabled="!editor.commands.insertHorizontalRule.canExec()" tooltip="Divider" @click="() => editor.commands.insertHorizontalRule()" > <div class='i-lucide-minus h-5 w-5'></div> </Button> <Button :pressed="editor.nodes.list.isActive({ kind: 'bullet' })" :disabled="!editor.commands.toggleList.canExec({ kind: 'bullet' })" tooltip="Bullet List" @click="() => editor.commands.toggleList({ kind: 'bullet' })" > <div class='i-lucide-list h-5 w-5' /> </Button> <Button :pressed="editor.nodes.list.isActive({ kind: 'ordered' })" :disabled="!editor.commands.toggleList.canExec({ kind: 'ordered' })" tooltip="Ordered List" @click="() => editor.commands.toggleList({ kind: 'ordered' })" > <div class='i-lucide-list-ordered h-5 w-5' /> </Button> <Button :pressed="editor.nodes.list.isActive({ kind: 'task' })" :disabled="!editor.commands.toggleList.canExec({ kind: 'task' })" tooltip="Task List" @click="() => editor.commands.toggleList({ kind: 'task' })" > <div class='i-lucide-list-checks h-5 w-5' /> </Button> <Button :pressed="editor.nodes.list.isActive({ kind: 'toggle' })" :disabled="!editor.commands.toggleList.canExec({ kind: 'toggle' })" tooltip="Toggle List" @click="() => editor.commands.toggleList({ kind: 'toggle' })" > <div class='i-lucide-list-collapse h-5 w-5' /> </Button> <Button :pressed="false" :disabled="!editor.commands.indentList.canExec()" tooltip="Increase indentation" @click="() => editor.commands.indentList()" > <div class='i-lucide-indent-increase h-5 w-5' /> </Button> <Button :pressed="false" :disabled="!editor.commands.dedentList.canExec()" tooltip="Decrease indentation" @click="() => editor.commands.dedentList()" > <div class='i-lucide-indent-decrease h-5 w-5' /> </Button> <ImageUploadPopover :disabled="!editor.commands.insertImage.canExec()" tooltip="Insert Image" > <div class='i-lucide-image h-5 w-5' /> </ImageUploadPopover> </div> </template>
<script setup lang="ts"> import { BlockHandleAdd, BlockHandleDraggable, BlockHandlePopover, } from 'prosekit/vue/block-handle' </script> <template> <BlockHandlePopover class='flex items-center flex-row box-border justify-center transition border-0 [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200'> <BlockHandleAdd class='flex items-center box-border justify-center h-[1.5em] w-[1.5em] hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500/50 dark:text-gray-500/50 cursor-pointer'> <div class='i-lucide-plus h-5 w-5' /> </BlockHandleAdd> <BlockHandleDraggable class='flex items-center box-border justify-center h-[1.5em] w-[1.2em] hover:bg-gray-100 dark:hover:bg-gray-800 rounded text-gray-500/50 dark:text-gray-500/50 cursor-grab'> <div class='i-lucide-grip-vertical h-5 w-5' /> </BlockHandleDraggable> </BlockHandlePopover> </template>
<script setup lang="ts"> import type { CodeBlockAttrs } from 'prosekit/extensions/code-block' import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block' import type { VueNodeViewProps } from 'prosekit/vue' import { computed } from 'vue' const props = defineProps<VueNodeViewProps>() const language = computed({ get() { const attrs = props.node.value.attrs as CodeBlockAttrs return attrs.language || '' }, set(language: string) { const attrs: CodeBlockAttrs = { language } props.setAttrs(attrs) }, }) </script> <template> <div class='relative mx-2 top-3 h-0 select-none overflow-visible text-xs' contenteditable="false"> <select v-model="language" class='outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded border-none bg-transparent px-2 py-1 text-xs transition text-[var(--prosemirror-highlight)] opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 [div[data-node-view-root]:hover_&]:hover:opacity-80'> <option value="">Plain Text</option> <option v-for="info of shikiBundledLanguagesInfo" :key="info.id" :value="info.id" > {{ info.name }} </option> </select> </div> <pre :ref="props.contentRef" :data-language="language" /> </template>
<script setup lang="ts"> import { useEditor } from 'prosekit/vue' import { PopoverContent, PopoverRoot, PopoverTrigger, } from 'prosekit/vue/popover' import { computed, ref, } from 'vue' import Button from './button.vue' import type { EditorExtension } from './extension' const props = defineProps<{ disabled: Boolean tooltip: string }>() const open = ref(false) const webUrl = ref('') const objectUrl = ref('') const url = computed(() => webUrl.value || objectUrl.value) const editor = useEditor<EditorExtension>() function handleFileChange(event: Event) { const file = (event.target as HTMLInputElement)?.files?.[0] if (file) { objectUrl.value = URL.createObjectURL(file) webUrl.value = '' } else { objectUrl.value = '' } } function handleWebUrlChange(event: Event) { const url = (event.target as HTMLInputElement)?.value if (url) { webUrl.value = url objectUrl.value = '' } else { webUrl.value = '' } } function deferResetState() { setTimeout(() => { webUrl.value = '' objectUrl.value = '' }, 300) } function handleSubmit() { editor.value.commands.insertImage({ src: url.value }) deferResetState() open.value = false } function handleOpenChange(openValue: boolean) { if (!openValue) { deferResetState() } open.value = openValue } </script> <template> <PopoverRoot :open="open" @open-change="handleOpenChange"> <PopoverTrigger> <Button :pressed="open" :disabled="props.disabled" :tooltip="props.tooltip" > <slot /> </Button> </PopoverTrigger> <PopoverContent class='flex flex-col gap-y-4 p-6 text-sm w-sm 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 will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'> <template v-if="!objectUrl"> <label>Embed Link</label> <input 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' placeholder="Paste the image link..." type="url" :value="webUrl" @input="handleWebUrlChange" /> </template> <template v-if="!webUrl"> <label>Upload</label> <input 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' accept="image/*" type="file" @input="handleFileChange" /> </template> <button v-if="url" 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-none 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-10 px-4 py-2 w-full' @click="handleSubmit" > Insert Image </button> </PopoverContent> </PopoverRoot> </template>
<script setup lang="ts"> import { UploadTask } from 'prosekit/extensions/file' import type { ImageAttrs } from 'prosekit/extensions/image' import type { VueNodeViewProps } from 'prosekit/vue' import { ResizableHandle, ResizableRoot, } from 'prosekit/vue/resizable' import { computed, ref, watchEffect, } from 'vue' const props = defineProps<VueNodeViewProps>() const { setAttrs, node } = props const attrs = computed(() => node.value.attrs as ImageAttrs) const url = computed(() => attrs.value.src || '') const uploading = computed(() => url.value.startsWith('blob:')) const aspectRatio = ref<number | undefined>() const error = ref<string | undefined>() const progress = ref(0) watchEffect((onCleanup) => { if (!url.value.startsWith('blob:')) { return } const uploadTask = UploadTask.get<string>(url.value) if (!uploadTask) { return } const abortController = new AbortController() void uploadTask.finished .then((resultUrl) => { if (resultUrl && typeof resultUrl === 'string') { if (abortController.signal.aborted) { return } setAttrs({ src: resultUrl }) } else { if (abortController.signal.aborted) { return } error.value = 'Unexpected upload result' } UploadTask.delete(uploadTask.objectURL) }) .catch((error) => { if (abortController.signal.aborted) { return } error.value = String(error) UploadTask.delete(uploadTask.objectURL) }) const unsubscribe = uploadTask.subscribeProgress(({ loaded, total }) => { if (abortController.signal.aborted) { return } if (total > 0) { progress.value = loaded / total } }) onCleanup(() => { unsubscribe() abortController.abort() }) }) function handleImageLoad(event: Event) { const img = event.target as HTMLImageElement const { naturalWidth, naturalHeight } = img const ratio = naturalWidth / naturalHeight if (ratio && Number.isFinite(ratio)) { aspectRatio.value = ratio } if ( naturalWidth && naturalHeight && (!attrs.value.width || !attrs.value.height) ) { setAttrs({ width: naturalWidth, height: naturalHeight }) } } </script> <template> <ResizableRoot :width="attrs.width ?? undefined" :height="attrs.height ?? undefined" :aspect-ratio="aspectRatio" :data-selected="props.selected.value ? '' : undefined" class='relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-[600px] max-w-full min-h-[64px] min-w-[64px] outline-2 outline-transparent data-[selected]:outline-blue-500 outline-solid' @resize-end="(event) => setAttrs(event.detail)" > <img v-if="url && !error" :src="url" class='h-full w-full max-w-full max-h-full object-contain' @load="handleImageLoad" /> <div v-if="uploading && !error" class='absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded bg-gray-800/60 p-1.5 text-xs text-white/80 transition'> <div class='i-lucide-loader-circle h-4 w-4 animate-spin'></div> <div>{{ Math.round(progress * 100) }}%</div> </div> <div v-if="error" class='absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container'> <div class='i-lucide-image-off h-8 w-8'></div> <div class='hidden opacity-80 @xs:block'> Failed to upload image </div> </div> <ResizableHandle class='absolute bottom-0 right-0 rounded m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-[[data-resizing]]:opacity-100' position="bottom-right" > <div class='i-lucide-arrow-down-right h-4 w-4'></div> </ResizableHandle> </ResizableRoot> </template>
<script setup lang="ts"> import type { EditorState } from 'prosekit/pm/state' import { useEditor } from 'prosekit/vue' import { InlinePopover } from 'prosekit/vue/inline-popover' import { ref } from 'vue' import Button from './button.vue' import type { EditorExtension } from './extension' const editor = useEditor<EditorExtension>({ update: true }) const linkMenuOpen = ref(false) function setLinkMenuOpen(value: boolean) { linkMenuOpen.value = value } function toggleLinkMenuOpen() { linkMenuOpen.value = !linkMenuOpen.value } 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.href } } } 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-[8rem] space-x-1 overflow-auto whitespace-nowrap rounded-md p-1' > <Button :pressed="editor.marks.bold.isActive()" :disabled="!editor.commands.toggleBold.canExec()" tooltip="Bold" @click="() => editor.commands.toggleBold()" > <div class='i-lucide-bold h-5 w-5' /> </Button> <Button :pressed="editor.marks.italic.isActive()" :disabled="!editor.commands.toggleItalic.canExec()" tooltip="Italic" @click="() => editor.commands.toggleItalic()" > <div class='i-lucide-italic h-5 w-5' /> </Button> <Button :pressed="editor.marks.underline.isActive()" :disabled="!editor.commands.toggleUnderline.canExec()" tooltip="Underline" @click="() => editor.commands.toggleUnderline()" > <div class='i-lucide-underline h-5 w-5' /> </Button> <Button :pressed="editor.marks.strike.isActive()" :disabled="!editor.commands.toggleStrike.canExec()" tooltip="Strike" @click="() => editor.commands.toggleStrike()" > <div class='i-lucide-strikethrough h-5 w-5' /> </Button> <Button :pressed="editor.marks.code.isActive()" :disabled="!editor.commands.toggleCode.canExec()" tooltip="Code" @click="() => editor.commands.toggleCode()" > <div class='i-lucide-code h-5 w-5' /> </Button> <Button v-if="editor.commands.addLink.canExec({ href: '' })" :pressed="editor.marks.link.isActive()" tooltip="Link" @click=" () => { editor.commands.expandLink() toggleLinkMenuOpen() } " > <div class='i-lucide-link h-5 w-5' /> </Button> </InlinePopover> <InlinePopover :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="setLinkMenuOpen" > <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..." :defaultValue="getCurrentLink(editor.state)" 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-none focus-visible:outline-none file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50' /> </form> <button v-if="editor.marks.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-none 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>
<script setup lang="ts"> import { AutocompleteEmpty } from 'prosekit/vue/autocomplete' </script> <template> <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'> <span>No results</span> </AutocompleteEmpty> </template>
<script setup lang="ts"> import { AutocompleteItem } from 'prosekit/vue/autocomplete' defineProps<{ label: string kbd?: string onSelect: () => void }>() </script> <template> <AutocompleteItem 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="onSelect"> <span>{{ label }}</span> <kbd v-if="kbd" class='text-xs font-mono text-gray-400 dark:text-gray-500'>{{ kbd }}</kbd> </AutocompleteItem> </template>
<script setup lang="ts"> import { useEditor } from 'prosekit/vue' import { AutocompleteList, AutocompletePopover, } from 'prosekit/vue/autocomplete' import type { EditorExtension } from './extension' import SlashMenuEmpty from './slash-menu-empty.vue' import SlashMenuItem from './slash-menu-item.vue' const editor = useEditor<EditorExtension>() // Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading". const regex = /\/(|\S.*)$/iu </script> <template> <AutocompletePopover :regex="regex" 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'> <AutocompleteList> <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 /> </AutocompleteList> </AutocompletePopover> </template>
export const tags = [ { id: 1, label: 'book' }, { id: 2, label: 'movie' }, { id: 3, label: 'trip' }, { id: 4, label: 'music' }, { id: 5, label: 'art' }, { id: 6, label: 'food' }, { id: 7, label: 'sport' }, { id: 8, label: 'technology' }, { id: 9, label: 'fashion' }, { id: 10, label: 'nature' }, ]
<script setup lang="ts"> import { useEditor } from 'prosekit/vue' import { AutocompleteEmpty, AutocompleteItem, AutocompleteList, AutocompletePopover, } from 'prosekit/vue/autocomplete' import type { EditorExtension } from './extension' import { tags } from './tag-data' const editor = useEditor<EditorExtension>() function handleTagInsert(id: number, label: string) { editor.value.commands.insertMention({ id: id.toString(), value: '#' + label, kind: 'tag', }) editor.value.commands.insertText({ text: ' ' }) } </script> <template> <AutocompletePopover :regex="/#[\da-z]*$/i" 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'> <AutocompleteList> <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'> No results </AutocompleteEmpty> <AutocompleteItem v-for="tag in tags" :key="tag.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="() => handleTagInsert(tag.id, tag.label)" > {{ tag.label }} </AutocompleteItem> </AutocompleteList> </AutocompletePopover> </template>
import { insertNode, union, } from 'prosekit/core' import { defineFileDropHandler, defineFilePasteHandler, UploadTask, type Uploader, } from 'prosekit/extensions/file' /** * Returns an extension that handles image file uploads when pasting or dropping * images into the editor. */ export function defineImageFileHandlers() { return union( defineFilePasteHandler(({ view, file }) => { // Only handle image files if (!file.type.startsWith('image/')) { return false } // Upload the image to https://tmpfiles.org/ const uploadTask = new UploadTask({ file, uploader: tmpfilesUploader, }) // Insert the image node at the current text selection position const command = insertNode({ type: 'image', attrs: { src: uploadTask.objectURL }, }) return command(view.state, view.dispatch, view) }), defineFileDropHandler(({ view, file, pos }) => { // Only handle image files if (!file.type.startsWith('image/')) { return false } // Upload the image to https://tmpfiles.org/ const uploadTask = new UploadTask({ file, uploader: tmpfilesUploader, }) // Insert the image node at the drop position const command = insertNode({ type: 'image', attrs: { src: uploadTask.objectURL }, pos, }) return command(view.state, view.dispatch, view) }), ) } /** * Uploads the given file to https://tmpfiles.org/ and returns the URL of the * uploaded file. * * This function is only for demonstration purposes. All uploaded files will be * deleted after 1 hour. */ const tmpfilesUploader: Uploader<string> = ({ file, onProgress, }): Promise<string> => { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() const formData = new FormData() formData.append('file', file) xhr.upload.addEventListener('progress', (event) => { if (event.lengthComputable && onProgress) { onProgress({ loaded: event.loaded, total: event.total, }) } }) xhr.addEventListener('load', () => { if (xhr.status === 200) { try { const json = JSON.parse(xhr.responseText) const url: string = (json.data.url as string).replace( 'tmpfiles.org/', 'tmpfiles.org/dl/', ) // Simulate a larger delay setTimeout(() => resolve(url), 1000) } catch (error) { reject(new Error('Failed to parse response', { cause: error })) } } else { reject(new Error(`Upload failed with status ${xhr.status}`)) } }) xhr.addEventListener('error', () => { reject(new Error('Upload failed')) }) xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true) xhr.send(formData) }) }
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 type { EditorExtension } from './extension' import { users } from './user-data' 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: ' ' }) } </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'> <AutocompleteList> <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'> 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)" > {{ user.name }} </AutocompleteItem> </AutocompleteList> </AutocompletePopover> </template>
<script setup lang="ts"> import { TooltipContent, TooltipRoot, TooltipTrigger, } from 'prosekit/vue/tooltip' defineProps<{ pressed?: Boolean disabled?: Boolean tooltip?: string }>() const emit = defineEmits<{ click: [] }>() </script> <template> <TooltipRoot> <TooltipTrigger class='block'> <button :data-state="pressed ? 'on' : 'off'" :disabled="disabled ? true : undefined" 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 disabled:opacity-50 hover:disabled:opacity-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="() => emit('click')" @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-sm [&:not([data-state])]:hidden will-change-transform data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[state=open]:animate-duration-150 data-[state=closed]:animate-duration-200 data-[side=bottom]:slide-in-from-top-2 data-[side=bottom]:slide-out-to-top-2 data-[side=left]:slide-in-from-right-2 data-[side=left]:slide-out-to-right-2 data-[side=right]:slide-in-from-left-2 data-[side=right]:slide-out-to-left-2 data-[side=top]:slide-in-from-bottom-2 data-[side=top]:slide-out-to-bottom-2'> {{ tooltip }} </TooltipContent> </TooltipRoot> </template>