Example: rtl
Install this example with
shadcn:npx shadcn@latest add @prosekit/react-example-rtlnpx shadcn@latest add @prosekit/preact-example-rtlnpx shadcn@latest add @prosekit/solid-example-rtlnpx shadcn@latest add @prosekit/svelte-example-rtlnpx shadcn@latest add @prosekit/vue-example-rtl- examples/rtl/editor.tsx
- examples/rtl/index.ts
- sample/sample-doc-rtl.ts
- sample/sample-uploader.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/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/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/toolbar/index.ts
- ui/toolbar/toolbar.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { defineBasicExtension } from 'prosekit/basic'
import {
createEditor,
type NodeJSON,
} from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleContent } from '../../sample/sample-doc-rtl'
import { sampleUploader } from '../../sample/sample-uploader'
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 { Toolbar } from '../../ui/toolbar'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineBasicExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div dir="rtl" 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 />
<BlockHandle dir="rtl" />
<TableHandle dir="rtl" />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
const translation = {
'Paragraph': 'فقرة',
'Root list item': 'عنصر قائمة جذري',
'Sub list item': 'عنصر قائمة فرعي',
'Completed task': 'مهمة مكتملة',
'Pending task': 'مهمة قيد الانتظار',
'Quote': 'اقتباس',
} as const
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Right to Left' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: translation['Paragraph'] },
],
},
{
type: 'list',
attrs: { kind: 'bullet' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Root list item'] }] },
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
{
type: 'list',
attrs: { kind: 'task', checked: true },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Completed task'] }] },
],
},
{
type: 'list',
attrs: { kind: 'task', checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Pending task'] }] },
],
},
],
},
],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: 'hello world',
},
],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C1' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C2' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C3' }] },
],
},
],
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: translation['Quote'],
},
],
},
],
},
],
}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)
})
}import {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopover,
} from 'prosekit/preact/block-handle'
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function BlockHandle(props: Props) {
return (
<BlockHandlePopover
placement={props.dir === 'rtl' ? 'right' : 'left'}
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 { 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'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'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu() {
const editor = useEditor<BasicExtension>()
return (
<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(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function TableHandle(props: Props) {
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
placement={props.dir === 'rtl' ? 'right' : 'left'}
className="h-[1.5em] w-[1.2em] ltr:translate-x-[80%] rtl: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 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>
)
}- examples/rtl/editor.tsx
- examples/rtl/index.ts
- sample/sample-doc-rtl.ts
- sample/sample-uploader.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/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/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/toolbar/index.ts
- ui/toolbar/toolbar.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { defineBasicExtension } from 'prosekit/basic'
import {
createEditor,
type NodeJSON,
} from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { sampleContent } from '../../sample/sample-doc-rtl'
import { sampleUploader } from '../../sample/sample-uploader'
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 { Toolbar } from '../../ui/toolbar'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps) {
const defaultContent = props.initialContent ?? sampleContent
const editor = useMemo(() => {
const extension = defineBasicExtension()
return createEditor({ extension, defaultContent })
}, [defaultContent])
return (
<ProseKit editor={editor}>
<div dir="rtl" 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 />
<BlockHandle dir="rtl" />
<TableHandle dir="rtl" />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}'use client'
export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
const translation = {
'Paragraph': 'فقرة',
'Root list item': 'عنصر قائمة جذري',
'Sub list item': 'عنصر قائمة فرعي',
'Completed task': 'مهمة مكتملة',
'Pending task': 'مهمة قيد الانتظار',
'Quote': 'اقتباس',
} as const
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Right to Left' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: translation['Paragraph'] },
],
},
{
type: 'list',
attrs: { kind: 'bullet' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Root list item'] }] },
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
{
type: 'list',
attrs: { kind: 'task', checked: true },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Completed task'] }] },
],
},
{
type: 'list',
attrs: { kind: 'task', checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Pending task'] }] },
],
},
],
},
],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: 'hello world',
},
],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C1' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C2' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C3' }] },
],
},
],
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: translation['Quote'],
},
],
},
],
},
],
}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)
})
}import {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopover,
} from 'prosekit/react/block-handle'
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function BlockHandle(props: Props) {
return (
<BlockHandlePopover
placement={props.dir === 'rtl' ? 'right' : 'left'}
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 { 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'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'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu() {
const editor = useEditor<BasicExtension>()
return (
<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(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function TableHandle(props: Props) {
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
placement={props.dir === 'rtl' ? 'right' : 'left'}
className="h-[1.5em] w-[1.2em] ltr:translate-x-[80%] rtl: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 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>
)
}- examples/rtl/editor.tsx
- examples/rtl/index.ts
- sample/sample-doc-rtl.ts
- sample/sample-uploader.ts
- ui/block-handle/block-handle.tsx
- ui/block-handle/index.ts
- ui/button/button.tsx
- ui/button/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/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/toolbar/index.ts
- ui/toolbar/toolbar.tsx
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { defineBasicExtension } from 'prosekit/basic'
import {
createEditor,
type NodeJSON,
} from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { sampleContent } from '../../sample/sample-doc-rtl'
import { sampleUploader } from '../../sample/sample-uploader'
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 { Toolbar } from '../../ui/toolbar'
interface EditorProps {
initialContent?: NodeJSON
}
export default function Editor(props: EditorProps): JSX.Element {
const extension = defineBasicExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({ extension, defaultContent })
return (
<ProseKit editor={editor}>
<div dir="rtl" 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 />
<BlockHandle dir="rtl" />
<TableHandle dir="rtl" />
<DropIndicator />
</div>
</div>
</ProseKit>
)
}export { default as ExampleEditor } from './editor'import type { NodeJSON } from 'prosekit/core'
const translation = {
'Paragraph': 'فقرة',
'Root list item': 'عنصر قائمة جذري',
'Sub list item': 'عنصر قائمة فرعي',
'Completed task': 'مهمة مكتملة',
'Pending task': 'مهمة قيد الانتظار',
'Quote': 'اقتباس',
} as const
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Right to Left' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: translation['Paragraph'] },
],
},
{
type: 'list',
attrs: { kind: 'bullet' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Root list item'] }] },
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
{
type: 'list',
attrs: { kind: 'task', checked: true },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Completed task'] }] },
],
},
{
type: 'list',
attrs: { kind: 'task', checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Pending task'] }] },
],
},
],
},
],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: 'hello world',
},
],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C1' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C2' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C3' }] },
],
},
],
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: translation['Quote'],
},
],
},
],
},
],
}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)
})
}import {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopover,
} from 'prosekit/solid/block-handle'
import type { JSX } from 'solid-js'
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function BlockHandle(props: Props): JSX.Element {
return (
<BlockHandlePopover
placement={props.dir === 'rtl' ? 'right' : 'left'}
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 { 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'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'
// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading".
const regex = canUseRegexLookbehind() ? /(?<!\S)\/(\S.*)?$/u : /\/(\S.*)?$/u
export default function SlashMenu(): JSX.Element {
const editor = useEditor<BasicExtension>()
return (
<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(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
export default function TableHandle(props: Props): 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
placement={props.dir === 'rtl' ? 'right' : 'left'}
class="h-[1.5em] w-[1.2em] ltr:translate-x-[80%] rtl: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 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>
)
}- examples/rtl/editor.svelte
- examples/rtl/index.ts
- sample/sample-doc-rtl.ts
- sample/sample-uploader.ts
- ui/block-handle/block-handle.svelte
- ui/block-handle/index.ts
- ui/button/button.svelte
- ui/button/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/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/toolbar/index.ts
- ui/toolbar/toolbar.svelte
<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { defineBasicExtension } from 'prosekit/basic'
import {
createEditor,
type NodeJSON,
} from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { sampleContent } from '../../sample/sample-doc-rtl'
import { sampleUploader } from '../../sample/sample-uploader'
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 { Toolbar } from '../../ui/toolbar'
const props: {
initialContent?: NodeJSON
} = $props()
const extension = defineBasicExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({ extension, defaultContent })
</script>
<ProseKit {editor}>
<div dir="rtl" 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 {@attach editor.mount} class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<BlockHandle dir="rtl" />
<TableHandle dir="rtl" />
<DropIndicator />
</div>
</div>
</ProseKit>export { default as ExampleEditor } from './editor.svelte'import type { NodeJSON } from 'prosekit/core'
const translation = {
'Paragraph': 'فقرة',
'Root list item': 'عنصر قائمة جذري',
'Sub list item': 'عنصر قائمة فرعي',
'Completed task': 'مهمة مكتملة',
'Pending task': 'مهمة قيد الانتظار',
'Quote': 'اقتباس',
} as const
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Right to Left' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: translation['Paragraph'] },
],
},
{
type: 'list',
attrs: { kind: 'bullet' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Root list item'] }] },
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
{
type: 'list',
attrs: { kind: 'task', checked: true },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Completed task'] }] },
],
},
{
type: 'list',
attrs: { kind: 'task', checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Pending task'] }] },
],
},
],
},
],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: 'hello world',
},
],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C1' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C2' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C3' }] },
],
},
],
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: translation['Quote'],
},
],
},
],
},
],
}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)
})
}<script lang="ts">
import {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopover,
} from 'prosekit/svelte/block-handle'
interface Props {
dir?: 'ltr' | 'rtl'
}
const props: Props = $props()
</script>
<BlockHandlePopover
placement={props.dir === 'rtl' ? 'right' : 'left'}
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 { 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'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(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
const props: Props = $props()
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
placement={props.dir === 'rtl' ? 'right' : 'left'}
class="h-[1.5em] w-[1.2em] ltr:translate-x-[80%] rtl: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 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>- examples/rtl/editor.vue
- examples/rtl/index.ts
- sample/sample-doc-rtl.ts
- sample/sample-uploader.ts
- ui/block-handle/block-handle.vue
- ui/block-handle/index.ts
- ui/button/button.vue
- ui/button/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/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/toolbar/index.ts
- ui/toolbar/toolbar.vue
<script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { defineBasicExtension } from 'prosekit/basic'
import {
createEditor,
type NodeJSON,
} from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { sampleContent } from '../../sample/sample-doc-rtl'
import { sampleUploader } from '../../sample/sample-uploader'
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 { Toolbar } from '../../ui/toolbar'
const props = defineProps<{
initialContent?: NodeJSON
}>()
const extension = defineBasicExtension()
const defaultContent = props.initialContent ?? sampleContent
const editor = createEditor({ extension, defaultContent })
</script>
<template>
<ProseKit :editor="editor">
<div dir="rtl" 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="(el) => editor.mount(el as HTMLElement | null)" class="ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500"></div>
<InlineMenu />
<SlashMenu />
<BlockHandle dir="rtl" />
<TableHandle dir="rtl" />
<DropIndicator />
</div>
</div>
</ProseKit>
</template>export { default as ExampleEditor } from './editor.vue'import type { NodeJSON } from 'prosekit/core'
const translation = {
'Paragraph': 'فقرة',
'Root list item': 'عنصر قائمة جذري',
'Sub list item': 'عنصر قائمة فرعي',
'Completed task': 'مهمة مكتملة',
'Pending task': 'مهمة قيد الانتظار',
'Quote': 'اقتباس',
} as const
export const sampleContent: NodeJSON = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Right to Left' }],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: translation['Paragraph'] },
],
},
{
type: 'list',
attrs: { kind: 'bullet' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Root list item'] }] },
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
],
},
{
type: 'list',
attrs: { kind: 'ordered' },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Sub list item'] }] },
{
type: 'list',
attrs: { kind: 'task', checked: true },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Completed task'] }] },
],
},
{
type: 'list',
attrs: { kind: 'task', checked: false },
content: [
{ type: 'paragraph', content: [{ type: 'text', text: translation['Pending task'] }] },
],
},
],
},
],
},
{
type: 'codeBlock',
attrs: { language: 'javascript' },
content: [
{
type: 'text',
text: 'hello world',
},
],
},
{
type: 'table',
content: [
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B1' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C1' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B2' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C2' }] },
],
},
],
},
{
type: 'tableRow',
content: [
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'A3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'B3' }] },
],
},
{
type: 'tableCell',
attrs: {},
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'C3' }] },
],
},
],
},
],
},
{
type: 'blockquote',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: translation['Quote'],
},
],
},
],
},
],
}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)
})
}<script setup lang="ts">
import {
BlockHandleAdd,
BlockHandleDraggable,
BlockHandlePopover,
} from 'prosekit/vue/block-handle'
interface Props {
dir?: 'ltr' | 'rtl'
}
const props = defineProps<Props>()
</script>
<template>
<BlockHandlePopover
:placement="props.dir === 'rtl' ? 'right' : 'left'"
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'
const props = defineProps<{
pressed?: boolean
disabled?: boolean
onClick?: () => void
tooltip?: string
}>()
</script>
<template>
<TooltipRoot>
<TooltipTrigger class="block">
<button
:data-state="props.pressed ? 'on' : 'off'"
:disabled="props.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="props.onClick"
@mousedown.prevent
>
<slot />
<span v-if="props.tooltip" class="sr-only">{{ props.tooltip }}</span>
</button>
</TooltipTrigger>
<TooltipContent v-if="props.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">
{{ props.tooltip }}
</TooltipContent>
</TooltipRoot>
</template>export { default as Button } from './button.vue'<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="props.disabled" :tooltip="props.tooltip">
<slot />
</Button>
</PopoverTrigger>
<PopoverContent class="flex flex-col gap-y-4 p-6 text-sm w-sm z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden will-change-transform 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'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'
const props = defineProps<{
label: string
kbd?: string
onSelect: () => void
}>()
</script>
<template>
<AutocompleteItem class="relative flex items-center justify-between min-w-32 scroll-my-1 rounded-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="props.onSelect">
<span>{{ props.label }}</span>
<kbd v-if="props.kbd" class="text-xs font-mono text-gray-400 dark:text-gray-500">{{ props.kbd }}</kbd>
</AutocompleteItem>
</template><script setup lang="ts">
import type { BasicExtension } from 'prosekit/basic'
import { canUseRegexLookbehind } from 'prosekit/core'
import { useEditor } from 'prosekit/vue'
import {
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(),
},
}
}
interface Props {
dir?: 'ltr' | 'rtl'
}
const props = defineProps<Props>()
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
:placement="props.dir === 'rtl' ? 'right' : 'left'"
class="h-[1.5em] w-[1.2em] ltr:translate-x-[80%] rtl: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 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'
const props = 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="props.uploader && items.insertImage"
:uploader="props.uploader"
:disabled="!items.insertImage.canExec"
tooltip="Insert Image"
>
<div class="i-lucide-image size-5 block" />
</ImageUploadPopover>
</div>
</template>