Example: full
Install this example with
shadcn:npx shadcn@latest add @prosekit/react-example-fullnpx shadcn@latest add @prosekit/preact-example-fullnpx shadcn@latest add @prosekit/solid-example-fullnpx shadcn@latest add @prosekit/svelte-example-fullnpx shadcn@latest add @prosekit/vue-example-full- examples/full/editor.tsx
- examples/full/extension.ts
- examples/full/index.ts
- sample/sample-doc-full.ts
- sample/sample-uploader.ts
- sample/tag-data.ts
- sample/user-data.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.tsx
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.tsx
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.tsx
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.tsx
- ui/slash-menu/slash-menu-item.tsx
- ui/slash-menu/slash-menu.tsx
- ui/table-handle/index.ts
- ui/table-handle/table-handle.tsx
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.tsx
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
- ui/user-menu/index.ts
- ui/user-menu/user-menu.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { defaultContent } from '../../sample/sample-doc-full'
import { sampleUploader } from '../../sample/sample-uploader'
import { tags } from '../../sample/tag-data'
import { users } from '../../sample/user-data'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { InlineMenu } from '../../ui/inline-menu'
import { SlashMenu } from '../../ui/slash-menu'
import { TableHandle } from '../../ui/table-handle'
import { TagMenu } from '../../ui/tag-menu'
import { Toolbar } from '../../ui/toolbar'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white">
<Toolbar uploader={sampleUploader} />
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu users={users} />
<TagMenu tags={tags} />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineCodeBlockView } from '../../ui/code-block-view'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Bullet points that guide thoughts' }] },
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] },
],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* 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 by the server after 1 hour.
*/
export const sampleUploader: 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({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.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 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' },
]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 {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopover,
} from 'prosekit/preact/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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200">
<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-sm text-gray-500/50 dark:text-gray-500/50 cursor-pointer">
<div className="i-lucide-plus size-5 block" />
</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-sm text-gray-500/50 dark:text-gray-500/50 cursor-grab">
<div className="i-lucide-grip-vertical size-5 block" />
</BlockHandleDraggable>
</BlockHandlePopover>
)
}export { default as BlockHandle } from './block-handle'import type {
ComponentChild,
MouseEventHandler,
} from 'preact'
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/preact/tooltip'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: MouseEventHandler<HTMLButtonElement>
tooltip?: string
children: ComponentChild
}) {
return (
<TooltipRoot>
<TooltipTrigger className="block">
<button
data-state={props.pressed ? 'on' : 'off'}
disabled={props.disabled}
onClick={props.onClick}
onMouseDown={(event) => {
// Prevent the editor from being blurred when the button is clicked
event.preventDefault()
}}
className="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{props.children}
{props.tooltip ? <span className="sr-only">{props.tooltip}</span> : null}
</button>
</TooltipTrigger>
{props.tooltip
? (
<TooltipContent className="z-50 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{props.tooltip}
</TooltipContent>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'import type { JSX } from 'preact'
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { PreactNodeViewProps } from 'prosekit/preact'
export default function CodeBlockView(props: PreactNodeViewProps) {
const attrs = props.node.attrs as CodeBlockAttrs
const language = attrs.language || ''
const setLanguage = (language: string) => {
const attrs: CodeBlockAttrs = { language }
props.setAttrs(attrs)
}
const handleChange = (
event: JSX.TargetedEvent<HTMLSelectElement, Event>,
) => {
setLanguage(event.currentTarget.value)
}
return (
<>
<div className="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable={false}>
<select
aria-label="Code block language"
className="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={handleChange}
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 type { Extension } from 'prosekit/core'
import {
definePreactNodeView,
type PreactNodeViewComponent,
} from 'prosekit/preact'
import CodeBlockView from './code-block-view'
export function defineCodeBlockView(): Extension {
return definePreactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies PreactNodeViewComponent,
})
}import { DropIndicator as BaseDropIndicator } from 'prosekit/preact/drop-indicator'
export default function DropIndicator() {
return <BaseDropIndicator className="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator'import type { ComponentChild } from 'preact'
import type { JSX } from 'preact'
import {
useId,
useState,
} from 'preact/hooks'
import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/preact'
import {
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from 'prosekit/preact/popover'
import { Button } from '../button'
export default function ImageUploadPopover(props: {
uploader: Uploader<string>
tooltip: string
disabled: boolean
children: ComponentChild
}) {
const [open, setOpen] = useState(false)
const [url, setUrl] = useState('')
const [file, setFile] = useState<File | null>(null)
const ariaId = useId()
const editor = useEditor<ImageExtension>()
const handleFileChange = (
event: JSX.TargetedEvent<HTMLInputElement, Event>,
) => {
const file = event.currentTarget.files?.[0]
if (file) {
setFile(file)
setUrl('')
} else {
setFile(null)
}
}
const handleUrlChange = (
event: JSX.TargetedEvent<HTMLInputElement, Event>,
) => {
const url = event.currentTarget.value
if (url) {
setUrl(url)
setFile(null)
} else {
setUrl('')
}
}
const deferResetState = () => {
setTimeout(() => {
setUrl('')
setFile(null)
}, 300)
}
const handleSubmit = () => {
if (url) {
editor.commands.insertImage({ src: url })
} else if (file) {
editor.commands.uploadImage({ file, uploader: props.uploader })
}
setOpen(false)
deferResetState()
}
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
deferResetState()
}
setOpen(nextOpen)
}
return (
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open} disabled={props.disabled} tooltip={props.tooltip}>
{props.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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{file ? null : (
<>
<label htmlFor={`id-link-${ariaId}`}>Embed Link</label>
<input
id={`id-link-${ariaId}`}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Paste the image link..."
type="url"
value={url}
onChange={handleUrlChange}
/>
</>
)}
{url ? null : (
<>
<label htmlFor={`id-upload-${ariaId}`}>Upload</label>
<input
id={`id-upload-${ariaId}`}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
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-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" onClick={handleSubmit}>
Insert Image
</button>
)
: null}
{file
? (
<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-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" onClick={handleSubmit}>
Upload Image
</button>
)
: null}
</PopoverContent>
</PopoverRoot>
)
}export { default as ImageUploadPopover } from './image-upload-popover'import type { JSX } from 'preact'
import {
useEffect,
useState,
} from 'preact/hooks'
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { PreactNodeViewProps } from 'prosekit/preact'
import {
ResizableHandle,
ResizableRoot,
} from 'prosekit/preact/resizable'
export default function ImageView(props: PreactNodeViewProps) {
const attrs = props.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 (!uploading) return
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((error) => {
if (canceled) return
setError(String(error))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
return () => {
canceled = true
unsubscribeProgress()
}
}, [url, uploading])
const handleImageLoad = (
event: JSX.TargetedEvent<HTMLImageElement, Event>,
) => {
const img = event.currentTarget
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
setAspectRatio(ratio)
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => props.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}
alt="upload preview"
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-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div className="i-lucide-loader-circle size-4 animate-spin block"></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 size-8 block"></div>
<div className="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className="absolute bottom-0 right-0 rounded-sm 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 size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import {
definePreactNodeView,
type PreactNodeViewComponent,
} from 'prosekit/preact'
import ImageView from './image-view'
export function defineImageView(): Extension {
return definePreactNodeView({
name: 'image',
component: ImageView satisfies PreactNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu'import type { JSX } from 'preact'
import { useState } from 'preact/hooks'
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import {
useEditor,
useEditorDerivedValue,
} from 'prosekit/preact'
import { InlinePopover } from 'prosekit/preact/inline-popover'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
export default function InlineMenu() {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = useState(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor.commands.addLink({ href })
} else {
editor.commands.removeLink()
}
setLinkMenuOpen(false)
editor.focus()
}
const handleSubmit = (
event: JSX.TargetedEvent<HTMLFormElement, SubmitEvent>,
) => {
event.preventDefault()
const href = event.currentTarget.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}
return (
<>
<InlinePopover
data-testid="inline-menu-main"
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
onOpenChange={(open) => {
if (!open) {
setLinkMenuOpen(false)
}
}}
>
{items.bold && (
<Button
pressed={items.bold.isActive}
disabled={!items.bold.canExec}
onClick={items.bold.command}
tooltip="Bold"
>
<div className="i-lucide-bold size-5 block"></div>
</Button>
)}
{items.italic && (
<Button
pressed={items.italic.isActive}
disabled={!items.italic.canExec}
onClick={items.italic.command}
tooltip="Italic"
>
<div className="i-lucide-italic size-5 block"></div>
</Button>
)}
{items.underline && (
<Button
pressed={items.underline.isActive}
disabled={!items.underline.canExec}
onClick={items.underline.command}
tooltip="Underline"
>
<div className="i-lucide-underline size-5 block"></div>
</Button>
)}
{items.strike && (
<Button
pressed={items.strike.isActive}
disabled={!items.strike.canExec}
onClick={items.strike.command}
tooltip="Strikethrough"
>
<div className="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
{items.code && (
<Button
pressed={items.code.isActive}
disabled={!items.code.canExec}
onClick={items.code.command}
tooltip="Code"
>
<div className="i-lucide-code size-5 block"></div>
</Button>
)}
{items.link && items.link.canExec && (
<Button
pressed={items.link.isActive}
onClick={() => {
items.link?.command?.()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div className="i-lucide-link size-5 block"></div>
</Button>
)}
</InlinePopover>
{items.link && (
<InlinePopover
placement="bottom"
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={setLinkMenuOpen}
data-testid="inline-menu-link"
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
{linkMenuOpen && (
<form onSubmit={handleSubmit}>
<input
placeholder="Paste the link..."
defaultValue={items.link.currentLink}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
/>
</form>
)}
{items.link.isActive && (
<button
onClick={() => handleLinkUpdate()}
onMouseDown={(event) => event.preventDefault()}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
)}
</InlinePopover>
)}
</>
)
}export { default as SlashMenu } from './slash-menu'import { AutocompleteEmpty } from 'prosekit/preact/autocomplete'
export default function SlashMenuEmpty() {
return (
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
)
}import { AutocompleteItem } from 'prosekit/preact/autocomplete'
export default function SlashMenuItem(props: {
label: string
kbd?: string
onSelect: () => void
}) {
return (
<AutocompleteItem onSelect={props.onSelect} className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
<span>{props.label}</span>
{props.kbd && <kbd className="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>}
</AutocompleteItem>
)
}import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/preact'
import {
AutocompleteList,
AutocompletePopover,
} from 'prosekit/preact/autocomplete'
import SlashMenuEmpty from './slash-menu-empty'
import SlashMenuItem from './slash-menu-item'
export default function SlashMenu() {
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(|\S.*)$/u : /\/(|\S.*)$/u
return (
<AutocompletePopover regex={regex} className="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden">
<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 { default as TableHandle } from './table-handle'import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/preact'
import {
TableHandleColumnRoot,
TableHandleColumnTrigger,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandlePopoverContent,
TableHandlePopoverItem,
TableHandleRoot,
TableHandleRowRoot,
TableHandleRowTrigger,
} from 'prosekit/preact/table-handle'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
export default function TableHandle() {
const state = useEditorDerivedValue(getTableHandleState)
return (
<TableHandleRoot className="contents">
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnRoot className="h-[1.2em] w-[1.5em] translate-y-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleColumnTrigger className="flex items-center justify-center">
<div className="i-lucide-grip-horizontal size-5 block"></div>
</TableHandleColumnTrigger>
<TableHandlePopoverContent className="relative block max-h-100 min-w-32 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">
{state.addTableColumnBefore.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.addTableColumnBefore.command}
>
<span>Insert Left</span>
</TableHandlePopoverItem>
)}
{state.addTableColumnAfter.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.addTableColumnAfter.command}
>
<span>Insert Right</span>
</TableHandlePopoverItem>
)}
{state.deleteCellSelection.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span className="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
)}
{state.deleteTableColumn.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.deleteTableColumn.command}
>
<span>Delete Column</span>
</TableHandlePopoverItem>
)}
{state.deleteTable.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger=""
onSelect={state.deleteTable.command}
>
<span>Delete Table</span>
</TableHandlePopoverItem>
)}
</TableHandlePopoverContent>
</TableHandleColumnRoot>
<TableHandleRowRoot className="h-[1.5em] w-[1.2em] translate-x-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleRowTrigger className="flex items-center justify-center">
<div className="i-lucide-grip-vertical size-5 block"></div>
</TableHandleRowTrigger>
<TableHandlePopoverContent className="relative block max-h-100 min-w-32 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">
{state.addTableRowAbove.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.addTableRowAbove.command}
>
<span>Insert Above</span>
</TableHandlePopoverItem>
)}
{state.addTableRowBelow.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.addTableRowBelow.command}
>
<span>Insert Below</span>
</TableHandlePopoverItem>
)}
{state.deleteCellSelection.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span className="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
)}
{state.deleteTableRow.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.deleteTableRow.command}
>
<span>Delete Row</span>
</TableHandlePopoverItem>
)}
{state.deleteTable.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger=""
onSelect={state.deleteTable.command}
>
<span>Delete Table</span>
</TableHandlePopoverItem>
)}
</TableHandlePopoverContent>
</TableHandleRowRoot>
</TableHandleRoot>
)
}export { default as TagMenu } from './tag-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/preact'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/preact/autocomplete'
export default function TagMenu(props: { tags: { id: number; label: string }[] }) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
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-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
>
<AutocompleteList>
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
No results
</AutocompleteEmpty>
{props.tags.map((tag) => (
<AutocompleteItem
key={tag.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
)
}export { default as Toolbar } from './toolbar'import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { Uploader } from 'prosekit/extensions/file'
import { useEditorDerivedValue } from 'prosekit/preact'
import { Button } from '../button'
import { ImageUploadPopover } from '../image-upload-popover'
function getToolbarItems(editor: Editor<BasicExtension>) {
return {
undo: editor.commands.undo
? {
isActive: false,
canExec: editor.commands.undo.canExec(),
command: () => editor.commands.undo(),
}
: undefined,
redo: editor.commands.redo
? {
isActive: false,
canExec: editor.commands.redo.canExec(),
command: () => editor.commands.redo(),
}
: undefined,
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
codeBlock: editor.commands.insertCodeBlock
? {
isActive: editor.nodes.codeBlock.isActive(),
canExec: editor.commands.insertCodeBlock.canExec({ language: 'javascript' }),
command: () => editor.commands.insertCodeBlock({ language: 'javascript' }),
}
: undefined,
heading1: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 1 }),
canExec: editor.commands.toggleHeading.canExec({ level: 1 }),
command: () => editor.commands.toggleHeading({ level: 1 }),
}
: undefined,
heading2: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 2 }),
canExec: editor.commands.toggleHeading.canExec({ level: 2 }),
command: () => editor.commands.toggleHeading({ level: 2 }),
}
: undefined,
heading3: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 3 }),
canExec: editor.commands.toggleHeading.canExec({ level: 3 }),
command: () => editor.commands.toggleHeading({ level: 3 }),
}
: undefined,
horizontalRule: editor.commands.insertHorizontalRule
? {
isActive: editor.nodes.horizontalRule.isActive(),
canExec: editor.commands.insertHorizontalRule.canExec(),
command: () => editor.commands.insertHorizontalRule(),
}
: undefined,
blockquote: editor.commands.toggleBlockquote
? {
isActive: editor.nodes.blockquote.isActive(),
canExec: editor.commands.toggleBlockquote.canExec(),
command: () => editor.commands.toggleBlockquote(),
}
: undefined,
bulletList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'bullet' }),
canExec: editor.commands.toggleList.canExec({ kind: 'bullet' }),
command: () => editor.commands.toggleList({ kind: 'bullet' }),
}
: undefined,
orderedList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'ordered' }),
canExec: editor.commands.toggleList.canExec({ kind: 'ordered' }),
command: () => editor.commands.toggleList({ kind: 'ordered' }),
}
: undefined,
taskList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'task' }),
canExec: editor.commands.toggleList.canExec({ kind: 'task' }),
command: () => editor.commands.toggleList({ kind: 'task' }),
}
: undefined,
toggleList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'toggle' }),
canExec: editor.commands.toggleList.canExec({ kind: 'toggle' }),
command: () => editor.commands.toggleList({ kind: 'toggle' }),
}
: undefined,
indentList: editor.commands.indentList
? {
isActive: false,
canExec: editor.commands.indentList.canExec(),
command: () => editor.commands.indentList(),
}
: undefined,
dedentList: editor.commands.dedentList
? {
isActive: false,
canExec: editor.commands.dedentList.canExec(),
command: () => editor.commands.dedentList(),
}
: undefined,
insertImage: editor.commands.insertImage
? {
isActive: false,
canExec: editor.commands.insertImage.canExec(),
}
: undefined,
}
}
export default function Toolbar(props: { uploader?: Uploader<string> }) {
const items = useEditorDerivedValue(getToolbarItems)
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">
{items.undo && (
<Button
pressed={items.undo.isActive}
disabled={!items.undo.canExec}
onClick={items.undo.command}
tooltip="Undo"
>
<div className="i-lucide-undo-2 size-5 block" />
</Button>
)}
{items.redo && (
<Button
pressed={items.redo.isActive}
disabled={!items.redo.canExec}
onClick={items.redo.command}
tooltip="Redo"
>
<div className="i-lucide-redo-2 size-5 block" />
</Button>
)}
{items.bold && (
<Button
pressed={items.bold.isActive}
disabled={!items.bold.canExec}
onClick={items.bold.command}
tooltip="Bold"
>
<div className="i-lucide-bold size-5 block" />
</Button>
)}
{items.italic && (
<Button
pressed={items.italic.isActive}
disabled={!items.italic.canExec}
onClick={items.italic.command}
tooltip="Italic"
>
<div className="i-lucide-italic size-5 block" />
</Button>
)}
{items.underline && (
<Button
pressed={items.underline.isActive}
disabled={!items.underline.canExec}
onClick={items.underline.command}
tooltip="Underline"
>
<div className="i-lucide-underline size-5 block" />
</Button>
)}
{items.strike && (
<Button
pressed={items.strike.isActive}
disabled={!items.strike.canExec}
onClick={items.strike.command}
tooltip="Strike"
>
<div className="i-lucide-strikethrough size-5 block" />
</Button>
)}
{items.code && (
<Button
pressed={items.code.isActive}
disabled={!items.code.canExec}
onClick={items.code.command}
tooltip="Code"
>
<div className="i-lucide-code size-5 block" />
</Button>
)}
{items.codeBlock && (
<Button
pressed={items.codeBlock.isActive}
disabled={!items.codeBlock.canExec}
onClick={items.codeBlock.command}
tooltip="Code Block"
>
<div className="i-lucide-square-code size-5 block" />
</Button>
)}
{items.heading1 && (
<Button
pressed={items.heading1.isActive}
disabled={!items.heading1.canExec}
onClick={items.heading1.command}
tooltip="Heading 1"
>
<div className="i-lucide-heading-1 size-5 block" />
</Button>
)}
{items.heading2 && (
<Button
pressed={items.heading2.isActive}
disabled={!items.heading2.canExec}
onClick={items.heading2.command}
tooltip="Heading 2"
>
<div className="i-lucide-heading-2 size-5 block" />
</Button>
)}
{items.heading3 && (
<Button
pressed={items.heading3.isActive}
disabled={!items.heading3.canExec}
onClick={items.heading3.command}
tooltip="Heading 3"
>
<div className="i-lucide-heading-3 size-5 block" />
</Button>
)}
{items.horizontalRule && (
<Button
pressed={items.horizontalRule.isActive}
disabled={!items.horizontalRule.canExec}
onClick={items.horizontalRule.command}
tooltip="Divider"
>
<div className="i-lucide-minus size-5 block"></div>
</Button>
)}
{items.blockquote && (
<Button
pressed={items.blockquote.isActive}
disabled={!items.blockquote.canExec}
onClick={items.blockquote.command}
tooltip="Blockquote"
>
<div className="i-lucide-text-quote size-5 block" />
</Button>
)}
{items.bulletList && (
<Button
pressed={items.bulletList.isActive}
disabled={!items.bulletList.canExec}
onClick={items.bulletList.command}
tooltip="Bullet List"
>
<div className="i-lucide-list size-5 block" />
</Button>
)}
{items.orderedList && (
<Button
pressed={items.orderedList.isActive}
disabled={!items.orderedList.canExec}
onClick={items.orderedList.command}
tooltip="Ordered List"
>
<div className="i-lucide-list-ordered size-5 block" />
</Button>
)}
{items.taskList && (
<Button
pressed={items.taskList.isActive}
disabled={!items.taskList.canExec}
onClick={items.taskList.command}
tooltip="Task List"
>
<div className="i-lucide-list-checks size-5 block" />
</Button>
)}
{items.toggleList && (
<Button
pressed={items.toggleList.isActive}
disabled={!items.toggleList.canExec}
onClick={items.toggleList.command}
tooltip="Toggle List"
>
<div className="i-lucide-list-collapse size-5 block" />
</Button>
)}
{items.indentList && (
<Button
pressed={items.indentList.isActive}
disabled={!items.indentList.canExec}
onClick={items.indentList.command}
tooltip="Increase indentation"
>
<div className="i-lucide-indent-increase size-5 block" />
</Button>
)}
{items.dedentList && (
<Button
pressed={items.dedentList.isActive}
disabled={!items.dedentList.canExec}
onClick={items.dedentList.command}
tooltip="Decrease indentation"
>
<div className="i-lucide-indent-decrease size-5 block" />
</Button>
)}
{props.uploader && items.insertImage && (
<ImageUploadPopover
uploader={props.uploader}
disabled={!items.insertImage.canExec}
tooltip="Insert Image"
>
<div className="i-lucide-image size-5 block" />
</ImageUploadPopover>
)}
</div>
)
}export { default as UserMenu } from './user-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/preact'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/preact/autocomplete'
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompletePopover
regex={/@\w*$/}
className="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
onQueryChange={props.onQueryChange}
onOpenChange={props.onOpenChange}
>
<AutocompleteList>
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{props.users.map((user) => (
<AutocompleteItem
key={user.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span className={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
)
}- examples/full/editor.tsx
- examples/full/extension.ts
- examples/full/index.ts
- sample/sample-doc-full.ts
- sample/sample-uploader.ts
- sample/tag-data.ts
- sample/user-data.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.tsx
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.tsx
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.tsx
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.tsx
- ui/slash-menu/slash-menu-item.tsx
- ui/slash-menu/slash-menu.tsx
- ui/table-handle/index.ts
- ui/table-handle/table-handle.tsx
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.tsx
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
- ui/user-menu/index.ts
- ui/user-menu/user-menu.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { defaultContent } from '../../sample/sample-doc-full'
import { sampleUploader } from '../../sample/sample-uploader'
import { tags } from '../../sample/tag-data'
import { users } from '../../sample/user-data'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { InlineMenu } from '../../ui/inline-menu'
import { SlashMenu } from '../../ui/slash-menu'
import { TableHandle } from '../../ui/table-handle'
import { TagMenu } from '../../ui/tag-menu'
import { Toolbar } from '../../ui/toolbar'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension, defaultContent })
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white">
<Toolbar uploader={sampleUploader} />
<div className="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} className="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu users={users} />
<TagMenu tags={tags} />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineCodeBlockView } from '../../ui/code-block-view'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>'use client'
export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Bullet points that guide thoughts' }] },
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] },
],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* 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 by the server after 1 hour.
*/
export const sampleUploader: 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({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.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 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' },
]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 {
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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200">
<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-sm text-gray-500/50 dark:text-gray-500/50 cursor-pointer">
<div className="i-lucide-plus size-5 block" />
</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-sm text-gray-500/50 dark:text-gray-500/50 cursor-grab">
<div className="i-lucide-grip-vertical size-5 block" />
</BlockHandleDraggable>
</BlockHandlePopover>
)
}export { default as BlockHandle } from './block-handle'import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/react/tooltip'
import type {
MouseEventHandler,
ReactNode,
} from 'react'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: MouseEventHandler<HTMLButtonElement>
tooltip?: string
children: ReactNode
}) {
return (
<TooltipRoot>
<TooltipTrigger className="block">
<button
data-state={props.pressed ? 'on' : 'off'}
disabled={props.disabled}
onClick={props.onClick}
onMouseDown={(event) => {
// Prevent the editor from being blurred when the button is clicked
event.preventDefault()
}}
className="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{props.children}
{props.tooltip ? <span className="sr-only">{props.tooltip}</span> : null}
</button>
</TooltipTrigger>
{props.tooltip
? (
<TooltipContent className="z-50 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{props.tooltip}
</TooltipContent>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'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
aria-label="Code block language"
className="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]: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 type { Extension } from 'prosekit/core'
import {
defineReactNodeView,
type ReactNodeViewComponent,
} from 'prosekit/react'
import CodeBlockView from './code-block-view'
export function defineCodeBlockView(): Extension {
return defineReactNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies ReactNodeViewComponent,
})
}import { DropIndicator as BaseDropIndicator } from 'prosekit/react/drop-indicator'
export default function DropIndicator() {
return <BaseDropIndicator className="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator'import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/react'
import {
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from 'prosekit/react/popover'
import {
useId,
useState,
type ReactNode,
} from 'react'
import { Button } from '../button'
export default function ImageUploadPopover(props: {
uploader: Uploader<string>
tooltip: string
disabled: boolean
children: ReactNode
}) {
const [open, setOpen] = useState(false)
const [url, setUrl] = useState('')
const [file, setFile] = useState<File | null>(null)
const ariaId = useId()
const editor = useEditor<ImageExtension>()
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = (
event,
) => {
const file = event.target.files?.[0]
if (file) {
setFile(file)
setUrl('')
} else {
setFile(null)
}
}
const handleUrlChange: React.ChangeEventHandler<HTMLInputElement> = (
event,
) => {
const url = event.target.value
if (url) {
setUrl(url)
setFile(null)
} else {
setUrl('')
}
}
const deferResetState = () => {
setTimeout(() => {
setUrl('')
setFile(null)
}, 300)
}
const handleSubmit = () => {
if (url) {
editor.commands.insertImage({ src: url })
} else if (file) {
editor.commands.uploadImage({ file, uploader: props.uploader })
}
setOpen(false)
deferResetState()
}
const handleOpenChange = (open: boolean) => {
if (!open) {
deferResetState()
}
setOpen(open)
}
return (
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open} disabled={props.disabled} tooltip={props.tooltip}>
{props.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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{file ? null : (
<>
<label htmlFor={`id-link-${ariaId}`}>Embed Link</label>
<input
id={`id-link-${ariaId}`}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Paste the image link..."
type="url"
value={url}
onChange={handleUrlChange}
/>
</>
)}
{url ? null : (
<>
<label htmlFor={`id-upload-${ariaId}`}>Upload</label>
<input
id={`id-upload-${ariaId}`}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
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-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" onClick={handleSubmit}>
Insert Image
</button>
)
: null}
{file
? (
<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-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" onClick={handleSubmit}>
Upload Image
</button>
)
: null}
</PopoverContent>
</PopoverRoot>
)
}export { default as ImageUploadPopover } from './image-upload-popover'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 attrs = props.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 (!uploading) return
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((error) => {
if (canceled) return
setError(String(error))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
return () => {
canceled = true
unsubscribeProgress()
}
}, [url, uploading])
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)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => props.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}
alt="upload preview"
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-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div className="i-lucide-loader-circle size-4 animate-spin block"></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 size-8 block"></div>
<div className="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className="absolute bottom-0 right-0 rounded-sm 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 size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import {
defineReactNodeView,
type ReactNodeViewComponent,
} from 'prosekit/react'
import ImageView from './image-view'
export function defineImageView(): Extension {
return defineReactNodeView({
name: 'image',
component: ImageView satisfies ReactNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import {
useEditor,
useEditorDerivedValue,
} from 'prosekit/react'
import { InlinePopover } from 'prosekit/react/inline-popover'
import { useState } from 'react'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
export default function InlineMenu() {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = useState(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor.commands.addLink({ href })
} else {
editor.commands.removeLink()
}
setLinkMenuOpen(false)
editor.focus()
}
return (
<>
<InlinePopover
data-testid="inline-menu-main"
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
onOpenChange={(open) => {
if (!open) {
setLinkMenuOpen(false)
}
}}
>
{items.bold && (
<Button
pressed={items.bold.isActive}
disabled={!items.bold.canExec}
onClick={items.bold.command}
tooltip="Bold"
>
<div className="i-lucide-bold size-5 block"></div>
</Button>
)}
{items.italic && (
<Button
pressed={items.italic.isActive}
disabled={!items.italic.canExec}
onClick={items.italic.command}
tooltip="Italic"
>
<div className="i-lucide-italic size-5 block"></div>
</Button>
)}
{items.underline && (
<Button
pressed={items.underline.isActive}
disabled={!items.underline.canExec}
onClick={items.underline.command}
tooltip="Underline"
>
<div className="i-lucide-underline size-5 block"></div>
</Button>
)}
{items.strike && (
<Button
pressed={items.strike.isActive}
disabled={!items.strike.canExec}
onClick={items.strike.command}
tooltip="Strikethrough"
>
<div className="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
{items.code && (
<Button
pressed={items.code.isActive}
disabled={!items.code.canExec}
onClick={items.code.command}
tooltip="Code"
>
<div className="i-lucide-code size-5 block"></div>
</Button>
)}
{items.link && items.link.canExec && (
<Button
pressed={items.link.isActive}
onClick={() => {
items.link?.command?.()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div className="i-lucide-link size-5 block"></div>
</Button>
)}
</InlinePopover>
{items.link && (
<InlinePopover
placement={'bottom'}
defaultOpen={false}
open={linkMenuOpen}
onOpenChange={setLinkMenuOpen}
data-testid="inline-menu-link"
className="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
>
{linkMenuOpen && (
<form
onSubmit={(event) => {
event.preventDefault()
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}}
>
<input
placeholder="Paste the link..."
defaultValue={items.link.currentLink}
className="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
</input>
</form>
)}
{items.link.isActive && (
<button
onClick={() => handleLinkUpdate()}
onMouseDown={(event) => event.preventDefault()}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
)}
</InlinePopover>
)}
</>
)
}export { default as SlashMenu } from './slash-menu'import { AutocompleteEmpty } from 'prosekit/react/autocomplete'
export default function SlashMenuEmpty() {
return (
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
<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-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
<span>{props.label}</span>
{props.kbd && <kbd className="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>}
</AutocompleteItem>
)
}import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/react'
import {
AutocompleteList,
AutocompletePopover,
} from 'prosekit/react/autocomplete'
import SlashMenuEmpty from './slash-menu-empty'
import SlashMenuItem from './slash-menu-item'
export default function SlashMenu() {
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(|\S.*)$/u : /\/(|\S.*)$/u
return (
<AutocompletePopover regex={regex} className="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden">
<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 { default as TableHandle } from './table-handle'import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/react'
import {
TableHandleColumnRoot,
TableHandleColumnTrigger,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandlePopoverContent,
TableHandlePopoverItem,
TableHandleRoot,
TableHandleRowRoot,
TableHandleRowTrigger,
} from 'prosekit/react/table-handle'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
export default function TableHandle() {
const state = useEditorDerivedValue(getTableHandleState)
return (
<TableHandleRoot className="contents">
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnRoot className="h-[1.2em] w-[1.5em] translate-y-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleColumnTrigger className="flex items-center justify-center">
<div className="i-lucide-grip-horizontal size-5 block"></div>
</TableHandleColumnTrigger>
<TableHandlePopoverContent className="relative block max-h-100 min-w-32 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">
{state.addTableColumnBefore.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.addTableColumnBefore.command}
>
<span>Insert Left</span>
</TableHandlePopoverItem>
)}
{state.addTableColumnAfter.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.addTableColumnAfter.command}
>
<span>Insert Right</span>
</TableHandlePopoverItem>
)}
{state.deleteCellSelection.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span className="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
)}
{state.deleteTableColumn.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.deleteTableColumn.command}
>
<span>Delete Column</span>
</TableHandlePopoverItem>
)}
{state.deleteTable.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger=""
onSelect={state.deleteTable.command}
>
<span>Delete Table</span>
</TableHandlePopoverItem>
)}
</TableHandlePopoverContent>
</TableHandleColumnRoot>
<TableHandleRowRoot className="h-[1.5em] w-[1.2em] translate-x-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleRowTrigger className="flex items-center justify-center">
<div className="i-lucide-grip-vertical size-5 block"></div>
</TableHandleRowTrigger>
<TableHandlePopoverContent className="relative block max-h-100 min-w-32 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">
{state.addTableRowAbove.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.addTableRowAbove.command}
>
<span>Insert Above</span>
</TableHandlePopoverItem>
)}
{state.addTableRowBelow.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.addTableRowBelow.command}
>
<span>Insert Below</span>
</TableHandlePopoverItem>
)}
{state.deleteCellSelection.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span className="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
)}
{state.deleteTableRow.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={state.deleteTableRow.command}
>
<span>Delete Row</span>
</TableHandlePopoverItem>
)}
{state.deleteTable.canExec && (
<TableHandlePopoverItem
className="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger=""
onSelect={state.deleteTable.command}
>
<span>Delete Table</span>
</TableHandlePopoverItem>
)}
</TableHandlePopoverContent>
</TableHandleRowRoot>
</TableHandleRoot>
)
}export { default as TagMenu } from './tag-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/react/autocomplete'
export default function TagMenu(props: { tags: { id: number; label: string }[] }) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
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-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
>
<AutocompleteList>
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
No results
</AutocompleteEmpty>
{props.tags.map((tag) => (
<AutocompleteItem
key={tag.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
)
}export { default as Toolbar } from './toolbar'import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { Uploader } from 'prosekit/extensions/file'
import { useEditorDerivedValue } from 'prosekit/react'
import { Button } from '../button'
import { ImageUploadPopover } from '../image-upload-popover'
function getToolbarItems(editor: Editor<BasicExtension>) {
return {
undo: editor.commands.undo
? {
isActive: false,
canExec: editor.commands.undo.canExec(),
command: () => editor.commands.undo(),
}
: undefined,
redo: editor.commands.redo
? {
isActive: false,
canExec: editor.commands.redo.canExec(),
command: () => editor.commands.redo(),
}
: undefined,
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
codeBlock: editor.commands.insertCodeBlock
? {
isActive: editor.nodes.codeBlock.isActive(),
canExec: editor.commands.insertCodeBlock.canExec({ language: 'javascript' }),
command: () => editor.commands.insertCodeBlock({ language: 'javascript' }),
}
: undefined,
heading1: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 1 }),
canExec: editor.commands.toggleHeading.canExec({ level: 1 }),
command: () => editor.commands.toggleHeading({ level: 1 }),
}
: undefined,
heading2: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 2 }),
canExec: editor.commands.toggleHeading.canExec({ level: 2 }),
command: () => editor.commands.toggleHeading({ level: 2 }),
}
: undefined,
heading3: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 3 }),
canExec: editor.commands.toggleHeading.canExec({ level: 3 }),
command: () => editor.commands.toggleHeading({ level: 3 }),
}
: undefined,
horizontalRule: editor.commands.insertHorizontalRule
? {
isActive: editor.nodes.horizontalRule.isActive(),
canExec: editor.commands.insertHorizontalRule.canExec(),
command: () => editor.commands.insertHorizontalRule(),
}
: undefined,
blockquote: editor.commands.toggleBlockquote
? {
isActive: editor.nodes.blockquote.isActive(),
canExec: editor.commands.toggleBlockquote.canExec(),
command: () => editor.commands.toggleBlockquote(),
}
: undefined,
bulletList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'bullet' }),
canExec: editor.commands.toggleList.canExec({ kind: 'bullet' }),
command: () => editor.commands.toggleList({ kind: 'bullet' }),
}
: undefined,
orderedList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'ordered' }),
canExec: editor.commands.toggleList.canExec({ kind: 'ordered' }),
command: () => editor.commands.toggleList({ kind: 'ordered' }),
}
: undefined,
taskList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'task' }),
canExec: editor.commands.toggleList.canExec({ kind: 'task' }),
command: () => editor.commands.toggleList({ kind: 'task' }),
}
: undefined,
toggleList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'toggle' }),
canExec: editor.commands.toggleList.canExec({ kind: 'toggle' }),
command: () => editor.commands.toggleList({ kind: 'toggle' }),
}
: undefined,
indentList: editor.commands.indentList
? {
isActive: false,
canExec: editor.commands.indentList.canExec(),
command: () => editor.commands.indentList(),
}
: undefined,
dedentList: editor.commands.dedentList
? {
isActive: false,
canExec: editor.commands.dedentList.canExec(),
command: () => editor.commands.dedentList(),
}
: undefined,
insertImage: editor.commands.insertImage
? {
isActive: false,
canExec: editor.commands.insertImage.canExec(),
}
: undefined,
}
}
export default function Toolbar(props: { uploader?: Uploader<string> }) {
const items = useEditorDerivedValue(getToolbarItems)
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">
{items.undo && (
<Button
pressed={items.undo.isActive}
disabled={!items.undo.canExec}
onClick={items.undo.command}
tooltip="Undo"
>
<div className="i-lucide-undo-2 size-5 block" />
</Button>
)}
{items.redo && (
<Button
pressed={items.redo.isActive}
disabled={!items.redo.canExec}
onClick={items.redo.command}
tooltip="Redo"
>
<div className="i-lucide-redo-2 size-5 block" />
</Button>
)}
{items.bold && (
<Button
pressed={items.bold.isActive}
disabled={!items.bold.canExec}
onClick={items.bold.command}
tooltip="Bold"
>
<div className="i-lucide-bold size-5 block" />
</Button>
)}
{items.italic && (
<Button
pressed={items.italic.isActive}
disabled={!items.italic.canExec}
onClick={items.italic.command}
tooltip="Italic"
>
<div className="i-lucide-italic size-5 block" />
</Button>
)}
{items.underline && (
<Button
pressed={items.underline.isActive}
disabled={!items.underline.canExec}
onClick={items.underline.command}
tooltip="Underline"
>
<div className="i-lucide-underline size-5 block" />
</Button>
)}
{items.strike && (
<Button
pressed={items.strike.isActive}
disabled={!items.strike.canExec}
onClick={items.strike.command}
tooltip="Strike"
>
<div className="i-lucide-strikethrough size-5 block" />
</Button>
)}
{items.code && (
<Button
pressed={items.code.isActive}
disabled={!items.code.canExec}
onClick={items.code.command}
tooltip="Code"
>
<div className="i-lucide-code size-5 block" />
</Button>
)}
{items.codeBlock && (
<Button
pressed={items.codeBlock.isActive}
disabled={!items.codeBlock.canExec}
onClick={items.codeBlock.command}
tooltip="Code Block"
>
<div className="i-lucide-square-code size-5 block" />
</Button>
)}
{items.heading1 && (
<Button
pressed={items.heading1.isActive}
disabled={!items.heading1.canExec}
onClick={items.heading1.command}
tooltip="Heading 1"
>
<div className="i-lucide-heading-1 size-5 block" />
</Button>
)}
{items.heading2 && (
<Button
pressed={items.heading2.isActive}
disabled={!items.heading2.canExec}
onClick={items.heading2.command}
tooltip="Heading 2"
>
<div className="i-lucide-heading-2 size-5 block" />
</Button>
)}
{items.heading3 && (
<Button
pressed={items.heading3.isActive}
disabled={!items.heading3.canExec}
onClick={items.heading3.command}
tooltip="Heading 3"
>
<div className="i-lucide-heading-3 size-5 block" />
</Button>
)}
{items.horizontalRule && (
<Button
pressed={items.horizontalRule.isActive}
disabled={!items.horizontalRule.canExec}
onClick={items.horizontalRule.command}
tooltip="Divider"
>
<div className="i-lucide-minus size-5 block"></div>
</Button>
)}
{items.blockquote && (
<Button
pressed={items.blockquote.isActive}
disabled={!items.blockquote.canExec}
onClick={items.blockquote.command}
tooltip="Blockquote"
>
<div className="i-lucide-text-quote size-5 block" />
</Button>
)}
{items.bulletList && (
<Button
pressed={items.bulletList.isActive}
disabled={!items.bulletList.canExec}
onClick={items.bulletList.command}
tooltip="Bullet List"
>
<div className="i-lucide-list size-5 block" />
</Button>
)}
{items.orderedList && (
<Button
pressed={items.orderedList.isActive}
disabled={!items.orderedList.canExec}
onClick={items.orderedList.command}
tooltip="Ordered List"
>
<div className="i-lucide-list-ordered size-5 block" />
</Button>
)}
{items.taskList && (
<Button
pressed={items.taskList.isActive}
disabled={!items.taskList.canExec}
onClick={items.taskList.command}
tooltip="Task List"
>
<div className="i-lucide-list-checks size-5 block" />
</Button>
)}
{items.toggleList && (
<Button
pressed={items.toggleList.isActive}
disabled={!items.toggleList.canExec}
onClick={items.toggleList.command}
tooltip="Toggle List"
>
<div className="i-lucide-list-collapse size-5 block" />
</Button>
)}
{items.indentList && (
<Button
pressed={items.indentList.isActive}
disabled={!items.indentList.canExec}
onClick={items.indentList.command}
tooltip="Increase indentation"
>
<div className="i-lucide-indent-increase size-5 block" />
</Button>
)}
{items.dedentList && (
<Button
pressed={items.dedentList.isActive}
disabled={!items.dedentList.canExec}
onClick={items.dedentList.command}
tooltip="Decrease indentation"
>
<div className="i-lucide-indent-decrease size-5 block" />
</Button>
)}
{props.uploader && items.insertImage && (
<ImageUploadPopover
uploader={props.uploader}
disabled={!items.insertImage.canExec}
tooltip="Insert Image"
>
<div className="i-lucide-image size-5 block" />
</ImageUploadPopover>
)}
</div>
)
}export { default as UserMenu } from './user-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/react'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/react/autocomplete'
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}) {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.commands.insertText({ text: ' ' })
}
return (
<AutocompletePopover
regex={/@\w*$/}
className="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
onQueryChange={props.onQueryChange}
onOpenChange={props.onOpenChange}
>
<AutocompleteList>
<AutocompleteEmpty className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{props.users.map((user) => (
<AutocompleteItem
key={user.id}
className="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span className={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
))}
</AutocompleteList>
</AutocompletePopover>
)
}- examples/full/editor.tsx
- examples/full/extension.ts
- examples/full/index.ts
- sample/sample-doc-full.ts
- sample/sample-uploader.ts
- sample/tag-data.ts
- sample/user-data.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/index.ts
- ui/code-block-view/code-block-view.tsx
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.tsx
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.tsx
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.tsx
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.tsx
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.tsx
- ui/slash-menu/slash-menu-item.tsx
- ui/slash-menu/slash-menu.tsx
- ui/table-handle/index.ts
- ui/table-handle/table-handle.tsx
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.tsx
- ui/toolbar/index.ts
- ui/toolbar/toolbar.tsx
- ui/user-menu/index.ts
- ui/user-menu/user-menu.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { defaultContent } from '../../sample/sample-doc-full'
import { sampleUploader } from '../../sample/sample-uploader'
import { tags } from '../../sample/tag-data'
import { users } from '../../sample/user-data'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { InlineMenu } from '../../ui/inline-menu'
import { SlashMenu } from '../../ui/slash-menu'
import { TableHandle } from '../../ui/table-handle'
import { TagMenu } from '../../ui/tag-menu'
import { Toolbar } from '../../ui/toolbar'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
export default function Editor(): JSX.Element {
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
return (
<ProseKit editor={editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white">
<Toolbar uploader={sampleUploader} />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ref={editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu users={users} />
<TagMenu tags={tags} />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineCodeBlockView } from '../../ui/code-block-view'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Bullet points that guide thoughts' }] },
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] },
],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* 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 by the server after 1 hour.
*/
export const sampleUploader: 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({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.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 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' },
]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 {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopover,
} from 'prosekit/solid/block-handle'
import type { JSX } from 'solid-js'
export default function BlockHandle(): JSX.Element {
return (
<BlockHandlePopover class="flex items-center flex-row box-border justify-center transition border-0 [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200">
<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-sm text-gray-500/50 dark:text-gray-500/50 cursor-pointer">
<div class="i-lucide-plus size-5 block" />
</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-sm text-gray-500/50 dark:text-gray-500/50 cursor-grab">
<div class="i-lucide-grip-vertical size-5 block" />
</BlockHandleDraggable>
</BlockHandlePopover>
)
}export { default as BlockHandle } from './block-handle'import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/solid/tooltip'
import type { JSX } from 'solid-js'
export default function Button(props: {
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
children: JSX.Element
}): JSX.Element {
return (
<TooltipRoot>
<TooltipTrigger class="block">
<button
data-state={props.pressed ? 'on' : 'off'}
disabled={props.disabled}
onClick={props.onClick}
onMouseDown={(event) => {
// Prevent the editor from being blurred when the button is clicked
event.preventDefault()
}}
class="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
>
{props.children}
{props.tooltip ? <span class="sr-only">{props.tooltip}</span> : null}
</button>
</TooltipTrigger>
{props.tooltip
? (
<TooltipContent class="z-50 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{props.tooltip}
</TooltipContent>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { SolidNodeViewProps } from 'prosekit/solid'
import {
For,
type JSX,
} from 'solid-js'
export default function CodeBlockView(props: SolidNodeViewProps): JSX.Element {
const attrs = () => props.node.attrs as CodeBlockAttrs
const language = () => attrs().language
const setLanguage = (lang: string) => {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
return (
<>
<div class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable={false}>
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
onChange={(event) => setLanguage(event.target.value)}
value={language() || ''}
>
<option value="">Plain Text</option>
<For each={shikiBundledLanguagesInfo}>
{(info) => (
<option value={info.id}>
{info.name}
</option>
)}
</For>
</select>
</div>
<pre ref={props.contentRef} data-language={language()}></pre>
</>
)
}import type { Extension } from 'prosekit/core'
import {
defineSolidNodeView,
type SolidNodeViewComponent,
} from 'prosekit/solid'
import CodeBlockView from './code-block-view'
export function defineCodeBlockView(): Extension {
return defineSolidNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView satisfies SolidNodeViewComponent,
})
}import { DropIndicator as BaseDropIndicator } from 'prosekit/solid/drop-indicator'
import type { JSX } from 'solid-js'
export default function DropIndicator(): JSX.Element {
return <BaseDropIndicator class="z-50 transition-all bg-blue-500" />
}export { default as DropIndicator } from './drop-indicator'import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/solid'
import {
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from 'prosekit/solid/popover'
import {
createSignal,
createUniqueId,
Show,
type JSX,
} from 'solid-js'
import { Button } from '../button'
export default function ImageUploadPopover(props: {
uploader: Uploader<string>
tooltip: string
disabled: boolean
children: JSX.Element
}): JSX.Element {
const [open, setOpen] = createSignal(false)
const [url, setUrl] = createSignal('')
const [file, setFile] = createSignal<File | null>(null)
const ariaId = createUniqueId()
const editor = useEditor<ImageExtension>()
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const selectedFile = target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
setUrl('')
} else {
setFile(null)
}
}
const handleUrlChange = (event: Event) => {
const target = event.target as HTMLInputElement
const inputUrl = target.value
if (inputUrl) {
setUrl(inputUrl)
setFile(null)
} else {
setUrl('')
}
}
const deferResetState = () => {
setTimeout(() => {
setUrl('')
setFile(null)
}, 300)
}
const handleSubmit = () => {
if (url()) {
editor().commands.insertImage({ src: url() })
} else if (file()) {
editor().commands.uploadImage({ file: file()!, uploader: props.uploader })
}
setOpen(false)
deferResetState()
}
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
deferResetState()
}
setOpen(isOpen)
}
return (
<PopoverRoot open={open()} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open()} disabled={props.disabled} tooltip={props.tooltip}>
{props.children}
</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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
<Show when={!file()}>
<label for={`id-link-${ariaId}`}>Embed Link</label>
<input
id={`id-link-${ariaId}`}
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Paste the image link..."
type="url"
value={url()}
onInput={handleUrlChange}
/>
</Show>
<Show when={!url()}>
<label for={`id-upload-${ariaId}`}>Upload</label>
<input
id={`id-upload-${ariaId}`}
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
accept="image/*"
type="file"
onChange={handleFileChange}
/>
</Show>
<Show when={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-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" onClick={handleSubmit}>
Insert Image
</button>
</Show>
<Show when={file()}>
<button class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" onClick={handleSubmit}>
Upload Image
</button>
</Show>
</PopoverContent>
</PopoverRoot>
)
}export { default as ImageUploadPopover } from './image-upload-popover'import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { SolidNodeViewProps } from 'prosekit/solid'
import {
ResizableHandle,
ResizableRoot,
} from 'prosekit/solid/resizable'
import {
createEffect,
createSignal,
onCleanup,
Show,
type JSX,
} from 'solid-js'
export default function ImageView(props: SolidNodeViewProps): JSX.Element {
const attrs = () => props.node.attrs as ImageAttrs
const url = () => attrs().src || ''
const uploading = () => url().startsWith('blob:')
const [aspectRatio, setAspectRatio] = createSignal<number | undefined>()
const [error, setError] = createSignal<string | undefined>()
const [progress, setProgress] = createSignal(0)
createEffect(() => {
if (!uploading()) return
const uploadTask = UploadTask.get<string>(url())
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
setError(String(err))
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
setProgress(total ? loaded / total : 0)
})
onCleanup(() => {
canceled = true
unsubscribeProgress()
})
})
const handleImageLoad = (event: Event) => {
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)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs().width ?? undefined}
height={attrs().height ?? undefined}
aspectRatio={aspectRatio()}
onResizeEnd={(event) => props.setAttrs(event.detail)}
attr:data-selected={props.selected ? '' : 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"
>
<Show when={url() && !error()}>
<img
src={url()}
onLoad={handleImageLoad}
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
/>
</Show>
<Show when={uploading() && !error()}>
<div class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress() * 100)}%</div>
</div>
</Show>
<Show when={error()}>
<div 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 size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
</Show>
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm 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 size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
)
}import type { Extension } from 'prosekit/core'
import {
defineSolidNodeView,
type SolidNodeViewComponent,
} from 'prosekit/solid'
import ImageView from './image-view'
export function defineImageView(): Extension {
return defineSolidNodeView({
name: 'image',
component: ImageView satisfies SolidNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import {
useEditor,
useEditorDerivedValue,
} from 'prosekit/solid'
import { InlinePopover } from 'prosekit/solid/inline-popover'
import {
createSignal,
Show,
type JSX,
} from 'solid-js'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
export default function InlineMenu(): JSX.Element {
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const [linkMenuOpen, setLinkMenuOpen] = createSignal(false)
const toggleLinkMenuOpen = () => setLinkMenuOpen((open) => !open)
const handleLinkUpdate = (href?: string) => {
if (href) {
editor().commands.addLink({ href })
} else {
editor().commands.removeLink()
}
setLinkMenuOpen(false)
editor().focus()
}
return (
<>
<InlinePopover
attr:data-testid="inline-menu-main"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
onOpenChange={(open) => {
if (!open) {
setLinkMenuOpen(false)
}
}}
>
<Show when={items().bold}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().italic}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().underline}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().strike}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Strikethrough"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().code}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().link?.canExec && items().link}>
{(item) => (
<Button
pressed={item().isActive}
onClick={() => {
item().command()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
)}
</Show>
</InlinePopover>
<Show when={items().link}>
{(item) => (
<InlinePopover
placement={'bottom'}
defaultOpen={false}
open={linkMenuOpen()}
onOpenChange={setLinkMenuOpen}
attr: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"
>
<Show when={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..."
value={item().currentLink || ''}
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
</input>
</form>
</Show>
<Show when={item().isActive}>
<button
onClick={() => handleLinkUpdate()}
onMouseDown={(event) => event.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-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
>
Remove link
</button>
</Show>
</InlinePopover>
)}
</Show>
</>
)
}export { default as SlashMenu } from './slash-menu'import { AutocompleteEmpty } from 'prosekit/solid/autocomplete'
import type { JSX } from 'solid-js'
export default function SlashMenuEmpty(): JSX.Element {
return (
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty>
)
}import { AutocompleteItem } from 'prosekit/solid/autocomplete'
import {
Show,
type JSX,
} from 'solid-js'
export default function SlashMenuItem(props: {
label: string
kbd?: string
onSelect: () => void
}): JSX.Element {
return (
<AutocompleteItem onSelect={props.onSelect} class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
<span>{props.label}</span>
<Show when={props.kbd}>
<kbd class="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>
</Show>
</AutocompleteItem>
)
}import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/solid'
import {
AutocompleteList,
AutocompletePopover,
} from 'prosekit/solid/autocomplete'
import type { JSX } from 'solid-js'
import SlashMenuEmpty from './slash-menu-empty'
import SlashMenuItem from './slash-menu-item'
export default function SlashMenu(): JSX.Element {
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(|\S.*)$/u : /\/(|\S.*)$/u
return (
<AutocompletePopover regex={regex} class="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden">
<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 { default as TableHandle } from './table-handle'import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/solid'
import {
TableHandleColumnRoot,
TableHandleColumnTrigger,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandlePopoverContent,
TableHandlePopoverItem,
TableHandleRoot,
TableHandleRowRoot,
TableHandleRowTrigger,
} from 'prosekit/solid/table-handle'
import {
Show,
type JSX,
} from 'solid-js'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
export default function TableHandle(): JSX.Element {
const state = useEditorDerivedValue(getTableHandleState)
return (
<TableHandleRoot class="contents">
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnRoot class="h-[1.2em] w-[1.5em] translate-y-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleColumnTrigger class="flex items-center justify-center">
<div class="i-lucide-grip-horizontal size-5 block"></div>
</TableHandleColumnTrigger>
<TableHandlePopoverContent class="relative block max-h-100 min-w-32 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">
<Show when={state().addTableColumnBefore.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => state().addTableColumnBefore.command()}
>
<span>Insert Left</span>
</TableHandlePopoverItem>
</Show>
<Show when={state().addTableColumnAfter.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => state().addTableColumnAfter.command()}
>
<span>Insert Right</span>
</TableHandlePopoverItem>
</Show>
<Show when={state().deleteCellSelection.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => state().deleteCellSelection.command()}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
</Show>
<Show when={state().deleteTableColumn.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => state().deleteTableColumn.command()}
>
<span>Delete Column</span>
</TableHandlePopoverItem>
</Show>
<Show when={state().deleteTable.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger=""
onSelect={() => state().deleteTable.command()}
>
<span>Delete Table</span>
</TableHandlePopoverItem>
</Show>
</TableHandlePopoverContent>
</TableHandleColumnRoot>
<TableHandleRowRoot class="h-[1.5em] w-[1.2em] translate-x-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleRowTrigger class="flex items-center justify-center">
<div class="i-lucide-grip-vertical size-5 block"></div>
</TableHandleRowTrigger>
<TableHandlePopoverContent class="relative block max-h-100 min-w-32 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">
<Show when={state().addTableRowAbove.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => state().addTableRowAbove.command()}
>
<span>Insert Above</span>
</TableHandlePopoverItem>
</Show>
<Show when={state().addTableRowBelow.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => state().addTableRowBelow.command()}
>
<span>Insert Below</span>
</TableHandlePopoverItem>
</Show>
<Show when={state().deleteCellSelection.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => state().deleteCellSelection.command()}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
</Show>
<Show when={state().deleteTableRow.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => state().deleteTableRow.command()}
>
<span>Delete Row</span>
</TableHandlePopoverItem>
</Show>
<Show when={state().deleteTable.canExec}>
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger=""
onSelect={() => state().deleteTable.command()}
>
<span>Delete Table</span>
</TableHandlePopoverItem>
</Show>
</TableHandlePopoverContent>
</TableHandleRowRoot>
</TableHandleRoot>
)
}export { default as TagMenu } from './tag-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/solid'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/solid/autocomplete'
import {
For,
type JSX,
} from 'solid-js'
export default function TagMenu(props: { tags: { id: number; label: string }[] }): JSX.Element {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
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}
class="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
>
<AutocompleteList>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
No results
</AutocompleteEmpty>
<For each={props.tags}>
{(tag) => (
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
)}
</For>
</AutocompleteList>
</AutocompletePopover>
)
}export { default as Toolbar } from './toolbar'import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { Uploader } from 'prosekit/extensions/file'
import { useEditorDerivedValue } from 'prosekit/solid'
import {
Show,
type JSX,
} from 'solid-js'
import { Button } from '../button'
import { ImageUploadPopover } from '../image-upload-popover'
function getToolbarItems(editor: Editor<BasicExtension>) {
return {
undo: editor.commands.undo
? {
isActive: false,
canExec: editor.commands.undo.canExec(),
command: () => editor.commands.undo(),
}
: undefined,
redo: editor.commands.redo
? {
isActive: false,
canExec: editor.commands.redo.canExec(),
command: () => editor.commands.redo(),
}
: undefined,
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
codeBlock: editor.commands.insertCodeBlock
? {
isActive: editor.nodes.codeBlock.isActive(),
canExec: editor.commands.insertCodeBlock.canExec({ language: 'javascript' }),
command: () => editor.commands.insertCodeBlock({ language: 'javascript' }),
}
: undefined,
heading1: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 1 }),
canExec: editor.commands.toggleHeading.canExec({ level: 1 }),
command: () => editor.commands.toggleHeading({ level: 1 }),
}
: undefined,
heading2: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 2 }),
canExec: editor.commands.toggleHeading.canExec({ level: 2 }),
command: () => editor.commands.toggleHeading({ level: 2 }),
}
: undefined,
heading3: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 3 }),
canExec: editor.commands.toggleHeading.canExec({ level: 3 }),
command: () => editor.commands.toggleHeading({ level: 3 }),
}
: undefined,
horizontalRule: editor.commands.insertHorizontalRule
? {
isActive: editor.nodes.horizontalRule.isActive(),
canExec: editor.commands.insertHorizontalRule.canExec(),
command: () => editor.commands.insertHorizontalRule(),
}
: undefined,
blockquote: editor.commands.toggleBlockquote
? {
isActive: editor.nodes.blockquote.isActive(),
canExec: editor.commands.toggleBlockquote.canExec(),
command: () => editor.commands.toggleBlockquote(),
}
: undefined,
bulletList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'bullet' }),
canExec: editor.commands.toggleList.canExec({ kind: 'bullet' }),
command: () => editor.commands.toggleList({ kind: 'bullet' }),
}
: undefined,
orderedList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'ordered' }),
canExec: editor.commands.toggleList.canExec({ kind: 'ordered' }),
command: () => editor.commands.toggleList({ kind: 'ordered' }),
}
: undefined,
taskList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'task' }),
canExec: editor.commands.toggleList.canExec({ kind: 'task' }),
command: () => editor.commands.toggleList({ kind: 'task' }),
}
: undefined,
toggleList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'toggle' }),
canExec: editor.commands.toggleList.canExec({ kind: 'toggle' }),
command: () => editor.commands.toggleList({ kind: 'toggle' }),
}
: undefined,
indentList: editor.commands.indentList
? {
isActive: false,
canExec: editor.commands.indentList.canExec(),
command: () => editor.commands.indentList(),
}
: undefined,
dedentList: editor.commands.dedentList
? {
isActive: false,
canExec: editor.commands.dedentList.canExec(),
command: () => editor.commands.dedentList(),
}
: undefined,
insertImage: editor.commands.insertImage
? {
isActive: false,
canExec: editor.commands.insertImage.canExec(),
}
: undefined,
}
}
export default function Toolbar(props: { uploader?: Uploader<string> }): JSX.Element {
const items = useEditorDerivedValue(getToolbarItems)
return (
<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">
<Show when={items().undo}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Undo"
>
<div class="i-lucide-undo-2 size-5 block" />
</Button>
)}
</Show>
<Show when={items().redo}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Redo"
>
<div class="i-lucide-redo-2 size-5 block" />
</Button>
)}
</Show>
<Show when={items().bold}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block" />
</Button>
)}
</Show>
<Show when={items().italic}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block" />
</Button>
)}
</Show>
<Show when={items().underline}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block" />
</Button>
)}
</Show>
<Show when={items().strike}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Strike"
>
<div class="i-lucide-strikethrough size-5 block" />
</Button>
)}
</Show>
<Show when={items().code}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block" />
</Button>
)}
</Show>
<Show when={items().codeBlock}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Code Block"
>
<div class="i-lucide-square-code size-5 block" />
</Button>
)}
</Show>
<Show when={items().heading1}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Heading 1"
>
<div class="i-lucide-heading-1 size-5 block" />
</Button>
)}
</Show>
<Show when={items().heading2}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Heading 2"
>
<div class="i-lucide-heading-2 size-5 block" />
</Button>
)}
</Show>
<Show when={items().heading3}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Heading 3"
>
<div class="i-lucide-heading-3 size-5 block" />
</Button>
)}
</Show>
<Show when={items().horizontalRule}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Divider"
>
<div class="i-lucide-minus size-5 block"></div>
</Button>
)}
</Show>
<Show when={items().blockquote}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Blockquote"
>
<div class="i-lucide-text-quote size-5 block" />
</Button>
)}
</Show>
<Show when={items().bulletList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Bullet List"
>
<div class="i-lucide-list size-5 block" />
</Button>
)}
</Show>
<Show when={items().orderedList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Ordered List"
>
<div class="i-lucide-list-ordered size-5 block" />
</Button>
)}
</Show>
<Show when={items().taskList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Task List"
>
<div class="i-lucide-list-checks size-5 block" />
</Button>
)}
</Show>
<Show when={items().toggleList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Toggle List"
>
<div class="i-lucide-list-collapse size-5 block" />
</Button>
)}
</Show>
<Show when={items().indentList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Increase indentation"
>
<div class="i-lucide-indent-increase size-5 block" />
</Button>
)}
</Show>
<Show when={items().dedentList}>
{(item) => (
<Button
pressed={item().isActive}
disabled={!item().canExec}
onClick={item().command}
tooltip="Decrease indentation"
>
<div class="i-lucide-indent-decrease size-5 block" />
</Button>
)}
</Show>
<Show when={props.uploader && items().insertImage}>
{(item) => (
<ImageUploadPopover
uploader={props.uploader!}
disabled={!item().canExec}
tooltip="Insert Image"
>
<div class="i-lucide-image size-5 block" />
</ImageUploadPopover>
)}
</Show>
</div>
)
}export { default as UserMenu } from './user-menu'import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/solid'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/solid/autocomplete'
import {
For,
type JSX,
} from 'solid-js'
export default function UserMenu(props: {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}): JSX.Element {
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
const handleUserInsert = (id: number, username: string) => {
editor().commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor().commands.insertText({ text: ' ' })
}
return (
<AutocompletePopover
regex={/@\w*$/}
class="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
onQueryChange={props.onQueryChange}
onOpenChange={props.onOpenChange}
>
<AutocompleteList>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{props.loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
<For each={props.users}>
{(user) => (
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
<span class={props.loading ? 'opacity-50' : undefined}>
{user.name}
</span>
</AutocompleteItem>
)}
</For>
</AutocompleteList>
</AutocompletePopover>
)
}- examples/full/editor.svelte
- examples/full/extension.ts
- examples/full/index.ts
- sample/sample-doc-full.ts
- sample/sample-uploader.ts
- sample/tag-data.ts
- sample/user-data.ts
- ui/block-handle/block-handle.svelte
- ui/block-handle/index.ts
- ui/button/button.svelte
- ui/button/index.ts
- ui/code-block-view/code-block-view.svelte
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.svelte
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.svelte
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.svelte
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.svelte
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.svelte
- ui/slash-menu/slash-menu-item.svelte
- ui/slash-menu/slash-menu.svelte
- ui/table-handle/index.ts
- ui/table-handle/table-handle.svelte
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.svelte
- ui/toolbar/index.ts
- ui/toolbar/toolbar.svelte
- ui/user-menu/index.ts
- ui/user-menu/user-menu.svelte
<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { defaultContent } from '../../sample/sample-doc-full'
import { sampleUploader } from '../../sample/sample-uploader'
import { tags } from '../../sample/tag-data'
import { users } from '../../sample/user-data'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { InlineMenu } from '../../ui/inline-menu'
import { SlashMenu } from '../../ui/slash-menu'
import { TableHandle } from '../../ui/table-handle'
import { TagMenu } from '../../ui/tag-menu'
import { Toolbar } from '../../ui/toolbar'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
const mount = (element: HTMLElement) => {
editor.mount(element)
return { destroy: () => editor.unmount() }
}
</script>
<ProseKit {editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white">
<Toolbar uploader={sampleUploader} />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div use:mount class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu users={users} />
<TagMenu tags={tags} />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineCodeBlockView } from '../../ui/code-block-view'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Bullet points that guide thoughts' }] },
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] },
],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* 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 by the server after 1 hour.
*/
export const sampleUploader: 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({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.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 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' },
]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 {
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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200">
<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-sm text-gray-500/50 dark:text-gray-500/50 cursor-pointer">
<div class="i-lucide-plus size-5 block"></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-sm text-gray-500/50 dark:text-gray-500/50 cursor-grab">
<div class="i-lucide-grip-vertical size-5 block"></div>
</BlockHandleDraggable>
</BlockHandlePopover>export { default as BlockHandle } from './block-handle.svelte'<script lang="ts">
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/svelte/tooltip'
interface Props {
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
children?: import('svelte').Snippet
}
const props: Props = $props()
const pressed = $derived(props.pressed ?? false)
const disabled = $derived(props.disabled ?? false)
</script>
<TooltipRoot>
<TooltipTrigger class="block">
<button
data-state={pressed ? 'on' : 'off'}
{disabled}
class="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
onclick={props.onClick}
onmousedown={(e) => e.preventDefault()}
>
{@render props.children?.()}
{#if props.tooltip}
<span class="sr-only">{props.tooltip}</span>
{/if}
</button>
</TooltipTrigger>
{#if props.tooltip}
<TooltipContent class="z-50 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{props.tooltip}
</TooltipContent>
{/if}
</TooltipRoot>export { default as Button } from './button.svelte'<script lang="ts">
import type { CodeBlockAttrs } from 'prosekit/extensions/code-block'
import { shikiBundledLanguagesInfo } from 'prosekit/extensions/code-block'
import type { SvelteNodeViewProps } from 'prosekit/svelte'
interface Props extends SvelteNodeViewProps {}
const props: Props = $props()
const node = props.node
const attrs = $derived($node.attrs as CodeBlockAttrs)
function setLanguage(lang: string) {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
function bindContentRef(element: HTMLPreElement) {
props.contentRef(element)
}
</script>
<div class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable="false">
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
value={attrs.language || ''}
onchange={(event) => setLanguage((event.target as HTMLSelectElement).value)}
>
<option value="">Plain Text</option>
{#each shikiBundledLanguagesInfo as info (info.id)}
<option value={info.id}>
{info.name}
</option>
{/each}
</select>
</div>
<pre use:bindContentRef data-language={attrs.language}></pre>import type { Extension } from 'prosekit/core'
import {
defineSvelteNodeView,
type SvelteNodeViewComponent,
} from 'prosekit/svelte'
import CodeBlockView from './code-block-view.svelte'
export function defineCodeBlockView(): Extension {
return defineSvelteNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView as SvelteNodeViewComponent,
})
}<script lang="ts">
import { DropIndicator as BaseDropIndicator } from 'prosekit/svelte/drop-indicator'
</script>
<BaseDropIndicator class="z-50 transition-all bg-blue-500" />export { default as DropIndicator } from './drop-indicator.svelte'<script lang="ts">
import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/svelte'
import {
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from 'prosekit/svelte/popover'
import { Button } from '../button'
interface Props {
uploader: Uploader<string>
tooltip: string
disabled: boolean
children?: import('svelte').Snippet
}
const props: Props = $props()
let open = $state(false)
let url = $state('')
let file = $state<File | null>(null)
const ariaId = $props.id()
const editor = useEditor<ImageExtension>()
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement
const selectedFile = target.files?.[0]
if (selectedFile) {
file = selectedFile
url = ''
} else {
file = null
}
}
function handleUrlChange(event: Event) {
const target = event.target as HTMLInputElement
const inputUrl = target.value
if (inputUrl) {
url = inputUrl
file = null
} else {
url = ''
}
}
function deferResetState() {
setTimeout(() => {
url = ''
file = null
}, 300)
}
function handleSubmit() {
if (url) {
$editor.commands.insertImage({ src: url })
} else if (file) {
$editor.commands.uploadImage({ file, uploader: props.uploader })
}
open = false
deferResetState()
}
function handleOpenChange(isOpen: boolean) {
if (!isOpen) {
deferResetState()
}
open = isOpen
}
</script>
<PopoverRoot {open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open} disabled={props.disabled} tooltip={props.tooltip}>
{@render props.children?.()}
</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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{#if !file}
<label for="id-link-{ariaId}">Embed Link</label>
<input
id="id-link-{ariaId}"
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Paste the image link..."
type="url"
value={url}
oninput={handleUrlChange}
/>
{/if}
{#if !url}
<label for="id-upload-{ariaId}">Upload</label>
<input
id="id-upload-{ariaId}"
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
accept="image/*"
type="file"
onchange={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-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" onclick={handleSubmit}>
Insert Image
</button>
{/if}
{#if file}
<button class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" onclick={handleSubmit}>
Upload Image
</button>
{/if}
</PopoverContent>
</PopoverRoot>export { default as ImageUploadPopover } from './image-upload-popover.svelte'<script lang="ts">
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { SvelteNodeViewProps } from 'prosekit/svelte'
import {
ResizableHandle,
ResizableRoot,
} from 'prosekit/svelte/resizable'
import { onDestroy } from 'svelte'
interface Props extends SvelteNodeViewProps {}
const props: Props = $props()
const node = props.node
const selected = props.selected
const attrs = $derived($node.attrs as ImageAttrs)
const url = $derived(attrs.src || '')
const uploading = $derived(url.startsWith('blob:'))
let aspectRatio = $state<number | undefined>(undefined)
let error = $state<string | undefined>(undefined)
let progress = $state(0)
let unsubscribeProgress: (() => void) | undefined
$effect(() => {
if (!uploading) {
unsubscribeProgress?.()
return
}
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
error = String(err)
})
unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
progress = total ? loaded / total : 0
})
onDestroy(() => {
canceled = true
unsubscribeProgress?.()
})
})
function handleImageLoad(event: Event) {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
aspectRatio = ratio
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
props.setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
</script>
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
{aspectRatio}
data-selected={$selected ? '' : 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"
onResizeEnd={(event) => props.setAttrs(event.detail)}
>
{#if url && !error}
<img
src={url}
alt="upload preview"
class="h-full w-full max-w-full max-h-full object-contain"
onload={handleImageLoad}
/>
{/if}
{#if uploading && !error}
<div class="absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></div>
<div>{Math.round(progress * 100)}%</div>
</div>
{/if}
{#if error}
<div 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 size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
{/if}
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm 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 size-4 block"></div>
</ResizableHandle>
</ResizableRoot>import type { Extension } from 'prosekit/core'
import {
defineSvelteNodeView,
type SvelteNodeViewComponent,
} from 'prosekit/svelte'
import ImageView from './image-view.svelte'
export function defineImageView(): Extension {
return defineSvelteNodeView({
name: 'image',
component: ImageView as SvelteNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import {
useEditor,
useEditorDerivedValue,
} from 'prosekit/svelte'
import { InlinePopover } from 'prosekit/svelte/inline-popover'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const from = state.selection.$from
const marks = from.marksAcross(from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
let linkMenuOpen = $state(false)
function toggleLinkMenuOpen() {
linkMenuOpen = !linkMenuOpen
}
function handleLinkUpdate(href?: string) {
if (href) {
$editor.commands.addLink({ href })
} else {
$editor.commands.removeLink()
}
linkMenuOpen = false
$editor.focus()
}
</script>
<InlinePopover
data-testid="inline-menu-main"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
onOpenChange={(open) => {
if (!open) linkMenuOpen = false
}}
>
{#if $items.bold}
<Button
pressed={$items.bold.isActive}
disabled={!$items.bold.canExec}
onClick={$items.bold.command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
{/if}
{#if $items.italic}
<Button
pressed={$items.italic.isActive}
disabled={!$items.italic.canExec}
onClick={$items.italic.command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
{/if}
{#if $items.underline}
<Button
pressed={$items.underline.isActive}
disabled={!$items.underline.canExec}
onClick={$items.underline.command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
{/if}
{#if $items.strike}
<Button
pressed={$items.strike.isActive}
disabled={!$items.strike.canExec}
onClick={$items.strike.command}
tooltip="Strikethrough"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
{/if}
{#if $items.code}
<Button
pressed={$items.code.isActive}
disabled={!$items.code.canExec}
onClick={$items.code.command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
{/if}
{#if $items.link?.canExec && $items.link}
<Button
pressed={$items.link.isActive}
onClick={() => {
$items.link!.command()
toggleLinkMenuOpen()
}}
tooltip="Link"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
{/if}
</InlinePopover>
<InlinePopover
placement="bottom"
defaultOpen={false}
open={linkMenuOpen}
data-testid="inline-menu-link"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
onOpenChange={(open) => {
linkMenuOpen = open
}}
>
{#if linkMenuOpen && $items.link}
<form
onsubmit={(event) => {
event.preventDefault()
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}}
>
<input
placeholder="Paste the link..."
value={$items.link.currentLink || ''}
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
/>
</form>
{/if}
{#if $items.link?.isActive}
<button
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
onclick={() => handleLinkUpdate()}
onmousedown={(e) => e.preventDefault()}
>
Remove link
</button>
{/if}
</InlinePopover>export { default as SlashMenu } from './slash-menu.svelte'<script lang="ts">
import { AutocompleteEmpty } from 'prosekit/svelte/autocomplete'
</script>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
<span>No results</span>
</AutocompleteEmpty><script lang="ts">
import { AutocompleteItem } from 'prosekit/svelte/autocomplete'
interface Props {
label: string
kbd?: string
onSelect: () => void
}
const props: Props = $props()
</script>
<AutocompleteItem class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800" onSelect={props.onSelect}>
<span>{props.label}</span>
{#if props.kbd}
<kbd class="text-xs font-mono text-gray-400 dark:text-gray-500">{props.kbd}</kbd>
{/if}
</AutocompleteItem><script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/svelte'
import {
AutocompleteList,
AutocompletePopover,
} from 'prosekit/svelte/autocomplete'
import SlashMenuEmpty from './slash-menu-empty.svelte'
import SlashMenuItem from './slash-menu-item.svelte'
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(|\S.*)$/u : /\/(|\S.*)$/u
</script>
<AutocompletePopover {regex} class="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden">
<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 { default as TableHandle } from './table-handle.svelte'<script lang="ts">
import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/svelte'
import {
TableHandleColumnRoot,
TableHandleColumnTrigger,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandlePopoverContent,
TableHandlePopoverItem,
TableHandleRoot,
TableHandleRowRoot,
TableHandleRowTrigger,
} from 'prosekit/svelte/table-handle'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
const state = useEditorDerivedValue(getTableHandleState)
</script>
<TableHandleRoot class="contents">
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnRoot class="h-[1.2em] w-[1.5em] translate-y-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleColumnTrigger class="flex items-center justify-center">
<div class="i-lucide-grip-horizontal size-5 block"></div>
</TableHandleColumnTrigger>
<TableHandlePopoverContent class="relative block max-h-100 min-w-32 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">
{#if $state.addTableColumnBefore.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={$state.addTableColumnBefore.command}
>
<span>Insert Left</span>
</TableHandlePopoverItem>
{/if}
{#if $state.addTableColumnAfter.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={$state.addTableColumnAfter.command}
>
<span>Insert Right</span>
</TableHandlePopoverItem>
{/if}
{#if $state.deleteCellSelection.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={$state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
{/if}
{#if $state.deleteTableColumn.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={$state.deleteTableColumn.command}
>
<span>Delete Column</span>
</TableHandlePopoverItem>
{/if}
{#if $state.deleteTable.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger=""
onSelect={$state.deleteTable.command}
>
<span>Delete Table</span>
</TableHandlePopoverItem>
{/if}
</TableHandlePopoverContent>
</TableHandleColumnRoot>
<TableHandleRowRoot class="h-[1.5em] w-[1.2em] translate-x-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleRowTrigger class="flex items-center justify-center">
<div class="i-lucide-grip-vertical size-5 block"></div>
</TableHandleRowTrigger>
<TableHandlePopoverContent class="relative block max-h-100 min-w-32 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">
{#if $state.addTableRowAbove.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={$state.addTableRowAbove.command}
>
<span>Insert Above</span>
</TableHandlePopoverItem>
{/if}
{#if $state.addTableRowBelow.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={$state.addTableRowBelow.command}
>
<span>Insert Below</span>
</TableHandlePopoverItem>
{/if}
{#if $state.deleteCellSelection.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={$state.deleteCellSelection.command}
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
{/if}
{#if $state.deleteTableRow.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={$state.deleteTableRow.command}
>
<span>Delete Row</span>
</TableHandlePopoverItem>
{/if}
{#if $state.deleteTable.canExec}
<TableHandlePopoverItem
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger=""
onSelect={$state.deleteTable.command}
>
<span>Delete Table</span>
</TableHandlePopoverItem>
{/if}
</TableHandlePopoverContent>
</TableHandleRowRoot>
</TableHandleRoot>export { default as TagMenu } from './tag-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/svelte'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/svelte/autocomplete'
interface Props {
tags: { id: number; label: string }[]
}
const props: Props = $props()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function 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-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
>
<AutocompleteList>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
No results
</AutocompleteEmpty>
{#each props.tags as tag (tag.id)}
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleTagInsert(tag.id, tag.label)}
>
#{tag.label}
</AutocompleteItem>
{/each}
</AutocompleteList>
</AutocompletePopover>export { default as Toolbar } from './toolbar.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { Uploader } from 'prosekit/extensions/file'
import { useEditorDerivedValue } from 'prosekit/svelte'
import { Button } from '../button'
import { ImageUploadPopover } from '../image-upload-popover'
interface Props {
uploader?: Uploader<string>
}
const props: Props = $props()
const uploader = $derived(props.uploader)
function getToolbarItems(editor: Editor<BasicExtension>) {
return {
undo: editor.commands.undo
? {
isActive: false,
canExec: editor.commands.undo.canExec(),
command: () => editor.commands.undo(),
}
: undefined,
redo: editor.commands.redo
? {
isActive: false,
canExec: editor.commands.redo.canExec(),
command: () => editor.commands.redo(),
}
: undefined,
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
codeBlock: editor.commands.insertCodeBlock
? {
isActive: editor.nodes.codeBlock.isActive(),
canExec: editor.commands.insertCodeBlock.canExec({ language: 'javascript' }),
command: () => editor.commands.insertCodeBlock({ language: 'javascript' }),
}
: undefined,
heading1: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 1 }),
canExec: editor.commands.toggleHeading.canExec({ level: 1 }),
command: () => editor.commands.toggleHeading({ level: 1 }),
}
: undefined,
heading2: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 2 }),
canExec: editor.commands.toggleHeading.canExec({ level: 2 }),
command: () => editor.commands.toggleHeading({ level: 2 }),
}
: undefined,
heading3: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 3 }),
canExec: editor.commands.toggleHeading.canExec({ level: 3 }),
command: () => editor.commands.toggleHeading({ level: 3 }),
}
: undefined,
horizontalRule: editor.commands.insertHorizontalRule
? {
isActive: editor.nodes.horizontalRule.isActive(),
canExec: editor.commands.insertHorizontalRule.canExec(),
command: () => editor.commands.insertHorizontalRule(),
}
: undefined,
blockquote: editor.commands.toggleBlockquote
? {
isActive: editor.nodes.blockquote.isActive(),
canExec: editor.commands.toggleBlockquote.canExec(),
command: () => editor.commands.toggleBlockquote(),
}
: undefined,
bulletList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'bullet' }),
canExec: editor.commands.toggleList.canExec({ kind: 'bullet' }),
command: () => editor.commands.toggleList({ kind: 'bullet' }),
}
: undefined,
orderedList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'ordered' }),
canExec: editor.commands.toggleList.canExec({ kind: 'ordered' }),
command: () => editor.commands.toggleList({ kind: 'ordered' }),
}
: undefined,
taskList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'task' }),
canExec: editor.commands.toggleList.canExec({ kind: 'task' }),
command: () => editor.commands.toggleList({ kind: 'task' }),
}
: undefined,
toggleList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'toggle' }),
canExec: editor.commands.toggleList.canExec({ kind: 'toggle' }),
command: () => editor.commands.toggleList({ kind: 'toggle' }),
}
: undefined,
indentList: editor.commands.indentList
? {
isActive: false,
canExec: editor.commands.indentList.canExec(),
command: () => editor.commands.indentList(),
}
: undefined,
dedentList: editor.commands.dedentList
? {
isActive: false,
canExec: editor.commands.dedentList.canExec(),
command: () => editor.commands.dedentList(),
}
: undefined,
insertImage: editor.commands.insertImage
? {
isActive: false,
canExec: editor.commands.insertImage.canExec(),
}
: undefined,
}
}
const items = useEditorDerivedValue(getToolbarItems)
</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">
{#if $items.undo}
<Button
pressed={$items.undo.isActive}
disabled={!$items.undo.canExec}
onClick={$items.undo.command}
tooltip="Undo"
>
<div class="i-lucide-undo-2 size-5 block"></div>
</Button>
{/if}
{#if $items.redo}
<Button
pressed={$items.redo.isActive}
disabled={!$items.redo.canExec}
onClick={$items.redo.command}
tooltip="Redo"
>
<div class="i-lucide-redo-2 size-5 block"></div>
</Button>
{/if}
{#if $items.bold}
<Button
pressed={$items.bold.isActive}
disabled={!$items.bold.canExec}
onClick={$items.bold.command}
tooltip="Bold"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
{/if}
{#if $items.italic}
<Button
pressed={$items.italic.isActive}
disabled={!$items.italic.canExec}
onClick={$items.italic.command}
tooltip="Italic"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
{/if}
{#if $items.underline}
<Button
pressed={$items.underline.isActive}
disabled={!$items.underline.canExec}
onClick={$items.underline.command}
tooltip="Underline"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
{/if}
{#if $items.strike}
<Button
pressed={$items.strike.isActive}
disabled={!$items.strike.canExec}
onClick={$items.strike.command}
tooltip="Strike"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
{/if}
{#if $items.code}
<Button
pressed={$items.code.isActive}
disabled={!$items.code.canExec}
onClick={$items.code.command}
tooltip="Code"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
{/if}
{#if $items.codeBlock}
<Button
pressed={$items.codeBlock.isActive}
disabled={!$items.codeBlock.canExec}
onClick={$items.codeBlock.command}
tooltip="Code Block"
>
<div class="i-lucide-square-code size-5 block"></div>
</Button>
{/if}
{#if $items.heading1}
<Button
pressed={$items.heading1.isActive}
disabled={!$items.heading1.canExec}
onClick={$items.heading1.command}
tooltip="Heading 1"
>
<div class="i-lucide-heading-1 size-5 block"></div>
</Button>
{/if}
{#if $items.heading2}
<Button
pressed={$items.heading2.isActive}
disabled={!$items.heading2.canExec}
onClick={$items.heading2.command}
tooltip="Heading 2"
>
<div class="i-lucide-heading-2 size-5 block"></div>
</Button>
{/if}
{#if $items.heading3}
<Button
pressed={$items.heading3.isActive}
disabled={!$items.heading3.canExec}
onClick={$items.heading3.command}
tooltip="Heading 3"
>
<div class="i-lucide-heading-3 size-5 block"></div>
</Button>
{/if}
{#if $items.horizontalRule}
<Button
pressed={$items.horizontalRule.isActive}
disabled={!$items.horizontalRule.canExec}
onClick={$items.horizontalRule.command}
tooltip="Divider"
>
<div class="i-lucide-minus size-5 block"></div>
</Button>
{/if}
{#if $items.blockquote}
<Button
pressed={$items.blockquote.isActive}
disabled={!$items.blockquote.canExec}
onClick={$items.blockquote.command}
tooltip="Blockquote"
>
<div class="i-lucide-text-quote size-5 block"></div>
</Button>
{/if}
{#if $items.bulletList}
<Button
pressed={$items.bulletList.isActive}
disabled={!$items.bulletList.canExec}
onClick={$items.bulletList.command}
tooltip="Bullet List"
>
<div class="i-lucide-list size-5 block"></div>
</Button>
{/if}
{#if $items.orderedList}
<Button
pressed={$items.orderedList.isActive}
disabled={!$items.orderedList.canExec}
onClick={$items.orderedList.command}
tooltip="Ordered List"
>
<div class="i-lucide-list-ordered size-5 block"></div>
</Button>
{/if}
{#if $items.taskList}
<Button
pressed={$items.taskList.isActive}
disabled={!$items.taskList.canExec}
onClick={$items.taskList.command}
tooltip="Task List"
>
<div class="i-lucide-list-checks size-5 block"></div>
</Button>
{/if}
{#if $items.toggleList}
<Button
pressed={$items.toggleList.isActive}
disabled={!$items.toggleList.canExec}
onClick={$items.toggleList.command}
tooltip="Toggle List"
>
<div class="i-lucide-list-collapse size-5 block"></div>
</Button>
{/if}
{#if $items.indentList}
<Button
pressed={$items.indentList.isActive}
disabled={!$items.indentList.canExec}
onClick={$items.indentList.command}
tooltip="Increase indentation"
>
<div class="i-lucide-indent-increase size-5 block"></div>
</Button>
{/if}
{#if $items.dedentList}
<Button
pressed={$items.dedentList.isActive}
disabled={!$items.dedentList.canExec}
onClick={$items.dedentList.command}
tooltip="Decrease indentation"
>
<div class="i-lucide-indent-decrease size-5 block"></div>
</Button>
{/if}
{#if uploader && $items.insertImage}
<ImageUploadPopover
{uploader}
disabled={!$items.insertImage.canExec}
tooltip="Insert Image"
>
<div class="i-lucide-image size-5 block"></div>
</ImageUploadPopover>
{/if}
</div>export { default as UserMenu } from './user-menu.svelte'<script lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/svelte'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/svelte/autocomplete'
interface Props {
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}
const props: Props = $props()
const loading = $derived(props.loading ?? false)
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleUserInsert(id: number, username: string) {
$editor.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
$editor.commands.insertText({ text: ' ' })
}
</script>
<AutocompletePopover
regex={/@\w*$/}
class="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
onQueryChange={props.onQueryChange}
onOpenChange={props.onOpenChange}
>
<AutocompleteList>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{loading ? 'Loading...' : 'No results'}
</AutocompleteEmpty>
{#each props.users as user (user.id)}
<AutocompleteItem
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
onSelect={() => handleUserInsert(user.id, user.name)}
>
{#if loading}
<span class="opacity-50">
{user.name}
</span>
{:else}
<span>
{user.name}
</span>
{/if}
</AutocompleteItem>
{/each}
</AutocompleteList>
</AutocompletePopover>- examples/full/editor.vue
- examples/full/extension.ts
- examples/full/index.ts
- sample/sample-doc-full.ts
- sample/sample-uploader.ts
- sample/tag-data.ts
- sample/user-data.ts
- ui/block-handle/block-handle.vue
- ui/block-handle/index.ts
- ui/button/button.vue
- ui/button/index.ts
- ui/code-block-view/code-block-view.vue
- ui/code-block-view/index.ts
- ui/drop-indicator/drop-indicator.vue
- ui/drop-indicator/index.ts
- ui/image-upload-popover/image-upload-popover.vue
- ui/image-upload-popover/index.ts
- ui/image-view/image-view.vue
- ui/image-view/index.ts
- ui/inline-menu/index.ts
- ui/inline-menu/inline-menu.vue
- ui/slash-menu/index.ts
- ui/slash-menu/slash-menu-empty.vue
- ui/slash-menu/slash-menu-item.vue
- ui/slash-menu/slash-menu.vue
- ui/table-handle/index.ts
- ui/table-handle/table-handle.vue
- ui/tag-menu/index.ts
- ui/tag-menu/tag-menu.vue
- ui/toolbar/index.ts
- ui/toolbar/toolbar.vue
- ui/user-menu/index.ts
- ui/user-menu/user-menu.vue
<script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import {
ref,
watchPostEffect,
} from 'vue'
import { defaultContent } from '../../sample/sample-doc-full'
import { sampleUploader } from '../../sample/sample-uploader'
import { tags } from '../../sample/tag-data'
import { users } from '../../sample/user-data'
import { BlockHandle } from '../../ui/block-handle'
import { DropIndicator } from '../../ui/drop-indicator'
import { InlineMenu } from '../../ui/inline-menu'
import { SlashMenu } from '../../ui/slash-menu'
import { TableHandle } from '../../ui/table-handle'
import { TagMenu } from '../../ui/tag-menu'
import { Toolbar } from '../../ui/toolbar'
import { UserMenu } from '../../ui/user-menu'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension, defaultContent })
const editorRef = ref<HTMLDivElement | null>(null)
watchPostEffect((onCleanup) => {
editor.mount(editorRef.value)
onCleanup(() => editor.unmount())
})
</script>
<template>
<ProseKit :editor="editor">
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white">
<Toolbar :uploader="sampleUploader" />
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ref="editorRef" class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<UserMenu :users="users" />
<TagMenu :tags="tags" />
<BlockHandle />
<TableHandle />
<DropIndicator />
</div>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
import { defineCodeBlockShiki } from 'prosekit/extensions/code-block'
import { defineHorizontalRule } from 'prosekit/extensions/horizontal-rule'
import { defineImageUploadHandler } from 'prosekit/extensions/image'
import { defineMention } from 'prosekit/extensions/mention'
import { definePlaceholder } from 'prosekit/extensions/placeholder'
import { sampleUploader } from '../../sample/sample-uploader'
import { defineCodeBlockView } from '../../ui/code-block-view'
import { defineImageView } from '../../ui/image-view'
export function defineExtension() {
return union(
defineBasicExtension(),
definePlaceholder({ placeholder: 'Press / for commands...' }),
defineMention(),
defineCodeBlockShiki(),
defineHorizontalRule(),
defineCodeBlockView(),
defineImageView(),
defineImageUploadHandler({
uploader: sampleUploader,
}),
)
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'import type { NodeJSON } from 'prosekit/core'
export const defaultContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'The editor that thinks like you' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Every keystroke flows naturally. Every feature appears exactly when you need it. This is ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'writing without barriers' },
{ type: 'text', text: '.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Text that shines.' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Make your words ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'bold' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'italic' }], text: 'italic' },
{ type: 'text', text: ', ' },
{ type: 'text', marks: [{ type: 'underline' }], text: 'underlined' },
{ type: 'text', text: ', or ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'crossed out' },
{ type: 'text', text: '. Add ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'inline code' },
{ type: 'text', text: ' that stands out. Create ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://prosekit.dev' } }],
text: 'links',
},
{ type: 'text', text: ' that connect.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Select any text to format it. Type ' },
{ type: 'text', marks: [{ type: 'code' }], text: '@' },
{ type: 'text', text: ' to mention ' },
{ type: 'mention', attrs: { id: '39', value: '@someone', kind: 'user' } },
{ type: 'text', text: ' or ' },
{ type: 'text', marks: [{ type: 'code' }], text: '#' },
{ type: 'text', text: ' for ' },
{ type: 'mention', attrs: { id: '1', value: '#topics', kind: 'tag' } },
{ type: 'text', text: '. Press ' },
{ type: 'text', marks: [{ type: 'code' }], text: '/' },
{ type: 'text', text: " and discover what's possible." },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Lists that organize.' }],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Bullet points that guide thoughts' }] },
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Nested lists for complex ideas' }] },
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sub-points flow naturally' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'bullet', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Tasks that focus' }] },
{
type: 'list',
attrs: { kind: 'task', order: null, checked: true, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Done feels good' }] },
],
},
{
type: 'list',
attrs: { kind: 'task', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Todo drives action' }] },
],
},
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Numbered steps' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Sequential thinking' }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered', order: null, checked: false, collapsed: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Clear progress' }] },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Code that inspires.' }],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: '// Code that reads like poetry\nconst magic = createEditor()\nmagic.transform(thoughts)\n',
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Images that captivate.' }],
},
{
type: 'image',
attrs: {
src: 'https://static.photos/season/320x240/107',
},
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Drag the handle in the bottom right corner to resize.' },
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Tables that structure.' }],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Feature' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'How to use' }],
},
],
},
{
type: 'tableCell',
attrs: {},
content: [
{
type: 'paragraph',
content: [{ type: 'text', marks: [{ type: 'bold' }], text: 'Result' }],
},
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Format text' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Select and choose' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Perfect styling' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Add mentions' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Type @ and name' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Connected ideas' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Insert anything' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Press / for menu' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Endless possibilities' }] },
],
},
],
},
],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Quotes that inspire.' }],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '"This is not just an editor. This is how writing should feel."',
},
],
},
],
},
{ type: 'horizontalRule' },
{
type: 'paragraph',
content: [{ type: 'text', text: 'Start typing. Everything else just flows.' }],
},
],
}import type { Uploader } from 'prosekit/extensions/file'
/**
* 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 by the server after 1 hour.
*/
export const sampleUploader: 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({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.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 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' },
]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 {
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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200">
<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-sm text-gray-500/50 dark:text-gray-500/50 cursor-pointer">
<div class="i-lucide-plus size-5 block" />
</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-sm text-gray-500/50 dark:text-gray-500/50 cursor-grab">
<div class="i-lucide-grip-vertical size-5 block" />
</BlockHandleDraggable>
</BlockHandlePopover>
</template>export { default as BlockHandle } from './block-handle.vue'<script setup lang="ts">
import {
TooltipContent,
TooltipRoot,
TooltipTrigger,
} from 'prosekit/vue/tooltip'
defineProps<{
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
}>()
</script>
<template>
<TooltipRoot>
<TooltipTrigger class="block">
<button
:data-state="pressed ? 'on' : 'off'"
:disabled="disabled"
class="outline-unset focus-visible:outline-unset flex items-center justify-center rounded-md p-2 font-medium transition focus-visible:ring-2 text-sm focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 disabled:pointer-events-none min-w-9 min-h-9 text-gray-900 dark:text-gray-50 disabled:text-gray-900/50 dark:disabled:text-gray-50/50 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 data-[state=on]:bg-gray-200 dark:data-[state=on]:bg-gray-700"
@click="onClick"
@mousedown.prevent
>
<slot />
<span v-if="tooltip" class="sr-only">{{ tooltip }}</span>
</button>
</TooltipTrigger>
<TooltipContent v-if="tooltip" class="z-50 overflow-hidden rounded-md border border-solid bg-gray-900 dark:bg-gray-50 px-3 py-1.5 text-xs text-gray-50 dark:text-gray-900 shadow-xs [&:not([data-state])]:hidden will-change-transform motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
{{ tooltip }}
</TooltipContent>
</TooltipRoot>
</template>export { default as Button } from './button.vue'<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'
const props = defineProps<VueNodeViewProps>()
const attrs = () => props.node.value.attrs as CodeBlockAttrs
const language = () => attrs().language
function setLanguage(lang: string) {
const newAttrs: CodeBlockAttrs = { language: lang }
props.setAttrs(newAttrs)
}
</script>
<template>
<div class="relative mx-2 top-3 h-0 select-none overflow-visible text-xs" contentEditable="false">
<select
aria-label="Code block language"
class="outline-unset focus:outline-unset relative box-border w-auto cursor-pointer select-none appearance-none rounded-sm border-none bg-transparent px-2 py-1 text-xs transition text-(--prosemirror-highlight) opacity-0 hover:opacity-80 [div[data-node-view-root]:hover_&]:opacity-50 hover:[div[data-node-view-root]:hover_&]:opacity-80"
:value="language() || ''"
@change="(event) => setLanguage((event.target as HTMLSelectElement).value)"
>
<option value="">Plain Text</option>
<option
v-for="info in shikiBundledLanguagesInfo"
:key="info.id"
:value="info.id"
>
{{ info.name }}
</option>
</select>
</div>
<pre :ref="contentRef" :data-language="language()"></pre>
</template>import type { Extension } from 'prosekit/core'
import {
defineVueNodeView,
type VueNodeViewComponent,
} from 'prosekit/vue'
import CodeBlockView from './code-block-view.vue'
export function defineCodeBlockView(): Extension {
return defineVueNodeView({
name: 'codeBlock',
contentAs: 'code',
component: CodeBlockView as VueNodeViewComponent,
})
}<script setup lang="ts">
import { DropIndicator as BaseDropIndicator } from 'prosekit/vue/drop-indicator'
</script>
<template>
<BaseDropIndicator class="z-50 transition-all bg-blue-500" />
</template>export { default as DropIndicator } from './drop-indicator.vue'<script setup lang="ts">
import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/vue'
import {
PopoverContent,
PopoverRoot,
PopoverTrigger,
} from 'prosekit/vue/popover'
import {
ref,
useId,
} from 'vue'
import { Button } from '../button'
const props = defineProps<{
uploader: Uploader<string>
tooltip: string
disabled: boolean
}>()
const open = ref(false)
const url = ref('')
const file = ref<File | null>(null)
const ariaId = useId()
const editor = useEditor<ImageExtension>()
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement
const selectedFile = target.files?.[0]
if (selectedFile) {
file.value = selectedFile
url.value = ''
} else {
file.value = null
}
}
function handleUrlChange(event: Event) {
const target = event.target as HTMLInputElement
const inputUrl = target.value
if (inputUrl) {
url.value = inputUrl
file.value = null
} else {
url.value = ''
}
}
function deferResetState() {
setTimeout(() => {
url.value = ''
file.value = null
}, 300)
}
function handleSubmit() {
if (url.value) {
editor.value.commands.insertImage({ src: url.value })
} else if (file.value) {
editor.value.commands.uploadImage({ file: file.value, uploader: props.uploader })
}
open.value = false
deferResetState()
}
function handleOpenChange(isOpen: boolean) {
if (!isOpen) {
deferResetState()
}
open.value = isOpen
}
</script>
<template>
<PopoverRoot :open="open" @open-change="handleOpenChange">
<PopoverTrigger>
<Button :pressed="open" :disabled="disabled" :tooltip="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 motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:animate-duration-150 motion-safe:data-[state=closed]:animate-duration-200 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=bottom]:slide-out-to-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=left]:slide-out-to-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=right]:slide-out-to-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 motion-safe:data-[side=top]:slide-out-to-bottom-2">
<label v-if="!file" :for="`id-link-${ariaId}`">Embed Link</label>
<input
v-if="!file"
:id="`id-link-${ariaId}`"
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Paste the image link..."
type="url"
:value="url"
@input="handleUrlChange"
/>
<label v-if="!url" :for="`id-upload-${ariaId}`">Upload</label>
<input
v-if="!url"
:id="`id-upload-${ariaId}`"
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
accept="image/*"
type="file"
@change="handleFileChange"
/>
<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-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" @click="handleSubmit">
Insert Image
</button>
<button v-if="file" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-10 px-4 py-2 w-full" @click="handleSubmit">
Upload Image
</button>
</PopoverContent>
</PopoverRoot>
</template>export { default as ImageUploadPopover } from './image-upload-popover.vue'<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 attrs = computed(() => props.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 (!uploading.value) return
const uploadTask = UploadTask.get<string>(url.value)
if (!uploadTask) return
let canceled = false
uploadTask.finished.catch((err) => {
if (canceled) return
error.value = String(err)
})
const unsubscribeProgress = uploadTask.subscribeProgress(({ loaded, total }) => {
if (canceled) return
progress.value = total ? loaded / total : 0
})
onCleanup(() => {
canceled = true
unsubscribeProgress()
})
})
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)) {
props.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"
alt="upload preview"
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-sm bg-gray-800/60 p-1.5 text-xs text-white/80 transition">
<div class="i-lucide-loader-circle size-4 animate-spin block"></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 size-8 block"></div>
<div class="hidden opacity-80 @xs:block">
Failed to upload image
</div>
</div>
<ResizableHandle
class="absolute bottom-0 right-0 rounded-sm 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 size-4 block"></div>
</ResizableHandle>
</ResizableRoot>
</template>import type { Extension } from 'prosekit/core'
import {
defineVueNodeView,
type VueNodeViewComponent,
} from 'prosekit/vue'
import ImageView from './image-view.vue'
export function defineImageView(): Extension {
return defineVueNodeView({
name: 'image',
component: ImageView as VueNodeViewComponent,
})
}export { default as InlineMenu } from './inline-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { LinkAttrs } from 'prosekit/extensions/link'
import type { EditorState } from 'prosekit/pm/state'
import {
useEditor,
useEditorDerivedValue,
} from 'prosekit/vue'
import { InlinePopover } from 'prosekit/vue/inline-popover'
import { ref } from 'vue'
import { Button } from '../button'
function getInlineMenuItems(editor: Editor<BasicExtension>) {
return {
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
link: editor.commands.addLink
? {
isActive: editor.marks.link.isActive(),
canExec: editor.commands.addLink.canExec({ href: '' }),
command: () => editor.commands.expandLink(),
currentLink: getCurrentLink(editor.state) || '',
}
: undefined,
}
}
function getCurrentLink(state: EditorState): string | undefined {
const { $from } = state.selection
const marks = $from.marksAcross($from)
if (!marks) {
return
}
for (const mark of marks) {
if (mark.type.name === 'link') {
return (mark.attrs as LinkAttrs).href
}
}
}
const editor = useEditor<BasicExtension>()
const items = useEditorDerivedValue(getInlineMenuItems)
const linkMenuOpen = ref(false)
function toggleLinkMenuOpen() {
linkMenuOpen.value = !linkMenuOpen.value
}
function handleLinkUpdate(href?: string) {
if (href) {
editor.value.commands.addLink({ href })
} else {
editor.value.commands.removeLink()
}
linkMenuOpen.value = false
editor.value.focus()
}
</script>
<template>
<InlinePopover
data-testid="inline-menu-main"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex min-w-32 space-x-1 overflow-auto whitespace-nowrap rounded-md p-1"
@open-change="(open) => {
if (!open) linkMenuOpen = false
}"
>
<Button
v-if="items.bold"
:pressed="items.bold.isActive"
:disabled="!items.bold.canExec"
tooltip="Bold"
@click="items.bold.command"
>
<div class="i-lucide-bold size-5 block"></div>
</Button>
<Button
v-if="items.italic"
:pressed="items.italic.isActive"
:disabled="!items.italic.canExec"
tooltip="Italic"
@click="items.italic.command"
>
<div class="i-lucide-italic size-5 block"></div>
</Button>
<Button
v-if="items.underline"
:pressed="items.underline.isActive"
:disabled="!items.underline.canExec"
tooltip="Underline"
@click="items.underline.command"
>
<div class="i-lucide-underline size-5 block"></div>
</Button>
<Button
v-if="items.strike"
:pressed="items.strike.isActive"
:disabled="!items.strike.canExec"
tooltip="Strikethrough"
@click="items.strike.command"
>
<div class="i-lucide-strikethrough size-5 block"></div>
</Button>
<Button
v-if="items.code"
:pressed="items.code.isActive"
:disabled="!items.code.canExec"
tooltip="Code"
@click="items.code.command"
>
<div class="i-lucide-code size-5 block"></div>
</Button>
<Button
v-if="items.link?.canExec && items.link"
:pressed="items.link.isActive"
tooltip="Link"
@click="() => {
items.link!.command()
toggleLinkMenuOpen()
}"
>
<div class="i-lucide-link size-5 block"></div>
</Button>
</InlinePopover>
<InlinePopover
v-if="items.link"
placement="bottom"
:default-open="false"
:open="linkMenuOpen"
data-testid="inline-menu-link"
class="z-10 box-border border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden relative flex flex-col w-xs rounded-lg p-4 gap-y-2 items-stretch"
@open-change="(open) => {
linkMenuOpen = open
}"
>
<form
v-if="linkMenuOpen"
@submit.prevent="(event) => {
const target = event.target as HTMLFormElement | null
const href = target?.querySelector('input')?.value?.trim()
handleLinkUpdate(href)
}"
>
<input
placeholder="Paste the link..."
:value="items.link.currentLink || ''"
class="flex h-9 rounded-md w-full bg-white dark:bg-gray-950 px-3 py-2 text-sm placeholder:text-gray-500 dark:placeholder:text-gray-500 transition border box-border border-gray-200 dark:border-gray-800 border-solid ring-0 ring-transparent focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-0 outline-hidden focus-visible:outline-hidden file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50"
>
</form>
<button
v-if="items.link.isActive"
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white dark:ring-offset-gray-950 transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900 dark:focus-visible:ring-gray-300 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-0 bg-gray-900 dark:bg-gray-50 text-gray-50 dark:text-gray-900 hover:bg-gray-900/90 dark:hover:bg-gray-50/90 h-9 px-3"
@click="() => handleLinkUpdate()"
@mousedown.prevent
>
Remove link
</button>
</InlinePopover>
</template>export { default as SlashMenu } from './slash-menu.vue'<script setup lang="ts">
import { AutocompleteEmpty } from 'prosekit/vue/autocomplete'
</script>
<template>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
<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-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800" @select="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 type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/vue'
import {
AutocompleteList,
AutocompletePopover,
} from 'prosekit/vue/autocomplete'
import SlashMenuEmpty from './slash-menu-empty.vue'
import SlashMenuItem from './slash-menu-item.vue'
const editor = useEditor<BasicExtension>()
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(|\S.*)$/u : /\/(|\S.*)$/u
</script>
<template>
<AutocompletePopover :regex="regex" class="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden">
<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 { default as TableHandle } from './table-handle.vue'<script setup lang="ts">
import type { Editor } from 'prosekit/core'
import type { TableExtension } from 'prosekit/extensions/table'
import { useEditorDerivedValue } from 'prosekit/vue'
import {
TableHandleColumnRoot,
TableHandleColumnTrigger,
TableHandleDragPreview,
TableHandleDropIndicator,
TableHandlePopoverContent,
TableHandlePopoverItem,
TableHandleRoot,
TableHandleRowRoot,
TableHandleRowTrigger,
} from 'prosekit/vue/table-handle'
function getTableHandleState(editor: Editor<TableExtension>) {
return {
addTableColumnBefore: {
canExec: editor.commands.addTableColumnBefore.canExec(),
command: () => editor.commands.addTableColumnBefore(),
},
addTableColumnAfter: {
canExec: editor.commands.addTableColumnAfter.canExec(),
command: () => editor.commands.addTableColumnAfter(),
},
deleteCellSelection: {
canExec: editor.commands.deleteCellSelection.canExec(),
command: () => editor.commands.deleteCellSelection(),
},
deleteTableColumn: {
canExec: editor.commands.deleteTableColumn.canExec(),
command: () => editor.commands.deleteTableColumn(),
},
addTableRowAbove: {
canExec: editor.commands.addTableRowAbove.canExec(),
command: () => editor.commands.addTableRowAbove(),
},
addTableRowBelow: {
canExec: editor.commands.addTableRowBelow.canExec(),
command: () => editor.commands.addTableRowBelow(),
},
deleteTableRow: {
canExec: editor.commands.deleteTableRow.canExec(),
command: () => editor.commands.deleteTableRow(),
},
deleteTable: {
canExec: editor.commands.deleteTable.canExec(),
command: () => editor.commands.deleteTable(),
},
}
}
const state = useEditorDerivedValue(getTableHandleState)
</script>
<template>
<TableHandleRoot class="contents">
<TableHandleDragPreview />
<TableHandleDropIndicator />
<TableHandleColumnRoot class="h-[1.2em] w-[1.5em] translate-y-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleColumnTrigger class="flex items-center justify-center">
<div class="i-lucide-grip-horizontal size-5 block"></div>
</TableHandleColumnTrigger>
<TableHandlePopoverContent class="relative block max-h-100 min-w-32 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">
<TableHandlePopoverItem
v-if="state.addTableColumnBefore.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="state.addTableColumnBefore.command"
>
<span>Insert Left</span>
</TableHandlePopoverItem>
<TableHandlePopoverItem
v-if="state.addTableColumnAfter.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="state.addTableColumnAfter.command"
>
<span>Insert Right</span>
</TableHandlePopoverItem>
<TableHandlePopoverItem
v-if="state.deleteCellSelection.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="state.deleteCellSelection.command"
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
<TableHandlePopoverItem
v-if="state.deleteTableColumn.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="state.deleteTableColumn.command"
>
<span>Delete Column</span>
</TableHandlePopoverItem>
<TableHandlePopoverItem
v-if="state.deleteTable.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger
@select="state.deleteTable.command"
>
<span>Delete Table</span>
</TableHandlePopoverItem>
</TableHandlePopoverContent>
</TableHandleColumnRoot>
<TableHandleRowRoot class="h-[1.5em] w-[1.2em] translate-x-[80%] flex items-center box-border justify-center bg-white dark:bg-gray-950 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-sm text-gray-500/50 dark:text-gray-500/50 border border-gray-200 dark:border-gray-800 border-solid p-0 overflow-hidden duration-150 transition-discrete transition data-[state=closed]:opacity-0 starting:opacity-0 opacity-100 data-[state=closed]:scale-95 starting:scale-95 scale-100">
<TableHandleRowTrigger class="flex items-center justify-center">
<div class="i-lucide-grip-vertical size-5 block"></div>
</TableHandleRowTrigger>
<TableHandlePopoverContent class="relative block max-h-100 min-w-32 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">
<TableHandlePopoverItem
v-if="state.addTableRowAbove.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="state.addTableRowAbove.command"
>
<span>Insert Above</span>
</TableHandlePopoverItem>
<TableHandlePopoverItem
v-if="state.addTableRowBelow.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="state.addTableRowBelow.command"
>
<span>Insert Below</span>
</TableHandlePopoverItem>
<TableHandlePopoverItem
v-if="state.deleteCellSelection.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="state.deleteCellSelection.command"
>
<span>Clear Contents</span>
<span class="text-xs tracking-widest text-gray-500 dark:text-gray-500">Del</span>
</TableHandlePopoverItem>
<TableHandlePopoverItem
v-if="state.deleteTableRow.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="state.deleteTableRow.command"
>
<span>Delete Row</span>
</TableHandlePopoverItem>
<TableHandlePopoverItem
v-if="state.deleteTable.canExec"
class="relative min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 flex items-center justify-between gap-8 data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 hover:data-[disabled=true]:opacity-50 data-danger:text-red-500 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
data-danger
@select="state.deleteTable.command"
>
<span>Delete Table</span>
</TableHandlePopoverItem>
</TableHandlePopoverContent>
</TableHandleRowRoot>
</TableHandleRoot>
</template>export { default as TagMenu } from './tag-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/vue'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/vue/autocomplete'
const props = defineProps<{ tags: { id: number; label: string }[] }>()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
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-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
>
<AutocompleteList>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
No results
</AutocompleteEmpty>
<AutocompleteItem
v-for="tag in props.tags"
:key="tag.id"
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="() => handleTagInsert(tag.id, tag.label)"
>
#{{ tag.label }}
</AutocompleteItem>
</AutocompleteList>
</AutocompletePopover>
</template>export { default as Toolbar } from './toolbar.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Editor } from 'prosekit/core'
import type { Uploader } from 'prosekit/extensions/file'
import { useEditorDerivedValue } from 'prosekit/vue'
import { Button } from '../button'
import { ImageUploadPopover } from '../image-upload-popover'
defineProps<{ uploader?: Uploader<string> }>()
function getToolbarItems(editor: Editor<BasicExtension>) {
return {
undo: editor.commands.undo
? {
isActive: false,
canExec: editor.commands.undo.canExec(),
command: () => editor.commands.undo(),
}
: undefined,
redo: editor.commands.redo
? {
isActive: false,
canExec: editor.commands.redo.canExec(),
command: () => editor.commands.redo(),
}
: undefined,
bold: editor.commands.toggleBold
? {
isActive: editor.marks.bold.isActive(),
canExec: editor.commands.toggleBold.canExec(),
command: () => editor.commands.toggleBold(),
}
: undefined,
italic: editor.commands.toggleItalic
? {
isActive: editor.marks.italic.isActive(),
canExec: editor.commands.toggleItalic.canExec(),
command: () => editor.commands.toggleItalic(),
}
: undefined,
underline: editor.commands.toggleUnderline
? {
isActive: editor.marks.underline.isActive(),
canExec: editor.commands.toggleUnderline.canExec(),
command: () => editor.commands.toggleUnderline(),
}
: undefined,
strike: editor.commands.toggleStrike
? {
isActive: editor.marks.strike.isActive(),
canExec: editor.commands.toggleStrike.canExec(),
command: () => editor.commands.toggleStrike(),
}
: undefined,
code: editor.commands.toggleCode
? {
isActive: editor.marks.code.isActive(),
canExec: editor.commands.toggleCode.canExec(),
command: () => editor.commands.toggleCode(),
}
: undefined,
codeBlock: editor.commands.insertCodeBlock
? {
isActive: editor.nodes.codeBlock.isActive(),
canExec: editor.commands.insertCodeBlock.canExec({ language: 'javascript' }),
command: () => editor.commands.insertCodeBlock({ language: 'javascript' }),
}
: undefined,
heading1: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 1 }),
canExec: editor.commands.toggleHeading.canExec({ level: 1 }),
command: () => editor.commands.toggleHeading({ level: 1 }),
}
: undefined,
heading2: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 2 }),
canExec: editor.commands.toggleHeading.canExec({ level: 2 }),
command: () => editor.commands.toggleHeading({ level: 2 }),
}
: undefined,
heading3: editor.commands.toggleHeading
? {
isActive: editor.nodes.heading.isActive({ level: 3 }),
canExec: editor.commands.toggleHeading.canExec({ level: 3 }),
command: () => editor.commands.toggleHeading({ level: 3 }),
}
: undefined,
horizontalRule: editor.commands.insertHorizontalRule
? {
isActive: editor.nodes.horizontalRule.isActive(),
canExec: editor.commands.insertHorizontalRule.canExec(),
command: () => editor.commands.insertHorizontalRule(),
}
: undefined,
blockquote: editor.commands.toggleBlockquote
? {
isActive: editor.nodes.blockquote.isActive(),
canExec: editor.commands.toggleBlockquote.canExec(),
command: () => editor.commands.toggleBlockquote(),
}
: undefined,
bulletList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'bullet' }),
canExec: editor.commands.toggleList.canExec({ kind: 'bullet' }),
command: () => editor.commands.toggleList({ kind: 'bullet' }),
}
: undefined,
orderedList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'ordered' }),
canExec: editor.commands.toggleList.canExec({ kind: 'ordered' }),
command: () => editor.commands.toggleList({ kind: 'ordered' }),
}
: undefined,
taskList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'task' }),
canExec: editor.commands.toggleList.canExec({ kind: 'task' }),
command: () => editor.commands.toggleList({ kind: 'task' }),
}
: undefined,
toggleList: editor.commands.toggleList
? {
isActive: editor.nodes.list.isActive({ kind: 'toggle' }),
canExec: editor.commands.toggleList.canExec({ kind: 'toggle' }),
command: () => editor.commands.toggleList({ kind: 'toggle' }),
}
: undefined,
indentList: editor.commands.indentList
? {
isActive: false,
canExec: editor.commands.indentList.canExec(),
command: () => editor.commands.indentList(),
}
: undefined,
dedentList: editor.commands.dedentList
? {
isActive: false,
canExec: editor.commands.dedentList.canExec(),
command: () => editor.commands.dedentList(),
}
: undefined,
insertImage: editor.commands.insertImage
? {
isActive: false,
canExec: editor.commands.insertImage.canExec(),
}
: undefined,
}
}
const items = useEditorDerivedValue(getToolbarItems)
</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
v-if="items.undo"
:pressed="items.undo.isActive"
:disabled="!items.undo.canExec"
tooltip="Undo"
@click="items.undo.command"
>
<div class="i-lucide-undo-2 size-5 block" />
</Button>
<Button
v-if="items.redo"
:pressed="items.redo.isActive"
:disabled="!items.redo.canExec"
tooltip="Redo"
@click="items.redo.command"
>
<div class="i-lucide-redo-2 size-5 block" />
</Button>
<Button
v-if="items.bold"
:pressed="items.bold.isActive"
:disabled="!items.bold.canExec"
tooltip="Bold"
@click="items.bold.command"
>
<div class="i-lucide-bold size-5 block" />
</Button>
<Button
v-if="items.italic"
:pressed="items.italic.isActive"
:disabled="!items.italic.canExec"
tooltip="Italic"
@click="items.italic.command"
>
<div class="i-lucide-italic size-5 block" />
</Button>
<Button
v-if="items.underline"
:pressed="items.underline.isActive"
:disabled="!items.underline.canExec"
tooltip="Underline"
@click="items.underline.command"
>
<div class="i-lucide-underline size-5 block" />
</Button>
<Button
v-if="items.strike"
:pressed="items.strike.isActive"
:disabled="!items.strike.canExec"
tooltip="Strike"
@click="items.strike.command"
>
<div class="i-lucide-strikethrough size-5 block" />
</Button>
<Button
v-if="items.code"
:pressed="items.code.isActive"
:disabled="!items.code.canExec"
tooltip="Code"
@click="items.code.command"
>
<div class="i-lucide-code size-5 block" />
</Button>
<Button
v-if="items.codeBlock"
:pressed="items.codeBlock.isActive"
:disabled="!items.codeBlock.canExec"
tooltip="Code Block"
@click="items.codeBlock.command"
>
<div class="i-lucide-square-code size-5 block" />
</Button>
<Button
v-if="items.heading1"
:pressed="items.heading1.isActive"
:disabled="!items.heading1.canExec"
tooltip="Heading 1"
@click="items.heading1.command"
>
<div class="i-lucide-heading-1 size-5 block" />
</Button>
<Button
v-if="items.heading2"
:pressed="items.heading2.isActive"
:disabled="!items.heading2.canExec"
tooltip="Heading 2"
@click="items.heading2.command"
>
<div class="i-lucide-heading-2 size-5 block" />
</Button>
<Button
v-if="items.heading3"
:pressed="items.heading3.isActive"
:disabled="!items.heading3.canExec"
tooltip="Heading 3"
@click="items.heading3.command"
>
<div class="i-lucide-heading-3 size-5 block" />
</Button>
<Button
v-if="items.horizontalRule"
:pressed="items.horizontalRule.isActive"
:disabled="!items.horizontalRule.canExec"
tooltip="Divider"
@click="items.horizontalRule.command"
>
<div class="i-lucide-minus size-5 block"></div>
</Button>
<Button
v-if="items.blockquote"
:pressed="items.blockquote.isActive"
:disabled="!items.blockquote.canExec"
tooltip="Blockquote"
@click="items.blockquote.command"
>
<div class="i-lucide-text-quote size-5 block" />
</Button>
<Button
v-if="items.bulletList"
:pressed="items.bulletList.isActive"
:disabled="!items.bulletList.canExec"
tooltip="Bullet List"
@click="items.bulletList.command"
>
<div class="i-lucide-list size-5 block" />
</Button>
<Button
v-if="items.orderedList"
:pressed="items.orderedList.isActive"
:disabled="!items.orderedList.canExec"
tooltip="Ordered List"
@click="items.orderedList.command"
>
<div class="i-lucide-list-ordered size-5 block" />
</Button>
<Button
v-if="items.taskList"
:pressed="items.taskList.isActive"
:disabled="!items.taskList.canExec"
tooltip="Task List"
@click="items.taskList.command"
>
<div class="i-lucide-list-checks size-5 block" />
</Button>
<Button
v-if="items.toggleList"
:pressed="items.toggleList.isActive"
:disabled="!items.toggleList.canExec"
tooltip="Toggle List"
@click="items.toggleList.command"
>
<div class="i-lucide-list-collapse size-5 block" />
</Button>
<Button
v-if="items.indentList"
:pressed="items.indentList.isActive"
:disabled="!items.indentList.canExec"
tooltip="Increase indentation"
@click="items.indentList.command"
>
<div class="i-lucide-indent-increase size-5 block" />
</Button>
<Button
v-if="items.dedentList"
:pressed="items.dedentList.isActive"
:disabled="!items.dedentList.canExec"
tooltip="Decrease indentation"
@click="items.dedentList.command"
>
<div class="i-lucide-indent-decrease size-5 block" />
</Button>
<ImageUploadPopover
v-if="uploader && items.insertImage"
:uploader="uploader"
:disabled="!items.insertImage.canExec"
tooltip="Insert Image"
>
<div class="i-lucide-image size-5 block" />
</ImageUploadPopover>
</div>
</template>export { default as UserMenu } from './user-menu.vue'<script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import type { Union } from 'prosekit/core'
import type { MentionExtension } from 'prosekit/extensions/mention'
import { useEditor } from 'prosekit/vue'
import {
AutocompleteEmpty,
AutocompleteItem,
AutocompleteList,
AutocompletePopover,
} from 'prosekit/vue/autocomplete'
const props = defineProps<{
users: { id: number; name: string }[]
loading?: boolean
onQueryChange?: (query: string) => void
onOpenChange?: (open: boolean) => void
}>()
const editor = useEditor<Union<[MentionExtension, BasicExtension]>>()
function handleUserInsert(id: number, username: string) {
editor.value.commands.insertMention({
id: id.toString(),
value: '@' + username,
kind: 'user',
})
editor.value.commands.insertText({ text: ' ' })
}
</script>
<template>
<AutocompletePopover
:regex="/@\w*$/"
class="relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden"
@query-change="props.onQueryChange"
@open-change="props.onOpenChange"
>
<AutocompleteList>
<AutocompleteEmpty class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800">
{{ props.loading ? 'Loading...' : 'No results' }}
</AutocompleteEmpty>
<AutocompleteItem
v-for="user in props.users"
:key="user.id"
class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800"
@select="() => handleUserInsert(user.id, user.name)"
>
<span v-if="props.loading" class="opacity-50">
{{ user.name }}
</span>
<span v-else>
{{ user.name }}
</span>
</AutocompleteItem>
</AutocompleteList>
</AutocompletePopover>
</template>