Toolbar
A toolbar is a row of buttons that run editor commands. Each button shows its active state (e.g. "is bold currently applied?") and is disabled when the command can't be executed. Use it whenever you want a persistent button bar at the top of the editor.
'use client'
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/react'
import { useMemo } from 'react'
import { sampleUploader } from '../../sample/sample-uploader'
import { Toolbar } from '../../ui/toolbar'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension })
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] 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>
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
export function defineExtension() {
return union(defineBasicExtension())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'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)
})
}'use client'
import { TooltipPopup, TooltipPositioner, 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
? (
<TooltipPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup className="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button''use client'
import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/react'
import { PopoverPopup, PopoverPositioner, PopoverRoot, PopoverTrigger } from 'prosekit/react/popover'
import type { OpenChangeEvent } from 'prosekit/web/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 = (event: OpenChangeEvent) => {
if (!event.detail) {
deferResetState()
}
setOpen(event.detail)
}
return (
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open} disabled={props.disabled} tooltip={props.tooltip}>
{props.children}
</Button>
</PopoverTrigger>
<PopoverPositioner placement="bottom" className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<PopoverPopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col gap-y-4 p-6 text-sm w-sm">
{file ? null : (
<>
<label htmlFor={`id-link-${ariaId}`}>Embed Link</label>
<input
id={`id-link-${ariaId}`}
className="flex h-9 rounded-md w-full bg-[canvas] 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-[canvas] 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}
</PopoverPopup>
</PopoverPositioner>
</PopoverRoot>
)
}export { default as ImageUploadPopover } from './image-upload-popover'export { default as Toolbar } from './toolbar''use client'
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" />
</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>
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { useMemo } from 'preact/hooks'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/preact'
import { sampleUploader } from '../../sample/sample-uploader'
import { Toolbar } from '../../ui/toolbar'
import { defineExtension } from './extension'
export default function Editor() {
const editor = useMemo(() => {
const extension = defineExtension()
return createEditor({ extension })
}, [])
return (
<ProseKit editor={editor}>
<div className="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] 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>
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
export function defineExtension() {
return union(defineBasicExtension())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'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 type { ComponentChild, MouseEventHandler } from 'preact'
import { TooltipPopup, TooltipPositioner, 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
? (
<TooltipPositioner className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup className="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'import type { ComponentChild, 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 { PopoverPopup, PopoverPositioner, PopoverRoot, PopoverTrigger } from 'prosekit/preact/popover'
import type { OpenChangeEvent } from 'prosekit/web/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 = (event: OpenChangeEvent) => {
if (!event.detail) {
deferResetState()
}
setOpen(event.detail)
}
return (
<PopoverRoot open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open} disabled={props.disabled} tooltip={props.tooltip}>
{props.children}
</Button>
</PopoverTrigger>
<PopoverPositioner placement="bottom" className="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<PopoverPopup className="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col gap-y-4 p-6 text-sm w-sm">
{file ? null : (
<>
<label htmlFor={`id-link-${ariaId}`}>Embed Link</label>
<input
id={`id-link-${ariaId}`}
className="flex h-9 rounded-md w-full bg-[canvas] 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-[canvas] 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}
</PopoverPopup>
</PopoverPositioner>
</PopoverRoot>
)
}export { default as ImageUploadPopover } from './image-upload-popover'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" />
</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>
)
}import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/solid'
import type { JSX } from 'solid-js'
import { sampleUploader } from '../../sample/sample-uploader'
import { Toolbar } from '../../ui/toolbar'
import { defineExtension } from './extension'
export default function Editor(): JSX.Element {
const extension = defineExtension()
const editor = createEditor({ extension })
return (
<ProseKit editor={editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] 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>
</div>
</div>
</ProseKit>
)
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
export function defineExtension() {
return union(defineBasicExtension())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor'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 { TooltipPopup, TooltipPositioner, 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
? (
<TooltipPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
)
: null}
</TooltipRoot>
)
}export { default as Button } from './button'import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/solid'
import { PopoverPopup, PopoverPositioner, PopoverRoot, PopoverTrigger } from 'prosekit/solid/popover'
import type { OpenChangeEvent } from 'prosekit/web/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 = (event: OpenChangeEvent) => {
if (!event.detail) {
deferResetState()
}
setOpen(event.detail)
}
return (
<PopoverRoot open={open()} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open()} disabled={props.disabled} tooltip={props.tooltip}>
{props.children}
</Button>
</PopoverTrigger>
<PopoverPositioner placement="bottom" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<PopoverPopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col gap-y-4 p-6 text-sm w-sm">
<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-[canvas] 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-[canvas] 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>
</PopoverPopup>
</PopoverPositioner>
</PopoverRoot>
)
}export { default as ImageUploadPopover } from './image-upload-popover'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>
)
}<script lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/svelte'
import { sampleUploader } from '../../sample/sample-uploader'
import { Toolbar } from '../../ui/toolbar'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension })
</script>
<ProseKit {editor}>
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] 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>
</div>
</div>
</ProseKit>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
export function defineExtension() {
return union(defineBasicExtension())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.svelte'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 { TooltipPopup, TooltipPositioner, 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}
<TooltipPositioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{props.tooltip}
</TooltipPopup>
</TooltipPositioner>
{/if}
</TooltipRoot>export { default as Button } from './button.svelte'<script lang="ts">
import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import { useEditor } from 'prosekit/svelte'
import { PopoverPopup, PopoverPositioner, PopoverRoot, PopoverTrigger } from 'prosekit/svelte/popover'
import type { OpenChangeEvent } from 'prosekit/web/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(event: OpenChangeEvent) {
if (!event.detail) {
deferResetState()
}
open = event.detail
}
</script>
<PopoverRoot {open} onOpenChange={handleOpenChange}>
<PopoverTrigger>
<Button pressed={open} disabled={props.disabled} tooltip={props.tooltip}>
{@render props.children?.()}
</Button>
</PopoverTrigger>
<PopoverPositioner
placement="bottom"
class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
><PopoverPopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col gap-y-4 p-6 text-sm w-sm">
{#if !file}
<label for="id-link-{ariaId}">Embed Link</label>
<input
id="id-link-{ariaId}"
class="flex h-9 rounded-md w-full bg-[canvas] 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-[canvas] 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}
</PopoverPopup></PopoverPositioner>
</PopoverRoot>export { default as ImageUploadPopover } from './image-upload-popover.svelte'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><script setup lang="ts">
import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { createEditor } from 'prosekit/core'
import { ProseKit } from 'prosekit/vue'
import { sampleUploader } from '../../sample/sample-uploader'
import { Toolbar } from '../../ui/toolbar'
import { defineExtension } from './extension'
const extension = defineExtension()
const editor = createEditor({ extension })
</script>
<template>
<ProseKit :editor="editor">
<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] 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>
</div>
</ProseKit>
</template>import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
export function defineExtension() {
return union(defineBasicExtension())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { default as ExampleEditor } from './editor.vue'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 { TooltipPopup, TooltipPositioner, 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>
<TooltipPositioner v-if="props.tooltip" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<TooltipPopup class="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
{{ props.tooltip }}
</TooltipPopup>
</TooltipPositioner>
</TooltipRoot>
</template>export { default as Button } from './button.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 { PopoverPopup, PopoverPositioner, PopoverRoot, PopoverTrigger } from 'prosekit/vue/popover'
import type { OpenChangeEvent } from 'prosekit/web/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(event: OpenChangeEvent) {
if (!event.detail) {
deferResetState()
}
open.value = event.detail
}
</script>
<template>
<PopoverRoot :open="open" @open-change="handleOpenChange">
<PopoverTrigger>
<Button :pressed="open" :disabled="props.disabled" :tooltip="props.tooltip">
<slot />
</Button>
</PopoverTrigger>
<PopoverPositioner
placement="bottom"
class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none"
><PopoverPopup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col gap-y-4 p-6 text-sm w-sm">
<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-[canvas] 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-[canvas] 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>
</PopoverPopup></PopoverPositioner>
</PopoverRoot>
</template>export { default as ImageUploadPopover } from './image-upload-popover.vue'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>import 'prosekit/basic/style.css'
import 'prosekit/basic/typography.css'
import { ContextProvider } from '@lit/context'
import { html, LitElement, type PropertyDeclaration, type PropertyValues } from 'lit'
import { createRef, ref, type Ref } from 'lit/directives/ref.js'
import type { Editor } from 'prosekit/core'
import { createEditor } from 'prosekit/core'
import { sampleUploader } from '../../sample/sample-uploader'
import { editorContext } from '../../ui/editor-context'
import { registerLitEditorToolbar } from '../../ui/toolbar'
import { defineExtension } from './extension'
export class LitEditor extends LitElement {
static override properties = {
editor: {
state: true,
attribute: false,
} satisfies PropertyDeclaration<Editor>,
}
private editor: Editor
private ref: Ref<HTMLDivElement>
constructor() {
super()
const extension = defineExtension()
this.editor = createEditor({ extension })
this.ref = createRef<HTMLDivElement>()
new ContextProvider(this, {
context: editorContext,
initialValue: this.editor,
})
}
override createRenderRoot() {
return this
}
override disconnectedCallback() {
this.editor.unmount()
super.disconnectedCallback()
}
override updated(changedProperties: PropertyValues) {
super.updated(changedProperties)
this.editor.mount(this.ref.value)
}
override render() {
return html`<div class="box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-[canvas] text-black dark:text-white">
<lit-editor-toolbar .uploader=${sampleUploader}></lit-editor-toolbar>
<div class="relative w-full flex-1 box-border overflow-y-auto">
<div ${ref(this.ref)} 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>
</div>
</div>`
}
}
export function registerLitEditor() {
registerLitEditorToolbar()
if (customElements.get('lit-editor-example-toolbar')) return
customElements.define('lit-editor-example-toolbar', LitEditor)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-example-toolbar': LitEditor
}
}import { defineBasicExtension } from 'prosekit/basic'
import { union } from 'prosekit/core'
export function defineExtension() {
return union(defineBasicExtension())
}
export type EditorExtension = ReturnType<typeof defineExtension>export { LitEditor as ExampleEditor, registerLitEditor } from './editor'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 { html, LitElement, nothing, type PropertyDeclaration } from 'lit'
import {
registerTooltipPopupElement,
registerTooltipPositionerElement,
registerTooltipRootElement,
registerTooltipTriggerElement,
} from 'prosekit/lit/tooltip'
class LitButton extends LitElement {
static override properties = {
pressed: { type: Boolean },
disabled: { type: Boolean },
tooltip: { type: String },
icon: { type: String },
} satisfies Record<string, PropertyDeclaration>
pressed = false
disabled = false
tooltip = ''
icon = ''
override createRenderRoot() {
return this
}
override connectedCallback() {
super.connectedCallback()
this.classList.add('contents')
}
private handleMouseDown = (event: MouseEvent) => {
// Prevent the editor from being blurred when the button is clicked
event.preventDefault()
}
override render() {
const tooltip = this.tooltip
return html`
<prosekit-tooltip-root>
<prosekit-tooltip-trigger class="block">
<button
data-state=${this.pressed ? 'on' : 'off'}
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"
?disabled=${this.disabled}
@mousedown=${this.handleMouseDown}
>
${this.icon ? html`<div class="${this.icon}"></div>` : nothing}
${tooltip ? html`<span class="sr-only">${tooltip}</span>` : nothing}
</button>
</prosekit-tooltip-trigger>
${tooltip
? html`
<prosekit-tooltip-positioner class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-tooltip-popup class="flex box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none duration-100 data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 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 text-nowrap">
${tooltip}
</prosekit-tooltip-popup>
</prosekit-tooltip-positioner>
`
: nothing}
</prosekit-tooltip-root>
`
}
}
export function registerLitEditorButton() {
registerTooltipPopupElement()
registerTooltipPositionerElement()
registerTooltipRootElement()
registerTooltipTriggerElement()
if (customElements.get('lit-editor-button')) return
customElements.define('lit-editor-button', LitButton)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-button': LitButton
}
}export { registerLitEditorButton } from './button'import { createContext } from '@lit/context'
import type { Editor } from 'prosekit/core'
export const editorContext = createContext<Editor | undefined>('prosekit-editor')import { html, LitElement, nothing, type PropertyDeclaration } from 'lit'
import type { Editor } from 'prosekit/core'
import type { Uploader } from 'prosekit/extensions/file'
import type { ImageExtension } from 'prosekit/extensions/image'
import {
registerPopoverPopupElement,
registerPopoverPositionerElement,
registerPopoverRootElement,
registerPopoverTriggerElement,
type OpenChangeEvent,
} from 'prosekit/lit/popover'
import { registerLitEditorButton } from '../button'
let imageUploadId = 0
class LitImageUploadPopover extends LitElement {
static override properties = {
editor: { attribute: false } satisfies PropertyDeclaration<Editor>,
uploader: { attribute: false } satisfies PropertyDeclaration<Uploader<string>>,
tooltip: { type: String },
disabled: { type: Boolean },
icon: { type: String },
}
editor?: Editor<ImageExtension>
uploader?: Uploader<string>
tooltip = ''
disabled = false
icon = ''
private open = false
private url = ''
private file: File | null = null
private ariaId = `lit-image-upload-${imageUploadId++}`
override createRenderRoot() {
return this
}
override connectedCallback() {
super.connectedCallback()
this.classList.add('contents')
}
private handleOpenChange = (event: OpenChangeEvent) => {
if (!event.detail) {
this.deferResetState()
}
this.open = event.detail
this.requestUpdate()
}
private handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const selectedFile = target.files?.[0]
if (selectedFile) {
this.file = selectedFile
this.url = ''
} else {
this.file = null
}
this.requestUpdate()
}
private handleUrlChange = (event: Event) => {
const target = event.target as HTMLInputElement
const inputUrl = target.value
if (inputUrl) {
this.url = inputUrl
this.file = null
} else {
this.url = ''
}
this.requestUpdate()
}
private deferResetState() {
setTimeout(() => {
this.url = ''
this.file = null
this.requestUpdate()
}, 300)
}
private handleSubmit = () => {
const editor = this.editor
if (!editor) return
if (this.url) {
editor.commands.insertImage({ src: this.url })
} else if (this.file && this.uploader) {
editor.commands.uploadImage({ file: this.file, uploader: this.uploader })
}
this.open = false
this.deferResetState()
this.requestUpdate()
}
override render() {
return html`
<prosekit-popover-root .open=${this.open} @open-change=${this.handleOpenChange}>
<prosekit-popover-trigger>
<lit-editor-button
.pressed=${this.open}
.disabled=${this.disabled}
.tooltip=${this.tooltip}
.icon=${this.icon}
></lit-editor-button>
</prosekit-popover-trigger>
<prosekit-popover-positioner placement="bottom" class="block overflow-visible w-min h-min z-50 ease-out transition-transform duration-100 motion-reduce:transition-none">
<prosekit-popover-popup class="box-border origin-(--transform-origin) transition-[opacity,scale] transition-discrete motion-reduce:transition-none data-[state=closed]:duration-150 data-[state=closed]:opacity-0 starting:opacity-0 data-[state=closed]:scale-95 starting:scale-95 duration-40 rounded-xl border border-gray-200 dark:border-gray-800 shadow-lg bg-[canvas] flex flex-col gap-y-4 p-6 text-sm w-sm">
${!this.file
? html`
<label for="id-link-${this.ariaId}">Embed Link</label>
<input
id="id-link-${this.ariaId}"
class="flex h-9 rounded-md w-full bg-[canvas] 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=${this.url}
@input=${this.handleUrlChange}
/>
`
: nothing}
${!this.url
? html`
<label for="id-upload-${this.ariaId}">Upload</label>
<input
id="id-upload-${this.ariaId}"
class="flex h-9 rounded-md w-full bg-[canvas] 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=${this.handleFileChange}
/>
`
: nothing}
${this.url
? html`
<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" @click=${this.handleSubmit}>
Insert Image
</button>
`
: nothing}
${this.file
? html`
<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" @click=${this.handleSubmit}>
Upload Image
</button>
`
: nothing}
</prosekit-popover-popup>
</prosekit-popover-positioner>
</prosekit-popover-root>
`
}
}
export function registerLitEditorImageUploadPopover() {
registerLitEditorButton()
registerPopoverPopupElement()
registerPopoverPositionerElement()
registerPopoverRootElement()
registerPopoverTriggerElement()
if (customElements.get('lit-editor-image-upload-popover')) return
customElements.define('lit-editor-image-upload-popover', LitImageUploadPopover)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-image-upload-popover': LitImageUploadPopover
}
}export { registerLitEditorImageUploadPopover } from './image-upload-popover'export { registerLitEditorToolbar } from './toolbar'import { ContextConsumer } from '@lit/context'
import { html, LitElement, nothing, type PropertyDeclaration, type PropertyValues } from 'lit'
import type { BasicExtension } from 'prosekit/basic'
import { defineUpdateHandler, type Editor } from 'prosekit/core'
import type { Uploader } from 'prosekit/extensions/file'
import { registerLitEditorButton } from '../button'
import { editorContext } from '../editor-context'
import { registerLitEditorImageUploadPopover } 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,
}
}
class LitToolbar extends LitElement {
static override properties = {
uploader: { attribute: false } satisfies PropertyDeclaration<Uploader<string>>,
}
uploader?: Uploader<string>
private editorConsumer = new ContextConsumer(this, {
context: editorContext,
subscribe: true,
})
private removeUpdateExtension?: VoidFunction
override createRenderRoot() {
return this
}
override connectedCallback() {
super.connectedCallback()
this.classList.add('contents')
this.attachEditorListener()
}
override disconnectedCallback() {
this.detachEditorListener()
super.disconnectedCallback()
}
override updated(changedProperties: PropertyValues) {
super.updated(changedProperties)
this.attachEditorListener()
}
private attachEditorListener() {
this.detachEditorListener()
const editor = this.editorConsumer.value
if (!editor) return
this.removeUpdateExtension = editor.use(defineUpdateHandler(() => this.requestUpdate()))
}
private detachEditorListener() {
this.removeUpdateExtension?.()
this.removeUpdateExtension = undefined
}
override render() {
const editor = this.editorConsumer.value as Editor<BasicExtension> | undefined
if (!editor) {
return nothing
}
const items = getToolbarItems(editor)
return html`
<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">
${items.undo
? html`
<lit-editor-button
.pressed=${items.undo.isActive}
.disabled=${!items.undo.canExec}
tooltip="Undo"
icon="i-lucide-undo-2 size-5 block"
@click=${items.undo.command}
></lit-editor-button>
`
: nothing}
${items.redo
? html`
<lit-editor-button
.pressed=${items.redo.isActive}
.disabled=${!items.redo.canExec}
tooltip="Redo"
icon="i-lucide-redo-2 size-5 block"
@click=${items.redo.command}
></lit-editor-button>
`
: nothing}
${items.bold
? html`
<lit-editor-button
.pressed=${items.bold.isActive}
.disabled=${!items.bold.canExec}
tooltip="Bold"
icon="i-lucide-bold size-5 block"
@click=${items.bold.command}
></lit-editor-button>
`
: nothing}
${items.italic
? html`
<lit-editor-button
.pressed=${items.italic.isActive}
.disabled=${!items.italic.canExec}
tooltip="Italic"
icon="i-lucide-italic size-5 block"
@click=${items.italic.command}
></lit-editor-button>
`
: nothing}
${items.underline
? html`
<lit-editor-button
.pressed=${items.underline.isActive}
.disabled=${!items.underline.canExec}
tooltip="Underline"
icon="i-lucide-underline size-5 block"
@click=${items.underline.command}
></lit-editor-button>
`
: nothing}
${items.strike
? html`
<lit-editor-button
.pressed=${items.strike.isActive}
.disabled=${!items.strike.canExec}
tooltip="Strike"
icon="i-lucide-strikethrough size-5 block"
@click=${items.strike.command}
></lit-editor-button>
`
: nothing}
${items.code
? html`
<lit-editor-button
.pressed=${items.code.isActive}
.disabled=${!items.code.canExec}
tooltip="Code"
icon="i-lucide-code size-5 block"
@click=${items.code.command}
></lit-editor-button>
`
: nothing}
${items.codeBlock
? html`
<lit-editor-button
.pressed=${items.codeBlock.isActive}
.disabled=${!items.codeBlock.canExec}
tooltip="Code Block"
icon="i-lucide-square-code size-5 block"
@click=${items.codeBlock.command}
></lit-editor-button>
`
: nothing}
${items.heading1
? html`
<lit-editor-button
.pressed=${items.heading1.isActive}
.disabled=${!items.heading1.canExec}
tooltip="Heading 1"
icon="i-lucide-heading-1 size-5 block"
@click=${items.heading1.command}
></lit-editor-button>
`
: nothing}
${items.heading2
? html`
<lit-editor-button
.pressed=${items.heading2.isActive}
.disabled=${!items.heading2.canExec}
tooltip="Heading 2"
icon="i-lucide-heading-2 size-5 block"
@click=${items.heading2.command}
></lit-editor-button>
`
: nothing}
${items.heading3
? html`
<lit-editor-button
.pressed=${items.heading3.isActive}
.disabled=${!items.heading3.canExec}
tooltip="Heading 3"
icon="i-lucide-heading-3 size-5 block"
@click=${items.heading3.command}
></lit-editor-button>
`
: nothing}
${items.horizontalRule
? html`
<lit-editor-button
.pressed=${items.horizontalRule.isActive}
.disabled=${!items.horizontalRule.canExec}
tooltip="Divider"
icon="i-lucide-minus size-5 block"
@click=${items.horizontalRule.command}
></lit-editor-button>
`
: nothing}
${items.blockquote
? html`
<lit-editor-button
.pressed=${items.blockquote.isActive}
.disabled=${!items.blockquote.canExec}
tooltip="Blockquote"
icon="i-lucide-text-quote size-5 block"
@click=${items.blockquote.command}
></lit-editor-button>
`
: nothing}
${items.bulletList
? html`
<lit-editor-button
.pressed=${items.bulletList.isActive}
.disabled=${!items.bulletList.canExec}
tooltip="Bullet List"
icon="i-lucide-list size-5 block"
@click=${items.bulletList.command}
></lit-editor-button>
`
: nothing}
${items.orderedList
? html`
<lit-editor-button
.pressed=${items.orderedList.isActive}
.disabled=${!items.orderedList.canExec}
tooltip="Ordered List"
icon="i-lucide-list-ordered size-5 block"
@click=${items.orderedList.command}
></lit-editor-button>
`
: nothing}
${items.taskList
? html`
<lit-editor-button
.pressed=${items.taskList.isActive}
.disabled=${!items.taskList.canExec}
tooltip="Task List"
icon="i-lucide-list-checks size-5 block"
@click=${items.taskList.command}
></lit-editor-button>
`
: nothing}
${items.toggleList
? html`
<lit-editor-button
.pressed=${items.toggleList.isActive}
.disabled=${!items.toggleList.canExec}
tooltip="Toggle List"
icon="i-lucide-list-collapse size-5 block"
@click=${items.toggleList.command}
></lit-editor-button>
`
: nothing}
${items.indentList
? html`
<lit-editor-button
.pressed=${items.indentList.isActive}
.disabled=${!items.indentList.canExec}
tooltip="Increase indentation"
icon="i-lucide-indent-increase size-5 block"
@click=${items.indentList.command}
></lit-editor-button>
`
: nothing}
${items.dedentList
? html`
<lit-editor-button
.pressed=${items.dedentList.isActive}
.disabled=${!items.dedentList.canExec}
tooltip="Decrease indentation"
icon="i-lucide-indent-decrease size-5 block"
@click=${items.dedentList.command}
></lit-editor-button>
`
: nothing}
${this.uploader && items.insertImage
? html`
<lit-editor-image-upload-popover
.editor=${editor}
.uploader=${this.uploader}
.disabled=${!items.insertImage.canExec}
tooltip="Insert Image"
icon="i-lucide-image size-5 block"
></lit-editor-image-upload-popover>
`
: nothing}
</div>
`
}
}
export function registerLitEditorToolbar() {
registerLitEditorButton()
registerLitEditorImageUploadPopover()
if (customElements.get('lit-editor-toolbar')) return
customElements.define('lit-editor-toolbar', LitToolbar)
}
declare global {
interface HTMLElementTagNameMap {
'lit-editor-toolbar': LitToolbar
}
}Install
Section titled “Install”npx shadcn@latest add @prosekit/react-ui-toolbarnpx shadcn@latest add @prosekit/preact-ui-toolbarnpx shadcn@latest add @prosekit/solid-ui-toolbarnpx shadcn@latest add @prosekit/svelte-ui-toolbarnpx shadcn@latest add @prosekit/vue-ui-toolbarCopy and paste the code from the demo above into your project.